diff --git a/.codex/framework-version-recap.md b/.codex/framework-version-recap.md index b82d3acb..b55d7e69 100644 --- a/.codex/framework-version-recap.md +++ b/.codex/framework-version-recap.md @@ -67,7 +67,7 @@ Versions were checked against the installed Composer packages, `composer.json`, - Before `1.0.0`, update the current baseline migration instead of growing a long development migration chain. - Keep entities, baseline migration, tests, docs, and class map aligned when database shape changes. -- Package/module migrations may be planned as validated package contributions, but execution should remain core-owned. +- Extension migrations may be planned as validated extension contributions, but execution should remain core-owned. ## Twig 3.27 Notes @@ -76,7 +76,7 @@ Versions were checked against the installed Composer packages, `composer.json`, - Do not call old internal Twig extension functions such as `twig_escape_filter()` directly. Use runtime APIs such as `EscaperRuntime` where lower-level escaping is genuinely needed. - Do not pass `Twig\Template` where public APIs expect `TemplateWrapper`. - Keep templates small; move behavior to Twig extensions, components, or services only when the boundary is clear. -- Use TwigBundle `paths`, `form_themes`, globals, and strict variable settings intentionally for package/theme/template work. +- Use TwigBundle `paths`, `form_themes`, globals, and strict variable settings intentionally for extension/theme/template work. ## Tailwind CSS v4 And TailwindBundle Notes diff --git a/.codex/skills/audit-pr-readiness/SKILL.md b/.codex/skills/audit-pr-readiness/SKILL.md new file mode 100644 index 00000000..b48e363b --- /dev/null +++ b/.codex/skills/audit-pr-readiness/SKILL.md @@ -0,0 +1,78 @@ +--- +name: audit-pr-readiness +description: Run the repository PR-readiness audit before marking a branch or feature slice ready for review. Use when the user explicitly asks for PR readiness, ready-for-review preparation, final pre-PR audit, filling the PR template, or checking whether a branch is ready to submit. Do not use for ordinary code review, review-finding fixes, or broad local branch review unless readiness sign-off is requested. +--- + +# Audit PR Readiness + +Use this skill to prepare or audit a branch against the repository PR-readiness expectations. This is not a substitute for code review; use `local-code-review` for a complete bug-finding pass and `fix-review-findings` for supplied review comments. + +## Required Sources + +Before auditing, read: + +- `AGENTS.md`, especially `PR Readiness Audits` +- `.github/PULL_REQUEST_TEMPLATE.md` +- `dev/WORKLOG.md` +- relevant changed docs, drafts, manuals, tests, and class-map entries + +For the two PR-template references, use these sources directly: + +- `#57`: fetch GitHub issue `aavion/studio#57` and use it as the primary source for project-rules, architecture, naming, and documentation drift expectations. +- `#109`: fetch GitHub issue `aavion/studio#109` and use it as the primary source for codebase readability, naming, hierarchy, class map, frontend structure, and test-suite clarity. + +Do not copy the PR checklist into this skill as a second source of truth. Always use `.github/PULL_REQUEST_TEMPLATE.md` as the checklist basis. + +## Workflow + +1. Determine the review base and changed scope. +2. Read the current PR template and turn each checkbox into an evidence-backed audit item. +3. Inspect the branch diff and affected runtime surfaces for each applicable item. +4. Run or confirm the required verification commands: + - `bin/phpunit` + - `bin/jstest` + - `bin/lint` + - focused commands for changed surfaces when full suites do not cover them well +5. Check documentation alignment: + - `README.md` + - `dev/draft/*.md` + - `dev/CLASSMAP.md` + - `dev/WORKLOG.md` + - `dev/manual/*.md` + - `docs/*.md` +6. Check translation and user-facing copy alignment when copy changed. +7. Check setup/init/CI, cross-platform behavior, disabled-feature fallbacks, process/env handling, and extension/API/live route boundaries when touched. +8. Capture follow-ups in `dev/WORKLOG.md`'s To-Do section when they are real but not appropriate for the current branch. +9. Fill or update the PR template with honest checkboxes. Leave unchecked anything not actually audited or verified. + +## Evidence Standard + +- A checked box needs evidence from code inspection, docs inspection, tests, linting, rendering, command output, or an explicit "not applicable" reason. +- Do not mark a checklist item complete because similar work was done earlier in the branch. +- If a command was run outside the current session by the user, record it as user-reported unless you also ran it. +- If a full suite was skipped, state why and list the focused substitute checks. + +## Output + +Return a PR-ready note based on the project template: + +```md +## Summary + +## Testing + +## Documentation + +## Additional Checks + +## Linked Issues / Discussions + +## Review Notes +``` + +Also summarize: + +- small readiness fixes made +- skipped checks or proof gaps +- follow-ups recorded in `dev/WORKLOG.md` +- whether the branch is ready, blocked, or ready with caveats diff --git a/.codex/skills/bump-version-number/SKILL.md b/.codex/skills/bump-version-number/SKILL.md new file mode 100644 index 00000000..db11ac0b --- /dev/null +++ b/.codex/skills/bump-version-number/SKILL.md @@ -0,0 +1,67 @@ +--- +name: bump-version-number +description: Bump the project or extension version in this repository. Use when the user explicitly asks to raise, bump, set, or update the version number of the root project or a specific extension/package. Do not use for dependency-only updates, release-note drafting, or general package maintenance unless a project or extension version bump is requested. +--- + +# Bump Version Number + +Use this skill when the user asks to bump the root project version or the version of a specific extension. Treat the manifest as the source of truth. + +## Version Target + +1. Identify the intended target: + - root project: the root `.manifest` + - extension: the requested extension manifest, usually under `extensions/{slug}/.manifest` or in the extension repository +2. If the user provides an explicit version, use that exact version. +3. If no explicit version is provided: + - patch version `+1` for small changes, fixes, review fixes, docs-maintenance bumps, and routine compatibility updates + - minor version `+1` for larger feature slices or contract-visible feature work + - major version `+1` only after explicit user instruction and a brief confirmation of intent +4. Preserve the existing version format and manifest style. + +## Dependency And Compatibility Checks + +After changing the version: + +1. Resolve known manifest dependencies that reference the bumped project or extension. +2. Report dependency constraint conflicts immediately instead of silently widening them. +3. Update internal dependency constraints only when the new version clearly requires it or the user requested it. +4. Prefer dynamic tests that read from manifests over static assertions pinned to a concrete version. +5. If a test would fail only because it hardcodes the old version, update that assertion to compare against the manifest value. + +## External Update Awareness + +Check for available external updates and report safely applicable candidates. Do not update external dependencies unless the user asks for dependency updates. + +Run: + +```bash +COMPOSER_MEMORY_LIMIT=256M bin/composer outdated +bin/console importmap:outdated +``` + +If either command cannot run, report the reason. + +## Verification + +Run the full suites after the version bump unless the user explicitly narrows verification: + +```bash +bin/phpunit +bin/jstest +bin/lint +``` + +If full suites are too slow, blocked, or already user-reported, say exactly what was run and what remains unverified. + +## Output + +Summarize: + +- old version and new version +- manifest path changed +- dependency conflicts or compatible dependency references found +- external update candidates from Composer/importmap checks +- static version assertions converted to manifest-derived expectations +- verification results + diff --git a/.codex/skills/fix-review-findings/SKILL.md b/.codex/skills/fix-review-findings/SKILL.md new file mode 100644 index 00000000..fb944c82 --- /dev/null +++ b/.codex/skills/fix-review-findings/SKILL.md @@ -0,0 +1,58 @@ +--- +name: fix-review-findings +description: Fix one or more concrete review findings in this repository. Use when the user provides review comments, PR findings, inline review feedback, Cloud Review findings, or local review findings and explicitly asks Codex to fix or address them. Do not use for broad branch reviews, PR-readiness audits, or ordinary feature implementation without supplied findings. +--- + +# Fix Review Findings + +Use this skill to address supplied review findings with code changes. This is a fix workflow, not a discovery review. + +## Required Sources + +Before editing, read: + +- `AGENTS.md`, especially `Review Finding Fixes` +- `dev/WORKLOG.md` +- the cited files, tests, docs, and adjacent code paths for each finding +- all user comments, maintainer replies, product decisions, policy decisions, and requested scope limits attached to or near the supplied findings + +Use `local-code-review` only when the user separately asks for a complete local branch or PR review. Use `audit-pr-readiness` only when the user asks for a PR-readiness audit. + +## Workflow + +1. Restate the findings briefly and deduplicate shared root causes. +2. Match each finding with any user or maintainer comments. Treat explicit user decisions as binding for the fix direction unless they conflict with higher-priority safety or project rules. +3. Do not implement a fix the user rejected. If a comment marks a finding as intentional, policy-driven, or out of scope, document that decision or record the requested follow-up instead of changing behavior. +4. For each root cause, restate the concrete invariant, attacker or actor input when relevant, closest control or broken control, sink or state transition, impact, and preconditions. +5. Trace the affected boundary from source to sink. Before editing, establish that the reported weakness is concretely reachable in the checked-out code; if it is already fixed or invalid, prove that instead of patching a nearby concern. +6. Inspect adjacent and analogous paths that share the same policy, validator, resolver, route family, lifecycle step, storage boundary, contribution type, cleanup/rollback behavior, wrapper, or dangerous sink. +7. Decide whether the finding is valid, invalid, already fixed, an explicit product decision, or a follow-up. +8. Reproduce, encode, or otherwise pin the issue before fixing when feasible. Prefer a focused failing test or realistic-interface reproduction; if runtime proof is disproportionate, document the static proof and gap. +9. For valid findings, place the smallest central fix that covers all affected paths and follows the user's scope comments. +10. Add or update focused regression coverage for the failing path and any adjacent path changed by the fix. Include positive coverage for legitimate behavior that must continue to work. +11. Re-check the original source/control/sink path after the fix and search nearby bypasses or equivalent call paths that might avoid the new control. +12. Update docs, worklog, class map, translations, or follow-up notes only when the fix changes the documented contract or relevant callable map. +13. Run focused verification while developing, then a broader relevant slice before finishing. +14. If the user asks for separate commits, commit each logical fix separately with an imperative message. + +## Fix Discipline + +- Do not turn a review fix into a broad redesign. +- Do not hide larger or behavior-changing follow-ups inside a narrow fix; record them in `dev/WORKLOG.md`. +- Do not report success for a partially fixed finding. State residual risk or skipped verification clearly. +- Do not revert unrelated user or collaborator changes. +- Prefer structured `Message`, `WorkflowResult`, and domain-owned message catalogues where runtime feedback is needed. +- Do not weaken authentication, authorization, tenant isolation, input validation, sandboxing, logging, auditability, or lifecycle rollback semantics to make a finding or test pass. +- Treat setup errors, missing generated files, missing dependencies, or slow validation commands as evidence to investigate with bounded effort, not as immediate proof that runtime validation is impossible. +- Do not claim the original issue is fixed until the changed code and the original vulnerable path or broken invariant have both been checked. + +## Output + +When done, summarize: + +- findings addressed and status +- commits created, if any +- verification commands and results +- intentionally deferred or rejected findings, with the reason +- how the original path was shown closed, or the exact proof gap if runtime validation was not feasible +- remaining worktree state if not clean diff --git a/.codex/skills/local-code-review/SKILL.md b/.codex/skills/local-code-review/SKILL.md new file mode 100644 index 00000000..48e633d0 --- /dev/null +++ b/.codex/skills/local-code-review/SKILL.md @@ -0,0 +1,160 @@ +--- +name: local-code-review +description: Run a complete broad local branch or pre-PR code review for this repository. Use only when the user explicitly asks for a full local branch review, complete local PR review, broad pre-PR review, exhaustive mini-model review, or Cloud-Review-like full pass that reports findings without modifying production code. Do not use for focused review comments, narrow file checks, review-finding fixes, or small "please check this" requests. +--- + +# Local Code Review + +Use this skill only for a complete local review of the current branch or a specified diff before a pull request or before another broad review round. Treat it as a reporting task: inspect, validate, and report findings; do not edit production code, tests, docs, generated files, or Git history unless the user separately asks for fixes. + +Do not use this skill for narrow review-finding fixes, focused checks of a few files, single-topic audits, or quick sanity checks. Those should follow `AGENTS.md` review rules without loading this full-pass workflow. + +## Ground Rules + +- Follow `AGENTS.md` and the project review rules. +- Report real behavioral, security, lifecycle, data integrity, runtime, compatibility, or contract risks. Avoid speculative style findings. +- Trace each suspected issue from source to sink before reporting it. +- Inspect adjacent and analogous code paths that share the same policy, lifecycle, resolver, subscriber, validator, storage boundary, route family, process helper, or contribution path. +- When a changed shared helper, guard, wrapper, route pattern, template pattern, parser, filesystem helper, query builder, or contribution registry affects sibling call sites, inspect the affected sibling instances before reporting or closing the pattern. +- Keep independently reachable instances addressable. Do not hide separate routes, sinks, operations, protected actions, or concrete implementations behind one vague bucket unless they truly share one root cause and one minimal fix. +- Prefer the smallest precise finding over broad rewrite advice. +- Do not stop after the first few findings. Complete the planned passes unless blocked by missing context or tool failure. +- If a finding overlaps a known explicit product decision, mark it as such instead of reporting it as a bug. +- If a suspected issue is invalid after tracing, discard it silently or mention it only under "Reviewed But Not Reported" when useful. +- Identify the strongest repository counterevidence for a suspected finding before reporting it, especially evidence that the path is not reachable, already guarded, intentionally out of scope, or only a low-impact correctness concern. +- Calibrate severity from realistic reachability, affected product surface, preconditions, and impact. Do not inflate ordinary correctness bugs into security findings without a concrete attack or abuse path. +- Use absolute file paths and line references in findings. +- Do not import the full Codex Security scan artifact, ledger, or app-orchestration workflow into this skill unless concrete findings justify it. + +## Scope Setup + +1. Read `AGENTS.md`, `dev/WORKLOG.md`, and relevant feature docs or drafts touched by the branch. +2. Determine the review base: + - If the user provides a base, use it. + - Otherwise use the merge base with the upstream target branch when available. + - If no upstream target is known, ask for the intended base or state the assumed base explicitly. +3. Collect the changed files and commits: + - `git diff --name-status ...HEAD` + - `git log --oneline ..HEAD` + - `git status --short` +4. Identify generated, vendored, asset snapshot, lockfile, and pure rename files. Skim them for obvious hazards, but spend review depth on behavior-bearing code and contract documentation. +5. For large diffs or context-unstable runs, keep a temporary review worklist or notes file outside production code. Every source-like changed file should end as reviewed, generated/vendor/rename-only, not applicable, deferred with reason, or intentionally skipped with an honest coverage gap. + +## Review Passes + +Run these passes over the branch diff and the affected current code. Use focused `rg`, `git diff`, and file reads. Rebuild context from the repository after every context reset or model compaction. + +### Pass 1: Entry Points And Lifecycles + +Inspect controllers, commands, API/live endpoints, operations, schedulers, event hooks, subscribers, installers, activation/deactivation flows, setup/init, and process runners. + +Look for: +- broken precondition checks, authorization gaps, or scope mismatches +- rollback paths that restore only part of a state change +- success results after failed side effects +- stale cache, stale registry, stale lock, or stale filesystem state +- async/detached work that hides failure or changes ordering assumptions +- faulty/disabled fallback paths that do not match the normal path + +### Pass 2: Persistence And Data Integrity + +Inspect Doctrine entities, repositories, DBAL access, migrations, config storage, content revisions, extension-owned data, schemas, and cleanup/purge paths. + +Look for: +- partial destructive operations without rollback, journal, or explicit best-effort contract +- stale foreign-key-like references, orphaned records, or historical revision breakage +- inconsistent identifiers, owner prefixes, namespace collisions, or slug normalization collisions +- update/install/purge flows that handle active state but miss inactive, faulty, removed, or stale states +- database portability issues across SQLite, MySQL/MariaDB, and PostgreSQL + +### Pass 3: Boundaries And Validation + +Inspect validators, file policies, manifests, contribution registries, extension/module/theme boundaries, asset/template/language policies, and public extension-facing interfaces. + +Look for: +- validator/consumer drift where validation accepts data that runtime ignores or rejects data runtime accepts +- shallow scans followed by deep recursive copy, sync, or execution +- unsafe files becoming publicly reachable +- scope gates missing on API/live/routes/assets/templates/settings/database/content contributions +- owner checks missing on contribution objects or settings +- manifest/documentation promises that are not enforced by code + +### Pass 4: Security And Privacy + +Inspect public input, request identity, session use, tokens, HMACs, secrets, browser storage, cookies, logs, audit records, subprocess environment, and user-provided files. + +Look for: +- token replay, weak binding, missing one-shot consumption, or predictable IDs +- unredacted secrets in logs, messages, docs, fixtures, or errors +- path traversal, symlink, archive extraction, or public asset exposure +- SSRF, command injection, unsafe deserialization, unsafe dynamic class loading, or unsafe PHP execution +- consent, cookie, privacy, or cross-site behavior that violates the documented boundary + +### Pass 5: Runtime Integration And Compatibility + +Inspect service wiring, DI config, environment handling, test bootstrap, init scripts, Composer/Node handling, cache warmup, translation aggregation, asset rebuild, and cross-platform path handling. + +Look for: +- dev-only assumptions in production setup +- missing optional dependency fallbacks +- platform-specific shell/path behavior +- translation catalogue drift +- generated/runtime file mismatches +- tests that pass only because of shared global state + +### Pass 6: Tests And Documentation Drift + +Inspect tests and documentation for the changed behavior. + +Look for: +- missing regression coverage for new branches, failure paths, and rollback paths +- assertions pinned to incidental counts, exact versions, or implementation ordering +- docs that promise unsupported behavior +- worklog/classmap omissions for relevant callable or contract changes +- tests that skip too broadly or hide actual failure + +## Validation Standard + +Before reporting a finding: + +1. Name the invariant or contract that should hold. +2. Show the exact path where the invariant can be broken. +3. Confirm the issue is reachable from a public, admin, operation, CLI, extension, setup, or runtime path. +4. Check whether adjacent paths already solve the same issue differently. +5. Identify source, closest control or broken control, sink or state transition, impact, preconditions, and strongest counterevidence. +6. Estimate impact and priority from actual reachability and product impact, not from the name of the bug class. +7. Prefer one finding for the shared root cause instead of many duplicates, while preserving independently reachable affected locations. + +## Output Format + +Start with findings, ordered by severity. If there are no findings, say that clearly and list residual risks or unverified areas. + +Use this shape: + +```md +## Findings + +[P1] Short imperative title +File: /absolute/path/to/file.php:123 +Issue: What is wrong. +Impact: Why it matters. +Evidence / failure scenario: How the issue is reached. +Suggested minimal fix: The smallest safe fix or contract clarification. + +## Reviewed But Not Reported +- Optional: invalidated concerns, explicit product decisions, or deferred known follow-ups. + +## Coverage +- Base reviewed: `...HEAD` +- Passes completed: entry points/lifecycle, persistence, boundaries, security/privacy, runtime, tests/docs +- Worklist closure: changed source-like files reviewed, classified, deferred, or explicitly skipped +- Commands or inspections used: concise list +- Not verified: honest gaps, tool failures, or skipped generated/vendor surfaces +``` + +Use priorities consistently: + +- `P0`: data loss, critical security, or system-wide breakage likely on normal use +- `P1`: serious reachable bug, privilege/security boundary break, or destructive lifecycle failure +- `P2`: important edge case, stale-state, rollback, contract, or compatibility bug +- `P3`: low-risk drift, misleading docs, missing focused coverage, or minor operational hazard diff --git a/.env.test b/.env.test index bd508936..b533b083 100755 --- a/.env.test +++ b/.env.test @@ -8,3 +8,11 @@ APP_SECRET='test-environment-app-secret-not-secure' ###> doctrine/doctrine-bundle ### DATABASE_URL="sqlite:///%kernel.project_dir%/var/test/test.db" ###< doctrine/doctrine-bundle ### + +###> optional mysql/mariadb integration tests ### +# Dedicated optional integration-test database. It must be empty before use and +# tests are allowed to drop every table in it before and after each run. +# Keep disabled by default. Set MYSQL_TEST_ACTIVE to 1, true, yes, or on to opt in. +MYSQL_TEST_ACTIVE=false +MYSQL_TEST_DSN="mysql://test:test@127.0.0.1:3306/studio_test?charset=utf8mb4" +###< optional mysql/mariadb integration tests ### diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfe07704 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 44d741ed..941cb03c 100755 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,9 +16,10 @@ ## Additional Checks - [ ] Security/privacy considerations, public entry points, sessions, secrets, and browser storage reviewed -- [ ] Package/module boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed +- [ ] Extension boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed - [ ] Setup/init/CI, cross-platform behavior, disabled-feature fallbacks, and process/env handling reviewed - [ ] Project-rules-, architecture-, naming- and documentation-drift reviewed (see #57 for details) +- [ ] Codebase readability, naming, hierarchy, class map, frontend structure, and test-suite clarity reviewed (see #109 for details) - [ ] Follow-up tasks captured in WORKLOG - [ ] Updated / aligned translations and user-facing copy diff --git a/.gitignore b/.gitignore index c04e216f..0481be07 100755 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,8 @@ $RECYCLE.BIN/ /build/ /dist/ ###< release artifacts ### + +###> node_modules ### +/node_modules/ +/assets/node_modules/ +###< node_modules ### diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..5a042d1f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "extensions/icon-captcha"] + path = extensions/icon-captcha + url = https://github.com/aavion/icon-captcha.git +[submodule "extensions/demo-module"] + path = extensions/demo-module + url = https://github.com/aavion/demo-module.git +[submodule "dev/extension-template"] + path = dev/extension-template + url = https://github.com/aavion/extension-template.git diff --git a/.manifest b/.manifest index 9039cbdf..27d29a26 100644 --- a/.manifest +++ b/.manifest @@ -3,10 +3,10 @@ # APP_CHANNEL defines the target branch inside the specified repository. ##> aavion/studio manifest ### -APP_VERSION=0.2.4 -APP_DATE=2026-06-14 +APP_VERSION=0.2.6 +APP_DATE=2026-06-20 APP_NAME=Studio -APP_AUTHOR=Dominik Letica +APP_AUTHOR=aavion Media APP_DESCRIPTION=Symfony 8.1 based content-management system for structured project websites. APP_LICENSE=MIT APP_HOMEPAGE=https://www.aavion.media diff --git a/AGENTS.md b/AGENTS.md index c1e7aa5f..1257ba79 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Agent Guide > **Status**: Active -> **Updated**: 2026-06-13 +> **Updated**: 2026-06-20 > **Owner**: Dominik Letica, OpenAI/Codex > **Purpose:** Provide practical, repository-specific instructions and binding project rules for coding agents working on this Symfony application. @@ -37,23 +37,24 @@ - Modularity is preferred over monolithic implementations. Split large classes, controllers, services, tests, and helpers when the extracted boundary improves responsibility, reuse, readability, or LLM context stability. - Files should stay below roughly 300 lines where practical because this size remains reliable for agent context handling, review, and patching. Treat this as a soft limit: split files when a clear responsibility boundary exists, but do not fragment a naturally cohesive file only to satisfy the line target. - Public and contributor-facing callable, interface, hook, event, command, route, payload, translation-key, and extension-point names must be clear, consistent, and easy to document. -- New or refactored package-owned technical identifiers must follow one stable owner/scope/name convention. The native core/system package owner is `system`; reserve `Studio`/`studio` for branding sourced from `.manifest`, branding assets, or deliberately retained legacy surfaces until those surfaces are intentionally migrated. -- System UI, template, and CSS names should stay branding-neutral. Use `{system|package-slug}-*` for owner-level selectors, `{system|package-slug}-frontend-*` for frontend selectors, `{system|package-slug}-backend-*` for backend selectors, and `{system|package-slug}-{provider-scope}-*` for provider selectors. Package-owned selectors must remain under the package slug and declared scope so package validation can detect collisions. -- Prefer plain domain names when a storage boundary is already owned and no package/user collision is possible, for example `visitor_id`, `message`, `audit`, or `access`. Use the `system` owner prefix only where it protects a shared namespace such as cookies, browser storage, session attributes, service tags, package identities, generated assets, or package-facing extension points. -- CLI command names should prefer Symfony-style domain namespaces such as `assets:*`, `packages:*`, or `scheduler:*` over product branding. A product prefix is acceptable only when it improves clarity, avoids a real namespace collision, or is deliberately retained until a planned pre-`1.0.0` command migration. +- Reusable technical identifiers must use `App\Core\Validation\IdentifierSpec` for shared validation shapes such as owner slugs, content slugs, dot-path identifiers, snake-case identifiers, handler keys, scheduler-style machine identifiers, database prefixes, ACL group identifiers, and canonical UUIDs. Owner slugs for extensions, generated namespaces, CSS-safe owners, API owner segments, and similar technical ownership boundaries must start with a lowercase letter, use only lowercase letters, digits, and single hyphen-separated segments, and stay at 60 characters or less. Content slugs use the same lowercase single-hyphen shape and length limit, but may start with a digit for natural public URLs such as year-based pages or error pages. Wider database columns are allowed as storage headroom, but validation must remain no wider than storage. +- New or refactored extension-owned technical identifiers must follow one stable owner/scope/name convention. The native core/system extension owner is `system`; reserve `Studio`/`studio` for branding sourced from `.manifest`, branding assets, or deliberately retained legacy surfaces until those surfaces are intentionally migrated. +- System UI, template, and CSS names should stay branding-neutral. Use `{system|extension-slug}-*` for owner-level selectors, `{system|extension-slug}-frontend-*` for frontend selectors, `{system|extension-slug}-backend-*` for backend selectors, and `{system|extension-slug}-{provider-scope}-*` for provider selectors. Extension-owned selectors must remain under the extension slug and declared scope so extension validation can detect collisions. +- Prefer plain domain names when a storage boundary is already owned and no extension/user collision is possible, for example `visitor_id`, `message`, `audit`, or `access`. Use the `system` owner prefix only where it protects a shared namespace such as cookies, browser storage, session attributes, service tags, extension identities, generated assets, or extension-facing integration points. +- CLI command names should prefer Symfony-style domain namespaces such as `assets:*`, `extensions:*`, or `scheduler:*` over product branding. A product prefix is acceptable only when it improves clarity, avoids a real namespace collision, or is deliberately retained until a planned pre-`1.0.0` command migration. - Built-in file logs should stay descriptive and environment-scoped. Prefer `var/log/{APP_ENV}/{message|audit|access}-{rotation_date}.log` over adding a product or system owner prefix to every log filename. - Runtime errors, validation failures, operational diagnostics, and user-facing feedback should use the shared `Message`, `MessageCode`, `MessageKey`, `WorkflowResult`, or `MessageException` layer wherever practical. Hard exceptions with literal text are allowed only for consciously chosen low-level invariants or unrecoverable adapter failures, and should not be used as a convenience shortcut around structured messages. -- Message code/key constants must live in domain-owned `*MessageCode` and `*MessageKey` catalogue classes close to the owning namespace. `App\Core\Message\MessageCode` and `App\Core\Message\MessageKey` are aggregation entry points, not cross-domain constant warehouses. Catalogue constant names must be namespace/scope-bound with the owning machine namespace first, for example `PACKAGE_INSTALL_*`, `SETUP_PROMPT_*`, or `ACL_GROUP_*`, and their values must stay in the matching machine namespace, for example `package.install.*`, `message.setup.prompt.*`, or `message.acl.group_*`. Future package-owned catalogues must use package-owned namespaces and must not override `system`, core, or other package namespaces; system catalogues win conflicts. -- Public hook descriptors must be registered by domain-owned `EventHookDescriptorProviderInterface` implementations close to the event owner. `PublicEventHookRegistry` aggregates providers for documentation, tooling, debug output, and package API validation; it must not become a central cross-domain list of every hook. +- Message code/key constants must live in domain-owned `*MessageCode` and `*MessageKey` catalogue classes close to the owning namespace. `App\Core\Message\MessageCode` and `App\Core\Message\MessageKey` are aggregation entry points, not cross-domain constant warehouses. Catalogue constant names must be namespace/scope-bound with the owning machine namespace first, for example `EXTENSION_INSTALL_*`, `SETUP_PROMPT_*`, or `ACL_GROUP_*`, and their values must stay in the matching machine namespace, for example `extension.install.*`, `message.setup.prompt.*`, or `message.acl.group_*`. Future extension-owned catalogues must use extension-owned namespaces and must not override `system`, core, or other extension namespaces; system catalogues win conflicts. +- Public hook descriptors must be registered by domain-owned `EventHookDescriptorProviderInterface` implementations close to the event owner. `PublicEventHookRegistry` aggregates providers for documentation, tooling, debug output, and extension API validation; it must not become a central cross-domain list of every hook. - Available languages must be discovered from translation catalogues, runtime configuration, or explicit content data. Do not hardcode language variants in runtime control flow, default API parameters, or administrative form fields. Content entities may intentionally store separate localized variants per language, but non-content domain data should use one generic label/name unless localized variants are a documented product requirement. -- Core/system cookies must be first-party and technically scoped only, such as visitor identification, session/security binding, language preference, or appearance preference. Core must not set or read cross-site advertising, profiling, or external analytics cookies. Future advertising or external statistics packages must own their own cookie strategy, consent requirements, documentation, and isolation from core technical cookies, for example through a centralized consent interface where packages register their cookie policies. +- Core/system cookies must be first-party and technically scoped only, such as visitor identification, session/security binding, language preference, or appearance preference. Core must not set or read cross-site advertising, profiling, or external analytics cookies. Future advertising or external statistics extensions must own their own cookie strategy, consent requirements, documentation, and isolation from core technical cookies, for example through a centralized consent interface where extensions register their cookie policies. - Child processes and detached runners must use the central process/environment helpers unless a direct process call is intentionally local and documented. Application subprocesses must inherit Symfony Dotenv-derived configuration and must filter web/CGI request context before execution. - Add small wrapper or helper APIs when they make public or extension-facing behavior easier to explain, safer to call, or less error-prone. - Prefer Symfony, Doctrine, Twig, Messenger, Validator, Serializer, Process, Filesystem, Security, EventDispatcher, Form, Translation, and other maintained vendor capabilities over custom infrastructure unless the custom abstraction has clear project-specific value. - Additional vendor packages are acceptable when they reduce custom maintenance, improve portability/security, or integrate cleanly with Symfony without making the project unnecessarily heavy. - Keep tests behavior-focused. Secure public behavior, cross-platform assumptions, security boundaries, and data-model guarantees without pinning fragile template, CSS, or implementation details. - Performance and data-model decisions must be justified by expected behavior and scale, including identifier strategy, indexes, pagination, filtering, sorting, caching, filesystem scans, process spawning, request/visitor identifiers, and full-table or full-tree work. -- Security and misuse resistance must be considered for public entry points, sessions, tokens, visitor/request identity, subprocesses, filesystem access, package/module boundaries, logging, audit data, secrets, and environment propagation. +- Security and misuse resistance must be considered for public entry points, sessions, tokens, visitor/request identity, subprocesses, filesystem access, extension/module boundaries, logging, audit data, secrets, and environment propagation. - Feature drafts, existing implementation choices, and early pre-`1.0.0` assumptions are guidance, not law. Prefer a simpler, safer, more Symfony-native, or more maintainable design when evidence supports changing course. ### Architecture And Drift Audits @@ -61,7 +62,7 @@ - Use audits to verify that current code and new feature work still follow the architecture and development rules above. - Challenge feature drafts, existing implementation choices, and early pre-`1.0.0` assumptions during audits instead of treating them as binding. - Review performance and data-model decisions critically, including UUIDs versus auto-increment identifiers, indexes, pagination, filtering, sorting, caching, filesystem scans, process spawning, request/visitor identifiers, and full-table or full-tree work. -- Review security and misuse resistance around public entry points, sessions, tokens, visitor/request identity, subprocesses, filesystem access, package/module boundaries, logging, audit data, secrets, and environment propagation. +- Review security and misuse resistance around public entry points, sessions, tokens, visitor/request identity, subprocesses, filesystem access, extension/module boundaries, logging, audit data, secrets, and environment propagation. - Capture audit findings with evidence, impact, recommendation, and priority. Apply small safe improvements directly; split larger refactors into dedicated follow-up issues or audit PR slices. ## Operating Principles @@ -100,11 +101,27 @@ - Translation changes must keep matching source catalogue files and keys synchronized across all locale directories under `translations/languages/`; runtime `translations/messages.*.yaml` files are generated from those sources. - Refactors before the first public `1.0.0` release may remove obsolete code instead of keeping compatibility shims, but callers, tests, docs, and class map entries must be updated immediately. - If a requested narrow change exposes unrelated drift, fix it only when it blocks the task; otherwise record the follow-up in `dev/WORKLOG.md`. -- When addressing review findings, trace adjacent and analogous code paths that share the same policy, transition, or boundary, and apply or explicitly rule out the same fix there to avoid one-path-only hardening. + +### Review Finding Fixes +- When available and applicable, prefer the project-local `fix-review-findings` Codex skill for concrete review comments, PR findings, Cloud Review findings, or local review findings. +- Before applying a fix for a review finding, trace the affected boundary from source to sink and inspect adjacent, related, and analogous code paths that share the same classifier, subscriber, guard, resolver, route family, subject selection, response behavior, storage boundary, or policy decision. +- Prefer fixing the narrowest central boundary that covers all affected paths. Apply a path-local fix only when evidence shows the issue is truly path-specific. +- Keep review fixes simple, modular, and minimally invasive. Do not broaden them into unrelated refactors, compatibility shims, or speculative redesigns. +- While tracing the affected boundary, actively look for additional unreported edge cases, including bypasses, abuse paths, privacy leaks, performance regressions, setup/pre-auth behavior, disabled-feature fallbacks, response redaction, and cache/storage failure behavior. +- Fix small in-scope adjacent issues directly when they share the same boundary and risk profile. Record larger or behavior-changing follow-ups in `dev/WORKLOG.md` instead of hiding them inside the review fix. +- Add or update regression coverage for the reported finding and any adjacent paths changed by the fix. When an analogous path is inspected and intentionally not changed, make that reasoning clear in the worklog, final notes, or PR response where useful. + +### PR Readiness Audits +- When available and applicable, prefer the project-local `audit-pr-readiness` Codex skill for PR-readiness checks, ready-for-review preparation, and PR template completion. +- Before marking a branch, pull request, or feature slice ready for review, run the PR-readiness checklist as a real audit pass over the branch diff and the affected runtime surfaces. Do not treat checklist items as passive boxes to tick. +- The audit must explicitly review security/privacy considerations; public entry points; authentication, authorization, sessions, secrets, browser storage, and response redaction; extension/module boundaries; access levels; route/API/live endpoint scopes; naming and collision risks; setup/init/CI behavior; cross-platform behavior; disabled-feature fallbacks; process and environment handling; default seed coverage for implemented config keys; translations and user-facing copy; project-rule, architecture, naming, documentation, and performance drift; and captured follow-up tasks. +- Use evidence from code inspection, focused tests, render checks, linting, documentation diffs, class map/worklog updates, and seed/default coverage as appropriate for the changed surface. If a checklist item is not applicable, record why instead of silently skipping it. +- Fix small readiness issues directly when they are in scope and low risk. Record larger, behavior-changing, or separate-domain issues in `dev/WORKLOG.md` with a clear next action. +- PR notes must summarize the readiness audit outcome, including verification commands, skipped checks or proof gaps, documentation/worklog/classmap status, translation status, security/privacy considerations, and remaining follow-ups. ## Build and Verification Commands -- `bin/init` initializes the repository, refreshes dependencies and assets, locks referenced Symfony UX icons locally when possible, and is the preferred recovery path for broken or incomplete `vendor/` packages because it removes an existing `vendor/` tree before Composer runs. -- `composer install` installs PHP dependencies and verifies required extensions. +- `bin/init` initializes the repository, synchronizes configured Git submodules, refreshes dependencies and assets, locks referenced Symfony UX icons locally when possible, and is the preferred recovery path for broken or incomplete `vendor/` packages because it removes an existing `vendor/` tree before Composer runs. +- `composer install` installs PHP dependencies and verifies required packages. - `bin/lint` runs the full project lint suite, including Markdown parse checks, local Symfony UX icon reference checks, and a Git whitespace check that excludes Markdown hard line breaks; pass one or more files or directories to run focused type-based checks, or use `bin/lint --diff`, `bin/lint --staged`, `bin/lint --diff=`, or `bin/lint --changed=` to lint supported Git changes when Git and a work tree are available. - `php -l ` checks PHP syntax for a changed file. - `php bin/console lint:container` validates Symfony container wiring after service or configuration changes. @@ -183,6 +200,9 @@ ## Review Mode - In code review, lead with findings ordered by severity and include file and line references. +- For explicit complete local branch, PR, broad pre-PR, exhaustive mini-model, or Cloud-Review-like local reviews, prefer the project-local `local-code-review` Codex skill when available and applicable. +- Review-fix implementation must follow the Review Finding Fixes rules under Change Expectations before applying code changes. +- PR-readiness sign-off must follow the PR Readiness Audits rules under Change Expectations instead of only copying checklist items. - Verify worklog, documentation, tests, class map, translations, screenshots, security notes, and PR checklist items when they are relevant to the reviewed change. - Check drift between code and feature drafts in `dev/draft/`; update it only when asked to make changes, otherwise report the drift. - Review translation coverage with `bin/lint ` when user-facing copy changed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2b4c9a..e4f20244 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,38 @@ # Changelog > **Status**: Active -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-20 > **Owner**: Dominik Letica > **Purpose:** Changelog and release tracking. -## Pending -### 2026-05-19 -- [x] Removed Symfony skeleton frontend demo code from the main asset entrypoint and deleted the example Stimulus controller. -- [x] Moved ApexCharts and CodeMirror into dedicated Stimulus controllers with lazy lifecycle handling. -- [x] Added basic documentation templates and guidelines. -### 2026-05-15 -- [x] Initialized git repository for future use +## Recent development stages +### 0.1.0 +- Core foundation + +### 0.2.0 +- Functional admin ui/ux +- Functional content foundation +- Introduced features: ExtensionManagement, UserManagement, Logs, SetupWizard, Scheduler +- Updated Symfony framework to version 8.1 +- Updated dependencies + +### 0.2.1 +- Symfony-UX integration + +### 0.2.2 +- Added GeoIP and AbuseFoundation + +### 0.2.3 +- Added ACL enforcement + +### 0.2.4 +- Added rate enforcement + +### 0.2.5 +- Added auto-ban enforcement + +### 0.2.6 +- Extended extension contracts + ## Releases -* Not released yet +### Not released yet diff --git a/README.md b/README.md index afcffd1a..9c37a11f 100755 --- a/README.md +++ b/README.md @@ -1,32 +1,40 @@ # Studio -> **Version**: 0.2.4 +> **Version**: 0.2.6 > **Status**: Active development -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-19 > **Owner**: Dominik Letica > **Purpose:** A Symfony-based CMS foundation for structured, extensible project websites. -**Note:** Studio is still in active development and is not ready for production use yet. +**Note:** Studio is still in active development and is not ready for production use. -Studio is a CMS foundation for websites that need more than a pile of static pages: structured content, thoughtful access control, package-based customization, and admin workflows that stay understandable when something goes wrong. +Studio is a CMS foundation for websites that need more than a pile of static pages: structured content, thoughtful access control, extension-based customization, and admin workflows that remain understandable when something goes wrong. -The goal is a quiet, dependable system for project websites, portfolios, documentation hubs, and small editorial sites. Content should be modelled clearly, extended through packages, and managed through tools that explain what they are about to do before they do it. +The goal is a quiet, dependable system for project websites, portfolios, documentation hubs, and small editorial sites. Content should be modelled clearly, extended through extensions, and managed through tools that explain what they are about to do before they do it. Studio is built around: - structured content with schema-driven fields; -- package-scoped themes, modules, captcha providers, and editor integrations; -- a shared lifecycle for first-party and third-party packages; -- explicit hooks, provider contracts, and replaceable services; +- extension-scoped themes, modules, captcha providers, and editor integrations; +- a shared lifecycle for first-party and third-party extensions; +- explicit hooks, provider contracts, runtime contributions, and replaceable services; - editorial workflows such as draft, publish, preview, diff, import, export, and backup; - ACL-aware content, menus, users, media, APIs, resolvers, and search; - operational admin tools with action logs and recoverable failure paths. -The project stays close to Symfony, Doctrine, Twig, AssetMapper, Tailwind, Stimulus, and PHPUnit. Core code should remain small and inspectable; packages can extend or replace behavior through documented contracts. +The project stays close to Symfony, Doctrine, Twig, AssetMapper, Tailwind, Stimulus, and PHPUnit. Core code should remain small and inspectable; extensions can extend or replace behavior through documented contracts. ## Current state -Studio currently contains the first system foundations, setup flow, package lifecycle pieces, backend/admin surfaces, user management, ACL roles and groups, runtime translations, asset rebuild tooling, action logs, and early content primitives. It is moving quickly, so expect APIs, migrations, and UI details to change before `1.0.0`. +Studio currently contains the system foundation, setup flow, extension lifecycle pieces, extension runtime contributions, backend/admin surfaces, user management, ACL roles and groups, runtime translations, frontend asset rebuild tooling, action logs, and early content primitives. + +The project is moving quickly. Before `1.0.0`, APIs, migrations, extension contracts, and UI details may still change when a simpler or safer design becomes clear. + +## Extension model + +Extensions live under `extensions/{extension-slug}/` and are described by a `.manifest` file. Active extensions may provide templates, assets, translations, settings, providers, hooks, scheduled tasks, and other documented runtime contributions through `extension.php`. + +The extension boundary matters: an extension should be easy to inspect, activate, deactivate, update, and remove. Extension-owned names should stay under the extension slug so themes, modules, providers, and future third-party integrations do not collide with core or with each other. Feature drafts live in [dev/draft](dev/draft/README.md), developer notes start in [dev/manual](dev/manual/README.md), and active work is tracked in [dev/WORKLOG.md](dev/WORKLOG.md). @@ -39,4 +47,11 @@ Before making changes, read: - [Developer manual](dev/manual/README.md) - [Worklog](dev/WORKLOG.md) -Start with `bin/init` after a clean checkout. Use focused checks while developing and run broader verification before opening a pull request. +Start with `bin/init` after a clean checkout. Use focused checks while developing and run broader verification before opening a pull request: + +```bash +bin/lint +php bin/phpunit +``` + +For smaller changes, prefer the focused form first, for example `bin/lint README.md` or a targeted PHPUnit test. If a check cannot run in your environment, note that clearly in the worklog or pull request notes. diff --git a/SECURITY.md b/SECURITY.md index 825202c3..3e71d031 100755 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,21 @@ # Security Policy > **Status**: Active -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-16 > **Owner**: Dominik Letica > **Purpose:** Support and reporting policy. ## Supported Versions -### main-Channel -Stable builds (`main`-branch or stable releases, where `main` always reflects the latest stable release) are supported at least until the next version is publicly available. +### stable-Channel +Stable builds (`stable-*`-branch or tagless releases) are supported at least until the next version is publicly available. Please consider to keep your environment always up to date for security patches to apply. If you're facing any problems, please feel free to [report](https://github.com/aavion/studio/issues) them to get assistance. **Note:** Versions prior to the first major release are considered `dev` (see below). | Version | Supported | | ------- | ------------------ | -| < 1.0.0 | :x: (see `dev`) | +| < 1.0.0 | :x: | ### beta-Channel Beta builds (`beta-*` branch or releases with `beta`-tag) receive limited support via GitHub Issues at least until the next version is publicly available. diff --git a/assets/app.js b/assets/app.js index 55971566..2ea49b84 100755 --- a/assets/app.js +++ b/assets/app.js @@ -7,9 +7,9 @@ import './js/backend/index.js'; import './js/backend/admin/index.js'; import './js/backend/editor/index.js'; import './js/backend/setup/index.js'; -import './js/packages/extension.js'; -import './js/packages/frontend-theme.js'; -import './js/packages/backend-theme.js'; +import './js/extensions/extension.js'; +import './js/extensions/frontend-theme.js'; +import './js/extensions/backend-theme.js'; registerReactControllerComponents(); registerVueControllerComponents(); diff --git a/assets/js/packages/.gitignore b/assets/extensions/.gitignore similarity index 100% rename from assets/js/packages/.gitignore rename to assets/extensions/.gitignore diff --git a/assets/extensions/README.md b/assets/extensions/README.md new file mode 100644 index 00000000..4781fcb6 --- /dev/null +++ b/assets/extensions/README.md @@ -0,0 +1,5 @@ +# Extension assets + +This directory is managed by the extension lifecycle. + +Only assets from active extensions are mirrored here so AssetMapper can serve them from the normal `assets/` tree. Source extension directories under `extensions/` are not exposed wholesale. diff --git a/assets/fonts/noto/notocoloremoji-regular.ttf b/assets/fonts/noto/notocoloremoji-regular.ttf new file mode 100644 index 00000000..b6520151 Binary files /dev/null and b/assets/fonts/noto/notocoloremoji-regular.ttf differ diff --git a/assets/fonts/noto/notosans-italic-variable.ttf b/assets/fonts/noto/notosans-italic-variable.ttf new file mode 100644 index 00000000..6245ba01 Binary files /dev/null and b/assets/fonts/noto/notosans-italic-variable.ttf differ diff --git a/assets/fonts/noto/notosans-variable.ttf b/assets/fonts/noto/notosans-variable.ttf new file mode 100644 index 00000000..9530d84d Binary files /dev/null and b/assets/fonts/noto/notosans-variable.ttf differ diff --git a/assets/fonts/tabler-icons/tabler-icons.woff b/assets/fonts/tabler-icons/tabler-icons.woff index f8dd9852..c97bf607 100644 Binary files a/assets/fonts/tabler-icons/tabler-icons.woff and b/assets/fonts/tabler-icons/tabler-icons.woff differ diff --git a/assets/fonts/tabler-icons/tabler-icons.woff2 b/assets/fonts/tabler-icons/tabler-icons.woff2 index 6eee5dec..d2524b5a 100644 Binary files a/assets/fonts/tabler-icons/tabler-icons.woff2 and b/assets/fonts/tabler-icons/tabler-icons.woff2 differ diff --git a/assets/packages/.gitignore b/assets/js/extensions/.gitignore similarity index 100% rename from assets/packages/.gitignore rename to assets/js/extensions/.gitignore diff --git a/assets/js/extensions/README.md b/assets/js/extensions/README.md new file mode 100644 index 00000000..6ea34fee --- /dev/null +++ b/assets/js/extensions/README.md @@ -0,0 +1,5 @@ +# Extension asset imports + +This directory is managed by the extension lifecycle. + +Only assets from mirrored active extensions are included here so AssetMapper can serve them from the normal `assets/` tree. Source extension directories under `extensions/` are not exposed wholesale. diff --git a/assets/js/packages/README.md b/assets/js/packages/README.md deleted file mode 100644 index f0ad7efb..00000000 --- a/assets/js/packages/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Package asset imports - -This directory is managed by the package lifecycle. - -Only assets from mirrored active packages are included here so AssetMapper can serve them from the normal `assets/` tree. Source package directories under `packages/` are not exposed wholesale. diff --git a/assets/packages/README.md b/assets/packages/README.md deleted file mode 100644 index f9737c2c..00000000 --- a/assets/packages/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Package assets - -This directory is managed by the package lifecycle. - -Only assets from active packages are mirrored here so AssetMapper can serve them from the normal `assets/` tree. Source package directories under `packages/` are not exposed wholesale. diff --git a/assets/styles/app.css b/assets/styles/app.css index 3fc89f70..d49fdc75 100755 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "./fonts.css"; @import "./icons.css"; +@import "./emoji.css"; @import "./illustrations.css"; @import "./codemirror.css"; @import "./tokens/base.css"; @@ -11,8 +12,8 @@ @import "./backend/admin/base.css"; @import "./backend/editor/base.css"; @import "./backend/setup/base.css"; -@import "./packages/extension.css"; -@import "./packages/frontend-theme.css"; -@import "./packages/backend-theme.css"; +@import "./extensions/extension.css"; +@import "./extensions/frontend-theme.css"; +@import "./extensions/backend-theme.css"; @custom-variant dark (&:where(.dark, .dark *)); diff --git a/assets/styles/backend/admin/base.css b/assets/styles/backend/admin/base.css index e640e80a..06109879 100644 --- a/assets/styles/backend/admin/base.css +++ b/assets/styles/backend/admin/base.css @@ -99,7 +99,7 @@ pointer-events: none; } -.system-backend-package-hero { +.system-backend-extension-hero { position: relative; overflow: hidden; max-height: 18rem; @@ -109,7 +109,7 @@ background: var(--color-gray-100); } -.system-backend-package-hero::after { +.system-backend-extension-hero::after { position: absolute; inset: auto 0 0; height: 45%; @@ -118,7 +118,7 @@ pointer-events: none; } -.system-backend-package-hero img { +.system-backend-extension-hero img { display: block; width: 100%; max-height: 18rem; diff --git a/assets/styles/emoji.css b/assets/styles/emoji.css new file mode 100644 index 00000000..c56b266b --- /dev/null +++ b/assets/styles/emoji.css @@ -0,0 +1,15824 @@ +/* Auto-generated helper classes for Noto Color Emoji. */ +/* Source: assets/fonts/noto/notocoloremoji-regular.ttf + Unicode emoji-test v17.0. */ + +.emoji { + font-family: "Noto Color Emoji" !important; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; +} + +.emoji-grinning-face:before { + content: "\01f600"; +} + +.emoji-grinning-face-with-big-eyes:before { + content: "\01f603"; +} + +.emoji-grinning-face-with-smiling-eyes:before { + content: "\01f604"; +} + +.emoji-beaming-face-with-smiling-eyes:before { + content: "\01f601"; +} + +.emoji-grinning-squinting-face:before { + content: "\01f606"; +} + +.emoji-grinning-face-with-sweat:before { + content: "\01f605"; +} + +.emoji-rolling-on-the-floor-laughing:before { + content: "\01f923"; +} + +.emoji-face-with-tears-of-joy:before { + content: "\01f602"; +} + +.emoji-slightly-smiling-face:before { + content: "\01f642"; +} + +.emoji-upside-down-face:before { + content: "\01f643"; +} + +.emoji-melting-face:before { + content: "\01fae0"; +} + +.emoji-winking-face:before { + content: "\01f609"; +} + +.emoji-smiling-face-with-smiling-eyes:before { + content: "\01f60a"; +} + +.emoji-smiling-face-with-halo:before { + content: "\01f607"; +} + +.emoji-smiling-face-with-hearts:before { + content: "\01f970"; +} + +.emoji-smiling-face-with-heart-eyes:before { + content: "\01f60d"; +} + +.emoji-star-struck:before { + content: "\01f929"; +} + +.emoji-face-blowing-a-kiss:before { + content: "\01f618"; +} + +.emoji-kissing-face:before { + content: "\01f617"; +} + +.emoji-smiling-face:before { + content: "\00263a\00fe0f"; +} + +.emoji-kissing-face-with-closed-eyes:before { + content: "\01f61a"; +} + +.emoji-kissing-face-with-smiling-eyes:before { + content: "\01f619"; +} + +.emoji-smiling-face-with-tear:before { + content: "\01f972"; +} + +.emoji-face-savoring-food:before { + content: "\01f60b"; +} + +.emoji-face-with-tongue:before { + content: "\01f61b"; +} + +.emoji-winking-face-with-tongue:before { + content: "\01f61c"; +} + +.emoji-zany-face:before { + content: "\01f92a"; +} + +.emoji-squinting-face-with-tongue:before { + content: "\01f61d"; +} + +.emoji-money-mouth-face:before { + content: "\01f911"; +} + +.emoji-smiling-face-with-open-hands:before { + content: "\01f917"; +} + +.emoji-face-with-hand-over-mouth:before { + content: "\01f92d"; +} + +.emoji-face-with-open-eyes-and-hand-over-mouth:before { + content: "\01fae2"; +} + +.emoji-face-with-peeking-eye:before { + content: "\01fae3"; +} + +.emoji-shushing-face:before { + content: "\01f92b"; +} + +.emoji-thinking-face:before { + content: "\01f914"; +} + +.emoji-saluting-face:before { + content: "\01fae1"; +} + +.emoji-zipper-mouth-face:before { + content: "\01f910"; +} + +.emoji-face-with-raised-eyebrow:before { + content: "\01f928"; +} + +.emoji-neutral-face:before { + content: "\01f610"; +} + +.emoji-expressionless-face:before { + content: "\01f611"; +} + +.emoji-face-without-mouth:before { + content: "\01f636"; +} + +.emoji-dotted-line-face:before { + content: "\01fae5"; +} + +.emoji-face-in-clouds:before { + content: "\01f636\00200d\01f32b\00fe0f"; +} + +.emoji-smirking-face:before { + content: "\01f60f"; +} + +.emoji-unamused-face:before { + content: "\01f612"; +} + +.emoji-face-with-rolling-eyes:before { + content: "\01f644"; +} + +.emoji-grimacing-face:before { + content: "\01f62c"; +} + +.emoji-face-exhaling:before { + content: "\01f62e\00200d\01f4a8"; +} + +.emoji-lying-face:before { + content: "\01f925"; +} + +.emoji-shaking-face:before { + content: "\01fae8"; +} + +.emoji-head-shaking-horizontally:before { + content: "\01f642\00200d\002194\00fe0f"; +} + +.emoji-head-shaking-vertically:before { + content: "\01f642\00200d\002195\00fe0f"; +} + +.emoji-relieved-face:before { + content: "\01f60c"; +} + +.emoji-pensive-face:before { + content: "\01f614"; +} + +.emoji-sleepy-face:before { + content: "\01f62a"; +} + +.emoji-drooling-face:before { + content: "\01f924"; +} + +.emoji-sleeping-face:before { + content: "\01f634"; +} + +.emoji-face-with-bags-under-eyes:before { + content: "\01fae9"; +} + +.emoji-face-with-medical-mask:before { + content: "\01f637"; +} + +.emoji-face-with-thermometer:before { + content: "\01f912"; +} + +.emoji-face-with-head-bandage:before { + content: "\01f915"; +} + +.emoji-nauseated-face:before { + content: "\01f922"; +} + +.emoji-face-vomiting:before { + content: "\01f92e"; +} + +.emoji-sneezing-face:before { + content: "\01f927"; +} + +.emoji-hot-face:before { + content: "\01f975"; +} + +.emoji-cold-face:before { + content: "\01f976"; +} + +.emoji-woozy-face:before { + content: "\01f974"; +} + +.emoji-face-with-crossed-out-eyes:before { + content: "\01f635"; +} + +.emoji-face-with-spiral-eyes:before { + content: "\01f635\00200d\01f4ab"; +} + +.emoji-exploding-head:before { + content: "\01f92f"; +} + +.emoji-cowboy-hat-face:before { + content: "\01f920"; +} + +.emoji-partying-face:before { + content: "\01f973"; +} + +.emoji-disguised-face:before { + content: "\01f978"; +} + +.emoji-smiling-face-with-sunglasses:before { + content: "\01f60e"; +} + +.emoji-nerd-face:before { + content: "\01f913"; +} + +.emoji-face-with-monocle:before { + content: "\01f9d0"; +} + +.emoji-confused-face:before { + content: "\01f615"; +} + +.emoji-face-with-diagonal-mouth:before { + content: "\01fae4"; +} + +.emoji-worried-face:before { + content: "\01f61f"; +} + +.emoji-slightly-frowning-face:before { + content: "\01f641"; +} + +.emoji-frowning-face:before { + content: "\002639\00fe0f"; +} + +.emoji-face-with-open-mouth:before { + content: "\01f62e"; +} + +.emoji-hushed-face:before { + content: "\01f62f"; +} + +.emoji-astonished-face:before { + content: "\01f632"; +} + +.emoji-flushed-face:before { + content: "\01f633"; +} + +.emoji-distorted-face:before { + content: "\01faea"; +} + +.emoji-pleading-face:before { + content: "\01f97a"; +} + +.emoji-face-holding-back-tears:before { + content: "\01f979"; +} + +.emoji-frowning-face-with-open-mouth:before { + content: "\01f626"; +} + +.emoji-anguished-face:before { + content: "\01f627"; +} + +.emoji-fearful-face:before { + content: "\01f628"; +} + +.emoji-anxious-face-with-sweat:before { + content: "\01f630"; +} + +.emoji-sad-but-relieved-face:before { + content: "\01f625"; +} + +.emoji-crying-face:before { + content: "\01f622"; +} + +.emoji-loudly-crying-face:before { + content: "\01f62d"; +} + +.emoji-face-screaming-in-fear:before { + content: "\01f631"; +} + +.emoji-confounded-face:before { + content: "\01f616"; +} + +.emoji-persevering-face:before { + content: "\01f623"; +} + +.emoji-disappointed-face:before { + content: "\01f61e"; +} + +.emoji-downcast-face-with-sweat:before { + content: "\01f613"; +} + +.emoji-weary-face:before { + content: "\01f629"; +} + +.emoji-tired-face:before { + content: "\01f62b"; +} + +.emoji-yawning-face:before { + content: "\01f971"; +} + +.emoji-face-with-steam-from-nose:before { + content: "\01f624"; +} + +.emoji-enraged-face:before { + content: "\01f621"; +} + +.emoji-angry-face:before { + content: "\01f620"; +} + +.emoji-face-with-symbols-on-mouth:before { + content: "\01f92c"; +} + +.emoji-smiling-face-with-horns:before { + content: "\01f608"; +} + +.emoji-angry-face-with-horns:before { + content: "\01f47f"; +} + +.emoji-skull:before { + content: "\01f480"; +} + +.emoji-skull-and-crossbones:before { + content: "\002620\00fe0f"; +} + +.emoji-pile-of-poo:before { + content: "\01f4a9"; +} + +.emoji-clown-face:before { + content: "\01f921"; +} + +.emoji-ogre:before { + content: "\01f479"; +} + +.emoji-goblin:before { + content: "\01f47a"; +} + +.emoji-ghost:before { + content: "\01f47b"; +} + +.emoji-alien:before { + content: "\01f47d"; +} + +.emoji-alien-monster:before { + content: "\01f47e"; +} + +.emoji-robot:before { + content: "\01f916"; +} + +.emoji-grinning-cat:before { + content: "\01f63a"; +} + +.emoji-grinning-cat-with-smiling-eyes:before { + content: "\01f638"; +} + +.emoji-cat-with-tears-of-joy:before { + content: "\01f639"; +} + +.emoji-smiling-cat-with-heart-eyes:before { + content: "\01f63b"; +} + +.emoji-cat-with-wry-smile:before { + content: "\01f63c"; +} + +.emoji-kissing-cat:before { + content: "\01f63d"; +} + +.emoji-weary-cat:before { + content: "\01f640"; +} + +.emoji-crying-cat:before { + content: "\01f63f"; +} + +.emoji-pouting-cat:before { + content: "\01f63e"; +} + +.emoji-see-no-evil-monkey:before { + content: "\01f648"; +} + +.emoji-hear-no-evil-monkey:before { + content: "\01f649"; +} + +.emoji-speak-no-evil-monkey:before { + content: "\01f64a"; +} + +.emoji-love-letter:before { + content: "\01f48c"; +} + +.emoji-heart-with-arrow:before { + content: "\01f498"; +} + +.emoji-heart-with-ribbon:before { + content: "\01f49d"; +} + +.emoji-sparkling-heart:before { + content: "\01f496"; +} + +.emoji-growing-heart:before { + content: "\01f497"; +} + +.emoji-beating-heart:before { + content: "\01f493"; +} + +.emoji-revolving-hearts:before { + content: "\01f49e"; +} + +.emoji-two-hearts:before { + content: "\01f495"; +} + +.emoji-heart-decoration:before { + content: "\01f49f"; +} + +.emoji-heart-exclamation:before { + content: "\002763\00fe0f"; +} + +.emoji-broken-heart:before { + content: "\01f494"; +} + +.emoji-heart-on-fire:before { + content: "\002764\00fe0f\00200d\01f525"; +} + +.emoji-mending-heart:before { + content: "\002764\00fe0f\00200d\01fa79"; +} + +.emoji-red-heart:before { + content: "\002764\00fe0f"; +} + +.emoji-pink-heart:before { + content: "\01fa77"; +} + +.emoji-orange-heart:before { + content: "\01f9e1"; +} + +.emoji-yellow-heart:before { + content: "\01f49b"; +} + +.emoji-green-heart:before { + content: "\01f49a"; +} + +.emoji-blue-heart:before { + content: "\01f499"; +} + +.emoji-light-blue-heart:before { + content: "\01fa75"; +} + +.emoji-purple-heart:before { + content: "\01f49c"; +} + +.emoji-brown-heart:before { + content: "\01f90e"; +} + +.emoji-black-heart:before { + content: "\01f5a4"; +} + +.emoji-grey-heart:before { + content: "\01fa76"; +} + +.emoji-white-heart:before { + content: "\01f90d"; +} + +.emoji-kiss-mark:before { + content: "\01f48b"; +} + +.emoji-hundred-points:before { + content: "\01f4af"; +} + +.emoji-anger-symbol:before { + content: "\01f4a2"; +} + +.emoji-fight-cloud:before { + content: "\01faef"; +} + +.emoji-collision:before { + content: "\01f4a5"; +} + +.emoji-dizzy:before { + content: "\01f4ab"; +} + +.emoji-sweat-droplets:before { + content: "\01f4a6"; +} + +.emoji-dashing-away:before { + content: "\01f4a8"; +} + +.emoji-hole:before { + content: "\01f573\00fe0f"; +} + +.emoji-speech-balloon:before { + content: "\01f4ac"; +} + +.emoji-eye-in-speech-bubble:before { + content: "\01f441\00fe0f\00200d\01f5e8\00fe0f"; +} + +.emoji-left-speech-bubble:before { + content: "\01f5e8\00fe0f"; +} + +.emoji-right-anger-bubble:before { + content: "\01f5ef\00fe0f"; +} + +.emoji-thought-balloon:before { + content: "\01f4ad"; +} + +.emoji-zzz:before { + content: "\01f4a4"; +} + +.emoji-waving-hand:before { + content: "\01f44b"; +} + +.emoji-waving-hand-light-skin-tone:before { + content: "\01f44b\01f3fb"; +} + +.emoji-waving-hand-medium-light-skin-tone:before { + content: "\01f44b\01f3fc"; +} + +.emoji-waving-hand-medium-skin-tone:before { + content: "\01f44b\01f3fd"; +} + +.emoji-waving-hand-medium-dark-skin-tone:before { + content: "\01f44b\01f3fe"; +} + +.emoji-waving-hand-dark-skin-tone:before { + content: "\01f44b\01f3ff"; +} + +.emoji-raised-back-of-hand:before { + content: "\01f91a"; +} + +.emoji-raised-back-of-hand-light-skin-tone:before { + content: "\01f91a\01f3fb"; +} + +.emoji-raised-back-of-hand-medium-light-skin-tone:before { + content: "\01f91a\01f3fc"; +} + +.emoji-raised-back-of-hand-medium-skin-tone:before { + content: "\01f91a\01f3fd"; +} + +.emoji-raised-back-of-hand-medium-dark-skin-tone:before { + content: "\01f91a\01f3fe"; +} + +.emoji-raised-back-of-hand-dark-skin-tone:before { + content: "\01f91a\01f3ff"; +} + +.emoji-hand-with-fingers-splayed:before { + content: "\01f590\00fe0f"; +} + +.emoji-hand-with-fingers-splayed-light-skin-tone:before { + content: "\01f590\01f3fb"; +} + +.emoji-hand-with-fingers-splayed-medium-light-skin-tone:before { + content: "\01f590\01f3fc"; +} + +.emoji-hand-with-fingers-splayed-medium-skin-tone:before { + content: "\01f590\01f3fd"; +} + +.emoji-hand-with-fingers-splayed-medium-dark-skin-tone:before { + content: "\01f590\01f3fe"; +} + +.emoji-hand-with-fingers-splayed-dark-skin-tone:before { + content: "\01f590\01f3ff"; +} + +.emoji-raised-hand:before { + content: "\00270b"; +} + +.emoji-raised-hand-light-skin-tone:before { + content: "\00270b\01f3fb"; +} + +.emoji-raised-hand-medium-light-skin-tone:before { + content: "\00270b\01f3fc"; +} + +.emoji-raised-hand-medium-skin-tone:before { + content: "\00270b\01f3fd"; +} + +.emoji-raised-hand-medium-dark-skin-tone:before { + content: "\00270b\01f3fe"; +} + +.emoji-raised-hand-dark-skin-tone:before { + content: "\00270b\01f3ff"; +} + +.emoji-vulcan-salute:before { + content: "\01f596"; +} + +.emoji-vulcan-salute-light-skin-tone:before { + content: "\01f596\01f3fb"; +} + +.emoji-vulcan-salute-medium-light-skin-tone:before { + content: "\01f596\01f3fc"; +} + +.emoji-vulcan-salute-medium-skin-tone:before { + content: "\01f596\01f3fd"; +} + +.emoji-vulcan-salute-medium-dark-skin-tone:before { + content: "\01f596\01f3fe"; +} + +.emoji-vulcan-salute-dark-skin-tone:before { + content: "\01f596\01f3ff"; +} + +.emoji-rightwards-hand:before { + content: "\01faf1"; +} + +.emoji-rightwards-hand-light-skin-tone:before { + content: "\01faf1\01f3fb"; +} + +.emoji-rightwards-hand-medium-light-skin-tone:before { + content: "\01faf1\01f3fc"; +} + +.emoji-rightwards-hand-medium-skin-tone:before { + content: "\01faf1\01f3fd"; +} + +.emoji-rightwards-hand-medium-dark-skin-tone:before { + content: "\01faf1\01f3fe"; +} + +.emoji-rightwards-hand-dark-skin-tone:before { + content: "\01faf1\01f3ff"; +} + +.emoji-leftwards-hand:before { + content: "\01faf2"; +} + +.emoji-leftwards-hand-light-skin-tone:before { + content: "\01faf2\01f3fb"; +} + +.emoji-leftwards-hand-medium-light-skin-tone:before { + content: "\01faf2\01f3fc"; +} + +.emoji-leftwards-hand-medium-skin-tone:before { + content: "\01faf2\01f3fd"; +} + +.emoji-leftwards-hand-medium-dark-skin-tone:before { + content: "\01faf2\01f3fe"; +} + +.emoji-leftwards-hand-dark-skin-tone:before { + content: "\01faf2\01f3ff"; +} + +.emoji-palm-down-hand:before { + content: "\01faf3"; +} + +.emoji-palm-down-hand-light-skin-tone:before { + content: "\01faf3\01f3fb"; +} + +.emoji-palm-down-hand-medium-light-skin-tone:before { + content: "\01faf3\01f3fc"; +} + +.emoji-palm-down-hand-medium-skin-tone:before { + content: "\01faf3\01f3fd"; +} + +.emoji-palm-down-hand-medium-dark-skin-tone:before { + content: "\01faf3\01f3fe"; +} + +.emoji-palm-down-hand-dark-skin-tone:before { + content: "\01faf3\01f3ff"; +} + +.emoji-palm-up-hand:before { + content: "\01faf4"; +} + +.emoji-palm-up-hand-light-skin-tone:before { + content: "\01faf4\01f3fb"; +} + +.emoji-palm-up-hand-medium-light-skin-tone:before { + content: "\01faf4\01f3fc"; +} + +.emoji-palm-up-hand-medium-skin-tone:before { + content: "\01faf4\01f3fd"; +} + +.emoji-palm-up-hand-medium-dark-skin-tone:before { + content: "\01faf4\01f3fe"; +} + +.emoji-palm-up-hand-dark-skin-tone:before { + content: "\01faf4\01f3ff"; +} + +.emoji-leftwards-pushing-hand:before { + content: "\01faf7"; +} + +.emoji-leftwards-pushing-hand-light-skin-tone:before { + content: "\01faf7\01f3fb"; +} + +.emoji-leftwards-pushing-hand-medium-light-skin-tone:before { + content: "\01faf7\01f3fc"; +} + +.emoji-leftwards-pushing-hand-medium-skin-tone:before { + content: "\01faf7\01f3fd"; +} + +.emoji-leftwards-pushing-hand-medium-dark-skin-tone:before { + content: "\01faf7\01f3fe"; +} + +.emoji-leftwards-pushing-hand-dark-skin-tone:before { + content: "\01faf7\01f3ff"; +} + +.emoji-rightwards-pushing-hand:before { + content: "\01faf8"; +} + +.emoji-rightwards-pushing-hand-light-skin-tone:before { + content: "\01faf8\01f3fb"; +} + +.emoji-rightwards-pushing-hand-medium-light-skin-tone:before { + content: "\01faf8\01f3fc"; +} + +.emoji-rightwards-pushing-hand-medium-skin-tone:before { + content: "\01faf8\01f3fd"; +} + +.emoji-rightwards-pushing-hand-medium-dark-skin-tone:before { + content: "\01faf8\01f3fe"; +} + +.emoji-rightwards-pushing-hand-dark-skin-tone:before { + content: "\01faf8\01f3ff"; +} + +.emoji-ok-hand:before { + content: "\01f44c"; +} + +.emoji-ok-hand-light-skin-tone:before { + content: "\01f44c\01f3fb"; +} + +.emoji-ok-hand-medium-light-skin-tone:before { + content: "\01f44c\01f3fc"; +} + +.emoji-ok-hand-medium-skin-tone:before { + content: "\01f44c\01f3fd"; +} + +.emoji-ok-hand-medium-dark-skin-tone:before { + content: "\01f44c\01f3fe"; +} + +.emoji-ok-hand-dark-skin-tone:before { + content: "\01f44c\01f3ff"; +} + +.emoji-pinched-fingers:before { + content: "\01f90c"; +} + +.emoji-pinched-fingers-light-skin-tone:before { + content: "\01f90c\01f3fb"; +} + +.emoji-pinched-fingers-medium-light-skin-tone:before { + content: "\01f90c\01f3fc"; +} + +.emoji-pinched-fingers-medium-skin-tone:before { + content: "\01f90c\01f3fd"; +} + +.emoji-pinched-fingers-medium-dark-skin-tone:before { + content: "\01f90c\01f3fe"; +} + +.emoji-pinched-fingers-dark-skin-tone:before { + content: "\01f90c\01f3ff"; +} + +.emoji-pinching-hand:before { + content: "\01f90f"; +} + +.emoji-pinching-hand-light-skin-tone:before { + content: "\01f90f\01f3fb"; +} + +.emoji-pinching-hand-medium-light-skin-tone:before { + content: "\01f90f\01f3fc"; +} + +.emoji-pinching-hand-medium-skin-tone:before { + content: "\01f90f\01f3fd"; +} + +.emoji-pinching-hand-medium-dark-skin-tone:before { + content: "\01f90f\01f3fe"; +} + +.emoji-pinching-hand-dark-skin-tone:before { + content: "\01f90f\01f3ff"; +} + +.emoji-victory-hand:before { + content: "\00270c\00fe0f"; +} + +.emoji-victory-hand-light-skin-tone:before { + content: "\00270c\01f3fb"; +} + +.emoji-victory-hand-medium-light-skin-tone:before { + content: "\00270c\01f3fc"; +} + +.emoji-victory-hand-medium-skin-tone:before { + content: "\00270c\01f3fd"; +} + +.emoji-victory-hand-medium-dark-skin-tone:before { + content: "\00270c\01f3fe"; +} + +.emoji-victory-hand-dark-skin-tone:before { + content: "\00270c\01f3ff"; +} + +.emoji-crossed-fingers:before { + content: "\01f91e"; +} + +.emoji-crossed-fingers-light-skin-tone:before { + content: "\01f91e\01f3fb"; +} + +.emoji-crossed-fingers-medium-light-skin-tone:before { + content: "\01f91e\01f3fc"; +} + +.emoji-crossed-fingers-medium-skin-tone:before { + content: "\01f91e\01f3fd"; +} + +.emoji-crossed-fingers-medium-dark-skin-tone:before { + content: "\01f91e\01f3fe"; +} + +.emoji-crossed-fingers-dark-skin-tone:before { + content: "\01f91e\01f3ff"; +} + +.emoji-hand-with-index-finger-and-thumb-crossed:before { + content: "\01faf0"; +} + +.emoji-hand-with-index-finger-and-thumb-crossed-light-skin-tone:before { + content: "\01faf0\01f3fb"; +} + +.emoji-hand-with-index-finger-and-thumb-crossed-medium-light-skin-tone:before { + content: "\01faf0\01f3fc"; +} + +.emoji-hand-with-index-finger-and-thumb-crossed-medium-skin-tone:before { + content: "\01faf0\01f3fd"; +} + +.emoji-hand-with-index-finger-and-thumb-crossed-medium-dark-skin-tone:before { + content: "\01faf0\01f3fe"; +} + +.emoji-hand-with-index-finger-and-thumb-crossed-dark-skin-tone:before { + content: "\01faf0\01f3ff"; +} + +.emoji-love-you-gesture:before { + content: "\01f91f"; +} + +.emoji-love-you-gesture-light-skin-tone:before { + content: "\01f91f\01f3fb"; +} + +.emoji-love-you-gesture-medium-light-skin-tone:before { + content: "\01f91f\01f3fc"; +} + +.emoji-love-you-gesture-medium-skin-tone:before { + content: "\01f91f\01f3fd"; +} + +.emoji-love-you-gesture-medium-dark-skin-tone:before { + content: "\01f91f\01f3fe"; +} + +.emoji-love-you-gesture-dark-skin-tone:before { + content: "\01f91f\01f3ff"; +} + +.emoji-sign-of-the-horns:before { + content: "\01f918"; +} + +.emoji-sign-of-the-horns-light-skin-tone:before { + content: "\01f918\01f3fb"; +} + +.emoji-sign-of-the-horns-medium-light-skin-tone:before { + content: "\01f918\01f3fc"; +} + +.emoji-sign-of-the-horns-medium-skin-tone:before { + content: "\01f918\01f3fd"; +} + +.emoji-sign-of-the-horns-medium-dark-skin-tone:before { + content: "\01f918\01f3fe"; +} + +.emoji-sign-of-the-horns-dark-skin-tone:before { + content: "\01f918\01f3ff"; +} + +.emoji-call-me-hand:before { + content: "\01f919"; +} + +.emoji-call-me-hand-light-skin-tone:before { + content: "\01f919\01f3fb"; +} + +.emoji-call-me-hand-medium-light-skin-tone:before { + content: "\01f919\01f3fc"; +} + +.emoji-call-me-hand-medium-skin-tone:before { + content: "\01f919\01f3fd"; +} + +.emoji-call-me-hand-medium-dark-skin-tone:before { + content: "\01f919\01f3fe"; +} + +.emoji-call-me-hand-dark-skin-tone:before { + content: "\01f919\01f3ff"; +} + +.emoji-backhand-index-pointing-left:before { + content: "\01f448"; +} + +.emoji-backhand-index-pointing-left-light-skin-tone:before { + content: "\01f448\01f3fb"; +} + +.emoji-backhand-index-pointing-left-medium-light-skin-tone:before { + content: "\01f448\01f3fc"; +} + +.emoji-backhand-index-pointing-left-medium-skin-tone:before { + content: "\01f448\01f3fd"; +} + +.emoji-backhand-index-pointing-left-medium-dark-skin-tone:before { + content: "\01f448\01f3fe"; +} + +.emoji-backhand-index-pointing-left-dark-skin-tone:before { + content: "\01f448\01f3ff"; +} + +.emoji-backhand-index-pointing-right:before { + content: "\01f449"; +} + +.emoji-backhand-index-pointing-right-light-skin-tone:before { + content: "\01f449\01f3fb"; +} + +.emoji-backhand-index-pointing-right-medium-light-skin-tone:before { + content: "\01f449\01f3fc"; +} + +.emoji-backhand-index-pointing-right-medium-skin-tone:before { + content: "\01f449\01f3fd"; +} + +.emoji-backhand-index-pointing-right-medium-dark-skin-tone:before { + content: "\01f449\01f3fe"; +} + +.emoji-backhand-index-pointing-right-dark-skin-tone:before { + content: "\01f449\01f3ff"; +} + +.emoji-backhand-index-pointing-up:before { + content: "\01f446"; +} + +.emoji-backhand-index-pointing-up-light-skin-tone:before { + content: "\01f446\01f3fb"; +} + +.emoji-backhand-index-pointing-up-medium-light-skin-tone:before { + content: "\01f446\01f3fc"; +} + +.emoji-backhand-index-pointing-up-medium-skin-tone:before { + content: "\01f446\01f3fd"; +} + +.emoji-backhand-index-pointing-up-medium-dark-skin-tone:before { + content: "\01f446\01f3fe"; +} + +.emoji-backhand-index-pointing-up-dark-skin-tone:before { + content: "\01f446\01f3ff"; +} + +.emoji-middle-finger:before { + content: "\01f595"; +} + +.emoji-middle-finger-light-skin-tone:before { + content: "\01f595\01f3fb"; +} + +.emoji-middle-finger-medium-light-skin-tone:before { + content: "\01f595\01f3fc"; +} + +.emoji-middle-finger-medium-skin-tone:before { + content: "\01f595\01f3fd"; +} + +.emoji-middle-finger-medium-dark-skin-tone:before { + content: "\01f595\01f3fe"; +} + +.emoji-middle-finger-dark-skin-tone:before { + content: "\01f595\01f3ff"; +} + +.emoji-backhand-index-pointing-down:before { + content: "\01f447"; +} + +.emoji-backhand-index-pointing-down-light-skin-tone:before { + content: "\01f447\01f3fb"; +} + +.emoji-backhand-index-pointing-down-medium-light-skin-tone:before { + content: "\01f447\01f3fc"; +} + +.emoji-backhand-index-pointing-down-medium-skin-tone:before { + content: "\01f447\01f3fd"; +} + +.emoji-backhand-index-pointing-down-medium-dark-skin-tone:before { + content: "\01f447\01f3fe"; +} + +.emoji-backhand-index-pointing-down-dark-skin-tone:before { + content: "\01f447\01f3ff"; +} + +.emoji-index-pointing-up:before { + content: "\00261d\00fe0f"; +} + +.emoji-index-pointing-up-light-skin-tone:before { + content: "\00261d\01f3fb"; +} + +.emoji-index-pointing-up-medium-light-skin-tone:before { + content: "\00261d\01f3fc"; +} + +.emoji-index-pointing-up-medium-skin-tone:before { + content: "\00261d\01f3fd"; +} + +.emoji-index-pointing-up-medium-dark-skin-tone:before { + content: "\00261d\01f3fe"; +} + +.emoji-index-pointing-up-dark-skin-tone:before { + content: "\00261d\01f3ff"; +} + +.emoji-index-pointing-at-the-viewer:before { + content: "\01faf5"; +} + +.emoji-index-pointing-at-the-viewer-light-skin-tone:before { + content: "\01faf5\01f3fb"; +} + +.emoji-index-pointing-at-the-viewer-medium-light-skin-tone:before { + content: "\01faf5\01f3fc"; +} + +.emoji-index-pointing-at-the-viewer-medium-skin-tone:before { + content: "\01faf5\01f3fd"; +} + +.emoji-index-pointing-at-the-viewer-medium-dark-skin-tone:before { + content: "\01faf5\01f3fe"; +} + +.emoji-index-pointing-at-the-viewer-dark-skin-tone:before { + content: "\01faf5\01f3ff"; +} + +.emoji-thumbs-up:before { + content: "\01f44d"; +} + +.emoji-thumbs-up-light-skin-tone:before { + content: "\01f44d\01f3fb"; +} + +.emoji-thumbs-up-medium-light-skin-tone:before { + content: "\01f44d\01f3fc"; +} + +.emoji-thumbs-up-medium-skin-tone:before { + content: "\01f44d\01f3fd"; +} + +.emoji-thumbs-up-medium-dark-skin-tone:before { + content: "\01f44d\01f3fe"; +} + +.emoji-thumbs-up-dark-skin-tone:before { + content: "\01f44d\01f3ff"; +} + +.emoji-thumbs-down:before { + content: "\01f44e"; +} + +.emoji-thumbs-down-light-skin-tone:before { + content: "\01f44e\01f3fb"; +} + +.emoji-thumbs-down-medium-light-skin-tone:before { + content: "\01f44e\01f3fc"; +} + +.emoji-thumbs-down-medium-skin-tone:before { + content: "\01f44e\01f3fd"; +} + +.emoji-thumbs-down-medium-dark-skin-tone:before { + content: "\01f44e\01f3fe"; +} + +.emoji-thumbs-down-dark-skin-tone:before { + content: "\01f44e\01f3ff"; +} + +.emoji-raised-fist:before { + content: "\00270a"; +} + +.emoji-raised-fist-light-skin-tone:before { + content: "\00270a\01f3fb"; +} + +.emoji-raised-fist-medium-light-skin-tone:before { + content: "\00270a\01f3fc"; +} + +.emoji-raised-fist-medium-skin-tone:before { + content: "\00270a\01f3fd"; +} + +.emoji-raised-fist-medium-dark-skin-tone:before { + content: "\00270a\01f3fe"; +} + +.emoji-raised-fist-dark-skin-tone:before { + content: "\00270a\01f3ff"; +} + +.emoji-oncoming-fist:before { + content: "\01f44a"; +} + +.emoji-oncoming-fist-light-skin-tone:before { + content: "\01f44a\01f3fb"; +} + +.emoji-oncoming-fist-medium-light-skin-tone:before { + content: "\01f44a\01f3fc"; +} + +.emoji-oncoming-fist-medium-skin-tone:before { + content: "\01f44a\01f3fd"; +} + +.emoji-oncoming-fist-medium-dark-skin-tone:before { + content: "\01f44a\01f3fe"; +} + +.emoji-oncoming-fist-dark-skin-tone:before { + content: "\01f44a\01f3ff"; +} + +.emoji-left-facing-fist:before { + content: "\01f91b"; +} + +.emoji-left-facing-fist-light-skin-tone:before { + content: "\01f91b\01f3fb"; +} + +.emoji-left-facing-fist-medium-light-skin-tone:before { + content: "\01f91b\01f3fc"; +} + +.emoji-left-facing-fist-medium-skin-tone:before { + content: "\01f91b\01f3fd"; +} + +.emoji-left-facing-fist-medium-dark-skin-tone:before { + content: "\01f91b\01f3fe"; +} + +.emoji-left-facing-fist-dark-skin-tone:before { + content: "\01f91b\01f3ff"; +} + +.emoji-right-facing-fist:before { + content: "\01f91c"; +} + +.emoji-right-facing-fist-light-skin-tone:before { + content: "\01f91c\01f3fb"; +} + +.emoji-right-facing-fist-medium-light-skin-tone:before { + content: "\01f91c\01f3fc"; +} + +.emoji-right-facing-fist-medium-skin-tone:before { + content: "\01f91c\01f3fd"; +} + +.emoji-right-facing-fist-medium-dark-skin-tone:before { + content: "\01f91c\01f3fe"; +} + +.emoji-right-facing-fist-dark-skin-tone:before { + content: "\01f91c\01f3ff"; +} + +.emoji-clapping-hands:before { + content: "\01f44f"; +} + +.emoji-clapping-hands-light-skin-tone:before { + content: "\01f44f\01f3fb"; +} + +.emoji-clapping-hands-medium-light-skin-tone:before { + content: "\01f44f\01f3fc"; +} + +.emoji-clapping-hands-medium-skin-tone:before { + content: "\01f44f\01f3fd"; +} + +.emoji-clapping-hands-medium-dark-skin-tone:before { + content: "\01f44f\01f3fe"; +} + +.emoji-clapping-hands-dark-skin-tone:before { + content: "\01f44f\01f3ff"; +} + +.emoji-raising-hands:before { + content: "\01f64c"; +} + +.emoji-raising-hands-light-skin-tone:before { + content: "\01f64c\01f3fb"; +} + +.emoji-raising-hands-medium-light-skin-tone:before { + content: "\01f64c\01f3fc"; +} + +.emoji-raising-hands-medium-skin-tone:before { + content: "\01f64c\01f3fd"; +} + +.emoji-raising-hands-medium-dark-skin-tone:before { + content: "\01f64c\01f3fe"; +} + +.emoji-raising-hands-dark-skin-tone:before { + content: "\01f64c\01f3ff"; +} + +.emoji-heart-hands:before { + content: "\01faf6"; +} + +.emoji-heart-hands-light-skin-tone:before { + content: "\01faf6\01f3fb"; +} + +.emoji-heart-hands-medium-light-skin-tone:before { + content: "\01faf6\01f3fc"; +} + +.emoji-heart-hands-medium-skin-tone:before { + content: "\01faf6\01f3fd"; +} + +.emoji-heart-hands-medium-dark-skin-tone:before { + content: "\01faf6\01f3fe"; +} + +.emoji-heart-hands-dark-skin-tone:before { + content: "\01faf6\01f3ff"; +} + +.emoji-open-hands:before { + content: "\01f450"; +} + +.emoji-open-hands-light-skin-tone:before { + content: "\01f450\01f3fb"; +} + +.emoji-open-hands-medium-light-skin-tone:before { + content: "\01f450\01f3fc"; +} + +.emoji-open-hands-medium-skin-tone:before { + content: "\01f450\01f3fd"; +} + +.emoji-open-hands-medium-dark-skin-tone:before { + content: "\01f450\01f3fe"; +} + +.emoji-open-hands-dark-skin-tone:before { + content: "\01f450\01f3ff"; +} + +.emoji-palms-up-together:before { + content: "\01f932"; +} + +.emoji-palms-up-together-light-skin-tone:before { + content: "\01f932\01f3fb"; +} + +.emoji-palms-up-together-medium-light-skin-tone:before { + content: "\01f932\01f3fc"; +} + +.emoji-palms-up-together-medium-skin-tone:before { + content: "\01f932\01f3fd"; +} + +.emoji-palms-up-together-medium-dark-skin-tone:before { + content: "\01f932\01f3fe"; +} + +.emoji-palms-up-together-dark-skin-tone:before { + content: "\01f932\01f3ff"; +} + +.emoji-handshake:before { + content: "\01f91d"; +} + +.emoji-handshake-light-skin-tone:before { + content: "\01f91d\01f3fb"; +} + +.emoji-handshake-medium-light-skin-tone:before { + content: "\01f91d\01f3fc"; +} + +.emoji-handshake-medium-skin-tone:before { + content: "\01f91d\01f3fd"; +} + +.emoji-handshake-medium-dark-skin-tone:before { + content: "\01f91d\01f3fe"; +} + +.emoji-handshake-dark-skin-tone:before { + content: "\01f91d\01f3ff"; +} + +.emoji-handshake-light-skin-tone-medium-light-skin-tone:before { + content: "\01faf1\01f3fb\00200d\01faf2\01f3fc"; +} + +.emoji-handshake-light-skin-tone-medium-skin-tone:before { + content: "\01faf1\01f3fb\00200d\01faf2\01f3fd"; +} + +.emoji-handshake-light-skin-tone-medium-dark-skin-tone:before { + content: "\01faf1\01f3fb\00200d\01faf2\01f3fe"; +} + +.emoji-handshake-light-skin-tone-dark-skin-tone:before { + content: "\01faf1\01f3fb\00200d\01faf2\01f3ff"; +} + +.emoji-handshake-medium-light-skin-tone-light-skin-tone:before { + content: "\01faf1\01f3fc\00200d\01faf2\01f3fb"; +} + +.emoji-handshake-medium-light-skin-tone-medium-skin-tone:before { + content: "\01faf1\01f3fc\00200d\01faf2\01f3fd"; +} + +.emoji-handshake-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01faf1\01f3fc\00200d\01faf2\01f3fe"; +} + +.emoji-handshake-medium-light-skin-tone-dark-skin-tone:before { + content: "\01faf1\01f3fc\00200d\01faf2\01f3ff"; +} + +.emoji-handshake-medium-skin-tone-light-skin-tone:before { + content: "\01faf1\01f3fd\00200d\01faf2\01f3fb"; +} + +.emoji-handshake-medium-skin-tone-medium-light-skin-tone:before { + content: "\01faf1\01f3fd\00200d\01faf2\01f3fc"; +} + +.emoji-handshake-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01faf1\01f3fd\00200d\01faf2\01f3fe"; +} + +.emoji-handshake-medium-skin-tone-dark-skin-tone:before { + content: "\01faf1\01f3fd\00200d\01faf2\01f3ff"; +} + +.emoji-handshake-medium-dark-skin-tone-light-skin-tone:before { + content: "\01faf1\01f3fe\00200d\01faf2\01f3fb"; +} + +.emoji-handshake-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01faf1\01f3fe\00200d\01faf2\01f3fc"; +} + +.emoji-handshake-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01faf1\01f3fe\00200d\01faf2\01f3fd"; +} + +.emoji-handshake-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01faf1\01f3fe\00200d\01faf2\01f3ff"; +} + +.emoji-handshake-dark-skin-tone-light-skin-tone:before { + content: "\01faf1\01f3ff\00200d\01faf2\01f3fb"; +} + +.emoji-handshake-dark-skin-tone-medium-light-skin-tone:before { + content: "\01faf1\01f3ff\00200d\01faf2\01f3fc"; +} + +.emoji-handshake-dark-skin-tone-medium-skin-tone:before { + content: "\01faf1\01f3ff\00200d\01faf2\01f3fd"; +} + +.emoji-handshake-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01faf1\01f3ff\00200d\01faf2\01f3fe"; +} + +.emoji-folded-hands:before { + content: "\01f64f"; +} + +.emoji-folded-hands-light-skin-tone:before { + content: "\01f64f\01f3fb"; +} + +.emoji-folded-hands-medium-light-skin-tone:before { + content: "\01f64f\01f3fc"; +} + +.emoji-folded-hands-medium-skin-tone:before { + content: "\01f64f\01f3fd"; +} + +.emoji-folded-hands-medium-dark-skin-tone:before { + content: "\01f64f\01f3fe"; +} + +.emoji-folded-hands-dark-skin-tone:before { + content: "\01f64f\01f3ff"; +} + +.emoji-writing-hand:before { + content: "\00270d\00fe0f"; +} + +.emoji-writing-hand-light-skin-tone:before { + content: "\00270d\01f3fb"; +} + +.emoji-writing-hand-medium-light-skin-tone:before { + content: "\00270d\01f3fc"; +} + +.emoji-writing-hand-medium-skin-tone:before { + content: "\00270d\01f3fd"; +} + +.emoji-writing-hand-medium-dark-skin-tone:before { + content: "\00270d\01f3fe"; +} + +.emoji-writing-hand-dark-skin-tone:before { + content: "\00270d\01f3ff"; +} + +.emoji-nail-polish:before { + content: "\01f485"; +} + +.emoji-nail-polish-light-skin-tone:before { + content: "\01f485\01f3fb"; +} + +.emoji-nail-polish-medium-light-skin-tone:before { + content: "\01f485\01f3fc"; +} + +.emoji-nail-polish-medium-skin-tone:before { + content: "\01f485\01f3fd"; +} + +.emoji-nail-polish-medium-dark-skin-tone:before { + content: "\01f485\01f3fe"; +} + +.emoji-nail-polish-dark-skin-tone:before { + content: "\01f485\01f3ff"; +} + +.emoji-selfie:before { + content: "\01f933"; +} + +.emoji-selfie-light-skin-tone:before { + content: "\01f933\01f3fb"; +} + +.emoji-selfie-medium-light-skin-tone:before { + content: "\01f933\01f3fc"; +} + +.emoji-selfie-medium-skin-tone:before { + content: "\01f933\01f3fd"; +} + +.emoji-selfie-medium-dark-skin-tone:before { + content: "\01f933\01f3fe"; +} + +.emoji-selfie-dark-skin-tone:before { + content: "\01f933\01f3ff"; +} + +.emoji-flexed-biceps:before { + content: "\01f4aa"; +} + +.emoji-flexed-biceps-light-skin-tone:before { + content: "\01f4aa\01f3fb"; +} + +.emoji-flexed-biceps-medium-light-skin-tone:before { + content: "\01f4aa\01f3fc"; +} + +.emoji-flexed-biceps-medium-skin-tone:before { + content: "\01f4aa\01f3fd"; +} + +.emoji-flexed-biceps-medium-dark-skin-tone:before { + content: "\01f4aa\01f3fe"; +} + +.emoji-flexed-biceps-dark-skin-tone:before { + content: "\01f4aa\01f3ff"; +} + +.emoji-mechanical-arm:before { + content: "\01f9be"; +} + +.emoji-mechanical-leg:before { + content: "\01f9bf"; +} + +.emoji-leg:before { + content: "\01f9b5"; +} + +.emoji-leg-light-skin-tone:before { + content: "\01f9b5\01f3fb"; +} + +.emoji-leg-medium-light-skin-tone:before { + content: "\01f9b5\01f3fc"; +} + +.emoji-leg-medium-skin-tone:before { + content: "\01f9b5\01f3fd"; +} + +.emoji-leg-medium-dark-skin-tone:before { + content: "\01f9b5\01f3fe"; +} + +.emoji-leg-dark-skin-tone:before { + content: "\01f9b5\01f3ff"; +} + +.emoji-foot:before { + content: "\01f9b6"; +} + +.emoji-foot-light-skin-tone:before { + content: "\01f9b6\01f3fb"; +} + +.emoji-foot-medium-light-skin-tone:before { + content: "\01f9b6\01f3fc"; +} + +.emoji-foot-medium-skin-tone:before { + content: "\01f9b6\01f3fd"; +} + +.emoji-foot-medium-dark-skin-tone:before { + content: "\01f9b6\01f3fe"; +} + +.emoji-foot-dark-skin-tone:before { + content: "\01f9b6\01f3ff"; +} + +.emoji-ear:before { + content: "\01f442"; +} + +.emoji-ear-light-skin-tone:before { + content: "\01f442\01f3fb"; +} + +.emoji-ear-medium-light-skin-tone:before { + content: "\01f442\01f3fc"; +} + +.emoji-ear-medium-skin-tone:before { + content: "\01f442\01f3fd"; +} + +.emoji-ear-medium-dark-skin-tone:before { + content: "\01f442\01f3fe"; +} + +.emoji-ear-dark-skin-tone:before { + content: "\01f442\01f3ff"; +} + +.emoji-ear-with-hearing-aid:before { + content: "\01f9bb"; +} + +.emoji-ear-with-hearing-aid-light-skin-tone:before { + content: "\01f9bb\01f3fb"; +} + +.emoji-ear-with-hearing-aid-medium-light-skin-tone:before { + content: "\01f9bb\01f3fc"; +} + +.emoji-ear-with-hearing-aid-medium-skin-tone:before { + content: "\01f9bb\01f3fd"; +} + +.emoji-ear-with-hearing-aid-medium-dark-skin-tone:before { + content: "\01f9bb\01f3fe"; +} + +.emoji-ear-with-hearing-aid-dark-skin-tone:before { + content: "\01f9bb\01f3ff"; +} + +.emoji-nose:before { + content: "\01f443"; +} + +.emoji-nose-light-skin-tone:before { + content: "\01f443\01f3fb"; +} + +.emoji-nose-medium-light-skin-tone:before { + content: "\01f443\01f3fc"; +} + +.emoji-nose-medium-skin-tone:before { + content: "\01f443\01f3fd"; +} + +.emoji-nose-medium-dark-skin-tone:before { + content: "\01f443\01f3fe"; +} + +.emoji-nose-dark-skin-tone:before { + content: "\01f443\01f3ff"; +} + +.emoji-brain:before { + content: "\01f9e0"; +} + +.emoji-anatomical-heart:before { + content: "\01fac0"; +} + +.emoji-lungs:before { + content: "\01fac1"; +} + +.emoji-tooth:before { + content: "\01f9b7"; +} + +.emoji-bone:before { + content: "\01f9b4"; +} + +.emoji-eyes:before { + content: "\01f440"; +} + +.emoji-eye:before { + content: "\01f441\00fe0f"; +} + +.emoji-tongue:before { + content: "\01f445"; +} + +.emoji-mouth:before { + content: "\01f444"; +} + +.emoji-biting-lip:before { + content: "\01fae6"; +} + +.emoji-baby:before { + content: "\01f476"; +} + +.emoji-baby-light-skin-tone:before { + content: "\01f476\01f3fb"; +} + +.emoji-baby-medium-light-skin-tone:before { + content: "\01f476\01f3fc"; +} + +.emoji-baby-medium-skin-tone:before { + content: "\01f476\01f3fd"; +} + +.emoji-baby-medium-dark-skin-tone:before { + content: "\01f476\01f3fe"; +} + +.emoji-baby-dark-skin-tone:before { + content: "\01f476\01f3ff"; +} + +.emoji-child:before { + content: "\01f9d2"; +} + +.emoji-child-light-skin-tone:before { + content: "\01f9d2\01f3fb"; +} + +.emoji-child-medium-light-skin-tone:before { + content: "\01f9d2\01f3fc"; +} + +.emoji-child-medium-skin-tone:before { + content: "\01f9d2\01f3fd"; +} + +.emoji-child-medium-dark-skin-tone:before { + content: "\01f9d2\01f3fe"; +} + +.emoji-child-dark-skin-tone:before { + content: "\01f9d2\01f3ff"; +} + +.emoji-boy:before { + content: "\01f466"; +} + +.emoji-boy-light-skin-tone:before { + content: "\01f466\01f3fb"; +} + +.emoji-boy-medium-light-skin-tone:before { + content: "\01f466\01f3fc"; +} + +.emoji-boy-medium-skin-tone:before { + content: "\01f466\01f3fd"; +} + +.emoji-boy-medium-dark-skin-tone:before { + content: "\01f466\01f3fe"; +} + +.emoji-boy-dark-skin-tone:before { + content: "\01f466\01f3ff"; +} + +.emoji-girl:before { + content: "\01f467"; +} + +.emoji-girl-light-skin-tone:before { + content: "\01f467\01f3fb"; +} + +.emoji-girl-medium-light-skin-tone:before { + content: "\01f467\01f3fc"; +} + +.emoji-girl-medium-skin-tone:before { + content: "\01f467\01f3fd"; +} + +.emoji-girl-medium-dark-skin-tone:before { + content: "\01f467\01f3fe"; +} + +.emoji-girl-dark-skin-tone:before { + content: "\01f467\01f3ff"; +} + +.emoji-person:before { + content: "\01f9d1"; +} + +.emoji-person-light-skin-tone:before { + content: "\01f9d1\01f3fb"; +} + +.emoji-person-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc"; +} + +.emoji-person-medium-skin-tone:before { + content: "\01f9d1\01f3fd"; +} + +.emoji-person-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe"; +} + +.emoji-person-dark-skin-tone:before { + content: "\01f9d1\01f3ff"; +} + +.emoji-person-blond-hair:before { + content: "\01f471"; +} + +.emoji-person-light-skin-tone-blond-hair:before { + content: "\01f471\01f3fb"; +} + +.emoji-person-medium-light-skin-tone-blond-hair:before { + content: "\01f471\01f3fc"; +} + +.emoji-person-medium-skin-tone-blond-hair:before { + content: "\01f471\01f3fd"; +} + +.emoji-person-medium-dark-skin-tone-blond-hair:before { + content: "\01f471\01f3fe"; +} + +.emoji-person-dark-skin-tone-blond-hair:before { + content: "\01f471\01f3ff"; +} + +.emoji-man:before { + content: "\01f468"; +} + +.emoji-man-light-skin-tone:before { + content: "\01f468\01f3fb"; +} + +.emoji-man-medium-light-skin-tone:before { + content: "\01f468\01f3fc"; +} + +.emoji-man-medium-skin-tone:before { + content: "\01f468\01f3fd"; +} + +.emoji-man-medium-dark-skin-tone:before { + content: "\01f468\01f3fe"; +} + +.emoji-man-dark-skin-tone:before { + content: "\01f468\01f3ff"; +} + +.emoji-person-beard:before { + content: "\01f9d4"; +} + +.emoji-person-light-skin-tone-beard:before { + content: "\01f9d4\01f3fb"; +} + +.emoji-person-medium-light-skin-tone-beard:before { + content: "\01f9d4\01f3fc"; +} + +.emoji-person-medium-skin-tone-beard:before { + content: "\01f9d4\01f3fd"; +} + +.emoji-person-medium-dark-skin-tone-beard:before { + content: "\01f9d4\01f3fe"; +} + +.emoji-person-dark-skin-tone-beard:before { + content: "\01f9d4\01f3ff"; +} + +.emoji-man-beard:before { + content: "\01f9d4\00200d\002642\00fe0f"; +} + +.emoji-man-light-skin-tone-beard:before { + content: "\01f9d4\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-medium-light-skin-tone-beard:before { + content: "\01f9d4\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-medium-skin-tone-beard:before { + content: "\01f9d4\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-medium-dark-skin-tone-beard:before { + content: "\01f9d4\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-dark-skin-tone-beard:before { + content: "\01f9d4\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-beard:before { + content: "\01f9d4\00200d\002640\00fe0f"; +} + +.emoji-woman-light-skin-tone-beard:before { + content: "\01f9d4\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-medium-light-skin-tone-beard:before { + content: "\01f9d4\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-medium-skin-tone-beard:before { + content: "\01f9d4\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-medium-dark-skin-tone-beard:before { + content: "\01f9d4\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-dark-skin-tone-beard:before { + content: "\01f9d4\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-man-red-hair:before { + content: "\01f468\00200d\01f9b0"; +} + +.emoji-man-light-skin-tone-red-hair:before { + content: "\01f468\01f3fb\00200d\01f9b0"; +} + +.emoji-man-medium-light-skin-tone-red-hair:before { + content: "\01f468\01f3fc\00200d\01f9b0"; +} + +.emoji-man-medium-skin-tone-red-hair:before { + content: "\01f468\01f3fd\00200d\01f9b0"; +} + +.emoji-man-medium-dark-skin-tone-red-hair:before { + content: "\01f468\01f3fe\00200d\01f9b0"; +} + +.emoji-man-dark-skin-tone-red-hair:before { + content: "\01f468\01f3ff\00200d\01f9b0"; +} + +.emoji-man-curly-hair:before { + content: "\01f468\00200d\01f9b1"; +} + +.emoji-man-light-skin-tone-curly-hair:before { + content: "\01f468\01f3fb\00200d\01f9b1"; +} + +.emoji-man-medium-light-skin-tone-curly-hair:before { + content: "\01f468\01f3fc\00200d\01f9b1"; +} + +.emoji-man-medium-skin-tone-curly-hair:before { + content: "\01f468\01f3fd\00200d\01f9b1"; +} + +.emoji-man-medium-dark-skin-tone-curly-hair:before { + content: "\01f468\01f3fe\00200d\01f9b1"; +} + +.emoji-man-dark-skin-tone-curly-hair:before { + content: "\01f468\01f3ff\00200d\01f9b1"; +} + +.emoji-man-white-hair:before { + content: "\01f468\00200d\01f9b3"; +} + +.emoji-man-light-skin-tone-white-hair:before { + content: "\01f468\01f3fb\00200d\01f9b3"; +} + +.emoji-man-medium-light-skin-tone-white-hair:before { + content: "\01f468\01f3fc\00200d\01f9b3"; +} + +.emoji-man-medium-skin-tone-white-hair:before { + content: "\01f468\01f3fd\00200d\01f9b3"; +} + +.emoji-man-medium-dark-skin-tone-white-hair:before { + content: "\01f468\01f3fe\00200d\01f9b3"; +} + +.emoji-man-dark-skin-tone-white-hair:before { + content: "\01f468\01f3ff\00200d\01f9b3"; +} + +.emoji-man-bald:before { + content: "\01f468\00200d\01f9b2"; +} + +.emoji-man-light-skin-tone-bald:before { + content: "\01f468\01f3fb\00200d\01f9b2"; +} + +.emoji-man-medium-light-skin-tone-bald:before { + content: "\01f468\01f3fc\00200d\01f9b2"; +} + +.emoji-man-medium-skin-tone-bald:before { + content: "\01f468\01f3fd\00200d\01f9b2"; +} + +.emoji-man-medium-dark-skin-tone-bald:before { + content: "\01f468\01f3fe\00200d\01f9b2"; +} + +.emoji-man-dark-skin-tone-bald:before { + content: "\01f468\01f3ff\00200d\01f9b2"; +} + +.emoji-woman:before { + content: "\01f469"; +} + +.emoji-woman-light-skin-tone:before { + content: "\01f469\01f3fb"; +} + +.emoji-woman-medium-light-skin-tone:before { + content: "\01f469\01f3fc"; +} + +.emoji-woman-medium-skin-tone:before { + content: "\01f469\01f3fd"; +} + +.emoji-woman-medium-dark-skin-tone:before { + content: "\01f469\01f3fe"; +} + +.emoji-woman-dark-skin-tone:before { + content: "\01f469\01f3ff"; +} + +.emoji-woman-red-hair:before { + content: "\01f469\00200d\01f9b0"; +} + +.emoji-woman-light-skin-tone-red-hair:before { + content: "\01f469\01f3fb\00200d\01f9b0"; +} + +.emoji-woman-medium-light-skin-tone-red-hair:before { + content: "\01f469\01f3fc\00200d\01f9b0"; +} + +.emoji-woman-medium-skin-tone-red-hair:before { + content: "\01f469\01f3fd\00200d\01f9b0"; +} + +.emoji-woman-medium-dark-skin-tone-red-hair:before { + content: "\01f469\01f3fe\00200d\01f9b0"; +} + +.emoji-woman-dark-skin-tone-red-hair:before { + content: "\01f469\01f3ff\00200d\01f9b0"; +} + +.emoji-person-red-hair:before { + content: "\01f9d1\00200d\01f9b0"; +} + +.emoji-person-light-skin-tone-red-hair:before { + content: "\01f9d1\01f3fb\00200d\01f9b0"; +} + +.emoji-person-medium-light-skin-tone-red-hair:before { + content: "\01f9d1\01f3fc\00200d\01f9b0"; +} + +.emoji-person-medium-skin-tone-red-hair:before { + content: "\01f9d1\01f3fd\00200d\01f9b0"; +} + +.emoji-person-medium-dark-skin-tone-red-hair:before { + content: "\01f9d1\01f3fe\00200d\01f9b0"; +} + +.emoji-person-dark-skin-tone-red-hair:before { + content: "\01f9d1\01f3ff\00200d\01f9b0"; +} + +.emoji-woman-curly-hair:before { + content: "\01f469\00200d\01f9b1"; +} + +.emoji-woman-light-skin-tone-curly-hair:before { + content: "\01f469\01f3fb\00200d\01f9b1"; +} + +.emoji-woman-medium-light-skin-tone-curly-hair:before { + content: "\01f469\01f3fc\00200d\01f9b1"; +} + +.emoji-woman-medium-skin-tone-curly-hair:before { + content: "\01f469\01f3fd\00200d\01f9b1"; +} + +.emoji-woman-medium-dark-skin-tone-curly-hair:before { + content: "\01f469\01f3fe\00200d\01f9b1"; +} + +.emoji-woman-dark-skin-tone-curly-hair:before { + content: "\01f469\01f3ff\00200d\01f9b1"; +} + +.emoji-person-curly-hair:before { + content: "\01f9d1\00200d\01f9b1"; +} + +.emoji-person-light-skin-tone-curly-hair:before { + content: "\01f9d1\01f3fb\00200d\01f9b1"; +} + +.emoji-person-medium-light-skin-tone-curly-hair:before { + content: "\01f9d1\01f3fc\00200d\01f9b1"; +} + +.emoji-person-medium-skin-tone-curly-hair:before { + content: "\01f9d1\01f3fd\00200d\01f9b1"; +} + +.emoji-person-medium-dark-skin-tone-curly-hair:before { + content: "\01f9d1\01f3fe\00200d\01f9b1"; +} + +.emoji-person-dark-skin-tone-curly-hair:before { + content: "\01f9d1\01f3ff\00200d\01f9b1"; +} + +.emoji-woman-white-hair:before { + content: "\01f469\00200d\01f9b3"; +} + +.emoji-woman-light-skin-tone-white-hair:before { + content: "\01f469\01f3fb\00200d\01f9b3"; +} + +.emoji-woman-medium-light-skin-tone-white-hair:before { + content: "\01f469\01f3fc\00200d\01f9b3"; +} + +.emoji-woman-medium-skin-tone-white-hair:before { + content: "\01f469\01f3fd\00200d\01f9b3"; +} + +.emoji-woman-medium-dark-skin-tone-white-hair:before { + content: "\01f469\01f3fe\00200d\01f9b3"; +} + +.emoji-woman-dark-skin-tone-white-hair:before { + content: "\01f469\01f3ff\00200d\01f9b3"; +} + +.emoji-person-white-hair:before { + content: "\01f9d1\00200d\01f9b3"; +} + +.emoji-person-light-skin-tone-white-hair:before { + content: "\01f9d1\01f3fb\00200d\01f9b3"; +} + +.emoji-person-medium-light-skin-tone-white-hair:before { + content: "\01f9d1\01f3fc\00200d\01f9b3"; +} + +.emoji-person-medium-skin-tone-white-hair:before { + content: "\01f9d1\01f3fd\00200d\01f9b3"; +} + +.emoji-person-medium-dark-skin-tone-white-hair:before { + content: "\01f9d1\01f3fe\00200d\01f9b3"; +} + +.emoji-person-dark-skin-tone-white-hair:before { + content: "\01f9d1\01f3ff\00200d\01f9b3"; +} + +.emoji-woman-bald:before { + content: "\01f469\00200d\01f9b2"; +} + +.emoji-woman-light-skin-tone-bald:before { + content: "\01f469\01f3fb\00200d\01f9b2"; +} + +.emoji-woman-medium-light-skin-tone-bald:before { + content: "\01f469\01f3fc\00200d\01f9b2"; +} + +.emoji-woman-medium-skin-tone-bald:before { + content: "\01f469\01f3fd\00200d\01f9b2"; +} + +.emoji-woman-medium-dark-skin-tone-bald:before { + content: "\01f469\01f3fe\00200d\01f9b2"; +} + +.emoji-woman-dark-skin-tone-bald:before { + content: "\01f469\01f3ff\00200d\01f9b2"; +} + +.emoji-person-bald:before { + content: "\01f9d1\00200d\01f9b2"; +} + +.emoji-person-light-skin-tone-bald:before { + content: "\01f9d1\01f3fb\00200d\01f9b2"; +} + +.emoji-person-medium-light-skin-tone-bald:before { + content: "\01f9d1\01f3fc\00200d\01f9b2"; +} + +.emoji-person-medium-skin-tone-bald:before { + content: "\01f9d1\01f3fd\00200d\01f9b2"; +} + +.emoji-person-medium-dark-skin-tone-bald:before { + content: "\01f9d1\01f3fe\00200d\01f9b2"; +} + +.emoji-person-dark-skin-tone-bald:before { + content: "\01f9d1\01f3ff\00200d\01f9b2"; +} + +.emoji-woman-blond-hair:before { + content: "\01f471\00200d\002640\00fe0f"; +} + +.emoji-woman-light-skin-tone-blond-hair:before { + content: "\01f471\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-medium-light-skin-tone-blond-hair:before { + content: "\01f471\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-medium-skin-tone-blond-hair:before { + content: "\01f471\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-medium-dark-skin-tone-blond-hair:before { + content: "\01f471\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-dark-skin-tone-blond-hair:before { + content: "\01f471\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-man-blond-hair:before { + content: "\01f471\00200d\002642\00fe0f"; +} + +.emoji-man-light-skin-tone-blond-hair:before { + content: "\01f471\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-medium-light-skin-tone-blond-hair:before { + content: "\01f471\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-medium-skin-tone-blond-hair:before { + content: "\01f471\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-medium-dark-skin-tone-blond-hair:before { + content: "\01f471\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-dark-skin-tone-blond-hair:before { + content: "\01f471\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-older-person:before { + content: "\01f9d3"; +} + +.emoji-older-person-light-skin-tone:before { + content: "\01f9d3\01f3fb"; +} + +.emoji-older-person-medium-light-skin-tone:before { + content: "\01f9d3\01f3fc"; +} + +.emoji-older-person-medium-skin-tone:before { + content: "\01f9d3\01f3fd"; +} + +.emoji-older-person-medium-dark-skin-tone:before { + content: "\01f9d3\01f3fe"; +} + +.emoji-older-person-dark-skin-tone:before { + content: "\01f9d3\01f3ff"; +} + +.emoji-old-man:before { + content: "\01f474"; +} + +.emoji-old-man-light-skin-tone:before { + content: "\01f474\01f3fb"; +} + +.emoji-old-man-medium-light-skin-tone:before { + content: "\01f474\01f3fc"; +} + +.emoji-old-man-medium-skin-tone:before { + content: "\01f474\01f3fd"; +} + +.emoji-old-man-medium-dark-skin-tone:before { + content: "\01f474\01f3fe"; +} + +.emoji-old-man-dark-skin-tone:before { + content: "\01f474\01f3ff"; +} + +.emoji-old-woman:before { + content: "\01f475"; +} + +.emoji-old-woman-light-skin-tone:before { + content: "\01f475\01f3fb"; +} + +.emoji-old-woman-medium-light-skin-tone:before { + content: "\01f475\01f3fc"; +} + +.emoji-old-woman-medium-skin-tone:before { + content: "\01f475\01f3fd"; +} + +.emoji-old-woman-medium-dark-skin-tone:before { + content: "\01f475\01f3fe"; +} + +.emoji-old-woman-dark-skin-tone:before { + content: "\01f475\01f3ff"; +} + +.emoji-person-frowning:before { + content: "\01f64d"; +} + +.emoji-person-frowning-light-skin-tone:before { + content: "\01f64d\01f3fb"; +} + +.emoji-person-frowning-medium-light-skin-tone:before { + content: "\01f64d\01f3fc"; +} + +.emoji-person-frowning-medium-skin-tone:before { + content: "\01f64d\01f3fd"; +} + +.emoji-person-frowning-medium-dark-skin-tone:before { + content: "\01f64d\01f3fe"; +} + +.emoji-person-frowning-dark-skin-tone:before { + content: "\01f64d\01f3ff"; +} + +.emoji-man-frowning:before { + content: "\01f64d\00200d\002642\00fe0f"; +} + +.emoji-man-frowning-light-skin-tone:before { + content: "\01f64d\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-frowning-medium-light-skin-tone:before { + content: "\01f64d\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-frowning-medium-skin-tone:before { + content: "\01f64d\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-frowning-medium-dark-skin-tone:before { + content: "\01f64d\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-frowning-dark-skin-tone:before { + content: "\01f64d\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-frowning:before { + content: "\01f64d\00200d\002640\00fe0f"; +} + +.emoji-woman-frowning-light-skin-tone:before { + content: "\01f64d\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-frowning-medium-light-skin-tone:before { + content: "\01f64d\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-frowning-medium-skin-tone:before { + content: "\01f64d\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-frowning-medium-dark-skin-tone:before { + content: "\01f64d\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-frowning-dark-skin-tone:before { + content: "\01f64d\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-pouting:before { + content: "\01f64e"; +} + +.emoji-person-pouting-light-skin-tone:before { + content: "\01f64e\01f3fb"; +} + +.emoji-person-pouting-medium-light-skin-tone:before { + content: "\01f64e\01f3fc"; +} + +.emoji-person-pouting-medium-skin-tone:before { + content: "\01f64e\01f3fd"; +} + +.emoji-person-pouting-medium-dark-skin-tone:before { + content: "\01f64e\01f3fe"; +} + +.emoji-person-pouting-dark-skin-tone:before { + content: "\01f64e\01f3ff"; +} + +.emoji-man-pouting:before { + content: "\01f64e\00200d\002642\00fe0f"; +} + +.emoji-man-pouting-light-skin-tone:before { + content: "\01f64e\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-pouting-medium-light-skin-tone:before { + content: "\01f64e\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-pouting-medium-skin-tone:before { + content: "\01f64e\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-pouting-medium-dark-skin-tone:before { + content: "\01f64e\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-pouting-dark-skin-tone:before { + content: "\01f64e\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-pouting:before { + content: "\01f64e\00200d\002640\00fe0f"; +} + +.emoji-woman-pouting-light-skin-tone:before { + content: "\01f64e\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-pouting-medium-light-skin-tone:before { + content: "\01f64e\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-pouting-medium-skin-tone:before { + content: "\01f64e\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-pouting-medium-dark-skin-tone:before { + content: "\01f64e\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-pouting-dark-skin-tone:before { + content: "\01f64e\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-gesturing-no:before { + content: "\01f645"; +} + +.emoji-person-gesturing-no-light-skin-tone:before { + content: "\01f645\01f3fb"; +} + +.emoji-person-gesturing-no-medium-light-skin-tone:before { + content: "\01f645\01f3fc"; +} + +.emoji-person-gesturing-no-medium-skin-tone:before { + content: "\01f645\01f3fd"; +} + +.emoji-person-gesturing-no-medium-dark-skin-tone:before { + content: "\01f645\01f3fe"; +} + +.emoji-person-gesturing-no-dark-skin-tone:before { + content: "\01f645\01f3ff"; +} + +.emoji-man-gesturing-no:before { + content: "\01f645\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-no-light-skin-tone:before { + content: "\01f645\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-no-medium-light-skin-tone:before { + content: "\01f645\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-no-medium-skin-tone:before { + content: "\01f645\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-no-medium-dark-skin-tone:before { + content: "\01f645\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-no-dark-skin-tone:before { + content: "\01f645\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-gesturing-no:before { + content: "\01f645\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-no-light-skin-tone:before { + content: "\01f645\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-no-medium-light-skin-tone:before { + content: "\01f645\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-no-medium-skin-tone:before { + content: "\01f645\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-no-medium-dark-skin-tone:before { + content: "\01f645\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-no-dark-skin-tone:before { + content: "\01f645\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-gesturing-ok:before { + content: "\01f646"; +} + +.emoji-person-gesturing-ok-light-skin-tone:before { + content: "\01f646\01f3fb"; +} + +.emoji-person-gesturing-ok-medium-light-skin-tone:before { + content: "\01f646\01f3fc"; +} + +.emoji-person-gesturing-ok-medium-skin-tone:before { + content: "\01f646\01f3fd"; +} + +.emoji-person-gesturing-ok-medium-dark-skin-tone:before { + content: "\01f646\01f3fe"; +} + +.emoji-person-gesturing-ok-dark-skin-tone:before { + content: "\01f646\01f3ff"; +} + +.emoji-man-gesturing-ok:before { + content: "\01f646\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-ok-light-skin-tone:before { + content: "\01f646\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-ok-medium-light-skin-tone:before { + content: "\01f646\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-ok-medium-skin-tone:before { + content: "\01f646\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-ok-medium-dark-skin-tone:before { + content: "\01f646\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-gesturing-ok-dark-skin-tone:before { + content: "\01f646\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-gesturing-ok:before { + content: "\01f646\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-ok-light-skin-tone:before { + content: "\01f646\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-ok-medium-light-skin-tone:before { + content: "\01f646\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-ok-medium-skin-tone:before { + content: "\01f646\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-ok-medium-dark-skin-tone:before { + content: "\01f646\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-gesturing-ok-dark-skin-tone:before { + content: "\01f646\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-tipping-hand:before { + content: "\01f481"; +} + +.emoji-person-tipping-hand-light-skin-tone:before { + content: "\01f481\01f3fb"; +} + +.emoji-person-tipping-hand-medium-light-skin-tone:before { + content: "\01f481\01f3fc"; +} + +.emoji-person-tipping-hand-medium-skin-tone:before { + content: "\01f481\01f3fd"; +} + +.emoji-person-tipping-hand-medium-dark-skin-tone:before { + content: "\01f481\01f3fe"; +} + +.emoji-person-tipping-hand-dark-skin-tone:before { + content: "\01f481\01f3ff"; +} + +.emoji-man-tipping-hand:before { + content: "\01f481\00200d\002642\00fe0f"; +} + +.emoji-man-tipping-hand-light-skin-tone:before { + content: "\01f481\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-tipping-hand-medium-light-skin-tone:before { + content: "\01f481\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-tipping-hand-medium-skin-tone:before { + content: "\01f481\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-tipping-hand-medium-dark-skin-tone:before { + content: "\01f481\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-tipping-hand-dark-skin-tone:before { + content: "\01f481\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-tipping-hand:before { + content: "\01f481\00200d\002640\00fe0f"; +} + +.emoji-woman-tipping-hand-light-skin-tone:before { + content: "\01f481\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-tipping-hand-medium-light-skin-tone:before { + content: "\01f481\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-tipping-hand-medium-skin-tone:before { + content: "\01f481\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-tipping-hand-medium-dark-skin-tone:before { + content: "\01f481\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-tipping-hand-dark-skin-tone:before { + content: "\01f481\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-raising-hand:before { + content: "\01f64b"; +} + +.emoji-person-raising-hand-light-skin-tone:before { + content: "\01f64b\01f3fb"; +} + +.emoji-person-raising-hand-medium-light-skin-tone:before { + content: "\01f64b\01f3fc"; +} + +.emoji-person-raising-hand-medium-skin-tone:before { + content: "\01f64b\01f3fd"; +} + +.emoji-person-raising-hand-medium-dark-skin-tone:before { + content: "\01f64b\01f3fe"; +} + +.emoji-person-raising-hand-dark-skin-tone:before { + content: "\01f64b\01f3ff"; +} + +.emoji-man-raising-hand:before { + content: "\01f64b\00200d\002642\00fe0f"; +} + +.emoji-man-raising-hand-light-skin-tone:before { + content: "\01f64b\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-raising-hand-medium-light-skin-tone:before { + content: "\01f64b\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-raising-hand-medium-skin-tone:before { + content: "\01f64b\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-raising-hand-medium-dark-skin-tone:before { + content: "\01f64b\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-raising-hand-dark-skin-tone:before { + content: "\01f64b\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-raising-hand:before { + content: "\01f64b\00200d\002640\00fe0f"; +} + +.emoji-woman-raising-hand-light-skin-tone:before { + content: "\01f64b\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-raising-hand-medium-light-skin-tone:before { + content: "\01f64b\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-raising-hand-medium-skin-tone:before { + content: "\01f64b\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-raising-hand-medium-dark-skin-tone:before { + content: "\01f64b\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-raising-hand-dark-skin-tone:before { + content: "\01f64b\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-deaf-person:before { + content: "\01f9cf"; +} + +.emoji-deaf-person-light-skin-tone:before { + content: "\01f9cf\01f3fb"; +} + +.emoji-deaf-person-medium-light-skin-tone:before { + content: "\01f9cf\01f3fc"; +} + +.emoji-deaf-person-medium-skin-tone:before { + content: "\01f9cf\01f3fd"; +} + +.emoji-deaf-person-medium-dark-skin-tone:before { + content: "\01f9cf\01f3fe"; +} + +.emoji-deaf-person-dark-skin-tone:before { + content: "\01f9cf\01f3ff"; +} + +.emoji-deaf-man:before { + content: "\01f9cf\00200d\002642\00fe0f"; +} + +.emoji-deaf-man-light-skin-tone:before { + content: "\01f9cf\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-deaf-man-medium-light-skin-tone:before { + content: "\01f9cf\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-deaf-man-medium-skin-tone:before { + content: "\01f9cf\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-deaf-man-medium-dark-skin-tone:before { + content: "\01f9cf\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-deaf-man-dark-skin-tone:before { + content: "\01f9cf\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-deaf-woman:before { + content: "\01f9cf\00200d\002640\00fe0f"; +} + +.emoji-deaf-woman-light-skin-tone:before { + content: "\01f9cf\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-deaf-woman-medium-light-skin-tone:before { + content: "\01f9cf\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-deaf-woman-medium-skin-tone:before { + content: "\01f9cf\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-deaf-woman-medium-dark-skin-tone:before { + content: "\01f9cf\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-deaf-woman-dark-skin-tone:before { + content: "\01f9cf\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-bowing:before { + content: "\01f647"; +} + +.emoji-person-bowing-light-skin-tone:before { + content: "\01f647\01f3fb"; +} + +.emoji-person-bowing-medium-light-skin-tone:before { + content: "\01f647\01f3fc"; +} + +.emoji-person-bowing-medium-skin-tone:before { + content: "\01f647\01f3fd"; +} + +.emoji-person-bowing-medium-dark-skin-tone:before { + content: "\01f647\01f3fe"; +} + +.emoji-person-bowing-dark-skin-tone:before { + content: "\01f647\01f3ff"; +} + +.emoji-man-bowing:before { + content: "\01f647\00200d\002642\00fe0f"; +} + +.emoji-man-bowing-light-skin-tone:before { + content: "\01f647\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-bowing-medium-light-skin-tone:before { + content: "\01f647\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-bowing-medium-skin-tone:before { + content: "\01f647\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-bowing-medium-dark-skin-tone:before { + content: "\01f647\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-bowing-dark-skin-tone:before { + content: "\01f647\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-bowing:before { + content: "\01f647\00200d\002640\00fe0f"; +} + +.emoji-woman-bowing-light-skin-tone:before { + content: "\01f647\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-bowing-medium-light-skin-tone:before { + content: "\01f647\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-bowing-medium-skin-tone:before { + content: "\01f647\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-bowing-medium-dark-skin-tone:before { + content: "\01f647\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-bowing-dark-skin-tone:before { + content: "\01f647\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-facepalming:before { + content: "\01f926"; +} + +.emoji-person-facepalming-light-skin-tone:before { + content: "\01f926\01f3fb"; +} + +.emoji-person-facepalming-medium-light-skin-tone:before { + content: "\01f926\01f3fc"; +} + +.emoji-person-facepalming-medium-skin-tone:before { + content: "\01f926\01f3fd"; +} + +.emoji-person-facepalming-medium-dark-skin-tone:before { + content: "\01f926\01f3fe"; +} + +.emoji-person-facepalming-dark-skin-tone:before { + content: "\01f926\01f3ff"; +} + +.emoji-man-facepalming:before { + content: "\01f926\00200d\002642\00fe0f"; +} + +.emoji-man-facepalming-light-skin-tone:before { + content: "\01f926\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-facepalming-medium-light-skin-tone:before { + content: "\01f926\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-facepalming-medium-skin-tone:before { + content: "\01f926\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-facepalming-medium-dark-skin-tone:before { + content: "\01f926\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-facepalming-dark-skin-tone:before { + content: "\01f926\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-facepalming:before { + content: "\01f926\00200d\002640\00fe0f"; +} + +.emoji-woman-facepalming-light-skin-tone:before { + content: "\01f926\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-facepalming-medium-light-skin-tone:before { + content: "\01f926\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-facepalming-medium-skin-tone:before { + content: "\01f926\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-facepalming-medium-dark-skin-tone:before { + content: "\01f926\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-facepalming-dark-skin-tone:before { + content: "\01f926\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-shrugging:before { + content: "\01f937"; +} + +.emoji-person-shrugging-light-skin-tone:before { + content: "\01f937\01f3fb"; +} + +.emoji-person-shrugging-medium-light-skin-tone:before { + content: "\01f937\01f3fc"; +} + +.emoji-person-shrugging-medium-skin-tone:before { + content: "\01f937\01f3fd"; +} + +.emoji-person-shrugging-medium-dark-skin-tone:before { + content: "\01f937\01f3fe"; +} + +.emoji-person-shrugging-dark-skin-tone:before { + content: "\01f937\01f3ff"; +} + +.emoji-man-shrugging:before { + content: "\01f937\00200d\002642\00fe0f"; +} + +.emoji-man-shrugging-light-skin-tone:before { + content: "\01f937\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-shrugging-medium-light-skin-tone:before { + content: "\01f937\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-shrugging-medium-skin-tone:before { + content: "\01f937\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-shrugging-medium-dark-skin-tone:before { + content: "\01f937\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-shrugging-dark-skin-tone:before { + content: "\01f937\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-shrugging:before { + content: "\01f937\00200d\002640\00fe0f"; +} + +.emoji-woman-shrugging-light-skin-tone:before { + content: "\01f937\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-shrugging-medium-light-skin-tone:before { + content: "\01f937\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-shrugging-medium-skin-tone:before { + content: "\01f937\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-shrugging-medium-dark-skin-tone:before { + content: "\01f937\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-shrugging-dark-skin-tone:before { + content: "\01f937\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-health-worker:before { + content: "\01f9d1\00200d\002695\00fe0f"; +} + +.emoji-health-worker-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002695\00fe0f"; +} + +.emoji-health-worker-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002695\00fe0f"; +} + +.emoji-health-worker-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002695\00fe0f"; +} + +.emoji-health-worker-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002695\00fe0f"; +} + +.emoji-health-worker-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002695\00fe0f"; +} + +.emoji-man-health-worker:before { + content: "\01f468\00200d\002695\00fe0f"; +} + +.emoji-man-health-worker-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\002695\00fe0f"; +} + +.emoji-man-health-worker-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\002695\00fe0f"; +} + +.emoji-man-health-worker-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\002695\00fe0f"; +} + +.emoji-man-health-worker-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\002695\00fe0f"; +} + +.emoji-man-health-worker-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\002695\00fe0f"; +} + +.emoji-woman-health-worker:before { + content: "\01f469\00200d\002695\00fe0f"; +} + +.emoji-woman-health-worker-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002695\00fe0f"; +} + +.emoji-woman-health-worker-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002695\00fe0f"; +} + +.emoji-woman-health-worker-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\002695\00fe0f"; +} + +.emoji-woman-health-worker-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002695\00fe0f"; +} + +.emoji-woman-health-worker-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002695\00fe0f"; +} + +.emoji-student:before { + content: "\01f9d1\00200d\01f393"; +} + +.emoji-student-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f393"; +} + +.emoji-student-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f393"; +} + +.emoji-student-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f393"; +} + +.emoji-student-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f393"; +} + +.emoji-student-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f393"; +} + +.emoji-man-student:before { + content: "\01f468\00200d\01f393"; +} + +.emoji-man-student-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f393"; +} + +.emoji-man-student-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f393"; +} + +.emoji-man-student-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f393"; +} + +.emoji-man-student-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f393"; +} + +.emoji-man-student-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f393"; +} + +.emoji-woman-student:before { + content: "\01f469\00200d\01f393"; +} + +.emoji-woman-student-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f393"; +} + +.emoji-woman-student-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f393"; +} + +.emoji-woman-student-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f393"; +} + +.emoji-woman-student-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f393"; +} + +.emoji-woman-student-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f393"; +} + +.emoji-teacher:before { + content: "\01f9d1\00200d\01f3eb"; +} + +.emoji-teacher-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f3eb"; +} + +.emoji-teacher-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f3eb"; +} + +.emoji-teacher-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f3eb"; +} + +.emoji-teacher-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f3eb"; +} + +.emoji-teacher-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f3eb"; +} + +.emoji-man-teacher:before { + content: "\01f468\00200d\01f3eb"; +} + +.emoji-man-teacher-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f3eb"; +} + +.emoji-man-teacher-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f3eb"; +} + +.emoji-man-teacher-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f3eb"; +} + +.emoji-man-teacher-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f3eb"; +} + +.emoji-man-teacher-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f3eb"; +} + +.emoji-woman-teacher:before { + content: "\01f469\00200d\01f3eb"; +} + +.emoji-woman-teacher-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f3eb"; +} + +.emoji-woman-teacher-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f3eb"; +} + +.emoji-woman-teacher-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f3eb"; +} + +.emoji-woman-teacher-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f3eb"; +} + +.emoji-woman-teacher-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f3eb"; +} + +.emoji-judge:before { + content: "\01f9d1\00200d\002696\00fe0f"; +} + +.emoji-judge-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002696\00fe0f"; +} + +.emoji-judge-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002696\00fe0f"; +} + +.emoji-judge-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002696\00fe0f"; +} + +.emoji-judge-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002696\00fe0f"; +} + +.emoji-judge-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002696\00fe0f"; +} + +.emoji-man-judge:before { + content: "\01f468\00200d\002696\00fe0f"; +} + +.emoji-man-judge-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\002696\00fe0f"; +} + +.emoji-man-judge-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\002696\00fe0f"; +} + +.emoji-man-judge-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\002696\00fe0f"; +} + +.emoji-man-judge-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\002696\00fe0f"; +} + +.emoji-man-judge-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\002696\00fe0f"; +} + +.emoji-woman-judge:before { + content: "\01f469\00200d\002696\00fe0f"; +} + +.emoji-woman-judge-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002696\00fe0f"; +} + +.emoji-woman-judge-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002696\00fe0f"; +} + +.emoji-woman-judge-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\002696\00fe0f"; +} + +.emoji-woman-judge-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002696\00fe0f"; +} + +.emoji-woman-judge-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002696\00fe0f"; +} + +.emoji-farmer:before { + content: "\01f9d1\00200d\01f33e"; +} + +.emoji-farmer-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f33e"; +} + +.emoji-farmer-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f33e"; +} + +.emoji-farmer-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f33e"; +} + +.emoji-farmer-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f33e"; +} + +.emoji-farmer-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f33e"; +} + +.emoji-man-farmer:before { + content: "\01f468\00200d\01f33e"; +} + +.emoji-man-farmer-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f33e"; +} + +.emoji-man-farmer-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f33e"; +} + +.emoji-man-farmer-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f33e"; +} + +.emoji-man-farmer-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f33e"; +} + +.emoji-man-farmer-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f33e"; +} + +.emoji-woman-farmer:before { + content: "\01f469\00200d\01f33e"; +} + +.emoji-woman-farmer-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f33e"; +} + +.emoji-woman-farmer-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f33e"; +} + +.emoji-woman-farmer-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f33e"; +} + +.emoji-woman-farmer-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f33e"; +} + +.emoji-woman-farmer-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f33e"; +} + +.emoji-cook:before { + content: "\01f9d1\00200d\01f373"; +} + +.emoji-cook-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f373"; +} + +.emoji-cook-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f373"; +} + +.emoji-cook-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f373"; +} + +.emoji-cook-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f373"; +} + +.emoji-cook-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f373"; +} + +.emoji-man-cook:before { + content: "\01f468\00200d\01f373"; +} + +.emoji-man-cook-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f373"; +} + +.emoji-man-cook-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f373"; +} + +.emoji-man-cook-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f373"; +} + +.emoji-man-cook-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f373"; +} + +.emoji-man-cook-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f373"; +} + +.emoji-woman-cook:before { + content: "\01f469\00200d\01f373"; +} + +.emoji-woman-cook-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f373"; +} + +.emoji-woman-cook-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f373"; +} + +.emoji-woman-cook-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f373"; +} + +.emoji-woman-cook-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f373"; +} + +.emoji-woman-cook-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f373"; +} + +.emoji-mechanic:before { + content: "\01f9d1\00200d\01f527"; +} + +.emoji-mechanic-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f527"; +} + +.emoji-mechanic-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f527"; +} + +.emoji-mechanic-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f527"; +} + +.emoji-mechanic-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f527"; +} + +.emoji-mechanic-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f527"; +} + +.emoji-man-mechanic:before { + content: "\01f468\00200d\01f527"; +} + +.emoji-man-mechanic-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f527"; +} + +.emoji-man-mechanic-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f527"; +} + +.emoji-man-mechanic-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f527"; +} + +.emoji-man-mechanic-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f527"; +} + +.emoji-man-mechanic-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f527"; +} + +.emoji-woman-mechanic:before { + content: "\01f469\00200d\01f527"; +} + +.emoji-woman-mechanic-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f527"; +} + +.emoji-woman-mechanic-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f527"; +} + +.emoji-woman-mechanic-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f527"; +} + +.emoji-woman-mechanic-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f527"; +} + +.emoji-woman-mechanic-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f527"; +} + +.emoji-factory-worker:before { + content: "\01f9d1\00200d\01f3ed"; +} + +.emoji-factory-worker-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f3ed"; +} + +.emoji-factory-worker-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f3ed"; +} + +.emoji-factory-worker-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f3ed"; +} + +.emoji-factory-worker-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f3ed"; +} + +.emoji-factory-worker-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f3ed"; +} + +.emoji-man-factory-worker:before { + content: "\01f468\00200d\01f3ed"; +} + +.emoji-man-factory-worker-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f3ed"; +} + +.emoji-man-factory-worker-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f3ed"; +} + +.emoji-man-factory-worker-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f3ed"; +} + +.emoji-man-factory-worker-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f3ed"; +} + +.emoji-man-factory-worker-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f3ed"; +} + +.emoji-woman-factory-worker:before { + content: "\01f469\00200d\01f3ed"; +} + +.emoji-woman-factory-worker-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f3ed"; +} + +.emoji-woman-factory-worker-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f3ed"; +} + +.emoji-woman-factory-worker-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f3ed"; +} + +.emoji-woman-factory-worker-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f3ed"; +} + +.emoji-woman-factory-worker-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f3ed"; +} + +.emoji-office-worker:before { + content: "\01f9d1\00200d\01f4bc"; +} + +.emoji-office-worker-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f4bc"; +} + +.emoji-office-worker-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f4bc"; +} + +.emoji-office-worker-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f4bc"; +} + +.emoji-office-worker-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f4bc"; +} + +.emoji-office-worker-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f4bc"; +} + +.emoji-man-office-worker:before { + content: "\01f468\00200d\01f4bc"; +} + +.emoji-man-office-worker-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f4bc"; +} + +.emoji-man-office-worker-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f4bc"; +} + +.emoji-man-office-worker-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f4bc"; +} + +.emoji-man-office-worker-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f4bc"; +} + +.emoji-man-office-worker-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f4bc"; +} + +.emoji-woman-office-worker:before { + content: "\01f469\00200d\01f4bc"; +} + +.emoji-woman-office-worker-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f4bc"; +} + +.emoji-woman-office-worker-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f4bc"; +} + +.emoji-woman-office-worker-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f4bc"; +} + +.emoji-woman-office-worker-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f4bc"; +} + +.emoji-woman-office-worker-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f4bc"; +} + +.emoji-scientist:before { + content: "\01f9d1\00200d\01f52c"; +} + +.emoji-scientist-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f52c"; +} + +.emoji-scientist-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f52c"; +} + +.emoji-scientist-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f52c"; +} + +.emoji-scientist-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f52c"; +} + +.emoji-scientist-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f52c"; +} + +.emoji-man-scientist:before { + content: "\01f468\00200d\01f52c"; +} + +.emoji-man-scientist-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f52c"; +} + +.emoji-man-scientist-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f52c"; +} + +.emoji-man-scientist-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f52c"; +} + +.emoji-man-scientist-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f52c"; +} + +.emoji-man-scientist-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f52c"; +} + +.emoji-woman-scientist:before { + content: "\01f469\00200d\01f52c"; +} + +.emoji-woman-scientist-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f52c"; +} + +.emoji-woman-scientist-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f52c"; +} + +.emoji-woman-scientist-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f52c"; +} + +.emoji-woman-scientist-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f52c"; +} + +.emoji-woman-scientist-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f52c"; +} + +.emoji-technologist:before { + content: "\01f9d1\00200d\01f4bb"; +} + +.emoji-technologist-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f4bb"; +} + +.emoji-technologist-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f4bb"; +} + +.emoji-technologist-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f4bb"; +} + +.emoji-technologist-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f4bb"; +} + +.emoji-technologist-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f4bb"; +} + +.emoji-man-technologist:before { + content: "\01f468\00200d\01f4bb"; +} + +.emoji-man-technologist-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f4bb"; +} + +.emoji-man-technologist-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f4bb"; +} + +.emoji-man-technologist-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f4bb"; +} + +.emoji-man-technologist-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f4bb"; +} + +.emoji-man-technologist-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f4bb"; +} + +.emoji-woman-technologist:before { + content: "\01f469\00200d\01f4bb"; +} + +.emoji-woman-technologist-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f4bb"; +} + +.emoji-woman-technologist-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f4bb"; +} + +.emoji-woman-technologist-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f4bb"; +} + +.emoji-woman-technologist-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f4bb"; +} + +.emoji-woman-technologist-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f4bb"; +} + +.emoji-singer:before { + content: "\01f9d1\00200d\01f3a4"; +} + +.emoji-singer-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f3a4"; +} + +.emoji-singer-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f3a4"; +} + +.emoji-singer-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f3a4"; +} + +.emoji-singer-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f3a4"; +} + +.emoji-singer-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f3a4"; +} + +.emoji-man-singer:before { + content: "\01f468\00200d\01f3a4"; +} + +.emoji-man-singer-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f3a4"; +} + +.emoji-man-singer-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f3a4"; +} + +.emoji-man-singer-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f3a4"; +} + +.emoji-man-singer-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f3a4"; +} + +.emoji-man-singer-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f3a4"; +} + +.emoji-woman-singer:before { + content: "\01f469\00200d\01f3a4"; +} + +.emoji-woman-singer-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f3a4"; +} + +.emoji-woman-singer-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f3a4"; +} + +.emoji-woman-singer-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f3a4"; +} + +.emoji-woman-singer-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f3a4"; +} + +.emoji-woman-singer-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f3a4"; +} + +.emoji-artist:before { + content: "\01f9d1\00200d\01f3a8"; +} + +.emoji-artist-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f3a8"; +} + +.emoji-artist-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f3a8"; +} + +.emoji-artist-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f3a8"; +} + +.emoji-artist-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f3a8"; +} + +.emoji-artist-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f3a8"; +} + +.emoji-man-artist:before { + content: "\01f468\00200d\01f3a8"; +} + +.emoji-man-artist-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f3a8"; +} + +.emoji-man-artist-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f3a8"; +} + +.emoji-man-artist-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f3a8"; +} + +.emoji-man-artist-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f3a8"; +} + +.emoji-man-artist-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f3a8"; +} + +.emoji-woman-artist:before { + content: "\01f469\00200d\01f3a8"; +} + +.emoji-woman-artist-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f3a8"; +} + +.emoji-woman-artist-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f3a8"; +} + +.emoji-woman-artist-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f3a8"; +} + +.emoji-woman-artist-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f3a8"; +} + +.emoji-woman-artist-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f3a8"; +} + +.emoji-pilot:before { + content: "\01f9d1\00200d\002708\00fe0f"; +} + +.emoji-pilot-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002708\00fe0f"; +} + +.emoji-pilot-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002708\00fe0f"; +} + +.emoji-pilot-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002708\00fe0f"; +} + +.emoji-pilot-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002708\00fe0f"; +} + +.emoji-pilot-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002708\00fe0f"; +} + +.emoji-man-pilot:before { + content: "\01f468\00200d\002708\00fe0f"; +} + +.emoji-man-pilot-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\002708\00fe0f"; +} + +.emoji-man-pilot-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\002708\00fe0f"; +} + +.emoji-man-pilot-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\002708\00fe0f"; +} + +.emoji-man-pilot-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\002708\00fe0f"; +} + +.emoji-man-pilot-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\002708\00fe0f"; +} + +.emoji-woman-pilot:before { + content: "\01f469\00200d\002708\00fe0f"; +} + +.emoji-woman-pilot-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002708\00fe0f"; +} + +.emoji-woman-pilot-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002708\00fe0f"; +} + +.emoji-woman-pilot-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\002708\00fe0f"; +} + +.emoji-woman-pilot-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002708\00fe0f"; +} + +.emoji-woman-pilot-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002708\00fe0f"; +} + +.emoji-astronaut:before { + content: "\01f9d1\00200d\01f680"; +} + +.emoji-astronaut-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f680"; +} + +.emoji-astronaut-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f680"; +} + +.emoji-astronaut-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f680"; +} + +.emoji-astronaut-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f680"; +} + +.emoji-astronaut-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f680"; +} + +.emoji-man-astronaut:before { + content: "\01f468\00200d\01f680"; +} + +.emoji-man-astronaut-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f680"; +} + +.emoji-man-astronaut-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f680"; +} + +.emoji-man-astronaut-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f680"; +} + +.emoji-man-astronaut-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f680"; +} + +.emoji-man-astronaut-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f680"; +} + +.emoji-woman-astronaut:before { + content: "\01f469\00200d\01f680"; +} + +.emoji-woman-astronaut-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f680"; +} + +.emoji-woman-astronaut-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f680"; +} + +.emoji-woman-astronaut-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f680"; +} + +.emoji-woman-astronaut-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f680"; +} + +.emoji-woman-astronaut-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f680"; +} + +.emoji-firefighter:before { + content: "\01f9d1\00200d\01f692"; +} + +.emoji-firefighter-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f692"; +} + +.emoji-firefighter-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f692"; +} + +.emoji-firefighter-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f692"; +} + +.emoji-firefighter-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f692"; +} + +.emoji-firefighter-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f692"; +} + +.emoji-man-firefighter:before { + content: "\01f468\00200d\01f692"; +} + +.emoji-man-firefighter-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f692"; +} + +.emoji-man-firefighter-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f692"; +} + +.emoji-man-firefighter-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f692"; +} + +.emoji-man-firefighter-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f692"; +} + +.emoji-man-firefighter-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f692"; +} + +.emoji-woman-firefighter:before { + content: "\01f469\00200d\01f692"; +} + +.emoji-woman-firefighter-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f692"; +} + +.emoji-woman-firefighter-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f692"; +} + +.emoji-woman-firefighter-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f692"; +} + +.emoji-woman-firefighter-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f692"; +} + +.emoji-woman-firefighter-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f692"; +} + +.emoji-police-officer:before { + content: "\01f46e"; +} + +.emoji-police-officer-light-skin-tone:before { + content: "\01f46e\01f3fb"; +} + +.emoji-police-officer-medium-light-skin-tone:before { + content: "\01f46e\01f3fc"; +} + +.emoji-police-officer-medium-skin-tone:before { + content: "\01f46e\01f3fd"; +} + +.emoji-police-officer-medium-dark-skin-tone:before { + content: "\01f46e\01f3fe"; +} + +.emoji-police-officer-dark-skin-tone:before { + content: "\01f46e\01f3ff"; +} + +.emoji-man-police-officer:before { + content: "\01f46e\00200d\002642\00fe0f"; +} + +.emoji-man-police-officer-light-skin-tone:before { + content: "\01f46e\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-police-officer-medium-light-skin-tone:before { + content: "\01f46e\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-police-officer-medium-skin-tone:before { + content: "\01f46e\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-police-officer-medium-dark-skin-tone:before { + content: "\01f46e\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-police-officer-dark-skin-tone:before { + content: "\01f46e\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-police-officer:before { + content: "\01f46e\00200d\002640\00fe0f"; +} + +.emoji-woman-police-officer-light-skin-tone:before { + content: "\01f46e\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-police-officer-medium-light-skin-tone:before { + content: "\01f46e\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-police-officer-medium-skin-tone:before { + content: "\01f46e\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-police-officer-medium-dark-skin-tone:before { + content: "\01f46e\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-police-officer-dark-skin-tone:before { + content: "\01f46e\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-detective:before { + content: "\01f575\00fe0f"; +} + +.emoji-detective-light-skin-tone:before { + content: "\01f575\01f3fb"; +} + +.emoji-detective-medium-light-skin-tone:before { + content: "\01f575\01f3fc"; +} + +.emoji-detective-medium-skin-tone:before { + content: "\01f575\01f3fd"; +} + +.emoji-detective-medium-dark-skin-tone:before { + content: "\01f575\01f3fe"; +} + +.emoji-detective-dark-skin-tone:before { + content: "\01f575\01f3ff"; +} + +.emoji-man-detective:before { + content: "\01f575\00fe0f\00200d\002642\00fe0f"; +} + +.emoji-man-detective-light-skin-tone:before { + content: "\01f575\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-detective-medium-light-skin-tone:before { + content: "\01f575\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-detective-medium-skin-tone:before { + content: "\01f575\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-detective-medium-dark-skin-tone:before { + content: "\01f575\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-detective-dark-skin-tone:before { + content: "\01f575\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-detective:before { + content: "\01f575\00fe0f\00200d\002640\00fe0f"; +} + +.emoji-woman-detective-light-skin-tone:before { + content: "\01f575\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-detective-medium-light-skin-tone:before { + content: "\01f575\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-detective-medium-skin-tone:before { + content: "\01f575\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-detective-medium-dark-skin-tone:before { + content: "\01f575\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-detective-dark-skin-tone:before { + content: "\01f575\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-guard:before { + content: "\01f482"; +} + +.emoji-guard-light-skin-tone:before { + content: "\01f482\01f3fb"; +} + +.emoji-guard-medium-light-skin-tone:before { + content: "\01f482\01f3fc"; +} + +.emoji-guard-medium-skin-tone:before { + content: "\01f482\01f3fd"; +} + +.emoji-guard-medium-dark-skin-tone:before { + content: "\01f482\01f3fe"; +} + +.emoji-guard-dark-skin-tone:before { + content: "\01f482\01f3ff"; +} + +.emoji-man-guard:before { + content: "\01f482\00200d\002642\00fe0f"; +} + +.emoji-man-guard-light-skin-tone:before { + content: "\01f482\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-guard-medium-light-skin-tone:before { + content: "\01f482\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-guard-medium-skin-tone:before { + content: "\01f482\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-guard-medium-dark-skin-tone:before { + content: "\01f482\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-guard-dark-skin-tone:before { + content: "\01f482\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-guard:before { + content: "\01f482\00200d\002640\00fe0f"; +} + +.emoji-woman-guard-light-skin-tone:before { + content: "\01f482\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-guard-medium-light-skin-tone:before { + content: "\01f482\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-guard-medium-skin-tone:before { + content: "\01f482\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-guard-medium-dark-skin-tone:before { + content: "\01f482\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-guard-dark-skin-tone:before { + content: "\01f482\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-ninja:before { + content: "\01f977"; +} + +.emoji-ninja-light-skin-tone:before { + content: "\01f977\01f3fb"; +} + +.emoji-ninja-medium-light-skin-tone:before { + content: "\01f977\01f3fc"; +} + +.emoji-ninja-medium-skin-tone:before { + content: "\01f977\01f3fd"; +} + +.emoji-ninja-medium-dark-skin-tone:before { + content: "\01f977\01f3fe"; +} + +.emoji-ninja-dark-skin-tone:before { + content: "\01f977\01f3ff"; +} + +.emoji-construction-worker:before { + content: "\01f477"; +} + +.emoji-construction-worker-light-skin-tone:before { + content: "\01f477\01f3fb"; +} + +.emoji-construction-worker-medium-light-skin-tone:before { + content: "\01f477\01f3fc"; +} + +.emoji-construction-worker-medium-skin-tone:before { + content: "\01f477\01f3fd"; +} + +.emoji-construction-worker-medium-dark-skin-tone:before { + content: "\01f477\01f3fe"; +} + +.emoji-construction-worker-dark-skin-tone:before { + content: "\01f477\01f3ff"; +} + +.emoji-man-construction-worker:before { + content: "\01f477\00200d\002642\00fe0f"; +} + +.emoji-man-construction-worker-light-skin-tone:before { + content: "\01f477\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-construction-worker-medium-light-skin-tone:before { + content: "\01f477\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-construction-worker-medium-skin-tone:before { + content: "\01f477\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-construction-worker-medium-dark-skin-tone:before { + content: "\01f477\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-construction-worker-dark-skin-tone:before { + content: "\01f477\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-construction-worker:before { + content: "\01f477\00200d\002640\00fe0f"; +} + +.emoji-woman-construction-worker-light-skin-tone:before { + content: "\01f477\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-construction-worker-medium-light-skin-tone:before { + content: "\01f477\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-construction-worker-medium-skin-tone:before { + content: "\01f477\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-construction-worker-medium-dark-skin-tone:before { + content: "\01f477\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-construction-worker-dark-skin-tone:before { + content: "\01f477\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-with-crown:before { + content: "\01fac5"; +} + +.emoji-person-with-crown-light-skin-tone:before { + content: "\01fac5\01f3fb"; +} + +.emoji-person-with-crown-medium-light-skin-tone:before { + content: "\01fac5\01f3fc"; +} + +.emoji-person-with-crown-medium-skin-tone:before { + content: "\01fac5\01f3fd"; +} + +.emoji-person-with-crown-medium-dark-skin-tone:before { + content: "\01fac5\01f3fe"; +} + +.emoji-person-with-crown-dark-skin-tone:before { + content: "\01fac5\01f3ff"; +} + +.emoji-prince:before { + content: "\01f934"; +} + +.emoji-prince-light-skin-tone:before { + content: "\01f934\01f3fb"; +} + +.emoji-prince-medium-light-skin-tone:before { + content: "\01f934\01f3fc"; +} + +.emoji-prince-medium-skin-tone:before { + content: "\01f934\01f3fd"; +} + +.emoji-prince-medium-dark-skin-tone:before { + content: "\01f934\01f3fe"; +} + +.emoji-prince-dark-skin-tone:before { + content: "\01f934\01f3ff"; +} + +.emoji-princess:before { + content: "\01f478"; +} + +.emoji-princess-light-skin-tone:before { + content: "\01f478\01f3fb"; +} + +.emoji-princess-medium-light-skin-tone:before { + content: "\01f478\01f3fc"; +} + +.emoji-princess-medium-skin-tone:before { + content: "\01f478\01f3fd"; +} + +.emoji-princess-medium-dark-skin-tone:before { + content: "\01f478\01f3fe"; +} + +.emoji-princess-dark-skin-tone:before { + content: "\01f478\01f3ff"; +} + +.emoji-person-wearing-turban:before { + content: "\01f473"; +} + +.emoji-person-wearing-turban-light-skin-tone:before { + content: "\01f473\01f3fb"; +} + +.emoji-person-wearing-turban-medium-light-skin-tone:before { + content: "\01f473\01f3fc"; +} + +.emoji-person-wearing-turban-medium-skin-tone:before { + content: "\01f473\01f3fd"; +} + +.emoji-person-wearing-turban-medium-dark-skin-tone:before { + content: "\01f473\01f3fe"; +} + +.emoji-person-wearing-turban-dark-skin-tone:before { + content: "\01f473\01f3ff"; +} + +.emoji-man-wearing-turban:before { + content: "\01f473\00200d\002642\00fe0f"; +} + +.emoji-man-wearing-turban-light-skin-tone:before { + content: "\01f473\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-wearing-turban-medium-light-skin-tone:before { + content: "\01f473\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-wearing-turban-medium-skin-tone:before { + content: "\01f473\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-wearing-turban-medium-dark-skin-tone:before { + content: "\01f473\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-wearing-turban-dark-skin-tone:before { + content: "\01f473\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-wearing-turban:before { + content: "\01f473\00200d\002640\00fe0f"; +} + +.emoji-woman-wearing-turban-light-skin-tone:before { + content: "\01f473\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-wearing-turban-medium-light-skin-tone:before { + content: "\01f473\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-wearing-turban-medium-skin-tone:before { + content: "\01f473\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-wearing-turban-medium-dark-skin-tone:before { + content: "\01f473\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-wearing-turban-dark-skin-tone:before { + content: "\01f473\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-with-skullcap:before { + content: "\01f472"; +} + +.emoji-person-with-skullcap-light-skin-tone:before { + content: "\01f472\01f3fb"; +} + +.emoji-person-with-skullcap-medium-light-skin-tone:before { + content: "\01f472\01f3fc"; +} + +.emoji-person-with-skullcap-medium-skin-tone:before { + content: "\01f472\01f3fd"; +} + +.emoji-person-with-skullcap-medium-dark-skin-tone:before { + content: "\01f472\01f3fe"; +} + +.emoji-person-with-skullcap-dark-skin-tone:before { + content: "\01f472\01f3ff"; +} + +.emoji-woman-with-headscarf:before { + content: "\01f9d5"; +} + +.emoji-woman-with-headscarf-light-skin-tone:before { + content: "\01f9d5\01f3fb"; +} + +.emoji-woman-with-headscarf-medium-light-skin-tone:before { + content: "\01f9d5\01f3fc"; +} + +.emoji-woman-with-headscarf-medium-skin-tone:before { + content: "\01f9d5\01f3fd"; +} + +.emoji-woman-with-headscarf-medium-dark-skin-tone:before { + content: "\01f9d5\01f3fe"; +} + +.emoji-woman-with-headscarf-dark-skin-tone:before { + content: "\01f9d5\01f3ff"; +} + +.emoji-person-in-tuxedo:before { + content: "\01f935"; +} + +.emoji-person-in-tuxedo-light-skin-tone:before { + content: "\01f935\01f3fb"; +} + +.emoji-person-in-tuxedo-medium-light-skin-tone:before { + content: "\01f935\01f3fc"; +} + +.emoji-person-in-tuxedo-medium-skin-tone:before { + content: "\01f935\01f3fd"; +} + +.emoji-person-in-tuxedo-medium-dark-skin-tone:before { + content: "\01f935\01f3fe"; +} + +.emoji-person-in-tuxedo-dark-skin-tone:before { + content: "\01f935\01f3ff"; +} + +.emoji-man-in-tuxedo:before { + content: "\01f935\00200d\002642\00fe0f"; +} + +.emoji-man-in-tuxedo-light-skin-tone:before { + content: "\01f935\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-in-tuxedo-medium-light-skin-tone:before { + content: "\01f935\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-in-tuxedo-medium-skin-tone:before { + content: "\01f935\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-in-tuxedo-medium-dark-skin-tone:before { + content: "\01f935\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-in-tuxedo-dark-skin-tone:before { + content: "\01f935\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-in-tuxedo:before { + content: "\01f935\00200d\002640\00fe0f"; +} + +.emoji-woman-in-tuxedo-light-skin-tone:before { + content: "\01f935\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-in-tuxedo-medium-light-skin-tone:before { + content: "\01f935\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-in-tuxedo-medium-skin-tone:before { + content: "\01f935\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-in-tuxedo-medium-dark-skin-tone:before { + content: "\01f935\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-in-tuxedo-dark-skin-tone:before { + content: "\01f935\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-with-veil:before { + content: "\01f470"; +} + +.emoji-person-with-veil-light-skin-tone:before { + content: "\01f470\01f3fb"; +} + +.emoji-person-with-veil-medium-light-skin-tone:before { + content: "\01f470\01f3fc"; +} + +.emoji-person-with-veil-medium-skin-tone:before { + content: "\01f470\01f3fd"; +} + +.emoji-person-with-veil-medium-dark-skin-tone:before { + content: "\01f470\01f3fe"; +} + +.emoji-person-with-veil-dark-skin-tone:before { + content: "\01f470\01f3ff"; +} + +.emoji-man-with-veil:before { + content: "\01f470\00200d\002642\00fe0f"; +} + +.emoji-man-with-veil-light-skin-tone:before { + content: "\01f470\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-with-veil-medium-light-skin-tone:before { + content: "\01f470\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-with-veil-medium-skin-tone:before { + content: "\01f470\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-with-veil-medium-dark-skin-tone:before { + content: "\01f470\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-with-veil-dark-skin-tone:before { + content: "\01f470\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-with-veil:before { + content: "\01f470\00200d\002640\00fe0f"; +} + +.emoji-woman-with-veil-light-skin-tone:before { + content: "\01f470\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-with-veil-medium-light-skin-tone:before { + content: "\01f470\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-with-veil-medium-skin-tone:before { + content: "\01f470\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-with-veil-medium-dark-skin-tone:before { + content: "\01f470\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-with-veil-dark-skin-tone:before { + content: "\01f470\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-pregnant-woman:before { + content: "\01f930"; +} + +.emoji-pregnant-woman-light-skin-tone:before { + content: "\01f930\01f3fb"; +} + +.emoji-pregnant-woman-medium-light-skin-tone:before { + content: "\01f930\01f3fc"; +} + +.emoji-pregnant-woman-medium-skin-tone:before { + content: "\01f930\01f3fd"; +} + +.emoji-pregnant-woman-medium-dark-skin-tone:before { + content: "\01f930\01f3fe"; +} + +.emoji-pregnant-woman-dark-skin-tone:before { + content: "\01f930\01f3ff"; +} + +.emoji-pregnant-man:before { + content: "\01fac3"; +} + +.emoji-pregnant-man-light-skin-tone:before { + content: "\01fac3\01f3fb"; +} + +.emoji-pregnant-man-medium-light-skin-tone:before { + content: "\01fac3\01f3fc"; +} + +.emoji-pregnant-man-medium-skin-tone:before { + content: "\01fac3\01f3fd"; +} + +.emoji-pregnant-man-medium-dark-skin-tone:before { + content: "\01fac3\01f3fe"; +} + +.emoji-pregnant-man-dark-skin-tone:before { + content: "\01fac3\01f3ff"; +} + +.emoji-pregnant-person:before { + content: "\01fac4"; +} + +.emoji-pregnant-person-light-skin-tone:before { + content: "\01fac4\01f3fb"; +} + +.emoji-pregnant-person-medium-light-skin-tone:before { + content: "\01fac4\01f3fc"; +} + +.emoji-pregnant-person-medium-skin-tone:before { + content: "\01fac4\01f3fd"; +} + +.emoji-pregnant-person-medium-dark-skin-tone:before { + content: "\01fac4\01f3fe"; +} + +.emoji-pregnant-person-dark-skin-tone:before { + content: "\01fac4\01f3ff"; +} + +.emoji-breast-feeding:before { + content: "\01f931"; +} + +.emoji-breast-feeding-light-skin-tone:before { + content: "\01f931\01f3fb"; +} + +.emoji-breast-feeding-medium-light-skin-tone:before { + content: "\01f931\01f3fc"; +} + +.emoji-breast-feeding-medium-skin-tone:before { + content: "\01f931\01f3fd"; +} + +.emoji-breast-feeding-medium-dark-skin-tone:before { + content: "\01f931\01f3fe"; +} + +.emoji-breast-feeding-dark-skin-tone:before { + content: "\01f931\01f3ff"; +} + +.emoji-woman-feeding-baby:before { + content: "\01f469\00200d\01f37c"; +} + +.emoji-woman-feeding-baby-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f37c"; +} + +.emoji-woman-feeding-baby-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f37c"; +} + +.emoji-woman-feeding-baby-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f37c"; +} + +.emoji-woman-feeding-baby-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f37c"; +} + +.emoji-woman-feeding-baby-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f37c"; +} + +.emoji-man-feeding-baby:before { + content: "\01f468\00200d\01f37c"; +} + +.emoji-man-feeding-baby-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f37c"; +} + +.emoji-man-feeding-baby-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f37c"; +} + +.emoji-man-feeding-baby-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f37c"; +} + +.emoji-man-feeding-baby-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f37c"; +} + +.emoji-man-feeding-baby-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f37c"; +} + +.emoji-person-feeding-baby:before { + content: "\01f9d1\00200d\01f37c"; +} + +.emoji-person-feeding-baby-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f37c"; +} + +.emoji-person-feeding-baby-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f37c"; +} + +.emoji-person-feeding-baby-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f37c"; +} + +.emoji-person-feeding-baby-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f37c"; +} + +.emoji-person-feeding-baby-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f37c"; +} + +.emoji-baby-angel:before { + content: "\01f47c"; +} + +.emoji-baby-angel-light-skin-tone:before { + content: "\01f47c\01f3fb"; +} + +.emoji-baby-angel-medium-light-skin-tone:before { + content: "\01f47c\01f3fc"; +} + +.emoji-baby-angel-medium-skin-tone:before { + content: "\01f47c\01f3fd"; +} + +.emoji-baby-angel-medium-dark-skin-tone:before { + content: "\01f47c\01f3fe"; +} + +.emoji-baby-angel-dark-skin-tone:before { + content: "\01f47c\01f3ff"; +} + +.emoji-santa-claus:before { + content: "\01f385"; +} + +.emoji-santa-claus-light-skin-tone:before { + content: "\01f385\01f3fb"; +} + +.emoji-santa-claus-medium-light-skin-tone:before { + content: "\01f385\01f3fc"; +} + +.emoji-santa-claus-medium-skin-tone:before { + content: "\01f385\01f3fd"; +} + +.emoji-santa-claus-medium-dark-skin-tone:before { + content: "\01f385\01f3fe"; +} + +.emoji-santa-claus-dark-skin-tone:before { + content: "\01f385\01f3ff"; +} + +.emoji-mrs-claus:before { + content: "\01f936"; +} + +.emoji-mrs-claus-light-skin-tone:before { + content: "\01f936\01f3fb"; +} + +.emoji-mrs-claus-medium-light-skin-tone:before { + content: "\01f936\01f3fc"; +} + +.emoji-mrs-claus-medium-skin-tone:before { + content: "\01f936\01f3fd"; +} + +.emoji-mrs-claus-medium-dark-skin-tone:before { + content: "\01f936\01f3fe"; +} + +.emoji-mrs-claus-dark-skin-tone:before { + content: "\01f936\01f3ff"; +} + +.emoji-mx-claus:before { + content: "\01f9d1\00200d\01f384"; +} + +.emoji-mx-claus-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f384"; +} + +.emoji-mx-claus-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f384"; +} + +.emoji-mx-claus-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f384"; +} + +.emoji-mx-claus-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f384"; +} + +.emoji-mx-claus-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f384"; +} + +.emoji-superhero:before { + content: "\01f9b8"; +} + +.emoji-superhero-light-skin-tone:before { + content: "\01f9b8\01f3fb"; +} + +.emoji-superhero-medium-light-skin-tone:before { + content: "\01f9b8\01f3fc"; +} + +.emoji-superhero-medium-skin-tone:before { + content: "\01f9b8\01f3fd"; +} + +.emoji-superhero-medium-dark-skin-tone:before { + content: "\01f9b8\01f3fe"; +} + +.emoji-superhero-dark-skin-tone:before { + content: "\01f9b8\01f3ff"; +} + +.emoji-man-superhero:before { + content: "\01f9b8\00200d\002642\00fe0f"; +} + +.emoji-man-superhero-light-skin-tone:before { + content: "\01f9b8\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-superhero-medium-light-skin-tone:before { + content: "\01f9b8\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-superhero-medium-skin-tone:before { + content: "\01f9b8\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-superhero-medium-dark-skin-tone:before { + content: "\01f9b8\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-superhero-dark-skin-tone:before { + content: "\01f9b8\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-superhero:before { + content: "\01f9b8\00200d\002640\00fe0f"; +} + +.emoji-woman-superhero-light-skin-tone:before { + content: "\01f9b8\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-superhero-medium-light-skin-tone:before { + content: "\01f9b8\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-superhero-medium-skin-tone:before { + content: "\01f9b8\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-superhero-medium-dark-skin-tone:before { + content: "\01f9b8\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-superhero-dark-skin-tone:before { + content: "\01f9b8\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-supervillain:before { + content: "\01f9b9"; +} + +.emoji-supervillain-light-skin-tone:before { + content: "\01f9b9\01f3fb"; +} + +.emoji-supervillain-medium-light-skin-tone:before { + content: "\01f9b9\01f3fc"; +} + +.emoji-supervillain-medium-skin-tone:before { + content: "\01f9b9\01f3fd"; +} + +.emoji-supervillain-medium-dark-skin-tone:before { + content: "\01f9b9\01f3fe"; +} + +.emoji-supervillain-dark-skin-tone:before { + content: "\01f9b9\01f3ff"; +} + +.emoji-man-supervillain:before { + content: "\01f9b9\00200d\002642\00fe0f"; +} + +.emoji-man-supervillain-light-skin-tone:before { + content: "\01f9b9\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-supervillain-medium-light-skin-tone:before { + content: "\01f9b9\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-supervillain-medium-skin-tone:before { + content: "\01f9b9\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-supervillain-medium-dark-skin-tone:before { + content: "\01f9b9\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-supervillain-dark-skin-tone:before { + content: "\01f9b9\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-supervillain:before { + content: "\01f9b9\00200d\002640\00fe0f"; +} + +.emoji-woman-supervillain-light-skin-tone:before { + content: "\01f9b9\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-supervillain-medium-light-skin-tone:before { + content: "\01f9b9\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-supervillain-medium-skin-tone:before { + content: "\01f9b9\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-supervillain-medium-dark-skin-tone:before { + content: "\01f9b9\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-supervillain-dark-skin-tone:before { + content: "\01f9b9\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-mage:before { + content: "\01f9d9"; +} + +.emoji-mage-light-skin-tone:before { + content: "\01f9d9\01f3fb"; +} + +.emoji-mage-medium-light-skin-tone:before { + content: "\01f9d9\01f3fc"; +} + +.emoji-mage-medium-skin-tone:before { + content: "\01f9d9\01f3fd"; +} + +.emoji-mage-medium-dark-skin-tone:before { + content: "\01f9d9\01f3fe"; +} + +.emoji-mage-dark-skin-tone:before { + content: "\01f9d9\01f3ff"; +} + +.emoji-man-mage:before { + content: "\01f9d9\00200d\002642\00fe0f"; +} + +.emoji-man-mage-light-skin-tone:before { + content: "\01f9d9\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-mage-medium-light-skin-tone:before { + content: "\01f9d9\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-mage-medium-skin-tone:before { + content: "\01f9d9\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-mage-medium-dark-skin-tone:before { + content: "\01f9d9\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-mage-dark-skin-tone:before { + content: "\01f9d9\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-mage:before { + content: "\01f9d9\00200d\002640\00fe0f"; +} + +.emoji-woman-mage-light-skin-tone:before { + content: "\01f9d9\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-mage-medium-light-skin-tone:before { + content: "\01f9d9\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-mage-medium-skin-tone:before { + content: "\01f9d9\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-mage-medium-dark-skin-tone:before { + content: "\01f9d9\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-mage-dark-skin-tone:before { + content: "\01f9d9\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-fairy:before { + content: "\01f9da"; +} + +.emoji-fairy-light-skin-tone:before { + content: "\01f9da\01f3fb"; +} + +.emoji-fairy-medium-light-skin-tone:before { + content: "\01f9da\01f3fc"; +} + +.emoji-fairy-medium-skin-tone:before { + content: "\01f9da\01f3fd"; +} + +.emoji-fairy-medium-dark-skin-tone:before { + content: "\01f9da\01f3fe"; +} + +.emoji-fairy-dark-skin-tone:before { + content: "\01f9da\01f3ff"; +} + +.emoji-man-fairy:before { + content: "\01f9da\00200d\002642\00fe0f"; +} + +.emoji-man-fairy-light-skin-tone:before { + content: "\01f9da\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-fairy-medium-light-skin-tone:before { + content: "\01f9da\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-fairy-medium-skin-tone:before { + content: "\01f9da\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-fairy-medium-dark-skin-tone:before { + content: "\01f9da\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-fairy-dark-skin-tone:before { + content: "\01f9da\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-fairy:before { + content: "\01f9da\00200d\002640\00fe0f"; +} + +.emoji-woman-fairy-light-skin-tone:before { + content: "\01f9da\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-fairy-medium-light-skin-tone:before { + content: "\01f9da\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-fairy-medium-skin-tone:before { + content: "\01f9da\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-fairy-medium-dark-skin-tone:before { + content: "\01f9da\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-fairy-dark-skin-tone:before { + content: "\01f9da\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-vampire:before { + content: "\01f9db"; +} + +.emoji-vampire-light-skin-tone:before { + content: "\01f9db\01f3fb"; +} + +.emoji-vampire-medium-light-skin-tone:before { + content: "\01f9db\01f3fc"; +} + +.emoji-vampire-medium-skin-tone:before { + content: "\01f9db\01f3fd"; +} + +.emoji-vampire-medium-dark-skin-tone:before { + content: "\01f9db\01f3fe"; +} + +.emoji-vampire-dark-skin-tone:before { + content: "\01f9db\01f3ff"; +} + +.emoji-man-vampire:before { + content: "\01f9db\00200d\002642\00fe0f"; +} + +.emoji-man-vampire-light-skin-tone:before { + content: "\01f9db\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-vampire-medium-light-skin-tone:before { + content: "\01f9db\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-vampire-medium-skin-tone:before { + content: "\01f9db\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-vampire-medium-dark-skin-tone:before { + content: "\01f9db\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-vampire-dark-skin-tone:before { + content: "\01f9db\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-vampire:before { + content: "\01f9db\00200d\002640\00fe0f"; +} + +.emoji-woman-vampire-light-skin-tone:before { + content: "\01f9db\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-vampire-medium-light-skin-tone:before { + content: "\01f9db\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-vampire-medium-skin-tone:before { + content: "\01f9db\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-vampire-medium-dark-skin-tone:before { + content: "\01f9db\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-vampire-dark-skin-tone:before { + content: "\01f9db\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-merperson:before { + content: "\01f9dc"; +} + +.emoji-merperson-light-skin-tone:before { + content: "\01f9dc\01f3fb"; +} + +.emoji-merperson-medium-light-skin-tone:before { + content: "\01f9dc\01f3fc"; +} + +.emoji-merperson-medium-skin-tone:before { + content: "\01f9dc\01f3fd"; +} + +.emoji-merperson-medium-dark-skin-tone:before { + content: "\01f9dc\01f3fe"; +} + +.emoji-merperson-dark-skin-tone:before { + content: "\01f9dc\01f3ff"; +} + +.emoji-merman:before { + content: "\01f9dc\00200d\002642\00fe0f"; +} + +.emoji-merman-light-skin-tone:before { + content: "\01f9dc\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-merman-medium-light-skin-tone:before { + content: "\01f9dc\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-merman-medium-skin-tone:before { + content: "\01f9dc\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-merman-medium-dark-skin-tone:before { + content: "\01f9dc\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-merman-dark-skin-tone:before { + content: "\01f9dc\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-mermaid:before { + content: "\01f9dc\00200d\002640\00fe0f"; +} + +.emoji-mermaid-light-skin-tone:before { + content: "\01f9dc\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-mermaid-medium-light-skin-tone:before { + content: "\01f9dc\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-mermaid-medium-skin-tone:before { + content: "\01f9dc\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-mermaid-medium-dark-skin-tone:before { + content: "\01f9dc\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-mermaid-dark-skin-tone:before { + content: "\01f9dc\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-elf:before { + content: "\01f9dd"; +} + +.emoji-elf-light-skin-tone:before { + content: "\01f9dd\01f3fb"; +} + +.emoji-elf-medium-light-skin-tone:before { + content: "\01f9dd\01f3fc"; +} + +.emoji-elf-medium-skin-tone:before { + content: "\01f9dd\01f3fd"; +} + +.emoji-elf-medium-dark-skin-tone:before { + content: "\01f9dd\01f3fe"; +} + +.emoji-elf-dark-skin-tone:before { + content: "\01f9dd\01f3ff"; +} + +.emoji-man-elf:before { + content: "\01f9dd\00200d\002642\00fe0f"; +} + +.emoji-man-elf-light-skin-tone:before { + content: "\01f9dd\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-elf-medium-light-skin-tone:before { + content: "\01f9dd\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-elf-medium-skin-tone:before { + content: "\01f9dd\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-elf-medium-dark-skin-tone:before { + content: "\01f9dd\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-elf-dark-skin-tone:before { + content: "\01f9dd\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-elf:before { + content: "\01f9dd\00200d\002640\00fe0f"; +} + +.emoji-woman-elf-light-skin-tone:before { + content: "\01f9dd\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-elf-medium-light-skin-tone:before { + content: "\01f9dd\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-elf-medium-skin-tone:before { + content: "\01f9dd\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-elf-medium-dark-skin-tone:before { + content: "\01f9dd\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-elf-dark-skin-tone:before { + content: "\01f9dd\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-genie:before { + content: "\01f9de"; +} + +.emoji-man-genie:before { + content: "\01f9de\00200d\002642\00fe0f"; +} + +.emoji-woman-genie:before { + content: "\01f9de\00200d\002640\00fe0f"; +} + +.emoji-zombie:before { + content: "\01f9df"; +} + +.emoji-man-zombie:before { + content: "\01f9df\00200d\002642\00fe0f"; +} + +.emoji-woman-zombie:before { + content: "\01f9df\00200d\002640\00fe0f"; +} + +.emoji-troll:before { + content: "\01f9cc"; +} + +.emoji-hairy-creature:before { + content: "\01fac8"; +} + +.emoji-person-getting-massage:before { + content: "\01f486"; +} + +.emoji-person-getting-massage-light-skin-tone:before { + content: "\01f486\01f3fb"; +} + +.emoji-person-getting-massage-medium-light-skin-tone:before { + content: "\01f486\01f3fc"; +} + +.emoji-person-getting-massage-medium-skin-tone:before { + content: "\01f486\01f3fd"; +} + +.emoji-person-getting-massage-medium-dark-skin-tone:before { + content: "\01f486\01f3fe"; +} + +.emoji-person-getting-massage-dark-skin-tone:before { + content: "\01f486\01f3ff"; +} + +.emoji-man-getting-massage:before { + content: "\01f486\00200d\002642\00fe0f"; +} + +.emoji-man-getting-massage-light-skin-tone:before { + content: "\01f486\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-getting-massage-medium-light-skin-tone:before { + content: "\01f486\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-getting-massage-medium-skin-tone:before { + content: "\01f486\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-getting-massage-medium-dark-skin-tone:before { + content: "\01f486\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-getting-massage-dark-skin-tone:before { + content: "\01f486\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-getting-massage:before { + content: "\01f486\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-massage-light-skin-tone:before { + content: "\01f486\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-massage-medium-light-skin-tone:before { + content: "\01f486\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-massage-medium-skin-tone:before { + content: "\01f486\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-massage-medium-dark-skin-tone:before { + content: "\01f486\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-massage-dark-skin-tone:before { + content: "\01f486\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-getting-haircut:before { + content: "\01f487"; +} + +.emoji-person-getting-haircut-light-skin-tone:before { + content: "\01f487\01f3fb"; +} + +.emoji-person-getting-haircut-medium-light-skin-tone:before { + content: "\01f487\01f3fc"; +} + +.emoji-person-getting-haircut-medium-skin-tone:before { + content: "\01f487\01f3fd"; +} + +.emoji-person-getting-haircut-medium-dark-skin-tone:before { + content: "\01f487\01f3fe"; +} + +.emoji-person-getting-haircut-dark-skin-tone:before { + content: "\01f487\01f3ff"; +} + +.emoji-man-getting-haircut:before { + content: "\01f487\00200d\002642\00fe0f"; +} + +.emoji-man-getting-haircut-light-skin-tone:before { + content: "\01f487\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-getting-haircut-medium-light-skin-tone:before { + content: "\01f487\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-getting-haircut-medium-skin-tone:before { + content: "\01f487\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-getting-haircut-medium-dark-skin-tone:before { + content: "\01f487\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-getting-haircut-dark-skin-tone:before { + content: "\01f487\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-getting-haircut:before { + content: "\01f487\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-haircut-light-skin-tone:before { + content: "\01f487\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-haircut-medium-light-skin-tone:before { + content: "\01f487\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-haircut-medium-skin-tone:before { + content: "\01f487\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-haircut-medium-dark-skin-tone:before { + content: "\01f487\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-getting-haircut-dark-skin-tone:before { + content: "\01f487\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-walking:before { + content: "\01f6b6"; +} + +.emoji-person-walking-light-skin-tone:before { + content: "\01f6b6\01f3fb"; +} + +.emoji-person-walking-medium-light-skin-tone:before { + content: "\01f6b6\01f3fc"; +} + +.emoji-person-walking-medium-skin-tone:before { + content: "\01f6b6\01f3fd"; +} + +.emoji-person-walking-medium-dark-skin-tone:before { + content: "\01f6b6\01f3fe"; +} + +.emoji-person-walking-dark-skin-tone:before { + content: "\01f6b6\01f3ff"; +} + +.emoji-man-walking:before { + content: "\01f6b6\00200d\002642\00fe0f"; +} + +.emoji-man-walking-light-skin-tone:before { + content: "\01f6b6\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-walking-medium-light-skin-tone:before { + content: "\01f6b6\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-walking-medium-skin-tone:before { + content: "\01f6b6\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-walking-medium-dark-skin-tone:before { + content: "\01f6b6\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-walking-dark-skin-tone:before { + content: "\01f6b6\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-walking:before { + content: "\01f6b6\00200d\002640\00fe0f"; +} + +.emoji-woman-walking-light-skin-tone:before { + content: "\01f6b6\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-walking-medium-light-skin-tone:before { + content: "\01f6b6\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-walking-medium-skin-tone:before { + content: "\01f6b6\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-walking-medium-dark-skin-tone:before { + content: "\01f6b6\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-walking-dark-skin-tone:before { + content: "\01f6b6\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-walking-facing-right:before { + content: "\01f6b6\00200d\0027a1\00fe0f"; +} + +.emoji-person-walking-facing-right-light-skin-tone:before { + content: "\01f6b6\01f3fb\00200d\0027a1\00fe0f"; +} + +.emoji-person-walking-facing-right-medium-light-skin-tone:before { + content: "\01f6b6\01f3fc\00200d\0027a1\00fe0f"; +} + +.emoji-person-walking-facing-right-medium-skin-tone:before { + content: "\01f6b6\01f3fd\00200d\0027a1\00fe0f"; +} + +.emoji-person-walking-facing-right-medium-dark-skin-tone:before { + content: "\01f6b6\01f3fe\00200d\0027a1\00fe0f"; +} + +.emoji-person-walking-facing-right-dark-skin-tone:before { + content: "\01f6b6\01f3ff\00200d\0027a1\00fe0f"; +} + +.emoji-woman-walking-facing-right:before { + content: "\01f6b6\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-walking-facing-right-light-skin-tone:before { + content: "\01f6b6\01f3fb\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-walking-facing-right-medium-light-skin-tone:before { + content: "\01f6b6\01f3fc\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-walking-facing-right-medium-skin-tone:before { + content: "\01f6b6\01f3fd\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-walking-facing-right-medium-dark-skin-tone:before { + content: "\01f6b6\01f3fe\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-walking-facing-right-dark-skin-tone:before { + content: "\01f6b6\01f3ff\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-walking-facing-right:before { + content: "\01f6b6\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-walking-facing-right-light-skin-tone:before { + content: "\01f6b6\01f3fb\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-walking-facing-right-medium-light-skin-tone:before { + content: "\01f6b6\01f3fc\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-walking-facing-right-medium-skin-tone:before { + content: "\01f6b6\01f3fd\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-walking-facing-right-medium-dark-skin-tone:before { + content: "\01f6b6\01f3fe\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-walking-facing-right-dark-skin-tone:before { + content: "\01f6b6\01f3ff\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-person-standing:before { + content: "\01f9cd"; +} + +.emoji-person-standing-light-skin-tone:before { + content: "\01f9cd\01f3fb"; +} + +.emoji-person-standing-medium-light-skin-tone:before { + content: "\01f9cd\01f3fc"; +} + +.emoji-person-standing-medium-skin-tone:before { + content: "\01f9cd\01f3fd"; +} + +.emoji-person-standing-medium-dark-skin-tone:before { + content: "\01f9cd\01f3fe"; +} + +.emoji-person-standing-dark-skin-tone:before { + content: "\01f9cd\01f3ff"; +} + +.emoji-man-standing:before { + content: "\01f9cd\00200d\002642\00fe0f"; +} + +.emoji-man-standing-light-skin-tone:before { + content: "\01f9cd\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-standing-medium-light-skin-tone:before { + content: "\01f9cd\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-standing-medium-skin-tone:before { + content: "\01f9cd\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-standing-medium-dark-skin-tone:before { + content: "\01f9cd\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-standing-dark-skin-tone:before { + content: "\01f9cd\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-standing:before { + content: "\01f9cd\00200d\002640\00fe0f"; +} + +.emoji-woman-standing-light-skin-tone:before { + content: "\01f9cd\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-standing-medium-light-skin-tone:before { + content: "\01f9cd\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-standing-medium-skin-tone:before { + content: "\01f9cd\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-standing-medium-dark-skin-tone:before { + content: "\01f9cd\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-standing-dark-skin-tone:before { + content: "\01f9cd\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-kneeling:before { + content: "\01f9ce"; +} + +.emoji-person-kneeling-light-skin-tone:before { + content: "\01f9ce\01f3fb"; +} + +.emoji-person-kneeling-medium-light-skin-tone:before { + content: "\01f9ce\01f3fc"; +} + +.emoji-person-kneeling-medium-skin-tone:before { + content: "\01f9ce\01f3fd"; +} + +.emoji-person-kneeling-medium-dark-skin-tone:before { + content: "\01f9ce\01f3fe"; +} + +.emoji-person-kneeling-dark-skin-tone:before { + content: "\01f9ce\01f3ff"; +} + +.emoji-man-kneeling:before { + content: "\01f9ce\00200d\002642\00fe0f"; +} + +.emoji-man-kneeling-light-skin-tone:before { + content: "\01f9ce\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-kneeling-medium-light-skin-tone:before { + content: "\01f9ce\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-kneeling-medium-skin-tone:before { + content: "\01f9ce\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-kneeling-medium-dark-skin-tone:before { + content: "\01f9ce\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-kneeling-dark-skin-tone:before { + content: "\01f9ce\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-kneeling:before { + content: "\01f9ce\00200d\002640\00fe0f"; +} + +.emoji-woman-kneeling-light-skin-tone:before { + content: "\01f9ce\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-kneeling-medium-light-skin-tone:before { + content: "\01f9ce\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-kneeling-medium-skin-tone:before { + content: "\01f9ce\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-kneeling-medium-dark-skin-tone:before { + content: "\01f9ce\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-kneeling-dark-skin-tone:before { + content: "\01f9ce\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-kneeling-facing-right:before { + content: "\01f9ce\00200d\0027a1\00fe0f"; +} + +.emoji-person-kneeling-facing-right-light-skin-tone:before { + content: "\01f9ce\01f3fb\00200d\0027a1\00fe0f"; +} + +.emoji-person-kneeling-facing-right-medium-light-skin-tone:before { + content: "\01f9ce\01f3fc\00200d\0027a1\00fe0f"; +} + +.emoji-person-kneeling-facing-right-medium-skin-tone:before { + content: "\01f9ce\01f3fd\00200d\0027a1\00fe0f"; +} + +.emoji-person-kneeling-facing-right-medium-dark-skin-tone:before { + content: "\01f9ce\01f3fe\00200d\0027a1\00fe0f"; +} + +.emoji-person-kneeling-facing-right-dark-skin-tone:before { + content: "\01f9ce\01f3ff\00200d\0027a1\00fe0f"; +} + +.emoji-woman-kneeling-facing-right:before { + content: "\01f9ce\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-kneeling-facing-right-light-skin-tone:before { + content: "\01f9ce\01f3fb\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-kneeling-facing-right-medium-light-skin-tone:before { + content: "\01f9ce\01f3fc\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-kneeling-facing-right-medium-skin-tone:before { + content: "\01f9ce\01f3fd\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-kneeling-facing-right-medium-dark-skin-tone:before { + content: "\01f9ce\01f3fe\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-kneeling-facing-right-dark-skin-tone:before { + content: "\01f9ce\01f3ff\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-kneeling-facing-right:before { + content: "\01f9ce\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-kneeling-facing-right-light-skin-tone:before { + content: "\01f9ce\01f3fb\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-kneeling-facing-right-medium-light-skin-tone:before { + content: "\01f9ce\01f3fc\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-kneeling-facing-right-medium-skin-tone:before { + content: "\01f9ce\01f3fd\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-kneeling-facing-right-medium-dark-skin-tone:before { + content: "\01f9ce\01f3fe\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-kneeling-facing-right-dark-skin-tone:before { + content: "\01f9ce\01f3ff\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-person-with-white-cane:before { + content: "\01f9d1\00200d\01f9af"; +} + +.emoji-person-with-white-cane-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f9af"; +} + +.emoji-person-with-white-cane-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f9af"; +} + +.emoji-person-with-white-cane-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f9af"; +} + +.emoji-person-with-white-cane-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f9af"; +} + +.emoji-person-with-white-cane-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f9af"; +} + +.emoji-person-with-white-cane-facing-right:before { + content: "\01f9d1\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-person-with-white-cane-facing-right-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-person-with-white-cane-facing-right-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-person-with-white-cane-facing-right-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-person-with-white-cane-facing-right-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-person-with-white-cane-facing-right-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-man-with-white-cane:before { + content: "\01f468\00200d\01f9af"; +} + +.emoji-man-with-white-cane-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f9af"; +} + +.emoji-man-with-white-cane-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f9af"; +} + +.emoji-man-with-white-cane-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f9af"; +} + +.emoji-man-with-white-cane-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f9af"; +} + +.emoji-man-with-white-cane-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f9af"; +} + +.emoji-man-with-white-cane-facing-right:before { + content: "\01f468\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-man-with-white-cane-facing-right-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-man-with-white-cane-facing-right-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-man-with-white-cane-facing-right-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-man-with-white-cane-facing-right-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-man-with-white-cane-facing-right-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-woman-with-white-cane:before { + content: "\01f469\00200d\01f9af"; +} + +.emoji-woman-with-white-cane-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f9af"; +} + +.emoji-woman-with-white-cane-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f9af"; +} + +.emoji-woman-with-white-cane-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f9af"; +} + +.emoji-woman-with-white-cane-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f9af"; +} + +.emoji-woman-with-white-cane-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f9af"; +} + +.emoji-woman-with-white-cane-facing-right:before { + content: "\01f469\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-woman-with-white-cane-facing-right-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-woman-with-white-cane-facing-right-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-woman-with-white-cane-facing-right-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-woman-with-white-cane-facing-right-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-woman-with-white-cane-facing-right-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f9af\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-motorized-wheelchair:before { + content: "\01f9d1\00200d\01f9bc"; +} + +.emoji-person-in-motorized-wheelchair-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f9bc"; +} + +.emoji-person-in-motorized-wheelchair-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f9bc"; +} + +.emoji-person-in-motorized-wheelchair-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f9bc"; +} + +.emoji-person-in-motorized-wheelchair-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f9bc"; +} + +.emoji-person-in-motorized-wheelchair-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f9bc"; +} + +.emoji-person-in-motorized-wheelchair-facing-right:before { + content: "\01f9d1\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-motorized-wheelchair-facing-right-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-motorized-wheelchair-facing-right-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-motorized-wheelchair-facing-right-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-motorized-wheelchair-facing-right-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-motorized-wheelchair-facing-right-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-motorized-wheelchair:before { + content: "\01f468\00200d\01f9bc"; +} + +.emoji-man-in-motorized-wheelchair-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f9bc"; +} + +.emoji-man-in-motorized-wheelchair-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f9bc"; +} + +.emoji-man-in-motorized-wheelchair-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f9bc"; +} + +.emoji-man-in-motorized-wheelchair-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f9bc"; +} + +.emoji-man-in-motorized-wheelchair-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f9bc"; +} + +.emoji-man-in-motorized-wheelchair-facing-right:before { + content: "\01f468\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-motorized-wheelchair-facing-right-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-motorized-wheelchair-facing-right-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-motorized-wheelchair-facing-right-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-motorized-wheelchair-facing-right-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-motorized-wheelchair-facing-right-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-motorized-wheelchair:before { + content: "\01f469\00200d\01f9bc"; +} + +.emoji-woman-in-motorized-wheelchair-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f9bc"; +} + +.emoji-woman-in-motorized-wheelchair-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f9bc"; +} + +.emoji-woman-in-motorized-wheelchair-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f9bc"; +} + +.emoji-woman-in-motorized-wheelchair-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f9bc"; +} + +.emoji-woman-in-motorized-wheelchair-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f9bc"; +} + +.emoji-woman-in-motorized-wheelchair-facing-right:before { + content: "\01f469\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-motorized-wheelchair-facing-right-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-motorized-wheelchair-facing-right-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-motorized-wheelchair-facing-right-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-motorized-wheelchair-facing-right-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-motorized-wheelchair-facing-right-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f9bc\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-manual-wheelchair:before { + content: "\01f9d1\00200d\01f9bd"; +} + +.emoji-person-in-manual-wheelchair-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f9bd"; +} + +.emoji-person-in-manual-wheelchair-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f9bd"; +} + +.emoji-person-in-manual-wheelchair-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f9bd"; +} + +.emoji-person-in-manual-wheelchair-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f9bd"; +} + +.emoji-person-in-manual-wheelchair-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f9bd"; +} + +.emoji-person-in-manual-wheelchair-facing-right:before { + content: "\01f9d1\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-manual-wheelchair-facing-right-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-manual-wheelchair-facing-right-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-manual-wheelchair-facing-right-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-manual-wheelchair-facing-right-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-person-in-manual-wheelchair-facing-right-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-manual-wheelchair:before { + content: "\01f468\00200d\01f9bd"; +} + +.emoji-man-in-manual-wheelchair-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f9bd"; +} + +.emoji-man-in-manual-wheelchair-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f9bd"; +} + +.emoji-man-in-manual-wheelchair-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f9bd"; +} + +.emoji-man-in-manual-wheelchair-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f9bd"; +} + +.emoji-man-in-manual-wheelchair-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f9bd"; +} + +.emoji-man-in-manual-wheelchair-facing-right:before { + content: "\01f468\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-manual-wheelchair-facing-right-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-manual-wheelchair-facing-right-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-manual-wheelchair-facing-right-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-manual-wheelchair-facing-right-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-man-in-manual-wheelchair-facing-right-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-manual-wheelchair:before { + content: "\01f469\00200d\01f9bd"; +} + +.emoji-woman-in-manual-wheelchair-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f9bd"; +} + +.emoji-woman-in-manual-wheelchair-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f9bd"; +} + +.emoji-woman-in-manual-wheelchair-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f9bd"; +} + +.emoji-woman-in-manual-wheelchair-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f9bd"; +} + +.emoji-woman-in-manual-wheelchair-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f9bd"; +} + +.emoji-woman-in-manual-wheelchair-facing-right:before { + content: "\01f469\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-manual-wheelchair-facing-right-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-manual-wheelchair-facing-right-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-manual-wheelchair-facing-right-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-manual-wheelchair-facing-right-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-woman-in-manual-wheelchair-facing-right-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f9bd\00200d\0027a1\00fe0f"; +} + +.emoji-person-running:before { + content: "\01f3c3"; +} + +.emoji-person-running-light-skin-tone:before { + content: "\01f3c3\01f3fb"; +} + +.emoji-person-running-medium-light-skin-tone:before { + content: "\01f3c3\01f3fc"; +} + +.emoji-person-running-medium-skin-tone:before { + content: "\01f3c3\01f3fd"; +} + +.emoji-person-running-medium-dark-skin-tone:before { + content: "\01f3c3\01f3fe"; +} + +.emoji-person-running-dark-skin-tone:before { + content: "\01f3c3\01f3ff"; +} + +.emoji-man-running:before { + content: "\01f3c3\00200d\002642\00fe0f"; +} + +.emoji-man-running-light-skin-tone:before { + content: "\01f3c3\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-running-medium-light-skin-tone:before { + content: "\01f3c3\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-running-medium-skin-tone:before { + content: "\01f3c3\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-running-medium-dark-skin-tone:before { + content: "\01f3c3\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-running-dark-skin-tone:before { + content: "\01f3c3\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-running:before { + content: "\01f3c3\00200d\002640\00fe0f"; +} + +.emoji-woman-running-light-skin-tone:before { + content: "\01f3c3\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-running-medium-light-skin-tone:before { + content: "\01f3c3\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-running-medium-skin-tone:before { + content: "\01f3c3\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-running-medium-dark-skin-tone:before { + content: "\01f3c3\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-running-dark-skin-tone:before { + content: "\01f3c3\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-running-facing-right:before { + content: "\01f3c3\00200d\0027a1\00fe0f"; +} + +.emoji-person-running-facing-right-light-skin-tone:before { + content: "\01f3c3\01f3fb\00200d\0027a1\00fe0f"; +} + +.emoji-person-running-facing-right-medium-light-skin-tone:before { + content: "\01f3c3\01f3fc\00200d\0027a1\00fe0f"; +} + +.emoji-person-running-facing-right-medium-skin-tone:before { + content: "\01f3c3\01f3fd\00200d\0027a1\00fe0f"; +} + +.emoji-person-running-facing-right-medium-dark-skin-tone:before { + content: "\01f3c3\01f3fe\00200d\0027a1\00fe0f"; +} + +.emoji-person-running-facing-right-dark-skin-tone:before { + content: "\01f3c3\01f3ff\00200d\0027a1\00fe0f"; +} + +.emoji-woman-running-facing-right:before { + content: "\01f3c3\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-running-facing-right-light-skin-tone:before { + content: "\01f3c3\01f3fb\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-running-facing-right-medium-light-skin-tone:before { + content: "\01f3c3\01f3fc\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-running-facing-right-medium-skin-tone:before { + content: "\01f3c3\01f3fd\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-running-facing-right-medium-dark-skin-tone:before { + content: "\01f3c3\01f3fe\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-woman-running-facing-right-dark-skin-tone:before { + content: "\01f3c3\01f3ff\00200d\002640\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-running-facing-right:before { + content: "\01f3c3\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-running-facing-right-light-skin-tone:before { + content: "\01f3c3\01f3fb\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-running-facing-right-medium-light-skin-tone:before { + content: "\01f3c3\01f3fc\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-running-facing-right-medium-skin-tone:before { + content: "\01f3c3\01f3fd\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-running-facing-right-medium-dark-skin-tone:before { + content: "\01f3c3\01f3fe\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-man-running-facing-right-dark-skin-tone:before { + content: "\01f3c3\01f3ff\00200d\002642\00fe0f\00200d\0027a1\00fe0f"; +} + +.emoji-ballet-dancer:before { + content: "\01f9d1\00200d\01fa70"; +} + +.emoji-ballet-dancer-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01fa70"; +} + +.emoji-ballet-dancer-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01fa70"; +} + +.emoji-ballet-dancer-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01fa70"; +} + +.emoji-ballet-dancer-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01fa70"; +} + +.emoji-ballet-dancer-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01fa70"; +} + +.emoji-woman-dancing:before { + content: "\01f483"; +} + +.emoji-woman-dancing-light-skin-tone:before { + content: "\01f483\01f3fb"; +} + +.emoji-woman-dancing-medium-light-skin-tone:before { + content: "\01f483\01f3fc"; +} + +.emoji-woman-dancing-medium-skin-tone:before { + content: "\01f483\01f3fd"; +} + +.emoji-woman-dancing-medium-dark-skin-tone:before { + content: "\01f483\01f3fe"; +} + +.emoji-woman-dancing-dark-skin-tone:before { + content: "\01f483\01f3ff"; +} + +.emoji-man-dancing:before { + content: "\01f57a"; +} + +.emoji-man-dancing-light-skin-tone:before { + content: "\01f57a\01f3fb"; +} + +.emoji-man-dancing-medium-light-skin-tone:before { + content: "\01f57a\01f3fc"; +} + +.emoji-man-dancing-medium-skin-tone:before { + content: "\01f57a\01f3fd"; +} + +.emoji-man-dancing-medium-dark-skin-tone:before { + content: "\01f57a\01f3fe"; +} + +.emoji-man-dancing-dark-skin-tone:before { + content: "\01f57a\01f3ff"; +} + +.emoji-person-in-suit-levitating:before { + content: "\01f574\00fe0f"; +} + +.emoji-person-in-suit-levitating-light-skin-tone:before { + content: "\01f574\01f3fb"; +} + +.emoji-person-in-suit-levitating-medium-light-skin-tone:before { + content: "\01f574\01f3fc"; +} + +.emoji-person-in-suit-levitating-medium-skin-tone:before { + content: "\01f574\01f3fd"; +} + +.emoji-person-in-suit-levitating-medium-dark-skin-tone:before { + content: "\01f574\01f3fe"; +} + +.emoji-person-in-suit-levitating-dark-skin-tone:before { + content: "\01f574\01f3ff"; +} + +.emoji-people-with-bunny-ears:before { + content: "\01f46f"; +} + +.emoji-people-with-bunny-ears-light-skin-tone:before { + content: "\01f46f\01f3fb"; +} + +.emoji-people-with-bunny-ears-medium-light-skin-tone:before { + content: "\01f46f\01f3fc"; +} + +.emoji-people-with-bunny-ears-medium-skin-tone:before { + content: "\01f46f\01f3fd"; +} + +.emoji-people-with-bunny-ears-medium-dark-skin-tone:before { + content: "\01f46f\01f3fe"; +} + +.emoji-people-with-bunny-ears-dark-skin-tone:before { + content: "\01f46f\01f3ff"; +} + +.emoji-men-with-bunny-ears:before { + content: "\01f46f\00200d\002642\00fe0f"; +} + +.emoji-men-with-bunny-ears-light-skin-tone:before { + content: "\01f46f\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-men-with-bunny-ears-medium-light-skin-tone:before { + content: "\01f46f\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-men-with-bunny-ears-medium-skin-tone:before { + content: "\01f46f\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-men-with-bunny-ears-medium-dark-skin-tone:before { + content: "\01f46f\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-men-with-bunny-ears-dark-skin-tone:before { + content: "\01f46f\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-women-with-bunny-ears:before { + content: "\01f46f\00200d\002640\00fe0f"; +} + +.emoji-women-with-bunny-ears-light-skin-tone:before { + content: "\01f46f\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-women-with-bunny-ears-medium-light-skin-tone:before { + content: "\01f46f\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-women-with-bunny-ears-medium-skin-tone:before { + content: "\01f46f\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-women-with-bunny-ears-medium-dark-skin-tone:before { + content: "\01f46f\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-women-with-bunny-ears-dark-skin-tone:before { + content: "\01f46f\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-people-with-bunny-ears-light-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f430\00200d\01f9d1\01f3fc"; +} + +.emoji-people-with-bunny-ears-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f430\00200d\01f9d1\01f3fd"; +} + +.emoji-people-with-bunny-ears-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f430\00200d\01f9d1\01f3fe"; +} + +.emoji-people-with-bunny-ears-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f430\00200d\01f9d1\01f3ff"; +} + +.emoji-people-with-bunny-ears-medium-light-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f430\00200d\01f9d1\01f3fb"; +} + +.emoji-people-with-bunny-ears-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f430\00200d\01f9d1\01f3fd"; +} + +.emoji-people-with-bunny-ears-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f430\00200d\01f9d1\01f3fe"; +} + +.emoji-people-with-bunny-ears-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f430\00200d\01f9d1\01f3ff"; +} + +.emoji-people-with-bunny-ears-medium-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f430\00200d\01f9d1\01f3fb"; +} + +.emoji-people-with-bunny-ears-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f430\00200d\01f9d1\01f3fc"; +} + +.emoji-people-with-bunny-ears-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f430\00200d\01f9d1\01f3fe"; +} + +.emoji-people-with-bunny-ears-medium-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f430\00200d\01f9d1\01f3ff"; +} + +.emoji-people-with-bunny-ears-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f430\00200d\01f9d1\01f3fb"; +} + +.emoji-people-with-bunny-ears-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f430\00200d\01f9d1\01f3fc"; +} + +.emoji-people-with-bunny-ears-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f430\00200d\01f9d1\01f3fd"; +} + +.emoji-people-with-bunny-ears-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f430\00200d\01f9d1\01f3ff"; +} + +.emoji-people-with-bunny-ears-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f430\00200d\01f9d1\01f3fb"; +} + +.emoji-people-with-bunny-ears-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f430\00200d\01f9d1\01f3fc"; +} + +.emoji-people-with-bunny-ears-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f430\00200d\01f9d1\01f3fd"; +} + +.emoji-people-with-bunny-ears-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f430\00200d\01f9d1\01f3fe"; +} + +.emoji-men-with-bunny-ears-light-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f430\00200d\01f468\01f3fc"; +} + +.emoji-men-with-bunny-ears-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f430\00200d\01f468\01f3fd"; +} + +.emoji-men-with-bunny-ears-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f430\00200d\01f468\01f3fe"; +} + +.emoji-men-with-bunny-ears-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f430\00200d\01f468\01f3ff"; +} + +.emoji-men-with-bunny-ears-medium-light-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f430\00200d\01f468\01f3fb"; +} + +.emoji-men-with-bunny-ears-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f430\00200d\01f468\01f3fd"; +} + +.emoji-men-with-bunny-ears-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f430\00200d\01f468\01f3fe"; +} + +.emoji-men-with-bunny-ears-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f430\00200d\01f468\01f3ff"; +} + +.emoji-men-with-bunny-ears-medium-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f430\00200d\01f468\01f3fb"; +} + +.emoji-men-with-bunny-ears-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f430\00200d\01f468\01f3fc"; +} + +.emoji-men-with-bunny-ears-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f430\00200d\01f468\01f3fe"; +} + +.emoji-men-with-bunny-ears-medium-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f430\00200d\01f468\01f3ff"; +} + +.emoji-men-with-bunny-ears-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f430\00200d\01f468\01f3fb"; +} + +.emoji-men-with-bunny-ears-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f430\00200d\01f468\01f3fc"; +} + +.emoji-men-with-bunny-ears-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f430\00200d\01f468\01f3fd"; +} + +.emoji-men-with-bunny-ears-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f430\00200d\01f468\01f3ff"; +} + +.emoji-men-with-bunny-ears-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f430\00200d\01f468\01f3fb"; +} + +.emoji-men-with-bunny-ears-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f430\00200d\01f468\01f3fc"; +} + +.emoji-men-with-bunny-ears-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f430\00200d\01f468\01f3fd"; +} + +.emoji-men-with-bunny-ears-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f430\00200d\01f468\01f3fe"; +} + +.emoji-women-with-bunny-ears-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f430\00200d\01f469\01f3fc"; +} + +.emoji-women-with-bunny-ears-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f430\00200d\01f469\01f3fd"; +} + +.emoji-women-with-bunny-ears-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f430\00200d\01f469\01f3fe"; +} + +.emoji-women-with-bunny-ears-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f430\00200d\01f469\01f3ff"; +} + +.emoji-women-with-bunny-ears-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f430\00200d\01f469\01f3fb"; +} + +.emoji-women-with-bunny-ears-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f430\00200d\01f469\01f3fd"; +} + +.emoji-women-with-bunny-ears-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f430\00200d\01f469\01f3fe"; +} + +.emoji-women-with-bunny-ears-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f430\00200d\01f469\01f3ff"; +} + +.emoji-women-with-bunny-ears-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f430\00200d\01f469\01f3fb"; +} + +.emoji-women-with-bunny-ears-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f430\00200d\01f469\01f3fc"; +} + +.emoji-women-with-bunny-ears-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f430\00200d\01f469\01f3fe"; +} + +.emoji-women-with-bunny-ears-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f430\00200d\01f469\01f3ff"; +} + +.emoji-women-with-bunny-ears-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f430\00200d\01f469\01f3fb"; +} + +.emoji-women-with-bunny-ears-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f430\00200d\01f469\01f3fc"; +} + +.emoji-women-with-bunny-ears-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f430\00200d\01f469\01f3fd"; +} + +.emoji-women-with-bunny-ears-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f430\00200d\01f469\01f3ff"; +} + +.emoji-women-with-bunny-ears-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f430\00200d\01f469\01f3fb"; +} + +.emoji-women-with-bunny-ears-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f430\00200d\01f469\01f3fc"; +} + +.emoji-women-with-bunny-ears-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f430\00200d\01f469\01f3fd"; +} + +.emoji-women-with-bunny-ears-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f430\00200d\01f469\01f3fe"; +} + +.emoji-person-in-steamy-room:before { + content: "\01f9d6"; +} + +.emoji-person-in-steamy-room-light-skin-tone:before { + content: "\01f9d6\01f3fb"; +} + +.emoji-person-in-steamy-room-medium-light-skin-tone:before { + content: "\01f9d6\01f3fc"; +} + +.emoji-person-in-steamy-room-medium-skin-tone:before { + content: "\01f9d6\01f3fd"; +} + +.emoji-person-in-steamy-room-medium-dark-skin-tone:before { + content: "\01f9d6\01f3fe"; +} + +.emoji-person-in-steamy-room-dark-skin-tone:before { + content: "\01f9d6\01f3ff"; +} + +.emoji-man-in-steamy-room:before { + content: "\01f9d6\00200d\002642\00fe0f"; +} + +.emoji-man-in-steamy-room-light-skin-tone:before { + content: "\01f9d6\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-in-steamy-room-medium-light-skin-tone:before { + content: "\01f9d6\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-in-steamy-room-medium-skin-tone:before { + content: "\01f9d6\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-in-steamy-room-medium-dark-skin-tone:before { + content: "\01f9d6\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-in-steamy-room-dark-skin-tone:before { + content: "\01f9d6\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-in-steamy-room:before { + content: "\01f9d6\00200d\002640\00fe0f"; +} + +.emoji-woman-in-steamy-room-light-skin-tone:before { + content: "\01f9d6\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-in-steamy-room-medium-light-skin-tone:before { + content: "\01f9d6\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-in-steamy-room-medium-skin-tone:before { + content: "\01f9d6\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-in-steamy-room-medium-dark-skin-tone:before { + content: "\01f9d6\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-in-steamy-room-dark-skin-tone:before { + content: "\01f9d6\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-climbing:before { + content: "\01f9d7"; +} + +.emoji-person-climbing-light-skin-tone:before { + content: "\01f9d7\01f3fb"; +} + +.emoji-person-climbing-medium-light-skin-tone:before { + content: "\01f9d7\01f3fc"; +} + +.emoji-person-climbing-medium-skin-tone:before { + content: "\01f9d7\01f3fd"; +} + +.emoji-person-climbing-medium-dark-skin-tone:before { + content: "\01f9d7\01f3fe"; +} + +.emoji-person-climbing-dark-skin-tone:before { + content: "\01f9d7\01f3ff"; +} + +.emoji-man-climbing:before { + content: "\01f9d7\00200d\002642\00fe0f"; +} + +.emoji-man-climbing-light-skin-tone:before { + content: "\01f9d7\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-climbing-medium-light-skin-tone:before { + content: "\01f9d7\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-climbing-medium-skin-tone:before { + content: "\01f9d7\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-climbing-medium-dark-skin-tone:before { + content: "\01f9d7\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-climbing-dark-skin-tone:before { + content: "\01f9d7\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-climbing:before { + content: "\01f9d7\00200d\002640\00fe0f"; +} + +.emoji-woman-climbing-light-skin-tone:before { + content: "\01f9d7\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-climbing-medium-light-skin-tone:before { + content: "\01f9d7\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-climbing-medium-skin-tone:before { + content: "\01f9d7\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-climbing-medium-dark-skin-tone:before { + content: "\01f9d7\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-climbing-dark-skin-tone:before { + content: "\01f9d7\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-fencing:before { + content: "\01f93a"; +} + +.emoji-horse-racing:before { + content: "\01f3c7"; +} + +.emoji-horse-racing-light-skin-tone:before { + content: "\01f3c7\01f3fb"; +} + +.emoji-horse-racing-medium-light-skin-tone:before { + content: "\01f3c7\01f3fc"; +} + +.emoji-horse-racing-medium-skin-tone:before { + content: "\01f3c7\01f3fd"; +} + +.emoji-horse-racing-medium-dark-skin-tone:before { + content: "\01f3c7\01f3fe"; +} + +.emoji-horse-racing-dark-skin-tone:before { + content: "\01f3c7\01f3ff"; +} + +.emoji-skier:before { + content: "\0026f7\00fe0f"; +} + +.emoji-snowboarder:before { + content: "\01f3c2"; +} + +.emoji-snowboarder-light-skin-tone:before { + content: "\01f3c2\01f3fb"; +} + +.emoji-snowboarder-medium-light-skin-tone:before { + content: "\01f3c2\01f3fc"; +} + +.emoji-snowboarder-medium-skin-tone:before { + content: "\01f3c2\01f3fd"; +} + +.emoji-snowboarder-medium-dark-skin-tone:before { + content: "\01f3c2\01f3fe"; +} + +.emoji-snowboarder-dark-skin-tone:before { + content: "\01f3c2\01f3ff"; +} + +.emoji-person-golfing:before { + content: "\01f3cc\00fe0f"; +} + +.emoji-person-golfing-light-skin-tone:before { + content: "\01f3cc\01f3fb"; +} + +.emoji-person-golfing-medium-light-skin-tone:before { + content: "\01f3cc\01f3fc"; +} + +.emoji-person-golfing-medium-skin-tone:before { + content: "\01f3cc\01f3fd"; +} + +.emoji-person-golfing-medium-dark-skin-tone:before { + content: "\01f3cc\01f3fe"; +} + +.emoji-person-golfing-dark-skin-tone:before { + content: "\01f3cc\01f3ff"; +} + +.emoji-man-golfing:before { + content: "\01f3cc\00fe0f\00200d\002642\00fe0f"; +} + +.emoji-man-golfing-light-skin-tone:before { + content: "\01f3cc\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-golfing-medium-light-skin-tone:before { + content: "\01f3cc\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-golfing-medium-skin-tone:before { + content: "\01f3cc\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-golfing-medium-dark-skin-tone:before { + content: "\01f3cc\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-golfing-dark-skin-tone:before { + content: "\01f3cc\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-golfing:before { + content: "\01f3cc\00fe0f\00200d\002640\00fe0f"; +} + +.emoji-woman-golfing-light-skin-tone:before { + content: "\01f3cc\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-golfing-medium-light-skin-tone:before { + content: "\01f3cc\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-golfing-medium-skin-tone:before { + content: "\01f3cc\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-golfing-medium-dark-skin-tone:before { + content: "\01f3cc\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-golfing-dark-skin-tone:before { + content: "\01f3cc\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-surfing:before { + content: "\01f3c4"; +} + +.emoji-person-surfing-light-skin-tone:before { + content: "\01f3c4\01f3fb"; +} + +.emoji-person-surfing-medium-light-skin-tone:before { + content: "\01f3c4\01f3fc"; +} + +.emoji-person-surfing-medium-skin-tone:before { + content: "\01f3c4\01f3fd"; +} + +.emoji-person-surfing-medium-dark-skin-tone:before { + content: "\01f3c4\01f3fe"; +} + +.emoji-person-surfing-dark-skin-tone:before { + content: "\01f3c4\01f3ff"; +} + +.emoji-man-surfing:before { + content: "\01f3c4\00200d\002642\00fe0f"; +} + +.emoji-man-surfing-light-skin-tone:before { + content: "\01f3c4\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-surfing-medium-light-skin-tone:before { + content: "\01f3c4\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-surfing-medium-skin-tone:before { + content: "\01f3c4\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-surfing-medium-dark-skin-tone:before { + content: "\01f3c4\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-surfing-dark-skin-tone:before { + content: "\01f3c4\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-surfing:before { + content: "\01f3c4\00200d\002640\00fe0f"; +} + +.emoji-woman-surfing-light-skin-tone:before { + content: "\01f3c4\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-surfing-medium-light-skin-tone:before { + content: "\01f3c4\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-surfing-medium-skin-tone:before { + content: "\01f3c4\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-surfing-medium-dark-skin-tone:before { + content: "\01f3c4\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-surfing-dark-skin-tone:before { + content: "\01f3c4\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-rowing-boat:before { + content: "\01f6a3"; +} + +.emoji-person-rowing-boat-light-skin-tone:before { + content: "\01f6a3\01f3fb"; +} + +.emoji-person-rowing-boat-medium-light-skin-tone:before { + content: "\01f6a3\01f3fc"; +} + +.emoji-person-rowing-boat-medium-skin-tone:before { + content: "\01f6a3\01f3fd"; +} + +.emoji-person-rowing-boat-medium-dark-skin-tone:before { + content: "\01f6a3\01f3fe"; +} + +.emoji-person-rowing-boat-dark-skin-tone:before { + content: "\01f6a3\01f3ff"; +} + +.emoji-man-rowing-boat:before { + content: "\01f6a3\00200d\002642\00fe0f"; +} + +.emoji-man-rowing-boat-light-skin-tone:before { + content: "\01f6a3\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-rowing-boat-medium-light-skin-tone:before { + content: "\01f6a3\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-rowing-boat-medium-skin-tone:before { + content: "\01f6a3\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-rowing-boat-medium-dark-skin-tone:before { + content: "\01f6a3\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-rowing-boat-dark-skin-tone:before { + content: "\01f6a3\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-rowing-boat:before { + content: "\01f6a3\00200d\002640\00fe0f"; +} + +.emoji-woman-rowing-boat-light-skin-tone:before { + content: "\01f6a3\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-rowing-boat-medium-light-skin-tone:before { + content: "\01f6a3\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-rowing-boat-medium-skin-tone:before { + content: "\01f6a3\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-rowing-boat-medium-dark-skin-tone:before { + content: "\01f6a3\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-rowing-boat-dark-skin-tone:before { + content: "\01f6a3\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-swimming:before { + content: "\01f3ca"; +} + +.emoji-person-swimming-light-skin-tone:before { + content: "\01f3ca\01f3fb"; +} + +.emoji-person-swimming-medium-light-skin-tone:before { + content: "\01f3ca\01f3fc"; +} + +.emoji-person-swimming-medium-skin-tone:before { + content: "\01f3ca\01f3fd"; +} + +.emoji-person-swimming-medium-dark-skin-tone:before { + content: "\01f3ca\01f3fe"; +} + +.emoji-person-swimming-dark-skin-tone:before { + content: "\01f3ca\01f3ff"; +} + +.emoji-man-swimming:before { + content: "\01f3ca\00200d\002642\00fe0f"; +} + +.emoji-man-swimming-light-skin-tone:before { + content: "\01f3ca\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-swimming-medium-light-skin-tone:before { + content: "\01f3ca\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-swimming-medium-skin-tone:before { + content: "\01f3ca\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-swimming-medium-dark-skin-tone:before { + content: "\01f3ca\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-swimming-dark-skin-tone:before { + content: "\01f3ca\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-swimming:before { + content: "\01f3ca\00200d\002640\00fe0f"; +} + +.emoji-woman-swimming-light-skin-tone:before { + content: "\01f3ca\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-swimming-medium-light-skin-tone:before { + content: "\01f3ca\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-swimming-medium-skin-tone:before { + content: "\01f3ca\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-swimming-medium-dark-skin-tone:before { + content: "\01f3ca\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-swimming-dark-skin-tone:before { + content: "\01f3ca\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-bouncing-ball:before { + content: "\0026f9\00fe0f"; +} + +.emoji-person-bouncing-ball-light-skin-tone:before { + content: "\0026f9\01f3fb"; +} + +.emoji-person-bouncing-ball-medium-light-skin-tone:before { + content: "\0026f9\01f3fc"; +} + +.emoji-person-bouncing-ball-medium-skin-tone:before { + content: "\0026f9\01f3fd"; +} + +.emoji-person-bouncing-ball-medium-dark-skin-tone:before { + content: "\0026f9\01f3fe"; +} + +.emoji-person-bouncing-ball-dark-skin-tone:before { + content: "\0026f9\01f3ff"; +} + +.emoji-man-bouncing-ball:before { + content: "\0026f9\00fe0f\00200d\002642\00fe0f"; +} + +.emoji-man-bouncing-ball-light-skin-tone:before { + content: "\0026f9\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-bouncing-ball-medium-light-skin-tone:before { + content: "\0026f9\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-bouncing-ball-medium-skin-tone:before { + content: "\0026f9\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-bouncing-ball-medium-dark-skin-tone:before { + content: "\0026f9\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-bouncing-ball-dark-skin-tone:before { + content: "\0026f9\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-bouncing-ball:before { + content: "\0026f9\00fe0f\00200d\002640\00fe0f"; +} + +.emoji-woman-bouncing-ball-light-skin-tone:before { + content: "\0026f9\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-bouncing-ball-medium-light-skin-tone:before { + content: "\0026f9\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-bouncing-ball-medium-skin-tone:before { + content: "\0026f9\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-bouncing-ball-medium-dark-skin-tone:before { + content: "\0026f9\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-bouncing-ball-dark-skin-tone:before { + content: "\0026f9\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-lifting-weights:before { + content: "\01f3cb\00fe0f"; +} + +.emoji-person-lifting-weights-light-skin-tone:before { + content: "\01f3cb\01f3fb"; +} + +.emoji-person-lifting-weights-medium-light-skin-tone:before { + content: "\01f3cb\01f3fc"; +} + +.emoji-person-lifting-weights-medium-skin-tone:before { + content: "\01f3cb\01f3fd"; +} + +.emoji-person-lifting-weights-medium-dark-skin-tone:before { + content: "\01f3cb\01f3fe"; +} + +.emoji-person-lifting-weights-dark-skin-tone:before { + content: "\01f3cb\01f3ff"; +} + +.emoji-man-lifting-weights:before { + content: "\01f3cb\00fe0f\00200d\002642\00fe0f"; +} + +.emoji-man-lifting-weights-light-skin-tone:before { + content: "\01f3cb\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-lifting-weights-medium-light-skin-tone:before { + content: "\01f3cb\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-lifting-weights-medium-skin-tone:before { + content: "\01f3cb\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-lifting-weights-medium-dark-skin-tone:before { + content: "\01f3cb\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-lifting-weights-dark-skin-tone:before { + content: "\01f3cb\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-lifting-weights:before { + content: "\01f3cb\00fe0f\00200d\002640\00fe0f"; +} + +.emoji-woman-lifting-weights-light-skin-tone:before { + content: "\01f3cb\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-lifting-weights-medium-light-skin-tone:before { + content: "\01f3cb\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-lifting-weights-medium-skin-tone:before { + content: "\01f3cb\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-lifting-weights-medium-dark-skin-tone:before { + content: "\01f3cb\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-lifting-weights-dark-skin-tone:before { + content: "\01f3cb\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-biking:before { + content: "\01f6b4"; +} + +.emoji-person-biking-light-skin-tone:before { + content: "\01f6b4\01f3fb"; +} + +.emoji-person-biking-medium-light-skin-tone:before { + content: "\01f6b4\01f3fc"; +} + +.emoji-person-biking-medium-skin-tone:before { + content: "\01f6b4\01f3fd"; +} + +.emoji-person-biking-medium-dark-skin-tone:before { + content: "\01f6b4\01f3fe"; +} + +.emoji-person-biking-dark-skin-tone:before { + content: "\01f6b4\01f3ff"; +} + +.emoji-man-biking:before { + content: "\01f6b4\00200d\002642\00fe0f"; +} + +.emoji-man-biking-light-skin-tone:before { + content: "\01f6b4\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-biking-medium-light-skin-tone:before { + content: "\01f6b4\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-biking-medium-skin-tone:before { + content: "\01f6b4\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-biking-medium-dark-skin-tone:before { + content: "\01f6b4\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-biking-dark-skin-tone:before { + content: "\01f6b4\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-biking:before { + content: "\01f6b4\00200d\002640\00fe0f"; +} + +.emoji-woman-biking-light-skin-tone:before { + content: "\01f6b4\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-biking-medium-light-skin-tone:before { + content: "\01f6b4\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-biking-medium-skin-tone:before { + content: "\01f6b4\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-biking-medium-dark-skin-tone:before { + content: "\01f6b4\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-biking-dark-skin-tone:before { + content: "\01f6b4\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-mountain-biking:before { + content: "\01f6b5"; +} + +.emoji-person-mountain-biking-light-skin-tone:before { + content: "\01f6b5\01f3fb"; +} + +.emoji-person-mountain-biking-medium-light-skin-tone:before { + content: "\01f6b5\01f3fc"; +} + +.emoji-person-mountain-biking-medium-skin-tone:before { + content: "\01f6b5\01f3fd"; +} + +.emoji-person-mountain-biking-medium-dark-skin-tone:before { + content: "\01f6b5\01f3fe"; +} + +.emoji-person-mountain-biking-dark-skin-tone:before { + content: "\01f6b5\01f3ff"; +} + +.emoji-man-mountain-biking:before { + content: "\01f6b5\00200d\002642\00fe0f"; +} + +.emoji-man-mountain-biking-light-skin-tone:before { + content: "\01f6b5\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-mountain-biking-medium-light-skin-tone:before { + content: "\01f6b5\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-mountain-biking-medium-skin-tone:before { + content: "\01f6b5\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-mountain-biking-medium-dark-skin-tone:before { + content: "\01f6b5\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-mountain-biking-dark-skin-tone:before { + content: "\01f6b5\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-mountain-biking:before { + content: "\01f6b5\00200d\002640\00fe0f"; +} + +.emoji-woman-mountain-biking-light-skin-tone:before { + content: "\01f6b5\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-mountain-biking-medium-light-skin-tone:before { + content: "\01f6b5\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-mountain-biking-medium-skin-tone:before { + content: "\01f6b5\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-mountain-biking-medium-dark-skin-tone:before { + content: "\01f6b5\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-mountain-biking-dark-skin-tone:before { + content: "\01f6b5\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-cartwheeling:before { + content: "\01f938"; +} + +.emoji-person-cartwheeling-light-skin-tone:before { + content: "\01f938\01f3fb"; +} + +.emoji-person-cartwheeling-medium-light-skin-tone:before { + content: "\01f938\01f3fc"; +} + +.emoji-person-cartwheeling-medium-skin-tone:before { + content: "\01f938\01f3fd"; +} + +.emoji-person-cartwheeling-medium-dark-skin-tone:before { + content: "\01f938\01f3fe"; +} + +.emoji-person-cartwheeling-dark-skin-tone:before { + content: "\01f938\01f3ff"; +} + +.emoji-man-cartwheeling:before { + content: "\01f938\00200d\002642\00fe0f"; +} + +.emoji-man-cartwheeling-light-skin-tone:before { + content: "\01f938\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-cartwheeling-medium-light-skin-tone:before { + content: "\01f938\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-cartwheeling-medium-skin-tone:before { + content: "\01f938\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-cartwheeling-medium-dark-skin-tone:before { + content: "\01f938\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-cartwheeling-dark-skin-tone:before { + content: "\01f938\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-cartwheeling:before { + content: "\01f938\00200d\002640\00fe0f"; +} + +.emoji-woman-cartwheeling-light-skin-tone:before { + content: "\01f938\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-cartwheeling-medium-light-skin-tone:before { + content: "\01f938\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-cartwheeling-medium-skin-tone:before { + content: "\01f938\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-cartwheeling-medium-dark-skin-tone:before { + content: "\01f938\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-cartwheeling-dark-skin-tone:before { + content: "\01f938\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-people-wrestling:before { + content: "\01f93c"; +} + +.emoji-people-wrestling-light-skin-tone:before { + content: "\01f93c\01f3fb"; +} + +.emoji-people-wrestling-medium-light-skin-tone:before { + content: "\01f93c\01f3fc"; +} + +.emoji-people-wrestling-medium-skin-tone:before { + content: "\01f93c\01f3fd"; +} + +.emoji-people-wrestling-medium-dark-skin-tone:before { + content: "\01f93c\01f3fe"; +} + +.emoji-people-wrestling-dark-skin-tone:before { + content: "\01f93c\01f3ff"; +} + +.emoji-men-wrestling:before { + content: "\01f93c\00200d\002642\00fe0f"; +} + +.emoji-men-wrestling-light-skin-tone:before { + content: "\01f93c\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-men-wrestling-medium-light-skin-tone:before { + content: "\01f93c\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-men-wrestling-medium-skin-tone:before { + content: "\01f93c\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-men-wrestling-medium-dark-skin-tone:before { + content: "\01f93c\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-men-wrestling-dark-skin-tone:before { + content: "\01f93c\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-women-wrestling:before { + content: "\01f93c\00200d\002640\00fe0f"; +} + +.emoji-women-wrestling-light-skin-tone:before { + content: "\01f93c\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-women-wrestling-medium-light-skin-tone:before { + content: "\01f93c\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-women-wrestling-medium-skin-tone:before { + content: "\01f93c\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-women-wrestling-medium-dark-skin-tone:before { + content: "\01f93c\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-women-wrestling-dark-skin-tone:before { + content: "\01f93c\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-people-wrestling-light-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01faef\00200d\01f9d1\01f3fc"; +} + +.emoji-people-wrestling-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01faef\00200d\01f9d1\01f3fd"; +} + +.emoji-people-wrestling-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01faef\00200d\01f9d1\01f3fe"; +} + +.emoji-people-wrestling-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01faef\00200d\01f9d1\01f3ff"; +} + +.emoji-people-wrestling-medium-light-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01faef\00200d\01f9d1\01f3fb"; +} + +.emoji-people-wrestling-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01faef\00200d\01f9d1\01f3fd"; +} + +.emoji-people-wrestling-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01faef\00200d\01f9d1\01f3fe"; +} + +.emoji-people-wrestling-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01faef\00200d\01f9d1\01f3ff"; +} + +.emoji-people-wrestling-medium-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01faef\00200d\01f9d1\01f3fb"; +} + +.emoji-people-wrestling-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01faef\00200d\01f9d1\01f3fc"; +} + +.emoji-people-wrestling-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01faef\00200d\01f9d1\01f3fe"; +} + +.emoji-people-wrestling-medium-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01faef\00200d\01f9d1\01f3ff"; +} + +.emoji-people-wrestling-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01faef\00200d\01f9d1\01f3fb"; +} + +.emoji-people-wrestling-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01faef\00200d\01f9d1\01f3fc"; +} + +.emoji-people-wrestling-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01faef\00200d\01f9d1\01f3fd"; +} + +.emoji-people-wrestling-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01faef\00200d\01f9d1\01f3ff"; +} + +.emoji-people-wrestling-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01faef\00200d\01f9d1\01f3fb"; +} + +.emoji-people-wrestling-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01faef\00200d\01f9d1\01f3fc"; +} + +.emoji-people-wrestling-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01faef\00200d\01f9d1\01f3fd"; +} + +.emoji-people-wrestling-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01faef\00200d\01f9d1\01f3fe"; +} + +.emoji-men-wrestling-light-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01faef\00200d\01f468\01f3fc"; +} + +.emoji-men-wrestling-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fb\00200d\01faef\00200d\01f468\01f3fd"; +} + +.emoji-men-wrestling-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\01faef\00200d\01f468\01f3fe"; +} + +.emoji-men-wrestling-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\01faef\00200d\01f468\01f3ff"; +} + +.emoji-men-wrestling-medium-light-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01faef\00200d\01f468\01f3fb"; +} + +.emoji-men-wrestling-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fc\00200d\01faef\00200d\01f468\01f3fd"; +} + +.emoji-men-wrestling-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\01faef\00200d\01f468\01f3fe"; +} + +.emoji-men-wrestling-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\01faef\00200d\01f468\01f3ff"; +} + +.emoji-men-wrestling-medium-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\01faef\00200d\01f468\01f3fb"; +} + +.emoji-men-wrestling-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\01faef\00200d\01f468\01f3fc"; +} + +.emoji-men-wrestling-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\01faef\00200d\01f468\01f3fe"; +} + +.emoji-men-wrestling-medium-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\01faef\00200d\01f468\01f3ff"; +} + +.emoji-men-wrestling-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\01faef\00200d\01f468\01f3fb"; +} + +.emoji-men-wrestling-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\01faef\00200d\01f468\01f3fc"; +} + +.emoji-men-wrestling-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fe\00200d\01faef\00200d\01f468\01f3fd"; +} + +.emoji-men-wrestling-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01faef\00200d\01f468\01f3ff"; +} + +.emoji-men-wrestling-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\01faef\00200d\01f468\01f3fb"; +} + +.emoji-men-wrestling-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\01faef\00200d\01f468\01f3fc"; +} + +.emoji-men-wrestling-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3ff\00200d\01faef\00200d\01f468\01f3fd"; +} + +.emoji-men-wrestling-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01faef\00200d\01f468\01f3fe"; +} + +.emoji-women-wrestling-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01faef\00200d\01f469\01f3fc"; +} + +.emoji-women-wrestling-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\01faef\00200d\01f469\01f3fd"; +} + +.emoji-women-wrestling-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01faef\00200d\01f469\01f3fe"; +} + +.emoji-women-wrestling-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01faef\00200d\01f469\01f3ff"; +} + +.emoji-women-wrestling-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01faef\00200d\01f469\01f3fb"; +} + +.emoji-women-wrestling-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\01faef\00200d\01f469\01f3fd"; +} + +.emoji-women-wrestling-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01faef\00200d\01f469\01f3fe"; +} + +.emoji-women-wrestling-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01faef\00200d\01f469\01f3ff"; +} + +.emoji-women-wrestling-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01faef\00200d\01f469\01f3fb"; +} + +.emoji-women-wrestling-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01faef\00200d\01f469\01f3fc"; +} + +.emoji-women-wrestling-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01faef\00200d\01f469\01f3fe"; +} + +.emoji-women-wrestling-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01faef\00200d\01f469\01f3ff"; +} + +.emoji-women-wrestling-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01faef\00200d\01f469\01f3fb"; +} + +.emoji-women-wrestling-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01faef\00200d\01f469\01f3fc"; +} + +.emoji-women-wrestling-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\01faef\00200d\01f469\01f3fd"; +} + +.emoji-women-wrestling-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01faef\00200d\01f469\01f3ff"; +} + +.emoji-women-wrestling-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01faef\00200d\01f469\01f3fb"; +} + +.emoji-women-wrestling-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01faef\00200d\01f469\01f3fc"; +} + +.emoji-women-wrestling-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\01faef\00200d\01f469\01f3fd"; +} + +.emoji-women-wrestling-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01faef\00200d\01f469\01f3fe"; +} + +.emoji-person-playing-water-polo:before { + content: "\01f93d"; +} + +.emoji-person-playing-water-polo-light-skin-tone:before { + content: "\01f93d\01f3fb"; +} + +.emoji-person-playing-water-polo-medium-light-skin-tone:before { + content: "\01f93d\01f3fc"; +} + +.emoji-person-playing-water-polo-medium-skin-tone:before { + content: "\01f93d\01f3fd"; +} + +.emoji-person-playing-water-polo-medium-dark-skin-tone:before { + content: "\01f93d\01f3fe"; +} + +.emoji-person-playing-water-polo-dark-skin-tone:before { + content: "\01f93d\01f3ff"; +} + +.emoji-man-playing-water-polo:before { + content: "\01f93d\00200d\002642\00fe0f"; +} + +.emoji-man-playing-water-polo-light-skin-tone:before { + content: "\01f93d\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-playing-water-polo-medium-light-skin-tone:before { + content: "\01f93d\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-playing-water-polo-medium-skin-tone:before { + content: "\01f93d\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-playing-water-polo-medium-dark-skin-tone:before { + content: "\01f93d\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-playing-water-polo-dark-skin-tone:before { + content: "\01f93d\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-playing-water-polo:before { + content: "\01f93d\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-water-polo-light-skin-tone:before { + content: "\01f93d\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-water-polo-medium-light-skin-tone:before { + content: "\01f93d\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-water-polo-medium-skin-tone:before { + content: "\01f93d\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-water-polo-medium-dark-skin-tone:before { + content: "\01f93d\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-water-polo-dark-skin-tone:before { + content: "\01f93d\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-playing-handball:before { + content: "\01f93e"; +} + +.emoji-person-playing-handball-light-skin-tone:before { + content: "\01f93e\01f3fb"; +} + +.emoji-person-playing-handball-medium-light-skin-tone:before { + content: "\01f93e\01f3fc"; +} + +.emoji-person-playing-handball-medium-skin-tone:before { + content: "\01f93e\01f3fd"; +} + +.emoji-person-playing-handball-medium-dark-skin-tone:before { + content: "\01f93e\01f3fe"; +} + +.emoji-person-playing-handball-dark-skin-tone:before { + content: "\01f93e\01f3ff"; +} + +.emoji-man-playing-handball:before { + content: "\01f93e\00200d\002642\00fe0f"; +} + +.emoji-man-playing-handball-light-skin-tone:before { + content: "\01f93e\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-playing-handball-medium-light-skin-tone:before { + content: "\01f93e\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-playing-handball-medium-skin-tone:before { + content: "\01f93e\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-playing-handball-medium-dark-skin-tone:before { + content: "\01f93e\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-playing-handball-dark-skin-tone:before { + content: "\01f93e\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-playing-handball:before { + content: "\01f93e\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-handball-light-skin-tone:before { + content: "\01f93e\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-handball-medium-light-skin-tone:before { + content: "\01f93e\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-handball-medium-skin-tone:before { + content: "\01f93e\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-handball-medium-dark-skin-tone:before { + content: "\01f93e\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-playing-handball-dark-skin-tone:before { + content: "\01f93e\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-juggling:before { + content: "\01f939"; +} + +.emoji-person-juggling-light-skin-tone:before { + content: "\01f939\01f3fb"; +} + +.emoji-person-juggling-medium-light-skin-tone:before { + content: "\01f939\01f3fc"; +} + +.emoji-person-juggling-medium-skin-tone:before { + content: "\01f939\01f3fd"; +} + +.emoji-person-juggling-medium-dark-skin-tone:before { + content: "\01f939\01f3fe"; +} + +.emoji-person-juggling-dark-skin-tone:before { + content: "\01f939\01f3ff"; +} + +.emoji-man-juggling:before { + content: "\01f939\00200d\002642\00fe0f"; +} + +.emoji-man-juggling-light-skin-tone:before { + content: "\01f939\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-juggling-medium-light-skin-tone:before { + content: "\01f939\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-juggling-medium-skin-tone:before { + content: "\01f939\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-juggling-medium-dark-skin-tone:before { + content: "\01f939\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-juggling-dark-skin-tone:before { + content: "\01f939\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-juggling:before { + content: "\01f939\00200d\002640\00fe0f"; +} + +.emoji-woman-juggling-light-skin-tone:before { + content: "\01f939\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-juggling-medium-light-skin-tone:before { + content: "\01f939\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-juggling-medium-skin-tone:before { + content: "\01f939\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-juggling-medium-dark-skin-tone:before { + content: "\01f939\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-juggling-dark-skin-tone:before { + content: "\01f939\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-in-lotus-position:before { + content: "\01f9d8"; +} + +.emoji-person-in-lotus-position-light-skin-tone:before { + content: "\01f9d8\01f3fb"; +} + +.emoji-person-in-lotus-position-medium-light-skin-tone:before { + content: "\01f9d8\01f3fc"; +} + +.emoji-person-in-lotus-position-medium-skin-tone:before { + content: "\01f9d8\01f3fd"; +} + +.emoji-person-in-lotus-position-medium-dark-skin-tone:before { + content: "\01f9d8\01f3fe"; +} + +.emoji-person-in-lotus-position-dark-skin-tone:before { + content: "\01f9d8\01f3ff"; +} + +.emoji-man-in-lotus-position:before { + content: "\01f9d8\00200d\002642\00fe0f"; +} + +.emoji-man-in-lotus-position-light-skin-tone:before { + content: "\01f9d8\01f3fb\00200d\002642\00fe0f"; +} + +.emoji-man-in-lotus-position-medium-light-skin-tone:before { + content: "\01f9d8\01f3fc\00200d\002642\00fe0f"; +} + +.emoji-man-in-lotus-position-medium-skin-tone:before { + content: "\01f9d8\01f3fd\00200d\002642\00fe0f"; +} + +.emoji-man-in-lotus-position-medium-dark-skin-tone:before { + content: "\01f9d8\01f3fe\00200d\002642\00fe0f"; +} + +.emoji-man-in-lotus-position-dark-skin-tone:before { + content: "\01f9d8\01f3ff\00200d\002642\00fe0f"; +} + +.emoji-woman-in-lotus-position:before { + content: "\01f9d8\00200d\002640\00fe0f"; +} + +.emoji-woman-in-lotus-position-light-skin-tone:before { + content: "\01f9d8\01f3fb\00200d\002640\00fe0f"; +} + +.emoji-woman-in-lotus-position-medium-light-skin-tone:before { + content: "\01f9d8\01f3fc\00200d\002640\00fe0f"; +} + +.emoji-woman-in-lotus-position-medium-skin-tone:before { + content: "\01f9d8\01f3fd\00200d\002640\00fe0f"; +} + +.emoji-woman-in-lotus-position-medium-dark-skin-tone:before { + content: "\01f9d8\01f3fe\00200d\002640\00fe0f"; +} + +.emoji-woman-in-lotus-position-dark-skin-tone:before { + content: "\01f9d8\01f3ff\00200d\002640\00fe0f"; +} + +.emoji-person-taking-bath:before { + content: "\01f6c0"; +} + +.emoji-person-taking-bath-light-skin-tone:before { + content: "\01f6c0\01f3fb"; +} + +.emoji-person-taking-bath-medium-light-skin-tone:before { + content: "\01f6c0\01f3fc"; +} + +.emoji-person-taking-bath-medium-skin-tone:before { + content: "\01f6c0\01f3fd"; +} + +.emoji-person-taking-bath-medium-dark-skin-tone:before { + content: "\01f6c0\01f3fe"; +} + +.emoji-person-taking-bath-dark-skin-tone:before { + content: "\01f6c0\01f3ff"; +} + +.emoji-person-in-bed:before { + content: "\01f6cc"; +} + +.emoji-person-in-bed-light-skin-tone:before { + content: "\01f6cc\01f3fb"; +} + +.emoji-person-in-bed-medium-light-skin-tone:before { + content: "\01f6cc\01f3fc"; +} + +.emoji-person-in-bed-medium-skin-tone:before { + content: "\01f6cc\01f3fd"; +} + +.emoji-person-in-bed-medium-dark-skin-tone:before { + content: "\01f6cc\01f3fe"; +} + +.emoji-person-in-bed-dark-skin-tone:before { + content: "\01f6cc\01f3ff"; +} + +.emoji-people-holding-hands:before { + content: "\01f9d1\00200d\01f91d\00200d\01f9d1"; +} + +.emoji-people-holding-hands-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f91d\00200d\01f9d1\01f3fb"; +} + +.emoji-people-holding-hands-light-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f91d\00200d\01f9d1\01f3fc"; +} + +.emoji-people-holding-hands-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f91d\00200d\01f9d1\01f3fd"; +} + +.emoji-people-holding-hands-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f91d\00200d\01f9d1\01f3fe"; +} + +.emoji-people-holding-hands-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\01f91d\00200d\01f9d1\01f3ff"; +} + +.emoji-people-holding-hands-medium-light-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f91d\00200d\01f9d1\01f3fb"; +} + +.emoji-people-holding-hands-medium-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f91d\00200d\01f9d1\01f3fc"; +} + +.emoji-people-holding-hands-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f91d\00200d\01f9d1\01f3fd"; +} + +.emoji-people-holding-hands-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f91d\00200d\01f9d1\01f3fe"; +} + +.emoji-people-holding-hands-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\01f91d\00200d\01f9d1\01f3ff"; +} + +.emoji-people-holding-hands-medium-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f91d\00200d\01f9d1\01f3fb"; +} + +.emoji-people-holding-hands-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f91d\00200d\01f9d1\01f3fc"; +} + +.emoji-people-holding-hands-medium-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f91d\00200d\01f9d1\01f3fd"; +} + +.emoji-people-holding-hands-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f91d\00200d\01f9d1\01f3fe"; +} + +.emoji-people-holding-hands-medium-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\01f91d\00200d\01f9d1\01f3ff"; +} + +.emoji-people-holding-hands-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f91d\00200d\01f9d1\01f3fb"; +} + +.emoji-people-holding-hands-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f91d\00200d\01f9d1\01f3fc"; +} + +.emoji-people-holding-hands-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f91d\00200d\01f9d1\01f3fd"; +} + +.emoji-people-holding-hands-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f91d\00200d\01f9d1\01f3fe"; +} + +.emoji-people-holding-hands-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\01f91d\00200d\01f9d1\01f3ff"; +} + +.emoji-people-holding-hands-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f91d\00200d\01f9d1\01f3fb"; +} + +.emoji-people-holding-hands-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f91d\00200d\01f9d1\01f3fc"; +} + +.emoji-people-holding-hands-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f91d\00200d\01f9d1\01f3fd"; +} + +.emoji-people-holding-hands-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f91d\00200d\01f9d1\01f3fe"; +} + +.emoji-people-holding-hands-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\01f91d\00200d\01f9d1\01f3ff"; +} + +.emoji-women-holding-hands:before { + content: "\01f46d"; +} + +.emoji-women-holding-hands-light-skin-tone:before { + content: "\01f46d\01f3fb"; +} + +.emoji-women-holding-hands-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f469\01f3fc"; +} + +.emoji-women-holding-hands-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f469\01f3fd"; +} + +.emoji-women-holding-hands-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f469\01f3fe"; +} + +.emoji-women-holding-hands-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f469\01f3ff"; +} + +.emoji-women-holding-hands-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f469\01f3fb"; +} + +.emoji-women-holding-hands-medium-light-skin-tone:before { + content: "\01f46d\01f3fc"; +} + +.emoji-women-holding-hands-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f469\01f3fd"; +} + +.emoji-women-holding-hands-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f469\01f3fe"; +} + +.emoji-women-holding-hands-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f469\01f3ff"; +} + +.emoji-women-holding-hands-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f469\01f3fb"; +} + +.emoji-women-holding-hands-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f469\01f3fc"; +} + +.emoji-women-holding-hands-medium-skin-tone:before { + content: "\01f46d\01f3fd"; +} + +.emoji-women-holding-hands-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f469\01f3fe"; +} + +.emoji-women-holding-hands-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f469\01f3ff"; +} + +.emoji-women-holding-hands-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f469\01f3fb"; +} + +.emoji-women-holding-hands-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f469\01f3fc"; +} + +.emoji-women-holding-hands-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f469\01f3fd"; +} + +.emoji-women-holding-hands-medium-dark-skin-tone:before { + content: "\01f46d\01f3fe"; +} + +.emoji-women-holding-hands-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f469\01f3ff"; +} + +.emoji-women-holding-hands-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f469\01f3fb"; +} + +.emoji-women-holding-hands-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f469\01f3fc"; +} + +.emoji-women-holding-hands-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f469\01f3fd"; +} + +.emoji-women-holding-hands-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f469\01f3fe"; +} + +.emoji-women-holding-hands-dark-skin-tone:before { + content: "\01f46d\01f3ff"; +} + +.emoji-woman-and-man-holding-hands:before { + content: "\01f46b"; +} + +.emoji-woman-and-man-holding-hands-light-skin-tone:before { + content: "\01f46b\01f3fb"; +} + +.emoji-woman-and-man-holding-hands-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-woman-and-man-holding-hands-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-woman-and-man-holding-hands-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-woman-and-man-holding-hands-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-woman-and-man-holding-hands-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-woman-and-man-holding-hands-medium-light-skin-tone:before { + content: "\01f46b\01f3fc"; +} + +.emoji-woman-and-man-holding-hands-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-woman-and-man-holding-hands-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-woman-and-man-holding-hands-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-woman-and-man-holding-hands-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-woman-and-man-holding-hands-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-woman-and-man-holding-hands-medium-skin-tone:before { + content: "\01f46b\01f3fd"; +} + +.emoji-woman-and-man-holding-hands-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-woman-and-man-holding-hands-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-woman-and-man-holding-hands-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-woman-and-man-holding-hands-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-woman-and-man-holding-hands-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-woman-and-man-holding-hands-medium-dark-skin-tone:before { + content: "\01f46b\01f3fe"; +} + +.emoji-woman-and-man-holding-hands-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-woman-and-man-holding-hands-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-woman-and-man-holding-hands-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-woman-and-man-holding-hands-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-woman-and-man-holding-hands-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-woman-and-man-holding-hands-dark-skin-tone:before { + content: "\01f46b\01f3ff"; +} + +.emoji-men-holding-hands:before { + content: "\01f46c"; +} + +.emoji-men-holding-hands-light-skin-tone:before { + content: "\01f46c\01f3fb"; +} + +.emoji-men-holding-hands-light-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-men-holding-hands-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-men-holding-hands-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-men-holding-hands-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-men-holding-hands-medium-light-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-men-holding-hands-medium-light-skin-tone:before { + content: "\01f46c\01f3fc"; +} + +.emoji-men-holding-hands-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-men-holding-hands-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-men-holding-hands-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-men-holding-hands-medium-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-men-holding-hands-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-men-holding-hands-medium-skin-tone:before { + content: "\01f46c\01f3fd"; +} + +.emoji-men-holding-hands-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-men-holding-hands-medium-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-men-holding-hands-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-men-holding-hands-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-men-holding-hands-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-men-holding-hands-medium-dark-skin-tone:before { + content: "\01f46c\01f3fe"; +} + +.emoji-men-holding-hands-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\01f91d\00200d\01f468\01f3ff"; +} + +.emoji-men-holding-hands-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f91d\00200d\01f468\01f3fb"; +} + +.emoji-men-holding-hands-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f91d\00200d\01f468\01f3fc"; +} + +.emoji-men-holding-hands-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f91d\00200d\01f468\01f3fd"; +} + +.emoji-men-holding-hands-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\01f91d\00200d\01f468\01f3fe"; +} + +.emoji-men-holding-hands-dark-skin-tone:before { + content: "\01f46c\01f3ff"; +} + +.emoji-kiss:before { + content: "\01f48f"; +} + +.emoji-kiss-light-skin-tone:before { + content: "\01f48f\01f3fb"; +} + +.emoji-kiss-medium-light-skin-tone:before { + content: "\01f48f\01f3fc"; +} + +.emoji-kiss-medium-skin-tone:before { + content: "\01f48f\01f3fd"; +} + +.emoji-kiss-medium-dark-skin-tone:before { + content: "\01f48f\01f3fe"; +} + +.emoji-kiss-dark-skin-tone:before { + content: "\01f48f\01f3ff"; +} + +.emoji-kiss-person-person-light-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fc"; +} + +.emoji-kiss-person-person-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fd"; +} + +.emoji-kiss-person-person-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fe"; +} + +.emoji-kiss-person-person-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3ff"; +} + +.emoji-kiss-person-person-medium-light-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fb"; +} + +.emoji-kiss-person-person-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fd"; +} + +.emoji-kiss-person-person-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fe"; +} + +.emoji-kiss-person-person-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3ff"; +} + +.emoji-kiss-person-person-medium-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fb"; +} + +.emoji-kiss-person-person-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fc"; +} + +.emoji-kiss-person-person-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fe"; +} + +.emoji-kiss-person-person-medium-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3ff"; +} + +.emoji-kiss-person-person-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fb"; +} + +.emoji-kiss-person-person-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fc"; +} + +.emoji-kiss-person-person-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fd"; +} + +.emoji-kiss-person-person-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3ff"; +} + +.emoji-kiss-person-person-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fb"; +} + +.emoji-kiss-person-person-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fc"; +} + +.emoji-kiss-person-person-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fd"; +} + +.emoji-kiss-person-person-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f9d1\01f3fe"; +} + +.emoji-kiss-woman-man:before { + content: "\01f469\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468"; +} + +.emoji-kiss-woman-man-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-woman-man-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-woman-man-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-woman-man-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-woman-man-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-woman-man-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-woman-man-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-woman-man-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-woman-man-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-woman-man-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-woman-man-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-woman-man-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-woman-man-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-woman-man-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-woman-man-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-woman-man-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-woman-man-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-woman-man-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-woman-man-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-woman-man-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-woman-man-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-woman-man-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-woman-man-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-woman-man-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-woman-man-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-man-man:before { + content: "\01f468\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468"; +} + +.emoji-kiss-man-man-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-man-man-light-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-man-man-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-man-man-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-man-man-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-man-man-medium-light-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-man-man-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-man-man-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-man-man-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-man-man-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-man-man-medium-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-man-man-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-man-man-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-man-man-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-man-man-medium-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-man-man-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-man-man-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-man-man-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-man-man-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-man-man-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-man-man-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fb"; +} + +.emoji-kiss-man-man-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fc"; +} + +.emoji-kiss-man-man-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fd"; +} + +.emoji-kiss-man-man-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3fe"; +} + +.emoji-kiss-man-man-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f468\01f3ff"; +} + +.emoji-kiss-woman-woman:before { + content: "\01f469\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469"; +} + +.emoji-kiss-woman-woman-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fb"; +} + +.emoji-kiss-woman-woman-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fc"; +} + +.emoji-kiss-woman-woman-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fd"; +} + +.emoji-kiss-woman-woman-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fe"; +} + +.emoji-kiss-woman-woman-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3ff"; +} + +.emoji-kiss-woman-woman-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fb"; +} + +.emoji-kiss-woman-woman-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fc"; +} + +.emoji-kiss-woman-woman-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fd"; +} + +.emoji-kiss-woman-woman-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fe"; +} + +.emoji-kiss-woman-woman-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3ff"; +} + +.emoji-kiss-woman-woman-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fb"; +} + +.emoji-kiss-woman-woman-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fc"; +} + +.emoji-kiss-woman-woman-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fd"; +} + +.emoji-kiss-woman-woman-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fe"; +} + +.emoji-kiss-woman-woman-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3ff"; +} + +.emoji-kiss-woman-woman-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fb"; +} + +.emoji-kiss-woman-woman-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fc"; +} + +.emoji-kiss-woman-woman-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fd"; +} + +.emoji-kiss-woman-woman-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fe"; +} + +.emoji-kiss-woman-woman-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3ff"; +} + +.emoji-kiss-woman-woman-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fb"; +} + +.emoji-kiss-woman-woman-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fc"; +} + +.emoji-kiss-woman-woman-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fd"; +} + +.emoji-kiss-woman-woman-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3fe"; +} + +.emoji-kiss-woman-woman-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f48b\00200d\01f469\01f3ff"; +} + +.emoji-couple-with-heart:before { + content: "\01f491"; +} + +.emoji-couple-with-heart-light-skin-tone:before { + content: "\01f491\01f3fb"; +} + +.emoji-couple-with-heart-medium-light-skin-tone:before { + content: "\01f491\01f3fc"; +} + +.emoji-couple-with-heart-medium-skin-tone:before { + content: "\01f491\01f3fd"; +} + +.emoji-couple-with-heart-medium-dark-skin-tone:before { + content: "\01f491\01f3fe"; +} + +.emoji-couple-with-heart-dark-skin-tone:before { + content: "\01f491\01f3ff"; +} + +.emoji-couple-with-heart-person-person-light-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f9d1\01f3fc"; +} + +.emoji-couple-with-heart-person-person-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f9d1\01f3fd"; +} + +.emoji-couple-with-heart-person-person-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f9d1\01f3fe"; +} + +.emoji-couple-with-heart-person-person-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fb\00200d\002764\00fe0f\00200d\01f9d1\01f3ff"; +} + +.emoji-couple-with-heart-person-person-medium-light-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f9d1\01f3fb"; +} + +.emoji-couple-with-heart-person-person-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f9d1\01f3fd"; +} + +.emoji-couple-with-heart-person-person-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f9d1\01f3fe"; +} + +.emoji-couple-with-heart-person-person-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fc\00200d\002764\00fe0f\00200d\01f9d1\01f3ff"; +} + +.emoji-couple-with-heart-person-person-medium-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f9d1\01f3fb"; +} + +.emoji-couple-with-heart-person-person-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f9d1\01f3fc"; +} + +.emoji-couple-with-heart-person-person-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f9d1\01f3fe"; +} + +.emoji-couple-with-heart-person-person-medium-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fd\00200d\002764\00fe0f\00200d\01f9d1\01f3ff"; +} + +.emoji-couple-with-heart-person-person-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f9d1\01f3fb"; +} + +.emoji-couple-with-heart-person-person-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f9d1\01f3fc"; +} + +.emoji-couple-with-heart-person-person-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f9d1\01f3fd"; +} + +.emoji-couple-with-heart-person-person-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f9d1\01f3fe\00200d\002764\00fe0f\00200d\01f9d1\01f3ff"; +} + +.emoji-couple-with-heart-person-person-dark-skin-tone-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f9d1\01f3fb"; +} + +.emoji-couple-with-heart-person-person-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f9d1\01f3fc"; +} + +.emoji-couple-with-heart-person-person-dark-skin-tone-medium-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f9d1\01f3fd"; +} + +.emoji-couple-with-heart-person-person-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f9d1\01f3ff\00200d\002764\00fe0f\00200d\01f9d1\01f3fe"; +} + +.emoji-couple-with-heart-woman-man:before { + content: "\01f469\00200d\002764\00fe0f\00200d\01f468"; +} + +.emoji-couple-with-heart-woman-man-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-woman-man-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-woman-man-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-woman-man-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-woman-man-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-woman-man-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-woman-man-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-woman-man-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-woman-man-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-woman-man-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-woman-man-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-woman-man-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-woman-man-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-woman-man-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-woman-man-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-woman-man-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-woman-man-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-woman-man-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-woman-man-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-woman-man-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-woman-man-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-woman-man-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-woman-man-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-woman-man-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-woman-man-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-man-man:before { + content: "\01f468\00200d\002764\00fe0f\00200d\01f468"; +} + +.emoji-couple-with-heart-man-man-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-man-man-light-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-man-man-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-man-man-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-man-man-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fb\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-man-man-medium-light-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-man-man-medium-light-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-man-man-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-man-man-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-man-man-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fc\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-man-man-medium-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-man-man-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-man-man-medium-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-man-man-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-man-man-medium-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fd\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-man-man-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-man-man-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-man-man-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-man-man-medium-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-man-man-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f468\01f3fe\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-man-man-dark-skin-tone-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fb"; +} + +.emoji-couple-with-heart-man-man-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fc"; +} + +.emoji-couple-with-heart-man-man-dark-skin-tone-medium-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fd"; +} + +.emoji-couple-with-heart-man-man-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3fe"; +} + +.emoji-couple-with-heart-man-man-dark-skin-tone:before { + content: "\01f468\01f3ff\00200d\002764\00fe0f\00200d\01f468\01f3ff"; +} + +.emoji-couple-with-heart-woman-woman:before { + content: "\01f469\00200d\002764\00fe0f\00200d\01f469"; +} + +.emoji-couple-with-heart-woman-woman-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f469\01f3fb"; +} + +.emoji-couple-with-heart-woman-woman-light-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f469\01f3fc"; +} + +.emoji-couple-with-heart-woman-woman-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f469\01f3fd"; +} + +.emoji-couple-with-heart-woman-woman-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f469\01f3fe"; +} + +.emoji-couple-with-heart-woman-woman-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fb\00200d\002764\00fe0f\00200d\01f469\01f3ff"; +} + +.emoji-couple-with-heart-woman-woman-medium-light-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f469\01f3fb"; +} + +.emoji-couple-with-heart-woman-woman-medium-light-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f469\01f3fc"; +} + +.emoji-couple-with-heart-woman-woman-medium-light-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f469\01f3fd"; +} + +.emoji-couple-with-heart-woman-woman-medium-light-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f469\01f3fe"; +} + +.emoji-couple-with-heart-woman-woman-medium-light-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fc\00200d\002764\00fe0f\00200d\01f469\01f3ff"; +} + +.emoji-couple-with-heart-woman-woman-medium-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f469\01f3fb"; +} + +.emoji-couple-with-heart-woman-woman-medium-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f469\01f3fc"; +} + +.emoji-couple-with-heart-woman-woman-medium-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f469\01f3fd"; +} + +.emoji-couple-with-heart-woman-woman-medium-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f469\01f3fe"; +} + +.emoji-couple-with-heart-woman-woman-medium-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fd\00200d\002764\00fe0f\00200d\01f469\01f3ff"; +} + +.emoji-couple-with-heart-woman-woman-medium-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f469\01f3fb"; +} + +.emoji-couple-with-heart-woman-woman-medium-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f469\01f3fc"; +} + +.emoji-couple-with-heart-woman-woman-medium-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f469\01f3fd"; +} + +.emoji-couple-with-heart-woman-woman-medium-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f469\01f3fe"; +} + +.emoji-couple-with-heart-woman-woman-medium-dark-skin-tone-dark-skin-tone:before { + content: "\01f469\01f3fe\00200d\002764\00fe0f\00200d\01f469\01f3ff"; +} + +.emoji-couple-with-heart-woman-woman-dark-skin-tone-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f469\01f3fb"; +} + +.emoji-couple-with-heart-woman-woman-dark-skin-tone-medium-light-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f469\01f3fc"; +} + +.emoji-couple-with-heart-woman-woman-dark-skin-tone-medium-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f469\01f3fd"; +} + +.emoji-couple-with-heart-woman-woman-dark-skin-tone-medium-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f469\01f3fe"; +} + +.emoji-couple-with-heart-woman-woman-dark-skin-tone:before { + content: "\01f469\01f3ff\00200d\002764\00fe0f\00200d\01f469\01f3ff"; +} + +.emoji-family-man-woman-boy:before { + content: "\01f468\00200d\01f469\00200d\01f466"; +} + +.emoji-family-man-woman-girl:before { + content: "\01f468\00200d\01f469\00200d\01f467"; +} + +.emoji-family-man-woman-girl-boy:before { + content: "\01f468\00200d\01f469\00200d\01f467\00200d\01f466"; +} + +.emoji-family-man-woman-boy-boy:before { + content: "\01f468\00200d\01f469\00200d\01f466\00200d\01f466"; +} + +.emoji-family-man-woman-girl-girl:before { + content: "\01f468\00200d\01f469\00200d\01f467\00200d\01f467"; +} + +.emoji-family-man-man-boy:before { + content: "\01f468\00200d\01f468\00200d\01f466"; +} + +.emoji-family-man-man-girl:before { + content: "\01f468\00200d\01f468\00200d\01f467"; +} + +.emoji-family-man-man-girl-boy:before { + content: "\01f468\00200d\01f468\00200d\01f467\00200d\01f466"; +} + +.emoji-family-man-man-boy-boy:before { + content: "\01f468\00200d\01f468\00200d\01f466\00200d\01f466"; +} + +.emoji-family-man-man-girl-girl:before { + content: "\01f468\00200d\01f468\00200d\01f467\00200d\01f467"; +} + +.emoji-family-woman-woman-boy:before { + content: "\01f469\00200d\01f469\00200d\01f466"; +} + +.emoji-family-woman-woman-girl:before { + content: "\01f469\00200d\01f469\00200d\01f467"; +} + +.emoji-family-woman-woman-girl-boy:before { + content: "\01f469\00200d\01f469\00200d\01f467\00200d\01f466"; +} + +.emoji-family-woman-woman-boy-boy:before { + content: "\01f469\00200d\01f469\00200d\01f466\00200d\01f466"; +} + +.emoji-family-woman-woman-girl-girl:before { + content: "\01f469\00200d\01f469\00200d\01f467\00200d\01f467"; +} + +.emoji-family-man-boy:before { + content: "\01f468\00200d\01f466"; +} + +.emoji-family-man-boy-boy:before { + content: "\01f468\00200d\01f466\00200d\01f466"; +} + +.emoji-family-man-girl:before { + content: "\01f468\00200d\01f467"; +} + +.emoji-family-man-girl-boy:before { + content: "\01f468\00200d\01f467\00200d\01f466"; +} + +.emoji-family-man-girl-girl:before { + content: "\01f468\00200d\01f467\00200d\01f467"; +} + +.emoji-family-woman-boy:before { + content: "\01f469\00200d\01f466"; +} + +.emoji-family-woman-boy-boy:before { + content: "\01f469\00200d\01f466\00200d\01f466"; +} + +.emoji-family-woman-girl:before { + content: "\01f469\00200d\01f467"; +} + +.emoji-family-woman-girl-boy:before { + content: "\01f469\00200d\01f467\00200d\01f466"; +} + +.emoji-family-woman-girl-girl:before { + content: "\01f469\00200d\01f467\00200d\01f467"; +} + +.emoji-speaking-head:before { + content: "\01f5e3\00fe0f"; +} + +.emoji-bust-in-silhouette:before { + content: "\01f464"; +} + +.emoji-busts-in-silhouette:before { + content: "\01f465"; +} + +.emoji-people-hugging:before { + content: "\01fac2"; +} + +.emoji-family:before { + content: "\01f46a"; +} + +.emoji-family-adult-adult-child:before { + content: "\01f9d1\00200d\01f9d1\00200d\01f9d2"; +} + +.emoji-family-adult-adult-child-child:before { + content: "\01f9d1\00200d\01f9d1\00200d\01f9d2\00200d\01f9d2"; +} + +.emoji-family-adult-child:before { + content: "\01f9d1\00200d\01f9d2"; +} + +.emoji-family-adult-child-child:before { + content: "\01f9d1\00200d\01f9d2\00200d\01f9d2"; +} + +.emoji-footprints:before { + content: "\01f463"; +} + +.emoji-fingerprint:before { + content: "\01fac6"; +} + +.emoji-light-skin-tone:before { + content: "\01f3fb"; +} + +.emoji-medium-light-skin-tone:before { + content: "\01f3fc"; +} + +.emoji-medium-skin-tone:before { + content: "\01f3fd"; +} + +.emoji-medium-dark-skin-tone:before { + content: "\01f3fe"; +} + +.emoji-dark-skin-tone:before { + content: "\01f3ff"; +} + +.emoji-red-hair:before { + content: "\01f9b0"; +} + +.emoji-curly-hair:before { + content: "\01f9b1"; +} + +.emoji-white-hair:before { + content: "\01f9b3"; +} + +.emoji-bald:before { + content: "\01f9b2"; +} + +.emoji-monkey-face:before { + content: "\01f435"; +} + +.emoji-monkey:before { + content: "\01f412"; +} + +.emoji-gorilla:before { + content: "\01f98d"; +} + +.emoji-orangutan:before { + content: "\01f9a7"; +} + +.emoji-dog-face:before { + content: "\01f436"; +} + +.emoji-dog:before { + content: "\01f415"; +} + +.emoji-guide-dog:before { + content: "\01f9ae"; +} + +.emoji-service-dog:before { + content: "\01f415\00200d\01f9ba"; +} + +.emoji-poodle:before { + content: "\01f429"; +} + +.emoji-wolf:before { + content: "\01f43a"; +} + +.emoji-fox:before { + content: "\01f98a"; +} + +.emoji-raccoon:before { + content: "\01f99d"; +} + +.emoji-cat-face:before { + content: "\01f431"; +} + +.emoji-cat:before { + content: "\01f408"; +} + +.emoji-black-cat:before { + content: "\01f408\00200d\002b1b"; +} + +.emoji-lion:before { + content: "\01f981"; +} + +.emoji-tiger-face:before { + content: "\01f42f"; +} + +.emoji-tiger:before { + content: "\01f405"; +} + +.emoji-leopard:before { + content: "\01f406"; +} + +.emoji-horse-face:before { + content: "\01f434"; +} + +.emoji-moose:before { + content: "\01face"; +} + +.emoji-donkey:before { + content: "\01facf"; +} + +.emoji-horse:before { + content: "\01f40e"; +} + +.emoji-unicorn:before { + content: "\01f984"; +} + +.emoji-zebra:before { + content: "\01f993"; +} + +.emoji-deer:before { + content: "\01f98c"; +} + +.emoji-bison:before { + content: "\01f9ac"; +} + +.emoji-cow-face:before { + content: "\01f42e"; +} + +.emoji-ox:before { + content: "\01f402"; +} + +.emoji-water-buffalo:before { + content: "\01f403"; +} + +.emoji-cow:before { + content: "\01f404"; +} + +.emoji-pig-face:before { + content: "\01f437"; +} + +.emoji-pig:before { + content: "\01f416"; +} + +.emoji-boar:before { + content: "\01f417"; +} + +.emoji-pig-nose:before { + content: "\01f43d"; +} + +.emoji-ram:before { + content: "\01f40f"; +} + +.emoji-ewe:before { + content: "\01f411"; +} + +.emoji-goat:before { + content: "\01f410"; +} + +.emoji-camel:before { + content: "\01f42a"; +} + +.emoji-two-hump-camel:before { + content: "\01f42b"; +} + +.emoji-llama:before { + content: "\01f999"; +} + +.emoji-giraffe:before { + content: "\01f992"; +} + +.emoji-elephant:before { + content: "\01f418"; +} + +.emoji-mammoth:before { + content: "\01f9a3"; +} + +.emoji-rhinoceros:before { + content: "\01f98f"; +} + +.emoji-hippopotamus:before { + content: "\01f99b"; +} + +.emoji-mouse-face:before { + content: "\01f42d"; +} + +.emoji-mouse:before { + content: "\01f401"; +} + +.emoji-rat:before { + content: "\01f400"; +} + +.emoji-hamster:before { + content: "\01f439"; +} + +.emoji-rabbit-face:before { + content: "\01f430"; +} + +.emoji-rabbit:before { + content: "\01f407"; +} + +.emoji-chipmunk:before { + content: "\01f43f\00fe0f"; +} + +.emoji-beaver:before { + content: "\01f9ab"; +} + +.emoji-hedgehog:before { + content: "\01f994"; +} + +.emoji-bat:before { + content: "\01f987"; +} + +.emoji-bear:before { + content: "\01f43b"; +} + +.emoji-polar-bear:before { + content: "\01f43b\00200d\002744\00fe0f"; +} + +.emoji-koala:before { + content: "\01f428"; +} + +.emoji-panda:before { + content: "\01f43c"; +} + +.emoji-sloth:before { + content: "\01f9a5"; +} + +.emoji-otter:before { + content: "\01f9a6"; +} + +.emoji-skunk:before { + content: "\01f9a8"; +} + +.emoji-kangaroo:before { + content: "\01f998"; +} + +.emoji-badger:before { + content: "\01f9a1"; +} + +.emoji-paw-prints:before { + content: "\01f43e"; +} + +.emoji-turkey:before { + content: "\01f983"; +} + +.emoji-chicken:before { + content: "\01f414"; +} + +.emoji-rooster:before { + content: "\01f413"; +} + +.emoji-hatching-chick:before { + content: "\01f423"; +} + +.emoji-baby-chick:before { + content: "\01f424"; +} + +.emoji-front-facing-baby-chick:before { + content: "\01f425"; +} + +.emoji-bird:before { + content: "\01f426"; +} + +.emoji-penguin:before { + content: "\01f427"; +} + +.emoji-dove:before { + content: "\01f54a\00fe0f"; +} + +.emoji-eagle:before { + content: "\01f985"; +} + +.emoji-duck:before { + content: "\01f986"; +} + +.emoji-swan:before { + content: "\01f9a2"; +} + +.emoji-owl:before { + content: "\01f989"; +} + +.emoji-dodo:before { + content: "\01f9a4"; +} + +.emoji-feather:before { + content: "\01fab6"; +} + +.emoji-flamingo:before { + content: "\01f9a9"; +} + +.emoji-peacock:before { + content: "\01f99a"; +} + +.emoji-parrot:before { + content: "\01f99c"; +} + +.emoji-wing:before { + content: "\01fabd"; +} + +.emoji-black-bird:before { + content: "\01f426\00200d\002b1b"; +} + +.emoji-goose:before { + content: "\01fabf"; +} + +.emoji-phoenix:before { + content: "\01f426\00200d\01f525"; +} + +.emoji-frog:before { + content: "\01f438"; +} + +.emoji-crocodile:before { + content: "\01f40a"; +} + +.emoji-turtle:before { + content: "\01f422"; +} + +.emoji-lizard:before { + content: "\01f98e"; +} + +.emoji-snake:before { + content: "\01f40d"; +} + +.emoji-dragon-face:before { + content: "\01f432"; +} + +.emoji-dragon:before { + content: "\01f409"; +} + +.emoji-sauropod:before { + content: "\01f995"; +} + +.emoji-t-rex:before { + content: "\01f996"; +} + +.emoji-spouting-whale:before { + content: "\01f433"; +} + +.emoji-whale:before { + content: "\01f40b"; +} + +.emoji-dolphin:before { + content: "\01f42c"; +} + +.emoji-orca:before { + content: "\01facd"; +} + +.emoji-seal:before { + content: "\01f9ad"; +} + +.emoji-fish:before { + content: "\01f41f"; +} + +.emoji-tropical-fish:before { + content: "\01f420"; +} + +.emoji-blowfish:before { + content: "\01f421"; +} + +.emoji-shark:before { + content: "\01f988"; +} + +.emoji-octopus:before { + content: "\01f419"; +} + +.emoji-spiral-shell:before { + content: "\01f41a"; +} + +.emoji-coral:before { + content: "\01fab8"; +} + +.emoji-jellyfish:before { + content: "\01fabc"; +} + +.emoji-crab:before { + content: "\01f980"; +} + +.emoji-lobster:before { + content: "\01f99e"; +} + +.emoji-shrimp:before { + content: "\01f990"; +} + +.emoji-squid:before { + content: "\01f991"; +} + +.emoji-oyster:before { + content: "\01f9aa"; +} + +.emoji-snail:before { + content: "\01f40c"; +} + +.emoji-butterfly:before { + content: "\01f98b"; +} + +.emoji-bug:before { + content: "\01f41b"; +} + +.emoji-ant:before { + content: "\01f41c"; +} + +.emoji-honeybee:before { + content: "\01f41d"; +} + +.emoji-beetle:before { + content: "\01fab2"; +} + +.emoji-lady-beetle:before { + content: "\01f41e"; +} + +.emoji-cricket:before { + content: "\01f997"; +} + +.emoji-cockroach:before { + content: "\01fab3"; +} + +.emoji-spider:before { + content: "\01f577\00fe0f"; +} + +.emoji-spider-web:before { + content: "\01f578\00fe0f"; +} + +.emoji-scorpion:before { + content: "\01f982"; +} + +.emoji-mosquito:before { + content: "\01f99f"; +} + +.emoji-fly:before { + content: "\01fab0"; +} + +.emoji-worm:before { + content: "\01fab1"; +} + +.emoji-microbe:before { + content: "\01f9a0"; +} + +.emoji-bouquet:before { + content: "\01f490"; +} + +.emoji-cherry-blossom:before { + content: "\01f338"; +} + +.emoji-white-flower:before { + content: "\01f4ae"; +} + +.emoji-lotus:before { + content: "\01fab7"; +} + +.emoji-rosette:before { + content: "\01f3f5\00fe0f"; +} + +.emoji-rose:before { + content: "\01f339"; +} + +.emoji-wilted-flower:before { + content: "\01f940"; +} + +.emoji-hibiscus:before { + content: "\01f33a"; +} + +.emoji-sunflower:before { + content: "\01f33b"; +} + +.emoji-blossom:before { + content: "\01f33c"; +} + +.emoji-tulip:before { + content: "\01f337"; +} + +.emoji-hyacinth:before { + content: "\01fabb"; +} + +.emoji-seedling:before { + content: "\01f331"; +} + +.emoji-potted-plant:before { + content: "\01fab4"; +} + +.emoji-evergreen-tree:before { + content: "\01f332"; +} + +.emoji-deciduous-tree:before { + content: "\01f333"; +} + +.emoji-palm-tree:before { + content: "\01f334"; +} + +.emoji-cactus:before { + content: "\01f335"; +} + +.emoji-sheaf-of-rice:before { + content: "\01f33e"; +} + +.emoji-herb:before { + content: "\01f33f"; +} + +.emoji-shamrock:before { + content: "\002618\00fe0f"; +} + +.emoji-four-leaf-clover:before { + content: "\01f340"; +} + +.emoji-maple-leaf:before { + content: "\01f341"; +} + +.emoji-fallen-leaf:before { + content: "\01f342"; +} + +.emoji-leaf-fluttering-in-wind:before { + content: "\01f343"; +} + +.emoji-empty-nest:before { + content: "\01fab9"; +} + +.emoji-nest-with-eggs:before { + content: "\01faba"; +} + +.emoji-mushroom:before { + content: "\01f344"; +} + +.emoji-leafless-tree:before { + content: "\01fabe"; +} + +.emoji-grapes:before { + content: "\01f347"; +} + +.emoji-melon:before { + content: "\01f348"; +} + +.emoji-watermelon:before { + content: "\01f349"; +} + +.emoji-tangerine:before { + content: "\01f34a"; +} + +.emoji-lemon:before { + content: "\01f34b"; +} + +.emoji-lime:before { + content: "\01f34b\00200d\01f7e9"; +} + +.emoji-banana:before { + content: "\01f34c"; +} + +.emoji-pineapple:before { + content: "\01f34d"; +} + +.emoji-mango:before { + content: "\01f96d"; +} + +.emoji-red-apple:before { + content: "\01f34e"; +} + +.emoji-green-apple:before { + content: "\01f34f"; +} + +.emoji-pear:before { + content: "\01f350"; +} + +.emoji-peach:before { + content: "\01f351"; +} + +.emoji-cherries:before { + content: "\01f352"; +} + +.emoji-strawberry:before { + content: "\01f353"; +} + +.emoji-blueberries:before { + content: "\01fad0"; +} + +.emoji-kiwi-fruit:before { + content: "\01f95d"; +} + +.emoji-tomato:before { + content: "\01f345"; +} + +.emoji-olive:before { + content: "\01fad2"; +} + +.emoji-coconut:before { + content: "\01f965"; +} + +.emoji-avocado:before { + content: "\01f951"; +} + +.emoji-eggplant:before { + content: "\01f346"; +} + +.emoji-potato:before { + content: "\01f954"; +} + +.emoji-carrot:before { + content: "\01f955"; +} + +.emoji-ear-of-corn:before { + content: "\01f33d"; +} + +.emoji-hot-pepper:before { + content: "\01f336\00fe0f"; +} + +.emoji-bell-pepper:before { + content: "\01fad1"; +} + +.emoji-cucumber:before { + content: "\01f952"; +} + +.emoji-leafy-green:before { + content: "\01f96c"; +} + +.emoji-broccoli:before { + content: "\01f966"; +} + +.emoji-garlic:before { + content: "\01f9c4"; +} + +.emoji-onion:before { + content: "\01f9c5"; +} + +.emoji-peanuts:before { + content: "\01f95c"; +} + +.emoji-beans:before { + content: "\01fad8"; +} + +.emoji-chestnut:before { + content: "\01f330"; +} + +.emoji-ginger-root:before { + content: "\01fada"; +} + +.emoji-pea-pod:before { + content: "\01fadb"; +} + +.emoji-brown-mushroom:before { + content: "\01f344\00200d\01f7eb"; +} + +.emoji-root-vegetable:before { + content: "\01fadc"; +} + +.emoji-bread:before { + content: "\01f35e"; +} + +.emoji-croissant:before { + content: "\01f950"; +} + +.emoji-baguette-bread:before { + content: "\01f956"; +} + +.emoji-flatbread:before { + content: "\01fad3"; +} + +.emoji-pretzel:before { + content: "\01f968"; +} + +.emoji-bagel:before { + content: "\01f96f"; +} + +.emoji-pancakes:before { + content: "\01f95e"; +} + +.emoji-waffle:before { + content: "\01f9c7"; +} + +.emoji-cheese-wedge:before { + content: "\01f9c0"; +} + +.emoji-meat-on-bone:before { + content: "\01f356"; +} + +.emoji-poultry-leg:before { + content: "\01f357"; +} + +.emoji-cut-of-meat:before { + content: "\01f969"; +} + +.emoji-bacon:before { + content: "\01f953"; +} + +.emoji-hamburger:before { + content: "\01f354"; +} + +.emoji-french-fries:before { + content: "\01f35f"; +} + +.emoji-pizza:before { + content: "\01f355"; +} + +.emoji-hot-dog:before { + content: "\01f32d"; +} + +.emoji-sandwich:before { + content: "\01f96a"; +} + +.emoji-taco:before { + content: "\01f32e"; +} + +.emoji-burrito:before { + content: "\01f32f"; +} + +.emoji-tamale:before { + content: "\01fad4"; +} + +.emoji-stuffed-flatbread:before { + content: "\01f959"; +} + +.emoji-falafel:before { + content: "\01f9c6"; +} + +.emoji-egg:before { + content: "\01f95a"; +} + +.emoji-cooking:before { + content: "\01f373"; +} + +.emoji-shallow-pan-of-food:before { + content: "\01f958"; +} + +.emoji-pot-of-food:before { + content: "\01f372"; +} + +.emoji-fondue:before { + content: "\01fad5"; +} + +.emoji-bowl-with-spoon:before { + content: "\01f963"; +} + +.emoji-green-salad:before { + content: "\01f957"; +} + +.emoji-popcorn:before { + content: "\01f37f"; +} + +.emoji-butter:before { + content: "\01f9c8"; +} + +.emoji-salt:before { + content: "\01f9c2"; +} + +.emoji-canned-food:before { + content: "\01f96b"; +} + +.emoji-bento-box:before { + content: "\01f371"; +} + +.emoji-rice-cracker:before { + content: "\01f358"; +} + +.emoji-rice-ball:before { + content: "\01f359"; +} + +.emoji-cooked-rice:before { + content: "\01f35a"; +} + +.emoji-curry-rice:before { + content: "\01f35b"; +} + +.emoji-steaming-bowl:before { + content: "\01f35c"; +} + +.emoji-spaghetti:before { + content: "\01f35d"; +} + +.emoji-roasted-sweet-potato:before { + content: "\01f360"; +} + +.emoji-oden:before { + content: "\01f362"; +} + +.emoji-sushi:before { + content: "\01f363"; +} + +.emoji-fried-shrimp:before { + content: "\01f364"; +} + +.emoji-fish-cake-with-swirl:before { + content: "\01f365"; +} + +.emoji-moon-cake:before { + content: "\01f96e"; +} + +.emoji-dango:before { + content: "\01f361"; +} + +.emoji-dumpling:before { + content: "\01f95f"; +} + +.emoji-fortune-cookie:before { + content: "\01f960"; +} + +.emoji-takeout-box:before { + content: "\01f961"; +} + +.emoji-soft-ice-cream:before { + content: "\01f366"; +} + +.emoji-shaved-ice:before { + content: "\01f367"; +} + +.emoji-ice-cream:before { + content: "\01f368"; +} + +.emoji-doughnut:before { + content: "\01f369"; +} + +.emoji-cookie:before { + content: "\01f36a"; +} + +.emoji-birthday-cake:before { + content: "\01f382"; +} + +.emoji-shortcake:before { + content: "\01f370"; +} + +.emoji-cupcake:before { + content: "\01f9c1"; +} + +.emoji-pie:before { + content: "\01f967"; +} + +.emoji-chocolate-bar:before { + content: "\01f36b"; +} + +.emoji-candy:before { + content: "\01f36c"; +} + +.emoji-lollipop:before { + content: "\01f36d"; +} + +.emoji-custard:before { + content: "\01f36e"; +} + +.emoji-honey-pot:before { + content: "\01f36f"; +} + +.emoji-baby-bottle:before { + content: "\01f37c"; +} + +.emoji-glass-of-milk:before { + content: "\01f95b"; +} + +.emoji-hot-beverage:before { + content: "\002615"; +} + +.emoji-teapot:before { + content: "\01fad6"; +} + +.emoji-teacup-without-handle:before { + content: "\01f375"; +} + +.emoji-sake:before { + content: "\01f376"; +} + +.emoji-bottle-with-popping-cork:before { + content: "\01f37e"; +} + +.emoji-wine-glass:before { + content: "\01f377"; +} + +.emoji-cocktail-glass:before { + content: "\01f378"; +} + +.emoji-tropical-drink:before { + content: "\01f379"; +} + +.emoji-beer-mug:before { + content: "\01f37a"; +} + +.emoji-clinking-beer-mugs:before { + content: "\01f37b"; +} + +.emoji-clinking-glasses:before { + content: "\01f942"; +} + +.emoji-tumbler-glass:before { + content: "\01f943"; +} + +.emoji-pouring-liquid:before { + content: "\01fad7"; +} + +.emoji-cup-with-straw:before { + content: "\01f964"; +} + +.emoji-bubble-tea:before { + content: "\01f9cb"; +} + +.emoji-beverage-box:before { + content: "\01f9c3"; +} + +.emoji-mate:before { + content: "\01f9c9"; +} + +.emoji-ice:before { + content: "\01f9ca"; +} + +.emoji-chopsticks:before { + content: "\01f962"; +} + +.emoji-fork-and-knife-with-plate:before { + content: "\01f37d\00fe0f"; +} + +.emoji-fork-and-knife:before { + content: "\01f374"; +} + +.emoji-spoon:before { + content: "\01f944"; +} + +.emoji-kitchen-knife:before { + content: "\01f52a"; +} + +.emoji-jar:before { + content: "\01fad9"; +} + +.emoji-amphora:before { + content: "\01f3fa"; +} + +.emoji-globe-showing-europe-africa:before { + content: "\01f30d"; +} + +.emoji-globe-showing-americas:before { + content: "\01f30e"; +} + +.emoji-globe-showing-asia-australia:before { + content: "\01f30f"; +} + +.emoji-globe-with-meridians:before { + content: "\01f310"; +} + +.emoji-world-map:before { + content: "\01f5fa\00fe0f"; +} + +.emoji-map-of-japan:before { + content: "\01f5fe"; +} + +.emoji-compass:before { + content: "\01f9ed"; +} + +.emoji-snow-capped-mountain:before { + content: "\01f3d4\00fe0f"; +} + +.emoji-mountain:before { + content: "\0026f0\00fe0f"; +} + +.emoji-landslide:before { + content: "\01f6d8"; +} + +.emoji-volcano:before { + content: "\01f30b"; +} + +.emoji-mount-fuji:before { + content: "\01f5fb"; +} + +.emoji-camping:before { + content: "\01f3d5\00fe0f"; +} + +.emoji-beach-with-umbrella:before { + content: "\01f3d6\00fe0f"; +} + +.emoji-desert:before { + content: "\01f3dc\00fe0f"; +} + +.emoji-desert-island:before { + content: "\01f3dd\00fe0f"; +} + +.emoji-national-park:before { + content: "\01f3de\00fe0f"; +} + +.emoji-stadium:before { + content: "\01f3df\00fe0f"; +} + +.emoji-classical-building:before { + content: "\01f3db\00fe0f"; +} + +.emoji-building-construction:before { + content: "\01f3d7\00fe0f"; +} + +.emoji-brick:before { + content: "\01f9f1"; +} + +.emoji-rock:before { + content: "\01faa8"; +} + +.emoji-wood:before { + content: "\01fab5"; +} + +.emoji-hut:before { + content: "\01f6d6"; +} + +.emoji-houses:before { + content: "\01f3d8\00fe0f"; +} + +.emoji-derelict-house:before { + content: "\01f3da\00fe0f"; +} + +.emoji-house:before { + content: "\01f3e0"; +} + +.emoji-house-with-garden:before { + content: "\01f3e1"; +} + +.emoji-office-building:before { + content: "\01f3e2"; +} + +.emoji-japanese-post-office:before { + content: "\01f3e3"; +} + +.emoji-post-office:before { + content: "\01f3e4"; +} + +.emoji-hospital:before { + content: "\01f3e5"; +} + +.emoji-bank:before { + content: "\01f3e6"; +} + +.emoji-hotel:before { + content: "\01f3e8"; +} + +.emoji-love-hotel:before { + content: "\01f3e9"; +} + +.emoji-convenience-store:before { + content: "\01f3ea"; +} + +.emoji-school:before { + content: "\01f3eb"; +} + +.emoji-department-store:before { + content: "\01f3ec"; +} + +.emoji-factory:before { + content: "\01f3ed"; +} + +.emoji-japanese-castle:before { + content: "\01f3ef"; +} + +.emoji-castle:before { + content: "\01f3f0"; +} + +.emoji-wedding:before { + content: "\01f492"; +} + +.emoji-tokyo-tower:before { + content: "\01f5fc"; +} + +.emoji-statue-of-liberty:before { + content: "\01f5fd"; +} + +.emoji-church:before { + content: "\0026ea"; +} + +.emoji-mosque:before { + content: "\01f54c"; +} + +.emoji-hindu-temple:before { + content: "\01f6d5"; +} + +.emoji-synagogue:before { + content: "\01f54d"; +} + +.emoji-shinto-shrine:before { + content: "\0026e9\00fe0f"; +} + +.emoji-kaaba:before { + content: "\01f54b"; +} + +.emoji-fountain:before { + content: "\0026f2"; +} + +.emoji-tent:before { + content: "\0026fa"; +} + +.emoji-foggy:before { + content: "\01f301"; +} + +.emoji-night-with-stars:before { + content: "\01f303"; +} + +.emoji-cityscape:before { + content: "\01f3d9\00fe0f"; +} + +.emoji-sunrise-over-mountains:before { + content: "\01f304"; +} + +.emoji-sunrise:before { + content: "\01f305"; +} + +.emoji-cityscape-at-dusk:before { + content: "\01f306"; +} + +.emoji-sunset:before { + content: "\01f307"; +} + +.emoji-bridge-at-night:before { + content: "\01f309"; +} + +.emoji-hot-springs:before { + content: "\002668\00fe0f"; +} + +.emoji-carousel-horse:before { + content: "\01f3a0"; +} + +.emoji-playground-slide:before { + content: "\01f6dd"; +} + +.emoji-ferris-wheel:before { + content: "\01f3a1"; +} + +.emoji-roller-coaster:before { + content: "\01f3a2"; +} + +.emoji-barber-pole:before { + content: "\01f488"; +} + +.emoji-circus-tent:before { + content: "\01f3aa"; +} + +.emoji-locomotive:before { + content: "\01f682"; +} + +.emoji-railway-car:before { + content: "\01f683"; +} + +.emoji-high-speed-train:before { + content: "\01f684"; +} + +.emoji-bullet-train:before { + content: "\01f685"; +} + +.emoji-train:before { + content: "\01f686"; +} + +.emoji-metro:before { + content: "\01f687"; +} + +.emoji-light-rail:before { + content: "\01f688"; +} + +.emoji-station:before { + content: "\01f689"; +} + +.emoji-tram:before { + content: "\01f68a"; +} + +.emoji-monorail:before { + content: "\01f69d"; +} + +.emoji-mountain-railway:before { + content: "\01f69e"; +} + +.emoji-tram-car:before { + content: "\01f68b"; +} + +.emoji-bus:before { + content: "\01f68c"; +} + +.emoji-oncoming-bus:before { + content: "\01f68d"; +} + +.emoji-trolleybus:before { + content: "\01f68e"; +} + +.emoji-minibus:before { + content: "\01f690"; +} + +.emoji-ambulance:before { + content: "\01f691"; +} + +.emoji-fire-engine:before { + content: "\01f692"; +} + +.emoji-police-car:before { + content: "\01f693"; +} + +.emoji-oncoming-police-car:before { + content: "\01f694"; +} + +.emoji-taxi:before { + content: "\01f695"; +} + +.emoji-oncoming-taxi:before { + content: "\01f696"; +} + +.emoji-automobile:before { + content: "\01f697"; +} + +.emoji-oncoming-automobile:before { + content: "\01f698"; +} + +.emoji-sport-utility-vehicle:before { + content: "\01f699"; +} + +.emoji-pickup-truck:before { + content: "\01f6fb"; +} + +.emoji-delivery-truck:before { + content: "\01f69a"; +} + +.emoji-articulated-lorry:before { + content: "\01f69b"; +} + +.emoji-tractor:before { + content: "\01f69c"; +} + +.emoji-racing-car:before { + content: "\01f3ce\00fe0f"; +} + +.emoji-motorcycle:before { + content: "\01f3cd\00fe0f"; +} + +.emoji-motor-scooter:before { + content: "\01f6f5"; +} + +.emoji-manual-wheelchair:before { + content: "\01f9bd"; +} + +.emoji-motorized-wheelchair:before { + content: "\01f9bc"; +} + +.emoji-auto-rickshaw:before { + content: "\01f6fa"; +} + +.emoji-bicycle:before { + content: "\01f6b2"; +} + +.emoji-kick-scooter:before { + content: "\01f6f4"; +} + +.emoji-skateboard:before { + content: "\01f6f9"; +} + +.emoji-roller-skate:before { + content: "\01f6fc"; +} + +.emoji-bus-stop:before { + content: "\01f68f"; +} + +.emoji-motorway:before { + content: "\01f6e3\00fe0f"; +} + +.emoji-railway-track:before { + content: "\01f6e4\00fe0f"; +} + +.emoji-oil-drum:before { + content: "\01f6e2\00fe0f"; +} + +.emoji-fuel-pump:before { + content: "\0026fd"; +} + +.emoji-wheel:before { + content: "\01f6de"; +} + +.emoji-police-car-light:before { + content: "\01f6a8"; +} + +.emoji-horizontal-traffic-light:before { + content: "\01f6a5"; +} + +.emoji-vertical-traffic-light:before { + content: "\01f6a6"; +} + +.emoji-stop-sign:before { + content: "\01f6d1"; +} + +.emoji-construction:before { + content: "\01f6a7"; +} + +.emoji-anchor:before { + content: "\002693"; +} + +.emoji-ring-buoy:before { + content: "\01f6df"; +} + +.emoji-sailboat:before { + content: "\0026f5"; +} + +.emoji-canoe:before { + content: "\01f6f6"; +} + +.emoji-speedboat:before { + content: "\01f6a4"; +} + +.emoji-passenger-ship:before { + content: "\01f6f3\00fe0f"; +} + +.emoji-ferry:before { + content: "\0026f4\00fe0f"; +} + +.emoji-motor-boat:before { + content: "\01f6e5\00fe0f"; +} + +.emoji-ship:before { + content: "\01f6a2"; +} + +.emoji-airplane:before { + content: "\002708\00fe0f"; +} + +.emoji-small-airplane:before { + content: "\01f6e9\00fe0f"; +} + +.emoji-airplane-departure:before { + content: "\01f6eb"; +} + +.emoji-airplane-arrival:before { + content: "\01f6ec"; +} + +.emoji-parachute:before { + content: "\01fa82"; +} + +.emoji-seat:before { + content: "\01f4ba"; +} + +.emoji-helicopter:before { + content: "\01f681"; +} + +.emoji-suspension-railway:before { + content: "\01f69f"; +} + +.emoji-mountain-cableway:before { + content: "\01f6a0"; +} + +.emoji-aerial-tramway:before { + content: "\01f6a1"; +} + +.emoji-satellite:before { + content: "\01f6f0\00fe0f"; +} + +.emoji-rocket:before { + content: "\01f680"; +} + +.emoji-flying-saucer:before { + content: "\01f6f8"; +} + +.emoji-bellhop-bell:before { + content: "\01f6ce\00fe0f"; +} + +.emoji-luggage:before { + content: "\01f9f3"; +} + +.emoji-hourglass-done:before { + content: "\00231b"; +} + +.emoji-hourglass-not-done:before { + content: "\0023f3"; +} + +.emoji-watch:before { + content: "\00231a"; +} + +.emoji-alarm-clock:before { + content: "\0023f0"; +} + +.emoji-stopwatch:before { + content: "\0023f1\00fe0f"; +} + +.emoji-timer-clock:before { + content: "\0023f2\00fe0f"; +} + +.emoji-mantelpiece-clock:before { + content: "\01f570\00fe0f"; +} + +.emoji-twelve-oclock:before { + content: "\01f55b"; +} + +.emoji-twelve-thirty:before { + content: "\01f567"; +} + +.emoji-one-oclock:before { + content: "\01f550"; +} + +.emoji-one-thirty:before { + content: "\01f55c"; +} + +.emoji-two-oclock:before { + content: "\01f551"; +} + +.emoji-two-thirty:before { + content: "\01f55d"; +} + +.emoji-three-oclock:before { + content: "\01f552"; +} + +.emoji-three-thirty:before { + content: "\01f55e"; +} + +.emoji-four-oclock:before { + content: "\01f553"; +} + +.emoji-four-thirty:before { + content: "\01f55f"; +} + +.emoji-five-oclock:before { + content: "\01f554"; +} + +.emoji-five-thirty:before { + content: "\01f560"; +} + +.emoji-six-oclock:before { + content: "\01f555"; +} + +.emoji-six-thirty:before { + content: "\01f561"; +} + +.emoji-seven-oclock:before { + content: "\01f556"; +} + +.emoji-seven-thirty:before { + content: "\01f562"; +} + +.emoji-eight-oclock:before { + content: "\01f557"; +} + +.emoji-eight-thirty:before { + content: "\01f563"; +} + +.emoji-nine-oclock:before { + content: "\01f558"; +} + +.emoji-nine-thirty:before { + content: "\01f564"; +} + +.emoji-ten-oclock:before { + content: "\01f559"; +} + +.emoji-ten-thirty:before { + content: "\01f565"; +} + +.emoji-eleven-oclock:before { + content: "\01f55a"; +} + +.emoji-eleven-thirty:before { + content: "\01f566"; +} + +.emoji-new-moon:before { + content: "\01f311"; +} + +.emoji-waxing-crescent-moon:before { + content: "\01f312"; +} + +.emoji-first-quarter-moon:before { + content: "\01f313"; +} + +.emoji-waxing-gibbous-moon:before { + content: "\01f314"; +} + +.emoji-full-moon:before { + content: "\01f315"; +} + +.emoji-waning-gibbous-moon:before { + content: "\01f316"; +} + +.emoji-last-quarter-moon:before { + content: "\01f317"; +} + +.emoji-waning-crescent-moon:before { + content: "\01f318"; +} + +.emoji-crescent-moon:before { + content: "\01f319"; +} + +.emoji-new-moon-face:before { + content: "\01f31a"; +} + +.emoji-first-quarter-moon-face:before { + content: "\01f31b"; +} + +.emoji-last-quarter-moon-face:before { + content: "\01f31c"; +} + +.emoji-thermometer:before { + content: "\01f321\00fe0f"; +} + +.emoji-sun:before { + content: "\002600\00fe0f"; +} + +.emoji-full-moon-face:before { + content: "\01f31d"; +} + +.emoji-sun-with-face:before { + content: "\01f31e"; +} + +.emoji-ringed-planet:before { + content: "\01fa90"; +} + +.emoji-star:before { + content: "\002b50"; +} + +.emoji-glowing-star:before { + content: "\01f31f"; +} + +.emoji-shooting-star:before { + content: "\01f320"; +} + +.emoji-milky-way:before { + content: "\01f30c"; +} + +.emoji-cloud:before { + content: "\002601\00fe0f"; +} + +.emoji-sun-behind-cloud:before { + content: "\0026c5"; +} + +.emoji-cloud-with-lightning-and-rain:before { + content: "\0026c8\00fe0f"; +} + +.emoji-sun-behind-small-cloud:before { + content: "\01f324\00fe0f"; +} + +.emoji-sun-behind-large-cloud:before { + content: "\01f325\00fe0f"; +} + +.emoji-sun-behind-rain-cloud:before { + content: "\01f326\00fe0f"; +} + +.emoji-cloud-with-rain:before { + content: "\01f327\00fe0f"; +} + +.emoji-cloud-with-snow:before { + content: "\01f328\00fe0f"; +} + +.emoji-cloud-with-lightning:before { + content: "\01f329\00fe0f"; +} + +.emoji-tornado:before { + content: "\01f32a\00fe0f"; +} + +.emoji-fog:before { + content: "\01f32b\00fe0f"; +} + +.emoji-wind-face:before { + content: "\01f32c\00fe0f"; +} + +.emoji-cyclone:before { + content: "\01f300"; +} + +.emoji-rainbow:before { + content: "\01f308"; +} + +.emoji-closed-umbrella:before { + content: "\01f302"; +} + +.emoji-umbrella:before { + content: "\002602\00fe0f"; +} + +.emoji-umbrella-with-rain-drops:before { + content: "\002614"; +} + +.emoji-umbrella-on-ground:before { + content: "\0026f1\00fe0f"; +} + +.emoji-high-voltage:before { + content: "\0026a1"; +} + +.emoji-snowflake:before { + content: "\002744\00fe0f"; +} + +.emoji-snowman:before { + content: "\002603\00fe0f"; +} + +.emoji-snowman-without-snow:before { + content: "\0026c4"; +} + +.emoji-comet:before { + content: "\002604\00fe0f"; +} + +.emoji-fire:before { + content: "\01f525"; +} + +.emoji-droplet:before { + content: "\01f4a7"; +} + +.emoji-water-wave:before { + content: "\01f30a"; +} + +.emoji-jack-o-lantern:before { + content: "\01f383"; +} + +.emoji-christmas-tree:before { + content: "\01f384"; +} + +.emoji-fireworks:before { + content: "\01f386"; +} + +.emoji-sparkler:before { + content: "\01f387"; +} + +.emoji-firecracker:before { + content: "\01f9e8"; +} + +.emoji-sparkles:before { + content: "\002728"; +} + +.emoji-balloon:before { + content: "\01f388"; +} + +.emoji-party-popper:before { + content: "\01f389"; +} + +.emoji-confetti-ball:before { + content: "\01f38a"; +} + +.emoji-tanabata-tree:before { + content: "\01f38b"; +} + +.emoji-pine-decoration:before { + content: "\01f38d"; +} + +.emoji-japanese-dolls:before { + content: "\01f38e"; +} + +.emoji-carp-streamer:before { + content: "\01f38f"; +} + +.emoji-wind-chime:before { + content: "\01f390"; +} + +.emoji-moon-viewing-ceremony:before { + content: "\01f391"; +} + +.emoji-red-envelope:before { + content: "\01f9e7"; +} + +.emoji-ribbon:before { + content: "\01f380"; +} + +.emoji-wrapped-gift:before { + content: "\01f381"; +} + +.emoji-reminder-ribbon:before { + content: "\01f397\00fe0f"; +} + +.emoji-admission-tickets:before { + content: "\01f39f\00fe0f"; +} + +.emoji-ticket:before { + content: "\01f3ab"; +} + +.emoji-military-medal:before { + content: "\01f396\00fe0f"; +} + +.emoji-trophy:before { + content: "\01f3c6"; +} + +.emoji-sports-medal:before { + content: "\01f3c5"; +} + +.emoji-1st-place-medal:before { + content: "\01f947"; +} + +.emoji-2nd-place-medal:before { + content: "\01f948"; +} + +.emoji-3rd-place-medal:before { + content: "\01f949"; +} + +.emoji-soccer-ball:before { + content: "\0026bd"; +} + +.emoji-baseball:before { + content: "\0026be"; +} + +.emoji-softball:before { + content: "\01f94e"; +} + +.emoji-basketball:before { + content: "\01f3c0"; +} + +.emoji-volleyball:before { + content: "\01f3d0"; +} + +.emoji-american-football:before { + content: "\01f3c8"; +} + +.emoji-rugby-football:before { + content: "\01f3c9"; +} + +.emoji-tennis:before { + content: "\01f3be"; +} + +.emoji-flying-disc:before { + content: "\01f94f"; +} + +.emoji-bowling:before { + content: "\01f3b3"; +} + +.emoji-cricket-game:before { + content: "\01f3cf"; +} + +.emoji-field-hockey:before { + content: "\01f3d1"; +} + +.emoji-ice-hockey:before { + content: "\01f3d2"; +} + +.emoji-lacrosse:before { + content: "\01f94d"; +} + +.emoji-ping-pong:before { + content: "\01f3d3"; +} + +.emoji-badminton:before { + content: "\01f3f8"; +} + +.emoji-boxing-glove:before { + content: "\01f94a"; +} + +.emoji-martial-arts-uniform:before { + content: "\01f94b"; +} + +.emoji-goal-net:before { + content: "\01f945"; +} + +.emoji-flag-in-hole:before { + content: "\0026f3"; +} + +.emoji-ice-skate:before { + content: "\0026f8\00fe0f"; +} + +.emoji-fishing-pole:before { + content: "\01f3a3"; +} + +.emoji-diving-mask:before { + content: "\01f93f"; +} + +.emoji-running-shirt:before { + content: "\01f3bd"; +} + +.emoji-skis:before { + content: "\01f3bf"; +} + +.emoji-sled:before { + content: "\01f6f7"; +} + +.emoji-curling-stone:before { + content: "\01f94c"; +} + +.emoji-bullseye:before { + content: "\01f3af"; +} + +.emoji-yo-yo:before { + content: "\01fa80"; +} + +.emoji-kite:before { + content: "\01fa81"; +} + +.emoji-water-pistol:before { + content: "\01f52b"; +} + +.emoji-pool-8-ball:before { + content: "\01f3b1"; +} + +.emoji-crystal-ball:before { + content: "\01f52e"; +} + +.emoji-magic-wand:before { + content: "\01fa84"; +} + +.emoji-video-game:before { + content: "\01f3ae"; +} + +.emoji-joystick:before { + content: "\01f579\00fe0f"; +} + +.emoji-slot-machine:before { + content: "\01f3b0"; +} + +.emoji-game-die:before { + content: "\01f3b2"; +} + +.emoji-puzzle-piece:before { + content: "\01f9e9"; +} + +.emoji-teddy-bear:before { + content: "\01f9f8"; +} + +.emoji-pinata:before { + content: "\01fa85"; +} + +.emoji-mirror-ball:before { + content: "\01faa9"; +} + +.emoji-nesting-dolls:before { + content: "\01fa86"; +} + +.emoji-spade-suit:before { + content: "\002660\00fe0f"; +} + +.emoji-heart-suit:before { + content: "\002665\00fe0f"; +} + +.emoji-diamond-suit:before { + content: "\002666\00fe0f"; +} + +.emoji-club-suit:before { + content: "\002663\00fe0f"; +} + +.emoji-chess-pawn:before { + content: "\00265f\00fe0f"; +} + +.emoji-joker:before { + content: "\01f0cf"; +} + +.emoji-mahjong-red-dragon:before { + content: "\01f004"; +} + +.emoji-flower-playing-cards:before { + content: "\01f3b4"; +} + +.emoji-performing-arts:before { + content: "\01f3ad"; +} + +.emoji-framed-picture:before { + content: "\01f5bc\00fe0f"; +} + +.emoji-artist-palette:before { + content: "\01f3a8"; +} + +.emoji-thread:before { + content: "\01f9f5"; +} + +.emoji-sewing-needle:before { + content: "\01faa1"; +} + +.emoji-yarn:before { + content: "\01f9f6"; +} + +.emoji-knot:before { + content: "\01faa2"; +} + +.emoji-glasses:before { + content: "\01f453"; +} + +.emoji-sunglasses:before { + content: "\01f576\00fe0f"; +} + +.emoji-goggles:before { + content: "\01f97d"; +} + +.emoji-lab-coat:before { + content: "\01f97c"; +} + +.emoji-safety-vest:before { + content: "\01f9ba"; +} + +.emoji-necktie:before { + content: "\01f454"; +} + +.emoji-t-shirt:before { + content: "\01f455"; +} + +.emoji-jeans:before { + content: "\01f456"; +} + +.emoji-scarf:before { + content: "\01f9e3"; +} + +.emoji-gloves:before { + content: "\01f9e4"; +} + +.emoji-coat:before { + content: "\01f9e5"; +} + +.emoji-socks:before { + content: "\01f9e6"; +} + +.emoji-dress:before { + content: "\01f457"; +} + +.emoji-kimono:before { + content: "\01f458"; +} + +.emoji-sari:before { + content: "\01f97b"; +} + +.emoji-one-piece-swimsuit:before { + content: "\01fa71"; +} + +.emoji-briefs:before { + content: "\01fa72"; +} + +.emoji-shorts:before { + content: "\01fa73"; +} + +.emoji-bikini:before { + content: "\01f459"; +} + +.emoji-womans-clothes:before { + content: "\01f45a"; +} + +.emoji-folding-hand-fan:before { + content: "\01faad"; +} + +.emoji-purse:before { + content: "\01f45b"; +} + +.emoji-handbag:before { + content: "\01f45c"; +} + +.emoji-clutch-bag:before { + content: "\01f45d"; +} + +.emoji-shopping-bags:before { + content: "\01f6cd\00fe0f"; +} + +.emoji-backpack:before { + content: "\01f392"; +} + +.emoji-thong-sandal:before { + content: "\01fa74"; +} + +.emoji-mans-shoe:before { + content: "\01f45e"; +} + +.emoji-running-shoe:before { + content: "\01f45f"; +} + +.emoji-hiking-boot:before { + content: "\01f97e"; +} + +.emoji-flat-shoe:before { + content: "\01f97f"; +} + +.emoji-high-heeled-shoe:before { + content: "\01f460"; +} + +.emoji-womans-sandal:before { + content: "\01f461"; +} + +.emoji-ballet-shoes:before { + content: "\01fa70"; +} + +.emoji-womans-boot:before { + content: "\01f462"; +} + +.emoji-hair-pick:before { + content: "\01faae"; +} + +.emoji-crown:before { + content: "\01f451"; +} + +.emoji-womans-hat:before { + content: "\01f452"; +} + +.emoji-top-hat:before { + content: "\01f3a9"; +} + +.emoji-graduation-cap:before { + content: "\01f393"; +} + +.emoji-billed-cap:before { + content: "\01f9e2"; +} + +.emoji-military-helmet:before { + content: "\01fa96"; +} + +.emoji-rescue-workers-helmet:before { + content: "\0026d1\00fe0f"; +} + +.emoji-prayer-beads:before { + content: "\01f4ff"; +} + +.emoji-lipstick:before { + content: "\01f484"; +} + +.emoji-ring:before { + content: "\01f48d"; +} + +.emoji-gem-stone:before { + content: "\01f48e"; +} + +.emoji-muted-speaker:before { + content: "\01f507"; +} + +.emoji-speaker-low-volume:before { + content: "\01f508"; +} + +.emoji-speaker-medium-volume:before { + content: "\01f509"; +} + +.emoji-speaker-high-volume:before { + content: "\01f50a"; +} + +.emoji-loudspeaker:before { + content: "\01f4e2"; +} + +.emoji-megaphone:before { + content: "\01f4e3"; +} + +.emoji-postal-horn:before { + content: "\01f4ef"; +} + +.emoji-bell:before { + content: "\01f514"; +} + +.emoji-bell-with-slash:before { + content: "\01f515"; +} + +.emoji-musical-score:before { + content: "\01f3bc"; +} + +.emoji-musical-note:before { + content: "\01f3b5"; +} + +.emoji-musical-notes:before { + content: "\01f3b6"; +} + +.emoji-studio-microphone:before { + content: "\01f399\00fe0f"; +} + +.emoji-level-slider:before { + content: "\01f39a\00fe0f"; +} + +.emoji-control-knobs:before { + content: "\01f39b\00fe0f"; +} + +.emoji-microphone:before { + content: "\01f3a4"; +} + +.emoji-headphone:before { + content: "\01f3a7"; +} + +.emoji-radio:before { + content: "\01f4fb"; +} + +.emoji-saxophone:before { + content: "\01f3b7"; +} + +.emoji-trumpet:before { + content: "\01f3ba"; +} + +.emoji-trombone:before { + content: "\01fa8a"; +} + +.emoji-accordion:before { + content: "\01fa97"; +} + +.emoji-guitar:before { + content: "\01f3b8"; +} + +.emoji-musical-keyboard:before { + content: "\01f3b9"; +} + +.emoji-violin:before { + content: "\01f3bb"; +} + +.emoji-banjo:before { + content: "\01fa95"; +} + +.emoji-drum:before { + content: "\01f941"; +} + +.emoji-long-drum:before { + content: "\01fa98"; +} + +.emoji-maracas:before { + content: "\01fa87"; +} + +.emoji-flute:before { + content: "\01fa88"; +} + +.emoji-harp:before { + content: "\01fa89"; +} + +.emoji-mobile-phone:before { + content: "\01f4f1"; +} + +.emoji-mobile-phone-with-arrow:before { + content: "\01f4f2"; +} + +.emoji-telephone:before { + content: "\00260e\00fe0f"; +} + +.emoji-telephone-receiver:before { + content: "\01f4de"; +} + +.emoji-pager:before { + content: "\01f4df"; +} + +.emoji-fax-machine:before { + content: "\01f4e0"; +} + +.emoji-battery:before { + content: "\01f50b"; +} + +.emoji-low-battery:before { + content: "\01faab"; +} + +.emoji-electric-plug:before { + content: "\01f50c"; +} + +.emoji-laptop:before { + content: "\01f4bb"; +} + +.emoji-desktop-computer:before { + content: "\01f5a5\00fe0f"; +} + +.emoji-printer:before { + content: "\01f5a8\00fe0f"; +} + +.emoji-keyboard:before { + content: "\002328\00fe0f"; +} + +.emoji-computer-mouse:before { + content: "\01f5b1\00fe0f"; +} + +.emoji-trackball:before { + content: "\01f5b2\00fe0f"; +} + +.emoji-computer-disk:before { + content: "\01f4bd"; +} + +.emoji-floppy-disk:before { + content: "\01f4be"; +} + +.emoji-optical-disk:before { + content: "\01f4bf"; +} + +.emoji-dvd:before { + content: "\01f4c0"; +} + +.emoji-abacus:before { + content: "\01f9ee"; +} + +.emoji-movie-camera:before { + content: "\01f3a5"; +} + +.emoji-film-frames:before { + content: "\01f39e\00fe0f"; +} + +.emoji-film-projector:before { + content: "\01f4fd\00fe0f"; +} + +.emoji-clapper-board:before { + content: "\01f3ac"; +} + +.emoji-television:before { + content: "\01f4fa"; +} + +.emoji-camera:before { + content: "\01f4f7"; +} + +.emoji-camera-with-flash:before { + content: "\01f4f8"; +} + +.emoji-video-camera:before { + content: "\01f4f9"; +} + +.emoji-videocassette:before { + content: "\01f4fc"; +} + +.emoji-magnifying-glass-tilted-left:before { + content: "\01f50d"; +} + +.emoji-magnifying-glass-tilted-right:before { + content: "\01f50e"; +} + +.emoji-candle:before { + content: "\01f56f\00fe0f"; +} + +.emoji-light-bulb:before { + content: "\01f4a1"; +} + +.emoji-flashlight:before { + content: "\01f526"; +} + +.emoji-red-paper-lantern:before { + content: "\01f3ee"; +} + +.emoji-diya-lamp:before { + content: "\01fa94"; +} + +.emoji-notebook-with-decorative-cover:before { + content: "\01f4d4"; +} + +.emoji-closed-book:before { + content: "\01f4d5"; +} + +.emoji-open-book:before { + content: "\01f4d6"; +} + +.emoji-green-book:before { + content: "\01f4d7"; +} + +.emoji-blue-book:before { + content: "\01f4d8"; +} + +.emoji-orange-book:before { + content: "\01f4d9"; +} + +.emoji-books:before { + content: "\01f4da"; +} + +.emoji-notebook:before { + content: "\01f4d3"; +} + +.emoji-ledger:before { + content: "\01f4d2"; +} + +.emoji-page-with-curl:before { + content: "\01f4c3"; +} + +.emoji-scroll:before { + content: "\01f4dc"; +} + +.emoji-page-facing-up:before { + content: "\01f4c4"; +} + +.emoji-newspaper:before { + content: "\01f4f0"; +} + +.emoji-rolled-up-newspaper:before { + content: "\01f5de\00fe0f"; +} + +.emoji-bookmark-tabs:before { + content: "\01f4d1"; +} + +.emoji-bookmark:before { + content: "\01f516"; +} + +.emoji-label:before { + content: "\01f3f7\00fe0f"; +} + +.emoji-coin:before { + content: "\01fa99"; +} + +.emoji-money-bag:before { + content: "\01f4b0"; +} + +.emoji-treasure-chest:before { + content: "\01fa8e"; +} + +.emoji-yen-banknote:before { + content: "\01f4b4"; +} + +.emoji-dollar-banknote:before { + content: "\01f4b5"; +} + +.emoji-euro-banknote:before { + content: "\01f4b6"; +} + +.emoji-pound-banknote:before { + content: "\01f4b7"; +} + +.emoji-money-with-wings:before { + content: "\01f4b8"; +} + +.emoji-credit-card:before { + content: "\01f4b3"; +} + +.emoji-receipt:before { + content: "\01f9fe"; +} + +.emoji-chart-increasing-with-yen:before { + content: "\01f4b9"; +} + +.emoji-envelope:before { + content: "\002709\00fe0f"; +} + +.emoji-e-mail:before { + content: "\01f4e7"; +} + +.emoji-incoming-envelope:before { + content: "\01f4e8"; +} + +.emoji-envelope-with-arrow:before { + content: "\01f4e9"; +} + +.emoji-outbox-tray:before { + content: "\01f4e4"; +} + +.emoji-inbox-tray:before { + content: "\01f4e5"; +} + +.emoji-package:before { + content: "\01f4e6"; +} + +.emoji-closed-mailbox-with-raised-flag:before { + content: "\01f4eb"; +} + +.emoji-closed-mailbox-with-lowered-flag:before { + content: "\01f4ea"; +} + +.emoji-open-mailbox-with-raised-flag:before { + content: "\01f4ec"; +} + +.emoji-open-mailbox-with-lowered-flag:before { + content: "\01f4ed"; +} + +.emoji-postbox:before { + content: "\01f4ee"; +} + +.emoji-ballot-box-with-ballot:before { + content: "\01f5f3\00fe0f"; +} + +.emoji-pencil:before { + content: "\00270f\00fe0f"; +} + +.emoji-black-nib:before { + content: "\002712\00fe0f"; +} + +.emoji-fountain-pen:before { + content: "\01f58b\00fe0f"; +} + +.emoji-pen:before { + content: "\01f58a\00fe0f"; +} + +.emoji-paintbrush:before { + content: "\01f58c\00fe0f"; +} + +.emoji-crayon:before { + content: "\01f58d\00fe0f"; +} + +.emoji-memo:before { + content: "\01f4dd"; +} + +.emoji-briefcase:before { + content: "\01f4bc"; +} + +.emoji-file-folder:before { + content: "\01f4c1"; +} + +.emoji-open-file-folder:before { + content: "\01f4c2"; +} + +.emoji-card-index-dividers:before { + content: "\01f5c2\00fe0f"; +} + +.emoji-calendar:before { + content: "\01f4c5"; +} + +.emoji-tear-off-calendar:before { + content: "\01f4c6"; +} + +.emoji-spiral-notepad:before { + content: "\01f5d2\00fe0f"; +} + +.emoji-spiral-calendar:before { + content: "\01f5d3\00fe0f"; +} + +.emoji-card-index:before { + content: "\01f4c7"; +} + +.emoji-chart-increasing:before { + content: "\01f4c8"; +} + +.emoji-chart-decreasing:before { + content: "\01f4c9"; +} + +.emoji-bar-chart:before { + content: "\01f4ca"; +} + +.emoji-clipboard:before { + content: "\01f4cb"; +} + +.emoji-pushpin:before { + content: "\01f4cc"; +} + +.emoji-round-pushpin:before { + content: "\01f4cd"; +} + +.emoji-paperclip:before { + content: "\01f4ce"; +} + +.emoji-linked-paperclips:before { + content: "\01f587\00fe0f"; +} + +.emoji-straight-ruler:before { + content: "\01f4cf"; +} + +.emoji-triangular-ruler:before { + content: "\01f4d0"; +} + +.emoji-scissors:before { + content: "\002702\00fe0f"; +} + +.emoji-card-file-box:before { + content: "\01f5c3\00fe0f"; +} + +.emoji-file-cabinet:before { + content: "\01f5c4\00fe0f"; +} + +.emoji-wastebasket:before { + content: "\01f5d1\00fe0f"; +} + +.emoji-locked:before { + content: "\01f512"; +} + +.emoji-unlocked:before { + content: "\01f513"; +} + +.emoji-locked-with-pen:before { + content: "\01f50f"; +} + +.emoji-locked-with-key:before { + content: "\01f510"; +} + +.emoji-key:before { + content: "\01f511"; +} + +.emoji-old-key:before { + content: "\01f5dd\00fe0f"; +} + +.emoji-hammer:before { + content: "\01f528"; +} + +.emoji-axe:before { + content: "\01fa93"; +} + +.emoji-pick:before { + content: "\0026cf\00fe0f"; +} + +.emoji-hammer-and-pick:before { + content: "\002692\00fe0f"; +} + +.emoji-hammer-and-wrench:before { + content: "\01f6e0\00fe0f"; +} + +.emoji-dagger:before { + content: "\01f5e1\00fe0f"; +} + +.emoji-crossed-swords:before { + content: "\002694\00fe0f"; +} + +.emoji-bomb:before { + content: "\01f4a3"; +} + +.emoji-boomerang:before { + content: "\01fa83"; +} + +.emoji-bow-and-arrow:before { + content: "\01f3f9"; +} + +.emoji-shield:before { + content: "\01f6e1\00fe0f"; +} + +.emoji-carpentry-saw:before { + content: "\01fa9a"; +} + +.emoji-wrench:before { + content: "\01f527"; +} + +.emoji-screwdriver:before { + content: "\01fa9b"; +} + +.emoji-nut-and-bolt:before { + content: "\01f529"; +} + +.emoji-gear:before { + content: "\002699\00fe0f"; +} + +.emoji-clamp:before { + content: "\01f5dc\00fe0f"; +} + +.emoji-balance-scale:before { + content: "\002696\00fe0f"; +} + +.emoji-white-cane:before { + content: "\01f9af"; +} + +.emoji-link:before { + content: "\01f517"; +} + +.emoji-broken-chain:before { + content: "\0026d3\00fe0f\00200d\01f4a5"; +} + +.emoji-chains:before { + content: "\0026d3\00fe0f"; +} + +.emoji-hook:before { + content: "\01fa9d"; +} + +.emoji-toolbox:before { + content: "\01f9f0"; +} + +.emoji-magnet:before { + content: "\01f9f2"; +} + +.emoji-ladder:before { + content: "\01fa9c"; +} + +.emoji-shovel:before { + content: "\01fa8f"; +} + +.emoji-alembic:before { + content: "\002697\00fe0f"; +} + +.emoji-test-tube:before { + content: "\01f9ea"; +} + +.emoji-petri-dish:before { + content: "\01f9eb"; +} + +.emoji-dna:before { + content: "\01f9ec"; +} + +.emoji-microscope:before { + content: "\01f52c"; +} + +.emoji-telescope:before { + content: "\01f52d"; +} + +.emoji-satellite-antenna:before { + content: "\01f4e1"; +} + +.emoji-syringe:before { + content: "\01f489"; +} + +.emoji-drop-of-blood:before { + content: "\01fa78"; +} + +.emoji-pill:before { + content: "\01f48a"; +} + +.emoji-adhesive-bandage:before { + content: "\01fa79"; +} + +.emoji-crutch:before { + content: "\01fa7c"; +} + +.emoji-stethoscope:before { + content: "\01fa7a"; +} + +.emoji-x-ray:before { + content: "\01fa7b"; +} + +.emoji-door:before { + content: "\01f6aa"; +} + +.emoji-elevator:before { + content: "\01f6d7"; +} + +.emoji-mirror:before { + content: "\01fa9e"; +} + +.emoji-window:before { + content: "\01fa9f"; +} + +.emoji-bed:before { + content: "\01f6cf\00fe0f"; +} + +.emoji-couch-and-lamp:before { + content: "\01f6cb\00fe0f"; +} + +.emoji-chair:before { + content: "\01fa91"; +} + +.emoji-toilet:before { + content: "\01f6bd"; +} + +.emoji-plunger:before { + content: "\01faa0"; +} + +.emoji-shower:before { + content: "\01f6bf"; +} + +.emoji-bathtub:before { + content: "\01f6c1"; +} + +.emoji-mouse-trap:before { + content: "\01faa4"; +} + +.emoji-razor:before { + content: "\01fa92"; +} + +.emoji-lotion-bottle:before { + content: "\01f9f4"; +} + +.emoji-safety-pin:before { + content: "\01f9f7"; +} + +.emoji-broom:before { + content: "\01f9f9"; +} + +.emoji-basket:before { + content: "\01f9fa"; +} + +.emoji-roll-of-paper:before { + content: "\01f9fb"; +} + +.emoji-bucket:before { + content: "\01faa3"; +} + +.emoji-soap:before { + content: "\01f9fc"; +} + +.emoji-bubbles:before { + content: "\01fae7"; +} + +.emoji-toothbrush:before { + content: "\01faa5"; +} + +.emoji-sponge:before { + content: "\01f9fd"; +} + +.emoji-fire-extinguisher:before { + content: "\01f9ef"; +} + +.emoji-shopping-cart:before { + content: "\01f6d2"; +} + +.emoji-cigarette:before { + content: "\01f6ac"; +} + +.emoji-coffin:before { + content: "\0026b0\00fe0f"; +} + +.emoji-headstone:before { + content: "\01faa6"; +} + +.emoji-funeral-urn:before { + content: "\0026b1\00fe0f"; +} + +.emoji-nazar-amulet:before { + content: "\01f9ff"; +} + +.emoji-hamsa:before { + content: "\01faac"; +} + +.emoji-moai:before { + content: "\01f5ff"; +} + +.emoji-placard:before { + content: "\01faa7"; +} + +.emoji-identification-card:before { + content: "\01faaa"; +} + +.emoji-atm-sign:before { + content: "\01f3e7"; +} + +.emoji-litter-in-bin-sign:before { + content: "\01f6ae"; +} + +.emoji-potable-water:before { + content: "\01f6b0"; +} + +.emoji-wheelchair-symbol:before { + content: "\00267f"; +} + +.emoji-mens-room:before { + content: "\01f6b9"; +} + +.emoji-womens-room:before { + content: "\01f6ba"; +} + +.emoji-restroom:before { + content: "\01f6bb"; +} + +.emoji-baby-symbol:before { + content: "\01f6bc"; +} + +.emoji-water-closet:before { + content: "\01f6be"; +} + +.emoji-passport-control:before { + content: "\01f6c2"; +} + +.emoji-customs:before { + content: "\01f6c3"; +} + +.emoji-baggage-claim:before { + content: "\01f6c4"; +} + +.emoji-left-luggage:before { + content: "\01f6c5"; +} + +.emoji-warning:before { + content: "\0026a0\00fe0f"; +} + +.emoji-children-crossing:before { + content: "\01f6b8"; +} + +.emoji-no-entry:before { + content: "\0026d4"; +} + +.emoji-prohibited:before { + content: "\01f6ab"; +} + +.emoji-no-bicycles:before { + content: "\01f6b3"; +} + +.emoji-no-smoking:before { + content: "\01f6ad"; +} + +.emoji-no-littering:before { + content: "\01f6af"; +} + +.emoji-non-potable-water:before { + content: "\01f6b1"; +} + +.emoji-no-pedestrians:before { + content: "\01f6b7"; +} + +.emoji-no-mobile-phones:before { + content: "\01f4f5"; +} + +.emoji-no-one-under-eighteen:before { + content: "\01f51e"; +} + +.emoji-radioactive:before { + content: "\002622\00fe0f"; +} + +.emoji-biohazard:before { + content: "\002623\00fe0f"; +} + +.emoji-up-arrow:before { + content: "\002b06\00fe0f"; +} + +.emoji-up-right-arrow:before { + content: "\002197\00fe0f"; +} + +.emoji-right-arrow:before { + content: "\0027a1\00fe0f"; +} + +.emoji-down-right-arrow:before { + content: "\002198\00fe0f"; +} + +.emoji-down-arrow:before { + content: "\002b07\00fe0f"; +} + +.emoji-down-left-arrow:before { + content: "\002199\00fe0f"; +} + +.emoji-left-arrow:before { + content: "\002b05\00fe0f"; +} + +.emoji-up-left-arrow:before { + content: "\002196\00fe0f"; +} + +.emoji-up-down-arrow:before { + content: "\002195\00fe0f"; +} + +.emoji-left-right-arrow:before { + content: "\002194\00fe0f"; +} + +.emoji-right-arrow-curving-left:before { + content: "\0021a9\00fe0f"; +} + +.emoji-left-arrow-curving-right:before { + content: "\0021aa\00fe0f"; +} + +.emoji-right-arrow-curving-up:before { + content: "\002934\00fe0f"; +} + +.emoji-right-arrow-curving-down:before { + content: "\002935\00fe0f"; +} + +.emoji-clockwise-vertical-arrows:before { + content: "\01f503"; +} + +.emoji-counterclockwise-arrows-button:before { + content: "\01f504"; +} + +.emoji-back-arrow:before { + content: "\01f519"; +} + +.emoji-end-arrow:before { + content: "\01f51a"; +} + +.emoji-on-arrow:before { + content: "\01f51b"; +} + +.emoji-soon-arrow:before { + content: "\01f51c"; +} + +.emoji-top-arrow:before { + content: "\01f51d"; +} + +.emoji-place-of-worship:before { + content: "\01f6d0"; +} + +.emoji-atom-symbol:before { + content: "\00269b\00fe0f"; +} + +.emoji-om:before { + content: "\01f549\00fe0f"; +} + +.emoji-star-of-david:before { + content: "\002721\00fe0f"; +} + +.emoji-wheel-of-dharma:before { + content: "\002638\00fe0f"; +} + +.emoji-yin-yang:before { + content: "\00262f\00fe0f"; +} + +.emoji-latin-cross:before { + content: "\00271d\00fe0f"; +} + +.emoji-orthodox-cross:before { + content: "\002626\00fe0f"; +} + +.emoji-star-and-crescent:before { + content: "\00262a\00fe0f"; +} + +.emoji-peace-symbol:before { + content: "\00262e\00fe0f"; +} + +.emoji-menorah:before { + content: "\01f54e"; +} + +.emoji-dotted-six-pointed-star:before { + content: "\01f52f"; +} + +.emoji-khanda:before { + content: "\01faaf"; +} + +.emoji-aries:before { + content: "\002648"; +} + +.emoji-taurus:before { + content: "\002649"; +} + +.emoji-gemini:before { + content: "\00264a"; +} + +.emoji-cancer:before { + content: "\00264b"; +} + +.emoji-leo:before { + content: "\00264c"; +} + +.emoji-virgo:before { + content: "\00264d"; +} + +.emoji-libra:before { + content: "\00264e"; +} + +.emoji-scorpio:before { + content: "\00264f"; +} + +.emoji-sagittarius:before { + content: "\002650"; +} + +.emoji-capricorn:before { + content: "\002651"; +} + +.emoji-aquarius:before { + content: "\002652"; +} + +.emoji-pisces:before { + content: "\002653"; +} + +.emoji-ophiuchus:before { + content: "\0026ce"; +} + +.emoji-shuffle-tracks-button:before { + content: "\01f500"; +} + +.emoji-repeat-button:before { + content: "\01f501"; +} + +.emoji-repeat-single-button:before { + content: "\01f502"; +} + +.emoji-play-button:before { + content: "\0025b6\00fe0f"; +} + +.emoji-fast-forward-button:before { + content: "\0023e9"; +} + +.emoji-next-track-button:before { + content: "\0023ed\00fe0f"; +} + +.emoji-play-or-pause-button:before { + content: "\0023ef\00fe0f"; +} + +.emoji-reverse-button:before { + content: "\0025c0\00fe0f"; +} + +.emoji-fast-reverse-button:before { + content: "\0023ea"; +} + +.emoji-last-track-button:before { + content: "\0023ee\00fe0f"; +} + +.emoji-upwards-button:before { + content: "\01f53c"; +} + +.emoji-fast-up-button:before { + content: "\0023eb"; +} + +.emoji-downwards-button:before { + content: "\01f53d"; +} + +.emoji-fast-down-button:before { + content: "\0023ec"; +} + +.emoji-pause-button:before { + content: "\0023f8\00fe0f"; +} + +.emoji-stop-button:before { + content: "\0023f9\00fe0f"; +} + +.emoji-record-button:before { + content: "\0023fa\00fe0f"; +} + +.emoji-eject-button:before { + content: "\0023cf\00fe0f"; +} + +.emoji-cinema:before { + content: "\01f3a6"; +} + +.emoji-dim-button:before { + content: "\01f505"; +} + +.emoji-bright-button:before { + content: "\01f506"; +} + +.emoji-antenna-bars:before { + content: "\01f4f6"; +} + +.emoji-wireless:before { + content: "\01f6dc"; +} + +.emoji-vibration-mode:before { + content: "\01f4f3"; +} + +.emoji-mobile-phone-off:before { + content: "\01f4f4"; +} + +.emoji-female-sign:before { + content: "\002640\00fe0f"; +} + +.emoji-male-sign:before { + content: "\002642\00fe0f"; +} + +.emoji-transgender-symbol:before { + content: "\0026a7\00fe0f"; +} + +.emoji-multiply:before { + content: "\002716\00fe0f"; +} + +.emoji-plus:before { + content: "\002795"; +} + +.emoji-minus:before { + content: "\002796"; +} + +.emoji-divide:before { + content: "\002797"; +} + +.emoji-heavy-equals-sign:before { + content: "\01f7f0"; +} + +.emoji-infinity:before { + content: "\00267e\00fe0f"; +} + +.emoji-double-exclamation-mark:before { + content: "\00203c\00fe0f"; +} + +.emoji-exclamation-question-mark:before { + content: "\002049\00fe0f"; +} + +.emoji-red-question-mark:before { + content: "\002753"; +} + +.emoji-white-question-mark:before { + content: "\002754"; +} + +.emoji-white-exclamation-mark:before { + content: "\002755"; +} + +.emoji-red-exclamation-mark:before { + content: "\002757"; +} + +.emoji-wavy-dash:before { + content: "\003030\00fe0f"; +} + +.emoji-currency-exchange:before { + content: "\01f4b1"; +} + +.emoji-heavy-dollar-sign:before { + content: "\01f4b2"; +} + +.emoji-medical-symbol:before { + content: "\002695\00fe0f"; +} + +.emoji-recycling-symbol:before { + content: "\00267b\00fe0f"; +} + +.emoji-fleur-de-lis:before { + content: "\00269c\00fe0f"; +} + +.emoji-trident-emblem:before { + content: "\01f531"; +} + +.emoji-name-badge:before { + content: "\01f4db"; +} + +.emoji-japanese-symbol-for-beginner:before { + content: "\01f530"; +} + +.emoji-hollow-red-circle:before { + content: "\002b55"; +} + +.emoji-check-mark-button:before { + content: "\002705"; +} + +.emoji-check-box-with-check:before { + content: "\002611\00fe0f"; +} + +.emoji-check-mark:before { + content: "\002714\00fe0f"; +} + +.emoji-cross-mark:before { + content: "\00274c"; +} + +.emoji-cross-mark-button:before { + content: "\00274e"; +} + +.emoji-curly-loop:before { + content: "\0027b0"; +} + +.emoji-double-curly-loop:before { + content: "\0027bf"; +} + +.emoji-part-alternation-mark:before { + content: "\00303d\00fe0f"; +} + +.emoji-eight-spoked-asterisk:before { + content: "\002733\00fe0f"; +} + +.emoji-eight-pointed-star:before { + content: "\002734\00fe0f"; +} + +.emoji-sparkle:before { + content: "\002747\00fe0f"; +} + +.emoji-copyright:before { + content: "\0000a9\00fe0f"; +} + +.emoji-registered:before { + content: "\0000ae\00fe0f"; +} + +.emoji-trade-mark:before { + content: "\002122\00fe0f"; +} + +.emoji-splatter:before { + content: "\01fadf"; +} + +.emoji-keycap-number-sign:before { + content: "\000023\00fe0f\0020e3"; +} + +.emoji-keycap-asterisk:before { + content: "\00002a\00fe0f\0020e3"; +} + +.emoji-keycap-0:before { + content: "\000030\00fe0f\0020e3"; +} + +.emoji-keycap-1:before { + content: "\000031\00fe0f\0020e3"; +} + +.emoji-keycap-2:before { + content: "\000032\00fe0f\0020e3"; +} + +.emoji-keycap-3:before { + content: "\000033\00fe0f\0020e3"; +} + +.emoji-keycap-4:before { + content: "\000034\00fe0f\0020e3"; +} + +.emoji-keycap-5:before { + content: "\000035\00fe0f\0020e3"; +} + +.emoji-keycap-6:before { + content: "\000036\00fe0f\0020e3"; +} + +.emoji-keycap-7:before { + content: "\000037\00fe0f\0020e3"; +} + +.emoji-keycap-8:before { + content: "\000038\00fe0f\0020e3"; +} + +.emoji-keycap-9:before { + content: "\000039\00fe0f\0020e3"; +} + +.emoji-keycap-10:before { + content: "\01f51f"; +} + +.emoji-input-latin-uppercase:before { + content: "\01f520"; +} + +.emoji-input-latin-lowercase:before { + content: "\01f521"; +} + +.emoji-input-numbers:before { + content: "\01f522"; +} + +.emoji-input-symbols:before { + content: "\01f523"; +} + +.emoji-input-latin-letters:before { + content: "\01f524"; +} + +.emoji-a-button-blood-type:before { + content: "\01f170\00fe0f"; +} + +.emoji-ab-button-blood-type:before { + content: "\01f18e"; +} + +.emoji-b-button-blood-type:before { + content: "\01f171\00fe0f"; +} + +.emoji-cl-button:before { + content: "\01f191"; +} + +.emoji-cool-button:before { + content: "\01f192"; +} + +.emoji-free-button:before { + content: "\01f193"; +} + +.emoji-information:before { + content: "\002139\00fe0f"; +} + +.emoji-id-button:before { + content: "\01f194"; +} + +.emoji-circled-m:before { + content: "\0024c2\00fe0f"; +} + +.emoji-new-button:before { + content: "\01f195"; +} + +.emoji-ng-button:before { + content: "\01f196"; +} + +.emoji-o-button-blood-type:before { + content: "\01f17e\00fe0f"; +} + +.emoji-ok-button:before { + content: "\01f197"; +} + +.emoji-p-button:before { + content: "\01f17f\00fe0f"; +} + +.emoji-sos-button:before { + content: "\01f198"; +} + +.emoji-up-button:before { + content: "\01f199"; +} + +.emoji-vs-button:before { + content: "\01f19a"; +} + +.emoji-japanese-here-button:before { + content: "\01f201"; +} + +.emoji-japanese-service-charge-button:before { + content: "\01f202\00fe0f"; +} + +.emoji-japanese-monthly-amount-button:before { + content: "\01f237\00fe0f"; +} + +.emoji-japanese-not-free-of-charge-button:before { + content: "\01f236"; +} + +.emoji-japanese-reserved-button:before { + content: "\01f22f"; +} + +.emoji-japanese-bargain-button:before { + content: "\01f250"; +} + +.emoji-japanese-discount-button:before { + content: "\01f239"; +} + +.emoji-japanese-free-of-charge-button:before { + content: "\01f21a"; +} + +.emoji-japanese-prohibited-button:before { + content: "\01f232"; +} + +.emoji-japanese-acceptable-button:before { + content: "\01f251"; +} + +.emoji-japanese-application-button:before { + content: "\01f238"; +} + +.emoji-japanese-passing-grade-button:before { + content: "\01f234"; +} + +.emoji-japanese-vacancy-button:before { + content: "\01f233"; +} + +.emoji-japanese-congratulations-button:before { + content: "\003297\00fe0f"; +} + +.emoji-japanese-secret-button:before { + content: "\003299\00fe0f"; +} + +.emoji-japanese-open-for-business-button:before { + content: "\01f23a"; +} + +.emoji-japanese-no-vacancy-button:before { + content: "\01f235"; +} + +.emoji-red-circle:before { + content: "\01f534"; +} + +.emoji-orange-circle:before { + content: "\01f7e0"; +} + +.emoji-yellow-circle:before { + content: "\01f7e1"; +} + +.emoji-green-circle:before { + content: "\01f7e2"; +} + +.emoji-blue-circle:before { + content: "\01f535"; +} + +.emoji-purple-circle:before { + content: "\01f7e3"; +} + +.emoji-brown-circle:before { + content: "\01f7e4"; +} + +.emoji-black-circle:before { + content: "\0026ab"; +} + +.emoji-white-circle:before { + content: "\0026aa"; +} + +.emoji-red-square:before { + content: "\01f7e5"; +} + +.emoji-orange-square:before { + content: "\01f7e7"; +} + +.emoji-yellow-square:before { + content: "\01f7e8"; +} + +.emoji-green-square:before { + content: "\01f7e9"; +} + +.emoji-blue-square:before { + content: "\01f7e6"; +} + +.emoji-purple-square:before { + content: "\01f7ea"; +} + +.emoji-brown-square:before { + content: "\01f7eb"; +} + +.emoji-black-large-square:before { + content: "\002b1b"; +} + +.emoji-white-large-square:before { + content: "\002b1c"; +} + +.emoji-black-medium-square:before { + content: "\0025fc\00fe0f"; +} + +.emoji-white-medium-square:before { + content: "\0025fb\00fe0f"; +} + +.emoji-black-medium-small-square:before { + content: "\0025fe"; +} + +.emoji-white-medium-small-square:before { + content: "\0025fd"; +} + +.emoji-black-small-square:before { + content: "\0025aa\00fe0f"; +} + +.emoji-white-small-square:before { + content: "\0025ab\00fe0f"; +} + +.emoji-large-orange-diamond:before { + content: "\01f536"; +} + +.emoji-large-blue-diamond:before { + content: "\01f537"; +} + +.emoji-small-orange-diamond:before { + content: "\01f538"; +} + +.emoji-small-blue-diamond:before { + content: "\01f539"; +} + +.emoji-red-triangle-pointed-up:before { + content: "\01f53a"; +} + +.emoji-red-triangle-pointed-down:before { + content: "\01f53b"; +} + +.emoji-diamond-with-a-dot:before { + content: "\01f4a0"; +} + +.emoji-radio-button:before { + content: "\01f518"; +} + +.emoji-white-square-button:before { + content: "\01f533"; +} + +.emoji-black-square-button:before { + content: "\01f532"; +} + +.emoji-chequered-flag:before { + content: "\01f3c1"; +} + +.emoji-triangular-flag:before { + content: "\01f6a9"; +} + +.emoji-crossed-flags:before { + content: "\01f38c"; +} + +.emoji-black-flag:before { + content: "\01f3f4"; +} + +.emoji-white-flag:before { + content: "\01f3f3\00fe0f"; +} + +.emoji-rainbow-flag:before { + content: "\01f3f3\00fe0f\00200d\01f308"; +} + +.emoji-transgender-flag:before { + content: "\01f3f3\00fe0f\00200d\0026a7\00fe0f"; +} + +.emoji-pirate-flag:before { + content: "\01f3f4\00200d\002620\00fe0f"; +} + +.emoji-flag-ascension-island:before { + content: "\01f1e6\01f1e8"; +} + +.emoji-flag-andorra:before { + content: "\01f1e6\01f1e9"; +} + +.emoji-flag-united-arab-emirates:before { + content: "\01f1e6\01f1ea"; +} + +.emoji-flag-afghanistan:before { + content: "\01f1e6\01f1eb"; +} + +.emoji-flag-antigua-and-barbuda:before { + content: "\01f1e6\01f1ec"; +} + +.emoji-flag-anguilla:before { + content: "\01f1e6\01f1ee"; +} + +.emoji-flag-albania:before { + content: "\01f1e6\01f1f1"; +} + +.emoji-flag-armenia:before { + content: "\01f1e6\01f1f2"; +} + +.emoji-flag-angola:before { + content: "\01f1e6\01f1f4"; +} + +.emoji-flag-antarctica:before { + content: "\01f1e6\01f1f6"; +} + +.emoji-flag-argentina:before { + content: "\01f1e6\01f1f7"; +} + +.emoji-flag-american-samoa:before { + content: "\01f1e6\01f1f8"; +} + +.emoji-flag-austria:before { + content: "\01f1e6\01f1f9"; +} + +.emoji-flag-australia:before { + content: "\01f1e6\01f1fa"; +} + +.emoji-flag-aruba:before { + content: "\01f1e6\01f1fc"; +} + +.emoji-flag-aland-islands:before { + content: "\01f1e6\01f1fd"; +} + +.emoji-flag-azerbaijan:before { + content: "\01f1e6\01f1ff"; +} + +.emoji-flag-bosnia-and-herzegovina:before { + content: "\01f1e7\01f1e6"; +} + +.emoji-flag-barbados:before { + content: "\01f1e7\01f1e7"; +} + +.emoji-flag-bangladesh:before { + content: "\01f1e7\01f1e9"; +} + +.emoji-flag-belgium:before { + content: "\01f1e7\01f1ea"; +} + +.emoji-flag-burkina-faso:before { + content: "\01f1e7\01f1eb"; +} + +.emoji-flag-bulgaria:before { + content: "\01f1e7\01f1ec"; +} + +.emoji-flag-bahrain:before { + content: "\01f1e7\01f1ed"; +} + +.emoji-flag-burundi:before { + content: "\01f1e7\01f1ee"; +} + +.emoji-flag-benin:before { + content: "\01f1e7\01f1ef"; +} + +.emoji-flag-st-barthelemy:before { + content: "\01f1e7\01f1f1"; +} + +.emoji-flag-bermuda:before { + content: "\01f1e7\01f1f2"; +} + +.emoji-flag-brunei:before { + content: "\01f1e7\01f1f3"; +} + +.emoji-flag-bolivia:before { + content: "\01f1e7\01f1f4"; +} + +.emoji-flag-caribbean-netherlands:before { + content: "\01f1e7\01f1f6"; +} + +.emoji-flag-brazil:before { + content: "\01f1e7\01f1f7"; +} + +.emoji-flag-bahamas:before { + content: "\01f1e7\01f1f8"; +} + +.emoji-flag-bhutan:before { + content: "\01f1e7\01f1f9"; +} + +.emoji-flag-bouvet-island:before { + content: "\01f1e7\01f1fb"; +} + +.emoji-flag-botswana:before { + content: "\01f1e7\01f1fc"; +} + +.emoji-flag-belarus:before { + content: "\01f1e7\01f1fe"; +} + +.emoji-flag-belize:before { + content: "\01f1e7\01f1ff"; +} + +.emoji-flag-canada:before { + content: "\01f1e8\01f1e6"; +} + +.emoji-flag-cocos-keeling-islands:before { + content: "\01f1e8\01f1e8"; +} + +.emoji-flag-congo-kinshasa:before { + content: "\01f1e8\01f1e9"; +} + +.emoji-flag-central-african-republic:before { + content: "\01f1e8\01f1eb"; +} + +.emoji-flag-congo-brazzaville:before { + content: "\01f1e8\01f1ec"; +} + +.emoji-flag-switzerland:before { + content: "\01f1e8\01f1ed"; +} + +.emoji-flag-cote-divoire:before { + content: "\01f1e8\01f1ee"; +} + +.emoji-flag-cook-islands:before { + content: "\01f1e8\01f1f0"; +} + +.emoji-flag-chile:before { + content: "\01f1e8\01f1f1"; +} + +.emoji-flag-cameroon:before { + content: "\01f1e8\01f1f2"; +} + +.emoji-flag-china:before { + content: "\01f1e8\01f1f3"; +} + +.emoji-flag-colombia:before { + content: "\01f1e8\01f1f4"; +} + +.emoji-flag-clipperton-island:before { + content: "\01f1e8\01f1f5"; +} + +.emoji-flag-sark:before { + content: "\01f1e8\01f1f6"; +} + +.emoji-flag-costa-rica:before { + content: "\01f1e8\01f1f7"; +} + +.emoji-flag-cuba:before { + content: "\01f1e8\01f1fa"; +} + +.emoji-flag-cape-verde:before { + content: "\01f1e8\01f1fb"; +} + +.emoji-flag-curacao:before { + content: "\01f1e8\01f1fc"; +} + +.emoji-flag-christmas-island:before { + content: "\01f1e8\01f1fd"; +} + +.emoji-flag-cyprus:before { + content: "\01f1e8\01f1fe"; +} + +.emoji-flag-czechia:before { + content: "\01f1e8\01f1ff"; +} + +.emoji-flag-germany:before { + content: "\01f1e9\01f1ea"; +} + +.emoji-flag-diego-garcia:before { + content: "\01f1e9\01f1ec"; +} + +.emoji-flag-djibouti:before { + content: "\01f1e9\01f1ef"; +} + +.emoji-flag-denmark:before { + content: "\01f1e9\01f1f0"; +} + +.emoji-flag-dominica:before { + content: "\01f1e9\01f1f2"; +} + +.emoji-flag-dominican-republic:before { + content: "\01f1e9\01f1f4"; +} + +.emoji-flag-algeria:before { + content: "\01f1e9\01f1ff"; +} + +.emoji-flag-ceuta-and-melilla:before { + content: "\01f1ea\01f1e6"; +} + +.emoji-flag-ecuador:before { + content: "\01f1ea\01f1e8"; +} + +.emoji-flag-estonia:before { + content: "\01f1ea\01f1ea"; +} + +.emoji-flag-egypt:before { + content: "\01f1ea\01f1ec"; +} + +.emoji-flag-western-sahara:before { + content: "\01f1ea\01f1ed"; +} + +.emoji-flag-eritrea:before { + content: "\01f1ea\01f1f7"; +} + +.emoji-flag-spain:before { + content: "\01f1ea\01f1f8"; +} + +.emoji-flag-ethiopia:before { + content: "\01f1ea\01f1f9"; +} + +.emoji-flag-european-union:before { + content: "\01f1ea\01f1fa"; +} + +.emoji-flag-finland:before { + content: "\01f1eb\01f1ee"; +} + +.emoji-flag-fiji:before { + content: "\01f1eb\01f1ef"; +} + +.emoji-flag-falkland-islands:before { + content: "\01f1eb\01f1f0"; +} + +.emoji-flag-micronesia:before { + content: "\01f1eb\01f1f2"; +} + +.emoji-flag-faroe-islands:before { + content: "\01f1eb\01f1f4"; +} + +.emoji-flag-france:before { + content: "\01f1eb\01f1f7"; +} + +.emoji-flag-gabon:before { + content: "\01f1ec\01f1e6"; +} + +.emoji-flag-united-kingdom:before { + content: "\01f1ec\01f1e7"; +} + +.emoji-flag-grenada:before { + content: "\01f1ec\01f1e9"; +} + +.emoji-flag-georgia:before { + content: "\01f1ec\01f1ea"; +} + +.emoji-flag-french-guiana:before { + content: "\01f1ec\01f1eb"; +} + +.emoji-flag-guernsey:before { + content: "\01f1ec\01f1ec"; +} + +.emoji-flag-ghana:before { + content: "\01f1ec\01f1ed"; +} + +.emoji-flag-gibraltar:before { + content: "\01f1ec\01f1ee"; +} + +.emoji-flag-greenland:before { + content: "\01f1ec\01f1f1"; +} + +.emoji-flag-gambia:before { + content: "\01f1ec\01f1f2"; +} + +.emoji-flag-guinea:before { + content: "\01f1ec\01f1f3"; +} + +.emoji-flag-guadeloupe:before { + content: "\01f1ec\01f1f5"; +} + +.emoji-flag-equatorial-guinea:before { + content: "\01f1ec\01f1f6"; +} + +.emoji-flag-greece:before { + content: "\01f1ec\01f1f7"; +} + +.emoji-flag-south-georgia-and-south-sandwich-islands:before { + content: "\01f1ec\01f1f8"; +} + +.emoji-flag-guatemala:before { + content: "\01f1ec\01f1f9"; +} + +.emoji-flag-guam:before { + content: "\01f1ec\01f1fa"; +} + +.emoji-flag-guinea-bissau:before { + content: "\01f1ec\01f1fc"; +} + +.emoji-flag-guyana:before { + content: "\01f1ec\01f1fe"; +} + +.emoji-flag-hong-kong-sar-china:before { + content: "\01f1ed\01f1f0"; +} + +.emoji-flag-heard-and-mcdonald-islands:before { + content: "\01f1ed\01f1f2"; +} + +.emoji-flag-honduras:before { + content: "\01f1ed\01f1f3"; +} + +.emoji-flag-croatia:before { + content: "\01f1ed\01f1f7"; +} + +.emoji-flag-haiti:before { + content: "\01f1ed\01f1f9"; +} + +.emoji-flag-hungary:before { + content: "\01f1ed\01f1fa"; +} + +.emoji-flag-canary-islands:before { + content: "\01f1ee\01f1e8"; +} + +.emoji-flag-indonesia:before { + content: "\01f1ee\01f1e9"; +} + +.emoji-flag-ireland:before { + content: "\01f1ee\01f1ea"; +} + +.emoji-flag-israel:before { + content: "\01f1ee\01f1f1"; +} + +.emoji-flag-isle-of-man:before { + content: "\01f1ee\01f1f2"; +} + +.emoji-flag-india:before { + content: "\01f1ee\01f1f3"; +} + +.emoji-flag-british-indian-ocean-territory:before { + content: "\01f1ee\01f1f4"; +} + +.emoji-flag-iraq:before { + content: "\01f1ee\01f1f6"; +} + +.emoji-flag-iran:before { + content: "\01f1ee\01f1f7"; +} + +.emoji-flag-iceland:before { + content: "\01f1ee\01f1f8"; +} + +.emoji-flag-italy:before { + content: "\01f1ee\01f1f9"; +} + +.emoji-flag-jersey:before { + content: "\01f1ef\01f1ea"; +} + +.emoji-flag-jamaica:before { + content: "\01f1ef\01f1f2"; +} + +.emoji-flag-jordan:before { + content: "\01f1ef\01f1f4"; +} + +.emoji-flag-japan:before { + content: "\01f1ef\01f1f5"; +} + +.emoji-flag-kenya:before { + content: "\01f1f0\01f1ea"; +} + +.emoji-flag-kyrgyzstan:before { + content: "\01f1f0\01f1ec"; +} + +.emoji-flag-cambodia:before { + content: "\01f1f0\01f1ed"; +} + +.emoji-flag-kiribati:before { + content: "\01f1f0\01f1ee"; +} + +.emoji-flag-comoros:before { + content: "\01f1f0\01f1f2"; +} + +.emoji-flag-st-kitts-and-nevis:before { + content: "\01f1f0\01f1f3"; +} + +.emoji-flag-north-korea:before { + content: "\01f1f0\01f1f5"; +} + +.emoji-flag-south-korea:before { + content: "\01f1f0\01f1f7"; +} + +.emoji-flag-kuwait:before { + content: "\01f1f0\01f1fc"; +} + +.emoji-flag-cayman-islands:before { + content: "\01f1f0\01f1fe"; +} + +.emoji-flag-kazakhstan:before { + content: "\01f1f0\01f1ff"; +} + +.emoji-flag-laos:before { + content: "\01f1f1\01f1e6"; +} + +.emoji-flag-lebanon:before { + content: "\01f1f1\01f1e7"; +} + +.emoji-flag-st-lucia:before { + content: "\01f1f1\01f1e8"; +} + +.emoji-flag-liechtenstein:before { + content: "\01f1f1\01f1ee"; +} + +.emoji-flag-sri-lanka:before { + content: "\01f1f1\01f1f0"; +} + +.emoji-flag-liberia:before { + content: "\01f1f1\01f1f7"; +} + +.emoji-flag-lesotho:before { + content: "\01f1f1\01f1f8"; +} + +.emoji-flag-lithuania:before { + content: "\01f1f1\01f1f9"; +} + +.emoji-flag-luxembourg:before { + content: "\01f1f1\01f1fa"; +} + +.emoji-flag-latvia:before { + content: "\01f1f1\01f1fb"; +} + +.emoji-flag-libya:before { + content: "\01f1f1\01f1fe"; +} + +.emoji-flag-morocco:before { + content: "\01f1f2\01f1e6"; +} + +.emoji-flag-monaco:before { + content: "\01f1f2\01f1e8"; +} + +.emoji-flag-moldova:before { + content: "\01f1f2\01f1e9"; +} + +.emoji-flag-montenegro:before { + content: "\01f1f2\01f1ea"; +} + +.emoji-flag-st-martin:before { + content: "\01f1f2\01f1eb"; +} + +.emoji-flag-madagascar:before { + content: "\01f1f2\01f1ec"; +} + +.emoji-flag-marshall-islands:before { + content: "\01f1f2\01f1ed"; +} + +.emoji-flag-north-macedonia:before { + content: "\01f1f2\01f1f0"; +} + +.emoji-flag-mali:before { + content: "\01f1f2\01f1f1"; +} + +.emoji-flag-myanmar-burma:before { + content: "\01f1f2\01f1f2"; +} + +.emoji-flag-mongolia:before { + content: "\01f1f2\01f1f3"; +} + +.emoji-flag-macao-sar-china:before { + content: "\01f1f2\01f1f4"; +} + +.emoji-flag-northern-mariana-islands:before { + content: "\01f1f2\01f1f5"; +} + +.emoji-flag-martinique:before { + content: "\01f1f2\01f1f6"; +} + +.emoji-flag-mauritania:before { + content: "\01f1f2\01f1f7"; +} + +.emoji-flag-montserrat:before { + content: "\01f1f2\01f1f8"; +} + +.emoji-flag-malta:before { + content: "\01f1f2\01f1f9"; +} + +.emoji-flag-mauritius:before { + content: "\01f1f2\01f1fa"; +} + +.emoji-flag-maldives:before { + content: "\01f1f2\01f1fb"; +} + +.emoji-flag-malawi:before { + content: "\01f1f2\01f1fc"; +} + +.emoji-flag-mexico:before { + content: "\01f1f2\01f1fd"; +} + +.emoji-flag-malaysia:before { + content: "\01f1f2\01f1fe"; +} + +.emoji-flag-mozambique:before { + content: "\01f1f2\01f1ff"; +} + +.emoji-flag-namibia:before { + content: "\01f1f3\01f1e6"; +} + +.emoji-flag-new-caledonia:before { + content: "\01f1f3\01f1e8"; +} + +.emoji-flag-niger:before { + content: "\01f1f3\01f1ea"; +} + +.emoji-flag-norfolk-island:before { + content: "\01f1f3\01f1eb"; +} + +.emoji-flag-nigeria:before { + content: "\01f1f3\01f1ec"; +} + +.emoji-flag-nicaragua:before { + content: "\01f1f3\01f1ee"; +} + +.emoji-flag-netherlands:before { + content: "\01f1f3\01f1f1"; +} + +.emoji-flag-norway:before { + content: "\01f1f3\01f1f4"; +} + +.emoji-flag-nepal:before { + content: "\01f1f3\01f1f5"; +} + +.emoji-flag-nauru:before { + content: "\01f1f3\01f1f7"; +} + +.emoji-flag-niue:before { + content: "\01f1f3\01f1fa"; +} + +.emoji-flag-new-zealand:before { + content: "\01f1f3\01f1ff"; +} + +.emoji-flag-oman:before { + content: "\01f1f4\01f1f2"; +} + +.emoji-flag-panama:before { + content: "\01f1f5\01f1e6"; +} + +.emoji-flag-peru:before { + content: "\01f1f5\01f1ea"; +} + +.emoji-flag-french-polynesia:before { + content: "\01f1f5\01f1eb"; +} + +.emoji-flag-papua-new-guinea:before { + content: "\01f1f5\01f1ec"; +} + +.emoji-flag-philippines:before { + content: "\01f1f5\01f1ed"; +} + +.emoji-flag-pakistan:before { + content: "\01f1f5\01f1f0"; +} + +.emoji-flag-poland:before { + content: "\01f1f5\01f1f1"; +} + +.emoji-flag-st-pierre-and-miquelon:before { + content: "\01f1f5\01f1f2"; +} + +.emoji-flag-pitcairn-islands:before { + content: "\01f1f5\01f1f3"; +} + +.emoji-flag-puerto-rico:before { + content: "\01f1f5\01f1f7"; +} + +.emoji-flag-palestinian-territories:before { + content: "\01f1f5\01f1f8"; +} + +.emoji-flag-portugal:before { + content: "\01f1f5\01f1f9"; +} + +.emoji-flag-palau:before { + content: "\01f1f5\01f1fc"; +} + +.emoji-flag-paraguay:before { + content: "\01f1f5\01f1fe"; +} + +.emoji-flag-qatar:before { + content: "\01f1f6\01f1e6"; +} + +.emoji-flag-reunion:before { + content: "\01f1f7\01f1ea"; +} + +.emoji-flag-romania:before { + content: "\01f1f7\01f1f4"; +} + +.emoji-flag-serbia:before { + content: "\01f1f7\01f1f8"; +} + +.emoji-flag-russia:before { + content: "\01f1f7\01f1fa"; +} + +.emoji-flag-rwanda:before { + content: "\01f1f7\01f1fc"; +} + +.emoji-flag-saudi-arabia:before { + content: "\01f1f8\01f1e6"; +} + +.emoji-flag-solomon-islands:before { + content: "\01f1f8\01f1e7"; +} + +.emoji-flag-seychelles:before { + content: "\01f1f8\01f1e8"; +} + +.emoji-flag-sudan:before { + content: "\01f1f8\01f1e9"; +} + +.emoji-flag-sweden:before { + content: "\01f1f8\01f1ea"; +} + +.emoji-flag-singapore:before { + content: "\01f1f8\01f1ec"; +} + +.emoji-flag-st-helena:before { + content: "\01f1f8\01f1ed"; +} + +.emoji-flag-slovenia:before { + content: "\01f1f8\01f1ee"; +} + +.emoji-flag-svalbard-and-jan-mayen:before { + content: "\01f1f8\01f1ef"; +} + +.emoji-flag-slovakia:before { + content: "\01f1f8\01f1f0"; +} + +.emoji-flag-sierra-leone:before { + content: "\01f1f8\01f1f1"; +} + +.emoji-flag-san-marino:before { + content: "\01f1f8\01f1f2"; +} + +.emoji-flag-senegal:before { + content: "\01f1f8\01f1f3"; +} + +.emoji-flag-somalia:before { + content: "\01f1f8\01f1f4"; +} + +.emoji-flag-suriname:before { + content: "\01f1f8\01f1f7"; +} + +.emoji-flag-south-sudan:before { + content: "\01f1f8\01f1f8"; +} + +.emoji-flag-sao-tome-and-principe:before { + content: "\01f1f8\01f1f9"; +} + +.emoji-flag-el-salvador:before { + content: "\01f1f8\01f1fb"; +} + +.emoji-flag-sint-maarten:before { + content: "\01f1f8\01f1fd"; +} + +.emoji-flag-syria:before { + content: "\01f1f8\01f1fe"; +} + +.emoji-flag-eswatini:before { + content: "\01f1f8\01f1ff"; +} + +.emoji-flag-tristan-da-cunha:before { + content: "\01f1f9\01f1e6"; +} + +.emoji-flag-turks-and-caicos-islands:before { + content: "\01f1f9\01f1e8"; +} + +.emoji-flag-chad:before { + content: "\01f1f9\01f1e9"; +} + +.emoji-flag-french-southern-territories:before { + content: "\01f1f9\01f1eb"; +} + +.emoji-flag-togo:before { + content: "\01f1f9\01f1ec"; +} + +.emoji-flag-thailand:before { + content: "\01f1f9\01f1ed"; +} + +.emoji-flag-tajikistan:before { + content: "\01f1f9\01f1ef"; +} + +.emoji-flag-tokelau:before { + content: "\01f1f9\01f1f0"; +} + +.emoji-flag-timor-leste:before { + content: "\01f1f9\01f1f1"; +} + +.emoji-flag-turkmenistan:before { + content: "\01f1f9\01f1f2"; +} + +.emoji-flag-tunisia:before { + content: "\01f1f9\01f1f3"; +} + +.emoji-flag-tonga:before { + content: "\01f1f9\01f1f4"; +} + +.emoji-flag-turkiye:before { + content: "\01f1f9\01f1f7"; +} + +.emoji-flag-trinidad-and-tobago:before { + content: "\01f1f9\01f1f9"; +} + +.emoji-flag-tuvalu:before { + content: "\01f1f9\01f1fb"; +} + +.emoji-flag-taiwan:before { + content: "\01f1f9\01f1fc"; +} + +.emoji-flag-tanzania:before { + content: "\01f1f9\01f1ff"; +} + +.emoji-flag-ukraine:before { + content: "\01f1fa\01f1e6"; +} + +.emoji-flag-uganda:before { + content: "\01f1fa\01f1ec"; +} + +.emoji-flag-u-s-outlying-islands:before { + content: "\01f1fa\01f1f2"; +} + +.emoji-flag-united-nations:before { + content: "\01f1fa\01f1f3"; +} + +.emoji-flag-united-states:before { + content: "\01f1fa\01f1f8"; +} + +.emoji-flag-uruguay:before { + content: "\01f1fa\01f1fe"; +} + +.emoji-flag-uzbekistan:before { + content: "\01f1fa\01f1ff"; +} + +.emoji-flag-vatican-city:before { + content: "\01f1fb\01f1e6"; +} + +.emoji-flag-st-vincent-and-grenadines:before { + content: "\01f1fb\01f1e8"; +} + +.emoji-flag-venezuela:before { + content: "\01f1fb\01f1ea"; +} + +.emoji-flag-british-virgin-islands:before { + content: "\01f1fb\01f1ec"; +} + +.emoji-flag-u-s-virgin-islands:before { + content: "\01f1fb\01f1ee"; +} + +.emoji-flag-vietnam:before { + content: "\01f1fb\01f1f3"; +} + +.emoji-flag-vanuatu:before { + content: "\01f1fb\01f1fa"; +} + +.emoji-flag-wallis-and-futuna:before { + content: "\01f1fc\01f1eb"; +} + +.emoji-flag-samoa:before { + content: "\01f1fc\01f1f8"; +} + +.emoji-flag-kosovo:before { + content: "\01f1fd\01f1f0"; +} + +.emoji-flag-yemen:before { + content: "\01f1fe\01f1ea"; +} + +.emoji-flag-mayotte:before { + content: "\01f1fe\01f1f9"; +} + +.emoji-flag-south-africa:before { + content: "\01f1ff\01f1e6"; +} + +.emoji-flag-zambia:before { + content: "\01f1ff\01f1f2"; +} + +.emoji-flag-zimbabwe:before { + content: "\01f1ff\01f1fc"; +} + +.emoji-flag-england:before { + content: "\01f3f4\0e0067\0e0062\0e0065\0e006e\0e0067\0e007f"; +} + +.emoji-flag-scotland:before { + content: "\01f3f4\0e0067\0e0062\0e0073\0e0063\0e0074\0e007f"; +} + +.emoji-flag-wales:before { + content: "\01f3f4\0e0067\0e0062\0e0077\0e006c\0e0073\0e007f"; +} diff --git a/assets/styles/packages/.gitignore b/assets/styles/extensions/.gitignore similarity index 100% rename from assets/styles/packages/.gitignore rename to assets/styles/extensions/.gitignore diff --git a/assets/styles/extensions/README.md b/assets/styles/extensions/README.md new file mode 100644 index 00000000..6ea34fee --- /dev/null +++ b/assets/styles/extensions/README.md @@ -0,0 +1,5 @@ +# Extension asset imports + +This directory is managed by the extension lifecycle. + +Only assets from mirrored active extensions are included here so AssetMapper can serve them from the normal `assets/` tree. Source extension directories under `extensions/` are not exposed wholesale. diff --git a/assets/styles/fonts.css b/assets/styles/fonts.css index 8200fec5..d6ef59f5 100644 --- a/assets/styles/fonts.css +++ b/assets/styles/fonts.css @@ -66,6 +66,31 @@ font-family: "tabler-icons"; font-style: normal; font-weight: 400; - src: url("../fonts/tabler-icons/tabler-icons.woff2?v3.35.0") format("woff2"), - url("../fonts/tabler-icons/tabler-icons.woff?v3.35.0") format("woff"); + src: url("../fonts/tabler-icons/tabler-icons.woff2?v3.44.0") format("woff2"), + url("../fonts/tabler-icons/tabler-icons.woff?v3.44.0") format("woff"); +} + +/* Noto Sans TrueType */ +@font-face { + font-family: "Noto Sans"; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url("../fonts/noto/notosans-variable.ttf") format("truetype-variations"); +} + +@font-face { + font-family: "Noto Sans"; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: url("../fonts/noto/notosans-italic-variable.ttf") format("truetype-variations"); +} + +@font-face { + font-family: "Noto Color Emoji"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/noto/notocoloremoji-regular.ttf") format("truetype"); } diff --git a/assets/styles/icons.css b/assets/styles/icons.css index 32d736bb..578fe9a7 100644 --- a/assets/styles/icons.css +++ b/assets/styles/icons.css @@ -1,4 +1,6 @@ -/* Auto-generated helper classes for Tabler icons */ +/* Auto-generated helper classes for Tabler icons 3.44.0. */ +/* Source: @tabler/icons-webfont@3.44.0/dist/tabler-icons.css. */ + .ti { font-family: "tabler-icons" !important; speak: none; @@ -11,18 +13,6 @@ -moz-osx-font-smoothing: grayscale; } -.ti-3d-cube-sphere:before { - content: "\ecd7"; -} - -.ti-3d-cube-sphere-off:before { - content: "\f3b5"; -} - -.ti-3d-rotate:before { - content: "\f020"; -} - .ti-a-b:before { content: "\ec36"; } @@ -63,6 +53,14 @@ content: "\f0a7"; } +.ti-acorn:before { + content: "\10255"; +} + +.ti-acrobatic:before { + content: "\10254"; +} + .ti-activity:before { content: "\ed23"; } @@ -79,6 +77,14 @@ content: "\ef1f"; } +.ti-ad-circle:before { + content: "\f79e"; +} + +.ti-ad-circle-off:before { + content: "\f79d"; +} + .ti-ad-off:before { content: "\f3b7"; } @@ -99,14 +105,94 @@ content: "\ec37"; } +.ti-adjustments-bolt:before { + content: "\f7fb"; +} + +.ti-adjustments-cancel:before { + content: "\f7fc"; +} + +.ti-adjustments-check:before { + content: "\f7fd"; +} + +.ti-adjustments-code:before { + content: "\f7fe"; +} + +.ti-adjustments-cog:before { + content: "\f7ff"; +} + +.ti-adjustments-dollar:before { + content: "\f800"; +} + +.ti-adjustments-down:before { + content: "\f801"; +} + +.ti-adjustments-exclamation:before { + content: "\f802"; +} + +.ti-adjustments-heart:before { + content: "\f803"; +} + .ti-adjustments-horizontal:before { content: "\ec38"; } +.ti-adjustments-minus:before { + content: "\f804"; +} + .ti-adjustments-off:before { content: "\f0a8"; } +.ti-adjustments-pause:before { + content: "\f805"; +} + +.ti-adjustments-pin:before { + content: "\f806"; +} + +.ti-adjustments-plus:before { + content: "\f807"; +} + +.ti-adjustments-question:before { + content: "\f808"; +} + +.ti-adjustments-search:before { + content: "\f809"; +} + +.ti-adjustments-share:before { + content: "\f80a"; +} + +.ti-adjustments-spark:before { + content: "\ffbe"; +} + +.ti-adjustments-star:before { + content: "\f80b"; +} + +.ti-adjustments-up:before { + content: "\f80c"; +} + +.ti-adjustments-x:before { + content: "\f80d"; +} + .ti-aerial-lift:before { content: "\edfe"; } @@ -115,6 +201,22 @@ content: "\edff"; } +.ti-ai:before { + content: "\fee7"; +} + +.ti-ai-agent:before { + content: "\101f9"; +} + +.ti-ai-agents:before { + content: "\101f8"; +} + +.ti-ai-gateway:before { + content: "\101f7"; +} + .ti-air-balloon:before { content: "\f4a6"; } @@ -127,10 +229,18 @@ content: "\f542"; } +.ti-air-traffic-control:before { + content: "\fb01"; +} + .ti-alarm:before { content: "\ea04"; } +.ti-alarm-average:before { + content: "\fc9e"; +} + .ti-alarm-minus:before { content: "\f630"; } @@ -143,6 +253,10 @@ content: "\f631"; } +.ti-alarm-smoke:before { + content: "\100b6"; +} + .ti-alarm-snooze:before { content: "\f632"; } @@ -159,14 +273,50 @@ content: "\ea05"; } +.ti-alert-circle-off:before { + content: "\fc65"; +} + +.ti-alert-hexagon:before { + content: "\f80e"; +} + +.ti-alert-hexagon-off:before { + content: "\fc66"; +} + .ti-alert-octagon:before { content: "\ecc6"; } +.ti-alert-small:before { + content: "\f80f"; +} + +.ti-alert-small-off:before { + content: "\fc67"; +} + +.ti-alert-square:before { + content: "\f811"; +} + +.ti-alert-square-rounded:before { + content: "\f810"; +} + +.ti-alert-square-rounded-off:before { + content: "\fc68"; +} + .ti-alert-triangle:before { content: "\ea06"; } +.ti-alert-triangle-off:before { + content: "\fc69"; +} + .ti-alien:before { content: "\ebde"; } @@ -183,6 +333,22 @@ content: "\f532"; } +.ti-align-box-center-bottom:before { + content: "\facb"; +} + +.ti-align-box-center-middle:before { + content: "\f79f"; +} + +.ti-align-box-center-stretch:before { + content: "\facc"; +} + +.ti-align-box-center-top:before { + content: "\facd"; +} + .ti-align-box-left-bottom:before { content: "\f533"; } @@ -191,6 +357,10 @@ content: "\f534"; } +.ti-align-box-left-stretch:before { + content: "\face"; +} + .ti-align-box-left-top:before { content: "\f535"; } @@ -203,6 +373,10 @@ content: "\f537"; } +.ti-align-box-right-stretch:before { + content: "\facf"; +} + .ti-align-box-right-top:before { content: "\f538"; } @@ -231,14 +405,30 @@ content: "\ea09"; } +.ti-align-left-2:before { + content: "\ff00"; +} + .ti-align-right:before { content: "\ea0a"; } +.ti-align-right-2:before { + content: "\feff"; +} + .ti-alpha:before { content: "\f543"; } +.ti-alphabet-arabic:before { + content: "\ff2f"; +} + +.ti-alphabet-bangla:before { + content: "\ff2e"; +} + .ti-alphabet-cyrillic:before { content: "\f1df"; } @@ -247,10 +437,34 @@ content: "\f1e0"; } +.ti-alphabet-hebrew:before { + content: "\ff2d"; +} + +.ti-alphabet-korean:before { + content: "\ff2c"; +} + .ti-alphabet-latin:before { content: "\f1e1"; } +.ti-alphabet-polish:before { + content: "\101b1"; +} + +.ti-alphabet-runes:before { + content: "\101b0"; +} + +.ti-alphabet-thai:before { + content: "\ff2b"; +} + +.ti-alt:before { + content: "\fc54"; +} + .ti-ambulance:before { content: "\ebf5"; } @@ -335,6 +549,10 @@ content: "\f0ab"; } +.ti-api-book:before { + content: "\1020b"; +} + .ti-api-off:before { content: "\f0f8"; } @@ -355,6 +573,10 @@ content: "\f0ac"; } +.ti-archery-arrow:before { + content: "\fc55"; +} + .ti-archive:before { content: "\ea0b"; } @@ -415,6 +637,10 @@ content: "\eb77"; } +.ti-arrow-back-up-double:before { + content: "\f9ec"; +} + .ti-arrow-badge-down:before { content: "\f60b"; } @@ -431,6 +657,10 @@ content: "\f60e"; } +.ti-arrow-bar-both:before { + content: "\fadd"; +} + .ti-arrow-bar-down:before { content: "\ea0d"; } @@ -447,18 +677,34 @@ content: "\ec88"; } +.ti-arrow-bar-to-down-dashed:before { + content: "\10164"; +} + .ti-arrow-bar-to-left:before { content: "\ec89"; } +.ti-arrow-bar-to-left-dashed:before { + content: "\10163"; +} + .ti-arrow-bar-to-right:before { content: "\ec8a"; } +.ti-arrow-bar-to-right-dashed:before { + content: "\10162"; +} + .ti-arrow-bar-to-up:before { content: "\ec8b"; } +.ti-arrow-bar-to-up-dashed:before { + content: "\10161"; +} + .ti-arrow-bar-up:before { content: "\ea10"; } @@ -515,7 +761,7 @@ content: "\efed"; } -.ti-arrow-big-top:before { +.ti-arrow-big-up:before { content: "\eddd"; } @@ -531,6 +777,10 @@ content: "\f3a4"; } +.ti-arrow-capsule:before { + content: "\fade"; +} + .ti-arrow-curve-left:before { content: "\f048"; } @@ -551,6 +801,14 @@ content: "\ea11"; } +.ti-arrow-down-dashed:before { + content: "\1006a"; +} + +.ti-arrow-down-from-arc:before { + content: "\fd86"; +} + .ti-arrow-down-left:before { content: "\ea13"; } @@ -579,6 +837,18 @@ content: "\ed9b"; } +.ti-arrow-down-to-arc:before { + content: "\fd87"; +} + +.ti-arrow-elbow-left:before { + content: "\f9ed"; +} + +.ti-arrow-elbow-right:before { + content: "\f9ee"; +} + .ti-arrow-fork:before { content: "\f04a"; } @@ -591,6 +861,10 @@ content: "\eb78"; } +.ti-arrow-forward-up-double:before { + content: "\f9ef"; +} + .ti-arrow-guide:before { content: "\f22a"; } @@ -611,6 +885,14 @@ content: "\ea18"; } +.ti-arrow-left-dashed:before { + content: "\10069"; +} + +.ti-arrow-left-from-arc:before { + content: "\fd88"; +} + .ti-arrow-left-rhombus:before { content: "\f61e"; } @@ -627,6 +909,10 @@ content: "\ed9e"; } +.ti-arrow-left-to-arc:before { + content: "\fd89"; +} + .ti-arrow-loop-left:before { content: "\ed9f"; } @@ -647,6 +933,14 @@ content: "\f04e"; } +.ti-arrow-merge-alt-left:before { + content: "\fc9f"; +} + +.ti-arrow-merge-alt-right:before { + content: "\fca0"; +} + .ti-arrow-merge-both:before { content: "\f23b"; } @@ -679,18 +973,34 @@ content: "\ea1a"; } +.ti-arrow-narrow-down-dashed:before { + content: "\10068"; +} + .ti-arrow-narrow-left:before { content: "\ea1b"; } +.ti-arrow-narrow-left-dashed:before { + content: "\10067"; +} + .ti-arrow-narrow-right:before { content: "\ea1c"; } +.ti-arrow-narrow-right-dashed:before { + content: "\10066"; +} + .ti-arrow-narrow-up:before { content: "\ea1d"; } +.ti-arrow-narrow-up-dashed:before { + content: "\10065"; +} + .ti-arrow-ramp-left:before { content: "\ed3c"; } @@ -727,6 +1037,14 @@ content: "\ea1e"; } +.ti-arrow-right-dashed:before { + content: "\10064"; +} + +.ti-arrow-right-from-arc:before { + content: "\fd8a"; +} + .ti-arrow-right-rhombus:before { content: "\f61f"; } @@ -739,6 +1057,10 @@ content: "\eda3"; } +.ti-arrow-right-to-arc:before { + content: "\fd8b"; +} + .ti-arrow-rotary-first-left:before { content: "\f053"; } @@ -795,6 +1117,14 @@ content: "\ea20"; } +.ti-arrow-up-dashed:before { + content: "\10063"; +} + +.ti-arrow-up-from-arc:before { + content: "\fd8c"; +} + .ti-arrow-up-left:before { content: "\ea22"; } @@ -823,6 +1153,10 @@ content: "\eda7"; } +.ti-arrow-up-to-arc:before { + content: "\fd8d"; +} + .ti-arrow-wave-left-down:before { content: "\eda8"; } @@ -987,6 +1321,10 @@ content: "\f2cd"; } +.ti-arrows-transfer-up-down:before { + content: "\ffac"; +} + .ti-arrows-up:before { content: "\edb7"; } @@ -1083,6 +1421,22 @@ content: "\f3c1"; } +.ti-auth-2fa:before { + content: "\eca0"; +} + +.ti-automatic-gearbox:before { + content: "\fc89"; +} + +.ti-automation:before { + content: "\fef8"; +} + +.ti-avocado:before { + content: "\fd8e"; +} + .ti-award:before { content: "\ea2c"; } @@ -1111,6 +1465,10 @@ content: "\f05d"; } +.ti-background:before { + content: "\fd2c"; +} + .ti-backhoe:before { content: "\ed86"; } @@ -1123,6 +1481,10 @@ content: "\f3c2"; } +.ti-backslash:before { + content: "\fab9"; +} + .ti-backspace:before { content: "\ea2d"; } @@ -1131,14 +1493,26 @@ content: "\efc2"; } +.ti-badge-2k:before { + content: "\100b5"; +} + .ti-badge-3d:before { content: "\f555"; } +.ti-badge-3k:before { + content: "\100b4"; +} + .ti-badge-4k:before { content: "\f556"; } +.ti-badge-5k:before { + content: "\100b3"; +} + .ti-badge-8k:before { content: "\f557"; } @@ -1147,6 +1521,10 @@ content: "\f558"; } +.ti-badge-ad-off:before { + content: "\fd8f"; +} + .ti-badge-ar:before { content: "\f559"; } @@ -1231,11 +1609,11 @@ content: "\ec2b"; } -.ti-ballon:before { +.ti-balloon:before { content: "\ef3a"; } -.ti-ballon-off:before { +.ti-balloon-off:before { content: "\f0fd"; } @@ -1251,6 +1629,10 @@ content: "\ea2e"; } +.ti-banana:before { + content: "\10253"; +} + .ti-bandage:before { content: "\eb7a"; } @@ -1295,14 +1677,106 @@ content: "\f024"; } +.ti-baseline-density-large:before { + content: "\f9f0"; +} + +.ti-baseline-density-medium:before { + content: "\f9f1"; +} + +.ti-baseline-density-small:before { + content: "\f9f2"; +} + .ti-basket:before { content: "\ebe1"; } +.ti-basket-bolt:before { + content: "\fb43"; +} + +.ti-basket-cancel:before { + content: "\fb44"; +} + +.ti-basket-check:before { + content: "\fb45"; +} + +.ti-basket-code:before { + content: "\fb46"; +} + +.ti-basket-cog:before { + content: "\fb47"; +} + +.ti-basket-discount:before { + content: "\fb48"; +} + +.ti-basket-dollar:before { + content: "\fb49"; +} + +.ti-basket-down:before { + content: "\fb4a"; +} + +.ti-basket-exclamation:before { + content: "\fb4b"; +} + +.ti-basket-heart:before { + content: "\fb4c"; +} + +.ti-basket-minus:before { + content: "\fb4d"; +} + .ti-basket-off:before { content: "\f0b6"; } +.ti-basket-pause:before { + content: "\fb4e"; +} + +.ti-basket-pin:before { + content: "\fb4f"; +} + +.ti-basket-plus:before { + content: "\fb50"; +} + +.ti-basket-question:before { + content: "\fb51"; +} + +.ti-basket-search:before { + content: "\fb52"; +} + +.ti-basket-share:before { + content: "\fb53"; +} + +.ti-basket-star:before { + content: "\fb54"; +} + +.ti-basket-up:before { + content: "\fb55"; +} + +.ti-basket-x:before { + content: "\fb56"; +} + .ti-bat:before { content: "\f284"; } @@ -1351,10 +1825,58 @@ content: "\ef3c"; } -.ti-battery-off:before { +.ti-battery-exclamation:before { + content: "\ff1d"; +} + +.ti-battery-off:before { content: "\ed1c"; } +.ti-battery-spark:before { + content: "\ffbd"; +} + +.ti-battery-vertical:before { + content: "\ff13"; +} + +.ti-battery-vertical-1:before { + content: "\ff1c"; +} + +.ti-battery-vertical-2:before { + content: "\ff1b"; +} + +.ti-battery-vertical-3:before { + content: "\ff1a"; +} + +.ti-battery-vertical-4:before { + content: "\ff19"; +} + +.ti-battery-vertical-charging:before { + content: "\ff17"; +} + +.ti-battery-vertical-charging-2:before { + content: "\ff18"; +} + +.ti-battery-vertical-eco:before { + content: "\ff16"; +} + +.ti-battery-vertical-exclamation:before { + content: "\ff15"; +} + +.ti-battery-vertical-off:before { + content: "\ff14"; +} + .ti-beach:before { content: "\ef3d"; } @@ -1367,6 +1889,10 @@ content: "\eb5c"; } +.ti-bed-flat:before { + content: "\fca1"; +} + .ti-bed-off:before { content: "\f100"; } @@ -1383,6 +1909,42 @@ content: "\ea35"; } +.ti-bell-bolt:before { + content: "\f812"; +} + +.ti-bell-cancel:before { + content: "\f813"; +} + +.ti-bell-check:before { + content: "\f814"; +} + +.ti-bell-code:before { + content: "\f815"; +} + +.ti-bell-cog:before { + content: "\f816"; +} + +.ti-bell-dollar:before { + content: "\f817"; +} + +.ti-bell-down:before { + content: "\f818"; +} + +.ti-bell-exclamation:before { + content: "\f819"; +} + +.ti-bell-heart:before { + content: "\f81a"; +} + .ti-bell-minus:before { content: "\ede2"; } @@ -1391,10 +1953,22 @@ content: "\ece9"; } +.ti-bell-pause:before { + content: "\f81b"; +} + +.ti-bell-pin:before { + content: "\f81c"; +} + .ti-bell-plus:before { content: "\ede3"; } +.ti-bell-question:before { + content: "\f81d"; +} + .ti-bell-ringing:before { content: "\ed07"; } @@ -1407,6 +1981,22 @@ content: "\f05e"; } +.ti-bell-search:before { + content: "\f81e"; +} + +.ti-bell-share:before { + content: "\f81f"; +} + +.ti-bell-star:before { + content: "\f820"; +} + +.ti-bell-up:before { + content: "\f821"; +} + .ti-bell-x:before { content: "\ede5"; } @@ -1447,6 +2037,10 @@ content: "\f5d3"; } +.ti-binoculars:before { + content: "\fefe"; +} + .ti-biohazard:before { content: "\ecb8"; } @@ -1475,10 +2069,30 @@ content: "\f2f2"; } +.ti-blend-mode:before { + content: "\feb0"; +} + +.ti-blender:before { + content: "\fca2"; +} + +.ti-blind:before { + content: "\101af"; +} + +.ti-blob:before { + content: "\feaf"; +} + .ti-blockquote:before { content: "\ee09"; } +.ti-blocks:before { + content: "\100b2"; +} + .ti-bluetooth:before { content: "\ea37"; } @@ -1507,6 +2121,10 @@ content: "\f3a6"; } +.ti-body-scan:before { + content: "\fca3"; +} + .ti-bold:before { content: "\eb7b"; } @@ -1567,10 +2185,30 @@ content: "\ea3a"; } +.ti-bookmark-ai:before { + content: "\fc8a"; +} + +.ti-bookmark-edit:before { + content: "\fa5e"; +} + +.ti-bookmark-minus:before { + content: "\fa5f"; +} + .ti-bookmark-off:before { content: "\eced"; } +.ti-bookmark-plus:before { + content: "\fa60"; +} + +.ti-bookmark-question:before { + content: "\fa61"; +} + .ti-bookmarks:before { content: "\ed08"; } @@ -1587,6 +2225,10 @@ content: "\f0be"; } +.ti-boom:before { + content: "\fdbe"; +} + .ti-border-all:before { content: "\ea3b"; } @@ -1595,6 +2237,30 @@ content: "\ea3c"; } +.ti-border-bottom-plus:before { + content: "\fdbd"; +} + +.ti-border-corner-ios:before { + content: "\fd98"; +} + +.ti-border-corner-pill:before { + content: "\fd62"; +} + +.ti-border-corner-rounded:before { + content: "\fd63"; +} + +.ti-border-corner-square:before { + content: "\fd64"; +} + +.ti-border-corners:before { + content: "\f7a0"; +} + .ti-border-horizontal:before { content: "\ea3d"; } @@ -1607,6 +2273,10 @@ content: "\ea3f"; } +.ti-border-left-plus:before { + content: "\fdbc"; +} + .ti-border-none:before { content: "\ea40"; } @@ -1623,6 +2293,14 @@ content: "\ea42"; } +.ti-border-right-plus:before { + content: "\fdbb"; +} + +.ti-border-sides:before { + content: "\f7a1"; +} + .ti-border-style:before { content: "\ee0a"; } @@ -1635,10 +2313,18 @@ content: "\ea43"; } +.ti-border-top-plus:before { + content: "\fdba"; +} + .ti-border-vertical:before { content: "\ea44"; } +.ti-bot-id:before { + content: "\101f6"; +} + .ti-bottle:before { content: "\ef0b"; } @@ -1663,6 +2349,18 @@ content: "\f4fa"; } +.ti-bowl-chopsticks:before { + content: "\fd90"; +} + +.ti-bowl-spoon:before { + content: "\fd91"; +} + +.ti-bowling:before { + content: "\100b1"; +} + .ti-box:before { content: "\ea45"; } @@ -1771,10 +2469,6 @@ content: "\ee18"; } -.ti-box-seam:before { - content: "\f561"; -} - .ti-braces:before { content: "\ebcc"; } @@ -1787,6 +2481,14 @@ content: "\ebcd"; } +.ti-brackets-angle:before { + content: "\fcb2"; +} + +.ti-brackets-angle-off:before { + content: "\fcb1"; +} + .ti-brackets-contain:before { content: "\f1e5"; } @@ -1803,7 +2505,7 @@ content: "\f0c0"; } -.ti-braile:before { +.ti-braille:before { content: "\f545"; } @@ -1823,6 +2525,30 @@ content: "\f0dc"; } +.ti-brand-adobe-after-effect:before { + content: "\ff2a"; +} + +.ti-brand-adobe-illustrator:before { + content: "\ff29"; +} + +.ti-brand-adobe-indesign:before { + content: "\ff28"; +} + +.ti-brand-adobe-photoshop:before { + content: "\ff27"; +} + +.ti-brand-adobe-premiere:before { + content: "\ff26"; +} + +.ti-brand-adobe-xd:before { + content: "\ff25"; +} + .ti-brand-adonis-js:before { content: "\f496"; } @@ -1839,6 +2565,10 @@ content: "\f390"; } +.ti-brand-alipay:before { + content: "\f7a2"; +} + .ti-brand-alpine-js:before { content: "\f324"; } @@ -1851,11 +2581,15 @@ content: "\f653"; } +.ti-brand-amie:before { + content: "\ffab"; +} + .ti-brand-amigo:before { content: "\f5f9"; } -.ti-brand-amongus:before { +.ti-brand-among-us:before { content: "\f205"; } @@ -1867,6 +2601,10 @@ content: "\ef6b"; } +.ti-brand-ansible:before { + content: "\fa70"; +} + .ti-brand-ao3:before { content: "\f5e8"; } @@ -1883,6 +2621,10 @@ content: "\ed69"; } +.ti-brand-apple-news:before { + content: "\ff24"; +} + .ti-brand-apple-podcast:before { content: "\f1e6"; } @@ -1891,10 +2633,34 @@ content: "\ed24"; } +.ti-brand-arc:before { + content: "\feae"; +} + .ti-brand-asana:before { content: "\edc5"; } +.ti-brand-astro:before { + content: "\fdb9"; +} + +.ti-brand-audible:before { + content: "\10252"; +} + +.ti-brand-auth0:before { + content: "\fcb3"; +} + +.ti-brand-aws:before { + content: "\fa4c"; +} + +.ti-brand-azure:before { + content: "\fa4d"; +} + .ti-brand-backbone:before { content: "\f325"; } @@ -1919,10 +2685,18 @@ content: "\f208"; } +.ti-brand-bebo:before { + content: "\ffaa"; +} + .ti-brand-behance:before { content: "\ec6e"; } +.ti-brand-bilibili:before { + content: "\f6d2"; +} + .ti-brand-binance:before { content: "\f5a0"; } @@ -1935,7 +2709,7 @@ content: "\edc7"; } -.ti-brand-blackbery:before { +.ti-brand-blackberry:before { content: "\f568"; } @@ -1947,6 +2721,10 @@ content: "\f35a"; } +.ti-brand-bluesky:before { + content: "\fd75"; +} + .ti-brand-booking:before { content: "\edc8"; } @@ -1967,6 +2745,18 @@ content: "\f4cf"; } +.ti-brand-c-sharp:before { + content: "\f003"; +} + +.ti-brand-cake:before { + content: "\f7a3"; +} + +.ti-brand-cakephp:before { + content: "\f7af"; +} + .ti-brand-campaignmonitor:before { content: "\f328"; } @@ -1983,10 +2773,18 @@ content: "\ec18"; } +.ti-brand-cinema-4d:before { + content: "\fa71"; +} + .ti-brand-citymapper:before { content: "\f5fc"; } +.ti-brand-cloudflare:before { + content: "\fa4e"; +} + .ti-brand-codecov:before { content: "\f329"; } @@ -2027,6 +2825,14 @@ content: "\f5fe"; } +.ti-brand-craft:before { + content: "\fa72"; +} + +.ti-brand-crunchbase:before { + content: "\f7e3"; +} + .ti-brand-css3:before { content: "\ed6b"; } @@ -2051,6 +2857,10 @@ content: "\f24e"; } +.ti-brand-databricks:before { + content: "\fc41"; +} + .ti-brand-days-counter:before { content: "\f4d2"; } @@ -2063,6 +2873,10 @@ content: "\ef57"; } +.ti-brand-deezer:before { + content: "\f78b"; +} + .ti-brand-deliveroo:before { content: "\f4d3"; } @@ -2079,6 +2893,10 @@ content: "\ecfb"; } +.ti-brand-digg:before { + content: "\fa73"; +} + .ti-brand-dingtalk:before { content: "\f5ea"; } @@ -2119,6 +2937,10 @@ content: "\ec19"; } +.ti-brand-dropbox:before { + content: "\1018a"; +} + .ti-brand-drops:before { content: "\f4d5"; } @@ -2135,6 +2957,10 @@ content: "\f611"; } +.ti-brand-electronic-arts:before { + content: "\fa74"; +} + .ti-brand-ember:before { content: "\f497"; } @@ -2155,10 +2981,18 @@ content: "\ec1a"; } +.ti-brand-feedly:before { + content: "\fa75"; +} + .ti-brand-figma:before { content: "\ec93"; } +.ti-brand-filezilla:before { + content: "\fa76"; +} + .ti-brand-finder:before { content: "\f218"; } @@ -2171,6 +3005,10 @@ content: "\ecfd"; } +.ti-brand-fiverr:before { + content: "\f7a4"; +} + .ti-brand-flickr:before { content: "\ecfe"; } @@ -2199,6 +3037,10 @@ content: "\ec1b"; } +.ti-brand-framer-motion:before { + content: "\f78c"; +} + .ti-brand-funimation:before { content: "\f655"; } @@ -2227,6 +3069,10 @@ content: "\efa2"; } +.ti-brand-golang:before { + content: "\f78d"; +} + .ti-brand-google:before { content: "\ec1f"; } @@ -2251,6 +3097,10 @@ content: "\f601"; } +.ti-brand-google-maps:before { + content: "\fa4f"; +} + .ti-brand-google-one:before { content: "\f232"; } @@ -2291,6 +3141,10 @@ content: "\f5d6"; } +.ti-brand-hackerrank:before { + content: "\ff23"; +} + .ti-brand-hbo:before { content: "\f657"; } @@ -2299,6 +3153,10 @@ content: "\f32d"; } +.ti-brand-hexo:before { + content: "\fa50"; +} + .ti-brand-hipchat:before { content: "\edcd"; } @@ -2311,6 +3169,10 @@ content: "\f34a"; } +.ti-brand-infakt:before { + content: "\1020a"; +} + .ti-brand-instagram:before { content: "\ec20"; } @@ -2319,10 +3181,34 @@ content: "\f1cf"; } +.ti-brand-itch:before { + content: "\fa22"; +} + .ti-brand-javascript:before { content: "\ef0c"; } +.ti-brand-jira:before { + content: "\10231"; +} + +.ti-brand-juejin:before { + content: "\f7b0"; +} + +.ti-brand-kako-talk:before { + content: "\fd2d"; +} + +.ti-brand-kbin:before { + content: "\fad0"; +} + +.ti-brand-kick:before { + content: "\fa23"; +} + .ti-brand-kickstarter:before { content: "\edce"; } @@ -2339,6 +3225,18 @@ content: "\f001"; } +.ti-brand-leetcode:before { + content: "\fa51"; +} + +.ti-brand-letterboxd:before { + content: "\fa24"; +} + +.ti-brand-line:before { + content: "\f7e8"; +} + .ti-brand-linkedin:before { content: "\ec8c"; } @@ -2351,6 +3249,10 @@ content: "\f562"; } +.ti-brand-livewire:before { + content: "\fd76"; +} + .ti-brand-loom:before { content: "\ef70"; } @@ -2383,6 +3285,10 @@ content: "\ec70"; } +.ti-brand-meetup:before { + content: "\fc6a"; +} + .ti-brand-mercedes:before { content: "\f072"; } @@ -2395,6 +3301,14 @@ content: "\efb0"; } +.ti-brand-metabrainz:before { + content: "\ff12"; +} + +.ti-brand-minecraft:before { + content: "\faef"; +} + .ti-brand-miniprogram:before { content: "\f602"; } @@ -2451,6 +3365,10 @@ content: "\f0dd"; } +.ti-brand-nodejs:before { + content: "\fae0"; +} + .ti-brand-nord-vpn:before { content: "\f37f"; } @@ -2471,6 +3389,10 @@ content: "\ef8d"; } +.ti-brand-oauth:before { + content: "\fa52"; +} + .ti-brand-office:before { content: "\f398"; } @@ -2491,6 +3413,10 @@ content: "\edd0"; } +.ti-brand-openai:before { + content: "\f78e"; +} + .ti-brand-openvpn:before { content: "\f39a"; } @@ -2503,6 +3429,10 @@ content: "\edd1"; } +.ti-brand-parsinta:before { + content: "\fc42"; +} + .ti-brand-patreon:before { content: "\edd2"; } @@ -2535,6 +3465,14 @@ content: "\ec8d"; } +.ti-brand-planetscale:before { + content: "\f78f"; +} + +.ti-brand-pnpm:before { + content: "\fd77"; +} + .ti-brand-pocket:before { content: "\ed00"; } @@ -2547,6 +3485,10 @@ content: "\f5ed"; } +.ti-brand-printables:before { + content: "\fd1b"; +} + .ti-brand-prisma:before { content: "\f499"; } @@ -2571,6 +3513,10 @@ content: "\f606"; } +.ti-brand-radix-ui:before { + content: "\f790"; +} + .ti-brand-react:before { content: "\f34c"; } @@ -2599,6 +3545,14 @@ content: "\f4da"; } +.ti-brand-rumble:before { + content: "\fad1"; +} + +.ti-brand-rust:before { + content: "\fa53"; +} + .ti-brand-safari:before { content: "\ec23"; } @@ -2667,6 +3621,10 @@ content: "\f4fc"; } +.ti-brand-speedtest:before { + content: "\fa77"; +} + .ti-brand-spotify:before { content: "\ed03"; } @@ -2683,6 +3641,18 @@ content: "\ed6f"; } +.ti-brand-stellar:before { + content: "\10243"; +} + +.ti-brand-stocktwits:before { + content: "\fd78"; +} + +.ti-brand-storj:before { + content: "\fa54"; +} + .ti-brand-storybook:before { content: "\f332"; } @@ -2703,7 +3673,15 @@ content: "\ef74"; } -.ti-brand-superhuman:before { +.ti-brand-sugarizer:before { + content: "\f7a5"; +} + +.ti-brand-supabase:before { + content: "\f6d3"; +} + +.ti-brand-superhuman:before { content: "\f50c"; } @@ -2719,6 +3697,10 @@ content: "\f0df"; } +.ti-brand-swift:before { + content: "\fa55"; +} + .ti-brand-symfony:before { content: "\f616"; } @@ -2727,6 +3709,10 @@ content: "\ec8f"; } +.ti-brand-tabnine:before { + content: "\101ae"; +} + .ti-brand-tailwind:before { content: "\eca1"; } @@ -2735,6 +3721,10 @@ content: "\f5ef"; } +.ti-brand-teams:before { + content: "\fadf"; +} + .ti-brand-ted:before { content: "\f658"; } @@ -2743,10 +3733,26 @@ content: "\ec26"; } +.ti-brand-terraform:before { + content: "\fa56"; +} + +.ti-brand-tesla:before { + content: "\10099"; +} + .ti-brand-tether:before { content: "\f5a3"; } +.ti-brand-thingiverse:before { + content: "\fd1c"; +} + +.ti-brand-threads:before { + content: "\fb02"; +} + .ti-brand-threejs:before { content: "\f5f0"; } @@ -2827,6 +3833,10 @@ content: "\f39f"; } +.ti-brand-vechain:before { + content: "\10242"; +} + .ti-brand-vercel:before { content: "\ef24"; } @@ -2859,6 +3869,10 @@ content: "\ed72"; } +.ti-brand-vlc:before { + content: "\fa78"; +} + .ti-brand-volkswagen:before { content: "\f50e"; } @@ -2899,6 +3913,10 @@ content: "\ec74"; } +.ti-brand-wikipedia:before { + content: "\fa79"; +} + .ti-brand-windows:before { content: "\ecd8"; } @@ -2919,10 +3937,22 @@ content: "\f2d3"; } +.ti-brand-x:before { + content: "\fc0f"; +} + +.ti-brand-xamarin:before { + content: "\fa7a"; +} + .ti-brand-xbox:before { content: "\f298"; } +.ti-brand-xdeep:before { + content: "\fc10"; +} + .ti-brand-xing:before { content: "\f21a"; } @@ -2931,6 +3961,14 @@ content: "\ed73"; } +.ti-brand-yandex:before { + content: "\fae1"; +} + +.ti-brand-yarn:before { + content: "\fd79"; +} + .ti-brand-yatse:before { content: "\f213"; } @@ -2987,6 +4025,10 @@ content: "\ea46"; } +.ti-briefcase-2:before { + content: "\fb03"; +} + .ti-briefcase-off:before { content: "\f3cc"; } @@ -2999,6 +4041,10 @@ content: "\ee19"; } +.ti-brightness-auto:before { + content: "\fd99"; +} + .ti-brightness-down:before { content: "\eb7d"; } @@ -3031,6 +4077,14 @@ content: "\efd6"; } +.ti-browser-maximize:before { + content: "\100b0"; +} + +.ti-browser-minus:before { + content: "\100af"; +} + .ti-browser-off:before { content: "\f0c1"; } @@ -3039,6 +4093,10 @@ content: "\efd7"; } +.ti-browser-share:before { + content: "\100ae"; +} + .ti-browser-x:before { content: "\efd8"; } @@ -3051,6 +4109,34 @@ content: "\f0c2"; } +.ti-bubble:before { + content: "\feba"; +} + +.ti-bubble-minus:before { + content: "\febe"; +} + +.ti-bubble-plus:before { + content: "\febd"; +} + +.ti-bubble-tea:before { + content: "\ff51"; +} + +.ti-bubble-tea-2:before { + content: "\ff52"; +} + +.ti-bubble-text:before { + content: "\febc"; +} + +.ti-bubble-x:before { + content: "\febb"; +} + .ti-bucket:before { content: "\ea47"; } @@ -3075,6 +4161,10 @@ content: "\ea4f"; } +.ti-building-airport:before { + content: "\ffa9"; +} + .ti-building-arch:before { content: "\ea49"; } @@ -3095,6 +4185,10 @@ content: "\f4be"; } +.ti-building-burj-al-arab:before { + content: "\ff50"; +} + .ti-building-carousel:before { content: "\ed87"; } @@ -3111,6 +4205,10 @@ content: "\f4bf"; } +.ti-building-cog:before { + content: "\10062"; +} + .ti-building-community:before { content: "\ebf6"; } @@ -3119,6 +4217,10 @@ content: "\ee1b"; } +.ti-building-eiffel-tower:before { + content: "\10251"; +} + .ti-building-estate:before { content: "\f5a5"; } @@ -3143,14 +4245,30 @@ content: "\ed8a"; } +.ti-building-minus:before { + content: "\10061"; +} + .ti-building-monument:before { content: "\ed26"; } -.ti-building-pavilon:before { +.ti-building-mosque:before { + content: "\fa57"; +} + +.ti-building-off:before { + content: "\fefd"; +} + +.ti-building-pavilion:before { content: "\ebf7"; } +.ti-building-plus:before { + content: "\10060"; +} + .ti-building-skyscraper:before { content: "\ec39"; } @@ -3175,6 +4293,10 @@ content: "\f4c0"; } +.ti-buildings:before { + content: "\ff40"; +} + .ti-bulb:before { content: "\ea51"; } @@ -3187,6 +4309,10 @@ content: "\ee1d"; } +.ti-burger:before { + content: "\fcb4"; +} + .ti-bus:before { content: "\ebe4"; } @@ -3207,10 +4333,6 @@ content: "\efd9"; } -.ti-c-sharp:before { - content: "\f003"; -} - .ti-cactus:before { content: "\f21b"; } @@ -3227,6 +4349,10 @@ content: "\f104"; } +.ti-cake-roll:before { + content: "\100bd"; +} + .ti-calculator:before { content: "\eb80"; } @@ -3239,6 +4365,42 @@ content: "\ea53"; } +.ti-calendar-bolt:before { + content: "\f822"; +} + +.ti-calendar-cancel:before { + content: "\f823"; +} + +.ti-calendar-check:before { + content: "\f824"; +} + +.ti-calendar-clock:before { + content: "\fd2e"; +} + +.ti-calendar-code:before { + content: "\f825"; +} + +.ti-calendar-cog:before { + content: "\f826"; +} + +.ti-calendar-dollar:before { + content: "\f827"; +} + +.ti-calendar-dot:before { + content: "\fd3e"; +} + +.ti-calendar-down:before { + content: "\f828"; +} + .ti-calendar-due:before { content: "\f621"; } @@ -3247,18 +4409,66 @@ content: "\ea52"; } +.ti-calendar-exclamation:before { + content: "\f829"; +} + +.ti-calendar-heart:before { + content: "\f82a"; +} + .ti-calendar-minus:before { content: "\ebb9"; } +.ti-calendar-month:before { + content: "\fd2f"; +} + .ti-calendar-off:before { content: "\ee1f"; } +.ti-calendar-pause:before { + content: "\f82b"; +} + +.ti-calendar-pin:before { + content: "\f82c"; +} + .ti-calendar-plus:before { content: "\ebba"; } +.ti-calendar-question:before { + content: "\f82d"; +} + +.ti-calendar-repeat:before { + content: "\fad2"; +} + +.ti-calendar-sad:before { + content: "\fd1d"; +} + +.ti-calendar-search:before { + content: "\f82e"; +} + +.ti-calendar-share:before { + content: "\f82f"; +} + +.ti-calendar-smile:before { + content: "\fd1e"; +} + +.ti-calendar-star:before { + content: "\f830"; +} + .ti-calendar-stats:before { content: "\ee20"; } @@ -3267,34 +4477,146 @@ content: "\ee21"; } +.ti-calendar-up:before { + content: "\f831"; +} + +.ti-calendar-user:before { + content: "\fd1f"; +} + +.ti-calendar-week:before { + content: "\fd30"; +} + +.ti-calendar-x:before { + content: "\f832"; +} + .ti-camera:before { content: "\ea54"; } +.ti-camera-ai:before { + content: "\ffa8"; +} + +.ti-camera-bitcoin:before { + content: "\ffa7"; +} + +.ti-camera-bolt:before { + content: "\f833"; +} + +.ti-camera-cancel:before { + content: "\f834"; +} + +.ti-camera-check:before { + content: "\f835"; +} + +.ti-camera-code:before { + content: "\f836"; +} + +.ti-camera-cog:before { + content: "\f837"; +} + +.ti-camera-dollar:before { + content: "\f838"; +} + +.ti-camera-down:before { + content: "\f839"; +} + +.ti-camera-exclamation:before { + content: "\f83a"; +} + +.ti-camera-heart:before { + content: "\f83b"; +} + .ti-camera-minus:before { content: "\ec3a"; } +.ti-camera-moon:before { + content: "\ffa6"; +} + .ti-camera-off:before { content: "\ecee"; } +.ti-camera-pause:before { + content: "\f83c"; +} + +.ti-camera-pin:before { + content: "\f83d"; +} + .ti-camera-plus:before { content: "\ec3b"; } +.ti-camera-question:before { + content: "\f83e"; +} + .ti-camera-rotate:before { content: "\ee22"; } +.ti-camera-search:before { + content: "\f83f"; +} + .ti-camera-selfie:before { content: "\ee23"; } +.ti-camera-share:before { + content: "\f840"; +} + +.ti-camera-spark:before { + content: "\ffbc"; +} + +.ti-camera-star:before { + content: "\f841"; +} + +.ti-camera-up:before { + content: "\f842"; +} + +.ti-camera-x:before { + content: "\f843"; +} + +.ti-camper:before { + content: "\fa25"; +} + .ti-campfire:before { content: "\f5a7"; } +.ti-canary:before { + content: "\101f5"; +} + +.ti-cancel:before { + content: "\ff11"; +} + .ti-candle:before { content: "\efc6"; } @@ -3315,6 +4637,26 @@ content: "\f4c1"; } +.ti-cap-projecting:before { + content: "\ff22"; +} + +.ti-cap-rounded:before { + content: "\ff21"; +} + +.ti-cap-straight:before { + content: "\ff20"; +} + +.ti-capsule:before { + content: "\fae3"; +} + +.ti-capsule-horizontal:before { + content: "\fae2"; +} + .ti-capture:before { content: "\ec3c"; } @@ -3327,6 +4669,10 @@ content: "\ebbb"; } +.ti-car-4wd:before { + content: "\fdb8"; +} + .ti-car-crane:before { content: "\ef25"; } @@ -3335,14 +4681,62 @@ content: "\efa4"; } +.ti-car-door:before { + content: "\10250"; +} + +.ti-car-fan:before { + content: "\fdb3"; +} + +.ti-car-fan-1:before { + content: "\fdb7"; +} + +.ti-car-fan-2:before { + content: "\fdb6"; +} + +.ti-car-fan-3:before { + content: "\fdb5"; +} + +.ti-car-fan-auto:before { + content: "\fdb4"; +} + +.ti-car-garage:before { + content: "\fc77"; +} + +.ti-car-lifter:before { + content: "\1024f"; +} + .ti-car-off:before { content: "\f0c7"; } +.ti-car-off-road:before { + content: "\10230"; +} + +.ti-car-suspension:before { + content: "\1022f"; +} + +.ti-car-suv:before { + content: "\fc8b"; +} + .ti-car-turbine:before { content: "\f4fd"; } +.ti-carambola:before { + content: "\feb9"; +} + .ti-caravan:before { content: "\ec7c"; } @@ -3367,6 +4761,10 @@ content: "\eb5e"; } +.ti-caret-left-right:before { + content: "\fc43"; +} + .ti-caret-right:before { content: "\eb5f"; } @@ -3375,6 +4773,10 @@ content: "\eb60"; } +.ti-caret-up-down:before { + content: "\fc44"; +} + .ti-carousel-horizontal:before { content: "\f659"; } @@ -3399,14 +4801,66 @@ content: "\ee25"; } +.ti-cash-banknote-edit:before { + content: "\10149"; +} + +.ti-cash-banknote-heart:before { + content: "\10148"; +} + +.ti-cash-banknote-minus:before { + content: "\10147"; +} + +.ti-cash-banknote-move:before { + content: "\10145"; +} + +.ti-cash-banknote-move-back:before { + content: "\10146"; +} + .ti-cash-banknote-off:before { content: "\ee24"; } +.ti-cash-banknote-plus:before { + content: "\10144"; +} + +.ti-cash-edit:before { + content: "\10143"; +} + +.ti-cash-heart:before { + content: "\10142"; +} + +.ti-cash-minus:before { + content: "\10141"; +} + +.ti-cash-move:before { + content: "\1013f"; +} + +.ti-cash-move-back:before { + content: "\10140"; +} + .ti-cash-off:before { content: "\f105"; } +.ti-cash-plus:before { + content: "\1013e"; +} + +.ti-cash-register:before { + content: "\fee6"; +} + .ti-cast:before { content: "\ea56"; } @@ -3427,6 +4881,14 @@ content: "\f1f5"; } +.ti-category-minus:before { + content: "\fd20"; +} + +.ti-category-plus:before { + content: "\fd21"; +} + .ti-ce:before { content: "\ed75"; } @@ -3491,6 +4953,10 @@ content: "\f3d1"; } +.ti-chalkboard-teacher:before { + content: "\10160"; +} + .ti-charging-pile:before { content: "\ee26"; } @@ -3527,6 +4993,10 @@ content: "\f3d2"; } +.ti-chart-bar-popular:before { + content: "\fef7"; +} + .ti-chart-bubble:before { content: "\ec75"; } @@ -3539,6 +5009,18 @@ content: "\ee2b"; } +.ti-chart-cohort:before { + content: "\fef6"; +} + +.ti-chart-column:before { + content: "\ffa5"; +} + +.ti-chart-covariate:before { + content: "\ffa4"; +} + .ti-chart-donut:before { content: "\ea5b"; } @@ -3567,6 +5049,10 @@ content: "\f098"; } +.ti-chart-funnel:before { + content: "\fef5"; +} + .ti-chart-grid-dots:before { content: "\f4c2"; } @@ -3615,6 +5101,14 @@ content: "\f619"; } +.ti-chart-scatter:before { + content: "\fd93"; +} + +.ti-chart-scatter-3d:before { + content: "\fd92"; +} + .ti-chart-treemap:before { content: "\f381"; } @@ -3679,6 +5173,22 @@ content: "\f56f"; } +.ti-chevron-compact-down:before { + content: "\faf0"; +} + +.ti-chevron-compact-left:before { + content: "\faf1"; +} + +.ti-chevron-compact-right:before { + content: "\faf2"; +} + +.ti-chevron-compact-up:before { + content: "\faf3"; +} + .ti-chevron-down:before { content: "\ea5f"; } @@ -3695,10 +5205,18 @@ content: "\ea60"; } +.ti-chevron-left-pipe:before { + content: "\fae4"; +} + .ti-chevron-right:before { content: "\ea61"; } +.ti-chevron-right-pipe:before { + content: "\fae5"; +} + .ti-chevron-up:before { content: "\ea62"; } @@ -3747,6 +5265,14 @@ content: "\f383"; } +.ti-chocolate:before { + content: "\1024e"; +} + +.ti-christmas-ball:before { + content: "\fd31"; +} + .ti-christmas-tree:before { content: "\ed78"; } @@ -3759,6 +5285,42 @@ content: "\ea6b"; } +.ti-circle-arrow-down:before { + content: "\f6f9"; +} + +.ti-circle-arrow-down-left:before { + content: "\f6f6"; +} + +.ti-circle-arrow-down-right:before { + content: "\f6f8"; +} + +.ti-circle-arrow-left:before { + content: "\f6fb"; +} + +.ti-circle-arrow-right:before { + content: "\f6fd"; +} + +.ti-circle-arrow-up:before { + content: "\f703"; +} + +.ti-circle-arrow-up-left:before { + content: "\f700"; +} + +.ti-circle-arrow-up-right:before { + content: "\f702"; +} + +.ti-circle-asterisk:before { + content: "\101ad"; +} + .ti-circle-caret-down:before { content: "\f4a9"; } @@ -3815,6 +5377,170 @@ content: "\ed27"; } +.ti-circle-dashed-check:before { + content: "\feb8"; +} + +.ti-circle-dashed-letter-a:before { + content: "\ff9a"; +} + +.ti-circle-dashed-letter-b:before { + content: "\ff99"; +} + +.ti-circle-dashed-letter-c:before { + content: "\ff98"; +} + +.ti-circle-dashed-letter-d:before { + content: "\ff97"; +} + +.ti-circle-dashed-letter-e:before { + content: "\ff96"; +} + +.ti-circle-dashed-letter-f:before { + content: "\ff95"; +} + +.ti-circle-dashed-letter-g:before { + content: "\ff94"; +} + +.ti-circle-dashed-letter-h:before { + content: "\ff93"; +} + +.ti-circle-dashed-letter-i:before { + content: "\ff92"; +} + +.ti-circle-dashed-letter-j:before { + content: "\ff91"; +} + +.ti-circle-dashed-letter-k:before { + content: "\ff90"; +} + +.ti-circle-dashed-letter-l:before { + content: "\ff8f"; +} + +.ti-circle-dashed-letter-m:before { + content: "\ff8d"; +} + +.ti-circle-dashed-letter-n:before { + content: "\ff8c"; +} + +.ti-circle-dashed-letter-o:before { + content: "\ff8b"; +} + +.ti-circle-dashed-letter-p:before { + content: "\ff8a"; +} + +.ti-circle-dashed-letter-q:before { + content: "\ff89"; +} + +.ti-circle-dashed-letter-r:before { + content: "\ff88"; +} + +.ti-circle-dashed-letter-s:before { + content: "\ff87"; +} + +.ti-circle-dashed-letter-t:before { + content: "\ff86"; +} + +.ti-circle-dashed-letter-u:before { + content: "\ff85"; +} + +.ti-circle-dashed-letter-v:before { + content: "\ff84"; +} + +.ti-circle-dashed-letter-w:before { + content: "\ff83"; +} + +.ti-circle-dashed-letter-x:before { + content: "\ff82"; +} + +.ti-circle-dashed-letter-y:before { + content: "\ff81"; +} + +.ti-circle-dashed-letter-z:before { + content: "\ff80"; +} + +.ti-circle-dashed-minus:before { + content: "\feb7"; +} + +.ti-circle-dashed-number-0:before { + content: "\fc6b"; +} + +.ti-circle-dashed-number-1:before { + content: "\fc6c"; +} + +.ti-circle-dashed-number-2:before { + content: "\fc6d"; +} + +.ti-circle-dashed-number-3:before { + content: "\fc6e"; +} + +.ti-circle-dashed-number-4:before { + content: "\fc6f"; +} + +.ti-circle-dashed-number-5:before { + content: "\fc70"; +} + +.ti-circle-dashed-number-6:before { + content: "\fc71"; +} + +.ti-circle-dashed-number-7:before { + content: "\fc72"; +} + +.ti-circle-dashed-number-8:before { + content: "\fc73"; +} + +.ti-circle-dashed-number-9:before { + content: "\fc74"; +} + +.ti-circle-dashed-percentage:before { + content: "\fd7a"; +} + +.ti-circle-dashed-plus:before { + content: "\feb6"; +} + +.ti-circle-dashed-x:before { + content: "\fc75"; +} + .ti-circle-dot:before { content: "\efb1"; } @@ -3823,6 +5549,110 @@ content: "\ed28"; } +.ti-circle-dotted-letter-a:before { + content: "\ff7f"; +} + +.ti-circle-dotted-letter-b:before { + content: "\ff7e"; +} + +.ti-circle-dotted-letter-c:before { + content: "\ff7d"; +} + +.ti-circle-dotted-letter-d:before { + content: "\ff7c"; +} + +.ti-circle-dotted-letter-e:before { + content: "\ff7b"; +} + +.ti-circle-dotted-letter-f:before { + content: "\ff7a"; +} + +.ti-circle-dotted-letter-g:before { + content: "\ff79"; +} + +.ti-circle-dotted-letter-h:before { + content: "\ff78"; +} + +.ti-circle-dotted-letter-i:before { + content: "\ff77"; +} + +.ti-circle-dotted-letter-j:before { + content: "\ff76"; +} + +.ti-circle-dotted-letter-k:before { + content: "\ff75"; +} + +.ti-circle-dotted-letter-l:before { + content: "\ff74"; +} + +.ti-circle-dotted-letter-m:before { + content: "\ff73"; +} + +.ti-circle-dotted-letter-n:before { + content: "\ff72"; +} + +.ti-circle-dotted-letter-o:before { + content: "\ff71"; +} + +.ti-circle-dotted-letter-p:before { + content: "\ff70"; +} + +.ti-circle-dotted-letter-q:before { + content: "\ff6f"; +} + +.ti-circle-dotted-letter-r:before { + content: "\ff6e"; +} + +.ti-circle-dotted-letter-s:before { + content: "\ff6d"; +} + +.ti-circle-dotted-letter-t:before { + content: "\ff6c"; +} + +.ti-circle-dotted-letter-u:before { + content: "\ff6b"; +} + +.ti-circle-dotted-letter-v:before { + content: "\ff6a"; +} + +.ti-circle-dotted-letter-w:before { + content: "\ff69"; +} + +.ti-circle-dotted-letter-x:before { + content: "\ff68"; +} + +.ti-circle-dotted-letter-y:before { + content: "\ff67"; +} + +.ti-circle-dotted-letter-z:before { + content: "\ff66"; +} + .ti-circle-half:before { content: "\ee3f"; } @@ -3947,6 +5777,10 @@ content: "\ea68"; } +.ti-circle-minus-2:before { + content: "\fc8c"; +} + .ti-circle-number-0:before { content: "\ee34"; } @@ -3991,10 +5825,38 @@ content: "\ee40"; } +.ti-circle-open-arrow-down:before { + content: "\10209"; +} + +.ti-circle-open-arrow-left:before { + content: "\10208"; +} + +.ti-circle-open-arrow-right:before { + content: "\10207"; +} + +.ti-circle-open-arrow-up:before { + content: "\10206"; +} + +.ti-circle-percentage:before { + content: "\fd7b"; +} + .ti-circle-plus:before { content: "\ea69"; } +.ti-circle-plus-2:before { + content: "\fc8d"; +} + +.ti-circle-plus-minus:before { + content: "\10205"; +} + .ti-circle-rectangle:before { content: "\f010"; } @@ -4107,10 +5969,22 @@ content: "\ebe5"; } +.ti-clef:before { + content: "\10240"; +} + +.ti-clef-staff:before { + content: "\10241"; +} + .ti-click:before { content: "\ebbc"; } +.ti-cliff-jumping:before { + content: "\fefc"; +} + .ti-clipboard:before { content: "\ea6f"; } @@ -4143,6 +6017,14 @@ content: "\efb2"; } +.ti-clipboard-search:before { + content: "\10098"; +} + +.ti-clipboard-smile:before { + content: "\fd9a"; +} + .ti-clipboard-text:before { content: "\f089"; } @@ -4159,18 +6041,62 @@ content: "\ea70"; } +.ti-clock-12:before { + content: "\fc56"; +} + .ti-clock-2:before { content: "\f099"; } +.ti-clock-24:before { + content: "\fc57"; +} + +.ti-clock-bitcoin:before { + content: "\ff3f"; +} + +.ti-clock-bolt:before { + content: "\f844"; +} + .ti-clock-cancel:before { content: "\f546"; } +.ti-clock-check:before { + content: "\f7c1"; +} + +.ti-clock-code:before { + content: "\f845"; +} + +.ti-clock-cog:before { + content: "\f7c2"; +} + +.ti-clock-dollar:before { + content: "\f846"; +} + +.ti-clock-down:before { + content: "\f7c3"; +} + .ti-clock-edit:before { content: "\f547"; } +.ti-clock-exclamation:before { + content: "\f847"; +} + +.ti-clock-heart:before { + content: "\f7c4"; +} + .ti-clock-hour-1:before { content: "\f313"; } @@ -4219,6 +6145,10 @@ content: "\f31e"; } +.ti-clock-minus:before { + content: "\f848"; +} + .ti-clock-off:before { content: "\f0cf"; } @@ -4227,18 +6157,54 @@ content: "\f548"; } +.ti-clock-pin:before { + content: "\f849"; +} + .ti-clock-play:before { content: "\f549"; } +.ti-clock-plus:before { + content: "\f7c5"; +} + +.ti-clock-question:before { + content: "\f7c6"; +} + .ti-clock-record:before { content: "\f54a"; } +.ti-clock-search:before { + content: "\f7c7"; +} + +.ti-clock-share:before { + content: "\f84a"; +} + +.ti-clock-shield:before { + content: "\f7c8"; +} + +.ti-clock-star:before { + content: "\f7c9"; +} + .ti-clock-stop:before { content: "\f54b"; } +.ti-clock-up:before { + content: "\f7ca"; +} + +.ti-clock-x:before { + content: "\f7cb"; +} + .ti-clothes-rack:before { content: "\f285"; } @@ -4251,20 +6217,60 @@ content: "\ea76"; } -.ti-cloud-computing:before { - content: "\f1d0"; +.ti-cloud-bitcoin:before { + content: "\ff3e"; } -.ti-cloud-data-connection:before { - content: "\f1d1"; +.ti-cloud-bolt:before { + content: "\f84b"; } -.ti-cloud-download:before { - content: "\ea71"; +.ti-cloud-cancel:before { + content: "\f84c"; } -.ti-cloud-fog:before { - content: "\ecd9"; +.ti-cloud-check:before { + content: "\f84d"; +} + +.ti-cloud-code:before { + content: "\f84e"; +} + +.ti-cloud-cog:before { + content: "\f84f"; +} + +.ti-cloud-computing:before { + content: "\f1d0"; +} + +.ti-cloud-data-connection:before { + content: "\f1d1"; +} + +.ti-cloud-dollar:before { + content: "\f850"; +} + +.ti-cloud-down:before { + content: "\f851"; +} + +.ti-cloud-download:before { + content: "\ea71"; +} + +.ti-cloud-exclamation:before { + content: "\f852"; +} + +.ti-cloud-fog:before { + content: "\ecd9"; +} + +.ti-cloud-heart:before { + content: "\f853"; } .ti-cloud-lock:before { @@ -4275,26 +6281,70 @@ content: "\efda"; } +.ti-cloud-minus:before { + content: "\f854"; +} + +.ti-cloud-network:before { + content: "\fc78"; +} + .ti-cloud-off:before { content: "\ed3e"; } +.ti-cloud-pause:before { + content: "\f855"; +} + +.ti-cloud-pin:before { + content: "\f856"; +} + +.ti-cloud-plus:before { + content: "\f857"; +} + +.ti-cloud-question:before { + content: "\f858"; +} + .ti-cloud-rain:before { content: "\ea72"; } +.ti-cloud-search:before { + content: "\f859"; +} + +.ti-cloud-share:before { + content: "\f85a"; +} + .ti-cloud-snow:before { content: "\ea73"; } +.ti-cloud-star:before { + content: "\f85b"; +} + .ti-cloud-storm:before { content: "\ea74"; } +.ti-cloud-up:before { + content: "\f85c"; +} + .ti-cloud-upload:before { content: "\ea75"; } +.ti-cloud-x:before { + content: "\f85d"; +} + .ti-clover:before { content: "\f1ea"; } @@ -4311,7 +6361,11 @@ content: "\ea77"; } -.ti-code-asterix:before { +.ti-code-ai:before { + content: "\10267"; +} + +.ti-code-asterisk:before { content: "\f312"; } @@ -4339,6 +6393,22 @@ content: "\ee43"; } +.ti-code-variable:before { + content: "\100ab"; +} + +.ti-code-variable-minus:before { + content: "\100ad"; +} + +.ti-code-variable-plus:before { + content: "\100ac"; +} + +.ti-codeblock:before { + content: "\101f4"; +} + .ti-coffee:before { content: "\ef0e"; } @@ -4379,6 +6449,10 @@ content: "\f2c1"; } +.ti-coin-taka:before { + content: "\fd0d"; +} + .ti-coin-yen:before { content: "\f2c2"; } @@ -4419,10 +6493,26 @@ content: "\ee45"; } +.ti-column-remove:before { + content: "\faf4"; +} + .ti-columns:before { content: "\eb83"; } +.ti-columns-1:before { + content: "\f6d4"; +} + +.ti-columns-2:before { + content: "\f6d5"; +} + +.ti-columns-3:before { + content: "\f6d6"; +} + .ti-columns-off:before { content: "\f0d4"; } @@ -4467,6 +6557,10 @@ content: "\f3d8"; } +.ti-cone-plus:before { + content: "\fa94"; +} + .ti-confetti:before { content: "\ee46"; } @@ -4479,6 +6573,14 @@ content: "\f58a"; } +.ti-congruent-to:before { + content: "\ffa3"; +} + +.ti-connection:before { + content: "\101f3"; +} + .ti-container:before { content: "\ee47"; } @@ -4487,6 +6589,10 @@ content: "\f107"; } +.ti-contract:before { + content: "\fefb"; +} + .ti-contrast:before { content: "\ec4e"; } @@ -4508,11 +6614,11 @@ } .ti-cookie:before { - content: "\ef0f"; + content: "\fdb1"; } .ti-cookie-man:before { - content: "\f4c4"; + content: "\fdb2"; } .ti-cookie-off:before { @@ -4523,10 +6629,26 @@ content: "\ea7a"; } +.ti-copy-check:before { + content: "\fdb0"; +} + +.ti-copy-minus:before { + content: "\fdaf"; +} + .ti-copy-off:before { content: "\f0d8"; } +.ti-copy-plus:before { + content: "\fdae"; +} + +.ti-copy-x:before { + content: "\fdad"; +} + .ti-copyleft:before { content: "\ec3d"; } @@ -4659,10 +6781,26 @@ content: "\ea84"; } +.ti-credit-card-hand:before { + content: "\1022e"; +} + .ti-credit-card-off:before { content: "\ed11"; } +.ti-credit-card-pay:before { + content: "\fd32"; +} + +.ti-credit-card-refund:before { + content: "\fd33"; +} + +.ti-credits:before { + content: "\101f2"; +} + .ti-cricket:before { content: "\f09a"; } @@ -4671,6 +6809,34 @@ content: "\ea85"; } +.ti-crop-1-1:before { + content: "\fd50"; +} + +.ti-crop-16-9:before { + content: "\fd51"; +} + +.ti-crop-3-2:before { + content: "\fd52"; +} + +.ti-crop-5-4:before { + content: "\fd53"; +} + +.ti-crop-7-5:before { + content: "\fd54"; +} + +.ti-crop-landscape:before { + content: "\fd55"; +} + +.ti-crop-portrait:before { + content: "\fd56"; +} + .ti-cross:before { content: "\ef8f"; } @@ -4703,10 +6869,38 @@ content: "\f57b"; } +.ti-csv:before { + content: "\f791"; +} + +.ti-cube:before { + content: "\fa97"; +} + +.ti-cube-3d-sphere:before { + content: "\ecd7"; +} + +.ti-cube-3d-sphere-off:before { + content: "\f3b5"; +} + +.ti-cube-off:before { + content: "\fa95"; +} + +.ti-cube-plus:before { + content: "\fa96"; +} + .ti-cube-send:before { content: "\f61b"; } +.ti-cube-spark:before { + content: "\ffbb"; +} + .ti-cube-unfolded:before { content: "\f61c"; } @@ -4815,6 +7009,10 @@ content: "\f3dd"; } +.ti-currency-florin:before { + content: "\faf5"; +} + .ti-currency-forint:before { content: "\ee5a"; } @@ -4831,6 +7029,14 @@ content: "\f372"; } +.ti-currency-husd:before { + content: "\1023f"; +} + +.ti-currency-iranian-rial:before { + content: "\fa58"; +} + .ti-currency-kip:before { content: "\f373"; } @@ -4879,6 +7085,10 @@ content: "\ee62"; } +.ti-currency-nano:before { + content: "\f7a6"; +} + .ti-currency-off:before { content: "\f3de"; } @@ -4955,6 +7165,10 @@ content: "\f37d"; } +.ti-currency-tether:before { + content: "\1023e"; +} + .ti-currency-tugrik:before { content: "\ee6a"; } @@ -4963,6 +7177,10 @@ content: "\ee6b"; } +.ti-currency-xrp:before { + content: "\fd34"; +} + .ti-currency-yen:before { content: "\ebae"; } @@ -4975,6 +7193,10 @@ content: "\f29a"; } +.ti-currency-zcash:before { + content: "\1023d"; +} + .ti-currency-zloty:before { content: "\ee6c"; } @@ -5003,6 +7225,14 @@ content: "\f54c"; } +.ti-cylinder-off:before { + content: "\fa98"; +} + +.ti-cylinder-plus:before { + content: "\fa99"; +} + .ti-dashboard:before { content: "\ea87"; } @@ -5015,18 +7245,78 @@ content: "\ea88"; } +.ti-database-cog:before { + content: "\fa10"; +} + +.ti-database-dollar:before { + content: "\fa11"; +} + +.ti-database-edit:before { + content: "\fa12"; +} + +.ti-database-exclamation:before { + content: "\fa13"; +} + .ti-database-export:before { content: "\ee6e"; } +.ti-database-heart:before { + content: "\fa14"; +} + .ti-database-import:before { content: "\ee6f"; } +.ti-database-leak:before { + content: "\fa15"; +} + +.ti-database-minus:before { + content: "\fa16"; +} + .ti-database-off:before { content: "\ee70"; } +.ti-database-plus:before { + content: "\fa17"; +} + +.ti-database-search:before { + content: "\fa18"; +} + +.ti-database-share:before { + content: "\fa19"; +} + +.ti-database-smile:before { + content: "\fd9b"; +} + +.ti-database-star:before { + content: "\fa1a"; +} + +.ti-database-x:before { + content: "\fa1b"; +} + +.ti-deaf:before { + content: "\101ac"; +} + +.ti-decimal:before { + content: "\fa26"; +} + .ti-deer:before { content: "\f4c5"; } @@ -5047,6 +7337,14 @@ content: "\f110"; } +.ti-deselect:before { + content: "\f9f3"; +} + +.ti-desk:before { + content: "\fd35"; +} + .ti-details:before { content: "\ee71"; } @@ -5055,6 +7353,14 @@ content: "\f3e2"; } +.ti-device-3d-camera:before { + content: "\1022d"; +} + +.ti-device-3d-lens:before { + content: "\1022c"; +} + .ti-device-airpods:before { content: "\f5a9"; } @@ -5063,6 +7369,10 @@ content: "\f646"; } +.ti-device-airtag:before { + content: "\fae6"; +} + .ti-device-analytics:before { content: "\ee72"; } @@ -5087,6 +7397,10 @@ content: "\ee76"; } +.ti-device-computer-camera-2:before { + content: "\1023c"; +} + .ti-device-computer-camera-off:before { content: "\ee75"; } @@ -5099,3612 +7413,7180 @@ content: "\ee77"; } -.ti-device-desktop-off:before { - content: "\ee78"; +.ti-device-desktop-bolt:before { + content: "\f85e"; } -.ti-device-floppy:before { - content: "\eb62"; +.ti-device-desktop-cancel:before { + content: "\f85f"; } -.ti-device-gamepad:before { - content: "\eb63"; +.ti-device-desktop-check:before { + content: "\f860"; } -.ti-device-gamepad-2:before { - content: "\f1d2"; +.ti-device-desktop-code:before { + content: "\f861"; } -.ti-device-heart-monitor:before { - content: "\f060"; +.ti-device-desktop-cog:before { + content: "\f862"; } -.ti-device-ipad:before { - content: "\f648"; +.ti-device-desktop-dollar:before { + content: "\f863"; } -.ti-device-ipad-horizontal:before { - content: "\f647"; +.ti-device-desktop-down:before { + content: "\f864"; } -.ti-device-landline-phone:before { - content: "\f649"; +.ti-device-desktop-exclamation:before { + content: "\f865"; } -.ti-device-laptop:before { - content: "\eb64"; +.ti-device-desktop-heart:before { + content: "\f866"; } -.ti-device-laptop-off:before { - content: "\f061"; +.ti-device-desktop-minus:before { + content: "\f867"; } -.ti-device-mobile:before { - content: "\ea8a"; +.ti-device-desktop-off:before { + content: "\ee78"; } -.ti-device-mobile-charging:before { - content: "\f224"; +.ti-device-desktop-pause:before { + content: "\f868"; } -.ti-device-mobile-message:before { - content: "\ee79"; +.ti-device-desktop-pin:before { + content: "\f869"; } -.ti-device-mobile-off:before { - content: "\f062"; +.ti-device-desktop-plus:before { + content: "\f86a"; } -.ti-device-mobile-rotated:before { - content: "\ecdb"; +.ti-device-desktop-question:before { + content: "\f86b"; } -.ti-device-mobile-vibration:before { - content: "\eb86"; +.ti-device-desktop-search:before { + content: "\f86c"; } -.ti-device-nintendo:before { - content: "\f026"; +.ti-device-desktop-share:before { + content: "\f86d"; } -.ti-device-nintendo-off:before { - content: "\f111"; +.ti-device-desktop-star:before { + content: "\f86e"; } -.ti-device-sd-card:before { - content: "\f384"; +.ti-device-desktop-up:before { + content: "\f86f"; } -.ti-device-sim:before { - content: "\f4b2"; +.ti-device-desktop-x:before { + content: "\f870"; } -.ti-device-sim-1:before { - content: "\f4af"; +.ti-device-floppy:before { + content: "\eb62"; } -.ti-device-sim-2:before { - content: "\f4b0"; +.ti-device-gamepad:before { + content: "\eb63"; } -.ti-device-sim-3:before { - content: "\f4b1"; +.ti-device-gamepad-2:before { + content: "\f1d2"; } -.ti-device-speaker:before { - content: "\ea8b"; +.ti-device-gamepad-3:before { + content: "\fc58"; } -.ti-device-speaker-off:before { - content: "\f112"; +.ti-device-heart-monitor:before { + content: "\f060"; } -.ti-device-tablet:before { - content: "\ea8c"; +.ti-device-imac:before { + content: "\f7a7"; } -.ti-device-tablet-off:before { - content: "\f063"; +.ti-device-imac-bolt:before { + content: "\f871"; } -.ti-device-tv:before { - content: "\ea8d"; +.ti-device-imac-cancel:before { + content: "\f872"; } -.ti-device-tv-off:before { - content: "\f064"; +.ti-device-imac-check:before { + content: "\f873"; } -.ti-device-tv-old:before { - content: "\f1d3"; +.ti-device-imac-code:before { + content: "\f874"; } -.ti-device-watch:before { - content: "\ebf9"; +.ti-device-imac-cog:before { + content: "\f875"; } -.ti-device-watch-off:before { - content: "\f065"; +.ti-device-imac-dollar:before { + content: "\f876"; } -.ti-device-watch-stats:before { - content: "\ef7d"; +.ti-device-imac-down:before { + content: "\f877"; } -.ti-device-watch-stats-2:before { - content: "\ef7c"; +.ti-device-imac-exclamation:before { + content: "\f878"; } -.ti-devices:before { - content: "\eb87"; +.ti-device-imac-heart:before { + content: "\f879"; } -.ti-devices-2:before { - content: "\ed29"; +.ti-device-imac-minus:before { + content: "\f87a"; } -.ti-devices-off:before { - content: "\f3e4"; +.ti-device-imac-off:before { + content: "\f87b"; } -.ti-devices-pc:before { - content: "\ee7a"; +.ti-device-imac-pause:before { + content: "\f87c"; } -.ti-devices-pc-off:before { - content: "\f113"; +.ti-device-imac-pin:before { + content: "\f87d"; } -.ti-dialpad:before { - content: "\f067"; +.ti-device-imac-plus:before { + content: "\f87e"; } -.ti-dialpad-off:before { - content: "\f114"; +.ti-device-imac-question:before { + content: "\f87f"; } -.ti-diamond:before { - content: "\eb65"; +.ti-device-imac-search:before { + content: "\f880"; } -.ti-diamond-off:before { - content: "\f115"; +.ti-device-imac-share:before { + content: "\f881"; } -.ti-diamonds:before { - content: "\eff5"; +.ti-device-imac-star:before { + content: "\f882"; } -.ti-dice:before { - content: "\eb66"; +.ti-device-imac-up:before { + content: "\f883"; } -.ti-dice-1:before { - content: "\f08b"; +.ti-device-imac-x:before { + content: "\f884"; } -.ti-dice-2:before { - content: "\f08c"; +.ti-device-ipad:before { + content: "\f648"; } -.ti-dice-3:before { - content: "\f08d"; +.ti-device-ipad-bolt:before { + content: "\f885"; } -.ti-dice-4:before { - content: "\f08e"; +.ti-device-ipad-cancel:before { + content: "\f886"; } -.ti-dice-5:before { - content: "\f08f"; +.ti-device-ipad-check:before { + content: "\f887"; } -.ti-dice-6:before { - content: "\f090"; +.ti-device-ipad-code:before { + content: "\f888"; } -.ti-dimensions:before { - content: "\ee7b"; +.ti-device-ipad-cog:before { + content: "\f889"; } -.ti-direction:before { - content: "\ebfb"; +.ti-device-ipad-dollar:before { + content: "\f88a"; } -.ti-direction-horizontal:before { - content: "\ebfa"; +.ti-device-ipad-down:before { + content: "\f88b"; } -.ti-direction-sign:before { - content: "\f1f7"; +.ti-device-ipad-exclamation:before { + content: "\f88c"; } -.ti-direction-sign-off:before { - content: "\f3e5"; +.ti-device-ipad-heart:before { + content: "\f88d"; } -.ti-directions:before { - content: "\ea8e"; +.ti-device-ipad-horizontal:before { + content: "\f647"; } -.ti-directions-off:before { - content: "\f116"; +.ti-device-ipad-horizontal-bolt:before { + content: "\f88e"; } -.ti-disabled:before { - content: "\ea8f"; +.ti-device-ipad-horizontal-cancel:before { + content: "\f88f"; } -.ti-disabled-2:before { - content: "\ebaf"; +.ti-device-ipad-horizontal-check:before { + content: "\f890"; } -.ti-disabled-off:before { - content: "\f117"; +.ti-device-ipad-horizontal-code:before { + content: "\f891"; } -.ti-disc:before { - content: "\ea90"; +.ti-device-ipad-horizontal-cog:before { + content: "\f892"; } -.ti-disc-golf:before { - content: "\f385"; +.ti-device-ipad-horizontal-dollar:before { + content: "\f893"; } -.ti-disc-off:before { - content: "\f118"; +.ti-device-ipad-horizontal-down:before { + content: "\f894"; } -.ti-discount:before { - content: "\ebbd"; +.ti-device-ipad-horizontal-exclamation:before { + content: "\f895"; } -.ti-discount-2:before { - content: "\ee7c"; +.ti-device-ipad-horizontal-heart:before { + content: "\f896"; } -.ti-discount-2-off:before { - content: "\f3e6"; +.ti-device-ipad-horizontal-minus:before { + content: "\f897"; } -.ti-discount-check:before { - content: "\f1f8"; +.ti-device-ipad-horizontal-off:before { + content: "\f898"; } -.ti-discount-off:before { - content: "\f3e7"; +.ti-device-ipad-horizontal-pause:before { + content: "\f899"; } -.ti-divide:before { - content: "\ed5c"; +.ti-device-ipad-horizontal-pin:before { + content: "\f89a"; } -.ti-dna:before { - content: "\ee7d"; +.ti-device-ipad-horizontal-plus:before { + content: "\f89b"; } -.ti-dna-2:before { - content: "\ef5c"; +.ti-device-ipad-horizontal-question:before { + content: "\f89c"; } -.ti-dna-2-off:before { - content: "\f119"; +.ti-device-ipad-horizontal-search:before { + content: "\f89d"; } -.ti-dna-off:before { - content: "\f11a"; +.ti-device-ipad-horizontal-share:before { + content: "\f89e"; } -.ti-dog:before { - content: "\f660"; +.ti-device-ipad-horizontal-star:before { + content: "\f89f"; } -.ti-dog-bowl:before { - content: "\ef29"; +.ti-device-ipad-horizontal-up:before { + content: "\f8a0"; } -.ti-door:before { - content: "\ef4e"; +.ti-device-ipad-horizontal-x:before { + content: "\f8a1"; } -.ti-door-enter:before { - content: "\ef4c"; +.ti-device-ipad-minus:before { + content: "\f8a2"; } -.ti-door-exit:before { - content: "\ef4d"; +.ti-device-ipad-off:before { + content: "\f8a3"; } -.ti-door-off:before { - content: "\f11b"; +.ti-device-ipad-pause:before { + content: "\f8a4"; } -.ti-dots:before { - content: "\ea95"; +.ti-device-ipad-pin:before { + content: "\f8a5"; } -.ti-dots-circle-horizontal:before { - content: "\ea91"; +.ti-device-ipad-plus:before { + content: "\f8a6"; } -.ti-dots-diagonal:before { - content: "\ea93"; +.ti-device-ipad-question:before { + content: "\f8a7"; } -.ti-dots-diagonal-2:before { - content: "\ea92"; +.ti-device-ipad-search:before { + content: "\f8a8"; } -.ti-dots-vertical:before { - content: "\ea94"; +.ti-device-ipad-share:before { + content: "\f8a9"; } -.ti-download:before { - content: "\ea96"; +.ti-device-ipad-star:before { + content: "\f8aa"; } -.ti-download-off:before { - content: "\f11c"; +.ti-device-ipad-up:before { + content: "\f8ab"; } -.ti-drag-drop:before { - content: "\eb89"; +.ti-device-ipad-x:before { + content: "\f8ac"; } -.ti-drag-drop-2:before { - content: "\eb88"; +.ti-device-landline-phone:before { + content: "\f649"; } -.ti-drone:before { - content: "\ed79"; +.ti-device-laptop:before { + content: "\eb64"; } -.ti-drone-off:before { - content: "\ee7e"; +.ti-device-laptop-off:before { + content: "\f061"; } -.ti-drop-circle:before { - content: "\efde"; +.ti-device-mobile:before { + content: "\ea8a"; } -.ti-droplet:before { - content: "\ea97"; +.ti-device-mobile-bolt:before { + content: "\f8ad"; } -.ti-droplet-filled:before { - content: "\ee80"; +.ti-device-mobile-cancel:before { + content: "\f8ae"; } -.ti-droplet-filled-2:before { - content: "\ee7f"; +.ti-device-mobile-charging:before { + content: "\f224"; } -.ti-droplet-half:before { - content: "\ee82"; +.ti-device-mobile-check:before { + content: "\f8af"; } -.ti-droplet-half-2:before { - content: "\ee81"; +.ti-device-mobile-code:before { + content: "\f8b0"; } -.ti-droplet-off:before { - content: "\ee83"; +.ti-device-mobile-cog:before { + content: "\f8b1"; } -.ti-e-passport:before { - content: "\f4df"; +.ti-device-mobile-dollar:before { + content: "\f8b2"; } -.ti-ear:before { - content: "\ebce"; +.ti-device-mobile-down:before { + content: "\f8b3"; } -.ti-ear-off:before { - content: "\ee84"; +.ti-device-mobile-exclamation:before { + content: "\f8b4"; } -.ti-ease-in:before { - content: "\f573"; +.ti-device-mobile-heart:before { + content: "\f8b5"; } -.ti-ease-in-control-point:before { - content: "\f570"; +.ti-device-mobile-message:before { + content: "\ee79"; } -.ti-ease-in-out:before { - content: "\f572"; +.ti-device-mobile-minus:before { + content: "\f8b6"; } -.ti-ease-in-out-control-points:before { - content: "\f571"; +.ti-device-mobile-off:before { + content: "\f062"; } -.ti-ease-out:before { - content: "\f575"; +.ti-device-mobile-pause:before { + content: "\f8b7"; } -.ti-ease-out-control-point:before { - content: "\f574"; +.ti-device-mobile-pin:before { + content: "\f8b8"; } -.ti-edit:before { - content: "\ea98"; +.ti-device-mobile-plus:before { + content: "\f8b9"; } -.ti-edit-circle:before { - content: "\ee85"; +.ti-device-mobile-question:before { + content: "\f8ba"; } -.ti-edit-circle-off:before { - content: "\f11d"; +.ti-device-mobile-rotated:before { + content: "\ecdb"; } -.ti-edit-off:before { - content: "\f11e"; +.ti-device-mobile-search:before { + content: "\f8bb"; } -.ti-egg:before { - content: "\eb8a"; +.ti-device-mobile-share:before { + content: "\f8bc"; } -.ti-egg-cracked:before { - content: "\f2d6"; +.ti-device-mobile-star:before { + content: "\f8bd"; } -.ti-egg-fried:before { - content: "\f386"; +.ti-device-mobile-up:before { + content: "\f8be"; } -.ti-egg-off:before { - content: "\f11f"; +.ti-device-mobile-vibration:before { + content: "\eb86"; } -.ti-eggs:before { - content: "\f500"; +.ti-device-mobile-x:before { + content: "\f8bf"; } -.ti-elevator:before { - content: "\efdf"; +.ti-device-nintendo:before { + content: "\f026"; } -.ti-elevator-off:before { - content: "\f3e8"; +.ti-device-nintendo-off:before { + content: "\f111"; } -.ti-emergency-bed:before { - content: "\ef5d"; +.ti-device-projector:before { + content: "\fc11"; } -.ti-empathize:before { - content: "\f29b"; +.ti-device-remote:before { + content: "\f792"; } -.ti-empathize-off:before { - content: "\f3e9"; +.ti-device-screen:before { + content: "\1022b"; } -.ti-emphasis:before { - content: "\ebcf"; +.ti-device-sd-card:before { + content: "\f384"; } -.ti-engine:before { - content: "\ef7e"; +.ti-device-sim:before { + content: "\f4b2"; } -.ti-engine-off:before { - content: "\f120"; +.ti-device-sim-1:before { + content: "\f4af"; } -.ti-equal:before { - content: "\ee87"; +.ti-device-sim-2:before { + content: "\f4b0"; } -.ti-equal-double:before { - content: "\f4e1"; +.ti-device-sim-3:before { + content: "\f4b1"; } -.ti-equal-not:before { - content: "\ee86"; +.ti-device-speaker:before { + content: "\ea8b"; } -.ti-eraser:before { - content: "\eb8b"; +.ti-device-speaker-off:before { + content: "\f112"; } -.ti-eraser-off:before { - content: "\f121"; +.ti-device-tablet:before { + content: "\ea8c"; } -.ti-error-404:before { - content: "\f027"; +.ti-device-tablet-bolt:before { + content: "\f8c0"; } -.ti-error-404-off:before { - content: "\f122"; +.ti-device-tablet-cancel:before { + content: "\f8c1"; } -.ti-exchange:before { - content: "\ebe7"; +.ti-device-tablet-check:before { + content: "\f8c2"; } -.ti-exchange-off:before { - content: "\f123"; +.ti-device-tablet-code:before { + content: "\f8c3"; } -.ti-exclamation-circle:before { - content: "\f634"; +.ti-device-tablet-cog:before { + content: "\f8c4"; } -.ti-exclamation-mark:before { - content: "\efb4"; +.ti-device-tablet-dollar:before { + content: "\f8c5"; } -.ti-exclamation-mark-off:before { - content: "\f124"; +.ti-device-tablet-down:before { + content: "\f8c6"; } -.ti-explicit:before { - content: "\f256"; +.ti-device-tablet-exclamation:before { + content: "\f8c7"; } -.ti-explicit-off:before { - content: "\f3ea"; +.ti-device-tablet-heart:before { + content: "\f8c8"; } -.ti-exposure:before { - content: "\eb8c"; +.ti-device-tablet-minus:before { + content: "\f8c9"; } -.ti-exposure-0:before { - content: "\f29c"; +.ti-device-tablet-off:before { + content: "\f063"; } -.ti-exposure-minus-1:before { - content: "\f29d"; +.ti-device-tablet-pause:before { + content: "\f8ca"; } -.ti-exposure-minus-2:before { - content: "\f29e"; +.ti-device-tablet-pin:before { + content: "\f8cb"; } -.ti-exposure-off:before { - content: "\f3eb"; +.ti-device-tablet-plus:before { + content: "\f8cc"; } -.ti-exposure-plus-1:before { - content: "\f29f"; +.ti-device-tablet-question:before { + content: "\f8cd"; } -.ti-exposure-plus-2:before { - content: "\f2a0"; +.ti-device-tablet-search:before { + content: "\f8ce"; } -.ti-external-link:before { - content: "\ea99"; +.ti-device-tablet-share:before { + content: "\f8cf"; } -.ti-external-link-off:before { - content: "\f125"; +.ti-device-tablet-star:before { + content: "\f8d0"; } -.ti-eye:before { - content: "\ea9a"; +.ti-device-tablet-up:before { + content: "\f8d1"; } -.ti-eye-check:before { - content: "\ee88"; +.ti-device-tablet-x:before { + content: "\f8d2"; } -.ti-eye-off:before { - content: "\ecf0"; +.ti-device-tv:before { + content: "\ea8d"; } -.ti-eye-table:before { - content: "\ef5e"; +.ti-device-tv-off:before { + content: "\f064"; } -.ti-eyeglass:before { - content: "\ee8a"; +.ti-device-tv-old:before { + content: "\f1d3"; } -.ti-eyeglass-2:before { - content: "\ee89"; +.ti-device-unknown:before { + content: "\fef4"; } -.ti-eyeglass-off:before { - content: "\f126"; +.ti-device-usb:before { + content: "\fc59"; } -.ti-face-id:before { - content: "\ea9b"; +.ti-device-vision-pro:before { + content: "\fae7"; } -.ti-face-id-error:before { - content: "\efa7"; +.ti-device-watch:before { + content: "\ebf9"; } -.ti-face-mask:before { - content: "\efb5"; +.ti-device-watch-bolt:before { + content: "\f8d3"; } -.ti-face-mask-off:before { - content: "\f127"; +.ti-device-watch-cancel:before { + content: "\f8d4"; } -.ti-fall:before { - content: "\ecb9"; +.ti-device-watch-check:before { + content: "\f8d5"; } -.ti-feather:before { - content: "\ee8b"; +.ti-device-watch-code:before { + content: "\f8d6"; } -.ti-feather-off:before { - content: "\f128"; +.ti-device-watch-cog:before { + content: "\f8d7"; } -.ti-fence:before { - content: "\ef2a"; +.ti-device-watch-dollar:before { + content: "\f8d8"; } -.ti-fence-off:before { - content: "\f129"; +.ti-device-watch-down:before { + content: "\f8d9"; } -.ti-fidget-spinner:before { - content: "\f068"; +.ti-device-watch-exclamation:before { + content: "\f8da"; } -.ti-file:before { - content: "\eaa4"; +.ti-device-watch-heart:before { + content: "\f8db"; } -.ti-file-3d:before { - content: "\f032"; +.ti-device-watch-minus:before { + content: "\f8dc"; } -.ti-file-alert:before { - content: "\ede6"; +.ti-device-watch-off:before { + content: "\f065"; } -.ti-file-analytics:before { - content: "\ede7"; +.ti-device-watch-pause:before { + content: "\f8dd"; } -.ti-file-arrow-left:before { - content: "\f033"; +.ti-device-watch-pin:before { + content: "\f8de"; } -.ti-file-arrow-right:before { - content: "\f034"; +.ti-device-watch-plus:before { + content: "\f8df"; } -.ti-file-barcode:before { - content: "\f035"; +.ti-device-watch-question:before { + content: "\f8e0"; } -.ti-file-broken:before { - content: "\f501"; +.ti-device-watch-search:before { + content: "\f8e1"; } -.ti-file-certificate:before { - content: "\ed4d"; +.ti-device-watch-share:before { + content: "\f8e2"; } -.ti-file-chart:before { - content: "\f036"; +.ti-device-watch-star:before { + content: "\f8e3"; } -.ti-file-check:before { - content: "\ea9c"; +.ti-device-watch-stats:before { + content: "\ef7d"; } -.ti-file-code:before { - content: "\ebd0"; +.ti-device-watch-stats-2:before { + content: "\ef7c"; } -.ti-file-code-2:before { - content: "\ede8"; +.ti-device-watch-up:before { + content: "\f8e4"; } -.ti-file-database:before { - content: "\f037"; +.ti-device-watch-x:before { + content: "\f8e5"; } -.ti-file-delta:before { - content: "\f53d"; +.ti-devices:before { + content: "\eb87"; } -.ti-file-description:before { - content: "\f028"; +.ti-devices-2:before { + content: "\ed29"; } -.ti-file-diff:before { - content: "\ecf1"; +.ti-devices-bolt:before { + content: "\f8e6"; } -.ti-file-digit:before { - content: "\efa8"; +.ti-devices-cancel:before { + content: "\f8e7"; } -.ti-file-dislike:before { - content: "\ed2a"; +.ti-devices-check:before { + content: "\f8e8"; } -.ti-file-dollar:before { - content: "\efe0"; +.ti-devices-code:before { + content: "\f8e9"; } -.ti-file-dots:before { - content: "\f038"; +.ti-devices-cog:before { + content: "\f8ea"; } -.ti-file-download:before { - content: "\ea9d"; +.ti-devices-dollar:before { + content: "\f8eb"; } -.ti-file-euro:before { - content: "\efe1"; +.ti-devices-down:before { + content: "\f8ec"; } -.ti-file-export:before { - content: "\ede9"; +.ti-devices-exclamation:before { + content: "\f8ed"; } -.ti-file-function:before { - content: "\f53e"; +.ti-devices-heart:before { + content: "\f8ee"; } -.ti-file-horizontal:before { - content: "\ebb0"; +.ti-devices-minus:before { + content: "\f8ef"; } -.ti-file-import:before { - content: "\edea"; +.ti-devices-off:before { + content: "\f3e4"; } -.ti-file-infinity:before { - content: "\f502"; +.ti-devices-pause:before { + content: "\f8f0"; } -.ti-file-info:before { - content: "\edec"; +.ti-devices-pc:before { + content: "\ee7a"; } -.ti-file-invoice:before { - content: "\eb67"; +.ti-devices-pc-off:before { + content: "\f113"; } -.ti-file-lambda:before { - content: "\f53f"; +.ti-devices-pin:before { + content: "\f8f1"; } -.ti-file-like:before { - content: "\ed2b"; +.ti-devices-plus:before { + content: "\f8f2"; } -.ti-file-minus:before { - content: "\ea9e"; +.ti-devices-question:before { + content: "\f8f3"; } -.ti-file-music:before { - content: "\ea9f"; +.ti-devices-search:before { + content: "\f8f4"; } -.ti-file-off:before { - content: "\ecf2"; +.ti-devices-share:before { + content: "\f8f5"; } -.ti-file-orientation:before { - content: "\f2a1"; +.ti-devices-star:before { + content: "\f8f6"; } -.ti-file-pencil:before { - content: "\f039"; +.ti-devices-up:before { + content: "\f8f7"; } -.ti-file-percent:before { - content: "\f540"; +.ti-devices-x:before { + content: "\f8f8"; } -.ti-file-phone:before { - content: "\ecdc"; +.ti-diabolo:before { + content: "\fa9c"; } -.ti-file-plus:before { - content: "\eaa0"; +.ti-diabolo-off:before { + content: "\fa9a"; } -.ti-file-power:before { - content: "\f03a"; +.ti-diabolo-plus:before { + content: "\fa9b"; } -.ti-file-report:before { - content: "\eded"; +.ti-dialpad:before { + content: "\f067"; } -.ti-file-rss:before { - content: "\f03b"; +.ti-dialpad-off:before { + content: "\f114"; } -.ti-file-scissors:before { - content: "\f03c"; +.ti-diamond:before { + content: "\eb65"; } -.ti-file-search:before { - content: "\ed5d"; +.ti-diamond-off:before { + content: "\f115"; } -.ti-file-settings:before { - content: "\f029"; +.ti-diamonds:before { + content: "\eff5"; } -.ti-file-shredder:before { - content: "\eaa1"; +.ti-diaper:before { + content: "\ffa2"; } -.ti-file-signal:before { - content: "\f03d"; +.ti-dice:before { + content: "\eb66"; } -.ti-file-spreadsheet:before { - content: "\f03e"; +.ti-dice-1:before { + content: "\f08b"; } -.ti-file-stack:before { - content: "\f503"; +.ti-dice-2:before { + content: "\f08c"; } -.ti-file-star:before { - content: "\f03f"; +.ti-dice-3:before { + content: "\f08d"; } -.ti-file-symlink:before { - content: "\ed53"; +.ti-dice-4:before { + content: "\f08e"; } -.ti-file-text:before { - content: "\eaa2"; +.ti-dice-5:before { + content: "\f08f"; } -.ti-file-time:before { - content: "\f040"; +.ti-dice-6:before { + content: "\f090"; } -.ti-file-typography:before { - content: "\f041"; +.ti-dimensions:before { + content: "\ee7b"; } -.ti-file-unknown:before { - content: "\f042"; +.ti-direction:before { + content: "\ebfb"; } -.ti-file-upload:before { - content: "\ec91"; +.ti-direction-arrows:before { + content: "\fd36"; } -.ti-file-vector:before { - content: "\f043"; +.ti-direction-horizontal:before { + content: "\ebfa"; } -.ti-file-x:before { - content: "\eaa3"; +.ti-direction-sign:before { + content: "\f1f7"; } -.ti-file-zip:before { - content: "\ed4e"; +.ti-direction-sign-off:before { + content: "\f3e5"; } -.ti-files:before { - content: "\edef"; +.ti-directions:before { + content: "\ea8e"; } -.ti-files-off:before { - content: "\edee"; +.ti-directions-off:before { + content: "\f116"; } -.ti-filter:before { - content: "\eaa5"; +.ti-disabled:before { + content: "\ea8f"; } -.ti-filter-off:before { - content: "\ed2c"; +.ti-disabled-2:before { + content: "\ebaf"; } -.ti-fingerprint:before { - content: "\ebd1"; +.ti-disabled-off:before { + content: "\f117"; } -.ti-fingerprint-off:before { - content: "\f12a"; +.ti-disc:before { + content: "\ea90"; } -.ti-fire-hydrant:before { - content: "\f3a9"; -} +.ti-disc-golf:before { + content: "\f385"; +} + +.ti-disc-off:before { + content: "\f118"; +} + +.ti-discount:before { + content: "\ebbd"; +} + +.ti-discount-off:before { + content: "\f3e7"; +} + +.ti-divide:before { + content: "\ed5c"; +} + +.ti-dna:before { + content: "\ee7d"; +} + +.ti-dna-2:before { + content: "\ef5c"; +} + +.ti-dna-2-off:before { + content: "\f119"; +} + +.ti-dna-off:before { + content: "\f11a"; +} + +.ti-dog:before { + content: "\f660"; +} + +.ti-dog-bowl:before { + content: "\ef29"; +} + +.ti-door:before { + content: "\ef4e"; +} + +.ti-door-enter:before { + content: "\ef4c"; +} + +.ti-door-exit:before { + content: "\ef4d"; +} + +.ti-door-hanger:before { + content: "\1023b"; +} + +.ti-door-off:before { + content: "\f11b"; +} + +.ti-dots:before { + content: "\ea95"; +} + +.ti-dots-circle-horizontal:before { + content: "\ea91"; +} + +.ti-dots-diagonal:before { + content: "\ea93"; +} + +.ti-dots-diagonal-2:before { + content: "\ea92"; +} + +.ti-dots-vertical:before { + content: "\ea94"; +} + +.ti-download:before { + content: "\ea96"; +} + +.ti-download-off:before { + content: "\f11c"; +} + +.ti-drag-drop:before { + content: "\eb89"; +} + +.ti-drag-drop-2:before { + content: "\eb88"; +} + +.ti-drone:before { + content: "\ed79"; +} + +.ti-drone-off:before { + content: "\ee7e"; +} + +.ti-drop-circle:before { + content: "\efde"; +} + +.ti-droplet:before { + content: "\ea97"; +} + +.ti-droplet-bolt:before { + content: "\f8f9"; +} + +.ti-droplet-cancel:before { + content: "\f8fa"; +} + +.ti-droplet-check:before { + content: "\f8fb"; +} + +.ti-droplet-code:before { + content: "\f8fc"; +} + +.ti-droplet-cog:before { + content: "\f8fd"; +} + +.ti-droplet-dollar:before { + content: "\f8fe"; +} + +.ti-droplet-down:before { + content: "\f8ff"; +} + +.ti-droplet-exclamation:before { + content: "\f900"; +} + +.ti-droplet-half:before { + content: "\ee82"; +} + +.ti-droplet-half-2:before { + content: "\ee81"; +} + +.ti-droplet-heart:before { + content: "\f901"; +} + +.ti-droplet-minus:before { + content: "\f902"; +} + +.ti-droplet-off:before { + content: "\ee83"; +} + +.ti-droplet-pause:before { + content: "\f903"; +} + +.ti-droplet-pin:before { + content: "\f904"; +} + +.ti-droplet-plus:before { + content: "\f905"; +} + +.ti-droplet-question:before { + content: "\f906"; +} + +.ti-droplet-search:before { + content: "\f907"; +} + +.ti-droplet-share:before { + content: "\f908"; +} + +.ti-droplet-star:before { + content: "\f909"; +} + +.ti-droplet-up:before { + content: "\f90a"; +} + +.ti-droplet-x:before { + content: "\f90b"; +} + +.ti-droplets:before { + content: "\fc12"; +} + +.ti-dual-screen:before { + content: "\fa59"; +} + +.ti-dumbbell:before { + content: "\1024d"; +} + +.ti-dumpling:before { + content: "\feb5"; +} + +.ti-e-passport:before { + content: "\f4df"; +} + +.ti-ear:before { + content: "\ebce"; +} + +.ti-ear-off:before { + content: "\ee84"; +} + +.ti-ear-scan:before { + content: "\fd57"; +} + +.ti-earphone-bluetooth:before { + content: "\1023a"; +} + +.ti-ease-in:before { + content: "\f573"; +} + +.ti-ease-in-control-point:before { + content: "\f570"; +} + +.ti-ease-in-out:before { + content: "\f572"; +} + +.ti-ease-in-out-control-points:before { + content: "\f571"; +} + +.ti-ease-out:before { + content: "\f575"; +} + +.ti-ease-out-control-point:before { + content: "\f574"; +} + +.ti-edit:before { + content: "\ea98"; +} + +.ti-edit-circle:before { + content: "\ee85"; +} + +.ti-edit-circle-off:before { + content: "\f11d"; +} + +.ti-edit-off:before { + content: "\f11e"; +} + +.ti-egg:before { + content: "\eb8a"; +} + +.ti-egg-cracked:before { + content: "\f2d6"; +} + +.ti-egg-fried:before { + content: "\f386"; +} + +.ti-egg-off:before { + content: "\f11f"; +} + +.ti-eggs:before { + content: "\f500"; +} + +.ti-elevator:before { + content: "\efdf"; +} + +.ti-elevator-off:before { + content: "\f3e8"; +} + +.ti-email-stamp:before { + content: "\10266"; +} + +.ti-emergency-bed:before { + content: "\ef5d"; +} + +.ti-empathize:before { + content: "\f29b"; +} + +.ti-empathize-off:before { + content: "\f3e9"; +} + +.ti-emphasis:before { + content: "\ebcf"; +} + +.ti-engine:before { + content: "\ef7e"; +} + +.ti-engine-off:before { + content: "\f120"; +} + +.ti-equal:before { + content: "\ee87"; +} + +.ti-equal-double:before { + content: "\f4e1"; +} + +.ti-equal-not:before { + content: "\ee86"; +} + +.ti-eraser:before { + content: "\eb8b"; +} + +.ti-eraser-off:before { + content: "\f121"; +} + +.ti-error-404:before { + content: "\f027"; +} + +.ti-error-404-off:before { + content: "\f122"; +} + +.ti-escalator:before { + content: "\fb06"; +} + +.ti-escalator-down:before { + content: "\fb04"; +} + +.ti-escalator-up:before { + content: "\fb05"; +} + +.ti-exchange:before { + content: "\ebe7"; +} + +.ti-exchange-off:before { + content: "\f123"; +} + +.ti-exclamation-circle:before { + content: "\f634"; +} + +.ti-exclamation-mark:before { + content: "\efb4"; +} + +.ti-exclamation-mark-off:before { + content: "\f124"; +} + +.ti-exercise-ball:before { + content: "\1024c"; +} + +.ti-explicit:before { + content: "\f256"; +} + +.ti-explicit-off:before { + content: "\f3ea"; +} + +.ti-exposure:before { + content: "\eb8c"; +} + +.ti-exposure-0:before { + content: "\f29c"; +} + +.ti-exposure-minus-1:before { + content: "\f29d"; +} + +.ti-exposure-minus-2:before { + content: "\f29e"; +} + +.ti-exposure-off:before { + content: "\f3eb"; +} + +.ti-exposure-plus-1:before { + content: "\f29f"; +} + +.ti-exposure-plus-2:before { + content: "\f2a0"; +} + +.ti-external-link:before { + content: "\ea99"; +} + +.ti-external-link-off:before { + content: "\f125"; +} + +.ti-eye:before { + content: "\ea9a"; +} + +.ti-eye-bitcoin:before { + content: "\ff3d"; +} + +.ti-eye-bolt:before { + content: "\fb6d"; +} + +.ti-eye-cancel:before { + content: "\fb6e"; +} + +.ti-eye-check:before { + content: "\ee88"; +} + +.ti-eye-closed:before { + content: "\f7ec"; +} + +.ti-eye-code:before { + content: "\fb6f"; +} + +.ti-eye-cog:before { + content: "\f7ed"; +} + +.ti-eye-discount:before { + content: "\fb70"; +} + +.ti-eye-dollar:before { + content: "\fb71"; +} + +.ti-eye-dotted:before { + content: "\fead"; +} + +.ti-eye-down:before { + content: "\fb72"; +} + +.ti-eye-edit:before { + content: "\f7ee"; +} + +.ti-eye-exclamation:before { + content: "\f7ef"; +} + +.ti-eye-heart:before { + content: "\f7f0"; +} + +.ti-eye-minus:before { + content: "\fb73"; +} + +.ti-eye-off:before { + content: "\ecf0"; +} + +.ti-eye-pause:before { + content: "\fb74"; +} + +.ti-eye-pin:before { + content: "\fb75"; +} + +.ti-eye-plus:before { + content: "\fb76"; +} + +.ti-eye-question:before { + content: "\fb77"; +} + +.ti-eye-search:before { + content: "\fb78"; +} + +.ti-eye-share:before { + content: "\fb79"; +} + +.ti-eye-spark:before { + content: "\ffba"; +} + +.ti-eye-star:before { + content: "\fb7a"; +} + +.ti-eye-table:before { + content: "\ef5e"; +} + +.ti-eye-up:before { + content: "\fb7b"; +} + +.ti-eye-x:before { + content: "\f7f1"; +} + +.ti-eyeglass:before { + content: "\ee8a"; +} + +.ti-eyeglass-2:before { + content: "\ee89"; +} + +.ti-eyeglass-off:before { + content: "\f126"; +} + +.ti-face-id:before { + content: "\ea9b"; +} + +.ti-face-id-error:before { + content: "\efa7"; +} + +.ti-face-mask:before { + content: "\efb5"; +} + +.ti-face-mask-off:before { + content: "\f127"; +} + +.ti-fall:before { + content: "\ecb9"; +} + +.ti-favicon:before { + content: "\fd65"; +} + +.ti-feather:before { + content: "\ee8b"; +} + +.ti-feather-off:before { + content: "\f128"; +} + +.ti-fence:before { + content: "\ef2a"; +} + +.ti-fence-off:before { + content: "\f129"; +} + +.ti-ferry:before { + content: "\10074"; +} + +.ti-fidget-spinner:before { + content: "\f068"; +} + +.ti-file:before { + content: "\eaa4"; +} + +.ti-file-3d:before { + content: "\f032"; +} + +.ti-file-ai:before { + content: "\ffa1"; +} + +.ti-file-alert:before { + content: "\ede6"; +} + +.ti-file-analytics:before { + content: "\ede7"; +} + +.ti-file-arrow-left:before { + content: "\f033"; +} + +.ti-file-arrow-right:before { + content: "\f034"; +} + +.ti-file-barcode:before { + content: "\f035"; +} + +.ti-file-bitcoin:before { + content: "\ffa0"; +} + +.ti-file-broken:before { + content: "\f501"; +} + +.ti-file-certificate:before { + content: "\ed4d"; +} + +.ti-file-chart:before { + content: "\f036"; +} + +.ti-file-check:before { + content: "\ea9c"; +} + +.ti-file-code:before { + content: "\ebd0"; +} + +.ti-file-code-2:before { + content: "\ede8"; +} + +.ti-file-cv:before { + content: "\fa5a"; +} + +.ti-file-database:before { + content: "\f037"; +} + +.ti-file-delta:before { + content: "\f53d"; +} + +.ti-file-description:before { + content: "\f028"; +} + +.ti-file-diff:before { + content: "\ecf1"; +} + +.ti-file-digit:before { + content: "\efa8"; +} + +.ti-file-dislike:before { + content: "\ed2a"; +} + +.ti-file-dollar:before { + content: "\efe0"; +} + +.ti-file-dots:before { + content: "\f038"; +} + +.ti-file-download:before { + content: "\ea9d"; +} + +.ti-file-euro:before { + content: "\efe1"; +} + +.ti-file-excel:before { + content: "\fef3"; +} + +.ti-file-export:before { + content: "\ede9"; +} + +.ti-file-function:before { + content: "\f53e"; +} + +.ti-file-horizontal:before { + content: "\ebb0"; +} + +.ti-file-import:before { + content: "\edea"; +} + +.ti-file-infinity:before { + content: "\f502"; +} + +.ti-file-info:before { + content: "\edec"; +} + +.ti-file-invoice:before { + content: "\eb67"; +} + +.ti-file-isr:before { + content: "\feac"; +} + +.ti-file-lambda:before { + content: "\f53f"; +} + +.ti-file-like:before { + content: "\ed2b"; +} + +.ti-file-minus:before { + content: "\ea9e"; +} + +.ti-file-music:before { + content: "\ea9f"; +} + +.ti-file-neutral:before { + content: "\fd22"; +} + +.ti-file-off:before { + content: "\ecf2"; +} + +.ti-file-orientation:before { + content: "\f2a1"; +} + +.ti-file-pencil:before { + content: "\f039"; +} + +.ti-file-percent:before { + content: "\f540"; +} + +.ti-file-phone:before { + content: "\ecdc"; +} + +.ti-file-plus:before { + content: "\eaa0"; +} + +.ti-file-power:before { + content: "\f03a"; +} + +.ti-file-report:before { + content: "\eded"; +} + +.ti-file-rss:before { + content: "\f03b"; +} + +.ti-file-sad:before { + content: "\fd23"; +} + +.ti-file-scissors:before { + content: "\f03c"; +} + +.ti-file-search:before { + content: "\ed5d"; +} + +.ti-file-settings:before { + content: "\f029"; +} + +.ti-file-shredder:before { + content: "\eaa1"; +} + +.ti-file-signal:before { + content: "\f03d"; +} + +.ti-file-smile:before { + content: "\fd24"; +} + +.ti-file-spark:before { + content: "\ffb9"; +} + +.ti-file-spreadsheet:before { + content: "\f03e"; +} + +.ti-file-stack:before { + content: "\f503"; +} + +.ti-file-star:before { + content: "\f03f"; +} + +.ti-file-symlink:before { + content: "\ed53"; +} + +.ti-file-text:before { + content: "\eaa2"; +} + +.ti-file-text-ai:before { + content: "\fa27"; +} + +.ti-file-text-shield:before { + content: "\100f2"; +} + +.ti-file-text-spark:before { + content: "\ffb8"; +} + +.ti-file-time:before { + content: "\f040"; +} + +.ti-file-type-bmp:before { + content: "\fb07"; +} + +.ti-file-type-css:before { + content: "\fb08"; +} + +.ti-file-type-csv:before { + content: "\fb09"; +} + +.ti-file-type-doc:before { + content: "\fb0a"; +} + +.ti-file-type-docx:before { + content: "\fb0b"; +} + +.ti-file-type-html:before { + content: "\fb0c"; +} + +.ti-file-type-jpg:before { + content: "\fb0d"; +} + +.ti-file-type-js:before { + content: "\fb0e"; +} + +.ti-file-type-jsx:before { + content: "\fb0f"; +} + +.ti-file-type-pdf:before { + content: "\fb10"; +} + +.ti-file-type-php:before { + content: "\fb11"; +} + +.ti-file-type-png:before { + content: "\fb12"; +} + +.ti-file-type-ppt:before { + content: "\fb13"; +} + +.ti-file-type-rs:before { + content: "\fb14"; +} + +.ti-file-type-sql:before { + content: "\fb15"; +} + +.ti-file-type-svg:before { + content: "\fb16"; +} + +.ti-file-type-ts:before { + content: "\fb17"; +} + +.ti-file-type-tsx:before { + content: "\fb18"; +} + +.ti-file-type-txt:before { + content: "\fb19"; +} + +.ti-file-type-vue:before { + content: "\fb1a"; +} + +.ti-file-type-xls:before { + content: "\fb1b"; +} + +.ti-file-type-xml:before { + content: "\fb1c"; +} + +.ti-file-type-zip:before { + content: "\fb1d"; +} + +.ti-file-typography:before { + content: "\f041"; +} + +.ti-file-unknown:before { + content: "\f042"; +} + +.ti-file-upload:before { + content: "\ec91"; +} + +.ti-file-vector:before { + content: "\f043"; +} + +.ti-file-word:before { + content: "\fef2"; +} + +.ti-file-x:before { + content: "\eaa3"; +} + +.ti-file-zip:before { + content: "\ed4e"; +} + +.ti-files:before { + content: "\edef"; +} + +.ti-files-off:before { + content: "\edee"; +} + +.ti-filter:before { + content: "\eaa5"; +} + +.ti-filter-2:before { + content: "\1014b"; +} + +.ti-filter-2-bolt:before { + content: "\1015f"; +} + +.ti-filter-2-cancel:before { + content: "\1015e"; +} + +.ti-filter-2-check:before { + content: "\1015d"; +} + +.ti-filter-2-code:before { + content: "\1015c"; +} + +.ti-filter-2-cog:before { + content: "\1015b"; +} + +.ti-filter-2-discount:before { + content: "\1015a"; +} + +.ti-filter-2-dollar:before { + content: "\10159"; +} + +.ti-filter-2-down:before { + content: "\10158"; +} + +.ti-filter-2-edit:before { + content: "\10157"; +} + +.ti-filter-2-exclamation:before { + content: "\10156"; +} + +.ti-filter-2-minus:before { + content: "\10155"; +} + +.ti-filter-2-pause:before { + content: "\10154"; +} + +.ti-filter-2-pin:before { + content: "\10153"; +} + +.ti-filter-2-plus:before { + content: "\10152"; +} + +.ti-filter-2-question:before { + content: "\10151"; +} + +.ti-filter-2-search:before { + content: "\10150"; +} + +.ti-filter-2-share:before { + content: "\1014f"; +} + +.ti-filter-2-spark:before { + content: "\1014e"; +} + +.ti-filter-2-up:before { + content: "\1014d"; +} + +.ti-filter-2-x:before { + content: "\1014c"; +} + +.ti-filter-bolt:before { + content: "\fb7c"; +} + +.ti-filter-cancel:before { + content: "\fb7d"; +} + +.ti-filter-check:before { + content: "\fb7e"; +} + +.ti-filter-code:before { + content: "\fb7f"; +} + +.ti-filter-cog:before { + content: "\f9fe"; +} + +.ti-filter-discount:before { + content: "\fb80"; +} + +.ti-filter-dollar:before { + content: "\f9ff"; +} + +.ti-filter-down:before { + content: "\fb81"; +} + +.ti-filter-edit:before { + content: "\fa00"; +} + +.ti-filter-exclamation:before { + content: "\fb82"; +} + +.ti-filter-heart:before { + content: "\fb83"; +} + +.ti-filter-minus:before { + content: "\fa01"; +} + +.ti-filter-off:before { + content: "\ed2c"; +} + +.ti-filter-pause:before { + content: "\fb84"; +} + +.ti-filter-pin:before { + content: "\fb85"; +} + +.ti-filter-plus:before { + content: "\fa02"; +} + +.ti-filter-question:before { + content: "\fb86"; +} + +.ti-filter-search:before { + content: "\fb87"; +} + +.ti-filter-share:before { + content: "\fb88"; +} + +.ti-filter-spark:before { + content: "\1014a"; +} + +.ti-filter-star:before { + content: "\fa03"; +} + +.ti-filter-up:before { + content: "\fb89"; +} + +.ti-filter-x:before { + content: "\fa04"; +} + +.ti-filters:before { + content: "\f793"; +} + +.ti-fingerprint:before { + content: "\ebd1"; +} + +.ti-fingerprint-off:before { + content: "\f12a"; +} + +.ti-fingerprint-scan:before { + content: "\fcb5"; +} + +.ti-fire-extinguisher:before { + content: "\faf6"; +} + +.ti-fire-hydrant:before { + content: "\f3a9"; +} + +.ti-fire-hydrant-off:before { + content: "\f3ec"; +} + +.ti-firetruck:before { + content: "\ebe8"; +} + +.ti-firewall-check:before { + content: "\101f1"; +} + +.ti-firewall-flame:before { + content: "\101f0"; +} + +.ti-first-aid-kit:before { + content: "\ef5f"; +} + +.ti-first-aid-kit-off:before { + content: "\f3ed"; +} + +.ti-fish:before { + content: "\ef2b"; +} + +.ti-fish-bone:before { + content: "\f287"; +} + +.ti-fish-christianity:before { + content: "\f58b"; +} + +.ti-fish-hook:before { + content: "\f1f9"; +} + +.ti-fish-hook-off:before { + content: "\f3ee"; +} + +.ti-fish-off:before { + content: "\f12b"; +} + +.ti-flag:before { + content: "\eaa6"; +} + +.ti-flag-2:before { + content: "\ee8c"; +} + +.ti-flag-2-off:before { + content: "\f12c"; +} + +.ti-flag-3:before { + content: "\ee8d"; +} + +.ti-flag-bitcoin:before { + content: "\ff3c"; +} + +.ti-flag-bolt:before { + content: "\fb8a"; +} + +.ti-flag-cancel:before { + content: "\fb8b"; +} + +.ti-flag-check:before { + content: "\fb8c"; +} + +.ti-flag-code:before { + content: "\fb8d"; +} + +.ti-flag-cog:before { + content: "\fb8e"; +} + +.ti-flag-discount:before { + content: "\fb8f"; +} + +.ti-flag-dollar:before { + content: "\fb90"; +} + +.ti-flag-down:before { + content: "\fb91"; +} + +.ti-flag-exclamation:before { + content: "\fb92"; +} + +.ti-flag-heart:before { + content: "\fb93"; +} + +.ti-flag-minus:before { + content: "\fb94"; +} + +.ti-flag-off:before { + content: "\f12d"; +} + +.ti-flag-pause:before { + content: "\fb95"; +} + +.ti-flag-pin:before { + content: "\fb96"; +} + +.ti-flag-plus:before { + content: "\fb97"; +} + +.ti-flag-question:before { + content: "\fb98"; +} + +.ti-flag-search:before { + content: "\fb99"; +} + +.ti-flag-share:before { + content: "\fb9a"; +} + +.ti-flag-spark:before { + content: "\ffb7"; +} + +.ti-flag-star:before { + content: "\fb9b"; +} + +.ti-flag-up:before { + content: "\fb9c"; +} + +.ti-flag-x:before { + content: "\fb9d"; +} + +.ti-flame:before { + content: "\ec2c"; +} + +.ti-flame-off:before { + content: "\f12e"; +} + +.ti-flare:before { + content: "\ee8e"; +} + +.ti-flask:before { + content: "\ebd2"; +} + +.ti-flask-2:before { + content: "\ef60"; +} + +.ti-flask-2-off:before { + content: "\f12f"; +} + +.ti-flask-off:before { + content: "\f130"; +} + +.ti-flip-flops:before { + content: "\f564"; +} + +.ti-flip-horizontal:before { + content: "\eaa7"; +} + +.ti-flip-vertical:before { + content: "\eaa8"; +} + +.ti-float-center:before { + content: "\ebb1"; +} + +.ti-float-left:before { + content: "\ebb2"; +} + +.ti-float-none:before { + content: "\ed13"; +} + +.ti-float-right:before { + content: "\ebb3"; +} + +.ti-flood:before { + content: "\1024b"; +} + +.ti-flower:before { + content: "\eff6"; +} + +.ti-flower-off:before { + content: "\f131"; +} + +.ti-focus:before { + content: "\eb8d"; +} + +.ti-focus-2:before { + content: "\ebd3"; +} + +.ti-focus-auto:before { + content: "\fa62"; +} + +.ti-focus-centered:before { + content: "\f02a"; +} + +.ti-fold:before { + content: "\ed56"; +} + +.ti-fold-down:before { + content: "\ed54"; +} + +.ti-fold-up:before { + content: "\ed55"; +} + +.ti-folder:before { + content: "\eaad"; +} + +.ti-folder-bolt:before { + content: "\f90c"; +} + +.ti-folder-cancel:before { + content: "\f90d"; +} + +.ti-folder-check:before { + content: "\f90e"; +} + +.ti-folder-code:before { + content: "\f90f"; +} + +.ti-folder-cog:before { + content: "\f910"; +} + +.ti-folder-dollar:before { + content: "\f911"; +} + +.ti-folder-down:before { + content: "\f912"; +} + +.ti-folder-exclamation:before { + content: "\f913"; +} + +.ti-folder-heart:before { + content: "\f914"; +} + +.ti-folder-minus:before { + content: "\eaaa"; +} + +.ti-folder-off:before { + content: "\ed14"; +} + +.ti-folder-open:before { + content: "\faf7"; +} + +.ti-folder-pause:before { + content: "\f915"; +} + +.ti-folder-pin:before { + content: "\f916"; +} + +.ti-folder-plus:before { + content: "\eaab"; +} + +.ti-folder-question:before { + content: "\f917"; +} + +.ti-folder-root:before { + content: "\fd43"; +} + +.ti-folder-search:before { + content: "\f918"; +} + +.ti-folder-share:before { + content: "\f919"; +} + +.ti-folder-star:before { + content: "\f91a"; +} + +.ti-folder-symlink:before { + content: "\f91b"; +} + +.ti-folder-up:before { + content: "\f91c"; +} + +.ti-folder-x:before { + content: "\eaac"; +} + +.ti-folders:before { + content: "\eaae"; +} + +.ti-folders-off:before { + content: "\f133"; +} + +.ti-foodsteps:before { + content: "\10265"; +} + +.ti-forbid:before { + content: "\ebd5"; +} + +.ti-forbid-2:before { + content: "\ebd4"; +} + +.ti-forklift:before { + content: "\ebe9"; +} + +.ti-forms:before { + content: "\ee8f"; +} + +.ti-fountain:before { + content: "\f09b"; +} + +.ti-fountain-off:before { + content: "\f134"; +} + +.ti-frame:before { + content: "\eaaf"; +} + +.ti-frame-off:before { + content: "\f135"; +} + +.ti-free-rights:before { + content: "\efb6"; +} + +.ti-freeze-column:before { + content: "\fa63"; +} + +.ti-freeze-row:before { + content: "\fa65"; +} + +.ti-freeze-row-column:before { + content: "\fa64"; +} + +.ti-fridge:before { + content: "\f1fa"; +} + +.ti-fridge-off:before { + content: "\f3ef"; +} + +.ti-friends:before { + content: "\eab0"; +} + +.ti-friends-off:before { + content: "\f136"; +} + +.ti-frustum:before { + content: "\fa9f"; +} + +.ti-frustum-off:before { + content: "\fa9d"; +} + +.ti-frustum-plus:before { + content: "\fa9e"; +} + +.ti-function:before { + content: "\f225"; +} + +.ti-function-off:before { + content: "\f3f0"; +} + +.ti-galaxy:before { + content: "\fcb6"; +} + +.ti-garden-cart:before { + content: "\f23e"; +} + +.ti-garden-cart-off:before { + content: "\f3f1"; +} + +.ti-gas-station:before { + content: "\ec7d"; +} + +.ti-gas-station-off:before { + content: "\f137"; +} + +.ti-gauge:before { + content: "\eab1"; +} + +.ti-gauge-off:before { + content: "\f138"; +} + +.ti-gavel:before { + content: "\ef90"; +} + +.ti-gender-agender:before { + content: "\f0e1"; +} + +.ti-gender-androgyne:before { + content: "\f0e2"; +} + +.ti-gender-bigender:before { + content: "\f0e3"; +} + +.ti-gender-demiboy:before { + content: "\f0e4"; +} + +.ti-gender-demigirl:before { + content: "\f0e5"; +} + +.ti-gender-epicene:before { + content: "\f0e6"; +} + +.ti-gender-female:before { + content: "\f0e7"; +} + +.ti-gender-femme:before { + content: "\f0e8"; +} + +.ti-gender-genderfluid:before { + content: "\f0e9"; +} + +.ti-gender-genderless:before { + content: "\f0ea"; +} + +.ti-gender-genderqueer:before { + content: "\f0eb"; +} + +.ti-gender-hermaphrodite:before { + content: "\f0ec"; +} + +.ti-gender-intergender:before { + content: "\f0ed"; +} + +.ti-gender-male:before { + content: "\f0ee"; +} + +.ti-gender-neutrois:before { + content: "\f0ef"; +} + +.ti-gender-third:before { + content: "\f0f0"; +} + +.ti-gender-transgender:before { + content: "\f0f1"; +} + +.ti-gender-trasvesti:before { + content: "\f0f2"; +} + +.ti-geometry:before { + content: "\ee90"; +} + +.ti-ghost:before { + content: "\eb8e"; +} + +.ti-ghost-2:before { + content: "\f57c"; +} + +.ti-ghost-3:before { + content: "\fc13"; +} + +.ti-ghost-off:before { + content: "\f3f2"; +} + +.ti-gif:before { + content: "\f257"; +} + +.ti-gift:before { + content: "\eb68"; +} + +.ti-gift-card:before { + content: "\f3aa"; +} + +.ti-gift-off:before { + content: "\f3f3"; +} + +.ti-git-branch:before { + content: "\eab2"; +} + +.ti-git-branch-deleted:before { + content: "\f57d"; +} + +.ti-git-cherry-pick:before { + content: "\f57e"; +} + +.ti-git-commit:before { + content: "\eab3"; +} + +.ti-git-compare:before { + content: "\eab4"; +} + +.ti-git-fork:before { + content: "\eb8f"; +} + +.ti-git-merge:before { + content: "\eab5"; +} + +.ti-git-pull-request:before { + content: "\eab6"; +} + +.ti-git-pull-request-closed:before { + content: "\ef7f"; +} + +.ti-git-pull-request-conflict:before { + content: "\10264"; +} + +.ti-git-pull-request-draft:before { + content: "\efb7"; +} + +.ti-gizmo:before { + content: "\f02b"; +} + +.ti-glass:before { + content: "\eab8"; +} + +.ti-glass-champagne:before { + content: "\fd9c"; +} + +.ti-glass-cocktail:before { + content: "\fd9d"; +} + +.ti-glass-full:before { + content: "\eab7"; +} + +.ti-glass-gin:before { + content: "\fd9e"; +} + +.ti-glass-off:before { + content: "\ee91"; +} + +.ti-globe:before { + content: "\eab9"; +} + +.ti-globe-off:before { + content: "\f139"; +} + +.ti-go-game:before { + content: "\f512"; +} + +.ti-golf:before { + content: "\ed8c"; +} + +.ti-golf-off:before { + content: "\f13a"; +} + +.ti-gps:before { + content: "\ed7a"; +} + +.ti-gradienter:before { + content: "\f3ab"; +} + +.ti-grain:before { + content: "\ee92"; +} + +.ti-grape:before { + content: "\10239"; +} + +.ti-graph:before { + content: "\f288"; +} + +.ti-graph-off:before { + content: "\f3f4"; +} + +.ti-grave:before { + content: "\f580"; +} + +.ti-grave-2:before { + content: "\f57f"; +} + +.ti-grid-3x3:before { + content: "\fca4"; +} + +.ti-grid-4x4:before { + content: "\fca5"; +} + +.ti-grid-dots:before { + content: "\eaba"; +} + +.ti-grid-goldenratio:before { + content: "\fca6"; +} + +.ti-grid-pattern:before { + content: "\efc9"; +} + +.ti-grid-scan:before { + content: "\fca7"; +} + +.ti-grill:before { + content: "\efa9"; +} + +.ti-grill-fork:before { + content: "\f35b"; +} + +.ti-grill-off:before { + content: "\f3f5"; +} + +.ti-grill-spatula:before { + content: "\f35c"; +} + +.ti-grip-horizontal:before { + content: "\ec00"; +} + +.ti-grip-vertical:before { + content: "\ec01"; +} + +.ti-growth:before { + content: "\ee93"; +} + +.ti-guitar-pick:before { + content: "\f4c6"; +} + +.ti-gymnastics:before { + content: "\fd44"; +} + +.ti-h-1:before { + content: "\ec94"; +} + +.ti-h-2:before { + content: "\ec95"; +} + +.ti-h-3:before { + content: "\ec96"; +} + +.ti-h-4:before { + content: "\ec97"; +} + +.ti-h-5:before { + content: "\ec98"; +} + +.ti-h-6:before { + content: "\ec99"; +} + +.ti-hammer:before { + content: "\ef91"; +} + +.ti-hammer-drill:before { + content: "\10238"; +} + +.ti-hammer-off:before { + content: "\f13c"; +} + +.ti-hand-click:before { + content: "\ef4f"; +} + +.ti-hand-click-off:before { + content: "\100f1"; +} + +.ti-hand-finger:before { + content: "\ee94"; +} + +.ti-hand-finger-down:before { + content: "\ff4f"; +} + +.ti-hand-finger-left:before { + content: "\ff4e"; +} + +.ti-hand-finger-off:before { + content: "\f13d"; +} + +.ti-hand-finger-right:before { + content: "\ff4d"; +} + +.ti-hand-grab:before { + content: "\f091"; +} + +.ti-hand-little-finger:before { + content: "\ee95"; +} + +.ti-hand-love-you:before { + content: "\ee97"; +} + +.ti-hand-middle-finger:before { + content: "\ec2d"; +} + +.ti-hand-move:before { + content: "\ef50"; +} + +.ti-hand-off:before { + content: "\ed15"; +} + +.ti-hand-ring-finger:before { + content: "\ee96"; +} + +.ti-hand-sanitizer:before { + content: "\f5f4"; +} + +.ti-hand-stop:before { + content: "\ec2e"; +} + +.ti-hand-three-fingers:before { + content: "\ee98"; +} + +.ti-hand-two-fingers:before { + content: "\ee99"; +} + +.ti-hanger:before { + content: "\ee9a"; +} + +.ti-hanger-2:before { + content: "\f09c"; +} + +.ti-hanger-off:before { + content: "\f13e"; +} + +.ti-hash:before { + content: "\eabc"; +} + +.ti-haze:before { + content: "\efaa"; +} + +.ti-haze-moon:before { + content: "\faf8"; +} + +.ti-hdr:before { + content: "\fa7b"; +} + +.ti-heading:before { + content: "\ee9b"; +} + +.ti-heading-off:before { + content: "\f13f"; +} + +.ti-headphones:before { + content: "\eabd"; +} + +.ti-headphones-off:before { + content: "\ed1d"; +} + +.ti-headset:before { + content: "\eb90"; +} + +.ti-headset-off:before { + content: "\f3f6"; +} + +.ti-health-recognition:before { + content: "\f1fb"; +} + +.ti-heart:before { + content: "\eabe"; +} + +.ti-heart-bitcoin:before { + content: "\ff3b"; +} + +.ti-heart-bolt:before { + content: "\fb9e"; +} + +.ti-heart-broken:before { + content: "\ecba"; +} + +.ti-heart-cancel:before { + content: "\fb9f"; +} + +.ti-heart-check:before { + content: "\fba0"; +} + +.ti-heart-code:before { + content: "\fba1"; +} + +.ti-heart-cog:before { + content: "\fba2"; +} + +.ti-heart-discount:before { + content: "\fba3"; +} + +.ti-heart-dollar:before { + content: "\fba4"; +} + +.ti-heart-down:before { + content: "\fba5"; +} + +.ti-heart-exclamation:before { + content: "\fba6"; +} + +.ti-heart-handshake:before { + content: "\f0f3"; +} + +.ti-heart-minus:before { + content: "\f140"; +} + +.ti-heart-off:before { + content: "\f141"; +} + +.ti-heart-pause:before { + content: "\fba7"; +} + +.ti-heart-pin:before { + content: "\fba8"; +} + +.ti-heart-plus:before { + content: "\f142"; +} + +.ti-heart-question:before { + content: "\fba9"; +} + +.ti-heart-rate-monitor:before { + content: "\ef61"; +} + +.ti-heart-search:before { + content: "\fbaa"; +} + +.ti-heart-share:before { + content: "\fbab"; +} + +.ti-heart-spark:before { + content: "\ffb6"; +} + +.ti-heart-star:before { + content: "\fbac"; +} + +.ti-heart-up:before { + content: "\fbad"; +} + +.ti-heart-x:before { + content: "\fbae"; +} + +.ti-heartbeat:before { + content: "\ef92"; +} + +.ti-hearts:before { + content: "\f387"; +} + +.ti-hearts-off:before { + content: "\f3f7"; +} + +.ti-helicopter:before { + content: "\ed8e"; +} + +.ti-helicopter-landing:before { + content: "\ed8d"; +} + +.ti-helmet:before { + content: "\efca"; +} + +.ti-helmet-off:before { + content: "\f143"; +} + +.ti-help:before { + content: "\eabf"; +} + +.ti-help-circle:before { + content: "\f91d"; +} + +.ti-help-hexagon:before { + content: "\f7a8"; +} + +.ti-help-octagon:before { + content: "\f7a9"; +} + +.ti-help-off:before { + content: "\f3f8"; +} + +.ti-help-small:before { + content: "\f91e"; +} + +.ti-help-square:before { + content: "\f920"; +} + +.ti-help-square-rounded:before { + content: "\f91f"; +} + +.ti-help-triangle:before { + content: "\f921"; +} + +.ti-hemisphere:before { + content: "\faa2"; +} + +.ti-hemisphere-off:before { + content: "\faa0"; +} + +.ti-hemisphere-plus:before { + content: "\faa1"; +} + +.ti-hexagon:before { + content: "\ec02"; +} + +.ti-hexagon-3d:before { + content: "\f4c7"; +} + +.ti-hexagon-asterisk:before { + content: "\101ab"; +} + +.ti-hexagon-letter-a:before { + content: "\f463"; +} + +.ti-hexagon-letter-b:before { + content: "\f464"; +} + +.ti-hexagon-letter-c:before { + content: "\f465"; +} + +.ti-hexagon-letter-d:before { + content: "\f466"; +} + +.ti-hexagon-letter-e:before { + content: "\f467"; +} + +.ti-hexagon-letter-f:before { + content: "\f468"; +} + +.ti-hexagon-letter-g:before { + content: "\f469"; +} + +.ti-hexagon-letter-h:before { + content: "\f46a"; +} + +.ti-hexagon-letter-i:before { + content: "\f46b"; +} + +.ti-hexagon-letter-j:before { + content: "\f46c"; +} + +.ti-hexagon-letter-k:before { + content: "\f46d"; +} + +.ti-hexagon-letter-l:before { + content: "\f46e"; +} + +.ti-hexagon-letter-m:before { + content: "\f46f"; +} + +.ti-hexagon-letter-n:before { + content: "\f470"; +} + +.ti-hexagon-letter-o:before { + content: "\f471"; +} + +.ti-hexagon-letter-p:before { + content: "\f472"; +} + +.ti-hexagon-letter-q:before { + content: "\f473"; +} + +.ti-hexagon-letter-r:before { + content: "\f474"; +} + +.ti-hexagon-letter-s:before { + content: "\f475"; +} + +.ti-hexagon-letter-t:before { + content: "\f476"; +} + +.ti-hexagon-letter-u:before { + content: "\f477"; +} + +.ti-hexagon-letter-v:before { + content: "\f4b3"; +} + +.ti-hexagon-letter-w:before { + content: "\f478"; +} + +.ti-hexagon-letter-x:before { + content: "\f479"; +} + +.ti-hexagon-letter-y:before { + content: "\f47a"; +} + +.ti-hexagon-letter-z:before { + content: "\f47b"; +} + +.ti-hexagon-minus:before { + content: "\fc8f"; +} + +.ti-hexagon-minus-2:before { + content: "\fc8e"; +} + +.ti-hexagon-number-0:before { + content: "\f459"; +} + +.ti-hexagon-number-1:before { + content: "\f45a"; +} + +.ti-hexagon-number-2:before { + content: "\f45b"; +} + +.ti-hexagon-number-3:before { + content: "\f45c"; +} + +.ti-hexagon-number-4:before { + content: "\f45d"; +} + +.ti-hexagon-number-5:before { + content: "\f45e"; +} + +.ti-hexagon-number-6:before { + content: "\f45f"; +} + +.ti-hexagon-number-7:before { + content: "\f460"; +} + +.ti-hexagon-number-8:before { + content: "\f461"; +} + +.ti-hexagon-number-9:before { + content: "\f462"; +} + +.ti-hexagon-off:before { + content: "\ee9c"; +} + +.ti-hexagon-plus:before { + content: "\fc45"; +} + +.ti-hexagon-plus-2:before { + content: "\fc90"; +} + +.ti-hexagonal-prism:before { + content: "\faa5"; +} + +.ti-hexagonal-prism-off:before { + content: "\faa3"; +} + +.ti-hexagonal-prism-plus:before { + content: "\faa4"; +} + +.ti-hexagonal-pyramid:before { + content: "\faa8"; +} + +.ti-hexagonal-pyramid-off:before { + content: "\faa6"; +} + +.ti-hexagonal-pyramid-plus:before { + content: "\faa7"; +} + +.ti-hexagons:before { + content: "\f09d"; +} + +.ti-hexagons-off:before { + content: "\f3f9"; +} + +.ti-hierarchy:before { + content: "\ee9e"; +} + +.ti-hierarchy-2:before { + content: "\ee9d"; +} + +.ti-hierarchy-3:before { + content: "\f289"; +} + +.ti-hierarchy-off:before { + content: "\f3fa"; +} + +.ti-highlight:before { + content: "\ef3f"; +} + +.ti-highlight-off:before { + content: "\f144"; +} + +.ti-history:before { + content: "\ebea"; +} + +.ti-history-off:before { + content: "\f3fb"; +} + +.ti-history-toggle:before { + content: "\f1fc"; +} + +.ti-home:before { + content: "\eac1"; +} + +.ti-home-2:before { + content: "\eac0"; +} + +.ti-home-bitcoin:before { + content: "\ff3a"; +} + +.ti-home-bolt:before { + content: "\f336"; +} + +.ti-home-cancel:before { + content: "\f350"; +} + +.ti-home-check:before { + content: "\f337"; +} + +.ti-home-cog:before { + content: "\f338"; +} + +.ti-home-dollar:before { + content: "\f339"; +} + +.ti-home-dot:before { + content: "\f33a"; +} + +.ti-home-down:before { + content: "\f33b"; +} + +.ti-home-eco:before { + content: "\f351"; +} + +.ti-home-edit:before { + content: "\f352"; +} + +.ti-home-exclamation:before { + content: "\f33c"; +} + +.ti-home-hand:before { + content: "\f504"; +} + +.ti-home-heart:before { + content: "\f353"; +} + +.ti-home-infinity:before { + content: "\f505"; +} + +.ti-home-link:before { + content: "\f354"; +} + +.ti-home-lock:before { + content: "\10204"; +} + +.ti-home-minus:before { + content: "\f33d"; +} + +.ti-home-move:before { + content: "\f33e"; +} + +.ti-home-off:before { + content: "\f145"; +} + +.ti-home-plus:before { + content: "\f33f"; +} + +.ti-home-question:before { + content: "\f340"; +} + +.ti-home-ribbon:before { + content: "\f355"; +} + +.ti-home-search:before { + content: "\f341"; +} + +.ti-home-share:before { + content: "\f342"; +} + +.ti-home-shield:before { + content: "\f343"; +} + +.ti-home-signal:before { + content: "\f356"; +} + +.ti-home-spark:before { + content: "\ffb5"; +} + +.ti-home-star:before { + content: "\f344"; +} + +.ti-home-stats:before { + content: "\f345"; +} + +.ti-home-up:before { + content: "\f346"; +} + +.ti-home-x:before { + content: "\f347"; +} + +.ti-horse:before { + content: "\fc46"; +} + +.ti-horse-toy:before { + content: "\f28a"; +} + +.ti-horseshoe:before { + content: "\fcb7"; +} + +.ti-hospital:before { + content: "\fd59"; +} + +.ti-hospital-circle:before { + content: "\fd58"; +} + +.ti-hotel-service:before { + content: "\ef80"; +} + +.ti-hourglass:before { + content: "\ef93"; +} + +.ti-hourglass-empty:before { + content: "\f146"; +} + +.ti-hourglass-high:before { + content: "\f092"; +} + +.ti-hourglass-low:before { + content: "\f093"; +} + +.ti-hourglass-off:before { + content: "\f147"; +} + +.ti-hours-12:before { + content: "\fc53"; +} + +.ti-hours-24:before { + content: "\f5e7"; +} + +.ti-html:before { + content: "\f7b1"; +} + +.ti-http-connect:before { + content: "\fa28"; +} + +.ti-http-connect-off:before { + content: "\100e7"; +} + +.ti-http-delete:before { + content: "\fa29"; +} + +.ti-http-delete-off:before { + content: "\100e6"; +} + +.ti-http-get:before { + content: "\fa2a"; +} + +.ti-http-get-off:before { + content: "\100e5"; +} + +.ti-http-head:before { + content: "\fa2b"; +} + +.ti-http-head-off:before { + content: "\100e4"; +} + +.ti-http-options:before { + content: "\fa2c"; +} + +.ti-http-options-off:before { + content: "\100e3"; +} + +.ti-http-patch:before { + content: "\fa2d"; +} + +.ti-http-patch-off:before { + content: "\100e2"; +} + +.ti-http-post:before { + content: "\fa2e"; +} + +.ti-http-post-off:before { + content: "\100e1"; +} + +.ti-http-put:before { + content: "\fa2f"; +} + +.ti-http-put-off:before { + content: "\100e0"; +} + +.ti-http-que:before { + content: "\fa5b"; +} + +.ti-http-que-off:before { + content: "\100df"; +} + +.ti-http-trace:before { + content: "\fa30"; +} + +.ti-http-trace-off:before { + content: "\100de"; +} + +.ti-hula-hoop:before { + content: "\1024a"; +} + +.ti-ice-cream:before { + content: "\eac2"; +} + +.ti-ice-cream-2:before { + content: "\ee9f"; +} + +.ti-ice-cream-off:before { + content: "\f148"; +} + +.ti-ice-skating:before { + content: "\efcb"; +} + +.ti-iceberg:before { + content: "\1022a"; +} + +.ti-icons:before { + content: "\f1d4"; +} + +.ti-icons-off:before { + content: "\f3fc"; +} + +.ti-id:before { + content: "\eac3"; +} + +.ti-id-badge:before { + content: "\eff7"; +} + +.ti-id-badge-2:before { + content: "\f076"; +} + +.ti-id-badge-off:before { + content: "\f3fd"; +} + +.ti-id-off:before { + content: "\f149"; +} + +.ti-ikosaedr:before { + content: "\fec6"; +} + +.ti-image-generation:before { + content: "\101ef"; +} + +.ti-image-in-picture:before { + content: "\fd9f"; +} + +.ti-inbox:before { + content: "\eac4"; +} + +.ti-inbox-off:before { + content: "\f14a"; +} + +.ti-indent-decrease:before { + content: "\eb91"; +} + +.ti-indent-increase:before { + content: "\eb92"; +} + +.ti-infinity:before { + content: "\eb69"; +} + +.ti-infinity-2:before { + content: "\10237"; +} + +.ti-infinity-off:before { + content: "\f3fe"; +} + +.ti-info-circle:before { + content: "\eac5"; +} + +.ti-info-hexagon:before { + content: "\f7aa"; +} + +.ti-info-octagon:before { + content: "\f7ab"; +} + +.ti-info-small:before { + content: "\f922"; +} + +.ti-info-square:before { + content: "\eac6"; +} + +.ti-info-square-rounded:before { + content: "\f635"; +} + +.ti-info-triangle:before { + content: "\f923"; +} + +.ti-inner-shadow-bottom:before { + content: "\f520"; +} + +.ti-inner-shadow-bottom-left:before { + content: "\f51e"; +} + +.ti-inner-shadow-bottom-right:before { + content: "\f51f"; +} + +.ti-inner-shadow-left:before { + content: "\f521"; +} + +.ti-inner-shadow-right:before { + content: "\f522"; +} + +.ti-inner-shadow-top:before { + content: "\f525"; +} + +.ti-inner-shadow-top-left:before { + content: "\f523"; +} + +.ti-inner-shadow-top-right:before { + content: "\f524"; +} + +.ti-input-ai:before { + content: "\fc5a"; +} + +.ti-input-check:before { + content: "\fc5b"; +} + +.ti-input-search:before { + content: "\f2a2"; +} + +.ti-input-spark:before { + content: "\ffb4"; +} + +.ti-input-x:before { + content: "\fc5c"; +} + +.ti-invoice:before { + content: "\feab"; +} + +.ti-ironing:before { + content: "\fa7c"; +} + +.ti-ironing-1:before { + content: "\f2f4"; +} + +.ti-ironing-2:before { + content: "\f2f5"; +} + +.ti-ironing-3:before { + content: "\f2f6"; +} + +.ti-ironing-off:before { + content: "\f2f7"; +} + +.ti-ironing-steam:before { + content: "\f2f9"; +} + +.ti-ironing-steam-off:before { + content: "\f2f8"; +} + +.ti-irregular-polyhedron:before { + content: "\faab"; +} + +.ti-irregular-polyhedron-off:before { + content: "\faa9"; +} + +.ti-irregular-polyhedron-plus:before { + content: "\faaa"; +} + +.ti-italic:before { + content: "\eb93"; +} + +.ti-jacket:before { + content: "\f661"; +} + +.ti-jetpack:before { + content: "\f581"; +} + +.ti-jetski:before { + content: "\10229"; +} + +.ti-jewish-star:before { + content: "\f3ff"; +} + +.ti-join-bevel:before { + content: "\ff4c"; +} + +.ti-join-round:before { + content: "\ff4b"; +} + +.ti-join-straight:before { + content: "\ff4a"; +} + +.ti-joker:before { + content: "\1005f"; +} + +.ti-jpg:before { + content: "\f3ac"; +} + +.ti-json:before { + content: "\f7b2"; +} + +.ti-jump-rope:before { + content: "\ed8f"; +} + +.ti-karate:before { + content: "\ed32"; +} + +.ti-kayak:before { + content: "\f1d6"; +} + +.ti-kerning:before { + content: "\efb8"; +} + +.ti-key:before { + content: "\eac7"; +} + +.ti-key-off:before { + content: "\f14b"; +} + +.ti-keyboard:before { + content: "\ebd6"; +} + +.ti-keyboard-hide:before { + content: "\ec7e"; +} + +.ti-keyboard-off:before { + content: "\eea0"; +} + +.ti-keyboard-show:before { + content: "\ec7f"; +} + +.ti-keyframe:before { + content: "\f576"; +} + +.ti-keyframe-align-center:before { + content: "\f582"; +} + +.ti-keyframe-align-horizontal:before { + content: "\f583"; +} + +.ti-keyframe-align-vertical:before { + content: "\f584"; +} + +.ti-keyframes:before { + content: "\f585"; +} + +.ti-label:before { + content: "\ff38"; +} + +.ti-label-important:before { + content: "\ff49"; +} + +.ti-label-off:before { + content: "\ff39"; +} + +.ti-ladder:before { + content: "\efe2"; +} + +.ti-ladder-off:before { + content: "\f14c"; +} + +.ti-ladle:before { + content: "\fc14"; +} + +.ti-lambda:before { + content: "\f541"; +} + +.ti-lamp:before { + content: "\efab"; +} + +.ti-lamp-2:before { + content: "\f09e"; +} + +.ti-lamp-off:before { + content: "\f14d"; +} + +.ti-lane:before { + content: "\faf9"; +} + +.ti-language:before { + content: "\ebbe"; +} + +.ti-language-hiragana:before { + content: "\ef77"; +} + +.ti-language-katakana:before { + content: "\ef78"; +} + +.ti-language-off:before { + content: "\f14e"; +} + +.ti-lasso:before { + content: "\efac"; +} + +.ti-lasso-off:before { + content: "\f14f"; +} + +.ti-lasso-polygon:before { + content: "\f388"; +} + +.ti-laurel-wreath:before { + content: "\ff45"; +} + +.ti-laurel-wreath-1:before { + content: "\ff48"; +} + +.ti-laurel-wreath-2:before { + content: "\ff47"; +} + +.ti-laurel-wreath-3:before { + content: "\ff46"; +} + +.ti-lawn-mower:before { + content: "\10236"; +} + +.ti-layers-difference:before { + content: "\eac8"; +} + +.ti-layers-intersect:before { + content: "\eac9"; +} + +.ti-layers-intersect-2:before { + content: "\eff8"; +} + +.ti-layers-linked:before { + content: "\eea1"; +} + +.ti-layers-off:before { + content: "\f150"; +} + +.ti-layers-selected:before { + content: "\fea9"; +} + +.ti-layers-selected-bottom:before { + content: "\feaa"; +} + +.ti-layers-subtract:before { + content: "\eaca"; +} + +.ti-layers-union:before { + content: "\eacb"; +} + +.ti-layout:before { + content: "\eadb"; +} + +.ti-layout-2:before { + content: "\eacc"; +} + +.ti-layout-align-bottom:before { + content: "\eacd"; +} -.ti-fire-hydrant-off:before { - content: "\f3ec"; +.ti-layout-align-center:before { + content: "\eace"; } -.ti-firetruck:before { - content: "\ebe8"; +.ti-layout-align-left:before { + content: "\eacf"; } -.ti-first-aid-kit:before { - content: "\ef5f"; +.ti-layout-align-middle:before { + content: "\ead0"; } -.ti-first-aid-kit-off:before { - content: "\f3ed"; +.ti-layout-align-right:before { + content: "\ead1"; } -.ti-fish:before { - content: "\ef2b"; +.ti-layout-align-top:before { + content: "\ead2"; } -.ti-fish-bone:before { - content: "\f287"; +.ti-layout-board:before { + content: "\ef95"; +} + +.ti-layout-board-split:before { + content: "\ef94"; +} + +.ti-layout-bottombar:before { + content: "\ead3"; +} + +.ti-layout-bottombar-collapse:before { + content: "\f28b"; +} + +.ti-layout-bottombar-expand:before { + content: "\f28c"; +} + +.ti-layout-bottombar-inactive:before { + content: "\fd45"; +} + +.ti-layout-cards:before { + content: "\ec13"; +} + +.ti-layout-collage:before { + content: "\f389"; +} + +.ti-layout-columns:before { + content: "\ead4"; +} + +.ti-layout-dashboard:before { + content: "\f02c"; +} + +.ti-layout-distribute-horizontal:before { + content: "\ead5"; +} + +.ti-layout-distribute-vertical:before { + content: "\ead6"; +} + +.ti-layout-grid:before { + content: "\edba"; +} + +.ti-layout-grid-add:before { + content: "\edb9"; +} + +.ti-layout-grid-remove:before { + content: "\fa7d"; +} + +.ti-layout-kanban:before { + content: "\ec3f"; +} + +.ti-layout-list:before { + content: "\ec14"; +} + +.ti-layout-navbar:before { + content: "\ead7"; +} + +.ti-layout-navbar-collapse:before { + content: "\f28d"; +} + +.ti-layout-navbar-expand:before { + content: "\f28e"; +} + +.ti-layout-navbar-inactive:before { + content: "\fd46"; +} + +.ti-layout-off:before { + content: "\f151"; +} + +.ti-layout-rows:before { + content: "\ead8"; +} + +.ti-layout-sidebar:before { + content: "\eada"; +} + +.ti-layout-sidebar-inactive:before { + content: "\fd47"; +} + +.ti-layout-sidebar-left-collapse:before { + content: "\f004"; +} + +.ti-layout-sidebar-left-expand:before { + content: "\f005"; +} + +.ti-layout-sidebar-right:before { + content: "\ead9"; +} + +.ti-layout-sidebar-right-collapse:before { + content: "\f006"; +} + +.ti-layout-sidebar-right-expand:before { + content: "\f007"; +} + +.ti-layout-sidebar-right-inactive:before { + content: "\fd48"; +} + +.ti-leaf:before { + content: "\ed4f"; +} + +.ti-leaf-2:before { + content: "\ff44"; +} + +.ti-leaf-maple:before { + content: "\10249"; +} + +.ti-leaf-off:before { + content: "\f400"; +} + +.ti-lego:before { + content: "\eadc"; +} + +.ti-lego-off:before { + content: "\f401"; +} + +.ti-lemon:before { + content: "\ef10"; +} + +.ti-lemon-2:before { + content: "\ef81"; +} + +.ti-letter-a:before { + content: "\ec50"; +} + +.ti-letter-a-small:before { + content: "\fcc7"; +} + +.ti-letter-b:before { + content: "\ec51"; +} + +.ti-letter-b-small:before { + content: "\fcc8"; +} + +.ti-letter-c:before { + content: "\ec52"; +} + +.ti-letter-c-small:before { + content: "\fcc9"; +} + +.ti-letter-case:before { + content: "\eea5"; +} + +.ti-letter-case-lower:before { + content: "\eea2"; +} + +.ti-letter-case-toggle:before { + content: "\eea3"; +} + +.ti-letter-case-upper:before { + content: "\eea4"; +} + +.ti-letter-d:before { + content: "\ec53"; +} + +.ti-letter-d-small:before { + content: "\fcca"; +} + +.ti-letter-e:before { + content: "\ec54"; +} + +.ti-letter-e-small:before { + content: "\fccb"; +} + +.ti-letter-f:before { + content: "\ec55"; +} + +.ti-letter-f-small:before { + content: "\fccc"; +} + +.ti-letter-g:before { + content: "\ec56"; +} + +.ti-letter-g-small:before { + content: "\fccd"; +} + +.ti-letter-h:before { + content: "\ec57"; +} + +.ti-letter-h-small:before { + content: "\fcce"; +} + +.ti-letter-i:before { + content: "\ec58"; +} + +.ti-letter-i-small:before { + content: "\fccf"; +} + +.ti-letter-j:before { + content: "\ec59"; +} + +.ti-letter-j-small:before { + content: "\fcd0"; +} + +.ti-letter-k:before { + content: "\ec5a"; +} + +.ti-letter-k-small:before { + content: "\fcd1"; +} + +.ti-letter-l:before { + content: "\ec5b"; +} + +.ti-letter-l-small:before { + content: "\fcd2"; +} + +.ti-letter-m:before { + content: "\ec5c"; +} + +.ti-letter-m-small:before { + content: "\fcd3"; } -.ti-fish-christianity:before { - content: "\f58b"; +.ti-letter-n:before { + content: "\ec5d"; } -.ti-fish-hook:before { - content: "\f1f9"; +.ti-letter-n-small:before { + content: "\fcd4"; } -.ti-fish-hook-off:before { - content: "\f3ee"; +.ti-letter-o:before { + content: "\ec5e"; } -.ti-fish-off:before { - content: "\f12b"; +.ti-letter-o-small:before { + content: "\fcd5"; } -.ti-flag:before { - content: "\eaa6"; +.ti-letter-p:before { + content: "\ec5f"; } -.ti-flag-2:before { - content: "\ee8c"; +.ti-letter-p-small:before { + content: "\fcd6"; } -.ti-flag-2-off:before { - content: "\f12c"; +.ti-letter-q:before { + content: "\ec60"; } -.ti-flag-3:before { - content: "\ee8d"; +.ti-letter-q-small:before { + content: "\fcd7"; } -.ti-flag-off:before { - content: "\f12d"; +.ti-letter-r:before { + content: "\ec61"; } -.ti-flame:before { - content: "\ec2c"; +.ti-letter-r-small:before { + content: "\fcd8"; } -.ti-flame-off:before { - content: "\f12e"; +.ti-letter-s:before { + content: "\ec62"; } -.ti-flare:before { - content: "\ee8e"; +.ti-letter-s-small:before { + content: "\fcd9"; } -.ti-flask:before { - content: "\ebd2"; +.ti-letter-spacing:before { + content: "\eea6"; } -.ti-flask-2:before { - content: "\ef60"; +.ti-letter-t:before { + content: "\ec63"; } -.ti-flask-2-off:before { - content: "\f12f"; +.ti-letter-t-small:before { + content: "\fcda"; } -.ti-flask-off:before { - content: "\f130"; +.ti-letter-u:before { + content: "\ec64"; } -.ti-flip-flops:before { - content: "\f564"; +.ti-letter-u-small:before { + content: "\fcdb"; } -.ti-flip-horizontal:before { - content: "\eaa7"; +.ti-letter-v:before { + content: "\ec65"; } -.ti-flip-vertical:before { - content: "\eaa8"; +.ti-letter-v-small:before { + content: "\fcdc"; } -.ti-float-center:before { - content: "\ebb1"; +.ti-letter-w:before { + content: "\ec66"; } -.ti-float-left:before { - content: "\ebb2"; +.ti-letter-w-small:before { + content: "\fcdd"; } -.ti-float-none:before { - content: "\ed13"; +.ti-letter-x:before { + content: "\ec67"; } -.ti-float-right:before { - content: "\ebb3"; +.ti-letter-x-small:before { + content: "\fcde"; } -.ti-flower:before { - content: "\eff6"; +.ti-letter-y:before { + content: "\ec68"; } -.ti-flower-off:before { - content: "\f131"; +.ti-letter-y-small:before { + content: "\fcdf"; } -.ti-focus:before { - content: "\eb8d"; +.ti-letter-z:before { + content: "\ec69"; } -.ti-focus-2:before { - content: "\ebd3"; +.ti-letter-z-small:before { + content: "\fce0"; } -.ti-focus-centered:before { - content: "\f02a"; +.ti-library:before { + content: "\fd4c"; } -.ti-fold:before { - content: "\ed56"; +.ti-library-minus:before { + content: "\fd49"; } -.ti-fold-down:before { - content: "\ed54"; +.ti-library-photo:before { + content: "\fd4a"; } -.ti-fold-up:before { - content: "\ed55"; +.ti-library-plus:before { + content: "\fd4b"; } -.ti-folder:before { - content: "\eaad"; +.ti-license:before { + content: "\ebc0"; } -.ti-folder-minus:before { - content: "\eaaa"; +.ti-license-off:before { + content: "\f153"; } -.ti-folder-off:before { - content: "\ed14"; +.ti-lifebuoy:before { + content: "\eadd"; } -.ti-folder-plus:before { - content: "\eaab"; +.ti-lifebuoy-off:before { + content: "\f154"; } -.ti-folder-x:before { - content: "\eaac"; +.ti-lighter:before { + content: "\f794"; } -.ti-folders:before { - content: "\eaae"; +.ti-line:before { + content: "\ec40"; } -.ti-folders-off:before { - content: "\f133"; +.ti-line-dashed:before { + content: "\eea7"; } -.ti-forbid:before { - content: "\ebd5"; +.ti-line-dotted:before { + content: "\eea8"; } -.ti-forbid-2:before { - content: "\ebd4"; +.ti-line-height:before { + content: "\eb94"; } -.ti-forklift:before { - content: "\ebe9"; +.ti-line-scan:before { + content: "\fcb8"; } -.ti-forms:before { - content: "\ee8f"; +.ti-link:before { + content: "\eade"; } -.ti-fountain:before { - content: "\f09b"; +.ti-link-minus:before { + content: "\fd16"; } -.ti-fountain-off:before { - content: "\f134"; +.ti-link-off:before { + content: "\f402"; } -.ti-frame:before { - content: "\eaaf"; +.ti-link-plus:before { + content: "\fd17"; } -.ti-frame-off:before { - content: "\f135"; +.ti-list:before { + content: "\eb6b"; } -.ti-free-rights:before { - content: "\efb6"; +.ti-list-check:before { + content: "\eb6a"; } -.ti-fridge:before { - content: "\f1fa"; +.ti-list-details:before { + content: "\ef40"; } -.ti-fridge-off:before { - content: "\f3ef"; +.ti-list-letters:before { + content: "\fc47"; } -.ti-friends:before { - content: "\eab0"; +.ti-list-numbers:before { + content: "\ef11"; } -.ti-friends-off:before { - content: "\f136"; +.ti-list-search:before { + content: "\eea9"; } -.ti-function:before { - content: "\f225"; +.ti-list-tree:before { + content: "\fafa"; } -.ti-function-off:before { - content: "\f3f0"; +.ti-live-photo:before { + content: "\eadf"; } -.ti-garden-cart:before { - content: "\f23e"; +.ti-live-photo-off:before { + content: "\f403"; } -.ti-garden-cart-off:before { - content: "\f3f1"; +.ti-live-view:before { + content: "\ec6b"; } -.ti-gas-station:before { - content: "\ec7d"; +.ti-load-balancer:before { + content: "\fa5c"; } -.ti-gas-station-off:before { - content: "\f137"; +.ti-loader:before { + content: "\eca3"; } -.ti-gauge:before { - content: "\eab1"; +.ti-loader-2:before { + content: "\f226"; } -.ti-gauge-off:before { - content: "\f138"; +.ti-loader-3:before { + content: "\f513"; } -.ti-gavel:before { - content: "\ef90"; +.ti-loader-4:before { + content: "\10235"; } -.ti-gender-agender:before { - content: "\f0e1"; +.ti-loader-quarter:before { + content: "\eca2"; } -.ti-gender-androgyne:before { - content: "\f0e2"; +.ti-location:before { + content: "\eae0"; } -.ti-gender-bigender:before { - content: "\f0e3"; +.ti-location-bolt:before { + content: "\fbaf"; } -.ti-gender-demiboy:before { - content: "\f0e4"; +.ti-location-broken:before { + content: "\f2c4"; } -.ti-gender-demigirl:before { - content: "\f0e5"; +.ti-location-cancel:before { + content: "\fbb0"; } -.ti-gender-epicene:before { - content: "\f0e6"; +.ti-location-check:before { + content: "\fbb1"; } -.ti-gender-female:before { - content: "\f0e7"; +.ti-location-code:before { + content: "\fbb2"; } -.ti-gender-femme:before { - content: "\f0e8"; +.ti-location-cog:before { + content: "\fbb3"; } -.ti-gender-genderfluid:before { - content: "\f0e9"; +.ti-location-discount:before { + content: "\fbb4"; } -.ti-gender-genderless:before { - content: "\f0ea"; +.ti-location-dollar:before { + content: "\fbb5"; } -.ti-gender-genderqueer:before { - content: "\f0eb"; +.ti-location-down:before { + content: "\fbb6"; } -.ti-gender-hermaphrodite:before { - content: "\f0ec"; +.ti-location-exclamation:before { + content: "\fbb7"; } -.ti-gender-intergender:before { - content: "\f0ed"; +.ti-location-heart:before { + content: "\fbb8"; } -.ti-gender-male:before { - content: "\f0ee"; +.ti-location-minus:before { + content: "\fbb9"; } -.ti-gender-neutrois:before { - content: "\f0ef"; +.ti-location-off:before { + content: "\f155"; } -.ti-gender-third:before { - content: "\f0f0"; +.ti-location-pause:before { + content: "\fbba"; } -.ti-gender-transgender:before { - content: "\f0f1"; +.ti-location-pin:before { + content: "\fbbb"; } -.ti-gender-trasvesti:before { - content: "\f0f2"; +.ti-location-plus:before { + content: "\fbbc"; } -.ti-geometry:before { - content: "\ee90"; +.ti-location-question:before { + content: "\fbbd"; } -.ti-ghost:before { - content: "\eb8e"; +.ti-location-search:before { + content: "\fbbe"; } -.ti-ghost-2:before { - content: "\f57c"; +.ti-location-share:before { + content: "\fbbf"; } -.ti-ghost-off:before { - content: "\f3f2"; +.ti-location-star:before { + content: "\fbc0"; } -.ti-gif:before { - content: "\f257"; +.ti-location-up:before { + content: "\fbc1"; } -.ti-gift:before { - content: "\eb68"; +.ti-location-x:before { + content: "\fbc2"; } -.ti-gift-card:before { - content: "\f3aa"; +.ti-lock:before { + content: "\eae2"; } -.ti-gift-off:before { - content: "\f3f3"; +.ti-lock-access:before { + content: "\eeaa"; } -.ti-git-branch:before { - content: "\eab2"; +.ti-lock-access-off:before { + content: "\f404"; } -.ti-git-branch-deleted:before { - content: "\f57d"; +.ti-lock-bitcoin:before { + content: "\ff37"; } -.ti-git-cherry-pick:before { - content: "\f57e"; +.ti-lock-bolt:before { + content: "\f924"; } -.ti-git-commit:before { - content: "\eab3"; +.ti-lock-cancel:before { + content: "\f925"; } -.ti-git-compare:before { - content: "\eab4"; +.ti-lock-check:before { + content: "\f926"; } -.ti-git-fork:before { - content: "\eb8f"; +.ti-lock-code:before { + content: "\f927"; } -.ti-git-merge:before { - content: "\eab5"; +.ti-lock-cog:before { + content: "\f928"; } -.ti-git-pull-request:before { - content: "\eab6"; +.ti-lock-dollar:before { + content: "\f929"; } -.ti-git-pull-request-closed:before { - content: "\ef7f"; +.ti-lock-down:before { + content: "\f92a"; } -.ti-git-pull-request-draft:before { - content: "\efb7"; +.ti-lock-exclamation:before { + content: "\f92b"; } -.ti-gizmo:before { - content: "\f02b"; +.ti-lock-heart:before { + content: "\f92c"; } -.ti-glass:before { - content: "\eab8"; +.ti-lock-minus:before { + content: "\f92d"; } -.ti-glass-full:before { - content: "\eab7"; +.ti-lock-off:before { + content: "\ed1e"; } -.ti-glass-off:before { - content: "\ee91"; +.ti-lock-open:before { + content: "\eae1"; } -.ti-globe:before { - content: "\eab9"; +.ti-lock-open-2:before { + content: "\fea8"; } -.ti-globe-off:before { - content: "\f139"; +.ti-lock-open-off:before { + content: "\f156"; } -.ti-go-game:before { - content: "\f512"; +.ti-lock-password:before { + content: "\ff9f"; } -.ti-golf:before { - content: "\ed8c"; +.ti-lock-pause:before { + content: "\f92e"; } -.ti-golf-off:before { - content: "\f13a"; +.ti-lock-pin:before { + content: "\f92f"; } -.ti-gps:before { - content: "\ed7a"; +.ti-lock-plus:before { + content: "\f930"; } -.ti-gradienter:before { - content: "\f3ab"; +.ti-lock-question:before { + content: "\f931"; } -.ti-grain:before { - content: "\ee92"; +.ti-lock-search:before { + content: "\f932"; } -.ti-graph:before { - content: "\f288"; +.ti-lock-share:before { + content: "\f933"; } -.ti-graph-off:before { - content: "\f3f4"; +.ti-lock-square:before { + content: "\ef51"; } -.ti-grave:before { - content: "\f580"; +.ti-lock-square-rounded:before { + content: "\f636"; } -.ti-grave-2:before { - content: "\f57f"; +.ti-lock-star:before { + content: "\f934"; } -.ti-grid-dots:before { - content: "\eaba"; +.ti-lock-up:before { + content: "\f935"; } -.ti-grid-pattern:before { - content: "\efc9"; +.ti-lock-x:before { + content: "\f936"; } -.ti-grill:before { - content: "\efa9"; +.ti-logic-and:before { + content: "\f240"; } -.ti-grill-fork:before { - content: "\f35b"; +.ti-logic-buffer:before { + content: "\f241"; } -.ti-grill-off:before { - content: "\f3f5"; +.ti-logic-nand:before { + content: "\f242"; } -.ti-grill-spatula:before { - content: "\f35c"; +.ti-logic-nor:before { + content: "\f243"; } -.ti-grip-horizontal:before { - content: "\ec00"; +.ti-logic-not:before { + content: "\f244"; } -.ti-grip-vertical:before { - content: "\ec01"; +.ti-logic-or:before { + content: "\f245"; } -.ti-growth:before { - content: "\ee93"; +.ti-logic-xnor:before { + content: "\f246"; } -.ti-guitar-pick:before { - content: "\f4c6"; +.ti-logic-xor:before { + content: "\f247"; } -.ti-h-1:before { - content: "\ec94"; +.ti-login:before { + content: "\eba7"; } -.ti-h-2:before { - content: "\ec95"; +.ti-login-2:before { + content: "\fc76"; } -.ti-h-3:before { - content: "\ec96"; +.ti-logout:before { + content: "\eba8"; } -.ti-h-4:before { - content: "\ec97"; +.ti-logout-2:before { + content: "\fa7e"; } -.ti-h-5:before { - content: "\ec98"; +.ti-logs:before { + content: "\fea7"; } -.ti-h-6:before { - content: "\ec99"; +.ti-lollipop:before { + content: "\efcc"; } -.ti-hammer:before { - content: "\ef91"; +.ti-lollipop-off:before { + content: "\f157"; } -.ti-hammer-off:before { - content: "\f13c"; +.ti-luggage:before { + content: "\efad"; } -.ti-hand-click:before { - content: "\ef4f"; +.ti-luggage-off:before { + content: "\f158"; } -.ti-hand-finger:before { - content: "\ee94"; +.ti-lungs:before { + content: "\ef62"; } -.ti-hand-finger-off:before { - content: "\f13d"; +.ti-lungs-off:before { + content: "\f405"; } -.ti-hand-grab:before { - content: "\f091"; +.ti-macro:before { + content: "\eeab"; } -.ti-hand-little-finger:before { - content: "\ee95"; +.ti-macro-off:before { + content: "\f406"; } -.ti-hand-middle-finger:before { - content: "\ec2d"; +.ti-magnet:before { + content: "\eae3"; } -.ti-hand-move:before { - content: "\ef50"; +.ti-magnet-off:before { + content: "\f159"; } -.ti-hand-off:before { - content: "\ed15"; +.ti-magnetic:before { + content: "\fcb9"; } -.ti-hand-ring-finger:before { - content: "\ee96"; +.ti-mail:before { + content: "\eae5"; } -.ti-hand-rock:before { - content: "\ee97"; +.ti-mail-ai:before { + content: "\fa31"; } -.ti-hand-sanitizer:before { - content: "\f5f4"; +.ti-mail-bitcoin:before { + content: "\ff36"; } -.ti-hand-stop:before { - content: "\ec2e"; +.ti-mail-bolt:before { + content: "\f937"; } -.ti-hand-three-fingers:before { - content: "\ee98"; +.ti-mail-cancel:before { + content: "\f938"; } -.ti-hand-two-fingers:before { - content: "\ee99"; +.ti-mail-check:before { + content: "\f939"; } -.ti-hanger:before { - content: "\ee9a"; +.ti-mail-code:before { + content: "\f93a"; } -.ti-hanger-2:before { - content: "\f09c"; +.ti-mail-cog:before { + content: "\f93b"; } -.ti-hanger-off:before { - content: "\f13e"; +.ti-mail-dollar:before { + content: "\f93c"; } -.ti-hash:before { - content: "\eabc"; +.ti-mail-down:before { + content: "\f93d"; } -.ti-haze:before { - content: "\efaa"; +.ti-mail-exclamation:before { + content: "\f93e"; } -.ti-heading:before { - content: "\ee9b"; +.ti-mail-fast:before { + content: "\f069"; } -.ti-heading-off:before { - content: "\f13f"; +.ti-mail-forward:before { + content: "\eeac"; } -.ti-headphones:before { - content: "\eabd"; +.ti-mail-heart:before { + content: "\f93f"; } -.ti-headphones-off:before { - content: "\ed1d"; +.ti-mail-minus:before { + content: "\f940"; } -.ti-headset:before { - content: "\eb90"; +.ti-mail-off:before { + content: "\f15a"; } -.ti-headset-off:before { - content: "\f3f6"; +.ti-mail-opened:before { + content: "\eae4"; } -.ti-health-recognition:before { - content: "\f1fb"; +.ti-mail-pause:before { + content: "\f941"; } -.ti-heart:before { - content: "\eabe"; +.ti-mail-pin:before { + content: "\f942"; } -.ti-heart-broken:before { - content: "\ecba"; +.ti-mail-plus:before { + content: "\f943"; } -.ti-heart-handshake:before { - content: "\f0f3"; +.ti-mail-question:before { + content: "\f944"; } -.ti-heart-minus:before { - content: "\f140"; +.ti-mail-search:before { + content: "\f945"; } -.ti-heart-off:before { - content: "\f141"; +.ti-mail-share:before { + content: "\f946"; } -.ti-heart-plus:before { - content: "\f142"; +.ti-mail-spark:before { + content: "\ffb3"; } -.ti-heart-rate-monitor:before { - content: "\ef61"; +.ti-mail-star:before { + content: "\f947"; } -.ti-heartbeat:before { - content: "\ef92"; +.ti-mail-up:before { + content: "\f948"; } -.ti-hearts:before { - content: "\f387"; +.ti-mail-x:before { + content: "\f949"; } -.ti-hearts-off:before { - content: "\f3f7"; +.ti-mailbox:before { + content: "\eead"; } -.ti-helicopter:before { - content: "\ed8e"; +.ti-mailbox-off:before { + content: "\f15b"; } -.ti-helicopter-landing:before { - content: "\ed8d"; +.ti-man:before { + content: "\eae6"; } -.ti-helmet:before { - content: "\efca"; +.ti-manual-gearbox:before { + content: "\ed7b"; } -.ti-helmet-off:before { - content: "\f143"; +.ti-map:before { + content: "\eae9"; } -.ti-help:before { - content: "\eabf"; +.ti-map-2:before { + content: "\eae7"; } -.ti-help-off:before { - content: "\f3f8"; +.ti-map-bolt:before { + content: "\fbc3"; } -.ti-hexagon:before { - content: "\ec02"; +.ti-map-cancel:before { + content: "\fbc4"; } -.ti-hexagon-3d:before { - content: "\f4c7"; +.ti-map-check:before { + content: "\fbc5"; } -.ti-hexagon-letter-a:before { - content: "\f463"; +.ti-map-code:before { + content: "\fbc6"; } -.ti-hexagon-letter-b:before { - content: "\f464"; +.ti-map-cog:before { + content: "\fbc7"; } -.ti-hexagon-letter-c:before { - content: "\f465"; +.ti-map-discount:before { + content: "\fbc8"; } -.ti-hexagon-letter-d:before { - content: "\f466"; +.ti-map-dollar:before { + content: "\fbc9"; } -.ti-hexagon-letter-e:before { - content: "\f467"; +.ti-map-down:before { + content: "\fbca"; } -.ti-hexagon-letter-f:before { - content: "\f468"; +.ti-map-east:before { + content: "\fc5d"; } -.ti-hexagon-letter-g:before { - content: "\f469"; +.ti-map-exclamation:before { + content: "\fbcb"; } -.ti-hexagon-letter-h:before { - content: "\f46a"; +.ti-map-heart:before { + content: "\fbcc"; } -.ti-hexagon-letter-i:before { - content: "\f46b"; +.ti-map-lock:before { + content: "\10203"; } -.ti-hexagon-letter-j:before { - content: "\f46c"; +.ti-map-minus:before { + content: "\fbcd"; } -.ti-hexagon-letter-k:before { - content: "\f46d"; +.ti-map-north:before { + content: "\fc5e"; } -.ti-hexagon-letter-l:before { - content: "\f46e"; +.ti-map-off:before { + content: "\f15c"; } -.ti-hexagon-letter-m:before { - content: "\f46f"; +.ti-map-pause:before { + content: "\fbce"; } -.ti-hexagon-letter-n:before { - content: "\f470"; +.ti-map-pin:before { + content: "\eae8"; } -.ti-hexagon-letter-o:before { - content: "\f471"; +.ti-map-pin-2:before { + content: "\fc48"; } -.ti-hexagon-letter-p:before { - content: "\f472"; +.ti-map-pin-bolt:before { + content: "\f94a"; } -.ti-hexagon-letter-q:before { - content: "\f473"; +.ti-map-pin-cancel:before { + content: "\f94b"; } -.ti-hexagon-letter-r:before { - content: "\f474"; +.ti-map-pin-check:before { + content: "\f94c"; } -.ti-hexagon-letter-s:before { - content: "\f475"; +.ti-map-pin-code:before { + content: "\f94d"; } -.ti-hexagon-letter-t:before { - content: "\f476"; +.ti-map-pin-cog:before { + content: "\f94e"; } -.ti-hexagon-letter-u:before { - content: "\f477"; +.ti-map-pin-dollar:before { + content: "\f94f"; } -.ti-hexagon-letter-v:before { - content: "\f4b3"; +.ti-map-pin-down:before { + content: "\f950"; } -.ti-hexagon-letter-w:before { - content: "\f478"; +.ti-map-pin-exclamation:before { + content: "\f951"; } -.ti-hexagon-letter-x:before { - content: "\f479"; +.ti-map-pin-heart:before { + content: "\f952"; } -.ti-hexagon-letter-y:before { - content: "\f47a"; +.ti-map-pin-minus:before { + content: "\f953"; } -.ti-hexagon-letter-z:before { - content: "\f47b"; +.ti-map-pin-off:before { + content: "\ecf3"; } -.ti-hexagon-number-0:before { - content: "\f459"; +.ti-map-pin-pause:before { + content: "\f954"; } -.ti-hexagon-number-1:before { - content: "\f45a"; +.ti-map-pin-pin:before { + content: "\f955"; } -.ti-hexagon-number-2:before { - content: "\f45b"; +.ti-map-pin-plus:before { + content: "\f956"; } -.ti-hexagon-number-3:before { - content: "\f45c"; +.ti-map-pin-question:before { + content: "\f957"; } -.ti-hexagon-number-4:before { - content: "\f45d"; +.ti-map-pin-search:before { + content: "\f958"; } -.ti-hexagon-number-5:before { - content: "\f45e"; +.ti-map-pin-share:before { + content: "\f795"; } -.ti-hexagon-number-6:before { - content: "\f45f"; +.ti-map-pin-star:before { + content: "\f959"; } -.ti-hexagon-number-7:before { - content: "\f460"; +.ti-map-pin-up:before { + content: "\f95a"; } -.ti-hexagon-number-8:before { - content: "\f461"; +.ti-map-pin-x:before { + content: "\f95b"; } -.ti-hexagon-number-9:before { - content: "\f462"; +.ti-map-pins:before { + content: "\ed5e"; } -.ti-hexagon-off:before { - content: "\ee9c"; +.ti-map-plus:before { + content: "\fbcf"; } -.ti-hexagons:before { - content: "\f09d"; +.ti-map-question:before { + content: "\fbd0"; } -.ti-hexagons-off:before { - content: "\f3f9"; +.ti-map-route:before { + content: "\fc79"; } -.ti-hierarchy:before { - content: "\ee9e"; +.ti-map-search:before { + content: "\ef82"; } -.ti-hierarchy-2:before { - content: "\ee9d"; +.ti-map-share:before { + content: "\fbd1"; } -.ti-hierarchy-3:before { - content: "\f289"; +.ti-map-shield:before { + content: "\10202"; } -.ti-hierarchy-off:before { - content: "\f3fa"; +.ti-map-south:before { + content: "\fc5f"; } -.ti-highlight:before { - content: "\ef3f"; +.ti-map-star:before { + content: "\fbd2"; } -.ti-highlight-off:before { - content: "\f144"; +.ti-map-up:before { + content: "\fbd3"; } -.ti-history:before { - content: "\ebea"; +.ti-map-west:before { + content: "\fc60"; } -.ti-history-off:before { - content: "\f3fb"; +.ti-map-x:before { + content: "\fbd4"; } -.ti-history-toggle:before { - content: "\f1fc"; +.ti-markdown:before { + content: "\ec41"; } -.ti-home:before { - content: "\eac1"; +.ti-markdown-off:before { + content: "\f407"; } -.ti-home-2:before { - content: "\eac0"; +.ti-marquee:before { + content: "\ec77"; } -.ti-home-bolt:before { - content: "\f336"; +.ti-marquee-2:before { + content: "\eeae"; } -.ti-home-cancel:before { - content: "\f350"; +.ti-marquee-off:before { + content: "\f15d"; } -.ti-home-check:before { - content: "\f337"; +.ti-mars:before { + content: "\ec80"; } -.ti-home-cog:before { - content: "\f338"; +.ti-mask:before { + content: "\eeb0"; } -.ti-home-dollar:before { - content: "\f339"; +.ti-mask-off:before { + content: "\eeaf"; } -.ti-home-dot:before { - content: "\f33a"; +.ti-masks-theater:before { + content: "\f263"; } -.ti-home-down:before { - content: "\f33b"; +.ti-masks-theater-off:before { + content: "\f408"; } -.ti-home-eco:before { - content: "\f351"; +.ti-massage:before { + content: "\eeb1"; } -.ti-home-edit:before { - content: "\f352"; +.ti-matchstick:before { + content: "\f577"; } -.ti-home-exclamation:before { - content: "\f33c"; +.ti-math:before { + content: "\ebeb"; } -.ti-home-hand:before { - content: "\f504"; +.ti-math-1-divide-2:before { + content: "\f4e2"; } -.ti-home-heart:before { - content: "\f353"; +.ti-math-1-divide-3:before { + content: "\f4e3"; } -.ti-home-infinity:before { - content: "\f505"; +.ti-math-avg:before { + content: "\f0f4"; } -.ti-home-link:before { - content: "\f354"; +.ti-math-cos:before { + content: "\ff1f"; } -.ti-home-minus:before { - content: "\f33d"; +.ti-math-ctg:before { + content: "\ff35"; } -.ti-home-move:before { - content: "\f33e"; +.ti-math-equal-greater:before { + content: "\f4e4"; } -.ti-home-off:before { - content: "\f145"; +.ti-math-equal-lower:before { + content: "\f4e5"; } -.ti-home-plus:before { - content: "\f33f"; +.ti-math-function:before { + content: "\eeb2"; } -.ti-home-question:before { - content: "\f340"; +.ti-math-function-off:before { + content: "\f15e"; } -.ti-home-ribbon:before { - content: "\f355"; +.ti-math-function-y:before { + content: "\f4e6"; } -.ti-home-search:before { - content: "\f341"; +.ti-math-greater:before { + content: "\f4e7"; } -.ti-home-share:before { - content: "\f342"; +.ti-math-integral:before { + content: "\f4e9"; } -.ti-home-shield:before { - content: "\f343"; +.ti-math-integral-x:before { + content: "\f4e8"; } -.ti-home-signal:before { - content: "\f356"; +.ti-math-integrals:before { + content: "\f4ea"; } -.ti-home-star:before { - content: "\f344"; +.ti-math-lower:before { + content: "\f4eb"; } -.ti-home-stats:before { - content: "\f345"; +.ti-math-max:before { + content: "\f0f5"; } -.ti-home-up:before { - content: "\f346"; +.ti-math-max-min:before { + content: "\fda0"; } -.ti-home-x:before { - content: "\f347"; +.ti-math-min:before { + content: "\f0f6"; } -.ti-horse-toy:before { - content: "\f28a"; +.ti-math-not:before { + content: "\f4ec"; } -.ti-hotel-service:before { - content: "\ef80"; +.ti-math-off:before { + content: "\f409"; } -.ti-hourglass:before { - content: "\ef93"; +.ti-math-pi:before { + content: "\f4ee"; } -.ti-hourglass-empty:before { - content: "\f146"; +.ti-math-pi-divide-2:before { + content: "\f4ed"; } -.ti-hourglass-high:before { - content: "\f092"; +.ti-math-sec:before { + content: "\ff34"; } -.ti-hourglass-low:before { - content: "\f093"; +.ti-math-sin:before { + content: "\ff1e"; } -.ti-hourglass-off:before { - content: "\f147"; +.ti-math-symbols:before { + content: "\eeb3"; } -.ti-ice-cream:before { - content: "\eac2"; +.ti-math-tg:before { + content: "\ff33"; } -.ti-ice-cream-2:before { - content: "\ee9f"; +.ti-math-x-divide-2:before { + content: "\f4ef"; } -.ti-ice-cream-off:before { - content: "\f148"; +.ti-math-x-divide-y:before { + content: "\f4f1"; } -.ti-ice-skating:before { - content: "\efcb"; +.ti-math-x-divide-y-2:before { + content: "\f4f0"; } -.ti-icons:before { - content: "\f1d4"; +.ti-math-x-floor-divide-y:before { + content: "\10073"; } -.ti-icons-off:before { - content: "\f3fc"; +.ti-math-x-minus-x:before { + content: "\f4f2"; } -.ti-id:before { - content: "\eac3"; +.ti-math-x-minus-y:before { + content: "\f4f3"; } -.ti-id-badge:before { - content: "\eff7"; +.ti-math-x-plus-x:before { + content: "\f4f4"; } -.ti-id-badge-2:before { - content: "\f076"; +.ti-math-x-plus-y:before { + content: "\f4f5"; } -.ti-id-badge-off:before { - content: "\f3fd"; +.ti-math-xy:before { + content: "\f4f6"; } -.ti-id-off:before { - content: "\f149"; +.ti-math-y-minus-y:before { + content: "\f4f7"; } -.ti-inbox:before { - content: "\eac4"; +.ti-math-y-plus-y:before { + content: "\f4f8"; } -.ti-inbox-off:before { - content: "\f14a"; +.ti-matrix:before { + content: "\100bc"; } -.ti-indent-decrease:before { - content: "\eb91"; +.ti-maximize:before { + content: "\eaea"; } -.ti-indent-increase:before { - content: "\eb92"; +.ti-maximize-off:before { + content: "\f15f"; } -.ti-infinity:before { - content: "\eb69"; +.ti-meat:before { + content: "\ef12"; } -.ti-infinity-off:before { - content: "\f3fe"; +.ti-meat-off:before { + content: "\f40a"; } -.ti-info-circle:before { - content: "\eac5"; +.ti-medal:before { + content: "\ec78"; } -.ti-info-square:before { - content: "\eac6"; +.ti-medal-2:before { + content: "\efcd"; } -.ti-info-square-rounded:before { - content: "\f635"; +.ti-medical-cross:before { + content: "\ec2f"; } -.ti-inner-shadow-bottom:before { - content: "\f520"; +.ti-medical-cross-circle:before { + content: "\fae8"; } -.ti-inner-shadow-bottom-left:before { - content: "\f51e"; +.ti-medical-cross-off:before { + content: "\f160"; } -.ti-inner-shadow-bottom-right:before { - content: "\f51f"; +.ti-medicine-syrup:before { + content: "\ef63"; } -.ti-inner-shadow-left:before { - content: "\f521"; +.ti-meeple:before { + content: "\f514"; } -.ti-inner-shadow-right:before { - content: "\f522"; +.ti-melon:before { + content: "\fc7a"; } -.ti-inner-shadow-top:before { - content: "\f525"; +.ti-menorah:before { + content: "\f58c"; } -.ti-inner-shadow-top-left:before { - content: "\f523"; +.ti-menu:before { + content: "\eaeb"; } -.ti-inner-shadow-top-right:before { - content: "\f524"; +.ti-menu-2:before { + content: "\ec42"; } -.ti-input-search:before { - content: "\f2a2"; +.ti-menu-3:before { + content: "\ff43"; } -.ti-ironing-1:before { - content: "\f2f4"; +.ti-menu-4:before { + content: "\ff42"; } -.ti-ironing-2:before { - content: "\f2f5"; +.ti-menu-deep:before { + content: "\fafb"; } -.ti-ironing-3:before { - content: "\f2f6"; +.ti-menu-order:before { + content: "\f5f5"; } -.ti-ironing-off:before { - content: "\f2f7"; +.ti-mesh:before { + content: "\10201"; } -.ti-ironing-steam:before { - content: "\f2f9"; +.ti-message:before { + content: "\eaef"; } -.ti-ironing-steam-off:before { - content: "\f2f8"; +.ti-message-2:before { + content: "\eaec"; } -.ti-italic:before { - content: "\eb93"; +.ti-message-2-bolt:before { + content: "\f95c"; } -.ti-jacket:before { - content: "\f661"; +.ti-message-2-cancel:before { + content: "\f95d"; } -.ti-jetpack:before { - content: "\f581"; +.ti-message-2-check:before { + content: "\f95e"; } -.ti-jewish-star:before { - content: "\f3ff"; +.ti-message-2-code:before { + content: "\f012"; } -.ti-jpg:before { - content: "\f3ac"; +.ti-message-2-cog:before { + content: "\f95f"; } -.ti-jump-rope:before { - content: "\ed8f"; +.ti-message-2-dollar:before { + content: "\f960"; } -.ti-karate:before { - content: "\ed32"; +.ti-message-2-down:before { + content: "\f961"; } -.ti-kayak:before { - content: "\f1d6"; +.ti-message-2-exclamation:before { + content: "\f962"; } -.ti-kering:before { - content: "\efb8"; +.ti-message-2-heart:before { + content: "\f963"; } -.ti-key:before { - content: "\eac7"; +.ti-message-2-minus:before { + content: "\f964"; } -.ti-key-off:before { - content: "\f14b"; +.ti-message-2-off:before { + content: "\f40b"; } -.ti-keyboard:before { - content: "\ebd6"; +.ti-message-2-pause:before { + content: "\f965"; } -.ti-keyboard-hide:before { - content: "\ec7e"; +.ti-message-2-pin:before { + content: "\f966"; } -.ti-keyboard-off:before { - content: "\eea0"; +.ti-message-2-plus:before { + content: "\f967"; } -.ti-keyboard-show:before { - content: "\ec7f"; +.ti-message-2-question:before { + content: "\f968"; } -.ti-keyframe:before { - content: "\f576"; +.ti-message-2-search:before { + content: "\f969"; } -.ti-keyframe-align-center:before { - content: "\f582"; +.ti-message-2-share:before { + content: "\f077"; } -.ti-keyframe-align-horizontal:before { - content: "\f583"; +.ti-message-2-star:before { + content: "\f96a"; } -.ti-keyframe-align-vertical:before { - content: "\f584"; +.ti-message-2-up:before { + content: "\f96b"; } -.ti-keyframes:before { - content: "\f585"; +.ti-message-2-x:before { + content: "\f96c"; } -.ti-ladder:before { - content: "\efe2"; +.ti-message-bolt:before { + content: "\f96d"; } -.ti-ladder-off:before { - content: "\f14c"; +.ti-message-cancel:before { + content: "\f96e"; } -.ti-lambda:before { - content: "\f541"; +.ti-message-chatbot:before { + content: "\f38a"; } -.ti-lamp:before { - content: "\efab"; +.ti-message-check:before { + content: "\f96f"; } -.ti-lamp-2:before { - content: "\f09e"; +.ti-message-circle:before { + content: "\eaed"; } -.ti-lamp-off:before { - content: "\f14d"; +.ti-message-circle-bolt:before { + content: "\f970"; } -.ti-language:before { - content: "\ebbe"; +.ti-message-circle-cancel:before { + content: "\f971"; } -.ti-language-hiragana:before { - content: "\ef77"; +.ti-message-circle-check:before { + content: "\f972"; } -.ti-language-katakana:before { - content: "\ef78"; +.ti-message-circle-code:before { + content: "\f973"; } -.ti-language-off:before { - content: "\f14e"; +.ti-message-circle-cog:before { + content: "\f974"; } -.ti-lasso:before { - content: "\efac"; +.ti-message-circle-dollar:before { + content: "\f975"; } -.ti-lasso-off:before { - content: "\f14f"; +.ti-message-circle-down:before { + content: "\f976"; } -.ti-lasso-polygon:before { - content: "\f388"; +.ti-message-circle-exclamation:before { + content: "\f977"; } -.ti-layers-difference:before { - content: "\eac8"; +.ti-message-circle-heart:before { + content: "\f978"; } -.ti-layers-intersect:before { - content: "\eac9"; +.ti-message-circle-minus:before { + content: "\f979"; } -.ti-layers-intersect-2:before { - content: "\eff8"; +.ti-message-circle-off:before { + content: "\ed40"; } -.ti-layers-linked:before { - content: "\eea1"; +.ti-message-circle-pause:before { + content: "\f97a"; } -.ti-layers-off:before { - content: "\f150"; +.ti-message-circle-pin:before { + content: "\f97b"; } -.ti-layers-subtract:before { - content: "\eaca"; +.ti-message-circle-plus:before { + content: "\f97c"; } -.ti-layers-union:before { - content: "\eacb"; +.ti-message-circle-question:before { + content: "\f97d"; } -.ti-layout:before { - content: "\eadb"; +.ti-message-circle-search:before { + content: "\f97e"; } -.ti-layout-2:before { - content: "\eacc"; +.ti-message-circle-share:before { + content: "\f97f"; } -.ti-layout-align-bottom:before { - content: "\eacd"; +.ti-message-circle-star:before { + content: "\f980"; } -.ti-layout-align-center:before { - content: "\eace"; +.ti-message-circle-up:before { + content: "\f981"; } -.ti-layout-align-left:before { - content: "\eacf"; +.ti-message-circle-user:before { + content: "\fec5"; } -.ti-layout-align-middle:before { - content: "\ead0"; +.ti-message-circle-x:before { + content: "\f982"; } -.ti-layout-align-right:before { - content: "\ead1"; +.ti-message-code:before { + content: "\f013"; } -.ti-layout-align-top:before { - content: "\ead2"; +.ti-message-cog:before { + content: "\f983"; } -.ti-layout-board:before { - content: "\ef95"; +.ti-message-dollar:before { + content: "\f984"; } -.ti-layout-board-split:before { - content: "\ef94"; +.ti-message-dots:before { + content: "\eaee"; } -.ti-layout-bottombar:before { - content: "\ead3"; +.ti-message-down:before { + content: "\f985"; } -.ti-layout-bottombar-collapse:before { - content: "\f28b"; +.ti-message-exclamation:before { + content: "\f986"; } -.ti-layout-bottombar-expand:before { - content: "\f28c"; +.ti-message-forward:before { + content: "\f28f"; } -.ti-layout-cards:before { - content: "\ec13"; +.ti-message-heart:before { + content: "\f987"; } -.ti-layout-collage:before { - content: "\f389"; +.ti-message-language:before { + content: "\efae"; } -.ti-layout-columns:before { - content: "\ead4"; +.ti-message-minus:before { + content: "\f988"; } -.ti-layout-dashboard:before { - content: "\f02c"; +.ti-message-off:before { + content: "\ed41"; } -.ti-layout-distribute-horizontal:before { - content: "\ead5"; +.ti-message-pause:before { + content: "\f989"; } -.ti-layout-distribute-vertical:before { - content: "\ead6"; +.ti-message-pin:before { + content: "\f98a"; } -.ti-layout-grid:before { - content: "\edba"; +.ti-message-plus:before { + content: "\ec9a"; } -.ti-layout-grid-add:before { - content: "\edb9"; +.ti-message-question:before { + content: "\f98b"; } -.ti-layout-kanban:before { - content: "\ec3f"; +.ti-message-reply:before { + content: "\fd4d"; } -.ti-layout-list:before { - content: "\ec14"; +.ti-message-report:before { + content: "\ec9b"; } -.ti-layout-navbar:before { - content: "\ead7"; +.ti-message-search:before { + content: "\f98c"; } -.ti-layout-navbar-collapse:before { - content: "\f28d"; +.ti-message-share:before { + content: "\f078"; } -.ti-layout-navbar-expand:before { - content: "\f28e"; +.ti-message-star:before { + content: "\f98d"; } -.ti-layout-off:before { - content: "\f151"; +.ti-message-up:before { + content: "\f98e"; } -.ti-layout-rows:before { - content: "\ead8"; +.ti-message-user:before { + content: "\fec4"; } -.ti-layout-sidebar:before { - content: "\eada"; +.ti-message-x:before { + content: "\f98f"; } -.ti-layout-sidebar-left-collapse:before { - content: "\f004"; +.ti-messages:before { + content: "\eb6c"; } -.ti-layout-sidebar-left-expand:before { - content: "\f005"; +.ti-messages-off:before { + content: "\ed42"; } -.ti-layout-sidebar-right:before { - content: "\ead9"; +.ti-meteor:before { + content: "\f1fd"; } -.ti-layout-sidebar-right-collapse:before { - content: "\f006"; +.ti-meteor-off:before { + content: "\f40c"; } -.ti-layout-sidebar-right-expand:before { - content: "\f007"; +.ti-meter-cube:before { + content: "\fd7c"; } -.ti-leaf:before { - content: "\ed4f"; +.ti-meter-square:before { + content: "\fd7d"; } -.ti-leaf-off:before { - content: "\f400"; +.ti-metronome:before { + content: "\fd25"; } -.ti-lego:before { - content: "\eadc"; +.ti-michelin-bib-gourmand:before { + content: "\fae9"; } -.ti-lego-off:before { - content: "\f401"; +.ti-michelin-star:before { + content: "\faeb"; } -.ti-lemon:before { - content: "\ef10"; +.ti-michelin-star-green:before { + content: "\faea"; } -.ti-lemon-2:before { - content: "\ef81"; +.ti-mickey:before { + content: "\f2a3"; } -.ti-letter-a:before { - content: "\ec50"; +.ti-microfrontends:before { + content: "\101ee"; } -.ti-letter-b:before { - content: "\ec51"; +.ti-microphone:before { + content: "\eaf0"; } -.ti-letter-c:before { - content: "\ec52"; +.ti-microphone-2:before { + content: "\ef2c"; } -.ti-letter-case:before { - content: "\eea5"; +.ti-microphone-2-off:before { + content: "\f40d"; } -.ti-letter-case-lower:before { - content: "\eea2"; +.ti-microphone-off:before { + content: "\ed16"; } -.ti-letter-case-toggle:before { - content: "\eea3"; +.ti-microscope:before { + content: "\ef64"; } -.ti-letter-case-upper:before { - content: "\eea4"; +.ti-microscope-off:before { + content: "\f40e"; } -.ti-letter-d:before { - content: "\ec53"; +.ti-microwave:before { + content: "\f248"; } -.ti-letter-e:before { - content: "\ec54"; +.ti-microwave-off:before { + content: "\f264"; } -.ti-letter-f:before { - content: "\ec55"; +.ti-middleware:before { + content: "\101ed"; } -.ti-letter-g:before { - content: "\ec56"; +.ti-military-award:before { + content: "\f079"; } -.ti-letter-h:before { - content: "\ec57"; +.ti-military-rank:before { + content: "\efcf"; } -.ti-letter-i:before { - content: "\ec58"; +.ti-milk:before { + content: "\ef13"; } -.ti-letter-j:before { - content: "\ec59"; +.ti-milk-off:before { + content: "\f40f"; } -.ti-letter-k:before { - content: "\ec5a"; +.ti-milkshake:before { + content: "\f4c8"; } -.ti-letter-l:before { - content: "\ec5b"; +.ti-minimize:before { + content: "\eaf1"; } -.ti-letter-m:before { - content: "\ec5c"; +.ti-minus:before { + content: "\eaf2"; } -.ti-letter-n:before { - content: "\ec5d"; +.ti-minus-vertical:before { + content: "\eeb4"; } -.ti-letter-o:before { - content: "\ec5e"; +.ti-mist:before { + content: "\ec30"; } -.ti-letter-p:before { - content: "\ec5f"; +.ti-mist-off:before { + content: "\f410"; } -.ti-letter-q:before { - content: "\ec60"; +.ti-mobiledata:before { + content: "\f9f5"; } -.ti-letter-r:before { - content: "\ec61"; +.ti-mobiledata-off:before { + content: "\f9f4"; } -.ti-letter-s:before { - content: "\ec62"; +.ti-moneybag:before { + content: "\f506"; } -.ti-letter-spacing:before { - content: "\eea6"; +.ti-moneybag-edit:before { + content: "\1013d"; } -.ti-letter-t:before { - content: "\ec63"; +.ti-moneybag-heart:before { + content: "\1013c"; } -.ti-letter-u:before { - content: "\ec64"; +.ti-moneybag-minus:before { + content: "\1013b"; } -.ti-letter-v:before { - content: "\ec65"; +.ti-moneybag-move:before { + content: "\10139"; } -.ti-letter-w:before { - content: "\ec66"; +.ti-moneybag-move-back:before { + content: "\1013a"; } -.ti-letter-x:before { - content: "\ec67"; +.ti-moneybag-plus:before { + content: "\10138"; } -.ti-letter-y:before { - content: "\ec68"; +.ti-monkeybar:before { + content: "\feb4"; } -.ti-letter-z:before { - content: "\ec69"; +.ti-mood-angry:before { + content: "\f2de"; } -.ti-license:before { - content: "\ebc0"; +.ti-mood-annoyed:before { + content: "\f2e0"; } -.ti-license-off:before { - content: "\f153"; +.ti-mood-annoyed-2:before { + content: "\f2df"; } -.ti-lifebuoy:before { - content: "\eadd"; +.ti-mood-bitcoin:before { + content: "\ff32"; } -.ti-lifebuoy-off:before { - content: "\f154"; +.ti-mood-boy:before { + content: "\ed2d"; } -.ti-line:before { - content: "\ec40"; +.ti-mood-check:before { + content: "\f7b3"; } -.ti-line-dashed:before { - content: "\eea7"; +.ti-mood-cog:before { + content: "\f7b4"; } -.ti-line-dotted:before { - content: "\eea8"; +.ti-mood-confuzed:before { + content: "\eaf3"; } -.ti-line-height:before { - content: "\eb94"; +.ti-mood-crazy-happy:before { + content: "\ed90"; } -.ti-link:before { - content: "\eade"; +.ti-mood-cry:before { + content: "\ecbb"; } -.ti-link-off:before { - content: "\f402"; +.ti-mood-dollar:before { + content: "\f7b5"; } -.ti-list:before { - content: "\eb6b"; +.ti-mood-edit:before { + content: "\fa05"; } -.ti-list-check:before { - content: "\eb6a"; +.ti-mood-empty:before { + content: "\eeb5"; } -.ti-list-details:before { - content: "\ef40"; +.ti-mood-happy:before { + content: "\eaf4"; } -.ti-list-numbers:before { - content: "\ef11"; +.ti-mood-heart:before { + content: "\f7b6"; } -.ti-list-search:before { - content: "\eea9"; +.ti-mood-kid:before { + content: "\ec03"; } -.ti-live-photo:before { - content: "\eadf"; +.ti-mood-look-down:before { + content: "\fd37"; } -.ti-live-photo-off:before { - content: "\f403"; +.ti-mood-look-left:before { + content: "\f2c5"; } -.ti-live-view:before { - content: "\ec6b"; +.ti-mood-look-right:before { + content: "\f2c6"; } -.ti-loader:before { - content: "\eca3"; +.ti-mood-look-up:before { + content: "\fd38"; } -.ti-loader-2:before { - content: "\f226"; +.ti-mood-minus:before { + content: "\f7b7"; } -.ti-loader-3:before { - content: "\f513"; +.ti-mood-nerd:before { + content: "\f2e1"; } -.ti-loader-quarter:before { - content: "\eca2"; +.ti-mood-nervous:before { + content: "\ef96"; } -.ti-location:before { - content: "\eae0"; +.ti-mood-neutral:before { + content: "\eaf5"; } -.ti-location-broken:before { - content: "\f2c4"; +.ti-mood-off:before { + content: "\f161"; } -.ti-location-off:before { - content: "\f155"; +.ti-mood-pin:before { + content: "\f7b8"; } -.ti-lock:before { - content: "\eae2"; +.ti-mood-plus:before { + content: "\f7b9"; } -.ti-lock-access:before { - content: "\eeaa"; +.ti-mood-puzzled:before { + content: "\fd39"; } -.ti-lock-access-off:before { - content: "\f404"; +.ti-mood-sad:before { + content: "\eaf6"; } -.ti-lock-off:before { - content: "\ed1e"; +.ti-mood-sad-2:before { + content: "\f2e2"; } -.ti-lock-open:before { - content: "\eae1"; +.ti-mood-sad-dizzy:before { + content: "\f2e3"; } -.ti-lock-open-off:before { - content: "\f156"; +.ti-mood-sad-squint:before { + content: "\f2e4"; } -.ti-lock-square:before { - content: "\ef51"; +.ti-mood-search:before { + content: "\f7ba"; } -.ti-lock-square-rounded:before { - content: "\f636"; +.ti-mood-share:before { + content: "\fa06"; } -.ti-logic-and:before { - content: "\f240"; +.ti-mood-sick:before { + content: "\f2e5"; } -.ti-logic-buffer:before { - content: "\f241"; +.ti-mood-silence:before { + content: "\f2e6"; } -.ti-logic-nand:before { - content: "\f242"; +.ti-mood-sing:before { + content: "\f2c7"; } -.ti-logic-nor:before { - content: "\f243"; +.ti-mood-smile:before { + content: "\eaf7"; } -.ti-logic-not:before { - content: "\f244"; +.ti-mood-smile-beam:before { + content: "\f2e7"; } -.ti-logic-or:before { - content: "\f245"; +.ti-mood-smile-dizzy:before { + content: "\f2e8"; } -.ti-logic-xnor:before { - content: "\f246"; +.ti-mood-spark:before { + content: "\ffb2"; } -.ti-logic-xor:before { - content: "\f247"; +.ti-mood-surprised:before { + content: "\ec04"; } -.ti-login:before { - content: "\eba7"; +.ti-mood-tongue:before { + content: "\eb95"; } -.ti-logout:before { - content: "\eba8"; +.ti-mood-tongue-wink:before { + content: "\f2ea"; } -.ti-lollipop:before { - content: "\efcc"; +.ti-mood-tongue-wink-2:before { + content: "\f2e9"; } -.ti-lollipop-off:before { - content: "\f157"; +.ti-mood-unamused:before { + content: "\f2eb"; } -.ti-luggage:before { - content: "\efad"; +.ti-mood-up:before { + content: "\f7bb"; } -.ti-luggage-off:before { - content: "\f158"; +.ti-mood-wink:before { + content: "\f2ed"; } -.ti-lungs:before { - content: "\ef62"; +.ti-mood-wink-2:before { + content: "\f2ec"; } -.ti-lungs-off:before { - content: "\f405"; +.ti-mood-wrrr:before { + content: "\f2ee"; } -.ti-macro:before { - content: "\eeab"; +.ti-mood-x:before { + content: "\f7bc"; } -.ti-macro-off:before { - content: "\f406"; +.ti-mood-xd:before { + content: "\f2ef"; } -.ti-magnet:before { - content: "\eae3"; +.ti-moon:before { + content: "\eaf8"; } -.ti-magnet-off:before { - content: "\f159"; +.ti-moon-2:before { + content: "\ece6"; } -.ti-mail:before { - content: "\eae5"; +.ti-moon-off:before { + content: "\f162"; } -.ti-mail-fast:before { - content: "\f069"; +.ti-moon-stars:before { + content: "\ece7"; } -.ti-mail-forward:before { - content: "\eeac"; +.ti-moped:before { + content: "\ecbc"; } -.ti-mail-off:before { - content: "\f15a"; +.ti-mosque:before { + content: "\10234"; } -.ti-mail-opened:before { - content: "\eae4"; +.ti-motorbike:before { + content: "\eeb6"; } -.ti-mailbox:before { - content: "\eead"; +.ti-mountain:before { + content: "\ef97"; } -.ti-mailbox-off:before { - content: "\f15b"; +.ti-mountain-off:before { + content: "\f411"; } -.ti-man:before { - content: "\eae6"; +.ti-mouse:before { + content: "\eaf9"; } -.ti-manual-gearbox:before { - content: "\ed7b"; +.ti-mouse-2:before { + content: "\f1d7"; } -.ti-map:before { - content: "\eae9"; +.ti-mouse-off:before { + content: "\f163"; } -.ti-map-2:before { - content: "\eae7"; +.ti-moustache:before { + content: "\f4c9"; } -.ti-map-off:before { - content: "\f15c"; +.ti-movie:before { + content: "\eafa"; } -.ti-map-pin:before { - content: "\eae8"; +.ti-movie-off:before { + content: "\f164"; } -.ti-map-pin-off:before { - content: "\ecf3"; +.ti-mug:before { + content: "\eafb"; } -.ti-map-pins:before { - content: "\ed5e"; +.ti-mug-off:before { + content: "\f165"; } -.ti-map-search:before { - content: "\ef82"; +.ti-multiplier-0-5x:before { + content: "\ef41"; } -.ti-markdown:before { - content: "\ec41"; +.ti-multiplier-1-5x:before { + content: "\ef42"; } -.ti-markdown-off:before { - content: "\f407"; +.ti-multiplier-1x:before { + content: "\ef43"; } -.ti-marquee:before { - content: "\ec77"; +.ti-multiplier-2x:before { + content: "\ef44"; } -.ti-marquee-2:before { - content: "\eeae"; +.ti-mushroom:before { + content: "\ef14"; } -.ti-marquee-off:before { - content: "\f15d"; +.ti-mushroom-off:before { + content: "\f412"; } -.ti-mars:before { - content: "\ec80"; +.ti-music:before { + content: "\eafc"; } -.ti-mask:before { - content: "\eeb0"; +.ti-music-bolt:before { + content: "\fbd5"; } -.ti-mask-off:before { - content: "\eeaf"; +.ti-music-cancel:before { + content: "\fbd6"; } -.ti-masks-theater:before { - content: "\f263"; +.ti-music-check:before { + content: "\fbd7"; } -.ti-masks-theater-off:before { - content: "\f408"; +.ti-music-code:before { + content: "\fbd8"; } -.ti-massage:before { - content: "\eeb1"; +.ti-music-cog:before { + content: "\fbd9"; } -.ti-matchstick:before { - content: "\f577"; +.ti-music-discount:before { + content: "\fbda"; } -.ti-math:before { - content: "\ebeb"; +.ti-music-dollar:before { + content: "\fbdb"; } -.ti-math-1-divide-2:before { - content: "\f4e2"; +.ti-music-down:before { + content: "\fbdc"; } -.ti-math-1-divide-3:before { - content: "\f4e3"; +.ti-music-exclamation:before { + content: "\fbdd"; } -.ti-math-avg:before { - content: "\f0f4"; +.ti-music-heart:before { + content: "\fbde"; } -.ti-math-equal-greater:before { - content: "\f4e4"; +.ti-music-minus:before { + content: "\fbdf"; } -.ti-math-equal-lower:before { - content: "\f4e5"; +.ti-music-off:before { + content: "\f166"; } -.ti-math-function:before { - content: "\eeb2"; +.ti-music-pause:before { + content: "\fbe0"; } -.ti-math-function-off:before { - content: "\f15e"; +.ti-music-pin:before { + content: "\fbe1"; } -.ti-math-function-y:before { - content: "\f4e6"; +.ti-music-plus:before { + content: "\fbe2"; } -.ti-math-greater:before { - content: "\f4e7"; +.ti-music-question:before { + content: "\fbe3"; } -.ti-math-integral:before { - content: "\f4e9"; +.ti-music-search:before { + content: "\fbe4"; } -.ti-math-integral-x:before { - content: "\f4e8"; +.ti-music-share:before { + content: "\fbe5"; } -.ti-math-integrals:before { - content: "\f4ea"; +.ti-music-star:before { + content: "\fbe6"; } -.ti-math-lower:before { - content: "\f4eb"; +.ti-music-up:before { + content: "\fbe7"; } -.ti-math-max:before { - content: "\f0f5"; +.ti-music-x:before { + content: "\fbe8"; } -.ti-math-min:before { - content: "\f0f6"; +.ti-navigation:before { + content: "\f2c8"; } -.ti-math-not:before { - content: "\f4ec"; +.ti-navigation-bolt:before { + content: "\fbe9"; } -.ti-math-off:before { - content: "\f409"; +.ti-navigation-cancel:before { + content: "\fbea"; } -.ti-math-pi:before { - content: "\f4ee"; +.ti-navigation-check:before { + content: "\fbeb"; } -.ti-math-pi-divide-2:before { - content: "\f4ed"; +.ti-navigation-code:before { + content: "\fbec"; } -.ti-math-symbols:before { - content: "\eeb3"; +.ti-navigation-cog:before { + content: "\fbed"; } -.ti-math-x-divide-2:before { - content: "\f4ef"; +.ti-navigation-discount:before { + content: "\fbee"; } -.ti-math-x-divide-y:before { - content: "\f4f1"; +.ti-navigation-dollar:before { + content: "\fbef"; } -.ti-math-x-divide-y-2:before { - content: "\f4f0"; +.ti-navigation-down:before { + content: "\fbf0"; } -.ti-math-x-minus-x:before { - content: "\f4f2"; +.ti-navigation-east:before { + content: "\fcba"; } -.ti-math-x-minus-y:before { - content: "\f4f3"; +.ti-navigation-exclamation:before { + content: "\fbf1"; } -.ti-math-x-plus-x:before { - content: "\f4f4"; +.ti-navigation-heart:before { + content: "\fbf2"; } -.ti-math-x-plus-y:before { - content: "\f4f5"; +.ti-navigation-minus:before { + content: "\fbf3"; } -.ti-math-xy:before { - content: "\f4f6"; +.ti-navigation-north:before { + content: "\fcbb"; } -.ti-math-y-minus-y:before { - content: "\f4f7"; +.ti-navigation-off:before { + content: "\f413"; } -.ti-math-y-plus-y:before { - content: "\f4f8"; +.ti-navigation-pause:before { + content: "\fbf4"; } -.ti-maximize:before { - content: "\eaea"; +.ti-navigation-pin:before { + content: "\fbf5"; } -.ti-maximize-off:before { - content: "\f15f"; +.ti-navigation-plus:before { + content: "\fbf6"; } -.ti-meat:before { - content: "\ef12"; +.ti-navigation-question:before { + content: "\fbf7"; } -.ti-meat-off:before { - content: "\f40a"; +.ti-navigation-search:before { + content: "\fbf8"; } -.ti-medal:before { - content: "\ec78"; +.ti-navigation-share:before { + content: "\fbf9"; } -.ti-medal-2:before { - content: "\efcd"; +.ti-navigation-south:before { + content: "\fcbc"; } -.ti-medical-cross:before { - content: "\ec2f"; +.ti-navigation-star:before { + content: "\fbfa"; } -.ti-medical-cross-off:before { - content: "\f160"; +.ti-navigation-top:before { + content: "\faec"; } -.ti-medicine-syrup:before { - content: "\ef63"; +.ti-navigation-up:before { + content: "\fbfb"; } -.ti-meeple:before { - content: "\f514"; +.ti-navigation-west:before { + content: "\fcbd"; +} + +.ti-navigation-x:before { + content: "\fbfc"; } -.ti-menorah:before { - content: "\f58c"; +.ti-needle:before { + content: "\f508"; } -.ti-menu:before { - content: "\eaeb"; +.ti-needle-thread:before { + content: "\f507"; } -.ti-menu-2:before { - content: "\ec42"; +.ti-network:before { + content: "\f09f"; } -.ti-menu-order:before { - content: "\f5f5"; +.ti-network-off:before { + content: "\f414"; } -.ti-message:before { - content: "\eaef"; +.ti-new-section:before { + content: "\ebc1"; } -.ti-message-2:before { - content: "\eaec"; +.ti-news:before { + content: "\eafd"; } -.ti-message-2-code:before { - content: "\f012"; +.ti-news-off:before { + content: "\f167"; } -.ti-message-2-off:before { - content: "\f40b"; +.ti-nfc:before { + content: "\eeb7"; } -.ti-message-2-share:before { - content: "\f077"; +.ti-nfc-off:before { + content: "\f168"; } -.ti-message-chatbot:before { - content: "\f38a"; +.ti-no-copyright:before { + content: "\efb9"; } -.ti-message-circle:before { - content: "\eaed"; +.ti-no-creative-commons:before { + content: "\efba"; } -.ti-message-circle-2:before { - content: "\ed3f"; +.ti-no-derivatives:before { + content: "\efbb"; } -.ti-message-circle-off:before { - content: "\ed40"; +.ti-noise-reduction:before { + content: "\10263"; } -.ti-message-code:before { - content: "\f013"; +.ti-north-star:before { + content: "\f014"; } -.ti-message-dots:before { - content: "\eaee"; +.ti-notdef:before { + content: "\10248"; } -.ti-message-forward:before { - content: "\f28f"; +.ti-note:before { + content: "\eb6d"; } -.ti-message-language:before { - content: "\efae"; +.ti-note-off:before { + content: "\f169"; } -.ti-message-off:before { - content: "\ed41"; +.ti-notebook:before { + content: "\eb96"; } -.ti-message-plus:before { - content: "\ec9a"; +.ti-notebook-off:before { + content: "\f415"; } -.ti-message-report:before { - content: "\ec9b"; +.ti-notes:before { + content: "\eb6e"; } -.ti-message-share:before { - content: "\f078"; +.ti-notes-off:before { + content: "\f16a"; } -.ti-messages:before { - content: "\eb6c"; +.ti-notification:before { + content: "\eafe"; } -.ti-messages-off:before { - content: "\ed42"; +.ti-notification-off:before { + content: "\f16b"; } -.ti-meteor:before { - content: "\f1fd"; +.ti-number:before { + content: "\f1fe"; } -.ti-meteor-off:before { - content: "\f40c"; +.ti-number-0:before { + content: "\edf0"; } -.ti-mickey:before { - content: "\f2a3"; +.ti-number-0-small:before { + content: "\fce1"; } -.ti-microphone:before { - content: "\eaf0"; +.ti-number-1:before { + content: "\edf1"; } -.ti-microphone-2:before { - content: "\ef2c"; +.ti-number-1-small:before { + content: "\fce2"; } -.ti-microphone-2-off:before { - content: "\f40d"; +.ti-number-10:before { + content: "\1005e"; } -.ti-microphone-off:before { - content: "\ed16"; +.ti-number-10-small:before { + content: "\fce3"; } -.ti-microscope:before { - content: "\ef64"; +.ti-number-100-small:before { + content: "\10005"; } -.ti-microscope-off:before { - content: "\f40e"; +.ti-number-11:before { + content: "\1005d"; } -.ti-microwave:before { - content: "\f248"; +.ti-number-11-small:before { + content: "\fce4"; } -.ti-microwave-off:before { - content: "\f264"; +.ti-number-12-small:before { + content: "\fce5"; } -.ti-military-award:before { - content: "\f079"; +.ti-number-123:before { + content: "\f554"; } -.ti-military-rank:before { - content: "\efcf"; +.ti-number-13-small:before { + content: "\fce6"; } -.ti-milk:before { - content: "\ef13"; +.ti-number-14-small:before { + content: "\fce7"; } -.ti-milk-off:before { - content: "\f40f"; +.ti-number-15-small:before { + content: "\fce8"; } -.ti-milkshake:before { - content: "\f4c8"; +.ti-number-16-small:before { + content: "\fce9"; } -.ti-minimize:before { - content: "\eaf1"; +.ti-number-17-small:before { + content: "\fcea"; } -.ti-minus:before { - content: "\eaf2"; +.ti-number-18-small:before { + content: "\fceb"; } -.ti-minus-vertical:before { - content: "\eeb4"; +.ti-number-19-small:before { + content: "\fcec"; } -.ti-mist:before { - content: "\ec30"; +.ti-number-2:before { + content: "\edf2"; } -.ti-mist-off:before { - content: "\f410"; +.ti-number-2-small:before { + content: "\fced"; } -.ti-moneybag:before { - content: "\f506"; +.ti-number-20-small:before { + content: "\fcee"; } -.ti-mood-angry:before { - content: "\f2de"; +.ti-number-21-small:before { + content: "\fcef"; } -.ti-mood-annoyed:before { - content: "\f2e0"; +.ti-number-22-small:before { + content: "\fcf0"; } -.ti-mood-annoyed-2:before { - content: "\f2df"; +.ti-number-23-small:before { + content: "\fcf1"; } -.ti-mood-boy:before { - content: "\ed2d"; +.ti-number-24-small:before { + content: "\fcf2"; } -.ti-mood-confuzed:before { - content: "\eaf3"; +.ti-number-25-small:before { + content: "\fcf3"; } -.ti-mood-crazy-happy:before { - content: "\ed90"; +.ti-number-26-small:before { + content: "\fcf4"; } -.ti-mood-cry:before { - content: "\ecbb"; +.ti-number-27-small:before { + content: "\fcf5"; } -.ti-mood-empty:before { - content: "\eeb5"; +.ti-number-28-small:before { + content: "\fcf6"; } -.ti-mood-happy:before { - content: "\eaf4"; +.ti-number-29-small:before { + content: "\fcf7"; } -.ti-mood-kid:before { - content: "\ec03"; +.ti-number-3:before { + content: "\edf3"; } -.ti-mood-look-left:before { - content: "\f2c5"; +.ti-number-3-small:before { + content: "\fcf8"; } -.ti-mood-look-right:before { - content: "\f2c6"; +.ti-number-30-small:before { + content: "\10004"; } -.ti-mood-nerd:before { - content: "\f2e1"; +.ti-number-31-small:before { + content: "\10003"; } -.ti-mood-nervous:before { - content: "\ef96"; +.ti-number-32-small:before { + content: "\10002"; } -.ti-mood-neutral:before { - content: "\eaf5"; +.ti-number-33-small:before { + content: "\10001"; } -.ti-mood-off:before { - content: "\f161"; +.ti-number-34-small:before { + content: "\10000"; } -.ti-mood-sad:before { - content: "\eaf6"; +.ti-number-35-small:before { + content: "\10210"; } -.ti-mood-sad-2:before { - content: "\f2e2"; +.ti-number-36-small:before { + content: "\10211"; } -.ti-mood-sad-dizzy:before { - content: "\f2e3"; +.ti-number-37-small:before { + content: "\10212"; } -.ti-mood-sad-squint:before { - content: "\f2e4"; +.ti-number-38-small:before { + content: "\10213"; } -.ti-mood-sick:before { - content: "\f2e5"; +.ti-number-39-small:before { + content: "\10214"; } -.ti-mood-silence:before { - content: "\f2e6"; +.ti-number-4:before { + content: "\edf4"; } -.ti-mood-sing:before { - content: "\f2c7"; +.ti-number-4-small:before { + content: "\fcf9"; } -.ti-mood-smile:before { - content: "\eaf7"; +.ti-number-40-small:before { + content: "\10215"; } -.ti-mood-smile-beam:before { - content: "\f2e7"; +.ti-number-41-small:before { + content: "\10216"; } -.ti-mood-smile-dizzy:before { - content: "\f2e8"; +.ti-number-42-small:before { + content: "\10217"; } -.ti-mood-suprised:before { - content: "\ec04"; +.ti-number-43-small:before { + content: "\10218"; } -.ti-mood-tongue:before { - content: "\eb95"; +.ti-number-44-small:before { + content: "\10219"; } -.ti-mood-tongue-wink:before { - content: "\f2ea"; +.ti-number-45-small:before { + content: "\1021a"; } -.ti-mood-tongue-wink-2:before { - content: "\f2e9"; +.ti-number-46-small:before { + content: "\1021b"; } -.ti-mood-unamused:before { - content: "\f2eb"; +.ti-number-47-small:before { + content: "\1021c"; } -.ti-mood-wink:before { - content: "\f2ed"; +.ti-number-48-small:before { + content: "\1021d"; } -.ti-mood-wink-2:before { - content: "\f2ec"; +.ti-number-49-small:before { + content: "\1021e"; } -.ti-mood-wrrr:before { - content: "\f2ee"; +.ti-number-5:before { + content: "\edf5"; } -.ti-mood-xd:before { - content: "\f2ef"; +.ti-number-5-small:before { + content: "\fcfa"; } -.ti-moon:before { - content: "\eaf8"; +.ti-number-50-small:before { + content: "\1021f"; } -.ti-moon-2:before { - content: "\ece6"; +.ti-number-51-small:before { + content: "\ffef"; } -.ti-moon-off:before { - content: "\f162"; +.ti-number-52-small:before { + content: "\ffee"; } -.ti-moon-stars:before { - content: "\ece7"; +.ti-number-53-small:before { + content: "\ffed"; } -.ti-moped:before { - content: "\ecbc"; +.ti-number-54-small:before { + content: "\ffec"; } -.ti-motorbike:before { - content: "\eeb6"; +.ti-number-55-small:before { + content: "\ffeb"; } -.ti-mountain:before { - content: "\ef97"; +.ti-number-56-small:before { + content: "\ffea"; } -.ti-mountain-off:before { - content: "\f411"; +.ti-number-57-small:before { + content: "\ffe9"; } -.ti-mouse:before { - content: "\eaf9"; +.ti-number-58-small:before { + content: "\ffe8"; } -.ti-mouse-2:before { - content: "\f1d7"; +.ti-number-59-small:before { + content: "\ffe7"; } -.ti-mouse-off:before { - content: "\f163"; +.ti-number-6:before { + content: "\edf6"; } -.ti-moustache:before { - content: "\f4c9"; +.ti-number-6-small:before { + content: "\fcfb"; } -.ti-movie:before { - content: "\eafa"; +.ti-number-60-small:before { + content: "\ffe6"; } -.ti-movie-off:before { - content: "\f164"; +.ti-number-61-small:before { + content: "\ffe5"; } -.ti-mug:before { - content: "\eafb"; +.ti-number-62-small:before { + content: "\ffe4"; } -.ti-mug-off:before { - content: "\f165"; +.ti-number-63-small:before { + content: "\ffe3"; } -.ti-multiplier-0-5x:before { - content: "\ef41"; +.ti-number-64-small:before { + content: "\ffe2"; } -.ti-multiplier-1-5x:before { - content: "\ef42"; +.ti-number-65-small:before { + content: "\ffe1"; } -.ti-multiplier-1x:before { - content: "\ef43"; +.ti-number-66-small:before { + content: "\ffe0"; } -.ti-multiplier-2x:before { - content: "\ef44"; +.ti-number-67-small:before { + content: "\ffdf"; } -.ti-mushroom:before { - content: "\ef14"; +.ti-number-68-small:before { + content: "\ffde"; } -.ti-mushroom-off:before { - content: "\f412"; +.ti-number-69-small:before { + content: "\ffdd"; } -.ti-music:before { - content: "\eafc"; +.ti-number-7:before { + content: "\edf7"; } -.ti-music-off:before { - content: "\f166"; +.ti-number-7-small:before { + content: "\fcfc"; } -.ti-navigation:before { - content: "\f2c8"; +.ti-number-70-small:before { + content: "\ffdc"; } -.ti-navigation-off:before { - content: "\f413"; +.ti-number-71-small:before { + content: "\ffdb"; } -.ti-needle:before { - content: "\f508"; +.ti-number-72-small:before { + content: "\ffda"; } -.ti-needle-thread:before { - content: "\f507"; +.ti-number-73-small:before { + content: "\ffd9"; } -.ti-network:before { - content: "\f09f"; +.ti-number-74-small:before { + content: "\ffd8"; } -.ti-network-off:before { - content: "\f414"; +.ti-number-75-small:before { + content: "\ffd7"; } -.ti-new-section:before { - content: "\ebc1"; +.ti-number-76-small:before { + content: "\ffd6"; } -.ti-news:before { - content: "\eafd"; +.ti-number-77-small:before { + content: "\ffd5"; } -.ti-news-off:before { - content: "\f167"; +.ti-number-78-small:before { + content: "\ffd4"; } -.ti-nfc:before { - content: "\eeb7"; +.ti-number-79-small:before { + content: "\ffd3"; } -.ti-nfc-off:before { - content: "\f168"; +.ti-number-8:before { + content: "\edf8"; } -.ti-no-copyright:before { - content: "\efb9"; +.ti-number-8-small:before { + content: "\fcfd"; } -.ti-no-creative-commons:before { - content: "\efba"; +.ti-number-80-small:before { + content: "\ffd2"; } -.ti-no-derivatives:before { - content: "\efbb"; +.ti-number-81-small:before { + content: "\ffd1"; } -.ti-north-star:before { - content: "\f014"; +.ti-number-82-small:before { + content: "\ffd0"; } -.ti-note:before { - content: "\eb6d"; +.ti-number-83-small:before { + content: "\ffcf"; } -.ti-note-off:before { - content: "\f169"; +.ti-number-84-small:before { + content: "\ffce"; } -.ti-notebook:before { - content: "\eb96"; +.ti-number-85-small:before { + content: "\ffcd"; } -.ti-notebook-off:before { - content: "\f415"; +.ti-number-86-small:before { + content: "\ffcc"; } -.ti-notes:before { - content: "\eb6e"; +.ti-number-87-small:before { + content: "\ffcb"; } -.ti-notes-off:before { - content: "\f16a"; +.ti-number-88-small:before { + content: "\ffca"; } -.ti-notification:before { - content: "\eafe"; +.ti-number-89-small:before { + content: "\ffc9"; } -.ti-notification-off:before { - content: "\f16b"; +.ti-number-9:before { + content: "\edf9"; } -.ti-number:before { - content: "\f1fe"; +.ti-number-9-small:before { + content: "\fcfe"; } -.ti-number-0:before { - content: "\edf0"; +.ti-number-90-small:before { + content: "\ffc8"; } -.ti-number-1:before { - content: "\edf1"; +.ti-number-91-small:before { + content: "\ffc7"; } -.ti-number-2:before { - content: "\edf2"; +.ti-number-92-small:before { + content: "\ffc6"; } -.ti-number-3:before { - content: "\edf3"; +.ti-number-93-small:before { + content: "\ffc5"; } -.ti-number-4:before { - content: "\edf4"; +.ti-number-94-small:before { + content: "\ffc4"; } -.ti-number-5:before { - content: "\edf5"; +.ti-number-95-small:before { + content: "\ffc3"; } -.ti-number-6:before { - content: "\edf6"; +.ti-number-96-small:before { + content: "\ffc2"; } -.ti-number-7:before { - content: "\edf7"; +.ti-number-97-small:before { + content: "\ffc1"; } -.ti-number-8:before { - content: "\edf8"; +.ti-number-98-small:before { + content: "\ffc0"; } -.ti-number-9:before { - content: "\edf9"; +.ti-number-99-small:before { + content: "\ffbf"; } .ti-numbers:before { @@ -8715,18 +14597,58 @@ content: "\ef65"; } +.ti-nut:before { + content: "\fc61"; +} + +.ti-object-scan:before { + content: "\fef1"; +} + .ti-octagon:before { content: "\ecbd"; } +.ti-octagon-minus:before { + content: "\fc92"; +} + +.ti-octagon-minus-2:before { + content: "\fc91"; +} + .ti-octagon-off:before { content: "\eeb8"; } +.ti-octagon-plus:before { + content: "\fc94"; +} + +.ti-octagon-plus-2:before { + content: "\fc93"; +} + +.ti-octahedron:before { + content: "\faae"; +} + +.ti-octahedron-off:before { + content: "\faac"; +} + +.ti-octahedron-plus:before { + content: "\faad"; +} + .ti-old:before { content: "\eeb9"; } +.ti-olympic-torch:before { + content: "\10228"; +} + .ti-olympics:before { content: "\eeba"; } @@ -8743,6 +14665,10 @@ content: "\eb97"; } +.ti-option:before { + content: "\1019f"; +} + .ti-outbound:before { content: "\f249"; } @@ -8767,20 +14693,20 @@ content: "\eaff"; } -.ti-package-off:before { - content: "\f16c"; +.ti-package-export:before { + content: "\f07a"; } -.ti-packages:before { - content: "\f2c9"; +.ti-package-import:before { + content: "\f07b"; } -.ti-packge-export:before { - content: "\f07a"; +.ti-package-off:before { + content: "\f16c"; } -.ti-packge-import:before { - content: "\f07b"; +.ti-packages:before { + content: "\f2c9"; } .ti-pacman:before { @@ -8855,6 +14781,14 @@ content: "\eb03"; } +.ti-parking-circle:before { + content: "\fd5a"; +} + +.ti-parking-meter:before { + content: "\10227"; +} + .ti-parking-off:before { content: "\f172"; } @@ -8863,6 +14797,18 @@ content: "\f4ca"; } +.ti-password-fingerprint:before { + content: "\fc7b"; +} + +.ti-password-mobile-phone:before { + content: "\fc7c"; +} + +.ti-password-user:before { + content: "\fc7d"; +} + .ti-paw:before { content: "\eff9"; } @@ -8871,6 +14817,14 @@ content: "\f419"; } +.ti-paywall:before { + content: "\fd7e"; +} + +.ti-pdf:before { + content: "\f7ac"; +} + .ti-peace:before { content: "\ecbe"; } @@ -8879,6 +14833,46 @@ content: "\eb04"; } +.ti-pencil-bolt:before { + content: "\fbfd"; +} + +.ti-pencil-cancel:before { + content: "\fbfe"; +} + +.ti-pencil-check:before { + content: "\fbff"; +} + +.ti-pencil-code:before { + content: "\fc00"; +} + +.ti-pencil-cog:before { + content: "\fc01"; +} + +.ti-pencil-discount:before { + content: "\fc02"; +} + +.ti-pencil-dollar:before { + content: "\fc03"; +} + +.ti-pencil-down:before { + content: "\fc04"; +} + +.ti-pencil-exclamation:before { + content: "\fc05"; +} + +.ti-pencil-heart:before { + content: "\fc06"; +} + .ti-pencil-minus:before { content: "\f1eb"; } @@ -8887,10 +14881,46 @@ content: "\f173"; } +.ti-pencil-pause:before { + content: "\fc07"; +} + +.ti-pencil-pin:before { + content: "\fc08"; +} + .ti-pencil-plus:before { content: "\f1ec"; } +.ti-pencil-question:before { + content: "\fc09"; +} + +.ti-pencil-search:before { + content: "\fc0a"; +} + +.ti-pencil-share:before { + content: "\fc0b"; +} + +.ti-pencil-star:before { + content: "\fc0c"; +} + +.ti-pencil-up:before { + content: "\fc0d"; +} + +.ti-pencil-x:before { + content: "\fc0e"; +} + +.ti-pendulum:before { + content: "\10233"; +} + .ti-pennant:before { content: "\ed7d"; } @@ -8907,10 +14937,62 @@ content: "\efe3"; } +.ti-pentagon-minus:before { + content: "\feb3"; +} + +.ti-pentagon-number-0:before { + content: "\fc7e"; +} + +.ti-pentagon-number-1:before { + content: "\fc7f"; +} + +.ti-pentagon-number-2:before { + content: "\fc80"; +} + +.ti-pentagon-number-3:before { + content: "\fc81"; +} + +.ti-pentagon-number-4:before { + content: "\fc82"; +} + +.ti-pentagon-number-5:before { + content: "\fc83"; +} + +.ti-pentagon-number-6:before { + content: "\fc84"; +} + +.ti-pentagon-number-7:before { + content: "\fc85"; +} + +.ti-pentagon-number-8:before { + content: "\fc86"; +} + +.ti-pentagon-number-9:before { + content: "\fc87"; +} + .ti-pentagon-off:before { content: "\f41a"; } +.ti-pentagon-plus:before { + content: "\fc49"; +} + +.ti-pentagon-x:before { + content: "\fc88"; +} + .ti-pentagram:before { content: "\f586"; } @@ -8927,6 +15009,66 @@ content: "\ecf4"; } +.ti-percentage-0:before { + content: "\fee5"; +} + +.ti-percentage-10:before { + content: "\fee4"; +} + +.ti-percentage-100:before { + content: "\fee3"; +} + +.ti-percentage-20:before { + content: "\fee2"; +} + +.ti-percentage-25:before { + content: "\fee1"; +} + +.ti-percentage-30:before { + content: "\fee0"; +} + +.ti-percentage-33:before { + content: "\fedf"; +} + +.ti-percentage-40:before { + content: "\fede"; +} + +.ti-percentage-50:before { + content: "\fedd"; +} + +.ti-percentage-60:before { + content: "\fedc"; +} + +.ti-percentage-66:before { + content: "\fedb"; +} + +.ti-percentage-70:before { + content: "\feda"; +} + +.ti-percentage-75:before { + content: "\fed9"; +} + +.ti-percentage-80:before { + content: "\fed8"; +} + +.ti-percentage-90:before { + content: "\fed7"; +} + .ti-perfume:before { content: "\f509"; } @@ -8955,6 +15097,14 @@ content: "\ec05"; } +.ti-phone-done:before { + content: "\ff9e"; +} + +.ti-phone-end:before { + content: "\ff9d"; +} + .ti-phone-incoming:before { content: "\eb06"; } @@ -8975,6 +15125,14 @@ content: "\ec06"; } +.ti-phone-ringing:before { + content: "\ff9c"; +} + +.ti-phone-spark:before { + content: "\ffb1"; +} + .ti-phone-x:before { content: "\ec07"; } @@ -8983,6 +15141,22 @@ content: "\eb0a"; } +.ti-photo-ai:before { + content: "\fa32"; +} + +.ti-photo-alt:before { + content: "\10262"; +} + +.ti-photo-bitcoin:before { + content: "\ff31"; +} + +.ti-photo-bolt:before { + content: "\f990"; +} + .ti-photo-cancel:before { content: "\f35d"; } @@ -8991,6 +15165,30 @@ content: "\f35e"; } +.ti-photo-circle:before { + content: "\fc4a"; +} + +.ti-photo-circle-minus:before { + content: "\fc62"; +} + +.ti-photo-circle-plus:before { + content: "\fc63"; +} + +.ti-photo-code:before { + content: "\f991"; +} + +.ti-photo-cog:before { + content: "\f992"; +} + +.ti-photo-dollar:before { + content: "\f993"; +} + .ti-photo-down:before { content: "\f35f"; } @@ -8999,10 +15197,18 @@ content: "\f360"; } +.ti-photo-exclamation:before { + content: "\f994"; +} + .ti-photo-heart:before { content: "\f361"; } +.ti-photo-hexagon:before { + content: "\fc4b"; +} + .ti-photo-minus:before { content: "\f362"; } @@ -9011,18 +15217,62 @@ content: "\ecf6"; } +.ti-photo-pause:before { + content: "\f995"; +} + +.ti-photo-pentagon:before { + content: "\fc4c"; +} + +.ti-photo-pin:before { + content: "\f996"; +} + .ti-photo-plus:before { content: "\f363"; } +.ti-photo-question:before { + content: "\f997"; +} + +.ti-photo-scan:before { + content: "\fca8"; +} + .ti-photo-search:before { content: "\f364"; } +.ti-photo-sensor:before { + content: "\f798"; +} + +.ti-photo-sensor-2:before { + content: "\f796"; +} + +.ti-photo-sensor-3:before { + content: "\f797"; +} + +.ti-photo-share:before { + content: "\f998"; +} + .ti-photo-shield:before { content: "\f365"; } +.ti-photo-spark:before { + content: "\ffb0"; +} + +.ti-photo-square-rounded:before { + content: "\fc4d"; +} + .ti-photo-star:before { content: "\f366"; } @@ -9031,6 +15281,10 @@ content: "\f38b"; } +.ti-photo-video:before { + content: "\fc95"; +} + .ti-photo-x:before { content: "\f367"; } @@ -9039,6 +15293,18 @@ content: "\eebe"; } +.ti-piano:before { + content: "\fad3"; +} + +.ti-pick:before { + content: "\fafc"; +} + +.ti-picnic-table:before { + content: "\fed6"; +} + .ti-picture-in-picture:before { content: "\ed35"; } @@ -9071,6 +15337,14 @@ content: "\f5f6"; } +.ti-pilcrow-left:before { + content: "\fd7f"; +} + +.ti-pilcrow-right:before { + content: "\fd80"; +} + .ti-pill:before { content: "\ec44"; } @@ -9079,6 +15353,10 @@ content: "\f178"; } +.ti-pillow:before { + content: "\10226"; +} + .ti-pills:before { content: "\ef66"; } @@ -9087,6 +15365,14 @@ content: "\ec9c"; } +.ti-pin-end:before { + content: "\fd5b"; +} + +.ti-pin-invoke:before { + content: "\fd5c"; +} + .ti-ping-pong:before { content: "\f38d"; } @@ -9099,6 +15385,10 @@ content: "\ed5f"; } +.ti-pipeline:before { + content: "\10225"; +} + .ti-pizza:before { content: "\edbb"; } @@ -9159,14 +15449,90 @@ content: "\f17d"; } +.ti-play-basketball:before { + content: "\fa66"; +} + .ti-play-card:before { content: "\eebf"; } +.ti-play-card-1:before { + content: "\1005c"; +} + +.ti-play-card-10:before { + content: "\1005b"; +} + +.ti-play-card-2:before { + content: "\1005a"; +} + +.ti-play-card-3:before { + content: "\10059"; +} + +.ti-play-card-4:before { + content: "\10058"; +} + +.ti-play-card-5:before { + content: "\10057"; +} + +.ti-play-card-6:before { + content: "\10056"; +} + +.ti-play-card-7:before { + content: "\10055"; +} + +.ti-play-card-8:before { + content: "\10054"; +} + +.ti-play-card-9:before { + content: "\10053"; +} + +.ti-play-card-a:before { + content: "\10052"; +} + +.ti-play-card-j:before { + content: "\10051"; +} + +.ti-play-card-k:before { + content: "\10050"; +} + .ti-play-card-off:before { content: "\f17e"; } +.ti-play-card-q:before { + content: "\1004f"; +} + +.ti-play-card-star:before { + content: "\1004e"; +} + +.ti-play-football:before { + content: "\fa67"; +} + +.ti-play-handball:before { + content: "\fa68"; +} + +.ti-play-volleyball:before { + content: "\fa69"; +} + .ti-player-eject:before { content: "\efbc"; } @@ -9255,10 +15621,22 @@ content: "\f0a1"; } +.ti-plunger:before { + content: "\10232"; +} + .ti-plus:before { content: "\eb0b"; } +.ti-plus-equal:before { + content: "\f7ad"; +} + +.ti-plus-minus:before { + content: "\f7ae"; +} + .ti-png:before { content: "\f3ad"; } @@ -9283,6 +15661,98 @@ content: "\f265"; } +.ti-pointer-2:before { + content: "\10261"; +} + +.ti-pointer-bolt:before { + content: "\f999"; +} + +.ti-pointer-cancel:before { + content: "\f99a"; +} + +.ti-pointer-check:before { + content: "\f99b"; +} + +.ti-pointer-code:before { + content: "\f99c"; +} + +.ti-pointer-cog:before { + content: "\f99d"; +} + +.ti-pointer-collaboration:before { + content: "\1025f"; +} + +.ti-pointer-collaboration-2:before { + content: "\10260"; +} + +.ti-pointer-dollar:before { + content: "\f99e"; +} + +.ti-pointer-down:before { + content: "\f99f"; +} + +.ti-pointer-exclamation:before { + content: "\f9a0"; +} + +.ti-pointer-heart:before { + content: "\f9a1"; +} + +.ti-pointer-minus:before { + content: "\f9a2"; +} + +.ti-pointer-off:before { + content: "\f9a3"; +} + +.ti-pointer-pause:before { + content: "\f9a4"; +} + +.ti-pointer-pin:before { + content: "\f9a5"; +} + +.ti-pointer-plus:before { + content: "\f9a6"; +} + +.ti-pointer-question:before { + content: "\f9a7"; +} + +.ti-pointer-search:before { + content: "\f9a8"; +} + +.ti-pointer-share:before { + content: "\f9a9"; +} + +.ti-pointer-star:before { + content: "\f9aa"; +} + +.ti-pointer-up:before { + content: "\f9ab"; +} + +.ti-pointer-x:before { + content: "\f9ac"; +} + .ti-pokeball:before { content: "\eec1"; } @@ -9355,14 +15825,62 @@ content: "\f184"; } +.ti-prism:before { + content: "\fab1"; +} + +.ti-prism-light:before { + content: "\fea6"; +} + +.ti-prism-off:before { + content: "\faaf"; +} + +.ti-prism-plus:before { + content: "\fab0"; +} + .ti-prison:before { content: "\ef79"; } +.ti-progress:before { + content: "\fa0d"; +} + +.ti-progress-alert:before { + content: "\fa07"; +} + +.ti-progress-bolt:before { + content: "\fa08"; +} + +.ti-progress-check:before { + content: "\fa09"; +} + +.ti-progress-down:before { + content: "\fa0a"; +} + +.ti-progress-help:before { + content: "\fa0b"; +} + +.ti-progress-x:before { + content: "\fa0c"; +} + .ti-prompt:before { content: "\eb0f"; } +.ti-prong:before { + content: "\fda1"; +} + .ti-propeller:before { content: "\eec4"; } @@ -9371,6 +15889,10 @@ content: "\f185"; } +.ti-protocol:before { + content: "\fd81"; +} + .ti-pumpkin-scary:before { content: "\f587"; } @@ -9395,6 +15917,10 @@ content: "\f187"; } +.ti-pyramid-plus:before { + content: "\fab2"; +} + .ti-qrcode:before { content: "\eb11"; } @@ -9403,14 +15929,18 @@ content: "\f41e"; } -.ti-question-circle:before { - content: "\f637"; -} - .ti-question-mark:before { content: "\ec9d"; } +.ti-queue-pop-in:before { + content: "\10200"; +} + +.ti-queue-pop-out:before { + content: "\101ff"; +} + .ti-quote:before { content: "\efbe"; } @@ -9419,6 +15949,14 @@ content: "\f188"; } +.ti-quote-open:before { + content: "\10224"; +} + +.ti-quotes:before { + content: "\fb1e"; +} + .ti-radar:before { content: "\f017"; } @@ -9507,18 +16045,46 @@ content: "\edfa"; } +.ti-receipt-bitcoin:before { + content: "\fd66"; +} + +.ti-receipt-dollar:before { + content: "\fd67"; +} + +.ti-receipt-euro:before { + content: "\fd68"; +} + .ti-receipt-off:before { content: "\edfb"; } +.ti-receipt-pound:before { + content: "\fd69"; +} + .ti-receipt-refund:before { content: "\edfc"; } +.ti-receipt-rupee:before { + content: "\fd82"; +} + .ti-receipt-tax:before { content: "\edbd"; } +.ti-receipt-yen:before { + content: "\fd6a"; +} + +.ti-receipt-yuan:before { + content: "\fd6b"; +} + .ti-recharging:before { content: "\eeca"; } @@ -9535,10 +16101,30 @@ content: "\ed37"; } +.ti-rectangle-rounded-bottom:before { + content: "\faed"; +} + +.ti-rectangle-rounded-top:before { + content: "\faee"; +} + .ti-rectangle-vertical:before { content: "\ed36"; } +.ti-rectangular-prism:before { + content: "\fab5"; +} + +.ti-rectangular-prism-off:before { + content: "\fab3"; +} + +.ti-rectangular-prism-plus:before { + content: "\fab4"; +} + .ti-recycle:before { content: "\eb9b"; } @@ -9591,6 +16177,10 @@ content: "\f3ae"; } +.ti-reorder:before { + content: "\fc15"; +} + .ti-repeat:before { content: "\eb72"; } @@ -9611,6 +16201,10 @@ content: "\f422"; } +.ti-replace-user:before { + content: "\100f0"; +} + .ti-report:before { content: "\eece"; } @@ -9635,22 +16229,106 @@ content: "\ef84"; } +.ti-reserved-line:before { + content: "\f9f6"; +} + .ti-resize:before { content: "\eecf"; } +.ti-restore:before { + content: "\fafd"; +} + +.ti-rewind-backward-10:before { + content: "\faba"; +} + +.ti-rewind-backward-15:before { + content: "\fabb"; +} + +.ti-rewind-backward-20:before { + content: "\fabc"; +} + +.ti-rewind-backward-30:before { + content: "\fabd"; +} + +.ti-rewind-backward-40:before { + content: "\fabe"; +} + +.ti-rewind-backward-5:before { + content: "\fabf"; +} + +.ti-rewind-backward-50:before { + content: "\fac0"; +} + +.ti-rewind-backward-60:before { + content: "\fac1"; +} + +.ti-rewind-forward-10:before { + content: "\fac2"; +} + +.ti-rewind-forward-15:before { + content: "\fac3"; +} + +.ti-rewind-forward-20:before { + content: "\fac4"; +} + +.ti-rewind-forward-30:before { + content: "\fac5"; +} + +.ti-rewind-forward-40:before { + content: "\fac6"; +} + +.ti-rewind-forward-5:before { + content: "\fac7"; +} + +.ti-rewind-forward-50:before { + content: "\fac8"; +} + +.ti-rewind-forward-60:before { + content: "\fac9"; +} + .ti-ribbon-health:before { content: "\f58e"; } +.ti-rings:before { + content: "\fa6a"; +} + .ti-ripple:before { content: "\ed82"; } +.ti-ripple-down:before { + content: "\101aa"; +} + .ti-ripple-off:before { content: "\f190"; } +.ti-ripple-up:before { + content: "\101a9"; +} + .ti-road:before { content: "\f018"; } @@ -9667,6 +16345,10 @@ content: "\f00b"; } +.ti-robot-face:before { + content: "\fcbe"; +} + .ti-robot-off:before { content: "\f192"; } @@ -9695,6 +16377,26 @@ content: "\f599"; } +.ti-rosette-asterisk:before { + content: "\101a8"; +} + +.ti-rosette-discount:before { + content: "\ee7c"; +} + +.ti-rosette-discount-check:before { + content: "\f1f8"; +} + +.ti-rosette-discount-check-off:before { + content: "\ff10"; +} + +.ti-rosette-discount-off:before { + content: "\f3e6"; +} + .ti-rosette-number-0:before { content: "\f58f"; } @@ -9747,6 +16449,10 @@ content: "\ef85"; } +.ti-rotate-3d:before { + content: "\f020"; +} + .ti-rotate-clockwise:before { content: "\eb15"; } @@ -9763,6 +16469,10 @@ content: "\ec15"; } +.ti-roulette:before { + content: "\1025e"; +} + .ti-route:before { content: "\eb17"; } @@ -9771,10 +16481,38 @@ content: "\f4b6"; } +.ti-route-alt-left:before { + content: "\fca9"; +} + +.ti-route-alt-right:before { + content: "\fcaa"; +} + .ti-route-off:before { content: "\f194"; } +.ti-route-scan:before { + content: "\fcbf"; +} + +.ti-route-square:before { + content: "\fcac"; +} + +.ti-route-square-2:before { + content: "\fcab"; +} + +.ti-route-x:before { + content: "\fcae"; +} + +.ti-route-x-2:before { + content: "\fcad"; +} + .ti-router:before { content: "\eb18"; } @@ -9791,6 +16529,10 @@ content: "\eed1"; } +.ti-row-remove:before { + content: "\fafe"; +} + .ti-rss:before { content: "\eb19"; } @@ -9803,6 +16545,10 @@ content: "\f5aa"; } +.ti-rugby:before { + content: "\10247"; +} + .ti-ruler:before { content: "\eb1a"; } @@ -9823,6 +16569,10 @@ content: "\f291"; } +.ti-ruler-measure-2:before { + content: "\ff0f"; +} + .ti-ruler-off:before { content: "\f196"; } @@ -9831,6 +16581,10 @@ content: "\ec82"; } +.ti-rv-truck:before { + content: "\fcc0"; +} + .ti-s-turn-down:before { content: "\f516"; } @@ -9867,6 +16621,10 @@ content: "\ef16"; } +.ti-sandbox:before { + content: "\fd6c"; +} + .ti-satellite:before { content: "\eed3"; } @@ -9899,10 +16657,30 @@ content: "\ebc8"; } +.ti-scan-cube:before { + content: "\1025d"; +} + .ti-scan-eye:before { content: "\f1ff"; } +.ti-scan-letter-a:before { + content: "\10223"; +} + +.ti-scan-letter-t:before { + content: "\10222"; +} + +.ti-scan-position:before { + content: "\fdac"; +} + +.ti-scan-traces:before { + content: "\101ec"; +} + .ti-schema:before { content: "\f200"; } @@ -9939,6 +16717,10 @@ content: "\ecc1"; } +.ti-scoreboard:before { + content: "\fa6b"; +} + .ti-screen-share:before { content: "\ed18"; } @@ -9975,6 +16757,14 @@ content: "\f2d9"; } +.ti-scuba-diving:before { + content: "\fd4e"; +} + +.ti-scuba-diving-tank:before { + content: "\fefa"; +} + .ti-scuba-mask:before { content: "\eed4"; } @@ -10003,18 +16793,26 @@ content: "\f019"; } -.ti-seeding:before { +.ti-seedling:before { content: "\ed51"; } -.ti-seeding-off:before { +.ti-seedling-off:before { content: "\f19d"; } +.ti-segway:before { + content: "\10221"; +} + .ti-select:before { content: "\ec9e"; } +.ti-select-all:before { + content: "\f9f7"; +} + .ti-selector:before { content: "\eb1d"; } @@ -10023,6 +16821,10 @@ content: "\eb1e"; } +.ti-send-2:before { + content: "\fd5d"; +} + .ti-send-off:before { content: "\f429"; } @@ -10063,6 +16865,14 @@ content: "\f19e"; } +.ti-server-spark:before { + content: "\ffaf"; +} + +.ti-serverless:before { + content: "\101eb"; +} + .ti-servicemark:before { content: "\ec09"; } @@ -10075,14 +16885,98 @@ content: "\f5ac"; } +.ti-settings-ai:before { + content: "\101a7"; +} + .ti-settings-automation:before { content: "\eed6"; } +.ti-settings-bolt:before { + content: "\f9ad"; +} + +.ti-settings-cancel:before { + content: "\f9ae"; +} + +.ti-settings-check:before { + content: "\f9af"; +} + +.ti-settings-code:before { + content: "\f9b0"; +} + +.ti-settings-cog:before { + content: "\f9b1"; +} + +.ti-settings-dollar:before { + content: "\f9b2"; +} + +.ti-settings-down:before { + content: "\f9b3"; +} + +.ti-settings-exclamation:before { + content: "\f9b4"; +} + +.ti-settings-heart:before { + content: "\f9b5"; +} + +.ti-settings-minus:before { + content: "\f9b6"; +} + .ti-settings-off:before { content: "\f19f"; } +.ti-settings-pause:before { + content: "\f9b7"; +} + +.ti-settings-pin:before { + content: "\f9b8"; +} + +.ti-settings-plus:before { + content: "\f9b9"; +} + +.ti-settings-question:before { + content: "\f9ba"; +} + +.ti-settings-search:before { + content: "\f9bb"; +} + +.ti-settings-share:before { + content: "\f9bc"; +} + +.ti-settings-spark:before { + content: "\ffae"; +} + +.ti-settings-star:before { + content: "\f9bd"; +} + +.ti-settings-up:before { + content: "\f9be"; +} + +.ti-settings-x:before { + content: "\f9bf"; +} + .ti-shadow:before { content: "\eed8"; } @@ -10111,14 +17005,34 @@ content: "\eb21"; } +.ti-share-2:before { + content: "\f799"; +} + +.ti-share-3:before { + content: "\f7bd"; +} + .ti-share-off:before { content: "\f1a1"; } +.ti-shareplay:before { + content: "\fea5"; +} + .ti-shield:before { content: "\eb24"; } +.ti-shield-bolt:before { + content: "\f9c0"; +} + +.ti-shield-cancel:before { + content: "\f9c1"; +} + .ti-shield-check:before { content: "\eb22"; } @@ -10131,22 +17045,78 @@ content: "\ef9b"; } +.ti-shield-code:before { + content: "\f9c2"; +} + +.ti-shield-cog:before { + content: "\f9c3"; +} + +.ti-shield-dollar:before { + content: "\f9c4"; +} + +.ti-shield-down:before { + content: "\f9c5"; +} + +.ti-shield-exclamation:before { + content: "\f9c6"; +} + .ti-shield-half:before { content: "\f358"; } -.ti-shield-half-filled:before { - content: "\f357"; +.ti-shield-heart:before { + content: "\f9c7"; } .ti-shield-lock:before { content: "\ed58"; } +.ti-shield-minus:before { + content: "\f9c8"; +} + .ti-shield-off:before { content: "\ecf8"; } +.ti-shield-pause:before { + content: "\f9c9"; +} + +.ti-shield-pin:before { + content: "\f9ca"; +} + +.ti-shield-plus:before { + content: "\f9cb"; +} + +.ti-shield-question:before { + content: "\f9cc"; +} + +.ti-shield-search:before { + content: "\f9cd"; +} + +.ti-shield-share:before { + content: "\f9ce"; +} + +.ti-shield-star:before { + content: "\f9cf"; +} + +.ti-shield-up:before { + content: "\f9d0"; +} + .ti-shield-x:before { content: "\eb23"; } @@ -10183,30 +17153,142 @@ content: "\f5f8"; } +.ti-shopping-bag-check:before { + content: "\fc16"; +} + +.ti-shopping-bag-discount:before { + content: "\fc17"; +} + +.ti-shopping-bag-edit:before { + content: "\fc18"; +} + +.ti-shopping-bag-exclamation:before { + content: "\fc19"; +} + +.ti-shopping-bag-heart:before { + content: "\fda2"; +} + +.ti-shopping-bag-minus:before { + content: "\fc1a"; +} + +.ti-shopping-bag-plus:before { + content: "\fc1b"; +} + +.ti-shopping-bag-search:before { + content: "\fc1c"; +} + +.ti-shopping-bag-x:before { + content: "\fc1d"; +} + .ti-shopping-cart:before { content: "\eb25"; } +.ti-shopping-cart-bolt:before { + content: "\fb57"; +} + +.ti-shopping-cart-cancel:before { + content: "\fb58"; +} + +.ti-shopping-cart-check:before { + content: "\fb59"; +} + +.ti-shopping-cart-code:before { + content: "\fb5a"; +} + +.ti-shopping-cart-cog:before { + content: "\fb5b"; +} + +.ti-shopping-cart-copy:before { + content: "\fb5c"; +} + .ti-shopping-cart-discount:before { - content: "\eedb"; + content: "\fb5d"; +} + +.ti-shopping-cart-dollar:before { + content: "\fb5e"; +} + +.ti-shopping-cart-down:before { + content: "\fb5f"; +} + +.ti-shopping-cart-exclamation:before { + content: "\fb60"; +} + +.ti-shopping-cart-heart:before { + content: "\fb61"; +} + +.ti-shopping-cart-minus:before { + content: "\fb62"; } .ti-shopping-cart-off:before { content: "\eedc"; } +.ti-shopping-cart-pause:before { + content: "\fb63"; +} + +.ti-shopping-cart-pin:before { + content: "\fb64"; +} + .ti-shopping-cart-plus:before { - content: "\eedd"; + content: "\fb65"; +} + +.ti-shopping-cart-question:before { + content: "\fb66"; +} + +.ti-shopping-cart-search:before { + content: "\fb67"; +} + +.ti-shopping-cart-share:before { + content: "\fb68"; +} + +.ti-shopping-cart-star:before { + content: "\fb69"; +} + +.ti-shopping-cart-up:before { + content: "\fb6a"; } .ti-shopping-cart-x:before { - content: "\eede"; + content: "\fb6b"; } .ti-shovel:before { content: "\f1d9"; } +.ti-shovel-pitchforks:before { + content: "\fd3a"; +} + .ti-shredder:before { content: "\eedf"; } @@ -10219,6 +17301,10 @@ content: "\f06c"; } +.ti-signal-2g:before { + content: "\f79a"; +} + .ti-signal-3g:before { content: "\f1ee"; } @@ -10235,6 +17321,30 @@ content: "\f1f0"; } +.ti-signal-6g:before { + content: "\f9f8"; +} + +.ti-signal-e:before { + content: "\f9f9"; +} + +.ti-signal-g:before { + content: "\f9fa"; +} + +.ti-signal-h:before { + content: "\f9fc"; +} + +.ti-signal-h-plus:before { + content: "\f9fb"; +} + +.ti-signal-lte:before { + content: "\f9fd"; +} + .ti-signature:before { content: "\eee0"; } @@ -10259,6 +17369,26 @@ content: "\f42b"; } +.ti-skateboarding:before { + content: "\faca"; +} + +.ti-sketching:before { + content: "\1025c"; +} + +.ti-skew-x:before { + content: "\fd3b"; +} + +.ti-skew-y:before { + content: "\fd3c"; +} + +.ti-ski-jumping:before { + content: "\fa6c"; +} + .ti-skull:before { content: "\f292"; } @@ -10299,6 +17429,10 @@ content: "\ecc3"; } +.ti-snowboarding:before { + content: "\fd4f"; +} + .ti-snowflake:before { content: "\ec0b"; } @@ -10335,6 +17469,18 @@ content: "\f42c"; } +.ti-solar-electricity:before { + content: "\fcc1"; +} + +.ti-solar-panel:before { + content: "\f7bf"; +} + +.ti-solar-panel-2:before { + content: "\f7be"; +} + .ti-sort-0-9:before { content: "\f54d"; } @@ -10363,6 +17509,14 @@ content: "\ef19"; } +.ti-sort-ascending-shapes:before { + content: "\fd94"; +} + +.ti-sort-ascending-small-big:before { + content: "\fd95"; +} + .ti-sort-descending:before { content: "\eb27"; } @@ -10379,6 +17533,14 @@ content: "\ef1b"; } +.ti-sort-descending-shapes:before { + content: "\fd97"; +} + +.ti-sort-descending-small-big:before { + content: "\fd96"; +} + .ti-sort-z-a:before { content: "\f550"; } @@ -10407,6 +17569,10 @@ content: "\f1aa"; } +.ti-spaces:before { + content: "\fea4"; +} + .ti-spacing-horizontal:before { content: "\ef54"; } @@ -10419,6 +17585,26 @@ content: "\effa"; } +.ti-sparkle:before { + content: "\10259"; +} + +.ti-sparkle-2:before { + content: "\1025b"; +} + +.ti-sparkle-highlight:before { + content: "\1025a"; +} + +.ti-sparkles:before { + content: "\f6d7"; +} + +.ti-sparkles-2:before { + content: "\101a6"; +} + .ti-speakerphone:before { content: "\ed61"; } @@ -10427,6 +17613,22 @@ content: "\ed93"; } +.ti-sphere:before { + content: "\fab8"; +} + +.ti-sphere-2:before { + content: "\10258"; +} + +.ti-sphere-off:before { + content: "\fab6"; +} + +.ti-sphere-plus:before { + content: "\fab7"; +} + .ti-spider:before { content: "\f293"; } @@ -10455,6 +17657,10 @@ content: "\f42f"; } +.ti-sql:before { + content: "\f7c0"; +} + .ti-square:before { content: "\eb2c"; } @@ -10515,6 +17721,10 @@ content: "\f64e"; } +.ti-square-dashed:before { + content: "\100bb"; +} + .ti-square-dot:before { content: "\ed59"; } @@ -10680,6 +17890,10 @@ } .ti-square-minus:before { + content: "\1019e"; +} + +.ti-square-minus-2:before { content: "\eb29"; } @@ -10727,10 +17941,18 @@ content: "\eeef"; } +.ti-square-percentage:before { + content: "\fd83"; +} + .ti-square-plus:before { content: "\eb2a"; } +.ti-square-plus-2:before { + content: "\fc96"; +} + .ti-square-root:before { content: "\eef1"; } @@ -10743,6 +17965,10 @@ content: "\ecdf"; } +.ti-square-rotated-asterisk:before { + content: "\101a5"; +} + .ti-square-rotated-forbid:before { content: "\f01c"; } @@ -10919,6 +18145,10 @@ content: "\f63e"; } +.ti-square-rounded-minus-2:before { + content: "\fc97"; +} + .ti-square-rounded-number-0:before { content: "\f5c8"; } @@ -10959,10 +18189,18 @@ content: "\f5d1"; } +.ti-square-rounded-percentage:before { + content: "\fd84"; +} + .ti-square-rounded-plus:before { content: "\f63f"; } +.ti-square-rounded-plus-2:before { + content: "\fc98"; +} + .ti-square-rounded-x:before { content: "\f640"; } @@ -10979,12 +18217,16 @@ content: "\eb2b"; } +.ti-squares:before { + content: "\eef6"; +} + .ti-squares-diagonal:before { content: "\eef5"; } -.ti-squares-filled:before { - content: "\eef6"; +.ti-squares-selected:before { + content: "\fea3"; } .ti-stack:before { @@ -10999,6 +18241,26 @@ content: "\ef9d"; } +.ti-stack-back:before { + content: "\fd26"; +} + +.ti-stack-backward:before { + content: "\fd27"; +} + +.ti-stack-forward:before { + content: "\fd28"; +} + +.ti-stack-front:before { + content: "\fd29"; +} + +.ti-stack-middle:before { + content: "\fd2a"; +} + .ti-stack-pop:before { content: "\f234"; } @@ -11079,6 +18341,14 @@ content: "\eb2f"; } +.ti-sticker-2:before { + content: "\fd3d"; +} + +.ti-stopwatch:before { + content: "\ff9b"; +} + .ti-storm:before { content: "\f24c"; } @@ -11091,10 +18361,26 @@ content: "\f2db"; } +.ti-stretching-2:before { + content: "\fa6d"; +} + .ti-strikethrough:before { content: "\eb9e"; } +.ti-stroke-curved:before { + content: "\101fe"; +} + +.ti-stroke-dynamic:before { + content: "\101fd"; +} + +.ti-stroke-straight:before { + content: "\101fc"; +} + .ti-submarine:before { content: "\ed94"; } @@ -11107,6 +18393,22 @@ content: "\ec9f"; } +.ti-subtitles:before { + content: "\101a1"; +} + +.ti-subtitles-ai:before { + content: "\101a4"; +} + +.ti-subtitles-edit:before { + content: "\101a3"; +} + +.ti-subtitles-off:before { + content: "\101a2"; +} + .ti-sum:before { content: "\eb73"; } @@ -11119,6 +18421,10 @@ content: "\eb30"; } +.ti-sun-electricity:before { + content: "\fcc2"; +} + .ti-sun-high:before { content: "\f236"; } @@ -11171,6 +18477,22 @@ content: "\f551"; } +.ti-swipe-down:before { + content: "\fd5e"; +} + +.ti-swipe-left:before { + content: "\fd5f"; +} + +.ti-swipe-right:before { + content: "\fd60"; +} + +.ti-swipe-up:before { + content: "\fd61"; +} + .ti-switch:before { content: "\eb33"; } @@ -11211,14 +18533,34 @@ content: "\f25b"; } +.ti-table-column:before { + content: "\faff"; +} + +.ti-table-dashed:before { + content: "\100ba"; +} + +.ti-table-down:before { + content: "\fa1c"; +} + .ti-table-export:before { content: "\eef8"; } +.ti-table-heart:before { + content: "\fa1d"; +} + .ti-table-import:before { content: "\eef9"; } +.ti-table-minus:before { + content: "\fa1e"; +} + .ti-table-off:before { content: "\eefa"; } @@ -11227,11 +18569,31 @@ content: "\f25c"; } +.ti-table-plus:before { + content: "\fa1f"; +} + +.ti-table-row:before { + content: "\fb00"; +} + +.ti-table-share:before { + content: "\fa20"; +} + .ti-table-shortcut:before { content: "\f25d"; } +.ti-table-spark:before { + content: "\ffad"; +} + .ti-tag:before { + content: "\10096"; +} + +.ti-tag-minus:before { content: "\eb34"; } @@ -11239,6 +18601,14 @@ content: "\efc0"; } +.ti-tag-plus:before { + content: "\10097"; +} + +.ti-tag-starred:before { + content: "\fc99"; +} + .ti-tags:before { content: "\ef86"; } @@ -11247,6 +18617,10 @@ content: "\efc1"; } +.ti-taiwan-dollar:before { + content: "\10246"; +} + .ti-tallymark-1:before { content: "\ec46"; } @@ -11275,6 +18649,10 @@ content: "\eb35"; } +.ti-target-2:before { + content: "\10245"; +} + .ti-target-arrow:before { content: "\f51a"; } @@ -11283,6 +18661,18 @@ content: "\f1ad"; } +.ti-tax:before { + content: "\feee"; +} + +.ti-tax-euro:before { + content: "\fef0"; +} + +.ti-tax-pound:before { + content: "\feef"; +} + .ti-teapot:before { content: "\f552"; } @@ -11319,6 +18709,14 @@ content: "\ebee"; } +.ti-temperature-snow:before { + content: "\fda3"; +} + +.ti-temperature-sun:before { + content: "\fda4"; +} + .ti-template:before { content: "\eb39"; } @@ -11379,6 +18777,10 @@ content: "\eefd"; } +.ti-text-grammar:before { + content: "\fd6d"; +} + .ti-text-increase:before { content: "\f203"; } @@ -11399,6 +18801,14 @@ content: "\ef87"; } +.ti-text-scan-2:before { + content: "\fcc3"; +} + +.ti-text-scan-ai:before { + content: "\10257"; +} + .ti-text-size:before { content: "\f2b1"; } @@ -11411,6 +18821,10 @@ content: "\ebdd"; } +.ti-text-wrap-column:before { + content: "\feb2"; +} + .ti-text-wrap-disabled:before { content: "\eca7"; } @@ -11419,6 +18833,10 @@ content: "\f51b"; } +.ti-theater:before { + content: "\f79b"; +} + .ti-thermometer:before { content: "\ef67"; } @@ -11467,6 +18885,42 @@ content: "\f1b3"; } +.ti-time-duration-0:before { + content: "\fad4"; +} + +.ti-time-duration-10:before { + content: "\fad5"; +} + +.ti-time-duration-15:before { + content: "\fad6"; +} + +.ti-time-duration-30:before { + content: "\fad7"; +} + +.ti-time-duration-45:before { + content: "\fad8"; +} + +.ti-time-duration-5:before { + content: "\fad9"; +} + +.ti-time-duration-60:before { + content: "\fada"; +} + +.ti-time-duration-90:before { + content: "\fadb"; +} + +.ti-time-duration-off:before { + content: "\fadc"; +} + .ti-timeline:before { content: "\f031"; } @@ -11495,6 +18949,22 @@ content: "\f666"; } +.ti-timezone:before { + content: "\feed"; +} + +.ti-tip-jar:before { + content: "\feea"; +} + +.ti-tip-jar-euro:before { + content: "\feec"; +} + +.ti-tip-jar-pound:before { + content: "\feeb"; +} + .ti-tir:before { content: "\ebf0"; } @@ -11515,6 +18985,10 @@ content: "\f1b4"; } +.ti-toml:before { + content: "\fa5d"; +} + .ti-tool:before { content: "\eb40"; } @@ -11535,6 +19009,10 @@ content: "\f1b5"; } +.ti-tools-kitchen-3:before { + content: "\fd2b"; +} + .ti-tools-kitchen-off:before { content: "\f1b6"; } @@ -11651,6 +19129,38 @@ content: "\ed96"; } +.ti-transaction-bitcoin:before { + content: "\fd6e"; +} + +.ti-transaction-dollar:before { + content: "\fd6f"; +} + +.ti-transaction-euro:before { + content: "\fd70"; +} + +.ti-transaction-pound:before { + content: "\fd71"; +} + +.ti-transaction-rupee:before { + content: "\fd85"; +} + +.ti-transaction-yen:before { + content: "\fd72"; +} + +.ti-transaction-yuan:before { + content: "\fd73"; +} + +.ti-transfer:before { + content: "\fc1f"; +} + .ti-transfer-in:before { content: "\ef2f"; } @@ -11659,10 +19169,34 @@ content: "\ef30"; } +.ti-transfer-vertical:before { + content: "\fc1e"; +} + .ti-transform:before { content: "\f38e"; } +.ti-transform-point:before { + content: "\fda9"; +} + +.ti-transform-point-bottom-left:before { + content: "\fda5"; +} + +.ti-transform-point-bottom-right:before { + content: "\fda6"; +} + +.ti-transform-point-top-left:before { + content: "\fda7"; +} + +.ti-transform-point-top-right:before { + content: "\fda8"; +} + .ti-transition-bottom:before { content: "\f2b2"; } @@ -11691,6 +19225,10 @@ content: "\ef88"; } +.ti-treadmill:before { + content: "\fa6e"; +} + .ti-tree:before { content: "\ef01"; } @@ -11727,6 +19265,10 @@ content: "\edc4"; } +.ti-trending-up-down:before { + content: "\101fb"; +} + .ti-triangle:before { content: "\eb44"; } @@ -11735,10 +19277,26 @@ content: "\f01d"; } +.ti-triangle-minus:before { + content: "\fc9b"; +} + +.ti-triangle-minus-2:before { + content: "\fc9a"; +} + .ti-triangle-off:before { content: "\ef02"; } +.ti-triangle-plus:before { + content: "\fc9d"; +} + +.ti-triangle-plus-2:before { + content: "\fc9c"; +} + .ti-triangle-square-circle:before { content: "\ece8"; } @@ -11791,6 +19349,10 @@ content: "\f3b1"; } +.ti-typeface:before { + content: "\fdab"; +} + .ti-typography:before { content: "\ebc5"; } @@ -11799,18 +19361,42 @@ content: "\f1ba"; } -.ti-uf-off:before { - content: "\f26e"; +.ti-u-turn-left:before { + content: "\fea2"; +} + +.ti-u-turn-right:before { + content: "\fea1"; } .ti-ufo:before { content: "\f26f"; } +.ti-ufo-off:before { + content: "\f26e"; +} + +.ti-uhd:before { + content: "\100aa"; +} + .ti-umbrella:before { content: "\ebf1"; } +.ti-umbrella-2:before { + content: "\ff0e"; +} + +.ti-umbrella-closed:before { + content: "\ff0c"; +} + +.ti-umbrella-closed-2:before { + content: "\ff0d"; +} + .ti-umbrella-off:before { content: "\f1bb"; } @@ -11819,6 +19405,14 @@ content: "\eba2"; } +.ti-unicycle:before { + content: "\10244"; +} + +.ti-universe:before { + content: "\fcc4"; +} + .ti-unlink:before { content: "\eb46"; } @@ -11839,6 +19433,18 @@ content: "\eb4d"; } +.ti-user-bitcoin:before { + content: "\ff30"; +} + +.ti-user-bolt:before { + content: "\f9d1"; +} + +.ti-user-cancel:before { + content: "\f9d2"; +} + .ti-user-check:before { content: "\eb49"; } @@ -11847,10 +19453,42 @@ content: "\ef68"; } +.ti-user-code:before { + content: "\f9d3"; +} + +.ti-user-cog:before { + content: "\f9d4"; +} + +.ti-user-dollar:before { + content: "\f9d5"; +} + +.ti-user-down:before { + content: "\f9d6"; +} + +.ti-user-edit:before { + content: "\f7cc"; +} + .ti-user-exclamation:before { content: "\ec12"; } +.ti-user-heart:before { + content: "\f7cd"; +} + +.ti-user-hexagon:before { + content: "\fc4e"; +} + +.ti-user-key:before { + content: "\101ea"; +} + .ti-user-minus:before { content: "\eb4a"; } @@ -11859,14 +19497,62 @@ content: "\ecf9"; } +.ti-user-pause:before { + content: "\f9d7"; +} + +.ti-user-pentagon:before { + content: "\fc4f"; +} + +.ti-user-pin:before { + content: "\f7ce"; +} + .ti-user-plus:before { content: "\eb4b"; } +.ti-user-question:before { + content: "\f7cf"; +} + +.ti-user-scan:before { + content: "\fcaf"; +} + +.ti-user-screen:before { + content: "\fea0"; +} + .ti-user-search:before { content: "\ef89"; } +.ti-user-share:before { + content: "\f9d8"; +} + +.ti-user-shield:before { + content: "\f7d0"; +} + +.ti-user-square:before { + content: "\fc51"; +} + +.ti-user-square-rounded:before { + content: "\fc50"; +} + +.ti-user-star:before { + content: "\f7d1"; +} + +.ti-user-up:before { + content: "\f7d2"; +} + .ti-user-x:before { content: "\eb4c"; } @@ -11875,6 +19561,18 @@ content: "\ebf2"; } +.ti-users-group:before { + content: "\fa21"; +} + +.ti-users-minus:before { + content: "\fa0e"; +} + +.ti-users-plus:before { + content: "\fa0f"; +} + .ti-uv-index:before { content: "\f3b2"; } @@ -11983,8 +19681,16 @@ content: "\ed21"; } -.ti-view-360:before { - content: "\ed84"; +.ti-view-360:before { + content: "\ed84"; +} + +.ti-view-360-arrow:before { + content: "\f62f"; +} + +.ti-view-360-number:before { + content: "\f566"; } .ti-view-360-off:before { @@ -12003,10 +19709,22 @@ content: "\ebf3"; } +.ti-viewport-short:before { + content: "\fee9"; +} + +.ti-viewport-tall:before { + content: "\fee8"; +} + .ti-viewport-wide:before { content: "\ebf4"; } +.ti-vignette:before { + content: "\10256"; +} + .ti-vinyl:before { content: "\f00d"; } @@ -12015,6 +19733,10 @@ content: "\f3b3"; } +.ti-vip-2:before { + content: "\101fa"; +} + .ti-vip-off:before { content: "\f43a"; } @@ -12039,6 +19761,10 @@ content: "\f43b"; } +.ti-volcano:before { + content: "\f79c"; +} + .ti-volume:before { content: "\eb51"; } @@ -12051,10 +19777,18 @@ content: "\eb50"; } +.ti-volume-4:before { + content: "\1019d"; +} + .ti-volume-off:before { content: "\f1c3"; } +.ti-vs:before { + content: "\fc52"; +} + .ti-walk:before { content: "\ec87"; } @@ -12123,6 +19857,10 @@ content: "\f2ff"; } +.ti-wash-dry-flat:before { + content: "\fa7f"; +} + .ti-wash-dry-hang:before { content: "\f300"; } @@ -12151,10 +19889,18 @@ content: "\f323"; } +.ti-wash-eco:before { + content: "\fa80"; +} + .ti-wash-gentle:before { content: "\f306"; } +.ti-wash-hand:before { + content: "\fa81"; +} + .ti-wash-machine:before { content: "\f25e"; } @@ -12199,6 +19945,10 @@ content: "\f310"; } +.ti-waterpolo:before { + content: "\fa6f"; +} + .ti-wave-saw-tool:before { content: "\ecd3"; } @@ -12211,6 +19961,10 @@ content: "\ecd5"; } +.ti-waves-electricity:before { + content: "\fcc5"; +} + .ti-webhook:before { content: "\f01e"; } @@ -12223,6 +19977,18 @@ content: "\f589"; } +.ti-wheat:before { + content: "\100a8"; +} + +.ti-wheat-off:before { + content: "\100a9"; +} + +.ti-wheel:before { + content: "\fc64"; +} + .ti-wheelchair:before { content: "\f1db"; } @@ -12235,6 +20001,10 @@ content: "\f51d"; } +.ti-whisk:before { + content: "\101a0"; +} + .ti-wifi:before { content: "\eb52"; } @@ -12259,6 +20029,10 @@ content: "\ec34"; } +.ti-wind-electricity:before { + content: "\fcc6"; +} + .ti-wind-off:before { content: "\f1c7"; } @@ -12311,10 +20085,46 @@ content: "\eb54"; } +.ti-world-bolt:before { + content: "\f9d9"; +} + +.ti-world-cancel:before { + content: "\f9da"; +} + +.ti-world-check:before { + content: "\f9db"; +} + +.ti-world-code:before { + content: "\f9dc"; +} + +.ti-world-cog:before { + content: "\f9dd"; +} + +.ti-world-dollar:before { + content: "\f9de"; +} + +.ti-world-down:before { + content: "\f9df"; +} + .ti-world-download:before { content: "\ef8a"; } +.ti-world-exclamation:before { + content: "\f9e0"; +} + +.ti-world-heart:before { + content: "\f9e1"; +} + .ti-world-latitude:before { content: "\ed2e"; } @@ -12323,10 +20133,50 @@ content: "\ed2f"; } +.ti-world-map:before { + content: "\101e9"; +} + +.ti-world-minus:before { + content: "\f9e2"; +} + .ti-world-off:before { content: "\f1ca"; } +.ti-world-pause:before { + content: "\f9e3"; +} + +.ti-world-pin:before { + content: "\f9e4"; +} + +.ti-world-plus:before { + content: "\f9e5"; +} + +.ti-world-question:before { + content: "\f9e6"; +} + +.ti-world-search:before { + content: "\f9e7"; +} + +.ti-world-share:before { + content: "\f9e8"; +} + +.ti-world-star:before { + content: "\f9e9"; +} + +.ti-world-up:before { + content: "\f9ea"; +} + .ti-world-upload:before { content: "\ef8b"; } @@ -12335,6 +20185,10 @@ content: "\f38f"; } +.ti-world-x:before { + content: "\f9eb"; +} + .ti-wrecking-ball:before { content: "\ed97"; } @@ -12359,6 +20213,14 @@ content: "\eb55"; } +.ti-x-mark:before { + content: "\10220"; +} + +.ti-x-power-y:before { + content: "\10072"; +} + .ti-xbox-a:before { content: "\f2b6"; } @@ -12375,6 +20237,14 @@ content: "\f2b9"; } +.ti-xd:before { + content: "\fa33"; +} + +.ti-xxx:before { + content: "\fc20"; +} + .ti-yin-yang:before { content: "\ec35"; } @@ -12391,6 +20261,10 @@ content: "\f43f"; } +.ti-zero-config:before { + content: "\101e8"; +} + .ti-zip:before { content: "\f3b4"; } @@ -12443,6 +20317,10 @@ content: "\ecb7"; } +.ti-zoom:before { + content: "\fdaa"; +} + .ti-zoom-cancel:before { content: "\ec4d"; } @@ -12495,6 +20373,10 @@ content: "\f295"; } +.ti-zoom-scan:before { + content: "\fcb0"; +} + .ti-zzz:before { content: "\f228"; } @@ -12502,3 +20384,219 @@ .ti-zzz-off:before { content: "\f440"; } + +.ti-123:before { + content: "\f554"; +} + +.ti-360:before { + content: "\f62f"; +} + +.ti-code-asterix:before { + content: "\f312"; +} + +.ti-discount-2:before { + content: "\ee7c"; +} + +.ti-discount-2-off:before { + content: "\f3e6"; +} + +.ti-discount-check:before { + content: "\f1f8"; +} + +.ti-hand-rock:before { + content: "\ee97"; +} + +.ti-sort-deacending-small-big:before { + content: "\fd96"; +} + +.ti-shi-jumping:before { + content: "\fa6c"; +} + +.ti-box-seam:before { + content: "\eaff"; +} + +.ti-kering:before { + content: "\efb8"; +} + +.ti-2fa:before { + content: "\eca0"; +} + +.ti-3d-cube-sphere:before { + content: "\ecd7"; +} + +.ti-3d-cube-sphere-off:before { + content: "\f3b5"; +} + +.ti-3d-rotate:before { + content: "\f020"; +} + +.ti-12-hours:before { + content: "\fc53"; +} + +.ti-24-hours:before { + content: "\f5e7"; +} + +.ti-360-view:before { + content: "\f566"; +} + +.ti-circle-0:before { + content: "\ee34"; +} + +.ti-circle-1:before { + content: "\ee35"; +} + +.ti-circle-2:before { + content: "\ee36"; +} + +.ti-circle-3:before { + content: "\ee37"; +} + +.ti-circle-4:before { + content: "\ee38"; +} + +.ti-circle-5:before { + content: "\ee39"; +} + +.ti-circle-6:before { + content: "\ee3a"; +} + +.ti-circle-7:before { + content: "\ee3b"; +} + +.ti-circle-8:before { + content: "\ee3c"; +} + +.ti-circle-9:before { + content: "\ee3d"; +} + +.ti-hexagon-0:before { + content: "\f459"; +} + +.ti-hexagon-1:before { + content: "\f45a"; +} + +.ti-hexagon-2:before { + content: "\f45b"; +} + +.ti-hexagon-3:before { + content: "\f45c"; +} + +.ti-hexagon-4:before { + content: "\f45d"; +} + +.ti-hexagon-5:before { + content: "\f45e"; +} + +.ti-hexagon-6:before { + content: "\f45f"; +} + +.ti-hexagon-7:before { + content: "\f460"; +} + +.ti-hexagon-8:before { + content: "\f461"; +} + +.ti-hexagon-9:before { + content: "\f462"; +} + +.ti-square-0:before { + content: "\eee5"; +} + +.ti-square-1:before { + content: "\eee6"; +} + +.ti-square-2:before { + content: "\eee7"; +} + +.ti-square-3:before { + content: "\eee8"; +} + +.ti-square-4:before { + content: "\eee9"; +} + +.ti-square-5:before { + content: "\eeea"; +} + +.ti-square-6:before { + content: "\eeeb"; +} + +.ti-square-7:before { + content: "\eeec"; +} + +.ti-square-8:before { + content: "\eeed"; +} + +.ti-square-9:before { + content: "\eeee"; +} + +.ti-message-circle-2:before { + content: "\eaed"; +} + +.ti-mood-suprised:before { + content: "\ec04"; +} + +.ti-circle-dashed-letter-letter-v:before { + content: "\ff84"; +} + +.ti-seeding:before { + content: "\ed51"; +} + +.ti-seeding-off:before { + content: "\f19d"; +} + +.ti-brand-adobe-premier:before { + content: "\ff26"; +} diff --git a/assets/styles/packages/README.md b/assets/styles/packages/README.md deleted file mode 100644 index f0ad7efb..00000000 --- a/assets/styles/packages/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Package asset imports - -This directory is managed by the package lifecycle. - -Only assets from mirrored active packages are included here so AssetMapper can serve them from the normal `assets/` tree. Source package directories under `packages/` are not exposed wholesale. diff --git a/assets/styles/system/base.css b/assets/styles/system/base.css index a04b3eef..31c56b0d 100644 --- a/assets/styles/system/base.css +++ b/assets/styles/system/base.css @@ -685,6 +685,24 @@ html { border-bottom: 0; } +.system-acl-group-grid { + display: grid; + gap: 0.5rem; + min-width: min(100%, 22rem); +} + +.system-acl-group-row { + align-items: center; + display: grid; + gap: 0.5rem; + grid-template-columns: minmax(10rem, 1fr) minmax(8rem, 12rem); +} + +.system-acl-group-row code { + display: block; + margin-top: 0.125rem; +} + .system-markdown, .system-rich-text { @apply text-base leading-7; diff --git a/bin/ensure-package-asset-registries b/bin/ensure-extension-asset-registries old mode 100644 new mode 100755 similarity index 52% rename from bin/ensure-package-asset-registries rename to bin/ensure-extension-asset-registries index 76c176ab..f17cea61 --- a/bin/ensure-package-asset-registries +++ b/bin/ensure-extension-asset-registries @@ -3,8 +3,8 @@ declare(strict_types=1); -use App\Core\Package\PackageAssetFilesystem; -use App\Core\Package\PackageAssetRegistryWriter; +use App\Core\Extension\ExtensionAssetFilesystem; +use App\Core\Extension\ExtensionAssetRegistryWriter; $projectDir = dirname(__DIR__); $autoload = $projectDir.'/vendor/autoload.php'; @@ -18,9 +18,9 @@ if (!is_file($autoload)) { require $autoload; try { - (new PackageAssetRegistryWriter(new PackageAssetFilesystem($projectDir)))->ensureRegistryFilesExist(); + (new ExtensionAssetRegistryWriter(new ExtensionAssetFilesystem($projectDir)))->ensureRegistryFilesExist(); } catch (Throwable $throwable) { - fwrite(STDERR, 'Package asset registry files could not be prepared: '.$throwable->getMessage().PHP_EOL); + fwrite(STDERR, 'Extension asset registry files could not be prepared: '.$throwable->getMessage().PHP_EOL); exit(1); } diff --git a/bin/init b/bin/init index a2bdb281..140a2dce 100755 --- a/bin/init +++ b/bin/init @@ -7,8 +7,8 @@ ini_set('memory_limit', '512M'); ini_set('max_execution_time', 0); use App\Localization\CoreTranslationBootstrapper; -use App\Core\Package\PackageAssetFilesystem; -use App\Core\Package\PackageAssetRegistryWriter; +use App\Core\Extension\ExtensionAssetFilesystem; +use App\Core\Extension\ExtensionAssetRegistryWriter; use Symfony\Component\Dotenv\Dotenv; final class InitReporter @@ -102,6 +102,14 @@ final class InitCommand return 1; } + $this->syncGitSubmodules(); + + if ($this->reporter->hasFailures()) { + $this->reporter->printSummary(); + + return 1; + } + $this->resetVendorDirectory(); if ($this->reporter->hasFailures()) { @@ -154,7 +162,7 @@ final class InitCommand } $console = [PHP_BINARY, $this->projectDir.'/bin/console']; - $this->ensurePackageAssetRegistries(); + $this->ensureExtensionAssetRegistries(); $this->runSymfonyAssetSetup($console); if ($this->reporter->hasFailures()) { @@ -181,20 +189,20 @@ final class InitCommand { $this->runRequiredCommand([...$console, 'cache:clear', '--no-warmup'], 'Symfony cache is cleared without warmup.'); $this->runRequiredCommand([...$console, 'assets:install', 'public'], 'Bundle assets are installed.'); - $this->runRequiredCommand([...$console, 'importmap:install'], 'Importmap packages are installed.'); + $this->runRequiredCommand([...$console, 'importmap:install'], 'Import map dependencies are installed.'); $this->runOptionalCommand([...$console, 'ux:icons:lock'], 'Symfony UX icons are locked locally.'); $this->runOptionalCommand([...$console, 'mercure:install'], 'Optional Mercure hub binary is installed when supported.'); $this->runRequiredCommand([...$console, 'tailwind:build'], 'Tailwind CSS is built.'); $this->runRequiredCommand([...$console, 'cache:warmup'], 'Symfony cache is warmed.'); } - private function ensurePackageAssetRegistries(): void + private function ensureExtensionAssetRegistries(): void { try { - (new PackageAssetRegistryWriter(new PackageAssetFilesystem($this->projectDir)))->ensureRegistryFilesExist(); - $this->reporter->success('Package asset registry files exist for Tailwind and AssetMapper.'); + (new ExtensionAssetRegistryWriter(new ExtensionAssetFilesystem($this->projectDir)))->ensureRegistryFilesExist(); + $this->reporter->success('Extension asset registry files exist for Tailwind and AssetMapper.'); } catch (Throwable $throwable) { - $this->reporter->failure('Package asset registry files could not be prepared: '.$throwable->getMessage()); + $this->reporter->failure('Extension asset registry files could not be prepared: '.$throwable->getMessage()); } } @@ -272,6 +280,37 @@ final class InitCommand return null; } + private function syncGitSubmodules(): void + { + if (!is_file($this->projectDir.'/.gitmodules')) { + $this->reporter->success('No Git submodules configured.'); + + return; + } + + if (0 !== $this->runCommand(['git', '--version'], false)) { + $this->reporter->warning('Git is unavailable; skipping Git submodule initialization.'); + + return; + } + + if (0 !== $this->runCommand(['git', 'rev-parse', '--is-inside-work-tree'], false)) { + $this->reporter->warning('Project is not a Git work tree; skipping Git submodule initialization.'); + + return; + } + + $this->runRequiredCommand( + ['git', 'submodule', 'sync', '--recursive'], + 'Git submodule URLs are synchronized.', + ); + + $this->runRequiredCommand( + ['git', 'submodule', 'update', '--init', '--recursive'], + 'Git submodules are initialized.', + ); + } + private function loadSymfonyEnvironment(): string { (new Dotenv())->bootEnv($this->projectDir.'/.env'); @@ -411,10 +450,12 @@ final class InitCommand } $lock = $this->readComposerLock(); - foreach (($lock['packages'] ?? []) as $package) { - if (is_array($package)) { - foreach ($this->phpExtensionRequirements($package['require'] ?? []) as $extension) { - $extensions[] = $extension; + foreach ($this->composerLockPackageSectionsForEarlyPlatformChecks() as $section) { + foreach (($lock[$section] ?? []) as $package) { + if (is_array($package)) { + foreach ($this->phpExtensionRequirements($package['require'] ?? []) as $extension) { + $extensions[] = $extension; + } } } } @@ -432,9 +473,11 @@ final class InitCommand $lock = $this->readComposerLock(); $versions[] = $lock['platform']['php'] ?? null; - foreach (($lock['packages'] ?? []) as $package) { - if (is_array($package)) { - $versions[] = $package['require']['php'] ?? null; + foreach ($this->composerLockPackageSectionsForEarlyPlatformChecks() as $section) { + foreach (($lock[$section] ?? []) as $package) { + if (is_array($package)) { + $versions[] = $package['require']['php'] ?? null; + } } } @@ -453,6 +496,23 @@ final class InitCommand return $minimum; } + /** + * @return non-empty-list + */ + private function composerLockPackageSectionsForEarlyPlatformChecks(): array + { + return $this->includeDevComposerPackagesForEarlyPlatformChecks() + ? ['packages', 'packages-dev'] + : ['packages']; + } + + private function includeDevComposerPackagesForEarlyPlatformChecks(): bool + { + $environment = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? getenv('APP_ENV'); + + return is_string($environment) && in_array($environment, ['dev', 'test'], true); + } + /** * @param mixed $requirements * @@ -465,9 +525,9 @@ final class InitCommand } $extensions = []; - foreach ($requirements as $package => $constraint) { - if (is_string($package) && str_starts_with($package, 'ext-')) { - $extensions[] = substr($package, 4); + foreach ($requirements as $extension => $constraint) { + if (is_string($extension) && str_starts_with($extension, 'ext-')) { + $extensions[] = substr($extension, 4); } } diff --git a/bin/lint b/bin/lint index 79e1f772..3c7052e0 100755 --- a/bin/lint +++ b/bin/lint @@ -173,7 +173,7 @@ function filesWithExtensions(string $root, array $extensions): array { $excluded = [ '.git', - 'assets/packages', + 'assets/extensions', 'node_modules', 'public/assets', 'tests/Fixtures', @@ -807,7 +807,7 @@ function containsUxIconConfig(string $root, array $files): bool */ function uxIconTemplateFiles(string $root): array { - $collected = collectTargetFiles($root, ['templates', 'packages']); + $collected = collectTargetFiles($root, ['templates', 'extensions']); $groups = groupFilesByExtension($collected['files']); return $groups['twig'] ?? []; diff --git a/composer.json b/composer.json index 08f4ad2f..e4029fe4 100755 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "mck89/peast": "^1.17.6", "phpdocumentor/reflection-docblock": "^6.0.3", "phpstan/phpdoc-parser": "^2.3.2", - "sabberworm/php-css-parser": "^9.3", + "sabberworm/php-css-parser": "^9.4", "symfony/asset": "8.1.*", "symfony/asset-mapper": "8.1.*", "symfony/console": "8.1.*", @@ -69,26 +69,26 @@ "symfony/scheduler": "8.1.*", "symfony/security-bundle": "8.1.*", "symfony/serializer": "8.1.*", - "symfony/stimulus-bundle": "^3.1", + "symfony/stimulus-bundle": "^3.2", "symfony/string": "8.1.*", "symfony/translation": "8.1.*", "symfony/twig-bundle": "8.1.*", "symfony/uid": "8.1.*", - "symfony/ux-autocomplete": "^3.1", - "symfony/ux-calendar-link": "^3.1", - "symfony/ux-chartjs": "^3.1", - "symfony/ux-cropperjs": "^3.1", - "symfony/ux-dropzone": "^3.1", - "symfony/ux-icons": "^3.1", - "symfony/ux-leaflet-map": "^3.1", - "symfony/ux-live-component": "^3.1", - "symfony/ux-map": "^3.1", - "symfony/ux-native": "^3.1", - "symfony/ux-react": "^3.1", - "symfony/ux-translator": "^3.1", - "symfony/ux-turbo": "^3.1", - "symfony/ux-twig-component": "^3.1", - "symfony/ux-vue": "^3.1", + "symfony/ux-autocomplete": "^3.2", + "symfony/ux-calendar-link": "^3.2", + "symfony/ux-chartjs": "^3.2", + "symfony/ux-cropperjs": "^3.2", + "symfony/ux-dropzone": "^3.2", + "symfony/ux-icons": "^3.2", + "symfony/ux-leaflet-map": "^3.2", + "symfony/ux-live-component": "^3.2", + "symfony/ux-map": "^3.2", + "symfony/ux-native": "^3.2", + "symfony/ux-react": "^3.2", + "symfony/ux-translator": "^3.2", + "symfony/ux-turbo": "^3.2", + "symfony/ux-twig-component": "^3.2", + "symfony/ux-vue": "^3.2", "symfony/validator": "8.1.*", "symfony/web-link": "8.1.*", "symfony/workflow": "8.1.*", @@ -105,7 +105,7 @@ "symfony/debug-bundle": "8.1.*", "symfony/maker-bundle": "^1.67", "symfony/stopwatch": "8.1.*", - "symfony/ux-toolkit": "^3.1", + "symfony/ux-toolkit": "^3.2", "symfony/web-profiler-bundle": "8.1.*" }, "conflict": { @@ -121,7 +121,7 @@ "autoload": { "exclude-from-classmap": [ "tests/Fixtures/", - "packages/" + "extensions/" ], "psr-4": { "App\\": "src/" @@ -140,11 +140,11 @@ "tailwind:build": "symfony-cmd" }, "post-install-cmd": [ - "@php bin/ensure-package-asset-registries", + "@php bin/ensure-extension-asset-registries", "@auto-scripts" ], "post-update-cmd": [ - "@php bin/ensure-package-asset-registries", + "@php bin/ensure-extension-asset-registries", "@auto-scripts" ] } diff --git a/composer.lock b/composer.lock index b6c17c59..68cad134 100755 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "463d469af7c7ad1f9497a36ec6ac3853", + "content-hash": "b80099df02494f9b6a699eed41621d8a", "packages": [ { "name": "composer/ca-bundle", @@ -1536,16 +1536,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.0", + "version": "2.12.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", + "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7", "shasum": "" }, "require": { @@ -1635,7 +1635,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" + "source": "https://github.com/guzzle/psr7/tree/2.12.1" }, "funding": [ { @@ -1651,7 +1651,7 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:30:48+00:00" + "time": "2026-06-18T09:49:37+00:00" }, { "name": "intervention/image", @@ -1739,16 +1739,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.9.0", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886" + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bd1bda2ebfc8bff418565941771ea8f03c557886", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", "shasum": "" }, "require": { @@ -1758,7 +1758,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -1808,9 +1808,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.9.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" }, - "time": "2026-06-05T14:05:24+00:00" + "time": "2026-06-16T20:50:26+00:00" }, { "name": "lcobucci/jwt", @@ -3513,16 +3513,16 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.3.0", + "version": "v9.4.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949" + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", - "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", "shasum": "" }, "require": { @@ -3533,15 +3533,15 @@ "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.32 || 2.1.32", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", "phpunit/phpunit": "8.5.52", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.2.8", - "rector/type-perfect": "1.0.0 || 2.1.0", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", "squizlabs/php_codesniffer": "4.0.1", - "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1" + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -3549,7 +3549,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.4.x-dev" + "dev-main": "9.5.x-dev" } }, "autoload": { @@ -3587,9 +3587,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" }, - "time": "2026-03-03T17:31:43+00:00" + "time": "2026-06-18T15:10:53+00:00" }, { "name": "symfony/asset", @@ -8707,16 +8707,16 @@ }, { "name": "symfony/stimulus-bundle", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/stimulus-bundle.git", - "reference": "9dc58d7cbb77356642c63434033e64d00b6c6946" + "reference": "3cf8cd35c400f287def6ccd4574c111c07661054" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/9dc58d7cbb77356642c63434033e64d00b6c6946", - "reference": "9dc58d7cbb77356642c63434033e64d00b6c6946", + "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/3cf8cd35c400f287def6ccd4574c111c07661054", + "reference": "3cf8cd35c400f287def6ccd4574c111c07661054", "shasum": "" }, "require": { @@ -8759,7 +8759,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/stimulus-bundle/tree/v3.1.0" + "source": "https://github.com/symfony/stimulus-bundle/tree/v3.2.0" }, "funding": [ { @@ -8779,7 +8779,7 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/stopwatch", @@ -9466,16 +9466,16 @@ }, { "name": "symfony/ux-autocomplete", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-autocomplete.git", - "reference": "2b5e5bf287f8056dc39ebe69447de9ee828db1b4" + "reference": "558216281c4b722d4547ae9cde5d5cb7c1fc2a3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-autocomplete/zipball/2b5e5bf287f8056dc39ebe69447de9ee828db1b4", - "reference": "2b5e5bf287f8056dc39ebe69447de9ee828db1b4", + "url": "https://api.github.com/repos/symfony/ux-autocomplete/zipball/558216281c4b722d4547ae9cde5d5cb7c1fc2a3f", + "reference": "558216281c4b722d4547ae9cde5d5cb7c1fc2a3f", "shasum": "" }, "require": { @@ -9536,7 +9536,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-autocomplete/tree/v3.1.0" + "source": "https://github.com/symfony/ux-autocomplete/tree/v3.2.0" }, "funding": [ { @@ -9556,20 +9556,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T07:08:56+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-calendar-link", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-calendar-link.git", - "reference": "6a56e575c4f35730144626dca8a1a2efb61184b8" + "reference": "648b2d5c2e239e2f6a5694008fc07b6656be04fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-calendar-link/zipball/6a56e575c4f35730144626dca8a1a2efb61184b8", - "reference": "6a56e575c4f35730144626dca8a1a2efb61184b8", + "url": "https://api.github.com/repos/symfony/ux-calendar-link/zipball/648b2d5c2e239e2f6a5694008fc07b6656be04fa", + "reference": "648b2d5c2e239e2f6a5694008fc07b6656be04fa", "shasum": "" }, "require": { @@ -9622,7 +9622,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-calendar-link/tree/v3.1.0" + "source": "https://github.com/symfony/ux-calendar-link/tree/v3.2.0" }, "funding": [ { @@ -9642,20 +9642,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-11T18:39:20+00:00" }, { "name": "symfony/ux-chartjs", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-chartjs.git", - "reference": "129f511b9f3dfca6cb34975c9b77af09aa6d2a8b" + "reference": "b3873e0ecdeb05496e909335c2bda76a979b72ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/129f511b9f3dfca6cb34975c9b77af09aa6d2a8b", - "reference": "129f511b9f3dfca6cb34975c9b77af09aa6d2a8b", + "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/b3873e0ecdeb05496e909335c2bda76a979b72ad", + "reference": "b3873e0ecdeb05496e909335c2bda76a979b72ad", "shasum": "" }, "require": { @@ -9706,7 +9706,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-chartjs/tree/v3.1.0" + "source": "https://github.com/symfony/ux-chartjs/tree/v3.2.0" }, "funding": [ { @@ -9726,20 +9726,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-cropperjs", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-cropperjs.git", - "reference": "e49b20946048de2a4010ec4e81add822355ef3bc" + "reference": "12d8302afd985bc9bc99d77372feae0cdf58e794" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-cropperjs/zipball/e49b20946048de2a4010ec4e81add822355ef3bc", - "reference": "e49b20946048de2a4010ec4e81add822355ef3bc", + "url": "https://api.github.com/repos/symfony/ux-cropperjs/zipball/12d8302afd985bc9bc99d77372feae0cdf58e794", + "reference": "12d8302afd985bc9bc99d77372feae0cdf58e794", "shasum": "" }, "require": { @@ -9796,7 +9796,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-cropperjs/tree/v3.1.0" + "source": "https://github.com/symfony/ux-cropperjs/tree/v3.2.0" }, "funding": [ { @@ -9816,20 +9816,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-dropzone", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-dropzone.git", - "reference": "49475de4db15e2065969ff4e8cf67299c0c3fae6" + "reference": "5a6e9521da0c7b46229d540098998fd3fe9998f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-dropzone/zipball/49475de4db15e2065969ff4e8cf67299c0c3fae6", - "reference": "49475de4db15e2065969ff4e8cf67299c0c3fae6", + "url": "https://api.github.com/repos/symfony/ux-dropzone/zipball/5a6e9521da0c7b46229d540098998fd3fe9998f3", + "reference": "5a6e9521da0c7b46229d540098998fd3fe9998f3", "shasum": "" }, "require": { @@ -9879,7 +9879,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-dropzone/tree/v3.1.0" + "source": "https://github.com/symfony/ux-dropzone/tree/v3.2.0" }, "funding": [ { @@ -9899,20 +9899,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T10:28:27+00:00" }, { "name": "symfony/ux-icons", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-icons.git", - "reference": "8a50a3d439a1612f5952c587af72933041b7308e" + "reference": "a6b81d5953d5f963fc8f27a3584213d242406ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-icons/zipball/8a50a3d439a1612f5952c587af72933041b7308e", - "reference": "8a50a3d439a1612f5952c587af72933041b7308e", + "url": "https://api.github.com/repos/symfony/ux-icons/zipball/a6b81d5953d5f963fc8f27a3584213d242406ca1", + "reference": "a6b81d5953d5f963fc8f27a3584213d242406ca1", "shasum": "" }, "require": { @@ -9972,7 +9972,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-icons/tree/v3.1.0" + "source": "https://github.com/symfony/ux-icons/tree/v3.2.0" }, "funding": [ { @@ -9992,20 +9992,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-19T06:54:48+00:00" }, { "name": "symfony/ux-leaflet-map", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-leaflet-map.git", - "reference": "8ad68a905c95b8e19b65368cec2f723a564ca1c2" + "reference": "c399948d79deda450a747ad30710198e5b8b87e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-leaflet-map/zipball/8ad68a905c95b8e19b65368cec2f723a564ca1c2", - "reference": "8ad68a905c95b8e19b65368cec2f723a564ca1c2", + "url": "https://api.github.com/repos/symfony/ux-leaflet-map/zipball/c399948d79deda450a747ad30710198e5b8b87e2", + "reference": "c399948d79deda450a747ad30710198e5b8b87e2", "shasum": "" }, "require": { @@ -10047,7 +10047,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-leaflet-map/tree/v3.1.0" + "source": "https://github.com/symfony/ux-leaflet-map/tree/v3.2.0" }, "funding": [ { @@ -10067,20 +10067,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-live-component", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-live-component.git", - "reference": "9ad6223e00922c17e26ce111c3ced31a9c2e7210" + "reference": "ec86432d7b86f687ded54faeaacb61fe61f04d34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/9ad6223e00922c17e26ce111c3ced31a9c2e7210", - "reference": "9ad6223e00922c17e26ce111c3ced31a9c2e7210", + "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/ec86432d7b86f687ded54faeaacb61fe61f04d34", + "reference": "ec86432d7b86f687ded54faeaacb61fe61f04d34", "shasum": "" }, "require": { @@ -10148,7 +10148,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-live-component/tree/v3.1.0" + "source": "https://github.com/symfony/ux-live-component/tree/v3.2.0" }, "funding": [ { @@ -10168,20 +10168,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T07:08:56+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-map", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-map.git", - "reference": "1b327b84c6b548f29e45d9fe8a1585db832ebd05" + "reference": "dcbc5854fc025cb1c7aaf7638ca2ad7e87eddbb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-map/zipball/1b327b84c6b548f29e45d9fe8a1585db832ebd05", - "reference": "1b327b84c6b548f29e45d9fe8a1585db832ebd05", + "url": "https://api.github.com/repos/symfony/ux-map/zipball/dcbc5854fc025cb1c7aaf7638ca2ad7e87eddbb9", + "reference": "dcbc5854fc025cb1c7aaf7638ca2ad7e87eddbb9", "shasum": "" }, "require": { @@ -10236,7 +10236,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-map/tree/v3.1.0" + "source": "https://github.com/symfony/ux-map/tree/v3.2.0" }, "funding": [ { @@ -10256,20 +10256,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-native", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-native.git", - "reference": "3ff01673a654d76075b7920f128fbd93286d038d" + "reference": "ff054c897f9aabfae2c2cf69dcfa2b0f64267603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-native/zipball/3ff01673a654d76075b7920f128fbd93286d038d", - "reference": "3ff01673a654d76075b7920f128fbd93286d038d", + "url": "https://api.github.com/repos/symfony/ux-native/zipball/ff054c897f9aabfae2c2cf69dcfa2b0f64267603", + "reference": "ff054c897f9aabfae2c2cf69dcfa2b0f64267603", "shasum": "" }, "require": { @@ -10316,7 +10316,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-native/tree/v3.1.0" + "source": "https://github.com/symfony/ux-native/tree/v3.2.0" }, "funding": [ { @@ -10336,20 +10336,20 @@ "type": "tidelift" } ], - "time": "2026-05-06T04:34:57+00:00" + "time": "2026-06-05T01:39:06+00:00" }, { "name": "symfony/ux-react", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-react.git", - "reference": "d98f6f2edd99cdfc0d57253b15b9b300b0b603dc" + "reference": "178ed4536659b85b2d32c5285c8514aeb4eeb7d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-react/zipball/d98f6f2edd99cdfc0d57253b15b9b300b0b603dc", - "reference": "d98f6f2edd99cdfc0d57253b15b9b300b0b603dc", + "url": "https://api.github.com/repos/symfony/ux-react/zipball/178ed4536659b85b2d32c5285c8514aeb4eeb7d4", + "reference": "178ed4536659b85b2d32c5285c8514aeb4eeb7d4", "shasum": "" }, "require": { @@ -10396,7 +10396,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-react/tree/v3.1.0" + "source": "https://github.com/symfony/ux-react/tree/v3.2.0" }, "funding": [ { @@ -10416,20 +10416,20 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-translator", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-translator.git", - "reference": "e51565f1cbcf82b450eaaf74b2a9fd435aee1fc3" + "reference": "f6bd0e41e72510cd8c48ac9ab11eba29b10a185e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-translator/zipball/e51565f1cbcf82b450eaaf74b2a9fd435aee1fc3", - "reference": "e51565f1cbcf82b450eaaf74b2a9fd435aee1fc3", + "url": "https://api.github.com/repos/symfony/ux-translator/zipball/f6bd0e41e72510cd8c48ac9ab11eba29b10a185e", + "reference": "f6bd0e41e72510cd8c48ac9ab11eba29b10a185e", "shasum": "" }, "require": { @@ -10477,7 +10477,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-translator/tree/v3.1.0" + "source": "https://github.com/symfony/ux-translator/tree/v3.2.0" }, "funding": [ { @@ -10497,20 +10497,20 @@ "type": "tidelift" } ], - "time": "2026-05-28T13:53:01+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/ux-turbo", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-turbo.git", - "reference": "50faf143c13154dd928cfedb96f951bbecad4c73" + "reference": "923c6479da86ebdcbd4f723a80c24d47d50567fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/50faf143c13154dd928cfedb96f951bbecad4c73", - "reference": "50faf143c13154dd928cfedb96f951bbecad4c73", + "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/923c6479da86ebdcbd4f723a80c24d47d50567fa", + "reference": "923c6479da86ebdcbd4f723a80c24d47d50567fa", "shasum": "" }, "require": { @@ -10518,7 +10518,8 @@ "symfony/stimulus-bundle": "^2.9.1|^3.0" }, "conflict": { - "symfony/flex": "<1.13" + "symfony/flex": "<1.13", + "symfony/mercure": ">=0.7.0 <0.7.2" }, "require-dev": { "doctrine/doctrine-bundle": "^2.14|^3.0|^4.0", @@ -10577,7 +10578,7 @@ "turbo-stream" ], "support": { - "source": "https://github.com/symfony/ux-turbo/tree/v3.1.0" + "source": "https://github.com/symfony/ux-turbo/tree/v3.2.0" }, "funding": [ { @@ -10597,25 +10598,25 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-19T07:14:15+00:00" }, { "name": "symfony/ux-twig-component", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-twig-component.git", - "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55" + "reference": "a9b93b10fe2056a6f93ebc1f9e3509f7fafb5bf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/69763f39367d7185ebff3a8aec3e9d6ec7e10d55", - "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/a9b93b10fe2056a6f93ebc1f9e3509f7fafb5bf2", + "reference": "a9b93b10fe2056a6f93ebc1f9e3509f7fafb5bf2", "shasum": "" }, "require": { "php": ">=8.4", - "symfony/dependency-injection": "^7.4|^8.0", + "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.2|^3.0", "symfony/event-dispatcher": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", @@ -10628,6 +10629,7 @@ "phpunit/phpunit": "^11.1|^12.0", "symfony/console": "^7.4|^8.0", "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", "symfony/dom-crawler": "^7.4|^8.0", "symfony/framework-bundle": "^7.4|^8.0", "symfony/stimulus-bundle": "^2.9.1|^3.0", @@ -10665,7 +10667,7 @@ "twig" ], "support": { - "source": "https://github.com/symfony/ux-twig-component/tree/v3.1.0" + "source": "https://github.com/symfony/ux-twig-component/tree/v3.2.0" }, "funding": [ { @@ -10685,20 +10687,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T07:08:56+00:00" + "time": "2026-06-11T20:32:06+00:00" }, { "name": "symfony/ux-vue", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-vue.git", - "reference": "a505d5508aee8bcfefd32a444caed7d1d94bda1d" + "reference": "ba8e9101cd516970be540994fa80a18431198028" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-vue/zipball/a505d5508aee8bcfefd32a444caed7d1d94bda1d", - "reference": "a505d5508aee8bcfefd32a444caed7d1d94bda1d", + "url": "https://api.github.com/repos/symfony/ux-vue/zipball/ba8e9101cd516970be540994fa80a18431198028", + "reference": "ba8e9101cd516970be540994fa80a18431198028", "shasum": "" }, "require": { @@ -10749,7 +10751,7 @@ "symfony-ux" ], "support": { - "source": "https://github.com/symfony/ux-vue/tree/v3.1.0" + "source": "https://github.com/symfony/ux-vue/tree/v3.2.0" }, "funding": [ { @@ -10769,7 +10771,7 @@ "type": "tidelift" } ], - "time": "2026-05-22T05:04:55+00:00" + "time": "2026-06-05T09:42:50+00:00" }, { "name": "symfony/validator", @@ -11707,16 +11709,16 @@ }, { "name": "webmozart/assert", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", - "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { @@ -11767,9 +11769,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "time": "2026-05-20T13:07:01+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -12394,16 +12396,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.2.0", + "version": "13.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "3796ea973f1e7698f0d432c1c66662af9764fd9a" + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3796ea973f1e7698f0d432c1c66662af9764fd9a", - "reference": "3796ea973f1e7698f0d432c1c66662af9764fd9a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba", + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba", "shasum": "" }, "require": { @@ -12417,7 +12419,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.2", + "phpunit/php-code-coverage": "^14.2.2", "phpunit/php-file-iterator": "^7.0.0", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", @@ -12474,7 +12476,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.0" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1" }, "funding": [ { @@ -12482,7 +12484,7 @@ "type": "other" } ], - "time": "2026-06-05T03:13:07+00:00" + "time": "2026-06-15T13:14:22+00:00" }, { "name": "sebastian/cli-parser", @@ -14030,16 +14032,16 @@ }, { "name": "symfony/ux-toolkit", - "version": "v3.1.0", + "version": "v3.2.0", "source": { "type": "git", "url": "https://github.com/symfony/ux-toolkit.git", - "reference": "0b23585e731235e462e030bec4d4850d8cb1c5a8" + "reference": "3d04840005c5c345dc04a6df591c997f43b4aa0d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/ux-toolkit/zipball/0b23585e731235e462e030bec4d4850d8cb1c5a8", - "reference": "0b23585e731235e462e030bec4d4850d8cb1c5a8", + "url": "https://api.github.com/repos/symfony/ux-toolkit/zipball/3d04840005c5c345dc04a6df591c997f43b4aa0d", + "reference": "3d04840005c5c345dc04a6df591c997f43b4aa0d", "shasum": "" }, "require": { @@ -14047,6 +14049,7 @@ "symfony/console": "^7.4|^8.0", "symfony/filesystem": "^7.4|^8.0", "symfony/framework-bundle": "^7.4|^8.0", + "symfony/string": "^7.4|^8.0", "symfony/twig-bundle": "^7.4|^8.0", "symfony/ux-twig-component": "^3.1", "symfony/yaml": "^7.4|^8.0", @@ -14066,7 +14069,8 @@ }, "bin": [ "bin/ux-toolkit-kit-create", - "bin/ux-toolkit-kit-debug" + "bin/ux-toolkit-kit-debug", + "bin/ux-toolkit-kit-lint" ], "type": "symfony-bundle", "extra": { @@ -14113,7 +14117,7 @@ "ui" ], "support": { - "source": "https://github.com/symfony/ux-toolkit/tree/v3.1.0" + "source": "https://github.com/symfony/ux-toolkit/tree/v3.2.0" }, "funding": [ { @@ -14133,7 +14137,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T07:10:16+00:00" + "time": "2026-06-19T07:14:15+00:00" }, { "name": "symfony/web-profiler-bundle", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index d6a20493..137bb599 100755 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -18,8 +18,8 @@ framework: messenger.bus.default: [] routing: - App\Core\Package\PackageAssetRebuildMessage: async - App\Core\Package\PackageDiscoveryMessage: async + App\Core\Extension\ExtensionAssetRebuildMessage: async + App\Core\Extension\ExtensionDiscoveryMessage: async App\View\Alert\DispatchUiAlertMessage: async Symfony\Component\Mailer\Messenger\SendEmailMessage: async Symfony\Component\Notifier\Message\ChatMessage: async diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index 878741f2..cf2df369 100755 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -2,4 +2,6 @@ framework: default_locale: en translator: default_path: '%kernel.project_dir%/translations/runtime/%kernel.environment%' + fallbacks: + - en providers: diff --git a/config/services.yaml b/config/services.yaml index da508fcb..03baf8cb 100755 --- a/config/services.yaml +++ b/config/services.yaml @@ -41,6 +41,9 @@ services: App\Core\Event\EventHookDescriptorProviderInterface: tags: - { name: 'system.event_hook_provider', priority: 0 } + App\Core\Geo\GeoIpProviderInterface: + tags: + - { name: 'system.geoip_provider', priority: 0 } App\Backend\BackendViewProviderInterface: tags: - { name: 'system.backend_view_provider', priority: 0 } @@ -50,9 +53,9 @@ services: App\View\Injection\DynamicViewInjectionProviderInterface: tags: - { name: 'system.dynamic_view_injection_provider', priority: 0 } - App\Core\Package\Settings\PackageSettingProviderInterface: + App\Core\Extension\Settings\ExtensionSettingProviderInterface: tags: - - { name: 'system.package_setting_provider', priority: 0 } + - { name: 'system.extension_setting_provider', priority: 0 } App\Core\Operation\Live\LiveOperationQueueProviderInterface: tags: - { name: 'system.live_operation_queue_provider', priority: 0 } @@ -71,6 +74,9 @@ services: App\Security\AclGroupReferenceProviderInterface: tags: - { name: 'system.acl_group_reference_provider', priority: 0 } + App\Core\AdminAcl\AdminFeatureProviderInterface: + tags: + - { name: 'system.admin_feature_provider', priority: 0 } # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -97,7 +103,6 @@ services: - '../src/**/*Item.php' - '../src/**/*Message.php' - '../src/**/*Mode.php' - - '../src/**/*Package.php' - '../src/**/*Path.php' - '../src/**/*Plan.php' - '../src/**/*Queue.php' @@ -106,7 +111,7 @@ services: - '../src/**/*Scope.php' - '../src/**/*Source.php' - '../src/Debug/RouteRenderOptions.php' - - '../src/Core/Package/PackageSpec.php' + - '../src/Core/Geo/GeoIp2MaxMindDatabaseReader.php' - '../src/**/*Spec.php' - '../src/**/*Status.php' - '../src/**/*Type.php' @@ -160,9 +165,9 @@ services: App\Content\Api\ContentApiPath: ~ - App\Core\Package\PackageTranslationNamespaceValidator: + App\Core\Extension\ExtensionDependencyManifestValidator: arguments: - $fallbackLocale: '%kernel.default_locale%' + $projectDir: '%kernel.project_dir%' App\Setup\SetupRunner: arguments: @@ -172,6 +177,10 @@ services: arguments: $sessionFactory: '@session.factory' + App\Command\RenderRouteCommand: + arguments: + $environment: '%kernel.environment%' + App\Setup\SetupRedirectSubscriber: arguments: $projectDir: '%kernel.project_dir%' @@ -211,14 +220,18 @@ services: arguments: $providers: !tagged_iterator { tag: system.backend_view_provider } + App\Core\AdminAcl\AdminFeatureRegistry: + arguments: + $providers: !tagged_iterator { tag: system.admin_feature_provider } + App\View\Injection\ViewInjectionRegistry: arguments: $staticProviders: !tagged_iterator { tag: system.static_view_injection_provider } $dynamicProviders: !tagged_iterator { tag: system.dynamic_view_injection_provider } - App\Core\Package\Settings\PackageSettingRegistry: + App\Core\Extension\Settings\ExtensionSettingRegistry: arguments: - $providers: !tagged_iterator { tag: system.package_setting_provider } + $providers: !tagged_iterator { tag: system.extension_setting_provider } App\Scheduler\SchedulerTaskRegistry: arguments: @@ -249,6 +262,34 @@ services: arguments: $providers: !tagged_iterator { tag: system.acl_group_reference_provider } + App\Security\RateLimit\RateLimitLimiterFactory: + arguments: + $cachePool: '@cache.rate_limiter' + $lockFactory: '@lock.factory' + + App\Security\AutoBan\AutoBanStore: + arguments: + $cache: '@cache.app' + $lockFactory: '@lock.factory' + + App\Security\AutoBan\AutoBanSignalEvaluator: + arguments: + $environment: '%kernel.environment%' + + App\Security\AutoBan\AutoBanRequestSubscriber: + arguments: + $environment: '%kernel.environment%' + $trustedApiKeys: '@App\Security\AutoBan\TrustedApiKeyAutoBanBypass' + + App\Security\RateLimit\RateLimitRequestSubscriber: + arguments: + $environment: '%kernel.environment%' + $projectDir: '%kernel.project_dir%' + + App\Security\RateLimit\RateLimitAuthenticationSubscriber: + arguments: + $environment: '%kernel.environment%' + App\Localization\TranslationLanguageCatalog: arguments: $projectDir: '%kernel.project_dir%' @@ -267,7 +308,7 @@ services: arguments: $projectDir: '%kernel.project_dir%' - App\Core\Package\PackageAssetSyncer: + App\Core\Extension\ExtensionAssetSyncer: arguments: $projectDir: '%kernel.project_dir%' @@ -275,13 +316,13 @@ services: arguments: $projectDir: '%kernel.project_dir%' - App\Core\Package\PackageRegistryHandler: + App\Core\Extension\ExtensionRegistryHandler: arguments: $projectDir: '%kernel.project_dir%' $environment: '%kernel.environment%' $validationSpec: null - App\Core\Package\PackageRegistrySyncFinalizer: + App\Core\Extension\ExtensionRegistrySyncFinalizer: arguments: $environment: '%kernel.environment%' @@ -290,47 +331,51 @@ services: $projectDir: '%kernel.project_dir%' $environment: '%kernel.environment%' - App\Core\Package\PackageDiscoveryRunner: + App\Core\Extension\ExtensionDiscoveryRunner: arguments: $projectDir: '%kernel.project_dir%' $environment: '%kernel.environment%' - App\Core\Package\PackageRemover: + App\Core\Extension\ExtensionContributionReader: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Core\Extension\ExtensionRemover: arguments: $projectDir: '%kernel.project_dir%' - App\Core\Package\PackageFilesystemRemover: + App\Core\Extension\ExtensionFilesystemRemover: arguments: $projectDir: '%kernel.project_dir%' - App\Core\Package\Install\PackageInstallFilesystem: + App\Core\Extension\Install\ExtensionInstallFilesystem: arguments: $projectDir: '%kernel.project_dir%' - App\Core\Package\Install\PackageUploadStager: + App\Core\Extension\Install\ExtensionUploadStager: arguments: $environment: '%kernel.environment%' - App\Core\Package\Install\PackageInstallVerifier: + App\Core\Extension\Install\ExtensionInstallVerifier: arguments: $environment: '%kernel.environment%' - App\Core\Package\Install\PackageInstallApplier: + App\Core\Extension\Install\ExtensionInstallApplier: arguments: $environment: '%kernel.environment%' - App\Core\Package\PackageFaultResetter: + App\Core\Extension\ExtensionFaultResetter: arguments: $projectDir: '%kernel.project_dir%' $environment: '%kernel.environment%' - App\Core\Package\PackagePhpLoader: + App\Core\Extension\ExtensionPhpLoader: arguments: $projectDir: '%kernel.project_dir%' $environment: '%kernel.environment%' - $runtimeContributions: '@App\Core\Package\PackageRuntimeContributionRegistry' + $runtimeContributions: '@App\Core\Extension\ExtensionRuntimeContributionRegistry' - App\Core\Package\PackageRuntimeFailureHandler: + App\Core\Extension\ExtensionRuntimeFailureHandler: arguments: $environment: '%kernel.environment%' @@ -338,17 +383,20 @@ services: arguments: $providers: !tagged_iterator { tag: system.event_hook_provider } - App\Core\Package\ActivePackageAssetProviderInterface: - alias: App\Core\Package\ActivePackageAssetProvider + App\Core\Extension\ActiveExtensionAssetProviderInterface: + alias: App\Core\Extension\ActiveExtensionAssetProvider + + App\Core\Extension\ActiveExtensionProviderInterface: + alias: App\Core\Extension\ActiveExtensionProvider - App\Core\Package\ActivePackageProviderInterface: - alias: App\Core\Package\ActivePackageProvider + App\Core\Extension\ExtensionLifecycleAssetRebuilderInterface: + alias: App\Core\Extension\ExtensionLifecycleAssetRebuilder - App\Core\Package\PackageLifecycleAssetRebuilderInterface: - alias: App\Core\Package\PackageLifecycleAssetRebuilder + App\Core\Extension\ExtensionLifecycleCleanupRunnerInterface: + alias: App\Core\Extension\ExtensionLifecycleCleanupRunner - App\Core\Package\PackageLifecycleCleanupRunnerInterface: - alias: App\Core\Package\PackageLifecycleCleanupRunner + App\Core\Extension\ExtensionActivationContributionApplierInterface: + alias: App\Core\Extension\ExtensionActivationContributionApplier App\Core\Messenger\DeferredMessengerDrainStarterInterface: alias: App\Core\Messenger\DeferredMessengerDrainProcessStarter @@ -372,7 +420,33 @@ services: alias: App\Core\Log\AccessLogger App\Core\Geo\GeoIpResolverInterface: - alias: App\Core\Geo\NullGeoIpResolver + alias: App\Core\Geo\GeoIpResolver + + App\Core\Geo\GeoIpResolver: + arguments: + $providers: !tagged_iterator { tag: system.geoip_provider } + $fallbackProvider: '@App\Core\Geo\NullGeoIpProvider' + + App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface: + alias: App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory + + App\Core\Geo\MaxMindGeoIpDownloadClientInterface: + alias: App\Core\Geo\HttpMaxMindGeoIpDownloadClient + + App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface: + alias: App\Core\Geo\MaxMindGeoIpArchiveExtractor + + App\Core\Geo\HttpMaxMindGeoIpDownloadClient: + arguments: + $httpClient: null + + App\Core\Geo\MaxMindGeoIpProvider: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Core\Geo\MaxMindGeoIpDatabaseUpdater: + arguments: + $projectDir: '%kernel.project_dir%' App\Core\Log\OperationLoggerInterface: alias: App\Core\Log\OperationLogger @@ -407,6 +481,10 @@ services: $cacheDir: '%kernel.project_dir%/var/cache' $environment: '%kernel.environment%' + App\Security\Abuse\AbuseSubjectResolver: + arguments: + $secret: '%kernel.secret%' + App\Core\Security\SecretPayloadProtector: arguments: $secret: '%kernel.secret%' @@ -467,7 +545,7 @@ services: arguments: $maintenanceEnabled: '%env(bool:APP_MAINTENANCE)%' - App\View\SystemPackageMetadataProvider: + App\View\SystemExtensionMetadataProvider: arguments: $projectDir: '%kernel.project_dir%' @@ -475,7 +553,7 @@ services: arguments: $debug: '%kernel.debug%' - App\View\Template\PackageTemplatePathResolver: + App\View\Template\ExtensionTemplatePathResolver: arguments: $projectDir: '%kernel.project_dir%' @@ -511,6 +589,8 @@ services: App\View\Http\HttpErrorRenderer: arguments: + $projectDir: '%kernel.project_dir%' + $environment: '%kernel.environment%' $debug: '%kernel.debug%' App\View\Http\HttpErrorSubscriber: diff --git a/dev/CLASSMAP.md b/dev/CLASSMAP.md index 8bbacd39..654950cb 100755 --- a/dev/CLASSMAP.md +++ b/dev/CLASSMAP.md @@ -1,7 +1,7 @@ # Developer Class Map > **Status**: Active -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** This document tracks callable entry points (services, commands, controllers, Twig components, Stimulus controllers). Keep it up to date as new classes are added or interfaces change. This document is meant to evolve alongside the codebase—treat it as a living index for developers to quickly discover callables without grepping through the project. @@ -24,8 +24,8 @@ | Value object | `App\Core\Manifest\ManifestSpec` | Domain-neutral specification for required and allowed manifest keys. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Manifest/ManifestSpecTest.php`, `tests/Core/Manifest/ManifestValidatorTest.php` | | Service | `App\Core\Manifest\ManifestValidator` | Validates parsed manifests against a supplied manifest specification with debug success messages. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Manifest/ManifestValidatorTest.php` | | Value object | `App\Core\Filesystem\FileInventory` | Value object for sorted relative file and directory inventories. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Filesystem/FileInventoryScannerTest.php` | -| Service | `App\Core\Filesystem\FileInventoryScanner` | Reusable bounded-depth filesystem scanner for packages, imports, exports, and debug tools with platform-neutral root normalization and POSIX-style relative inventory output. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Filesystem/FileInventoryScannerTest.php`, `tests/Core/Package/PackageValidatorTest.php` | -| Service | `App\Core\Filesystem\PathGuard` | Shared relative-path normalizer, traversal guard, root joiner, and symlink ancestor detector for filesystem operations scoped to a root. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Filesystem/PathGuardTest.php`, `tests/Core/Package/PackageValidatorTest.php` | +| Service | `App\Core\Filesystem\FileInventoryScanner` | Reusable bounded-depth filesystem scanner for extensions, imports, exports, and debug tools with platform-neutral root normalization and POSIX-style relative inventory output. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Filesystem/FileInventoryScannerTest.php`, `tests/Core/Extension/ExtensionValidatorTest.php` | +| Service | `App\Core\Filesystem\PathGuard` | Shared relative-path normalizer, traversal guard, root joiner, and symlink ancestor detector for filesystem operations scoped to a root. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Filesystem/PathGuardTest.php`, `tests/Core/Extension/ExtensionValidatorTest.php` | | Services | `App\Database\TablePrefix`, `App\Database\PrefixedConnection`, `App\Database\DoctrineTablePrefixListener`, `App\Database\DatabaseReadyState` | Central APP_DATABASE_PREFIX support for known Studio tables across DBAL table helpers, raw DBAL SQL, Doctrine ORM metadata, join tables, Messenger, migrations, setup seeding, setup password recovery, and prefixed migration object names; suppresses normal app DB connections until setup is completed or an explicit setup command allows them. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Database/PrefixedConnectionTest.php`, `tests/Operations/SqliteMigrationTest.php` | | Value object | `App\Core\ActionLog\ActionLog` | Immutable collection for operation step entries with warning and failure summaries. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/ActionLog/ActionLogTest.php` | | Value object | `App\Core\ActionLog\ActionLogEntry` | Value object for a setup, dry-run, installer, backup, or update action step with timing, issues, messages, and context. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/ActionLog/ActionLogTest.php` | @@ -48,32 +48,33 @@ | Interface | `App\Core\Operation\OperationActionInterface` | Contract for executable operation actions that can also expose a dry-run preview. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/OperationExecutorTest.php` | | Value object | `App\Core\Operation\OperationExecution` | Value object containing an operation action log and aggregate workflow result. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/OperationExecutorTest.php` | | Service | `App\Core\Operation\OperationExecutor` | Bridges dry-run planning and action execution with ActionLog output, result message aggregation, highest-severity result aggregation, and exception mapping. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/OperationExecutorTest.php` | -| Services | `App\Core\Operation\Live\LiveOperationRunStore`, `App\Core\Operation\Live\LiveOperationRunCreator`, `App\Core\Operation\Live\LiveOperationRunStorage`, `App\Core\Operation\Live\LiveOperationRunProgressWriter`, `App\Core\Operation\Live\LiveOperationRunPresenter`, `App\Core\Operation\Live\LiveOperationRunLifecycle`, `App\Core\Operation\Live\LiveOperationRunnerSupervisor`, `App\Core\Operation\Live\LiveOperationRunnerProcessInspector`, `App\Core\Operation\Live\LiveOperationRunLock`, `App\Core\Operation\Live\LiveOperationQueueFactory`, `App\Core\Operation\Live\LiveOperationStarter`, `App\Core\Operation\Live\LiveOperationQueueProviderInterface`, package live-operation providers, `App\Core\Operation\Live\AclGroupApplyLiveOperationProvider` | File-backed live-operation foundation for staging ActionQueue runs under `var/operations/{APP_ENV}`, resolving queue providers including package operations and ACL group applies, starting a platform-aware detached console runner with PID tracking, serializing live runner execution through Symfony Lock plus a separate admin-visible runner-state file, listing transient run summaries for Admin Operations, cleaning expired runs, exposing token-protected ActionLog polling state under `/api/live/operations/**`, and exposing operator continuations for review-required runs. Creation, JSON storage, progress mutation, report shaping, stale-state/retention cleanup, runner lock supervision, and PID/process inspection are split into focused services behind the public run-store facade. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Operation/LiveOperationRunStoreTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/LiveOperationControllerTest.php`, `tests/Controller/BackendControllerTest.php` | -| Service/contract/controller | `App\Live\LiveEndpointDefinition`, `App\Live\LiveEndpointProviderInterface`, `App\Live\LiveEndpointHandlerInterface`, `App\Live\LiveEndpointRegistry`, `App\Live\LiveEndpointHandlerRegistry`, `App\Live\PackageLiveEndpointPath`, `App\Core\Package\PackageLiveContributionGuard`, `App\Core\Package\PackagePathPatternScope`, `App\Controller\LiveEndpointController` | Provides package-owned GET-only `/api/live/{package_slug}/...` endpoint registration for lightweight polling/manual live interactions, validates non-empty package live resource paths, path patterns, and handler keys below package-owned namespaces without top-level alternation escapes, rechecks the dispatched route slug against the endpoint owner, reserves system live slugs such as `alerts` and `operations`, defaults endpoints to public reads unless a definition declares a higher minimum access level, prefers exact endpoint paths before broad pattern matches, and supports `next_poll_ms: 0` manual poll responses plus explicit `live-poll#poll`/`live-poll#refresh` one-shot triggers through the shared frontend poller. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php`, `tests/Live/LiveEndpointRegistryTest.php` | +| Services | `App\Core\Operation\Live\LiveOperationRunStore`, `App\Core\Operation\Live\LiveOperationRunCreator`, `App\Core\Operation\Live\LiveOperationRunStorage`, `App\Core\Operation\Live\LiveOperationRunProgressWriter`, `App\Core\Operation\Live\LiveOperationRunPresenter`, `App\Core\Operation\Live\LiveOperationRunLifecycle`, `App\Core\Operation\Live\LiveOperationRunnerSupervisor`, `App\Core\Operation\Live\LiveOperationRunnerProcessInspector`, `App\Core\Operation\Live\LiveOperationRunLock`, `App\Core\Operation\Live\LiveOperationQueueFactory`, `App\Core\Operation\Live\LiveOperationStarter`, `App\Core\Operation\Live\LiveOperationQueueProviderInterface`, extension live-operation providers, `App\Core\Operation\Live\AclGroupApplyLiveOperationProvider` | File-backed live-operation foundation for staging ActionQueue runs under `var/operations/{APP_ENV}`, resolving queue providers including extension operations and ACL group applies, starting a platform-aware detached console runner with PID tracking, serializing live runner execution through Symfony Lock plus a separate admin-visible runner-state file, listing transient run summaries for Admin Operations, cleaning expired runs, exposing token-protected ActionLog polling state under `/api/live/operations/**`, and exposing operator continuations for review-required runs. Creation, JSON storage, progress mutation, report shaping, stale-state/retention cleanup, runner lock supervision, and PID/process inspection are split into focused services behind the public run-store facade. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Operation/LiveOperationRunStoreTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/LiveOperationControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service/contract/controller | `App\Live\LiveEndpointDefinition`, `App\Live\LiveEndpointProviderInterface`, `App\Live\LiveEndpointHandlerInterface`, `App\Live\LiveEndpointRegistry`, `App\Live\LiveEndpointHandlerRegistry`, `App\Live\ExtensionLiveEndpointPath`, `App\Core\Extension\ExtensionLiveContributionGuard`, `App\Core\Extension\ExtensionPathPatternScope`, `App\Controller\LiveEndpointController` | Provides extension-owned GET-only `/api/live/{extension_slug}/...` endpoint registration for lightweight polling/manual live interactions, validates non-empty extension live resource paths, endpoint owners, path patterns, and handler keys below extension-owned namespaces without top-level alternation escapes, rechecks the dispatched route slug against the endpoint owner, reserves system live slugs such as `alerts` and `operations`, defaults endpoints to public reads unless a definition declares a higher minimum access level, prefers exact endpoint paths before broad pattern matches, and supports `next_poll_ms: 0` manual poll responses plus explicit `live-poll#poll`/`live-poll#refresh` one-shot triggers through the shared frontend poller. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Extension/ExtensionLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php`, `tests/Live/LiveEndpointRegistryTest.php` | | Operation action | `App\Core\Operation\Filesystem\CopyFileAction` | Root-scoped operation action for copying files between source and target roots with dry-run diff previews and overwrite protection. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/FilesystemOperationActionTest.php` | | Operation action | `App\Core\Operation\Filesystem\EnsureDirectoryAction` | Root-scoped operation action for creating missing directories with ActionLog context. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/FilesystemOperationActionTest.php` | | Operation action | `App\Core\Operation\Filesystem\RemovePathAction` | Root-scoped operation action for removing files or directories with symlink and Windows directory-link guardrails. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/FilesystemOperationActionTest.php` | | Operation action | `App\Core\Operation\Filesystem\WriteFileAction` | Root-scoped operation action for writing files with parent-directory creation, dry-run diffs, and overwrite protection. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/FilesystemOperationActionTest.php` | | Operation action | `App\Core\Operation\Process\RunCommandAction`, `App\Core\Operation\Process\PhpCliUnavailableAction` | Operation actions for running argument-list commands with dry-run metadata, exit-code mapping, optional warning-only failures, CLI-process environment filtering, and output excerpts, or for failing command queues with a clear Message when no PHP CLI binary can be resolved. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Operation/RunCommandActionTest.php`, `tests/Core/Operation/PhpCliUnavailableActionTest.php` | -| Service/value object | `App\Core\Process\PhpCliBinaryManager`, `App\Core\Process\PhpCliBinaryResolver`, `App\Core\Process\PhpCliBinaryValidator`, `App\Core\Process\PhpCliBinaryPreferenceStore`, `App\Core\Process\PhpProjectRequirements`, `App\Core\Process\PhpCliBinaryResolution`, `App\Core\Process\CliProcessEnvironment`, `App\Core\Process\DetachedProcessStarter` | Resolves a real PHP CLI command prefix across web and CLI environments by validating the cached `APP_DEFAULT_PHP_BINARY` preference first, checking safe mode, process support, PHP version, required extensions, project/console readability, and resolver fallbacks, refreshing the preference in controlled setup/operation flows, passing Symfony Dotenv values to child processes while filtering web/CGI request variables, and starting detached background commands through one cross-platform output/PID marker boundary. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/manual/setup-init-snippets.md` | `tests/Core/Process/PhpCliBinaryManagerTest.php`, `tests/Core/Process/PhpCliBinaryResolverTest.php`, `tests/Core/Process/CliProcessEnvironmentTest.php`, `tests/Core/Process/DetachedProcessStarterTest.php`, `tests/Setup/SetupPreflightCheckerTest.php` | +| Service/value object | `App\Core\Process\PhpCliBinaryManager`, `App\Core\Process\PhpCliBinaryResolver`, `App\Core\Process\PhpCliBinaryValidator`, `App\Core\Process\PhpCliBinaryPreferenceStore`, `App\Core\Process\PhpProjectRequirements`, `App\Core\Process\PhpCliBinaryResolution`, `App\Core\Process\CliProcessEnvironment`, `App\Core\Process\DetachedProcessStarter` | Resolves a real PHP CLI command prefix across web and CLI environments by validating the cached `APP_DEFAULT_PHP_BINARY` preference first, checking safe mode, process support, PHP version, required extensions, project/console readability, and resolver fallbacks, refreshing the preference in controlled setup/operation flows, passing Symfony Dotenv values to child processes while filtering web/CGI request variables, and starting detached background commands through one cross-platform output/PID marker boundary with optional inherited file-descriptor cleanup for persistent services. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/manual/setup-init-snippets.md` | `tests/Core/Process/PhpCliBinaryManagerTest.php`, `tests/Core/Process/PhpCliBinaryResolverTest.php`, `tests/Core/Process/CliProcessEnvironmentTest.php`, `tests/Core/Process/DetachedProcessStarterTest.php`, `tests/Setup/SetupPreflightCheckerTest.php` | | Service | `App\Core\Security\SecretPayloadProtector` | Protects reversible secret payloads with context-labeled, versioned `APP_SECRET`-derived encryption material and optional associated data. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Security/SecretPayloadProtectorTest.php`, `tests/Setup/SetupLiveOperationPayloadProtectorTest.php` | -| Service | `App\Core\Asset\AssetRebuildQueueFactory`, `App\Core\Asset\TailwindBuildAction` | Builds the deterministic package-aware asset rebuild queue with package asset sync, translation aggregation, Symfony asset commands, UX Translator warm-cache output, non-blocking UX icon locking, non-blocking Tailwind startup warnings for web-server policy blocks, failing real Tailwind build errors, production compiled-asset cleanup plus AssetMapper compile, and final cache clear. | `dev/manual/frontend-asset-snippets.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Asset/AssetRebuildQueueFactoryTest.php`, `tests/Core/Asset/TailwindBuildActionTest.php` | +| Service | `App\Core\Asset\AssetRebuildQueueFactory`, `App\Core\Asset\TailwindBuildAction` | Builds the deterministic extension-aware asset rebuild queue with extension asset sync, translation aggregation, Symfony asset commands, UX Translator warm-cache output, non-blocking UX icon locking, non-blocking Tailwind startup warnings for web-server policy blocks, failing real Tailwind build errors, production compiled-asset cleanup plus AssetMapper compile, and final cache clear. | `dev/manual/frontend-asset-snippets.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Asset/AssetRebuildQueueFactoryTest.php`, `tests/Core/Asset/TailwindBuildActionTest.php` | | Service/commands | `App\Core\Mercure\MercureBinaryManager`, `App\Core\Mercure\MercureRuntime`, `App\Command\MercureInstallCommand`, `App\Command\MercureStartCommand`, `App\Command\MercureStopCommand`, `App\Command\MercureHealthCommand`, `App\Command\MercureCheckCommand` | Provides optional local Mercure hub tooling with a YAML-configured fixed version, fixed OS/architecture asset names for the Caddy-based prebuilt hub, SHA256-pinned release archive downloads below `var/mercure/{version}`, cache storage below `var/mercure/cache`, Bolt transport storage at `var/mercure/updates.db`, release-provided Caddyfile startup, JWT secrets passed through a protected `var/mercure/mercure.env` file instead of command arguments, non-secret local Caddy/Mercure directives passed through the detached-process environment, relaxed read-only hub reachability diagnostics for `2xx`, `400`, and `401` responses, strict publish-health probes that require a successful authenticated POST, colon-only local listen and configured hub URL normalization for probe URLs, Mercure-fingerprinted public EventSource subscribe health probes, best-effort macOS quarantine release, a `bin/mercure` wrapper, publish self-healing, public-endpoint failure shutdown, read-only diagnostics, PID-first plus exact-binary process detection, OS-aware stop support that waits for the tracked PID and exact-binary fallback processes to disappear, disabled-integration health no-op success, and graceful polling fallback. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.php`, `tests/Command/MercureHealthCommandTest.php` | | Service | `App\Core\Output\JsonOutputRenderer` | Shared raw JSON response renderer for `/api/live/**` UI flows, captcha seeds, polling, and future small JSON endpoints. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Output/JsonOutputRendererTest.php` | -| Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides a package-extendable cookie consent registry with duplicate-name rejection, package-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication, config-controlled availability and CORS handling, request-scoped authenticated or anonymous API context, read-only method gating, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | -| API endpoint registry/documentation | `App\Api\Endpoint\ApiEndpointProviderInterface`, `App\Api\Endpoint\ApiEndpointHandlerInterface`, `App\Api\Endpoint\ApiEndpointDefinition`, `App\Api\Endpoint\ApiEndpointAccessPolicy`, `App\Api\Endpoint\ApiEndpointRegistry`, `App\Api\Endpoint\ApiEndpointHandlerRegistry`, `App\Api\Endpoint\ApiEndpointNavigationBuilder`, `App\Api\Endpoint\CoreApiEndpointProvider`, `App\Api\Endpoint\ApiListQueryParameterDefinition`, `App\Api\Endpoint\PackageApiEndpointPath`, `App\Api\Documentation\OpenApiDocumentFactory`, `App\Controller\ApiEndpointController`, `App\Controller\ApiRootController`, `App\Controller\ApiDocumentationController` | Aggregates domain-owned endpoint definitions and handlers through service tags, enforces public safe-method registration, supports explicit anonymous read opt-ins and minimum access levels, dispatches exact paths before broad pattern matches, exposes navigable API root/parent resources with access metadata, and generates OpenAPI 3.2 documents with manifest metadata, server entries, shell/domain tag hierarchy, neutral `x-access` operation metadata, shared schemas, error responses, and trace-header documentation. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Documentation/OpenApiDocumentFactoryTest.php`, `tests/Api/Endpoint/ApiEndpointAccessPolicyTest.php`, `tests/Api/Endpoint/ApiEndpointNavigationBuilderTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryWiringTest.php`, `tests/Controller/ApiFoundationControllerTest.php` | -| Admin/settings API | `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel` | Provides navigable admin API endpoints under `/api/v1/admin`, endpoint permission matrices, settings-section read/update models through the existing settings form handler, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, and package lifecycle review/confirmation endpoints that start LiveOperation runs. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php` | +| Service | `App\Core\Routing\PathScopeMatcher`, `App\Core\Routing\RequestPathResolver`, `App\Core\Routing\IgnorableRequestPathMatcher` | Shared segment-bound path-scope matchers for raw technical route scopes, request-aware URL locale-prefix stripping only for locale-prefix UI/account scopes, and static/tooling/well-known request paths that should not spend rate-limit budget, access-statistics rows, active-ban checks, or passive error-status Security signals, so protected prefixes such as `/api/v1`, `/cron`, generated assets, profiler, toolbar paths, access-log surfaces, request intents, and abuse-subject workflows cannot accidentally match lookalike public content paths or localized non-technical aliases. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Routing/PathScopeMatcherTest.php`, `tests/Core/Routing/RequestPathResolverTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | +| Service/contract/controller/Twig | `App\Privacy\Cookie\CookieConsentDefinition`, `App\Privacy\Cookie\CookieConsentProviderInterface`, `App\Privacy\Cookie\CookieConsentRegistry`, `App\Privacy\Cookie\CookieConsentManager`, `App\Privacy\Cookie\ConsentCookieJar`, `App\Privacy\Cookie\CookieConsentResponseSubscriber`, `App\Privacy\Cookie\CookieConsentTwigExtension`, `App\Controller\CookieConsentController`, `templates/components/CookieConsent.html.twig`, `assets/controllers/cookie_consent_controller.js` | Provides an extension-extendable cookie consent registry with duplicate-name rejection, extension-load duplicate/core-cookie collision faulting, HTTP(S)/relative-only optional-cookie privacy links, central safe cookie get/set gate with registered cookie identity and policy-attribute enforcement, very-late response-time removal of registered optional cookies without stored consent while preserving explicit clear-cookie headers, explicit expiration of every rejected optional cookie, DNT/GPC-aware defaults, visitor-bound stateless HMAC CSRF protection that does not create anonymous sessions, signed TTL-validated system-owned consent-cookie persistence, selected optional-cookie state for later edits, safe relative-only consent redirects, reusable `cookie_consent_trigger_attributes()` links, and a frontend banner/overlay that only auto-opens when optional cookies are registered without stored consent. | `dev/draft/0.2.x-SecurityAccessControl.md`, `docs/**` | `tests/assets/controller_foundation.test.mjs`, `tests/Privacy/Cookie/CookieConsentManagerTest.php`, `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php` | +| API foundation/security | `App\Api\ApiFeaturePolicy`, `App\Api\Security\ApiAccessGuard`, `App\Api\Security\ApiKeyCredentialResolver`, `App\Api\Security\ApiKeyAuthenticator`, `App\Api\Security\ApiSecurityHandler`, `App\Api\Security\ApiAvailabilityCheckerInterface`, `App\Api\Security\DatabaseApiAvailabilityChecker`, `App\Api\Security\ApiAvailabilitySubscriber`, `App\Api\Security\ApiMaintenanceModeSubscriber`, `App\Api\Security\ApiDatabaseExceptionSubscriber`, `App\Api\Security\ApiUnavailableResponder`, `App\Api\Security\ApiEndpointAccessSubscriber`, `App\Api\Security\ApiEndpointPermissionSubscriber`, `App\Api\Security\ApiReadOnlyMethodSubscriber`, `App\Api\Security\ApiContentTypeSubscriber`, `App\Api\Security\ApiCorsSubscriber`, `App\Api\Security\ApiRequestMethodPolicy`, `App\Api\Http\ApiResponder`, `App\Api\Http\ApiRequestContext`, `App\Api\Http\ApiJsonRequestParser`, `App\Api\Http\ApiListQueryNormalizer`, `App\Api\Http\ApiTraceHeaderSubscriber` | Provides the versioned `/api/v1` runtime boundary with optional stateless Bearer API-key authentication through a shared HMAC-backed credential resolver, config-controlled availability and CORS handling that keeps anonymous preflights cheap while letting actual `Authorization` preflights reach rate-limit handling by requested method, request-scoped authenticated or anonymous API context, shared effective-method resolution for credentialed/read-only/CORS preflight decisions, endpoint-derived minimum-access checks, JSON content-type enforcement, setup/maintenance/database/disabled `503` JSON responses, trace headers, localized Message-layer data/error responses, JSON object request parsing, and shared list query normalization. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Http/ApiResponderTest.php`, `tests/Api/Http/ApiListQueryNormalizerTest.php`, `tests/Api/Http/ApiTraceHeaderSubscriberTest.php`, `tests/Api/Security/ApiRequestMethodPolicyTest.php`, `tests/Api/Security/ApiAvailabilitySubscriberTest.php`, `tests/Api/Security/ApiMaintenanceModeSubscriberTest.php`, `tests/Api/Security/ApiEndpointAccessSubscriberTest.php`, `tests/Api/Security/ApiEndpointPermissionSubscriberTest.php`, `tests/Api/Security/ApiReadOnlyMethodSubscriberTest.php`, `tests/Api/Security/ApiContentTypeSubscriberTest.php`, `tests/Api/Security/ApiCorsSubscriberTest.php` | +| API endpoint registry/documentation | `App\Api\Endpoint\ApiEndpointProviderInterface`, `App\Api\Endpoint\ApiEndpointHandlerInterface`, `App\Api\Endpoint\ApiEndpointDefinition`, `App\Api\Endpoint\ApiEndpointAccessPolicy`, `App\Api\Endpoint\ApiEndpointRegistry`, `App\Api\Endpoint\ApiEndpointHandlerRegistry`, `App\Api\Endpoint\ApiEndpointNavigationBuilder`, `App\Api\Endpoint\CoreApiEndpointProvider`, `App\Api\Endpoint\ApiListQueryParameterDefinition`, `App\Api\Endpoint\ExtensionApiEndpointPath`, `App\Api\Documentation\OpenApiDocumentFactory`, `App\Api\Documentation\OpenApiComponentsFactory`, `App\Api\Documentation\OpenApiTagFactory`, `App\Controller\ApiEndpointController`, `App\Controller\ApiRootController`, `App\Controller\ApiDocumentationController` | Aggregates domain-owned endpoint definitions and handlers through service tags, enforces public safe-method registration, supports explicit anonymous read opt-ins and minimum access levels, dispatches exact paths before broad pattern matches, exposes navigable API root/parent resources with access metadata, and generates OpenAPI 3.2 documents with manifest metadata, server entries, shell/domain tag hierarchy, neutral `x-access` operation metadata, shared schemas, error responses, and trace-header documentation. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Api/Documentation/OpenApiDocumentFactoryTest.php`, `tests/Api/Endpoint/ApiEndpointAccessPolicyTest.php`, `tests/Api/Endpoint/ApiEndpointNavigationBuilderTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryTest.php`, `tests/Api/Endpoint/ApiEndpointRegistryWiringTest.php`, `tests/Controller/ApiFoundationControllerTest.php` | +| Admin/settings API | `App\Api\Admin\AdminApiEndpointProvider`, `App\Api\Admin\AdminApiIndexHandler`, `App\Api\Admin\AdminPermissionMatrixApiHandler`, `App\Api\Admin\AdminPermissionMatrixReadModel`, `App\Api\Admin\AdminOperationalApiEndpointProvider`, `App\Api\Admin\AdminDeferredApiHandler`, `App\Api\Admin\AdminLogApiHandler`, `App\Api\Admin\AdminOperationApiHandler`, `App\Api\Admin\AdminSchedulerApiHandler`, `App\Api\Admin\AdminStatisticsApiHandler`, `App\Api\Admin\AdminThemeApiHandler`, `App\Api\Admin\LiveOperationApiResourceFactory`, `App\Core\Config\Api\SettingsApiEndpointProvider`, `App\Core\Config\Api\SettingsApiHandler`, `App\Core\Config\Api\SettingsApiReadModel` | Provides navigable admin API endpoints under `/api/v1/admin`, endpoint permission matrices, settings-section read/update models through the existing settings form handler and Admin ACL feature states, secret-redacted settings API output, log-source read models, live-operation detail/continuation resources with status/continue/confirm links, confirm-gated operation maintenance actions, scheduler task detail/history/update/run-now endpoints, and Admin ACL-gated extension lifecycle review/confirmation endpoints that start LiveOperation runs only after the caller is authorized. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Api/Admin/LiveOperationApiResourceFactoryTest.php` | | Content API | `App\Content\Api\ContentApiEndpointProvider`, `App\Content\Api\ContentApiNavigationHandler`, `App\Content\Api\ContentApiPath`, `App\Content\Api\ContentApiItemListQuery`, `App\Content\Api\ContentApiVisibleItemPager`, `App\Content\Api\ContentApiItemReadModel`, `App\Content\Api\ContentApiItemHandler`, `App\Content\Api\ContentApiMutationStubHandler`, `App\Content\Api\SchemaApiEndpointProvider`, `App\Content\Api\SchemaApiReadModel`, `App\Content\Api\SchemaApiHandler` | Provides collision-free content API dynamic resources below `items/`, ACL-aware published content navigation/items/detail paths with child, variant, and revision navigation, query-backed published content collection pagination/filtering/sorting after ACL filtering, deferred non-published status read surfaces, deferred content mutation command stubs, and author-level schema metadata including custom Twig. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/ApiContentSchemaControllerTest.php`, `tests/Controller/ApiContentItemControllerTest.php` | -| Package/user API | `App\Core\Package\Api\PackageApiEndpointProvider`, `App\Core\Package\Api\PackageApiHandler`, `App\Core\Package\Api\PackageApiNavigationHandler`, `App\Core\Package\Api\PackageApiReadModel`, package API contributions through `App\Core\Package\PackageContributions` and `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackagePathPatternScope`, `App\Security\Api\SelfServiceApiEndpointProvider`, `App\Security\Api\SelfServiceApiHandler`, `App\Security\Api\SelfServiceApiReadModel`, `App\Security\Api\UserApiEndpointProvider`, `App\Security\Api\UserApiHandler`, `App\Security\Api\UserGroupApiHandler`, `App\Security\Api\UserGroupApiReadModel`, `App\Security\Api\UserGroupMembershipApiHandler`, `App\Security\Api\UserReviewApiHandler`, `App\Security\Api\UserApiReadModel` | Provides package-owned endpoint/handler contributions below `/api/v1/packages/{package_slug}/...`, package read/navigation resources, package API path-pattern validation below the owned package namespace without top-level alternation escapes, user-facing self-service profile and own API-key resources with prefix validation before key material is generated, user detail updates for one role plus multiple groups, ACL group CRUD with impact review and optional LiveOperation execution, membership relationship mutations, registration/invitation approval/reissue/denial actions, and disputed-account security-review confirm/deny actions. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/ApiPackageControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageApiContributionGuardTest.php` | +| Extension/user API | `App\Core\Extension\Api\ExtensionApiEndpointProvider`, `App\Core\Extension\Api\ExtensionApiHandler`, `App\Core\Extension\Api\ExtensionApiNavigationHandler`, `App\Core\Extension\Api\ExtensionApiReadModel`, extension API contributions through `App\Core\Extension\ExtensionContributions` and `App\Core\Extension\ExtensionRuntimeContributionRegistry`, `App\Core\Extension\ExtensionPathPatternScope`, `App\Security\Api\SelfServiceApiEndpointProvider`, `App\Security\Api\SelfServiceApiHandler`, `App\Security\Api\SelfServiceApiReadModel`, `App\Security\Api\UserApiEndpointProvider`, `App\Security\Api\UserApiHandler`, `App\Security\Api\UserGroupApiHandler`, `App\Security\Api\UserGroupApiReadModel`, `App\Security\Api\UserGroupMembershipApiHandler`, `App\Security\Api\UserReviewApiHandler`, `App\Security\Api\UserApiReadModel` | Provides extension-owned endpoint/handler contributions below `/api/v1/extensions/{extension_slug}/...`, extension read/navigation resources, extension API owner and path-pattern validation below the owned extension namespace without top-level alternation escapes, user-facing self-service profile and own API-key resources with prefix validation before key material is generated, user detail updates for one role plus multiple groups, ACL group CRUD with impact review and optional LiveOperation execution, membership relationship mutations, registration/invitation approval/reissue/denial actions, and disputed-account security-review confirm/deny actions. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/ApiExtensionControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php`, `tests/Core/Extension/ExtensionApiContributionGuardTest.php` | | Service | `App\Core\Lint\CssLinter` | Reusable string-based CSS syntax linter using the strict Sabberworm CSS parser. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Service | `App\Core\Lint\JavaScriptLinter` | Reusable string-based JavaScript module syntax linter using Peast. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Service | `App\Core\Lint\JsonLinter` | Reusable string-based JSON syntax linter. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Value object | `App\Core\Lint\LintIssue` | Value object for reusable lint diagnostics. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Value object | `App\Core\Lint\LintResult` | Value object for reusable lint success and diagnostic results. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | -| Interface | `App\Core\Lint\LinterInterface` | Shared contract for content-based linters that can be reused by packages, editors, and debug tools. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | +| Interface | `App\Core\Lint\LinterInterface` | Shared contract for content-based linters that can be reused by extensions, editors, and debug tools. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Service | `App\Core\Lint\PhpLinter` | Reusable string-based PHP syntax linter backed by `php -l` through a temporary file. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | -| Service | `App\Core\Lint\TwigLinter` | Reusable string-based Twig syntax linter using Twig's parser with placeholder callbacks for runtime filters, functions, and tests supplied by Symfony, Studio, themes, or active packages. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | +| Service | `App\Core\Lint\TwigLinter` | Reusable string-based Twig syntax linter using Twig's parser with placeholder callbacks for runtime filters, functions, and tests supplied by Symfony, Studio, themes, or active extensions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | | Service | `App\Core\Lint\YamlLinter` | Reusable string-based YAML syntax linter using Symfony YAML. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Lint/LinterTest.php` | ## 2. Security Services and Subscribers @@ -86,6 +87,7 @@ | Enum | `App\Core\Access\AccessLevel` | Shared 0-9 access-level constants and validation for public, user, moderator, author, publisher, curator, manager, director, admin, and owner role tiers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\Access\AccessResolver` | Resolves inherited level-plus-group ACL rules for actors and capabilities. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php` | | Value object | `App\Core\Access\AccessRule` | Value object for explicit or inherited min-level plus group ACL rules, including the shared stored-group identifier normalization used by content, schema, and menu ACL fields. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/Access/AccessResolverTest.php`, `tests/Core/Access/AccessRuleTest.php` | +| Admin ACL registry/policy | `App\Core\AdminAcl\AdminFeatureProviderInterface`, `App\Core\AdminAcl\CoreAdminFeatureProvider`, `App\Core\AdminAcl\AdminFeatureDefaults`, `App\Core\AdminAcl\AdminFeatureRegistry`, `App\Core\AdminAcl\AdminFeatureDefinition`, `App\Core\AdminAcl\AdminFeatureAccessPolicy`, `App\Core\AdminAcl\AdminFeatureOverrideStore`, `App\Core\AdminAcl\AdminAclSettingsFormHandler`, `App\Core\AdminAcl\AdminPermissionSurface`, `App\Core\AdminAcl\AdminPermissionState`, `App\Api\Admin\AdminFeatureApiGuard`, `App\Backend\AdminOperationFeatureResolver` | Provides the lightweight domain-provider registry for thematic Admin/Editor/Frontend feature flags, seeded configurable defaults under `acl.admin.features`, Owner-gated override persistence, cache-backed registry/override/group matrix reads with explicit invalidation hooks, Admin-surface level gates, explicit ACL-group states that can grant or restrict after the surface gate, denied/visible/mutable state evaluation, API-key-owner feature checks, operation-continuation target-feature resolution, and redacted matrix-change audit summaries used by settings forms, backend actions, extension/theme UI/API callers, user/ACL/review workflows, scheduler/operations/log APIs, dynamic extension settings, and the `Settings/ACL` matrix. | `dev/draft/security-hardening/admin-acl-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Core/AdminAcl/AdminFeatureAccessPolicyTest.php`, `tests/Core/AdminAcl/AdminFeatureCacheTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/ApiExtensionControllerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/ApiUserControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php` | | Interface | `App\Security\AccessLevelAwareUserInterface` | Symfony user bridge for security users that expose the project's role-derived ACL access level. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Enum | `App\Security\UserRole` | Global account role enum that maps one exact user role to inherited Symfony roles and numeric ACL access levels. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserGroupMembershipManager`, `App\Security\AccountReactivationAccessResolver` | Shared user-group membership and deleted-account reactivation helpers used by admin and account-link flows so controllers do not duplicate group replacement, existing-group filtering, or role-preserving reactivation decisions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | @@ -94,22 +96,22 @@ | Security checker | `App\Security\UserAccountChecker` | Symfony form-login user checker that rejects inactive or deleted `UserAccount` records before authentication can create a session. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/SecurityControllerTest.php` | | Event subscriber | `App\Security\AppSecretRotationGuard` | Rejects unsupported short runtime `APP_SECRET` values before recovery handling, stores an environment-specific secret fingerprint, baselines first-seen secrets, and on detected rotation revokes active API keys while issuing password-reset links to active owners through the account-link delivery boundary; local Mercure hubs are stopped/refreshed around rotation when possible and marked unavailable if they cannot be safely stopped. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/AppSecretRotationGuardTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Service | `App\Security\UserAccountLifecycle`, `App\Security\AdminUserAssignmentOptions`, `App\Security\AdminUserAccountUpdateService`, `App\Security\AdminUserAccountUpdateResult`, `App\Security\AdminUserPasswordResetService`, `App\Security\UserPasswordChangeService`, `App\Security\UserPasswordChangeResult`, `App\Security\UserAccountClosureService`, `App\Security\UserAccountClosureResult`, `App\Security\PasswordPolicy`, `App\Security\PasswordPolicyErrorMapper` | Applies account status changes, records current/last status state markers, revokes active API keys plus pending password-reset/security-review tokens when accounts become inactive or deleted, keeps admin assignment option filtering, account update mutations, deleted-account status changes, admin password-reset creation, authenticated password-change review-token delivery, and self-service account closure outside controllers, enforces the shared account password policy across setup, registration, reset, and profile changes, and maps policy violations to stable user-facing error keys outside controllers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php`, `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Security/PasswordPolicyTest.php`, `tests/Security/PasswordPolicyErrorMapperTest.php` | -| Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, account-link TTL, profile username-change availability, validated notification recipients, and deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service | `App\Security\UserFlowConfig`, `App\Security\DeletedUserCleanup` | Reads database-backed user-flow settings for the system login menu, bounds menu sort order, disabled/admin-approval/auto-approval registration mode, optional default ACL group, bounded account-link TTL, profile username-change availability, validated notification recipients, and bounded deleted-user retention; the cleanup service lists retained deleted accounts, reassigns their revoked API keys to the stable hidden deleted-user account, and permanently removes entries older than the configured retention for admin and future scheduler use. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Config/ConfigTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/BackendControllerTest.php` | | Event subscriber | `App\Security\MaintenanceModeSubscriber` | Enforces the environment-backed `APP_MAINTENANCE` flag by returning `503` for public requests while allowing admin-or-higher users plus admin, login, and asset bypass paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Security/MaintenanceModeSubscriberTest.php` | | Service | `App\Backend\BackendAccessGuard` | Converts the current Symfony user into an access actor and checks backend area access through the shared ACL resolver. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | -| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed package discovery, asset rebuild dispatch, and cache clearing, with shared CSRF validation, translated flashes, JSON operation-start responses, and audit logging for controller adapters. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | -| Services | `App\Backend\PackageLifecycleAdmin`, `App\Backend\PackageAdminDetailProvider`, `App\Backend\PackageAdminFileReader`, `App\Backend\PackageAdminLinkResolver`, `App\Backend\PackageDependencyLabelParser`, `App\Backend\PackageLifecycleReviewProvider`, `App\Backend\PackageLifecycleActionHandler` | Provides a small package lifecycle admin facade while focused collaborators build package detail read models, read package manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php`, `tests/Backend/PackageAdminLinkResolverTest.php`, `tests/Backend/PackageDependencyLabelParserTest.php` | +| Service | `App\Backend\BackendActions`, `App\Backend\BackendActionResponder`, `App\Form\FormTokenValidator` | Provides admin maintenance actions for synchronous or LiveLog-backed extension discovery, asset rebuild dispatch, cache clearing, and GeoIP database updates, with shared CSRF validation, translated flashes, JSON operation-start responses, audit logging for controller adapters, and optional Admin ACL feature metadata so visible-only actions render disabled while backend execution still rechecks mutability. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php` | +| Services | `App\Backend\ExtensionLifecycleAdmin`, `App\Backend\ExtensionAdminDetailProvider`, `App\Backend\ExtensionAdminFileReader`, `App\Backend\ExtensionAdminLinkResolver`, `App\Backend\ExtensionDependencyLabelParser`, `App\Backend\ExtensionLifecycleReviewProvider`, `App\Backend\ExtensionLifecycleActionHandler` | Provides a small extension lifecycle admin facade while focused collaborators build extension detail read models, read extension manifest/README/preview files, sanitize metadata links, format dependency labels, prepare lifecycle review plans, and apply activation, deactivation, fault reset, purge, or deletion actions; extension lifecycle callers are now gated by the thematic `admin.extensions` Admin ACL feature before UI action rendering, API confirmation, or LiveOperation start. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiExtensionControllerTest.php`, `tests/Backend/ExtensionAdminLinkResolverTest.php`, `tests/Backend/ExtensionDependencyLabelParserTest.php` | | Enum | `App\Backend\BackendArea` | Defines native backend areas, route names, templates, navigation identifiers, and minimum access levels for setup, admin, and editor. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | | Service | `App\Backend\BackendRouteResolver`, `App\Backend\BackendRouteResult` | Resolves native backend area paths through registered backend views without using Doctrine for setup availability and returns renderable results instead of throwing for recoverable route states. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/BackendControllerTest.php` | -| Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider` | Collects core and package-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the first Admin Settings tree plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, database-backed pagination/filtering/sorting, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | -| Controllers | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Own focused Admin package install/detail/lifecycle and Admin Operations maintenance/detail/continuation routes that previously lived in the dynamic backend dispatcher. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | -| Service | `App\Core\Id\UuidFactory`, `App\Core\Operation\Live\LiveOperationHttpResponder` | Generates Symfony UID-backed UUIDv7 identifiers for controller-created records and renders LiveLog operation start responses with follow-up status URLs. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php` | +| Registry | `App\Backend\BackendViewRegistry`, `App\Backend\BackendViewDefinition`, `App\Backend\BackendViewProviderInterface`, `App\Backend\CoreBackendViewProvider`, `App\Backend\CoreAdminSettingsBackendViewProvider` | Collects core and extension-provided backend view definitions for fixed admin/editor route targets, templates, menu labels, access levels, groups, sort order, route parameters, and view context, including the Admin Settings tree through a dedicated provider plus administrative placeholders for themes, users, user reviews, scheduler, backups, logs, and the System Information diagnostic view. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | +| Service/value object | `App\Backend\AdminControllerContext`, `App\Backend\AdminViewContextProvider`, `App\Backend\BackendListViewHelper`, `App\View\Twig\AdminSettingsFormViewFactory`, `App\Security\AdminUserListViewFactory`, `App\Security\AdminUserReviewViewFactory`, `App\Security\AdminUserListQuery`, `App\Security\AdminGroupListQuery`, `App\Security\AdminUserReviewQuery` | Provides shared admin access, navigation, audit, admin dynamic-view context for operations/logs/statistics/system information, GeoIP settings status, the Owner-gated `Settings/ACL` matrix view model, ACL-aware operation action states, mutable-only Audit/Security Signal log source exposure, database-backed pagination/filtering/sorting, focused settings/extension form view models for Twig, review-queue view models, and separated request-derived list/review query values for modularized Admin controllers. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | +| Controllers | `App\Controller\AdminExtensionController`, `App\Controller\AdminOperationController`, `App\Controller\AdminUserController`, `App\Controller\AdminAclGroupController`, `App\Controller\AdminUserReviewController`, `App\Controller\AdminUserInvitationController` | Own focused Admin extension install/detail/lifecycle, Operations maintenance/detail/continuation, user management, ACL-group, invitation, and review routes with thematic Admin ACL feature enforcement before mutation or sensitive reveal; visible-only states keep expected UI controls rendered disabled where the workflow layout depends on them. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php`, `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/AdminUserReviewControllerTest.php` | +| Service/value object | `App\Core\Id\UuidFactory`, `App\Core\Validation\IdentifierSpec`, `App\Core\Operation\Live\LiveOperationHttpResponder` | Generates Symfony UID-backed UUIDv7 identifiers for controller-created records, centralizes reusable identifier validation patterns for slugs, dot-path identifiers, snake-case identifiers, handler keys, scheduler-style machine identifiers, database prefixes, ACL group identifiers, and UUIDs, and renders LiveLog operation start responses with follow-up status URLs. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/AdminUserControllerTest.php`, `tests/Controller/UserControllerTest.php`, `tests/Core/Validation/IdentifierSpecTest.php` | | Service | `App\Core\Console\ConsoleResultRenderer` | Renders workflow result issues/messages, JSON payloads, and status/WorkflowResult exit codes for console commands. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Console/ConsoleResultRendererTest.php` | -| Command/service | `App\Command\RenderRouteCommand`, `App\Debug\RouteRenderer`, `App\Debug\RouteRenderOptions`, `App\Debug\RouteRenderResult` | Provides project-wide CLI route rendering through `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, setup-completion, browser-route auth token, and API debug context support. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Command/RenderRouteCommandTest.php` | -| Event subscriber | `App\Backend\BackendNavigationSubscriber` | Adds registered backend view route-target navigation entries through the shared navigation hook so packages can later contribute menu items through the same boundary. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Navigation/NavigationBuilderTest.php` | -| Registry | `App\View\Injection\ViewInjectionRegistry`, `App\View\Injection\StaticViewInjection`, `App\View\Injection\ConfigurableStaticViewInjectionSet`, `App\View\Injection\ConfigurableStaticViewInjectionRoute`, `App\View\Injection\DynamicViewInjection`, `App\View\Injection\DynamicViewInjectionFilter`, `App\View\Injection\ViewSurface`, `App\View\Injection\DynamicViewInjectionSlot` | Collects package-facing static route/menu injections, package-setting-backed configurable static route sets, and content-aware dynamic slot or variant-route injections for the `public`, `admin`, and `editor` surfaces. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.1.x-StaticDynamicContent.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/DemoControllerTest.php` | -| Event payload | `App\View\Injection\Event\StaticViewInjectionRegistryEvent`, `App\View\Injection\Event\DynamicViewInjectionRegistryEvent` | Public mutable hooks that let packages add static and dynamic view injections before route, menu, slot, or variant resolution. | `dev/draft/0.2.x-EventHooksBuses.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Event/PublicEventHookRegistryTest.php`, `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php` | +| Command/service | `App\Command\RenderRouteCommand`, `App\Debug\RouteRenderer`, `App\Debug\RouteRenderOptions`, `App\Debug\RouteRenderResult` | Provides development/test-only CLI route rendering through `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, request headers, response-header output, setup-completion, browser-route auth token, and API debug context support. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Command/RenderRouteCommandTest.php` | +| Event subscriber | `App\Backend\BackendNavigationSubscriber` | Adds registered backend view route-target navigation entries through the shared navigation hook so extensions can later contribute menu items through the same boundary. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Navigation/NavigationBuilderTest.php` | +| Registry | `App\View\Injection\ViewInjectionRegistry`, `App\View\Injection\StaticViewInjection`, `App\View\Injection\ConfigurableStaticViewInjectionSet`, `App\View\Injection\ConfigurableStaticViewInjectionRoute`, `App\View\Injection\DynamicViewInjection`, `App\View\Injection\DynamicViewInjectionFilter`, `App\View\Injection\ViewSurface`, `App\View\Injection\DynamicViewInjectionSlot` | Collects extension-facing static route/menu injections, extension-setting-backed configurable static route sets, and content-aware dynamic slot or variant-route injections for the `public`, `admin`, and `editor` surfaces. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.1.x-StaticDynamicContent.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php`, `extensions/demo-module/tests/Controller/DemoControllerTest.php` | +| Event payload | `App\View\Injection\Event\StaticViewInjectionRegistryEvent`, `App\View\Injection\Event\DynamicViewInjectionRegistryEvent` | Public mutable hooks that let extensions add static and dynamic view injections before route, menu, slot, or variant resolution. | `dev/draft/0.2.x-EventHooksBuses.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Event/PublicEventHookRegistryTest.php`, `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\View\Injection\DynamicViewInjectionRenderer`, `App\View\Injection\StaticViewInjectionNavigationSubscriber` | Renders dynamic public content injections through physical Twig templates, reports failed dynamic injection rendering through the message layer with bounded context, and adds public static injections to navigation while preserving access metadata, parent relationships, and reserved public route prefixes. | `dev/draft/0.1.x-StaticDynamicContent.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Navigation/NavigationBuilderTest.php` | | Event subscriber | `App\Navigation\UserNavigationSubscriber` | Adds config-controlled virtual login/profile navigation roots to the main menu, including authenticated API-key, invitation, studio, admin, and logout children filtered by access metadata and optional link attributes. | `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Navigation/NavigationBuilderTest.php` | | Enum | `App\Security\ApiKeyStatus` | Enum for API key permission status, translated status labels, and simple read/write activity semantics. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Entity/CoreDatabaseModelTest.php` | @@ -122,84 +124,90 @@ | Controller/service | `App\Controller\SchedulerController`, `App\Command\SchedulerRunCommand`, `App\Scheduler\SchedulerRunner`, `App\Scheduler\SchedulerDueTaskSelector`, `App\Scheduler\SchedulerTaskRunRecorder`, `App\Scheduler\SchedulerFailurePolicy`, `App\Scheduler\SchedulerRunReporter`, `App\Scheduler\SchedulerTaskRegistry`, `App\Scheduler\SchedulerTaskSynchronizer`, scheduler executor/provider contracts | Provides the scheduler foundation with `/cron/run` API-key triggering, `bin/scheduler`/`scheduler:run`, registered task definitions, quoted command-target parsing, command/action-queue/callable executor boundaries, database-backed task state, separated due/runnability selection, persistent task-run recording, explicit failure thresholds, run/result reporting, Symfony Lock-backed run locking, failure counters, non-success HTTP/CLI status reporting, and per-task run history. | `dev/draft/0.4.x-Scheduler.md` | `tests/Scheduler/SchedulerRunnerTest.php`, `tests/Scheduler/SchedulerCommandTargetParserTest.php`, `tests/Controller/SchedulerControllerTest.php`, `tests/Command/SchedulerRunCommandTest.php`, `tests/Operations/SchedulerScriptTest.php` | | Controller/entity | `App\Controller\AdminSchedulerController`, `App\Entity\SchedulerTask`, `App\Entity\SchedulerTaskRun`, `App\Scheduler\SchedulerTaskStatus`, `App\Scheduler\SchedulerTaskRunStatus`, `App\Scheduler\SchedulerTaskType` | Renders the Admin scheduler task list/detail views, lets admins activate jobs and edit cron expressions, surfaces manual run-now failures or skips, stores task configuration, and records recent task executions for UI inspection. | `dev/draft/0.4.x-Scheduler.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Scheduler/SchedulerRunnerTest.php`, `tests/Controller/AdminSchedulerControllerTest.php` | -## 3. Configuration, Messages, Packages, and Workflow +## 3. Configuration, Messages, Extensions, and Workflow | Type | Symbol | Purpose | Docs | Tests | |------|--------|---------|------|-------| -| Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered defaults when keys are missing or the database is not ready, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | +| Service | `App\Core\Config\Config`, `App\Core\Config\ConfigDefaultProviderInterface`, `App\Core\Config\ConfigValidationGuard`, `App\Core\Config\Settings\CoreConfigDefaultProvider` | DBAL-backed configuration service with `get()` and `set()` helpers for JSON-encoded global config values, graceful fallback to centrally registered core defaults when keys are missing or the database is not ready, reusable runtime bounds normalization for already-persisted values, and message-backed diagnostics for invalid keys, malformed values, and storage failures. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Config/ConfigTest.php`, `tests/Core/Config/ConfigValidationGuardTest.php`, `tests/Controller/PublicContentLocalizationTest.php` | | Enum | `App\Core\Config\ConfigValueType` | Enum for typed database-backed configuration values. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | -| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, and Security audit policy controls. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Controller/BackendControllerTest.php` | +| Registry/service | `App\Core\Config\Settings\CoreSettingDefinition`, `App\Core\Config\Settings\CoreSettingsRegistry`, `App\Core\Config\Settings\CoreSettingsFormHandler` | Defines known global setting keys, default values, input types, options, validation metadata, admin settings sections, runtime default fallback metadata, setting-level access rules, sensitive setting preservation, and CSRF-protected typed persistence for generated core settings forms, including registration mode, username-change, Security audit/signal policy controls, Log Settings database-retention controls, and Owner-only GeoIP provider configuration. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/security-hardening/geoip-observability.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Core/Config/SettingsApiReadModelTest.php`, `tests/Controller/BackendControllerTest.php` | | Service | `App\Core\Diagnostics\SystemInfoProvider` | Builds the Admin Settings System Information report with current preflight rows, redacted server/PHP/Composer diagnostics through the managed PHP CLI resolver when needed, image-processing capabilities, deterministic loaded-extension output, and reduced PHP configuration data without exposing request, cookie, environment, or secret dumps. | `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | -| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | -| Entity | `App\Entity\PackageSettingEntry` | Doctrine entity for package-scoped settings stored separately from global configuration so purge can remove package-owned values. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | +| Service/model | `App\Form\FormInputType`, `App\Form\FormFieldDefinition`, `App\Form\FormDefinition`, `App\Form\FormBuilder`, `App\Form\FormSubmissionHandler`, `App\Form\FormValueCaster`, `App\Form\FormFieldValidator`, `App\Form\FormErrorKey`, `App\Form\FormSubmissionResult`, `App\Form\Autocomplete\AdminUserAutocomplete`, `App\Form\Autocomplete\AdminAclGroupAutocomplete`, `templates/*/partials/forms/fields/select.html.twig` | Renderer-neutral generated settings/config form definition and submission layer with inferred input types, option metadata, validation attributes, password inputs for sensitive settings, separated typed casting, separated option/value validation, centralized translated validation keys, captcha-provider field support, admin-scoped user/group entity autocomplete fields, and optional Symfony UX Autocomplete select wiring through field metadata or explicit partial parameters on the reserved `/_autocomplete/{alias}` route. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Form/FormBuilderTest.php`, `tests/Form/FormSubmissionHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `php bin/console debug:router ux_entity_autocomplete`, `php bin/console debug:container App\\Form\\Autocomplete\\AdminUserAutocomplete` | +| Entity | `App\Entity\ExtensionSettingEntry` | Doctrine entity for extension-scoped settings stored separately from global configuration so purge can remove extension-owned values. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Extension/ExtensionSettingsTest.php`, `tests/Core/Extension/ExtensionLifecycleCleanupRunnerTest.php` | | Value object | `App\Core\Message\Message` | Universal message value object carrying log level, code, translation key, parameters, and context for logs, output, validation, future localization, and invalid-argument diagnostics. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageTest.php` | -| Registry | `App\Core\Message\MessageCode`, domain-owned `*MessageCode` catalogues | Aggregates core-owned machine-readable message code catalogues while keeping constants close to their owning domains and leaving room for validated package-owned catalogues. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageCodeTest.php` | +| Registry | `App\Core\Message\MessageCode`, domain-owned `*MessageCode` catalogues | Aggregates core-owned machine-readable message code catalogues while keeping constants close to their owning domains and leaving room for validated extension-owned catalogues. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageCodeTest.php` | | Exception | `App\Core\Message\MessageException` | InvalidArgumentException subtype carrying a structured message with log level, code, translation key, parameters, and context for hard invariant boundaries. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageExceptionTest.php` | | Registry | `App\Core\Message\MessageKey`, domain-owned `*MessageKey` catalogues | Aggregates core-owned translation-key catalogues for messages, logs, output, validation, and future localization while enforcing owner/scope naming. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageKeyTest.php` | | Enum | `App\Core\Message\MessageLevel` | Enum for log-filterable message levels: success, exception, error, warning, info, and debug. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Message/MessageTest.php` | -| Interface | `App\Core\Event\PublicEventInterface` | Marker for documented public events that packages may subscribe to as stable extension hooks. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | +| Interface | `App\Core\Event\PublicEventInterface` | Marker for documented public events that extensions may subscribe to as stable extension hooks. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | | Enum | `App\Core\Event\EventHookMode` | Enum for public hook behavior modes: observe, extend, and replace. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | | Value object | `App\Core\Event\EventHookDescriptor` | Descriptor for one surfaced public hook, including event class, domain, mode, summary translation key, mutability, and stoppability. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | -| Service | `App\Core\Event\EventHookDescriptorProviderInterface`, `App\Content\ContentEventHookProvider`, `App\Navigation\NavigationEventHookProvider`, `App\Core\Package\PackageEventHookProvider`, `App\View\ViewEventHookProvider`, `App\View\Injection\ViewInjectionEventHookProvider` | Provider contract and domain-owned native providers for registering public hook descriptors without editing the registry itself. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | -| Service | `App\Core\Event\PublicEventHookRegistry` | Aggregates public package hook descriptors for documentation, tooling, debug output, and future admin/package inspection UI. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | +| Service | `App\Core\Event\EventHookDescriptorProviderInterface`, `App\Content\ContentEventHookProvider`, `App\Navigation\NavigationEventHookProvider`, `App\Core\Extension\ExtensionEventHookProvider`, `App\View\ViewEventHookProvider`, `App\View\Injection\ViewInjectionEventHookProvider` | Provider contract and domain-owned native providers for registering public hook descriptors without editing the registry itself. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | +| Service | `App\Core\Event\PublicEventHookRegistry` | Aggregates public extension hook descriptors for documentation, tooling, debug output, and future admin/extension inspection UI. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventHookRegistryTest.php` | | Service | `App\Core\Event\PublicEventDispatcher`, `App\Core\Event\PublicEventDispatchResult` | Safe public hook dispatcher that rejects unregistered hooks, converts listener failures into structured messages, and emits internal failure diagnostics. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventDispatcherTest.php` | | Event payload | `App\Core\Event\PublicHookFailedEvent` | Internal diagnostic event emitted after a public hook listener failure so lifecycle/logging layers can react without turning deactivation into a dispatcher side effect. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Event/PublicEventDispatcherTest.php` | | Service | `App\Debug\SystemDebugCollector` | Request-local debug collector for public hook dispatches and Twig namespace path resolution, exposed through `APP_DEBUG=1` HTML comments and Twig diagnostics. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | -| Value object | `App\Core\Package\PackageCandidate` | Value object for a discovered manifest-backed package candidate. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageDiscoveryTest.php` | -| Value object | `App\Core\Package\PackageAssetContribution` | Value object for CSS, JavaScript, static asset, and Tailwind-source contributions from active packages. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/Core/Package/PackageAssetRegistryBuilderTest.php` | -| Service | `App\Core\Package\ActivePackageProviderInterface`, `App\Core\Package\ActivePackageProvider` | Central active-package gate for loaders and lifecycle consumers; returns only active real filesystem packages and supports scope filtering for Twig, assets, and future package-aware loaders. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| Service | `App\Core\Package\PackageAdminOverview` | Read model for Admin Package Management rows with a virtual immutable system package fed by the root `.manifest`, registry metadata, status labels, scopes, version details, generic package settings links, and explicit lifecycle action links when available. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Controller/BackendControllerTest.php` | -| Service | `App\Core\Package\ThemeAdminOverview` | Read model for card-based Admin Theme Management sections that list frontend and backend themes together with the immutable native system theme fallback, package detail links, and quick use/active/repair controls. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | -| Interface | `App\Core\Package\ActivePackageAssetProviderInterface` | Contract for reading active packages that participate in asset sync, keeping commands testable and provider failures explicit. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php` | -| Service | `App\Core\Package\ActivePackageAssetProvider` | Adapts central active package records into asset sync packages while keeping the asset pipeline decoupled from the registry entity. | `dev/manual/frontend-asset-snippets.md` | N/A | -| Operation action | `App\Core\Package\PackageAssetSyncAction` | Operation action that mirrors active package assets and rewrites generated package CSS/JS registries. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | -| Value object | `App\Core\Package\PackageAssetSyncPackage` | Value object for one active package participating in asset sync with a safe identifier, directory, and scopes. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | -| Service | `App\Core\Package\PackageAssetSyncer` | Mirrors active package assets through a staged `assets/.packages.tmp-*` directory before replacing `assets/packages/`, dispatches public package asset hooks, rewrites package-authored CSS/JS paths, and rebuilds package asset registries only after registry targets are writable. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | -| Value object | `App\Core\Package\PackageAssetFilesystem` | Shared package-asset filesystem helper for project-relative paths, safe directory checks, temporary-file-backed file replacement, and platform-safe recursive cleanup. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | -| Service | `App\Core\Package\PackageAssetMirror` | Stages active package asset files, swaps the public `assets/packages/` mirror only after staging succeeds, and rewrites package-authored CSS/JS paths during copy. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | -| Service | `App\Core\Package\PackageAssetRegistryWriter` | Ensures ignored CSS and JavaScript package registries exist for clean checkouts, then writes generated registry contents for each asset bucket through temporary-file-backed replacement with all-target preflight and platform-safe rollback. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Package/PackageAssetSyncerTest.php`, `tests/Core/Package/PackageAssetRegistryBuilderTest.php` | -| Service | `App\Core\Package\PackageAssetPathRewriter` | Rewrites package-authored CSS URLs and JavaScript imports from private package source paths to public mirrored package asset paths. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/Core/Package/PackageAssetPathRewriterTest.php` | -| Service | `App\Core\Package\PackageAssetRegistryBuilder` | Builds deterministic generated CSS and JavaScript registries for active package asset buckets. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/Core/Package/PackageAssetRegistryBuilderTest.php` | -| Value object | `App\Core\Package\PackageDiscovery` | Discovers application, scoped package, and cached import manifests from standard package locations. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageDiscoveryTest.php` | -| Service | `App\Core\Package\PackageDiscoveryRunner` | Callable lifecycle entry point that runs package manifest discovery, validation-backed registry synchronization, and trigger-aware result aggregation for admin, installer, scheduler, and CLI flows. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageDiscoveryRunnerTest.php`, `tests/Command/PackageDiscoveryCommandTest.php` | -| Service | `App\Core\Package\PackageDiscoveryDispatcher` | Internal trigger boundary that queues package discovery through Messenger after a prefix-aware messenger-storage readiness check and returns localized operation messages for manual, installer, scheduler, and recovery callers. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageDispatcherTest.php`, `tests/Core/Package/PackageDiscoveryRunnerTest.php`, `tests/Command/PackageDiscoveryCommandTest.php` | -| Messenger message | `App\Core\Package\PackageDiscoveryMessage`, `App\Core\Package\PackageDiscoveryMessageHandler` | Deferred package discovery message and handler; the handler executes the discovery runner outside the triggering request or setup process. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageDiscoveryRunnerTest.php` | -| Event subscriber/service | `App\Core\Messenger\DeferredMessengerDrainSubscriber`, `App\Core\Messenger\DeferredMessengerDrain`, `App\Core\Messenger\DeferredMessengerDrainProcessStarter` | Opportunistic post-response Messenger drain for due configured Doctrine queue rows, starting detached `messenger:consume async` and optional web-triggered `bin/scheduler` processes behind one environment-scoped cooldown lock with Unix and Windows process-detach handling while treating unavailable setup-time database state and manual scheduler requests as no-ops. | `dev/draft/0.2.x-EventHooksBuses.md`, `dev/manual/package-lifecycle-snippets.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Messenger/DeferredMessengerDrainTest.php` | -| Service | `App\Core\Package\PackageRegistryHandler`, `App\Core\Package\PackageRegistrySyncFinalizer` | Reconciles discovered filesystem package candidates with the persistent package registry, registering new packages as inactive with installed version metadata, updating installed and manifest versions together, marking missing or invalid packages as removed or faulty, deactivating active reverse dependents when active packages leave the usable set, and finalizing sync flush plus asset rebuild dispatch/fallback through a focused finalizer. | `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageRegistryHandlerTest.php` | -| Service | `App\Core\Package\Install\PackageZipInstaller`, `App\Core\Package\Install\PackageUploadStager`, `App\Core\Package\Install\PackageZipExtractor`, `App\Core\Package\Install\PackageInstallVerifier`, `App\Core\Package\Install\PackageInstallApplier`, `App\Core\Package\Install\PackageInstallFilesystem`, `App\Core\Package\Install\PackageInstallPayload`, `App\Core\Package\Install\PackageInstallStageReader`, `App\Core\Package\Install\PackageInstallRegistry`, `App\Core\Package\Install\PackageInstallVersionGuard`, `App\Core\Package\Install\PackageReplacementPreflight`, `App\Core\Package\Install\PackageInstallRollbacker`, `App\Core\Package\Install\PackageReactivationPlanner`, `App\Core\Package\Install\PackageZipVerifyAction`, `App\Core\Package\Install\PackageZipApplyAction` | Stages uploaded package ZIP files, verifies package manifests and package lint rules, blocks registry-known downgrades, preflights active replacement dependencies, emits review-required continuation metadata, swaps existing package folders through a prepared replacement and rollback backup with platform-safe cleanup, runs discovery, and restores active reverse dependents when an overwritten package was previously active through focused staging, archive-safety, filesystem, registry, rollback, verifier, and applier boundaries. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageZipInstallerTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | -| Service | `App\Core\Package\PackageDependencyParser`, `App\Core\Package\PackageDependencyMetadataReader`, `App\Core\Package\PackageDependencyResolver` | Parses and resolves `PACKAGE_DEPENDENCIES` against the Studio package registry and the virtual active `system` package through separated metadata reading and graph traversal, checking syntax, package presence, status, minimum versions, circular hard dependencies, and active reverse dependents for lifecycle cascades. | `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageActivatorTest.php`, `tests/Core/Package/PackageValidatorTest.php` | -| Service | `App\Core\Package\PackageActivator`, `App\Core\Package\PackageActivationPlanner`, `App\Core\Package\PackageLifecycleStore`, `App\Core\Package\PackageLifecycleFinalizer` | Plans and executes package activation/deactivation through separated lifecycle collaborators for dependency/single-active/dependent planning, package lookup/status snapshots, asset rebuild finalization, and status rollback on rebuild failure. | `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageActivatorTest.php` | -| Messenger message | `App\Core\Package\PackageAssetRebuildMessage`, `App\Core\Package\PackageAssetRebuildDispatcher`, `App\Core\Package\PackageAssetRebuildMessageHandler` | Queues package-aware asset rebuilds after deferred lifecycle exits such as active packages becoming faulty, removed, or leaving the active set after runtime failure, guarded by a prefix-aware messenger-storage readiness check; registry sync may run the rebuild synchronously only as a dispatch-failure fallback. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageDispatcherTest.php`, `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageRegistryHandlerTest.php` | -| Service contract | `App\Core\Package\PackageLifecycleAssetRebuilderInterface`, `App\Core\Package\PackageLifecycleAssetRebuilder` | Rebuild boundary used by package lifecycle operations to run the active-package asset rebuild queue without coupling lifecycle tests to console commands. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/frontend-asset-snippets.md` | `tests/Core/Package/PackageActivatorTest.php`, `tests/Command/AssetRebuildCommandTest.php` | +| Value object | `App\Core\Extension\ExtensionCandidate` | Value object for a discovered manifest-backed extension candidate. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Extension/ExtensionDiscoveryTest.php` | +| Value object | `App\Core\Extension\ExtensionAssetContribution` | Value object for CSS, JavaScript, static asset, and Tailwind-source contributions from active extensions. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/Core/Extension/ExtensionAssetRegistryBuilderTest.php` | +| Service | `App\Core\Extension\ActiveExtensionProviderInterface`, `App\Core\Extension\ActiveExtensionProvider` | Central active-extension gate for loaders and lifecycle consumers; returns only active real filesystem extensions and supports scope filtering for Twig, assets, and future extension-aware loaders. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php` | +| Service | `App\Core\Extension\ExtensionAdminOverview` | Read model for Admin Extension Management rows with a virtual immutable system extension fed by the root `.manifest`, registry metadata, status labels, scopes, version details, generic extension settings links, and explicit lifecycle action links when available. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Controller/BackendControllerTest.php` | +| Service | `App\Core\Extension\ThemeAdminOverview` | Read model for card-based Admin Theme Management sections that list frontend and backend themes together with the immutable native system theme fallback, extension detail links, and quick use/active/repair controls. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/manual/admin-ui-snippets.md` | `tests/Controller/BackendControllerTest.php` | +| Interface | `App\Core\Extension\ActiveExtensionAssetProviderInterface` | Contract for reading active extensions that participate in asset sync, keeping commands testable and provider failures explicit. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php` | +| Service | `App\Core\Extension\ActiveExtensionAssetProvider` | Adapts central active extension records into asset sync targets while keeping the asset pipeline decoupled from the registry entity. | `dev/manual/frontend-asset-snippets.md` | N/A | +| Operation action | `App\Core\Extension\ExtensionAssetSyncAction` | Operation action that mirrors active extension assets and rewrites generated extension CSS/JS registries. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| Value object | `App\Core\Extension\ExtensionAssetSyncTarget` | Value object for one active extension participating in asset sync with a safe identifier, directory, and scopes. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| Service | `App\Core\Extension\ExtensionAssetSyncer` | Mirrors active extension assets through a staged `assets/.extensions.tmp-*` directory before replacing `assets/extensions/`, dispatches public extension asset hooks, rewrites extension-authored CSS/JS paths, and rebuilds extension asset registries only after registry targets are writable. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| Value object | `App\Core\Extension\ExtensionAssetFilesystem` | Shared extension-asset filesystem helper for project-relative paths, safe directory checks, temporary-file-backed file replacement, and platform-safe recursive cleanup. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| Service | `App\Core\Extension\ExtensionAssetMirror` | Stages active extension asset files, swaps the public `assets/extensions/` mirror only after staging succeeds, and rewrites extension-authored CSS/JS paths during copy. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| Service | `App\Core\Extension\ExtensionAssetRegistryWriter` | Ensures ignored CSS and JavaScript extension registries exist for clean checkouts, then writes generated registry contents for each asset bucket through temporary-file-backed replacement with all-target preflight and platform-safe rollback. | `dev/manual/frontend-asset-snippets.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php`, `tests/Core/Extension/ExtensionAssetRegistryBuilderTest.php` | +| Service | `App\Core\Extension\ExtensionAssetPathRewriter` | Rewrites extension-authored CSS URLs and JavaScript imports from private extension source paths to public mirrored extension asset paths. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/Core/Extension/ExtensionAssetPathRewriterTest.php` | +| Service | `App\Core\Extension\ExtensionAssetRegistryBuilder` | Builds deterministic generated CSS and JavaScript registries for active extension asset buckets, including neutral extension-bucket handling for module, provider, API, database, and content-schema scoped assets. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/Core/Extension/ExtensionAssetRegistryBuilderTest.php` | +| Value object | `App\Core\Extension\ExtensionDiscovery` | Discovers application, scoped extension, and cached import manifests from standard extension locations. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Extension/ExtensionDiscoveryTest.php` | +| Service | `App\Core\Extension\ExtensionDiscoveryRunner` | Callable lifecycle entry point that runs extension manifest discovery, validation-backed registry synchronization, and trigger-aware result aggregation for admin, installer, scheduler, and CLI flows. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionDiscoveryRunnerTest.php`, `tests/Command/ExtensionDiscoveryCommandTest.php` | +| Service | `App\Core\Extension\ExtensionDiscoveryDispatcher` | Internal trigger boundary that queues extension discovery through Messenger after a prefix-aware messenger-storage readiness check and returns localized operation messages for manual, installer, scheduler, and recovery callers. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionDispatcherTest.php`, `tests/Core/Extension/ExtensionDiscoveryRunnerTest.php`, `tests/Command/ExtensionDiscoveryCommandTest.php` | +| Messenger message | `App\Core\Extension\ExtensionDiscoveryMessage`, `App\Core\Extension\ExtensionDiscoveryMessageHandler` | Deferred extension discovery message and handler; the handler executes the discovery runner outside the triggering request or setup process. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Extension/ExtensionDiscoveryRunnerTest.php` | +| Event subscriber/service | `App\Core\Messenger\DeferredMessengerDrainSubscriber`, `App\Core\Messenger\DeferredMessengerDrain`, `App\Core\Messenger\DeferredMessengerDrainProcessStarter` | Opportunistic post-response Messenger drain for due configured Doctrine queue rows, starting detached `messenger:consume async` and optional web-triggered `bin/scheduler` processes behind one environment-scoped cooldown lock with Unix and Windows process-detach handling while treating unavailable setup-time database state and manual scheduler requests as no-ops. | `dev/draft/0.2.x-EventHooksBuses.md`, `dev/manual/extension-lifecycle-snippets.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Messenger/DeferredMessengerDrainTest.php` | +| Service | `App\Core\Extension\ExtensionRegistryHandler`, `App\Core\Extension\ExtensionRegistrySyncFinalizer` | Reconciles discovered filesystem extension candidates with the persistent extension registry, registering new extensions as inactive with installed version metadata and typed manifest variables, updating installed and manifest versions together, marking missing or invalid extensions as removed or faulty, deactivating active reverse dependents when active extensions leave the usable set, and finalizing sync flush plus synchronous extension-aware asset rebuild through a focused finalizer. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionRegistryHandlerTest.php` | +| Service | `App\Core\Extension\Install\ExtensionZipInstaller`, `App\Core\Extension\Install\ExtensionUploadStager`, `App\Core\Extension\Install\ExtensionZipExtractor`, `App\Core\Extension\Install\ExtensionInstallVerifier`, `App\Core\Extension\Install\ExtensionInstallApplier`, `App\Core\Extension\Install\ExtensionInstallFilesystem`, `App\Core\Extension\Install\ExtensionInstallPayload`, `App\Core\Extension\Install\ExtensionInstallStageReader`, `App\Core\Extension\Install\ExtensionInstallRegistry`, `App\Core\Extension\Install\ExtensionInstallVersionGuard`, `App\Core\Extension\Install\ExtensionReplacementPreflight`, `App\Core\Extension\Install\ExtensionInstallRollbacker`, `App\Core\Extension\Install\ExtensionReactivationPlanner`, `App\Core\Extension\Install\ExtensionZipVerifyAction`, `App\Core\Extension\Install\ExtensionZipApplyAction` | Stages uploaded extension ZIP files, verifies extension manifests and extension lint rules, accepts slug-named or flat `.manifest` ZIP roots while always installing into `extensions/{slug}`, blocks registry-known downgrades, preflights active replacement dependencies, emits review-required continuation metadata, swaps existing extension folders through a prepared replacement and rollback backup with platform-safe cleanup, runs discovery, and restores active reverse dependents when an overwritten extension was previously active through focused staging, archive-safety, filesystem, registry, rollback, verifier, and applier boundaries. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionZipInstallerTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | +| Service | `App\Core\Extension\ExtensionDependencyParser`, `App\Core\Extension\ExtensionDependencyMetadataReader`, `App\Core\Extension\ExtensionDependencyResolver` | Parses and resolves `EXTENSION_DEPENDENCIES` against the Studio extension registry and the virtual active `system` extension through separated metadata reading and graph traversal, checking syntax, one- to three-part numeric version constraints with four digits per part, extension presence, status, bare compatibility constraints, `>=` minimum versions, precision-pinned `=` constraints, circular hard dependencies, and active reverse dependents for lifecycle cascades. | `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Extension/ExtensionActivatorTest.php`, `tests/Core/Extension/ExtensionValidatorTest.php` | +| Service | `App\Core\Extension\ExtensionActivator`, `App\Core\Extension\ExtensionActivationPlanner`, `App\Core\Extension\ExtensionLifecycleStore`, `App\Core\Extension\ExtensionLifecycleFinalizer`, `App\Core\Extension\ExtensionActivationContributionApplier`, `App\Core\Extension\ExtensionContributionReader`, `App\Core\Extension\ExtensionOwnerName`, `App\Core\Extension\Database\ExtensionDatabaseTableNameResolver`, `App\Core\Extension\Content\ExtensionContentSchemaIdentifier` | Plans and executes extension activation/deactivation through separated lifecycle collaborators for dependency/single-active/dependent planning, including rejection of activation plans that would activate two extensions for the same single-active scope, extension lookup/status and content-status snapshots, activation-time contribution loading only for newly activated extensions, database/content-schema contribution application with collision-free length-prefixed ownership identifiers, created-table cleanup after contribution failure, asset rebuild finalization, and status/content rollback on rebuild or contribution failure. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionActivatorTest.php`, `tests/Core/Extension/ExtensionDatabaseSchemaSynchronizerTest.php`, `tests/Core/Extension/ExtensionContentSchemaSynchronizerTest.php` | +| Messenger message | `App\Core\Extension\ExtensionAssetRebuildMessage`, `App\Core\Extension\ExtensionAssetRebuildDispatcher`, `App\Core\Extension\ExtensionAssetRebuildMessageHandler` | Queues extension-aware asset rebuilds after deferred lifecycle exits such as active extensions becoming faulty, removed, or leaving the active set after runtime failure, guarded by a prefix-aware messenger-storage readiness check; registry sync may run the rebuild synchronously only as a dispatch-failure fallback. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionDispatcherTest.php`, `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php`, `tests/Core/Extension/ExtensionRegistryHandlerTest.php` | +| Service contract | `App\Core\Extension\ExtensionLifecycleAssetRebuilderInterface`, `App\Core\Extension\ExtensionLifecycleAssetRebuilder` | Rebuild boundary used by extension lifecycle operations to run the active-extension asset rebuild queue without coupling lifecycle tests to console commands. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/frontend-asset-snippets.md` | `tests/Core/Extension/ExtensionActivatorTest.php`, `tests/Command/AssetRebuildCommandTest.php` | | Service | `App\Localization\CoreTranslationBootstrapper` | Container-free bootstrap helper used by `bin/init` to generate core-only environment-specific runtime `messages.{locale}.yaml` catalogues before Composer auto-scripts or other Symfony consumers need translations. | `dev/manual/setup-init-snippets.md` | `tests/Localization/CoreTranslationBootstrapperTest.php` | -| Services | `App\Core\Translation\TranslationRuntimePath`, `App\Core\Translation\TranslationCatalogueAggregator`, `App\Core\Translation\TranslationSourceCollector`, `App\Core\Translation\TranslationCatalogueMerger`, `App\Core\Translation\TranslationRuntimeWriter`, `App\Core\Translation\TranslationAggregateAction` | Resolves and aggregates modular core translation sources plus active package language files through separated source collection, deterministic path ordering, YAML merge/collision handling, and staged runtime-directory replacement while preserving runtime metadata with platform-safe cleanup and keeping runtime generation out of Symfony cache warmers. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/TranslationCatalogueAggregatorTest.php` | -| Service contract | `App\Core\Package\PackageLifecycleCleanupRunnerInterface`, `App\Core\Package\PackageLifecycleCleanupRunner` | Cleanup boundary used by package purge/removal operations; removes package-scoped settings and leaves package-owned migrations/data cleanup for later. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageLifecycleCleanupRunnerTest.php` | -| Registry/service | `App\Core\Package\Settings\PackageSettingDefinition`, `App\Core\Package\Settings\PackageSettingProviderInterface`, `App\Core\Package\Settings\PackageSettingRegistry`, `App\Core\Package\Settings\PackageSettings`, `App\Core\Package\Settings\PackageSettingsFormHandler`, `App\Core\Package\Settings\PackageSettingsBackendViewProvider` | Provides typed package setting definitions with shared form input/validation metadata, active-package filtering, package-scoped get/set storage and typed form persistence, plus generic Admin Settings navigation/views for active packages with simple settings. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Core/Package/PackageSettingRegistryTest.php`, `tests/Core/Package/PackageSettingsTest.php`, `tests/Core/Package/PackageSettingsFormHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | -| Service/API | `App\Core\Package\PackagePhpLoader`, `App\Core\Package\PackageRuntimeContributionRegistry`, `App\Core\Package\PackageContributions` | Loads optional `package.php` runtime loaders for active real packages, atomically collects supported view/settings/API/live endpoint/scheduler/cookie-consent contributions, provides a readable package contribution builder for grouped package entry points, retains package scheduler execution providers, validates package-owned scheduler task source/trust boundaries, live endpoint namespaces, and package-scoped host-only same-site necessary cookie definitions, evaluates contribution providers inside the loader boundary, converts loader failures into lifecycle diagnostics, marks failing packages faulty, and deactivates active dependents with explicit messages. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/theme-module-developer-guidelines.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php`, `tests/Core/Package/PackageContributionsTest.php`, `tests/Core/Package/PackageActivatorTest.php`, `tests/Core/Package/PackageLiveContributionGuardTest.php` | -| Service | `App\Core\Package\PackageDependentDeactivator` | Deactivates active reverse dependents when a package becomes unavailable at runtime and emits dependency-aware lifecycle messages. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| Service | `App\Core\Package\PackageRemover`, `App\Core\Package\PackageRemovalPlanner`, `App\Core\Package\PackageFilesystemRemover`, `App\Core\Package\PackagePurger` | Plans and executes package removal through separated collaborators for deactivation-aware removal planning, safe package-directory deletion, registry row removal, final asset rebuilds, and removed-package purge cleanup. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| Service | `App\Core\Package\PackageFaultResetter` | Provides the non-destructive admin recovery path for faulty packages by validating the current package folder and resetting successful repairs to inactive. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/package-lifecycle-snippets.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| Service | `App\Core\Package\PackageRuntimeFailureHandler`, `App\Core\Package\PackageRuntimeFailureSubscriber` | Records package runtime failures from public hook diagnostics, marks identified active packages faulty, deactivates active dependents, and queues asset rebuilds so broken listeners do not keep breaking requests. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageLifecycleBoundaryTest.php` | -| Value object | `App\Core\Package\PackageInspection` | Value object describing package inventory and detected feature surfaces such as templates, assets, PHP, Twig, JSON, YAML, CSS, and JavaScript files. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageValidatorTest.php` | -| Service | `App\Core\Package\PackageOperationPlanner` | Translates selected package files into deterministic ActionQueues without installing or classifying packages. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageOperationPlannerTest.php` | -| Enum | `App\Core\Package\PackageSource` | Defines a normalized, project-root-scoped package discovery source and its optional manifest specification. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageDiscoveryTest.php`, `tests/Core/Package/PackageSourceTest.php` | -| Value object | `App\Core\Package\PackageSpec` | Domain-neutral package filesystem and optional preflight linting specification. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Package/PackageValidatorTest.php` | -| Service | `App\Core\Package\PackageValidator`, `App\Core\Package\PackageInventoryInspector`, `App\Core\Package\PackageRequiredPathValidator`, `App\Core\Package\PackageFilePolicy`, `App\Core\Package\PackagePhpCapabilityPolicy`, `App\Core\Package\PackageFileSyntaxValidator`, `App\Core\Package\PackageCssNamespaceValidator`, `App\Core\Package\PackageTemplateReferenceValidator`, `App\Core\Package\PackageSourceNamespaceValidator`, `App\Core\Package\PackageTranslationNamespaceValidator`, `App\Core\Package\PackageSchedulerCronValidator`, `App\Core\Package\PackageSchedulerCronInspector`, `App\Core\Package\PackageSchedulerDefinitionCallScanner`, `App\Core\Package\PackageSchedulerDefinitionImportResolver`, `App\Core\Package\PackagePhpCallArgumentParser` | Validates discovered package candidates for stable `PACKAGE_SLUG` metadata, required files/directories, feature inventory, package-owned CSS target class namespaces, template-scope references, source namespace boundaries, package translation namespaces, installable package file-policy allow/warn/block decisions, direct PHP capability blocks, literal scheduler cron registrations including aliases, and optional syntax checks with Tailwind directive tolerance before dry-run planning through focused inventory, policy, PHP parser, CSS selector, Twig reference, and cron-call inspection collaborators. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Package/PackageValidatorTest.php` | -| Enum | `App\Core\Package\ExtensionPackageStatus` | Enum for managed extension package lifecycle states including active, inactive, removed, and faulty registry records. | `dev/draft/0.2.x-PluginModules.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Core/Package/PackageRegistryHandlerTest.php` | -| Enum | `App\Core\Package\PackageScope` | Enum for allowed package scopes such as frontend theme, backend theme, module, captcha provider, and editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageScopeTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | -| Event payload | `App\Core\Package\Event\PackageAssetSyncStartedEvent` | Public observe hook dispatched before active package assets are synchronized. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | -| Event payload | `App\Core\Package\Event\PackageAssetRegistryBuildEvent` | Public mutable extend hook that allows subscribers to add package asset registry contributions before generated registries are written. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | -| Event payload | `App\Core\Package\Event\PackageAssetSyncCompletedEvent` | Public observe hook dispatched with asset sync metrics after generated registries are written. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Package/PackageAssetSyncerTest.php` | +| Services | `App\Core\Translation\TranslationRuntimePath`, `App\Core\Translation\TranslationCatalogueAggregator`, `App\Core\Translation\TranslationSourceCollector`, `App\Core\Translation\TranslationCatalogueMerger`, `App\Core\Translation\TranslationRuntimeWriter`, `App\Core\Translation\TranslationAggregateAction` | Resolves and aggregates modular core translation sources plus active extension language files through separated source collection, deterministic path ordering, YAML merge/collision handling, and staged runtime-directory replacement while preserving runtime metadata with platform-safe cleanup and keeping runtime generation out of Symfony cache warmers. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/TranslationCatalogueAggregatorTest.php` | +| Service contract | `App\Core\Extension\ExtensionLifecycleCleanupRunnerInterface`, `App\Core\Extension\ExtensionLifecycleCleanupRunner` | Cleanup boundary used by extension purge/removal operations; removes extension-scoped settings, admin ACL overrides, extension-owned database contribution tables, and unreferenced extension-owned content schema presets before registry purge. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php`, `tests/Core/Extension/ExtensionLifecycleCleanupRunnerTest.php` | +| Registry/service | `App\Core\Extension\Settings\ExtensionSettingDefinition`, `App\Core\Extension\Settings\ExtensionSettingProviderInterface`, `App\Core\Extension\Settings\ExtensionSettingRegistry`, `App\Core\Extension\Settings\ExtensionSettings`, `App\Core\Extension\Settings\ExtensionSettingsFormHandler`, `App\Core\Extension\Settings\ExtensionSettingsBackendViewProvider`, `App\Core\Extension\ExtensionManifestVariables` | Provides typed extension setting definitions with shared form input/validation metadata, active-extension filtering, extension-scoped get/set storage and typed form persistence, `manifest.{key}` fallback reads from immutable manifest metadata with persisted setting overrides, plus generic Admin Settings navigation/views for active extensions with simple settings. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionSettingRegistryTest.php`, `tests/Core/Extension/ExtensionSettingsTest.php`, `tests/Core/Extension/ExtensionManifestVariablesTest.php`, `tests/Core/Extension/ExtensionSettingsFormHandlerTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Navigation/NavigationBuilderTest.php` | +| Service/API | `App\Core\Extension\ExtensionPhpLoader`, `App\Core\Extension\ExtensionRuntimeContributionRegistry`, `App\Core\Extension\Contribution\ExtensionRuntimeContributionExpander`, `App\Core\Extension\Contribution\ExtensionRuntimeContributionGuard`, `App\Core\Extension\Contribution\ExtensionRuntimeViewContributions`, `App\Core\Extension\Contribution\ExtensionRuntimeEndpointContributions`, `App\Core\Extension\Contribution\ExtensionRuntimeSchedulerContributions`, `App\Core\Extension\ExtensionContributions` | Loads optional `extension.php` runtime loaders for active real extensions, atomically collects supported view/settings/API/live endpoint/scheduler/cookie-consent/database/content-schema contributions, provides a readable extension contribution builder for grouped extension entry points, retains extension scheduler execution providers, validates extension-owned scheduler task source/trust boundaries, `api`-gated API endpoint/handler owner namespaces, live endpoint owner namespaces, scope-gated database/content-schema contributions, and extension-scoped host-only same-site necessary cookie definitions, evaluates contribution providers inside the loader boundary, converts loader failures into lifecycle diagnostics, marks failing extensions faulty, deactivates active dependents with explicit messages, and runs synchronous asset rebuild when needed. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/theme-module-developer-guidelines.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php`, `tests/Core/Extension/ExtensionContributionsTest.php`, `tests/Core/Extension/ExtensionRuntimeContributionRegistryContractTest.php`, `tests/Core/Extension/ExtensionActivatorTest.php`, `tests/Core/Extension/ExtensionLiveContributionGuardTest.php` | +| Service/API | `App\Core\Extension\Database\ExtensionDatabaseTable`, `App\Core\Extension\Database\ExtensionDatabaseColumn`, `App\Core\Extension\Database\ExtensionDatabaseIndex`, `App\Core\Extension\Database\ExtensionDatabaseForeignKey`, `App\Core\Extension\Database\ExtensionDatabaseProviderInterface`, `App\Core\Extension\Database\ExtensionDatabaseSchemaSynchronizer`, `App\Core\Extension\Database\ExtensionDatabaseTableNameResolver`, `App\Core\Extension\Database\ExtensionDatabaseReferenceValidator`, `App\Core\Extension\Database\ExtensionDatabaseTableOrderer` | Declarative extension-owned database table contract gated by `database` scope; creates missing `{database-prefix}ext{normalized-slug-length}_{normalized-slug}_{local-table}` tables during activation, keeps local table/column/index/FK identifiers within the portable database identifier budget, supports primary keys, indexes, unique indexes, and foreign keys between tables owned by the same extension, validates pending/database FK references before table creation, orders FK-dependent creation/drop operations, and drops only tables matching that physical extension owner prefix during purge. | `dev/manual/extension-lifecycle-snippets.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Extension/ExtensionDatabaseSchemaSynchronizerTest.php`, `tests/Core/Extension/ExtensionRuntimeContributionRegistryContractTest.php`, `tests/Core/Extension/ExtensionActivatorTest.php` | +| Service/API | `App\Core\Extension\Content\ExtensionContentSchemaDefinition`, `App\Core\Extension\Content\ExtensionContentSchemaProviderInterface`, `App\Core\Extension\Content\ExtensionContentSchemaSynchronizer`, `App\Core\Extension\Content\ExtensionContentSchemaImpact` | Declarative extension content-schema preset contract gated by `content-schema` scope; stores contributed presets as locked module schemas, creates new active versions when definitions change, clears staged schema/version state on contribution failure, lists direct content impact for lifecycle review, archives public content that depends on deactivated extension schemas, force-archives content during purge, deletes unreferenced extension-owned presets, and retains still-referenced presets as disabled database copies with explicit warning context. | `dev/manual/extension-lifecycle-snippets.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Extension/ExtensionContentSchemaSynchronizerTest.php`, `tests/Core/Extension/ExtensionContentSchemaImpactTest.php`, `tests/Core/Extension/ExtensionRuntimeContributionRegistryContractTest.php` | +| Service | `App\Core\Extension\ExtensionDependentDeactivator` | Deactivates active reverse dependents when an extension becomes unavailable at runtime and emits dependency-aware lifecycle messages. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php` | +| Service | `App\Core\Extension\ExtensionRemover`, `App\Core\Extension\ExtensionRemovalPlanner`, `App\Core\Extension\ExtensionFilesystemRemover`, `App\Core\Extension\ExtensionPurger` | Plans and executes extension removal through separated collaborators for deactivation-aware removal planning, safe extension-directory deletion, registry row removal, content/status rollback after failed removal, final asset rebuilds, and removed-extension purge cleanup. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php` | +| Service | `App\Core\Extension\ExtensionFaultResetter` | Provides the non-destructive admin recovery path for faulty extensions by validating the current extension folder and resetting successful repairs to inactive. | `dev/draft/0.2.x-PluginModules.md`, `dev/manual/extension-lifecycle-snippets.md` | `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php` | +| Service | `App\Core\Extension\ExtensionRuntimeFailureHandler`, `App\Core\Extension\ExtensionRuntimeFailureSubscriber` | Records extension runtime failures from public hook diagnostics, marks identified active extensions faulty, deactivates active dependents, and runs synchronous asset rebuilds so broken listeners do not keep breaking requests. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Extension/ExtensionLifecycleBoundaryTest.php` | +| Value object | `App\Core\Extension\ExtensionInspection` | Value object describing extension inventory and detected feature surfaces such as templates, assets, PHP, Twig, JSON, YAML, CSS, and JavaScript files. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Extension/ExtensionValidatorTest.php` | +| Service | `App\Core\Extension\ExtensionOperationPlanner` | Translates selected extension files into deterministic ActionQueues without installing or classifying extensions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Extension/ExtensionOperationPlannerTest.php` | +| Enum | `App\Core\Extension\ExtensionSource` | Defines a normalized, project-root-scoped extension discovery source and its optional manifest specification. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Extension/ExtensionDiscoveryTest.php`, `tests/Core/Extension/ExtensionSourceTest.php` | +| Value object | `App\Core\Extension\ExtensionSpec` | Domain-neutral extension filesystem and optional preflight linting specification. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Core/Extension/ExtensionValidatorTest.php` | +| Service | `App\Core\Extension\ExtensionValidator`, `App\Core\Extension\ExtensionInventoryInspector`, `App\Core\Extension\ExtensionRequiredPathValidator`, `App\Core\Extension\ExtensionFilePolicy`, `App\Core\Extension\ExtensionPhpCapabilityPolicy`, `App\Core\Extension\ExtensionFileSyntaxValidator`, `App\Core\Extension\ExtensionCssNamespaceValidator`, `App\Core\Extension\ExtensionTemplateReferenceValidator`, `App\Core\Extension\ExtensionSourceNamespaceValidator`, `App\Core\Extension\ExtensionTranslationNamespaceValidator`, `App\Core\Extension\ExtensionSchedulerCronValidator`, `App\Core\Extension\ExtensionSchedulerCronInspector`, `App\Core\Extension\ExtensionSchedulerDefinitionCallScanner`, `App\Core\Extension\ExtensionSchedulerDefinitionImportResolver`, `App\Core\Extension\ExtensionPhpCallArgumentParser` | Validates discovered extension candidates for stable `EXTENSION_SLUG` metadata, bounded numeric `EXTENSION_VERSION` values, required files/directories, feature inventory, extension-owned CSS target class namespaces, tokenized template-scope references, source namespace boundaries, extension translation namespaces, installable extension file-policy allow/warn/block decisions, direct and dynamic PHP capability blocks, literal scheduler cron registrations including aliases, and optional syntax checks with Tailwind directive tolerance before dry-run planning through focused inventory, policy, PHP parser, CSS selector, Twig reference, and cron-call inspection collaborators. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Core/Extension/ExtensionValidatorTest.php` | +| Enum | `App\Core\Extension\ExtensionStatus` | Enum for managed extension lifecycle states including active, inactive, removed, and faulty registry records. | `dev/draft/0.2.x-PluginModules.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Core/Extension/ExtensionRegistryHandlerTest.php` | +| Enum | `App\Core\Extension\ExtensionScope` | Enum for allowed extension scopes such as frontend theme, backend theme, module, captcha provider, and editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Extension/ExtensionScopeTest.php`, `tests/Entity/CoreDatabaseModelTest.php` | +| Event payload | `App\Core\Extension\Event\ExtensionAssetSyncStartedEvent` | Public observe hook dispatched before active extension assets are synchronized. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| Event payload | `App\Core\Extension\Event\ExtensionAssetRegistryBuildEvent` | Public mutable extend hook that allows subscribers to add extension asset registry contributions before generated registries are written. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| Event payload | `App\Core\Extension\Event\ExtensionAssetSyncCompletedEvent` | Public observe hook dispatched with asset sync metrics after generated registries are written. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Core/Extension/ExtensionAssetSyncerTest.php` | | Value object | `App\Core\Workflow\WorkflowResult` | Value object for recoverable workflow results with success, invalid, review, blocked, failed states, message-backed issues, messages, and context. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | | Enum | `App\Core\Workflow\WorkflowStatus` | Enum for shared recoverable workflow result states. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Core/Workflow/WorkflowResultTest.php` | -| Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | -| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, surface, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel; `AccessLogSubscriber` refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | -| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpResolver` | Replaceable GeoIP lookup boundary used by access logging and access statistics; the initial null provider returns normalized `n/a` location values without external dependencies or hard failures. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Geo/NullGeoIpResolverTest.php` | +| Service contract | `App\Core\Message\MessageReporterInterface`, `App\Core\Message\MessageReporter`, `App\Core\Message\WorkflowResultMessageReporterInterface`, `App\Core\Message\WorkflowResultMessageReporter`, `App\Core\Log\MessageLoggerInterface`, `App\Core\Log\MonologMessageLogger` | Message-layer bridge that records translation keys plus redacted structured context through Monolog's `message` channel and the database lookup projection without making log-write failures fatal to callers. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Message/MessageReporterTest.php`, `tests/Core/Message/WorkflowResultMessageReporterTest.php`, `tests/Core/Log/MonologMessageLoggerTest.php`, `tests/Core/Log/MonologRetentionConfigTest.php`, `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/OperationExecutorTest.php` | +| Service contract | `App\Core\Log\AccessLoggerInterface`, `App\Core\Log\AccessLogger`, `App\Core\Log\AccessLogSubscriber`, `App\Core\Log\AccessRequestMetadata` | Writes request/response access entries with an internally generated request id, optional inbound correlation id, method, token-redacted requested/referrer paths, resolved route, exact-segment surface detection that strips language prefixes only for actual route locale attributes or enabled content route prefixes, status, timing, compact HMAC-derived visitor ID, host/referrer/language hints, content metadata, client/proxy IP hints, full user-agent, and GeoIP placeholders through Monolog's `access` channel and the database lookup projection; `AccessLogSubscriber` skips shared static/tooling/well-known paths, refreshes the first-party technical visitor cookie, triggers the parallel anonymized statistics recorder, and exposes redacted internal request trace data to Twig. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AccessLoggerTest.php`, `tests/Core/Log/AccessRequestMetadataTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php`, `tests/Controller/PublicContentErrorPageTest.php` | +| Service contract | `App\Core\Geo\GeoIpResolverInterface`, `App\Core\Geo\GeoIpResolver`, `App\Core\Geo\GeoIpProviderInterface`, `App\Core\Geo\GeoIpProviderStatus`, `App\Core\Geo\GeoIpResult`, `App\Core\Geo\NullGeoIpProvider`, `App\Core\Geo\NullGeoIpResolver`, `App\Core\Geo\MaxMindGeoIpConfig`, `App\Core\Geo\MaxMindGeoIpProvider`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderInterface`, `App\Core\Geo\MaxMindGeoIpDatabaseReaderFactoryInterface`, `App\Core\Geo\GeoIp2MaxMindDatabaseReader`, `App\Core\Geo\GeoIp2MaxMindDatabaseReaderFactory`, `App\Core\Geo\MaxMindGeoIpDatabaseUpdater`, `App\Core\Geo\MaxMindGeoIpDownloadClientInterface`, `App\Core\Geo\HttpMaxMindGeoIpDownloadClient`, `App\Core\Geo\MaxMindGeoIpArchiveExtractorInterface`, `App\Core\Geo\MaxMindGeoIpArchiveExtractor`, `App\Core\Geo\MaxMindGeoIpSchedulerProvider`, `App\Core\Operation\Live\MaxMindGeoIpLiveOperationProvider`, `App\Core\Geo\MaxMindGeoIpUpdateAction` | Replaceable provider-neutral GeoIP lookup and update boundary used by access logging, access statistics, and safe Admin settings status; the default null provider returns normalized `n/a` location values without external dependencies or hard failures, while the MaxMind provider uses the installed GeoIP2 database reader against a configured local `.mmdb` file. MaxMind database updates run only through explicit Admin Operations or the daily scheduler callable, use protected statistics settings, stream archives without materializing them in memory, write atomically to `var/geoip2`, reject unsafe archive member paths, preserve the previous database on failure where possible, bound stored location labels, and report redacted Message-layer diagnostics. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/geoip-observability.md` | `tests/Core/Geo/GeoIpResolverTest.php`, `tests/Core/Geo/NullGeoIpResolverTest.php`, `tests/Core/Geo/MaxMindGeoIpConfigTest.php`, `tests/Core/Geo/MaxMindGeoIpProviderTest.php`, `tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php`, `tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php`, `tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php`, `tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service contract | `App\Core\Log\OperationLoggerInterface`, `App\Core\Log\OperationLogger` | Writes durable live-operation terminal summaries with operation id, operation name, result status, timing, entry/message/issue counts, and continuation availability through the message layer without logging payloads or tokens. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Log/OperationLoggerTest.php`, `tests/Core/Operation/LiveOperationRunStoreTest.php` | -| Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, package install verification, package lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | -| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates and audits established sessions that reappear with a different visitor signal so copied session cookies do not stay usable. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | -| Services | `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogEntryPresenter`, `App\Core\Log\LogPagination` | Parses Monolog line output and provides a small Admin Logs facade over known source discovery, reverse line reading, filtering, entry IDs/summaries, pagination, and filter option models. | `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | -| Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service contract | `App\Core\Log\AuditLoggerInterface`, `App\Core\Log\AuditLogger`, `App\Core\Log\AuditLogPolicyInterface`, `App\Core\Log\ConfigAuditLogPolicy`, `App\Core\Log\AuditAuthenticationSubscriber` | Applies configurable Security audit policy settings, then writes audit entries with actor, access level, action, redacted context, and current request trace when available through Monolog's `audit` channel and the database lookup projection; authentication events, session visitor mismatches, password changes, backend maintenance, Operations maintenance, extension install verification, extension lifecycle starts/completions, and successful settings saves are recorded first. | `dev/manual/action-log-audit-snippets.md`, `dev/draft/0.4.x-ContactMailLogging.md` | `tests/Core/Log/AuditLoggerTest.php`, `tests/Core/Log/AuditAuthenticationSubscriberTest.php`, `tests/Core/Log/ConfigAuditLogPolicyTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/UserControllerTest.php` | +| Event subscriber | `App\Security\SessionVisitorBindingSubscriber` | Binds authenticated Symfony sessions to the first-party visitor ID after login or first legacy authenticated request, then terminates, audits, and records high-risk diagnostic plus source-subject passive security signals when established sessions reappear with a different visitor signal so copied session cookies do not stay usable and can feed auto-ban scoring for non-trusted subjects. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/manual/action-log-audit-snippets.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/SessionVisitorBindingSubscriberTest.php` | +| Services/value objects | `App\Security\Abuse\AbuseRequestInspector`, `App\Security\Abuse\AbuseSubjectResolver`, `App\Security\Abuse\RequestIntentClassifier`, `App\Security\Abuse\ActionCostCatalogue`, `App\Security\Abuse\SuspiciousProbePathMatcher`, `App\Security\Abuse\SuspiciousRequestPayloadMatcher`, `App\Security\Abuse\PassiveAbuseSignalSubscriber`, `App\Security\Abuse\SuspiciousPayloadSignalSubscriber`, `App\Security\Abuse\AbuseSubject`, `App\Security\Abuse\AbuseSubjectResolution`, `App\Security\Abuse\AbuseRequestProfile`, `App\Security\Abuse\ActionCost`, `App\Security\Abuse\AbuseSubjectType`, `App\Security\Abuse\RequestFamily`, `App\Security\Abuse\RequestIntent` | Passive Abuse Foundation facade and value model for rate-limit and auto-ban branches: resolves visitor/user/API/IP-bucket subjects plus HMAC-redacted submitted-account workflow subjects, account-token invitation/reset/security-review token subjects, and scheduler credential subjects without exposing raw IPs, API secrets, scheduler tokens, usernames, email addresses, or account tokens, keeps the stable IP bucket unchanged when cookie-less fallback Visitor-ID differentiation entropy changes, classifies request family and intent with exact route/segment boundaries including raw prefixless `/api/live/**`, exact raw `/cron/run` scheduler triggers, exact raw setup review apply submissions, URL locale-prefix stripping only for locale-prefix UI/account scopes, Admin API mutations before generic API writes, credentialed `OPTIONS` requests with any non-empty `Authorization` header by requested method while preserving cheap anonymous CORS preflight classification, unsafe-only public auth workflow intents, the exact `GET` recovery-login bypass render path and Admin export/download diagnostics before spoofable prefetch forgiveness while unsafe bypass submissions stay login attempts, high-impact Admin upload/download diagnostics before broad extension/admin buckets, cached configurable suspicious probe path patterns, and obvious public/untrusted query, form, and Content-Length-bounded JSON/raw-body attack-pattern payload detection that scores anonymous Admin/Editor/Setup payload probes while avoiding non-existent contact/captcha path assumptions and skipping authenticated Admin/Editor/Setup/trusted-user code-bearing forms plus valid trusted-user-owned scheduler credentials, assigns symbolic action costs, records source-subject Visitor/IP signals for high-signal probes, redacted suspicious payload classes, and `400`/`403`/`404`/`429` outcomes, excludes login-required `401` plus shared static/tooling/well-known paths by status alone, and avoids double-counting probe-plus-`400` as separate risk actions. | `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/policy-defaults.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Security/Abuse/AbuseSubjectResolverTest.php`, `tests/Security/Abuse/RequestIntentClassifierTest.php`, `tests/Security/Abuse/SuspiciousProbePathMatcherTest.php`, `tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php`, `tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php`, `tests/Security/Abuse/ActionCostCatalogueTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Routing/IgnorableRequestPathMatcherTest.php` | +| Services/subscribers/value objects | `App\Security\RateLimit\RateLimitPolicyCatalogue`, `App\Security\RateLimit\RateLimitBucketDescriptor`, `App\Security\RateLimit\RateLimitSubjectPolicy`, `App\Security\RateLimit\RateLimitProfile`, `App\Security\RateLimit\RateLimitEnforcer`, `App\Security\RateLimit\RateLimitEnforcementStage`, `App\Security\RateLimit\RateLimitLimiterFactory`, `App\Security\RateLimit\RateLimitSubjectSelector`, `App\Security\RateLimit\RateLimitResponseRenderer`, `App\Security\RateLimit\RateLimitRequestSubscriber`, `App\Security\RateLimit\RateLimitResetService`, `App\Security\RateLimit\RateLimitAuthenticationSubscriber`, `App\Security\RateLimit\RateLimitCheckResult` | Descriptor-backed Symfony RateLimiter enforcement for abuse-classified requests with central `off`/`standard`/`strict`/`panic` profile resolution, action-cost-derived credit budgets with two-action profile floors except explicit scheduler/probe interval policies, descriptor-owned enforcement-stage and subject-selection policy, profile-shaped persisted limiter IDs, Symfony lock-backed consume operations with pre-checked multi-bucket consume batches so exhausted later global buckets do not spend earlier workflow/account buckets, fail-open Message-layer storage diagnostics, very-early suspicious-probe blocking before extension loading/API availability/setup/maintenance gates with forced minimal `400 Invalid Request` HTML, authentication-failure workflow/API/Admin-API bucket charging through Security events including malformed or credentialed `OPTIONS` unless an active auto-ban response already owns the request, local Visitor/IP guard consumption before submitted-account workflow keys, ordinary bucket charging after Symfony authentication resolves active user/API-key subjects, Owner ordinary-rejection exemption except for the explicit scheduler interval policy and read-only Owner API key write denials including unsafe credentialed preflights, authenticated-user multipliers for ordinary navigation/read buckets, stable Visitor/IP fallback for invalid API credentials without trusting submitted API-key prefixes as primary limiter subjects, exact raw `/cron/run` scheduler intervals keyed by HMAC-redacted submitted scheduler credentials with IP anchoring that remains active even beside user sessions, raw segment-bounded scheduler JSON response detection, descriptorless Admin/Editor navigation falling back to global website buckets, pre-completion final setup-review apply charging without setup wizard navigation lockout, recovery-login render charging without website-bucket charging, raw `/api/live/**`, `/build/**`, and safe prefetch exclusion from deliberate website buckets, redacted HTML/JSON `429` responses with request references, active-profile scoped login-success reset for submitted-account/visitor/IP subjects, and a dormant verified-provider captcha reset interface that refuses `none`/missing/unverified provider success. | `dev/draft/security-hardening/rate-enforcement.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php`, `tests/Security/RateLimit/RateLimitLimiterFactoryTest.php`, `tests/Security/RateLimit/RateLimitEnforcerTest.php`, `tests/Security/RateLimit/RateLimitResetServiceTest.php`, `tests/Security/RateLimit/RateLimitRequestSubscriberTest.php`, `tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php`, `tests/Security/RateLimit/RateLimitResponseRendererTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Core/Config/CoreSettingsFormHandlerTest.php`, `tests/Controller/ApiSettingsControllerTest.php`, `tests/Controller/BackendControllerTest.php` | +| Services/subscribers/controller/API | `App\Security\AutoBan\AutoBanPolicy`, `App\Security\AutoBan\AutoBanScoreCatalogue`, `App\Security\AutoBan\AutoBanSignalEvaluator`, `App\Security\AutoBan\AutoBanStore`, `App\Security\AutoBan\AutoBanStoreResult`, `App\Security\AutoBan\ActiveAutoBan`, `App\Security\AutoBan\AutoBanSubject`, `App\Security\AutoBan\AutoBanRequestSubscriber`, `App\Security\AutoBan\TrustedApiKeyAutoBanBypass`, `App\Security\AutoBan\AutoBanAdminBrowser`, `App\Security\AutoBan\AutoBanResetService`, `App\Security\AutoBan\AutoBanOwnerAlertNotifier`, `App\Security\AutoBan\Api\AutoBanApiEndpointProvider`, `App\Security\AutoBan\Api\AutoBanApiHandler`, `App\Controller\AdminAutoBanController` | Score-based temporary auto-ban implementation for retained source-risk Security signals: evaluates scoreable Visitor/IP subject rows only after successful signal writes, uses a one-hour window with reset cutoffs, floors decisions to at least two qualifying requests, bounds the configured Visitor threshold, applies Visitor threshold `100` and IP multiplier `2`, escalates cache-backed TTL state through `1h`/`3h`/`24h`/`7d` from retained ban-trigger signals, prefers Visitor over IP when one request crosses both thresholds, serializes active-ban index updates, verifies rollback deletion with an expired-payload fallback when an index write fails, marks reused active bans so trigger signals/alerts are emitted only for newly created bans, pre-auth blocks `/api/v1/**` and raw `/cron/run` active source bans before API/scheduler auth success/failure, CORS preflights, controllers, or rate-limit buckets unless a shared HMAC-backed lookup proves an active trusted-user-owned API key including scheduler `?auth=` when enabled, blocks sessionless protected browser surfaces such as Admin, Editor, and protected User account routes before firewall access-control responses, rechecks previous-session protected-browser entry/access-denied handling so non-trusted users remain blocked while trusted contexts bypass, overrides later `400`/`401`/`403`/`404`/`429` error responses before passive signal scoring for dynamic public/content views, enforces remaining active bans before ordinary rate-limit buckets including `/api/live/**` and rechecks after request-phase signal writes so newly created bans stop before controllers, fails open when auto-ban config storage is unavailable while setup seeds enabled state into ready databases, returns forced bare `403` with `Retry-After` and request ID without recording another passive signal, lets recovery login renders bypass active bans, lets CSRF-marked recovery submissions reach authentication while marking active-banned recovery attempts to suppress auth-failure side effects, returns bare `403` on recovery login failures, and rechecks successful submissions so only trusted contexts remain unblocked, links from Security settings to a dedicated active-ban list, sends configurable hidden Owner alerts for newly decided bans with an action link to the list, exposes Owner-minimum `/api/v1/admin/security/auto-bans` list/detail/reset endpoints through the existing `admin.settings.security` ACL gate, shows newest retained signal detail first without letting historical rows displace current evidence, enriches active-ban detail with country/continent from the latest ban-trigger Request ID's access-log entry without copying GeoIP into Security signals, and serializes active release plus Owner reset cutoff persistence with ban creation while restoring active state best-effort if cutoff persistence fails. | `dev/draft/security-hardening/auto-ban.md`, `dev/draft/security-hardening/policy-defaults.md` | `tests/Security/AutoBan/AutoBanPolicyTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php`, `tests/Security/AutoBan/AutoBanStoreTest.php`, `tests/Security/AutoBan/AutoBanAdminBrowserTest.php`, `tests/Security/AutoBan/AutoBanResetServiceTest.php`, `tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php`, `tests/Security/AutoBan/AutoBanRequestSubscriberTest.php`, `tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php`, `tests/Core/Config/CoreSettingsRegistryTest.php`, `tests/Setup/SetupDefaultSeedTest.php`, `tests/Controller/BackendControllerTest.php`, `tests/Controller/ApiAdminOperationalControllerTest.php`, `tests/Controller/RateLimitEnforcementControllerTest.php`, `tests/Controller/SchedulerControllerTest.php` | +| Services | `App\Core\Log\DatabaseLogProjector`, `App\Core\Log\DatabaseLogBrowser`, `App\Core\Log\DatabaseLogRetentionPolicy`, `App\Security\Abuse\SecuritySignalRecorder`, `App\Core\Log\LogEntryFilter`, `App\Core\Log\LogPagination` | Writes minimized database lookup projections for message, audit, and access logs in parallel to file logs, records passive `security_signal_event` rows with policy-bounded Symfony Clock-backed expiry, purges expired projection/signal rows after writes, triggers write-path auto-ban score evaluation after successful scoreable signal inserts, and browses database-backed log sources with UUID detail links, broad case-insensitive hidden-field search that casts JSON context columns before matching and escapes SQL `LIKE` wildcards literally, source-specific filter sanitization, explicit bounded page sizes up to 500 rows, clamped row-fetch pagination, configured-retention read bounds, expired-signal read filtering, and default `NOTICE+` level filtering where levels are meaningful. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md`, `dev/draft/security-hardening/auto-ban.md` | `tests/Core/Log/DatabaseLogBrowserTest.php`, `tests/Core/Log/DatabaseLogProjectorTest.php`, `tests/Core/Log/DatabaseLogRetentionPolicyTest.php`, `tests/Security/Abuse/SecuritySignalRecorderTest.php`, `tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php` | +| Services | `App\Core\Log\AdminLogBrowser`, `App\Core\Log\MonologLineParser`, `App\Core\Log\LogFileBrowser`, `App\Core\Log\LogSourceRegistry`, `App\Core\Log\LogLineReader`, `App\Core\Log\LogEntryPresenter` | Combines database-backed message, audit, access, and security-signal sources with the file-backed Symfony application log for Admin/API browsing; application file entries use 5000-line reverse tailing, explicit bounded page sizes, source-specific filter sanitization, streaming/clamped row-fetch pagination, and stable synthetic detail IDs because Symfony Monolog lines do not carry database UUIDs. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/draft/security-hardening/abuse-foundation.md` | `tests/Core/Log/AdminLogBrowserTest.php`, `tests/Core/Log/MonologLineParserTest.php`, `tests/Core/Log/LogFileBrowserTest.php`, `tests/Controller/BackendControllerTest.php` | +| Service/entity | `App\Entity\AccessStatisticEvent`, `App\Core\Statistics\VisitorIdGenerator`, `App\Core\Statistics\VisitorIdentityStoreInterface`, `App\Core\Statistics\FileVisitorIdentityStore`, `App\Core\Statistics\UserAgentClassifier`, `App\Core\Statistics\AccessStatisticsWindow`, `App\Core\Statistics\AccessStatisticsPolicy`, `App\Core\Statistics\AccessStatisticsRecorderInterface`, `App\Core\Statistics\DatabaseAccessStatisticsRecorder`, `App\Core\Statistics\AccessStatisticsAggregator`, `App\Core\Statistics\AccessStatisticsSnapshotProvider`, `App\Core\Statistics\AccessStatisticsStoreInterface`, `App\Core\Statistics\FileAccessStatisticsStore` | Records one anonymized `access_statistic_event` row per request in parallel to the raw access log, derives stable visitor IDs from signed first-party cookie tokens with a short-lived file-backed cookie/fallback alias store for no-cookie first requests, mixes normalized forwarding-header candidates into cookie-less fallback hashes only as untrusted differentiation entropy, validates stored request and visitor IDs as compact technical trace tokens, applies statistics enablement and Do Not Track policy settings, reports recorder/aggregation/store failures through the message layer, then builds persisted access-statistics snapshots under normalized platform-neutral storage roots for selectable windows with total, page, and API request counts, approximate unique visitors, status families, top routes, frequent 404 routes, GeoIP-derived location fields, browser families, device types, bot request counts, surfaces, referrer hosts, languages, and average duration while omitting IP addresses, user-agents, raw visitor-cookie tokens, cookie hashes, fallback hashes, and visitor hashes from snapshot output. | `dev/draft/0.4.x-ContactMailLogging.md`, `dev/manual/action-log-audit-snippets.md` | `tests/Entity/AccessStatisticEventTest.php`, `tests/Core/Statistics/VisitorIdGeneratorTest.php`, `tests/Core/Statistics/UserAgentClassifierTest.php`, `tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php`, `tests/Core/Statistics/AccessStatisticsAggregatorTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php`, `tests/Controller/BackendControllerTest.php` | | Constants | `App\Core\State\StateMarkerKey` | Core state marker keys for reusable current/last lifecycle metadata lookups. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Enum | `App\Core\State\StateSubjectType` | Core state marker subject types for users, ACL groups, schemas, schema versions, content items, and revisions. | `dev/draft/0.1.x-CoreArchitecture.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Service | `App\Core\State\StateMarkerRecorder` | Upserts current/last lifecycle state markers through Doctrine so marker writes stay atomic with the owning mutation, and reads ordered marker summaries for detail views such as Admin User account history. | `dev/draft/0.1.x-CoreArchitecture.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Core/State/StateMarkerRecorderTest.php`, `tests/Controller/UserControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | @@ -216,9 +224,9 @@ | Interface/service | `App\Setup\SetupCommandExecutorInterface`, `App\Setup\ProcessSetupCommandExecutor` | Abstraction and default Symfony Process executor for setup subprocess calls so CLI and web setup share command execution behavior, including local Composer environment fallback values and web/CGI environment filtering. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/ProcessSetupCommandExecutorTest.php` | | Value object | `App\Setup\SetupCommandResult` | Value object for setup subprocess exit code, output, and error output. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Service | `App\Setup\SetupDatabaseConnectionFactory` | Creates DBAL setup connections from Doctrine database URLs across SQLite, MySQL/MariaDB, and PostgreSQL, including setup-time Symfony placeholder resolution and platform-safe SQLite path handling. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDatabaseConnectionFactoryTest.php`, `tests/Setup/SetupPasswordResetRunnerTest.php`, `tests/Setup/SetupRunnerTest.php` | -| Service | `App\Setup\SetupDefaultSeed` | Central setup default database seed for input-aware settings backed by the shared config default provider, optional ACL group seeds, the initial static-page schema/version, and setup home content defaults. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDefaultSeedTest.php`, `tests/Setup/SetupRunnerTest.php`, `tests/Operations/TestDatabaseSeedTest.php` | -| Services | `App\Setup\SetupDatabaseSeeder`, `App\Setup\SetupConfigSeeder`, `App\Setup\SetupAdminAccountSeeder`, `App\Setup\SetupInitialContentSeeder`, `App\Setup\SetupStateMarkerWriter` | Coordinates DBAL-backed setup seeding through focused config, owner/admin-account, initial-content, and state-marker writers while keeping seed data centralized in `SetupDefaultSeed`, including runtime-backed default settings such as API availability/CORS defaults, and failing setup when required config writes cannot be persisted. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupDefaultSeedTest.php` | -| Service | `App\Setup\SetupDryRunPlanner` | Builds setup dry-run ActionLog steps from the shared default seed, describing planned writes, commands, settings, admin seed data, cache clearing, package discovery/rebuild subprocesses, and completion marking without mutating state, while keeping PHP CLI command planning non-throwing when no usable CLI binary is available. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | +| Service | `App\Setup\SetupDefaultSeed` | Central setup default database seed for input-aware settings backed by the shared config default provider, sensitive GeoIP2 credential defaults, optional ACL group seeds, the initial static-page schema/version, and setup home content defaults. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupDefaultSeedTest.php`, `tests/Setup/SetupRunnerTest.php`, `tests/Operations/TestDatabaseSeedTest.php` | +| Services | `App\Setup\SetupDatabaseSeeder`, `App\Setup\SetupConfigSeeder`, `App\Setup\SetupAdminAccountSeeder`, `App\Setup\SetupInitialContentSeeder`, `App\Setup\SetupStateMarkerWriter` | Coordinates DBAL-backed setup seeding through focused config, owner/admin-account, initial-content, and state-marker writers while keeping seed data centralized in `SetupDefaultSeed`, including runtime-backed default settings such as API availability/CORS and GeoIP2 defaults, and failing setup when required config writes cannot be persisted. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupDefaultSeedTest.php` | +| Service | `App\Setup\SetupDryRunPlanner` | Builds setup dry-run ActionLog steps from the shared default seed, describing planned writes, commands, settings, admin seed data, cache clearing, extension discovery/rebuild subprocesses, and completion marking without mutating state, while keeping PHP CLI command planning non-throwing when no usable CLI binary is available. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Service/value object | `App\Setup\SetupEnvironmentWriter`, `App\Setup\SetupEnvironmentSnapshot` | Writes setup environment overrides into `.env.{APP_ENV}.local` while preserving unrelated keys, and snapshots pre-existing env files so setup rollback can restore unrelated configuration instead of deleting it; snapshot restore failures are reported in rollback context without hiding the original setup error. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupEnvironmentSnapshotTest.php` | | Value object/service | `App\Setup\SetupInput`, `App\Setup\SetupInputNormalizer`, `App\Setup\SetupInputValidator` | Callable setup input DTO for installer language, site metadata, database connection data, admin credentials, APP_SECRET handling, and dry-run mode, plus shared CLI/web normalization and validation for database drivers, database URLs, table prefixes, boolean flags, default admin email values, supported languages, default URI, admin credentials, and APP_SECRET length. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupCliInputFactoryTest.php`, `tests/Setup/SetupWebInputFactoryTest.php`, `tests/Setup/SetupInputValidatorTest.php` | | Service | `App\Setup\SetupLanguageCatalog` | Discovers available setup languages through the shared language catalogue discovery service while keeping setup-safe configured-default fallback behavior. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupLanguageCatalogTest.php`, `tests/Setup/SetupRunnerTest.php` | @@ -228,7 +236,7 @@ | Service | `App\Setup\SetupCompletionMarker` | DB-free setup completion marker that writes `APP_SETUP_COMPLETED` into Composer's dumped `.env.local.php` array and checks the already-loaded environment value so `/setup` can be locked after installation without requiring a database connection. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Setup/SetupCompletionMarkerTest.php`, `tests/Setup/SetupRunnerTest.php` | | Service | `App\Setup\SetupPasswordResetRunner` | Callable setup recovery runner that finds users and resets password hashes with shared password-policy validation and ActionLog output. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Setup/SetupPasswordResetRunnerTest.php` | | Value object | `App\Setup\SetupPasswordResetUser` | Read model for displaying UID, username, email, and status before a setup password reset. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Setup/SetupPasswordResetRunnerTest.php` | -| Service | `App\Setup\SetupRunner`, `App\Setup\SetupRuntimeCommandRunner`, `App\Setup\SetupDatabaseEnvironmentScope`, `App\Setup\SetupRunInputValidator`, `App\Setup\SetupStepAction`, `App\Setup\SetupPasswordPolicy`, `App\Setup\SetupRollbacker`, `App\Setup\SetupDatabaseTableSnapshot`, `App\Setup\SetupLiveOperationPayloadProtector`, `App\Setup\SetupWizardState` | Shared first-run setup runner that validates installer language and setup input policy, writes env overrides, delegates dump-env/migration/cache/package discovery/asset rebuild subprocesses to focused runtime command services, seeds default settings/admin/content data through a database-ready environment scope, stops any stale local Mercure hub before the post-seed health check can restart it with persisted setup secrets, exposes each setup step as a LiveOperation action, and marks setup complete with ActionLog output and dry-run planning; failed setup runs restore pre-existing env files and clean up only tables that were not present before this setup run across SQLite, MySQL/MariaDB, and PostgreSQL with a visible rollback summary message on the failed ActionLog step, while dry-run preparation avoids database table snapshots and file creation; web live-operation setup payloads protect setup secrets through the central context-labeled secret payload protector before they are stored on disk, and the wizard session key is shared centrally for controller cleanup after completion. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupLiveOperationPayloadProtectorTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | +| Service | `App\Setup\SetupRunner`, `App\Setup\SetupRuntimeCommandRunner`, `App\Setup\SetupDatabaseEnvironmentScope`, `App\Setup\SetupRunInputValidator`, `App\Setup\SetupStepAction`, `App\Setup\SetupPasswordPolicy`, `App\Setup\SetupRollbacker`, `App\Setup\SetupDatabaseTableSnapshot`, `App\Setup\SetupLiveOperationPayloadProtector`, `App\Setup\SetupWizardState` | Shared first-run setup runner that validates installer language and setup input policy, writes env overrides, delegates dump-env/migration/cache/extension discovery/asset rebuild subprocesses to focused runtime command services, seeds default settings/admin/content data through a database-ready environment scope, stops any stale local Mercure hub before the post-seed health check can restart it with persisted setup secrets, exposes each setup step as a LiveOperation action, and marks setup complete with ActionLog output and dry-run planning; failed setup runs restore pre-existing env files and clean up only tables that were not present before this setup run across SQLite, MySQL/MariaDB, and PostgreSQL with a visible rollback summary message on the failed ActionLog step, while dry-run preparation avoids database table snapshots and file creation; web live-operation setup payloads protect setup secrets through the central context-labeled secret payload protector before they are stored on disk, and the wizard session key is shared centrally for controller cleanup after completion. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php`, `tests/Setup/SetupLiveOperationPayloadProtectorTest.php`, `tests/Core/Operation/LiveOperationQueueFactoryTest.php` | | Service | `App\Setup\SetupSensitiveValueMasker` | Masks setup secrets in ActionLog contexts, especially database URL passwords in dry-run output. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupRunnerTest.php` | | Event subscriber | `App\Setup\SetupRedirectSubscriber` | Redirects main requests to `/setup` before setup completion using only the loaded environment marker, while allowing setup, profiler, toolbar, and static asset paths without touching Doctrine/DBAL; after setup completion it clears any remaining wizard session state. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Setup/SetupRedirectSubscriberTest.php` | | Exception | `App\Setup\SetupStepFailedException` | Setup-specific RuntimeException for failed subprocess or setup execution steps, with optional structured message payloads for recoverable localized setup failures. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Setup/SetupCompletionMarkerTest.php`, `tests/Setup/SetupRunnerTest.php` | @@ -258,8 +266,8 @@ | Value object | `App\Content\Routing\ContentRedirectTarget` | Value object for resolved redirect targets, distinguishing internal routes from external URLs. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRedirectResolverTest.php` | | Enum | `App\Content\Routing\ContentRedirectTargetType` | Enum for redirect target kinds such as internal route and external URL. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRedirectResolverTest.php` | | Value object | `App\Content\Routing\ContentRoutePath` | Value object for normalized content paths with optional trailing generic variant marker extraction. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Read/PublishedContentResolverTest.php`, `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/PublicContentRedirectTest.php` | -| Value object | `App\Content\Routing\ContentSlug` | Value object for strict lowercase ASCII content slug validation. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentSlugTest.php` | -| Service | `App\Content\Routing\ContentRouteGuard` | Guard for reserved public route prefixes such as `admin`, `editor`, `setup`, `cron`, `system`, and `user`, plus normalized content paths. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRouteGuardTest.php` | +| Value object | `App\Content\Routing\ContentSlug` | Value object for centralized lowercase ASCII content slug validation with digit-prefix support for natural public URLs. | `dev/draft/0.1.x-StaticDynamicContent.md`, `dev/manual/content-schema-snippets.md` | `tests/Content/Routing/ContentSlugTest.php`, `tests/Core/Validation/IdentifierSpecTest.php` | +| Service | `App\Content\Routing\ContentRouteGuard` | Guard for reserved public route prefixes including app, API, generated asset, profiler/toolbar, media, extension, and system namespaces, plus normalized content paths. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Content/Routing/ContentRouteGuardTest.php` | | Value object | `App\Content\Routing\ContentSystemRoute` | Defines the root parent sentinel plus the internal-only `/system/...` content namespace and virtual parent marker. | `dev/draft/0.1.x-StaticDynamicContent.md` | `tests/Entity/ContentItemTest.php`, `tests/Content/Read/PublishedContentResolverTest.php` | | Value object | `App\Content\Schema\ContentSchemaField` | Constants for reserved required base field identifiers that every content schema must define. | `dev/draft/0.3.x-SchemaContentFields.md` | `tests/Content/Schema/ContentSchemaFieldTest.php` | | Enum | `App\Content\Schema\ContentSchemaSource` | Enum for schema sources such as preset, custom, and module. | `dev/draft/0.3.x-SchemaContentFields.md` | `tests/Entity/ContentSchemaTest.php` | @@ -273,7 +281,7 @@ | Entity | `App\Entity\AclGroup` | Contextual ACL group with a generic administrative name, minimum assignable role, metadata, and many-to-many user membership. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Entity | `App\Entity\UserAccount` | User account model with profile/settings JSON, account status, exact global role, many-to-many contextual ACL group membership, and Symfony role exposure; lifecycle metadata lives in state markers. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Entity | `App\Entity\ApiKey` | API key model storing prefix, HMAC lookup hash, encrypted key payload, owner, and read/write or revoked status. | `dev/draft/0.4.x-ApiLayer.md` | `tests/Entity/CoreDatabaseModelTest.php` | -| Entity/helper | `App\Entity\ExtensionPackage`, `App\Core\Package\ExtensionPackageIdentity` | Scoped package management record with manifest/install metadata, activation state, scope list, and package-owned identity/scope validation helpers. | `dev/draft/0.2.x-PluginModules.md` | `tests/Entity/CoreDatabaseModelTest.php` | +| Entity/helper | `App\Entity\Extension`, `App\Core\Extension\ExtensionIdentity` | Scoped extension management record with manifest/install metadata, activation state, scope list, and extension-owned identity/scope validation helpers. | `dev/draft/0.2.x-PluginModules.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Entity | `App\Entity\SiteMenu` | Future menu container with translatable labels and ordered menu items. | `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Entity/CoreDatabaseModelTest.php` | | Entity | `App\Entity\SiteMenuItem` | Future menu item with validated target metadata and mutable view ACL override fields. | `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Entity/CoreDatabaseModelTest.php`, `tests/Controller/AdminUserControllerTest.php` | | Entity | `App\Entity\ContentSchema` | Database-backed content type definition with nullable active schema version for disable/staging/cleanup flows. | `dev/draft/0.3.x-SchemaContentFields.md` | `tests/Entity/ContentSchemaTest.php` | @@ -293,13 +301,13 @@ | Event payload | `App\Navigation\Event\NavigationBuilderEvent` | Public mutable extend hook for adding, removing, or reordering flat navigation items before tree normalization and render slicing. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/Navigation/NavigationBuilderTest.php`, `tests/Core/Event/PublicEventHookRegistryTest.php` | | Service | `App\Localization\LanguageCatalogueDiscovery`, `App\Localization\TranslationLanguageCatalog`, `App\Localization\LocalePreferenceResolver`, `App\Localization\RequestLocaleSubscriber`, `App\Localization\UserProfileLocaleService` | Discovers available application/setup languages from synchronized environment-specific runtime and source translation catalogues through one shared catalogue scanner, then resolves supported locale preferences for request, profile, and mail flows from strict URL prefixes, authenticated user settings, session/request state, or configured default language so backend, frontend, account, localized content, and profile settings routes share one dynamic language policy. | `dev/draft/0.1.x-StaticDynamicContent.md`, `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-MailerDeliveryContract.md` | `tests/Localization/LanguageCatalogueDiscoveryTest.php`, `tests/Localization/TranslationLanguageCatalogTest.php`, `tests/Localization/LocalePreferenceResolverTest.php`, `tests/Localization/RequestLocaleSubscriberTest.php`, `tests/Mail/MailLocaleResolverTest.php`, `tests/Controller/PublicContentLocalizationTest.php`, `tests/Controller/UserControllerTest.php`, `tests/Controller/UserProfileControllerTest.php` | | Service/model | `App\View\MarkdownRenderer`, `App\View\MarkdownProfile`, `App\View\MarkdownEmbedAdapter` | Profile-aware Markdown renderer for README, trusted design, rich allrounder, and safety-first basic rendering, including controlled YouTube embeds for trusted design Markdown. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/manual/theme-module-developer-guidelines.md` | `tests/View/MarkdownRendererTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | -| Service | `App\View\SystemPackageMetadataProvider` | Exposes immutable virtual system package metadata from the root `.manifest`, including name, author, description, version, source, and channel details for package/theme UI and Twig context. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/SystemPackageMetadataProviderTest.php` | -| Service | `App\View\PackageMacroRegistry` | Provides namespaced core macro template paths and future package macro namespace slots. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/PackageMacroRegistryTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | +| Service | `App\View\SystemExtensionMetadataProvider` | Exposes immutable virtual system extension metadata from the root `.manifest`, including name, author, description, version, source, and channel details for extension/theme UI and Twig context. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/SystemExtensionMetadataProviderTest.php` | +| Service | `App\View\ExtensionMacroRegistry` | Provides namespaced core macro template paths and future extension macro namespace slots. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/ExtensionMacroRegistryTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | | Service | `App\View\Chart\ChartFactory` | Builds Symfony UX Chart.js line, bar, and doughnut charts with shared labels/datasets/options conventions for dashboard and statistics panels. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Chart/ChartFactoryTest.php` | -| Event payload | `App\View\ViewContextEvent` | Public mutable event used by active package extensions to add universal Twig view context before rendering. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/ViewContextProviderTest.php` | -| Service | `App\View\ViewContextProvider` | Builds the universal Twig view context with system package metadata, macro namespaces, and event-collected extension variables. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/ViewContextProviderTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | -| Twig extensions | `App\View\Twig\ViewContextTwigExtension`, `App\View\Twig\ViewRuntimeTwigExtension`, `App\View\Twig\AdminViewTwigExtension` | Expose branding-neutral Twig helpers for view context, macro namespace lookup, public hook descriptors, profile-aware Markdown rendering, actor-aware navigation trees, safe HTML attributes, debug/request trace diagnostics, settings form builders, backend action definitions, package settings, theme/package summaries, and footer copyright rendering through separated helper families. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/View/Twig/ViewTwigExtensionTest.php`, `tests/Controller/BackendControllerTest.php` | -| Event payload/policy | `App\View\Event\ResponseHeadersEvent`, `App\View\Http\ResponseHeaderPolicy` | Public mutable extend hook for adding or removing ordinary safe HTTP response headers before sending the main response, with a policy that blocks invalid values plus cookie, authentication, transport, content-length, and core security header mutations from package listeners. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php` | +| Event payload | `App\View\ViewContextEvent` | Public mutable event used by active extensions to add universal Twig view context before rendering. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/ViewContextProviderTest.php` | +| Service | `App\View\ViewContextProvider` | Builds the universal Twig view context with system extension metadata, macro namespaces, and event-collected extension variables. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/ViewContextProviderTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | +| Twig extensions | `App\View\Twig\ViewContextTwigExtension`, `App\View\Twig\ViewRuntimeTwigExtension`, `App\View\Twig\AdminViewTwigExtension` | Expose branding-neutral Twig helpers for view context, macro namespace lookup, public hook descriptors, profile-aware Markdown rendering, actor-aware navigation trees, safe HTML attributes, debug/request trace diagnostics, settings form builders, backend action definitions, extension settings, theme/extension summaries, and footer copyright rendering through separated helper families. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/View/Twig/ViewTwigExtensionTest.php`, `tests/Controller/BackendControllerTest.php` | +| Event payload/policy | `App\View\Event\ResponseHeadersEvent`, `App\View\Http\ResponseHeaderPolicy` | Public mutable extend hook for adding or removing ordinary safe HTTP response headers before sending the main response, with a policy that blocks invalid values plus cookie, authentication, transport, content-length, and core security header mutations from extension listeners. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php` | | Event payload | `App\View\Event\OutputGeneratedEvent` | Public mutable extend hook for adjusting generated HTML output after rendering and before sending the main response. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php` | | Event subscriber | `App\View\Http\ResponseHookSubscriber` | Dispatches public response header and generated HTML output hooks for the main response while keeping failed hook mutations out of the final response. | `dev/draft/0.2.x-EventHooksBuses.md` | `tests/View/Http/ResponseHookSubscriberTest.php` | | Services | `App\View\Alert\UiAlert`, `App\View\Alert\UiAlertTopicFactory`, `App\View\Alert\UiAlertUserIdentityResolverInterface`, `App\View\Alert\DoctrineUiAlertUserIdentityResolver`, `App\View\Alert\UiAlertPublisherInterface`, `App\View\Alert\MercureUiAlertPublisher` | Defines the stable UI-alert payload with level, mode, actions, loading state, system-owned HMAC-bound user/session Mercure URN topic syntax derived from canonical account UIDs, resolvable usernames, or existing session cookies without minting new sessions, and publisher API for targeted frontend alerts without broadcasting unrelated message-layer entries. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertTopicFactoryTest.php`, `tests/View/Alert/MercureUiAlertPublisherTest.php` | @@ -315,18 +323,18 @@ | Routes `backend_admin_users`, `backend_admin_deleted_users`, `backend_admin_deleted_users_cleanup`, `backend_admin_deleted_user_activate`, `backend_admin_deleted_user_deactivate`, `backend_admin_user_detail`, `backend_admin_user_password_reset` | `App\Controller\AdminUserController` | Admin account list/detail HTTP adapter for searchable/paginated non-deleted accounts, retained deleted-account inspection, retention cleanup, account lifecycle marker summaries, filtered Audit Log links, status/role/group forms, and password-reset actions; assignment options, update mutations, deleted-account status changes, and reset-token creation live in Security services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_groups`, `backend_admin_user_group_detail`, `backend_admin_user_group_delete` | `App\Controller\AdminAclGroupController` | Admin ACL group routes with `details/` dynamic paths for searchable/paginated ACL groups, group detail member summaries, create/update/delete actions, hierarchy guardrails, impact review before updates/deletes, and LiveLog-backed group apply. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `backend_admin_user_reviews`, `backend_admin_user_review_reactivate`, `backend_admin_user_review_delete` | `App\Controller\AdminUserReviewController` | Admin review queue routes with `details/` disputed-account actions for contextual registration/invitation/dispute rows plus disputed-account reactivation and confirmed delete actions. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserReviewControllerTest.php`, `tests/Controller/AdminUserControllerTest.php` | -| Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | +| Routes `backend_admin_user_invite`, `backend_admin_user_invitation_approve`, `backend_admin_user_invitation_reissue`, `backend_admin_user_invitation_revoke` | `App\Controller\AdminUserInvitationController` | Thin HTTP adapter for admin invitation and account-link token routes; CSRF/access/redirect handling stays in the controller while role/group-aware invitation creation, deleted-account invitation reactivation, registration approval/rejection, token-state-derived review/user ACL selection, state-revalidated pending-token reissue, and revocation run through Security workflow services. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/AdminUserControllerTest.php` | | Routes `user_index`, `user_profile`, `user_profile_close`, `user_password` | `App\Controller\UserController` | Authenticated user account HTTP adapter for profile editing, optional self-service username changes, profile language updates, self-service account closure, and password changes; locale options/application, closure mutations, and security-review token delivery live in focused Localization and Security services. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/UserProfileControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_api_keys`, `user_api_key_reveal`, `user_api_key_revoke` | `App\Controller\UserApiKeyController` | Authenticated user API-key routes for generation, revocation, and password-confirmed reveal of encrypted key material. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-ApiLayer.md` | `tests/Controller/UserApiKeyControllerTest.php`, `tests/Controller/UserControllerTest.php` | | Routes `user_register`, `user_invitation_accept` | `App\Controller\UserRegistrationController` | Public registration and invitation routes for disabled/admin-approval/auto-approval registration, existing-account notices, optional default registration groups, deleted-account reactivation with token role/group reset, and invitation/registration token acceptance through a Security-owned mutation service. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | | Routes `user_reset_password`, `user_password_reset_token`, `user_security_review` | `App\Controller\UserPasswordRecoveryController` | Public password recovery and security-review routes for non-enumerating reset requests, reset completion, password-change review links, and password-change dispute locking. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/UserControllerTest.php` | -| Routes `backend_setup_index`, `backend_setup_step` | `App\Controller\SetupController` | DB-free web setup wizard adapter for language selection, preflight checks, site/settings input, driver-aware database details, first OWNER account data, review, setup runner execution, live-operation setup apply dispatch, result rendering, setup-completion locking, locale application, and setup-state cleanup after successful completion. Step transitions, state persistence, and database test execution live in setup services. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | -| Routes `backend_admin_index`, `backend_admin_route`, `backend_admin_log_detail`, `backend_editor_*` | `App\Controller\BackendController` | Native backend/editor dispatcher for area route resolution, area access checks, route messages, backend navigation context, generated Admin Settings submissions, and Admin Log detail rendering. Focused package and operation routes live in dedicated Admin controllers. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | -| Routes `backend_admin_package_*`, `backend_admin_operation_*` | `App\Controller\AdminPackageController`, `App\Controller\AdminOperationController` | Focused Admin package install/detail/lifecycle routes plus Admin Operations maintenance, detail, and review-continuation routes. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | +| Routes `backend_setup_index`, `backend_setup_step` | `App\Controller\SetupController` | DB-free web setup wizard adapter whose pre-completion ordinary wizard navigation is skipped by rate-limit enforcement except static suspicious-probe matching, while the final `POST /setup/review` apply submission may consume the setup-apply limiter before the controller handles language selection, preflight checks, site/settings input, driver-aware database details, first OWNER account data, review, setup runner execution, live-operation setup apply dispatch, result rendering, setup-completion locking, locale application, and setup-state cleanup after successful completion. Step transitions, state persistence, and database test execution live in setup services. | `dev/draft/0.1.x-SetupTestAutomation.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | +| Routes `backend_admin_index`, `backend_admin_route`, `backend_admin_log_detail`, `backend_editor_*` | `App\Controller\BackendController` | Native backend/editor dispatcher for area route resolution, area access checks, route messages, backend navigation context, generated Admin Settings submissions, and Admin Log detail rendering. Focused extension and operation routes live in dedicated Admin controllers. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/BackendControllerTest.php` | +| Routes `backend_admin_extension_*`, `backend_admin_operation_*` | `App\Controller\AdminExtensionController`, `App\Controller\AdminOperationController` | Focused Admin extension install/detail/lifecycle routes plus Admin Operations maintenance, detail, and review-continuation routes that require both mutable operations access and mutable target-domain access before starting continuation work. | `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/draft/security-hardening/admin-acl-enforcement.md` | `tests/Controller/BackendControllerTest.php` | | Routes `api_live_operation_status`, `api_live_operation_continue` | `App\Controller\LiveOperationController` | Public but token-protected JSON endpoints for live ActionLog operation state and provider-declared review continuations below the reserved `/api/live/**` internal API branch. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/LiveOperationControllerTest.php` | -| Route `api_live_package_dispatch` | `App\Controller\LiveEndpointController` | Dispatches package-owned live endpoint definitions below `/api/live/{package_slug}/...` while system routes keep priority for reserved live branches, endpoint minimum access levels are enforced before handler execution, and pattern matches are rejected when the matched route slug does not belong to the endpoint owner. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Package/PackageLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php` | +| Route `api_live_extension_dispatch` | `App\Controller\LiveEndpointController` | Dispatches extension-owned live endpoint definitions below `/api/live/{extension_slug}/...` while system routes keep priority for reserved live branches, endpoint minimum access levels are enforced before handler execution, and pattern matches are rejected when the matched route slug does not belong to the endpoint owner. | `dev/draft/0.4.x-ApiLayer.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Core/Extension/ExtensionLiveContributionGuardTest.php`, `tests/Controller/LiveEndpointControllerTest.php` | | Route `privacy_cookie_consent` | `App\Controller\CookieConsentController` | Validates visitor-bound stateless cookie-consent form tokens, stores selected optional cookie consent in a signed long-lived required cookie, expires optional cookies whose consent was withdrawn, and redirects back only to safe relative paths. | `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Privacy/Cookie/CookieConsentManagerTest.php` | -| Package routes `/demo`, `/demo/backend`, `/demo/typography` | `packages/demo-module/package.php` | Optional demo module runtime registering portable public demo routes, menu entries, shell previews, Markdown typography guide, and demo module settings through static view injection. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Controller/DemoControllerTest.php` | +| Extension routes `/demo`, `/demo/typography` | `extensions/demo-module/extension.php` | Optional demo module runtime registering portable public info and Markdown typography routes, menu entries, dynamic content injection, and demo module settings through static view injection. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-PluginModules.md` | `extensions/demo-module/tests/Controller/DemoControllerTest.php` | | Stimulus `code-editor` | `assets/controllers/code_editor_controller.js` | Lazily mounts CodeMirror editors with CSS, HTML, JavaScript, JSX, JSON, Markdown, PHP, TypeScript, and TSX language support. | N/A | N/A | | Stimulus `dialog` | `assets/controllers/dialog_controller.js` | Opens and closes native `` overlays through declarative actions and backdrop clicks so backend/frontend templates do not need inline JavaScript for modal behavior. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/controller_foundation.test.mjs` | | Stimulus `clipboard` | `assets/controllers/clipboard_controller.js` | Copies explicit, source-target, or element text through the Clipboard API with a textarea fallback and optional status feedback for API keys, tokens, debug snippets, and future copyable admin output. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/controller_foundation.test.mjs` | @@ -340,43 +348,43 @@ | `bin/lint` | `bin/lint` | Runs the project-wide validation suite for PHP syntax, Symfony container wiring, Twig/YAML syntax, JavaScript modules, JSON files, Markdown parsing through CommonMark/GFM, local Symfony UX icon references, Tailwind CSS buildability, translation-source key drift, and non-Markdown Git whitespace checks; accepts optional file/directory targets or `--diff`/`--diff=` for focused type-based linting of changed files, with focused CSS lint treating parser errors on known Tailwind directives, generated modern group at-rules, and empty custom-property fallbacks as informational only after reparsing a normalized CSS view while `tailwind:build` remains authoritative. | `dev/draft/0.1.x-SetupTestAutomation.md` | N/A | | `bin/jstest` | `bin/jstest`, `tests/assets/**/*.test.mjs` | Runs lightweight DOM-free JavaScript behavior tests through Node.js built-in `node --test` without a `node_modules` dependency tree, accepting focused test files or Node test-runner options and skipping successfully with a visible message when Node.js is unavailable. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/alert_payload.test.mjs`, `tests/assets/controller_foundation.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/live_poll.test.mjs` | | `bin/setup` | `bin/setup` | CLI adapter for the shared setup runner with defaults, selected-environment operation logging, and optional JSON output for automation. | `dev/draft/0.1.x-SetupTestAutomation.md` | `tests/Operations/SetupScriptTest.php`, `tests/Setup/SetupRunnerTest.php` | -| `packages:discover` | `App\Command\PackageDiscoveryCommand` | Queues package discovery with JSON output and trigger context, with explicit `--run-now` recovery support for synchronous execution. | `dev/manual/package-lifecycle-snippets.md` | `tests/Command/PackageDiscoveryCommandTest.php`, `tests/Core/Package/PackageDiscoveryRunnerTest.php` | -| `packages:assets:sync` | `App\Command\PackageAssetSyncCommand` | Mirrors active package assets and rewrites package asset registries with dry-run and JSON output support. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php`, `tests/Core/Package/PackageAssetSyncerTest.php` | -| `assets:rebuild` | `App\Command\AssetRebuildCommand` | Runs or queues the full package-aware asset rebuild queue with dry-run, progress, JSON output, Messenger dispatch, optional dependency preparation, and final cache clear. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php`, `tests/Core/Asset/AssetRebuildQueueFactoryTest.php` | +| `extensions:discover` | `App\Command\ExtensionDiscoveryCommand` | Queues extension discovery with JSON output and trigger context, with explicit `--run-now` recovery support for synchronous execution. | `dev/manual/extension-lifecycle-snippets.md` | `tests/Command/ExtensionDiscoveryCommandTest.php`, `tests/Core/Extension/ExtensionDiscoveryRunnerTest.php` | +| `extensions:assets:sync` | `App\Command\ExtensionAssetSyncCommand` | Mirrors active extension assets and rewrites extension asset registries with dry-run and JSON output support. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php`, `tests/Core/Extension/ExtensionAssetSyncerTest.php` | +| `assets:rebuild` | `App\Command\AssetRebuildCommand` | Runs or queues the full extension-aware asset rebuild queue with dry-run, progress, JSON output, Messenger dispatch, optional dependency preparation, and final cache clear. | `dev/manual/frontend-asset-snippets.md` | `tests/Command/AssetRebuildCommandTest.php`, `tests/Core/Asset/AssetRebuildQueueFactoryTest.php` | | `mercure:install`, `mercure:start`, `mercure:stop`, `mercure:health`, `mercure:check` | `App\Command\MercureInstallCommand`, `App\Command\MercureStartCommand`, `App\Command\MercureStopCommand`, `App\Command\MercureHealthCommand`, `App\Command\MercureCheckCommand` | Manage and inspect the optional local Mercure hub binary and health state; install fails non-zero when the binary cannot be installed, start installs missing binaries, uses the release-provided Caddyfile with protected envfile JWT secrets, and enables anonymous subscribers for HMAC-bound public alert topics, stop targets the stored PID first and then exact-binary fallback processes before reporting success, health retries configured publish endpoint recovery, treats disabled Mercure as a successful configured no-op, requires a successful authenticated publish POST plus public EventSource subscribe reachability before enabling push, stops the hub when only the public endpoint is unreachable, check prints current read-only diagnostics including tracked process, local endpoint reachability, configured publish URL/status, and public endpoint reachability, and polling fallback remains available when the hub is unavailable. | `dev/draft/0.4.x-OperationalAdminWorkflows.md`, `dev/manual/web-server-configuration.md` | `tests/Core/Mercure/MercureRuntimeTest.php`, `tests/Command/MercureHealthCommandTest.php` | | `statistics:snapshot` | `App\Command\AccessStatisticsSnapshotCommand` | Refreshes the stored access-statistics snapshot for a selected window, returning a clean skipped result when statistics are disabled and a failing exit code when snapshot storage fails. | `dev/draft/0.4.x-Scheduler.md` | `tests/Command/AccessStatisticsSnapshotCommandTest.php`, `tests/Core/Statistics/AccessStatisticsSnapshotProviderTest.php` | | `operations:run` | `App\Command\LiveOperationRunCommand` | Atomically claims one staged live operation in a detached console process, writes running/finished/review-required ActionLog entries back to the live-operation store for UI polling, and cleans expired operation artifacts after execution. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationRunStoreTest.php`, `tests/Controller/LiveOperationControllerTest.php` | | `operations:cleanup` | `App\Command\LiveOperationCleanupCommand` | Removes expired terminal and stale active live-operation state plus runner output files from `var/operations/{APP_ENV}`. | `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationRunStoreTest.php` | | `account-tokens:cleanup` | `App\Command\AccountTokenCleanupCommand` | Removes expired account tokens while preserving used security-review dispute tokens until their linked inactive account is resolved. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-Scheduler.md` | `tests/Command/AccountTokenCleanupCommandTest.php` | | `acl-groups:apply` | `App\Command\AclGroupApplyCommand` | Applies a reviewed ACL group update or delete from the console for maintenance/debugging and reuses the same apply service as the LiveLog action. | `dev/draft/0.2.x-SecurityAccessControl.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/AdminUserControllerTest.php` | -| `packages:lifecycle` | `App\Command\PackageLifecycleCommand` | Applies a package lifecycle action from the console so the live operation runner can execute package activation, deactivation, reset, purge, and delete outside the original page request. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/BackendControllerTest.php` | -| Package validator | `App\Core\Package\PackageValidator`, `App\Core\Package\PackageTemplatePathValidator`, `App\Core\Package\PackageTemplateReferenceValidator`, `App\Core\Package\PackageCssNamespaceValidator`, `App\Core\Package\PackageFilePolicy`, `App\Core\Package\PackagePhpCapabilityPolicy` | Validates package shape, lints package files with Tailwind directive tolerance only when a second strict parse without Tailwind directive lines succeeds, inspects package capabilities, enforces template scope, template-reference, macro namespace, and package-owned CSS target class namespace rules, blocks unsafe installable package payload paths, blocks direct filesystem/process/network/environment PHP capabilities, and emits non-blocking package-policy warnings for development-only payloads. | `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Package/PackageValidatorTest.php`, `tests/Core/Package/PackageFixtureTest.php` | +| `extensions:lifecycle` | `App\Command\ExtensionLifecycleCommand` | Applies an extension lifecycle action from the console so the live operation runner can execute extension activation, deactivation, reset, purge, and delete outside the original page request. | `dev/draft/0.2.x-PluginModules.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Core/Operation/LiveOperationQueueFactoryTest.php`, `tests/Controller/BackendControllerTest.php` | +| Extension validator | `App\Core\Extension\ExtensionValidator`, `App\Core\Extension\ExtensionTemplatePathValidator`, `App\Core\Extension\ExtensionTemplateReferenceValidator`, `App\Core\Extension\ExtensionCssNamespaceValidator`, `App\Core\Extension\ExtensionFilePolicy`, `App\Core\Extension\ExtensionPhpCapabilityPolicy` | Validates extension shape, lints extension files with Tailwind directive tolerance only when a second strict parse without Tailwind directive lines succeeds, inspects extension capabilities, enforces template scope, tokenized template-reference, macro namespace, and extension-owned CSS target class namespace rules, blocks unsafe installable extension payload paths, blocks direct and dynamic filesystem/process/network/environment PHP capabilities, and emits non-blocking extension-policy warnings for development-only payloads. | `dev/manual/theme-module-developer-guidelines.md` | `tests/Core/Extension/ExtensionValidatorTest.php`, `tests/Core/Extension/ExtensionFixtureTest.php` | ## 10. Components and Templates | Entry point | Symbol | Purpose | Docs | Tests | |-------------|--------|---------|------|-------| -| Layout templates | `templates/frontend/frontend.html.twig`, `templates/backend/{admin,editor,setup}.html.twig`, `templates/*/layouts/*.html.twig` | Native frontend, admin, editor, setup, and optional area layout skeletons. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/DemoControllerTest.php` | -| Demo module templates | `packages/demo-module/templates/{frontend,backend}/demo-module/*.html.twig` | Portable package-owned render targets for previewing native frontend/backend shells, shared primitives, Markdown typography profiles, forms, status badges, empty states, package tables, and operation panels before production UI routes exist. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-PluginModules.md` | `tests/Controller/DemoControllerTest.php` | -| Shared area partials | `templates/partials/**/*.html.twig`, `templates/frontend/partials/**/*.html.twig`, `templates/backend/partials/**/*.html.twig` | Granular native layout, system footer, navigation, typography, root-scoped alert feedback, action button, toolbar, and form field partials that establish early override points for themes and packages. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Twig/ViewTwigExtensionTest.php` | -| Scoped Twig components | `templates/components/*.html.twig`, `templates/frontend/components/*.html.twig`, `templates/backend/components/*.html.twig` | Namespace-aware root, frontend, and backend UI primitives for alerts, the notification-center alert stack, buttons, button groups, page headers, empty states, Chart.js panels, and coordinate-based UX Map views, resolved through the same Twig namespace path order used by active themes and packages. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Twig/TwigComponentNamespaceTest.php`, `php bin/console debug:twig-component root:AlertStack`, `php bin/console debug:twig-component root:ChartPanel`, `php bin/console debug:twig-component root:MapView`, `php bin/console debug:twig-component frontend:Button`, `php bin/console debug:twig-component backend:PageHeader` | -| Backend area index/message templates | `templates/backend/{admin,editor,setup}/{index,message}.html.twig`, `templates/backend/admin/{packages,themes,operations,section}.html.twig`, `templates/backend/admin/packages/*.html.twig`, `templates/backend/admin/settings/*.html.twig` | Minimal native render targets for backend area routing, localized message-layer feedback, package/theme/admin placeholder view registration, package detail/lifecycle review screens, transient live-operation inspection, cleanup, retained detail views with review continuation controls, typed settings forms, and backend navigation before feature-specific pages are added. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | +| Layout templates | `templates/frontend/frontend.html.twig`, `templates/backend/{admin,editor,setup}.html.twig`, `templates/*/layouts/*.html.twig` | Native frontend, admin, editor, setup, and optional area layout skeletons. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/PublicContentErrorPageTest.php`, `extensions/demo-module/tests/Controller/DemoControllerTest.php` | +| Demo module templates | `extensions/demo-module/templates/frontend/demo-module/*.html.twig` | Portable extension-owned render targets for previewing extension route contracts, configurable settings, dynamic content injection, and Markdown typography profiles before production UI routes exist. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-PluginModules.md` | `extensions/demo-module/tests/Controller/DemoControllerTest.php` | +| Shared area partials | `templates/partials/**/*.html.twig`, `templates/frontend/partials/**/*.html.twig`, `templates/backend/partials/**/*.html.twig` | Granular native layout, system footer, navigation, typography, root-scoped alert feedback, action button, toolbar, and form field partials that establish early override points for themes and extensions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Twig/ViewTwigExtensionTest.php` | +| Scoped Twig components | `templates/components/*.html.twig`, `templates/frontend/components/*.html.twig`, `templates/backend/components/*.html.twig` | Namespace-aware root, frontend, and backend UI primitives for alerts, the notification-center alert stack, buttons, button groups, page headers, empty states, Chart.js panels, and coordinate-based UX Map views, resolved through the same Twig namespace path order used by active themes and extensions. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/View/Twig/TwigComponentNamespaceTest.php`, `php bin/console debug:twig-component root:AlertStack`, `php bin/console debug:twig-component root:ChartPanel`, `php bin/console debug:twig-component root:MapView`, `php bin/console debug:twig-component frontend:Button`, `php bin/console debug:twig-component backend:PageHeader` | +| Backend area index/message templates | `templates/backend/{admin,editor,setup}/{index,message}.html.twig`, `templates/backend/admin/{extensions,themes,operations,section}.html.twig`, `templates/backend/admin/extensions/*.html.twig`, `templates/backend/admin/settings/*.html.twig` | Minimal native render targets for backend area routing, localized message-layer feedback, extension/theme/admin placeholder view registration, extension detail/lifecycle review screens, transient live-operation inspection, cleanup, retained detail views with review continuation controls, typed settings forms, and backend navigation before feature-specific pages are added. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/Controller/BackendControllerTest.php` | | Frontend primary navigation | `templates/frontend/partials/navigation/_primary.html.twig` | Native frontend navigation partial rendering the `main` menu through recursive `navigation()` output with translated route labels, active/ancestor classes, optional safe link metadata attributes, and the default three-level depth. | `dev/draft/0.1.x-ThemeEngine.md`, `dev/draft/0.3.x-NavigationSitemapBuilder.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Navigation/NavigationBuilderTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | | Backend area partials | `templates/backend/admin/partials/*.html.twig`, `templates/backend/editor/partials/*.html.twig`, `templates/backend/setup/partials/**/*.html.twig` | Granular backend-scoped admin, editor, and setup partial trees, including setup wizard alerts, step panels, preflight rows, footer navigation, and result logs. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-AdminInterfaceSetupUi.md` | `tests/Controller/BackendControllerTest.php` | | Content fallback | `templates/frontend/content/entity.html.twig`, `templates/frontend/content/partials/*.html.twig` | Native public fallback renderer for content chrome, injection slots, custom schema fieldsets, body Markdown/HTML, and generic field tables. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/Controller/PublicContentRenderingTest.php`, `tests/Controller/PublicContentRedirectTest.php` | | Macro registry templates | `templates/macros/**/*.html.twig` | Namespaced native Twig macro templates and aggregator entrypoint. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/Twig/ViewTwigExtensionTest.php` | -| Package template path resolver | `App\View\Template\PackageTemplatePathResolver`, `App\View\Template\PackageTemplatePathConfigurator` | Builds and registers deterministic Twig namespace path order for active packages through the central package gate, including `@frontend`, `@backend`, `@root`, `@provider`, root override protection through `system-template`, provider fallback ordering, and Console registration before UX icon locking or AssetMapper cache warming scans templates. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/Template/PackageTemplatePathResolverTest.php`, `tests/View/Template/PackageTemplatePathConfiguratorTest.php` | -| Provider templates | `templates/provider/{captcha,editor}/*.html.twig`, `templates/frontend/partials/forms/fields/captcha.html.twig`, `templates/backend/editor/fields/richtext.html.twig` | Native provider fallbacks and area stubs for optional captcha and editor-provider rendering through `@provider`, with CodeMirror as the base editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/View/Template/PackageTemplatePathConfiguratorTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | -| HTTP error renderer | `App\View\Http\HttpErrorRenderer`, `App\View\Http\HttpErrorSubscriber` | Renders recoverable HTTP errors through system content, frontend error templates, default error fallback, or anonymous-login `401` response. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/PublicContentAccessTest.php` | +| Extension template path resolver | `App\View\Template\ExtensionTemplatePathResolver`, `App\View\Template\ExtensionTemplatePathConfigurator` | Builds and registers deterministic Twig namespace path order for active extensions through the central extension gate, including `@frontend`, `@backend`, `@root`, `@provider`, root override protection through `system-template`, provider fallback ordering, and Console registration before UX icon locking or AssetMapper cache warming scans templates. | `dev/draft/0.1.x-ThemeEngine.md` | `tests/View/Template/ExtensionTemplatePathResolverTest.php`, `tests/View/Template/ExtensionTemplatePathConfiguratorTest.php` | +| Provider templates | `templates/provider/{captcha,editor}/*.html.twig`, `templates/frontend/partials/forms/fields/captcha.html.twig`, `templates/backend/editor/fields/richtext.html.twig` | Native provider fallbacks and area stubs for optional captcha and editor-provider rendering through `@provider`, with CodeMirror as the base editor provider. | `dev/draft/0.2.x-PluginModules.md` | `tests/View/Template/ExtensionTemplatePathConfiguratorTest.php`, `tests/View/Twig/ViewTwigExtensionTest.php` | +| HTTP error renderer | `App\View\Http\HttpErrorRenderer`, `App\View\Http\HttpErrorSubscriber` | Provides the browser error-page resolver entry point through `HttpErrorRenderer::resolve()`, rendering recoverable HTTP errors through system content, frontend error templates, default error fallback, anonymous-login `401` response, or forced/minimal bare HTML, with central `no-store` cache headers for rendered error pages and DB-free minimal HTML responses for all known `4xx`/`5xx` statuses before setup completion. | `dev/draft/0.1.x-ErrorHandlingValidation.md` | `tests/View/Http/HttpErrorRendererTest.php`, `tests/Controller/PublicContentErrorPageTest.php`, `tests/Controller/PublicContentAccessTest.php` | | Frontend error pages | `templates/frontend/error-pages/*.html.twig` | Native frontend-scoped fallback templates for HTTP error pages including lightweight `429` and `503`. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | N/A | | Live polling controllers | `assets/js/live/live_poll.js`, `assets/controllers/live_poll_controller.js`, `assets/controllers/operation_overlay_controller.js` | Provides a reusable live JSON polling primitive and Stimulus controller for `/api/live/**` endpoints with automatic polling, immediate `has_more` page draining when cursors advance, optional recoverable-error retry for fallback channels, `next_poll_ms: 0` manual-mode support, and one-shot `live-poll#poll`/`live-poll#refresh` actions while operation forms surface progress through notification-center runner alerts, keep the triggering button disabled while running, automatically run the OK/redirect/reload action shortly after successful operations unless the ActionLog overlay is opened, remap warning or failed triggers to details, and open the ActionLog overlay only on demand for details, continuation, retry, close controls, non-terminal hide controls that keep polling alive, and reusable running-alert detail actions that can reopen the overlay after it was hidden. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/controller_foundation.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/live_poll.test.mjs`, `tests/Controller/LiveOperationControllerTest.php` | | UI alert stream and inbox | `App\View\Alert\UiAlertDispatcherInterface`, `App\View\Alert\UiAlertDispatcher`, `App\View\Alert\UiAlertTranslation`, `App\View\Alert\WorkflowResultAlertSelector`, `App\View\Alert\UiAlertInbox`, `App\View\Alert\UiAlertDelivery`, `App\View\Alert\UiAlertPresentation`, `App\Command\UiAlertInboxCleanupCommand`, `App\Controller\LiveAlertController`, `assets/controllers/alert_stack_controller.js`, `assets/controllers/ui_alert_stream_controller.js`, `assets/controllers/ui_alert_poll_controller.js`, `assets/js/alerts/*.js` | Renders server-created, translated, client-created, Mercure-pushed, or polling-delivered UI alerts through one `addAlert()` interface with explicit `Direct`, robust `Queue`, and volatile low-level `Push` delivery modes, success-preserving workflow-result alert selection, DB-backed user/session topic inbox that only accepts system-owned UI-alert URN topics and stores bounded HMAC topic keys with setup-completion gating, canonical UID topics from account entities, account UIDs, or case-preserved resolvable usernames, portable append success reporting without sequence-specific insert IDs, paginated catch-up cursors, and scheduled expired-row cleanup that reports query failures, Mercure health-gated stream/push delivery with stable Alert IDs as Mercure event IDs, private-subscription authorization cookies for rendered alert stream topics, paginated inbox catch-up drains before stream connection, on stream open/reconnect, and during polling fallback, existing session-cookie topics, transient-failure retries, session-scoped sessionStorage-backed notification center with badge counts, visible no-JavaScript server-rendered alerts, silent stored-alert hydration that does not hide fresh server-rendered flashes, smooth panel open/close, outside-click/Escape hide behavior, hide-vs-close behavior, timed auto-removal for transient alerts with closed-alert dedupe, sanitized quiet text/link actions, presentation modes, and optional titles/actions/loading state. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.4.x-OperationalAdminWorkflows.md` | `tests/assets/alert_payload.test.mjs`, `tests/assets/live_alert_controllers.test.mjs`, `tests/assets/controller_foundation.test.mjs`, `tests/View/Alert/UiAlertTest.php`, `tests/View/Alert/UiAlertDeliveryTest.php`, `tests/View/Alert/UiAlertDispatcherTest.php`, `tests/View/Alert/UiAlertInboxTest.php`, `tests/View/Alert/WorkflowResultAlertSelectorTest.php`, `tests/View/Alert/MercureUiAlertPublisherTest.php`, `tests/Controller/LiveAlertControllerTest.php`, `tests/Command/UiAlertInboxCleanupCommandTest.php` | | Filter form controller | `assets/controllers/filter_form_controller.js` | Provides a reusable GET-list filter controller with debounced search-input submission, immediate select submission, submit-button busy state, page reset, and focus/caret restoration across GET refreshes for backend list and log filter forms. | `dev/draft/0.1.x-SystemThemeDesignSystem.md` | `tests/assets/controller_foundation.test.mjs` | | Frontend user templates | `templates/frontend/user/*.html.twig` | Frontend-scoped templates for login, register, password reset, profile editing and closure, password changes, API-key management/reveal, invitation/registration acceptance, and security-review routes. | `dev/draft/0.1.x-SystemThemeDesignSystem.md`, `dev/draft/0.2.x-SecurityAccessControl.md` | `tests/Controller/SecurityControllerTest.php`, `tests/Controller/UserControllerTest.php` | -## 11. Packages +## 11. Extensions -| Package | Manifest | Services | Routes | Assets | Purpose | Docs | Tests | +| Extension | Manifest | Services | Routes | Assets | Purpose | Docs | Tests | |---------|----------|----------|--------|--------|---------|------|-------| ## Maintenance Tips diff --git a/dev/WORKLOG.md b/dev/WORKLOG.md index c153dfee..abbf1fc6 100755 --- a/dev/WORKLOG.md +++ b/dev/WORKLOG.md @@ -1,7 +1,7 @@ # Developer Worklog > **Status**: Active -> **Updated**: 2026-06-14 +> **Updated**: 2026-06-20 > **Owner**: Core > **Purpose:** Keeps track of changes and upcoming tasks. @@ -43,7 +43,7 @@ - [ ] **0.5.x Release lifecycle** - [ ] Self-update and release workflow - - Open: package signature/checksum strategy; direct vs staged updates; rollback scope. + - Open: extension signature/checksum strategy; direct vs staged updates; rollback scope. - [ ] **Future** - [ ] Neural-like index and semantic resolver @@ -64,90 +64,68 @@ - [ ] Finish the visual design-system pass and first release-readiness verification shape in the UI/UX follow-up. - [ ] Add portable read-model/index strategy when JSON-held values such as localized titles need frequent list-view filtering or sorting across MariaDB/MySQL, SQLite, and PostgreSQL. - [ ] Editor/API follow-up: when the final content/editor model lands, replace provisional API content list filtering with a domain-owned actor-aware content list/read resolver covering canonical paths, language, variants, optional version selection, pagination, filtering, and sorting. -- [ ] Before production readiness, review public package/developer-facing class, interface, function, and Twig helper names for clarity and ergonomics; decide whether to rename directly or provide stable aliases so extension APIs read as intentional rather than provisional. -- [ ] Audit follow-up: add a durable package lifecycle operation journal/coordinator for multi-step activation, deactivation, install, rollback, and cleanup flows. -- [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding intentionally covers visitor changes, not complete cookie-pair duplication. +- [ ] Before production readiness, review public extension/developer-facing class, interface, function, and Twig helper names for clarity and ergonomics; decide whether to rename directly or provide stable aliases so extension APIs read as intentional rather than provisional. +- [ ] Audit follow-up: add a durable extension lifecycle operation journal/coordinator for multi-step activation, deactivation, install, rollback, and cleanup flows. +- [ ] Audit follow-up: design copied-session plus copied-visitor-cookie risk scoring in the Security branch; current hard session binding now records visitor changes as high-risk signals, but still does not detect complete cookie-pair duplication. - [ ] Audit follow-up: implement remember-me with Symfony-style persistent server-side tokens, visitor binding, explicit revocation, token rotation, and audit signals in the Security branch. - [ ] Audit follow-up: replace the debug account-link mail/message-log delivery stub with the real Mailer delivery contract and a dedicated Mail Message/API catalogue. -- [ ] Audit follow-up: decide whether optional branding packages need capabilities beyond `system-template`; package CSS class namespace validation is now enforced for package-owned selectors. +- [ ] Security follow-up: define and test production HTTP security-header policy, including CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, sensitive-route `no-store`, and documented route exceptions. +- [ ] Frontend-delivery follow-up: change custom system error-page rendering so `/system/error-pages/{status}` resolves a content entity for the inner error-page body/fieldset, then lets the status-specific error template decide the full page chrome. The current renderer sends custom error entities through the normal frontend content entity template, which is too rigid for lightweight `400`/`429` responses versus full `404` pages. +- [ ] Security/Admin ACL follow-up: add explicit Owner/configurable ACL gates for security-signal visibility/mutation, IP-bearing access-log projection visibility, related exports, cleanup operations, and future signal review actions across Admin UI, Admin API, Operations, and service boundaries. +- [ ] Audit follow-up: split the remaining large Admin ACL-adjacent controllers and API handlers along route/action boundaries when those domains are touched next; this slice already extracted the new matrix/form construction, while broader splits for `BackendController`, Admin user/ACL/extension controllers, extension APIs, operation/scheduler APIs, and the pre-existing large user ACL/review API handlers would be safer as a dedicated behavior-stable refactor. +- [ ] Editor/Content/Config follow-up: warn non-blockingly when a proposed route or slug would match a configured suspicious probe path, so legitimate content remains possible but accidental high-signal probe namespace collisions are visible before publication. +- [ ] Captcha/rate-limit follow-up: add a short-lived opaque 429 recovery context when real captcha challenges are wired, so verified provider-backed solves can reset only the whitelisted/resettable descriptor and subject scope that produced the rendered 429 without exposing bucket IDs, subject keys, IP data, or limiter internals. +- [ ] Aggregation/rate-limit follow-up: evaluate short-lived emergency country/continent traffic-shedding buckets for DDoS-like spikes. Treat this as aggregate rate limiting, not auto-ban or geo-blocking; ignore `n/a` GeoIP, keep thresholds extreme, preserve trusted-user recovery and Owner/API access, and use brief windows such as 5-15 minutes. +- [ ] Audit follow-up: decide whether optional branding extensions need capabilities beyond `system-template`; extension CSS class namespace validation is now enforced for extension-owned selectors. +- [ ] Extension database follow-up: design safe update handling for existing extension-owned table definitions after extension updates, including table/column/index/FK drift detection, explicit diagnostics, and a versioned update or migration-like operation instead of silently mutating existing tables during activation. +- [ ] Extension asset follow-up: design a Node dependency asset pipeline for extension-local `assets/node_modules` payloads, including dependency validation and safe mirroring rules for executable or otherwise unsafe dependency files without breaking legitimate frontend packages. +- [ ] Extension reference follow-up: design stable lookup/reference interfaces for core-owned users, ACL groups, content items, and content schema entities so extensions can persist portable IDs in their own tables without DB-level foreign keys that could block core deletions or couple core lifecycle semantics to extension tables. Lookup APIs must return explicit missing/deleted fallbacks, for example a deleted-user display label, so extension logic can handle unavailable core data without broken references. +- [ ] Extension API follow-up: before public extension API release, add a documented handler context or safe facade so extension API handlers can use controlled domain services instead of direct low-level Doctrine/DBAL access. +- [ ] Extension runtime follow-up: before enabling broader extension-owned services, routes, event subscribers, or Messenger handlers, define the active-extension gate, ownership attribution, validator policy, fault handling, and reviewable contribution interfaces for each surface. - [ ] Evaluate whether the documented minimum memory requirement should become 256M after PHPUnit 13.2/full-suite runs needed a higher CLI memory limit; do not fix this requirement until setup/init/lint/runtime memory behavior has been reviewed across target hosting platforms. ## Branch Logs -**Usage:** Keep concise session notes in the active worklog and include the current branch in headings, using the form `### YYYY-MM-DD branch-name`. Place new entries chronologically under the matching branch/date heading so reviewers can follow the PR context without reading full verification transcripts. Record meaningful committed or completed changes, decisions, blockers, and follow-ups; keep detailed verification in PR notes unless a result materially affects the worklog context. When switching to a different branch or after a PR is merged, compact the completed branch entry into [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md), then create the new branch entry at the top. - -### 2026-06-13 feat-symfony-ux-integration -- Added namespace-aware Twig component primitives for root, frontend, backend, and package-adjacent UI surfaces, then wired shared alert stacks, buttons, page headers, empty states, chart/map wrappers, and form field enhancements without removing the override-friendly partial entry points. -- Added reusable Stimulus/JS foundations for live polling, filter forms, dialog, clipboard, disclosure, tabs, notification-center behavior, Mercure alert streams, and manual one-shot polls; applied the filter/dialog/clipboard pieces to existing Admin logs/statistics/users/package/API-key surfaces where useful. -- Reworked UI alerts into a unified dispatcher and notification center with direct, queued, and low-level push delivery modes; request-time alerts, DB-backed inbox fallback, Mercure best-effort push, polling fallback, titles, actions, loading state, and `auto`/`hidden`/`persistent` presentation now share one public `addAlert()` path. -- Added a package-owned `/api/live/{package_slug}/...` endpoint registry and dispatch boundary for lightweight GET-only polling/manual interactions such as future captcha seed reloads. -- Added a package-extendable cookie-consent foundation with duplicate-name rejection, stateless public CSRF, consent-aware cookie helpers, optional-cookie withdrawal expiry, DNT/GPC-aware defaults, and a reusable overlay that can be reopened from later privacy/footer links. -- Added optional local Mercure tooling with installer/start/stop/health/check commands, fixed versioned Caddy-based release assets, `var/mercure` storage, read-only diagnostics, public subscribe probes, publish probes, setup seeding, scheduler health refresh, and graceful polling fallback when Push is unavailable. -- Switched local Mercure signing to derive from the validated/generated `APP_SECRET` by default, moved local hub secrets out of process arguments, required a 32-byte `APP_SECRET`, and made unsupported short app secrets fail before the app-secret rotation recovery flow can mark them as healed. -- Removed unused Alpine and ApexCharts wiring now covered by Symfony UX packages, kept UX assets lazy, repaired demo package/theme CSS validation, and clarified that `asset-map:compile` is production/release-only. -- Bounded large log reading from the end of the file to avoid Admin log timeouts on large application logs. -- Updated the design-system draft, Mercure web-server notes, class map, and related translation/catalogue entries for the new UI alert, live polling, component, cookie-consent, Mercure, and Symfony UX foundations. - -### 2026-06-14 feat-symfony-ux-integration -- Addressed review findings around live endpoint access and registration by making package live endpoints GET-only, enforcing minimum access levels before handler dispatch, reserving system live slugs, and preferring exact endpoint paths before broad pattern matches. -- Applied the same exact-before-pattern selection to regular API endpoint dispatch and split the oversized API class-map entry into focused API foundation/security, endpoint registry/documentation, admin/settings, content, and package/user rows. -- Folded `ui_alert_inbox` into the pre-`1.0.0` baseline migration and hardened prefixed index/constraint naming for alert-inbox schema objects. -- Hardened alert delivery and storage by scoping notification-center storage by user/session/surface, preserving closed-alert dedupe, giving queued/pushed alerts stable fallback IDs, and making explicit alert-inbox cleanup failures return a failing command status. -- Tightened cookie consent behavior by making rejection work without JavaScript, avoiding anonymous session creation from hidden CSRF tokens, rejecting duplicate consent definitions, and expiring withdrawn optional cookies. -- Kept profile views on cached Mercure availability only, required authenticated publish success for Mercure health, and kept setup/profile paths from starting or installing Mercure implicitly. -- Switched local Mercure downloads from deprecated legacy assets to the Caddy-based `mercure_{OS}_{ARCH}` archives, used the release Caddyfile plus protected env file for secrets, normalized Windows paths, and kept PID plus exact-binary process detection for start/stop/check diagnostics. -- Clarified Mercure public URL/reverse-proxy expectations in the web-server manual while keeping local checks precise enough to distinguish Symfony fallback responses from real Mercure SSE endpoints. -- Replaced committed Mercure JWT defaults with `${APP_SECRET}`, removed setup-time `MERCURE_JWT_SECRET` generation, and kept app-secret rotation naturally coupled to the default Mercure JWT key unless an operator explicitly configures a dedicated JWT secret. -- Deferred Mercure startup out of `bin/init`, made setup stop stale local hubs before health recovery starts them with persisted setup secrets, and coupled app-secret rotation recovery to Mercure stop/health refresh so local hubs do not keep stale signing keys. -- Stripped diagnostic message context from UI alert serialization so UI payloads expose only display-safe alert fields. -- Bound cookie-consent CSRF tokens to the existing visitor identity and hardened package live/API path-pattern guards plus live dispatch route-slug checks so package-owned endpoints cannot escape their namespace through broad regex patterns. -- Hardened late review edge cases for UI alerts, Mercure health, and setup copy by notifying only for newly created alerts, keeping server-rendered flashes visible during storage hydration, retrying transient alert-poll failures, treating disabled Mercure health as a configured success, and deriving setup secret browser constraints from the shared validator. -- Hardened queued alert fallback by including existing session-cookie topics in `/api/live/alerts` without starting anonymous sessions. -- Hardened follow-up review edges by removing PostgreSQL-sensitive `lastInsertId()` dependency from queued alert appends, making initial server-rendered alerts visible without JavaScript, normalizing colon-only Mercure listen addresses for local probes, and restoring timed removal of transient auto alerts without marking them as manually closed. -- Extended the Mercure colon-only listen hardening to configured hub URLs so `.env`-derived `http://:3000/.well-known/mercure` values normalize before publish/public probes. -- Hardened cookie consent and alert dispatch follow-up edges by clearing all rejected optional cookies even without stored consent, preserving clear-cookie response headers for rejected cookies, enforcing registered cookie identity in the consent jar, skipping topic-specific Mercure publishes while unavailable, and adding a root Twig-component namespace smoke test for `root:*` components. -- Tightened production-readiness edges by SHA256-pinning Mercure release archive downloads, rejecting custom consent cookies that change registered security attributes, signing and TTL-validating consent cookies, and covering safe relative consent redirects. -- Added lightweight native `node --test` JavaScript behavior testing through `bin/jstest` without a `node_modules` dependency tree, with first coverage for alert payload normalization and live polling cursor/retry/error behavior. -- Expanded JavaScript behavior coverage with a small test-only fake DOM and Stimulus controller loader for stable controller contract tests around clipboard/dialog/disclosure/tabs/filter forms, cookie consent, alert stack behavior, alert polling, and Mercure stream reconnect handling. -- Hardened final review edges by verifying stored Mercure PIDs against the exact binary before termination, avoiding parallel alert stream/poll delivery while adding one-shot stream catch-up from the inbox and stable Mercure event IDs, remembering auto-dismissed alert IDs, constraining package-owned necessary cookies to package-scoped host-only names, and marking Mercure unavailable when app-secret rotation cannot safely stop the local hub. -- Hardened cookie-consent package review edges by rejecting duplicate or core-reserved package cookie definitions during package loading and validating optional-cookie privacy links before they can render in the public consent UI. -- Hardened follow-up setup, redirect, alert-inbox, and naming edges by passing the persisted setup `APP_SECRET` as the Mercure setup health JWT secret, rejecting backslash/control-character local redirect targets, storing queued alert topics as bounded HMAC keys, and renaming the consent cookie to a system-owned name. -- Replaced host-derived UI-alert Mercure topic URLs with system-owned URN topics so alert transport identifiers no longer consume HTTP route namespace or depend on `DEFAULT_URI` length. -- Hardened additional review edges by running consent cookie filtering after response cookie writers, standardizing queued user alert topics on canonical account UIDs with username-to-UID normalization when resolvable, and rejecting package live endpoint root paths that cannot be routed by `/api/live/{packageSlug}/{resourcePath}`. -- Hardened follow-up alert edges by preserving username case during username-to-UID topic resolution. -- Removed the native browser notification opt-in and `symfony/ux-notify` dependency because UX Notify only works while a page keeps an active Mercure/EventSource stream; real closed-browser notifications need a separate future Web Push design. -- Completed another broad review pass across URL/link sinks, browser storage naming, and Mercure secret-file handling; hardened alert action links, package metadata URLs, content redirect targets, filter-form storage names, and protected Mercure env-file rewrites. -- Hardened the alert stream fallback so browsers without `EventSource` support or streams that fail before their first open switch to inbox polling without enabling parallel normal stream/poll delivery. -- Kept operation detail overlays dismissible during running operations by showing close controls in non-terminal states and hiding details without stopping the live poller. -- Hardened additional alert/CSS review edges by draining paginated queued-alert catch-up pages after Mercure stream opens, authorizing rendered stream subscriptions for private alert pushes, and suppressing strict-parser CSS limitations for Tailwind directives, generated modern group at-rules, and empty custom-property fallbacks only when a normalized second parse finds no adjacent syntax error. -- Follow-up hardening pass: removed unused legacy CSS-linter wrapper names after broadening the parser-normalization scope, made the generic live poller drain `has_more` pages immediately when cursors advance so polling fallback behaves like stream catch-up, and made Mercure stream views drain queued alerts before connecting while queuing a follow-up drain when the stream opens mid-catch-up. -- Follow-up: when the next feature branch starts, evaluate focused controller foundations for public consent/privacy settings, package live endpoint documentation/navigation, captcha provider/live seed flows, notification preference detail settings, and package-owned webhook/job callback endpoints before adding more broad UI surface. -- Follow-up: add a public privacy/footer trigger for `cookie_consent_trigger_attributes()` so visitors with stored consent can reopen cookie preferences and withdraw or adjust optional-cookie consent. -- Follow-up: evaluate converting high-use backend filters from GET-refresh enhancement to Symfony UX LiveComponent slices with URL-bound writable `LiveProp`s so filter input updates can re-render only the list component while keeping shareable query parameters. -- Follow-up: revisit the full operation overlay controller after the first real UI/UX feature slice; the polling core is now shared, but renderer/storage responsibilities can still be split further when more live consumers exist. - -### 2026-06-12 docs-cleanup -- Refreshed the `.codex` context inventory: marked the branding-neutral naming migration and first readiness audit as completed/historical, removed the obsolete standalone Symfony docs notes, made the framework recap the version-pinned dependency documentation cache, and updated it with current installed-dependency guidance for Symfony 8.1, Doctrine ORM/DBAL, Twig 3.27, Tailwind v4/TailwindBundle, Symfony UX, CommonMark, and PHPUnit 13. -- Extended `bin/lint` into the all-in-one diff linting entry point: it now supports `--diff`, `--diff=`, and `--diff:`, collects staged/unstaged or explicit Git diff files when Git is available, lints extensionless PHP scripts such as `bin/lint`, and runs a non-Markdown Git whitespace check that preserves intentional Markdown hard line breaks. -- Added Markdown parse coverage to `bin/lint` using the existing League CommonMark/GFM dependency so Markdown targets produce a real parse/render smoke-check instead of being reported as unsupported. -- Documented the Git whitespace/Markdown hard-break rule in `AGENTS.md`, updated the `.codex` tool index and class map for the new lint modes, and compacted the old 2026-06-07 API session into `dev/WORKLOG_HISTORY.md`. -- Moved the binding project rules from `.codex/PROJECT_RULES.md` into `AGENTS.md` so architecture, naming, pre-`1.0.0`, database, content-revision, security, and audit rules remain available when Codex project context changes or the `.codex` notes are not loaded. -- Removed the obsolete `.codex/PROJECT_RULES.md` duplicate, updated `.codex/ENVIRONMENT.md` for the new `/Volumes/Projekte/studio` checkout path, and refreshed `.codex/README.md` with active context, historical audit, tool, and cleanup guidance. -- Verified `.codex/resolve_cloud_artifacts.php` reports no cloud conflict artifacts; `.codex/clean_ignored_artifacts.php` dry-run still lists normal ignored generated/dependency directories such as `vendor/`, `var/`, `assets/vendor/`, `translations/runtime/`, and package build outputs, so no deletion was applied. - -### 2026-06-13 docs-cleanup -- Moved route rendering from the `.codex` helper into project code with `php bin/console render:route /path`, including optional debug role, existing user, method, host, HTTPS, setup-completion, browser-auth, and API debug context support; removed the obsolete `.codex/render.php` helper and updated render-review references. -- Extended `bin/lint` with `--staged` and `--changed=` while keeping Git-dependent target collection and whitespace checks graceful when Git or a work tree is unavailable. -- Reviewed the newly installed Symfony UX package set, kept optional UX Stimulus controllers lazy, removed generated React/Vue/Icon demo files, and tied committed Mercure defaults to `DEFAULT_URI` and `APP_SECRET` for development while documenting production override expectations. -- Updated the class map, dependency recap, local agent tooling notes, and active worklog/history to reflect the render command, lint modes, Symfony UX baseline, and branch-oriented worklog boundary. -- Changed worklog retention from per-session compaction to branch-scoped archival, restored the current `docs-cleanup` branch context from history, and mirrored the rule in `AGENTS.md`. -- Added Symfony UX icon locking to `bin/init` and the package-aware asset rebuild queue as non-blocking dependency steps, and registered active package template paths for icon/AssetMapper console scans so core and package icon references can be imported locally when Iconify is reachable without breaking offline CI or admin rebuilds. -- Added a local-only Symfony UX icon reference check to `bin/lint` so static Twig icon references fail when the required locked SVG is missing, without running the mutating network-backed `ux:icons:lock` command. -- Documented that locked SVGs in `assets/icons` should be committed as reviewable dependency snapshots while avoiding bulk-locking complete upstream icon sets by default. -- Declared `ext-sodium` as a direct Composer platform requirement and added it to the PR verification runner because the Symfony Mercure/JWT dependency chain requires `lcobucci/jwt`, which requires Sodium. -- Clarified `AGENTS.md` wording around session notes with branch/PR context and the boundary between agent-only `.codex` helpers and project-wide tooling. -- Normalized `AGENTS.md` wording so the document reads as a standalone first-version guide rather than as a patch over earlier agent habits. -- Disabled UX Translator TypeScript type dumps in production because the current AssetMapper setup uses JavaScript, not TypeScript, and recorded the UX Turbo 3.1 stream-listen deprecation in the dependency recap. -- Added cache warmup to `bin/init` and `ux:translator:warm-cache` to the package-aware asset rebuild queue so `var/translations/index.js` exists before AssetMapper resolves `assets/translator.js`. +**Usage:** Keep concise session notes in the active worklog and include the current branch in headings, using the form `### YYYY-MM-DD branch-name`. Place the newest branch/date heading directly below `## Branch Logs`; within a matching branch/date heading, add new notes at the top so the newest context stays first. Record meaningful committed or completed changes, decisions, blockers, and follow-ups; keep detailed verification in PR notes unless a result materially affects the worklog context. When switching to a different branch or after a PR is merged, compact the completed branch entry into [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md), then create the new branch entry at the top. + +### 2026-06-20 feat-security-captcha-contract +- Fixed CI follow-ups by keeping the demo module focused on its configurable public info page plus Markdown typography child route and moving detached-process inherited descriptor cleanup into the detached child shell with `/proc/self/fd` coverage on Linux. +- Proactively hardened adjacent extension review boundaries by tokenizing Twig template references, blocking dynamic PHP callable-expression invokes, and binding extension API/live endpoint definition owners to their extension slugs. +- Fixed Cloud Review findings for extension database table cleanup after mid-create failures, unbounded filesystem extension validation before recursive asset mirroring, non-module content-schema identifier collisions, and post-commit extension asset sync completion-hook failures. +- Fixed Cloud Review findings in runtime extension contributions and database DDL by requiring extension-owned view templates for each surface, rejecting raw extension column options before DBAL schema creation, and pinning hyphenated scheduler identifiers as valid in the current scheduler grammar. +- Fixed Cloud Review findings in static extension validation by blocking string-literal PHP callables, raw request superglobals, and cross-scope Twig template references inside expression functions. +- Fixed Cloud Review findings for flat-root extension ZIP installs, activation-plan single-active conflicts, portable extension database identifier limits, and bounded extension manifest/dependency version syntax. +- Gated destructive optional MySQL/MariaDB extension database integration tests behind explicit `MYSQL_TEST_ACTIVE=1` opt-in so the default PHPUnit suite cannot clear a reachable local `studio_test` database. +- Centralized reusable identifier validation in `IdentifierSpec`, including 60-character owner/content slug families, dot-path identifiers, snake-case identifiers, handler keys, scheduler-style machine identifiers, database prefixes, ACL group identifiers, and canonical UUIDs. +- Fixed Cloud Review findings for extension slug validation and cleanup ordering by capping extension slugs to the storage limit, rejecting digit-prefixed slugs consistently, aligning CSS/API/live namespace validation with the manifest slug contract, and purging extension database tables before content schemas. +- Anchored the project-local review, review-fix, and PR-readiness Codex skills in `AGENTS.md` as the preferred workflow helpers when their scoped triggers apply. +- Hardened the local review and review-fix Codex skills with compact practices adapted from the internal security workflows: explicit worklist closure, sibling-instance inspection, counterevidence checks, source/control/sink framing, bounded reproduction, and proof that the original path is closed after fixes. +- Fixed local-review findings for extension lifecycle cleanup and scheduler contribution boundaries by snapshotting/restoring settings and ACL cleanup around destructive purge steps, requiring extension-owned scheduler identifiers and callable/action-queue targets, and scoping scheduler runtime providers to their owning extension. +- Fixed follow-up review findings for active ZIP overwrite content restoration, foreign-owner setting contributions, purge cleanup ordering, activation rollback diagnostics, fault-persistence reporting, `.yaml` inventory alignment, and stale demo-extension documentation. +- Fixed Mercure test-lifecycle cleanup and added an explicit detached-process option for persistent services to close inherited file descriptors before detaching, while preserving default lock inheritance for operation and scheduler process chains. +- Fixed review findings for extension removal rollback content restoration, failed content-schema contribution staging cleanup, and full-depth ZIP payload policy validation before install. +- Added extension dependency version constraints with bare compatibility requirements, short-version floors for `>=` minimum requirements, and precision-pinned `=` requirements. +- Added an optional auto-skipping MySQL/MariaDB integration test fixture for extension database DDL cleanup, documented the local `studio_test` DSN/user setup, and kept the default PHPUnit lifecycle on SQLite. +- Fixed extension-contract review findings for collision-free extension-owned table/schema identifiers, API-scope asset registry bucketing, activation contribution reloading of already-active dependencies, explicit created-table cleanup on contribution failure, and content-status restoration when lifecycle rollback follows automatic schema-content archival. +- Kept extension-owned tests discoverable by adding `extensions/**/tests` discovery to PHPUnit and moving the demo module controller test into the demo extension tree so future extension submodules can carry their own host-integration coverage. +- Split oversized extension/OpenAPI contract collaborators into focused contribution expansion/guarding, endpoint/view/scheduler storage, extension database naming/reference/order helpers, and OpenAPI component/tag factories without changing runtime behavior. +- Fixed review findings by keeping early `bin/init` Composer platform checks aligned with `--no-dev` bootstrap installs, restricting extension language catalogues to documented `*.yaml` files, and configuring English as the Symfony translator fallback. +- Limited extension database foreign keys to extension-owned tables, with PK/unique reference validation and explicit follow-ups for safe update drift handling plus stable core-entity lookup/reference interfaces. + +### 2026-06-19 feat-security-captcha-contract +- Added the `api` extension scope, gated extension API endpoint/handler contributions behind it, exposed typed manifest variables through `ExtensionSettings::get($extension, 'manifest.{key}')` with persisted setting overrides, switched operation/command lifecycle asset rebuilds to synchronous execution, and changed content-schema purge to force-archive affected content while retaining still-referenced disabled schema copies with warning context. +- Implemented extension content-schema lifecycle impact handling for deactivation reviews and automatic archival of public content tied to deactivated extension schema presets. +- Documented content-schema lifecycle impact policy: deactivation should archive/unpublish directly affected public content, while automatic republish on extension reactivation is explicitly out of scope without a future confirmed restore journal. +- Clarified extension CSS namespace validation so owner-wide selectors and matching scope selectors are accepted while foreign rendered-area scope selectors stay blocked. +- Added scope-gated extension database and content-schema contribution contracts, with activation-time database/content-schema synchronization, purge cleanup, tests, and updated extension/template documentation. +- Relaxed and clarified extension validation boundaries for development metadata, private assets, extension-local dependency payloads, open `EXTENSION_*` manifest descriptors, and ZIP installer copy filtering. +- Added early Git submodule synchronization to `bin/init` so clean checkouts initialize extension submodules before Composer installs dependencies. + +### 2026-06-18 feat-security-captcha-contract +- Started the captcha contract branch after `feat-security-auto-ban` merged and compacted the completed auto-ban branch notes into `dev/WORKLOG_HISTORY.md`. +- Reviewed the captcha-contract plan, IconCaptcha handoff plan, and Security policy defaults. Expected branch scope is the generic provider contract, resolver, workflow/provider configuration, global form integration, safe validation/result model, and verified-provider success/failure hooks without shipping a concrete IconCaptcha provider. +- Added Noto Color Emoji utility CSS generated from the local font and Unicode emoji-test data, aligned the Noto `@font-face` descriptors with the local font metadata, and imported the emoji utilities into the main stylesheet. +- Updated the local Tabler icon webfont assets and generated utility class map to `@tabler/icons-webfont` 3.44.0. +- Curated a draft IconCaptcha challenge asset index under `.codex/tmp/challenge-assets` pairing 100 Noto Emoji SVGs with Tabler filled SVG icons, including category and confusable-family selection constraints. ### Archived Compacted Branch History - [WORKLOG_HISTORY.md](WORKLOG_HISTORY.md). diff --git a/dev/WORKLOG_HISTORY.md b/dev/WORKLOG_HISTORY.md index b69ed763..d784f62e 100644 --- a/dev/WORKLOG_HISTORY.md +++ b/dev/WORKLOG_HISTORY.md @@ -1,7 +1,7 @@ # Developer Worklog History > **Status**: Active -> **Updated**: 2026-06-13 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Preserve compacted branch/PR history moved out of `dev/WORKLOG.md` at branch boundaries. @@ -9,6 +9,55 @@ Move completed branch or PR logs from `dev/WORKLOG.md` into this file when switching branches or after a PR is merged. Keep the active worklog focused on the current branch so reviewers can see the full PR context while older project history stays available. ## Archived Branches +### 2026-06-18 feat-security-auto-ban +- Implemented the auto-ban slice: retained source-risk Security signals now feed Visitor/IP score aggregation from the signal-write path; active bans use cache-flock TTL state with an indexed active-ban list, Visitor-before-IP selection, reset cutoffs, retained trigger/reset context, escalated `1h`/`3h`/`24h`/`7d` TTLs, forced bare `403` responses with `Retry-After`, and Owner-gated list/detail/reset UI plus Admin API endpoints. +- Added policy/configuration and operator surfaces for auto-ban enablement, trusted-user access level, score threshold, Owner alerts, active-ban detail enrichment from access-log GeoIP context, manual release/reset signals, fail-open setup/config/storage behavior, and the documented `/user/login?bypass=1` recovery render/submission flow while keeping trusted users and trusted-user-owned API keys out of source-ban lockouts. +- Hardened the branch through 31 Codex Cloud Review findings: serialized active-ban index/reset state, verified cache deletion failures, avoided duplicate trigger signals/alerts, aligned API endpoint access contracts, fixed recovery-login/auth ordering, covered API/scheduler/browser pre-auth and response-phase error fallbacks, prevented auto-ban responses from creating new signals, bounded suspicious payload scanning, handled scheduler trusted-key contexts, enforced retention/TTL compatibility, and recorded one scheduler-authorization finding as invalid because auto-ban only decides trusted-user bypass eligibility while scheduler authorization remains scheduler-owned. +- Added adjacent hardening beyond direct findings: scoreable suspicious payload signals for obvious public/untrusted attack patterns, newest-first retained detail rows, forced access-log entries for bare auto-ban responses, request-ID/GeoIP correlation for ban detail, config validation guards for bounded persisted settings, and documentation/worklog/class-map updates. Closed the branch with full verification: `php bin/phpunit`, `bin/jstest`, and `bin/lint` passed before merge. + +### 2026-06-17 to 2026-06-18 feat-security-rate-enforcement +- Implemented the rate-enforcement slice: descriptor-backed Symfony RateLimiter facade, Owner-gated mode setting (`off`, `standard`, `strict`, `panic`), action-cost-derived policy catalogue, Website/API/Scheduler/Auth/Setup/Probe buckets, fail-open storage diagnostics through the Message layer, authenticated multipliers, Owner ordinary exemption, recovery-login handling, active-profile scoped resets, dormant captcha reset contract, and redacted HTML/JSON `429`/probe responses. +- Hardened the branch through 39 resolved Cloud Review findings: unsafe-only workflow charging, authentication-failure ordering, generated/static exclusions, active-profile resets, recovery buckets, account/token subjects, API/CORS/read-only preflight handling, Owner and scheduler exceptions, exact technical path scopes, probe ordering before package/API/setup/maintenance gates, setup-final-apply safety, multi-bucket pre-check/commit semantics, website fallback for descriptor gaps, and segment-bound API/Cron guards. +- Added shared path helpers, HTTP error-renderer bare/resolve behavior, `render:route` diagnostics hardening, rate-limit Security settings translations, future cache-panic documentation, worklog/class-map/draft updates, PR-readiness/review-fix project rules, and final review notes showing full `bin/phpunit`, `bin/jstest`, and `bin/lint` passed before merge. + +### 2026-06-16 to 2026-06-17 feat-security-admin-acl-enforcement +- Implemented the Admin ACL enforcement slice: domain-owned feature registry, denied/visible/mutable states, surface inference from feature keys, seeded Owner-configurable defaults, ACL-group override states, Owner-gated `Settings/ACL` matrix, dynamic active-package settings rows, and feature-matrix caching with explicit invalidation. +- Wired Admin ACL feature checks through protected settings fields, Admin navigation/views, package/theme actions, package lifecycle and settings, GeoIP maintenance, operations continuations, scheduler, logs, statistics, users, user reviews, ACL group management, backup/status surfaces, and Admin API handlers while keeping visible-only controls rendered disabled where layout depends on them. +- Hardened the slice through review rounds: separated ACL-group definition permissions from user group membership mutations, rechecked target-domain ACL for live-operation continuations, applied `admin.settings.security` to all Security settings fields including Captcha, gated the concrete Scheduler web controller, and documented policy decisions that pending account-token review actions use `admin.users.review` and trusted registered Scheduler tasks use `admin.scheduler`. +- Closed the branch with updated translations, runtime catalogues, drafts, class map, worklog notes, focused regression coverage, full PHPUnit, JavaScript tests, lint, and container validation. + +### 2026-06-16 feat-security-abuse-foundation +- Implemented the Abuse Foundation slice: passive security-signal model and recording, request intent/action-cost classification, suspicious probe matching, visitor/IP-bucket evidence handling, configurable probe patterns, session/visitor mismatch signals, and database/file-backed Admin log browsing refinements. +- Hardened the slice through review passes: retention-aware signal/log reads, portable database search, source-aware Admin Log filters and pagination, safe path/token sanitization, locale-aware route classification, cache invalidation for probe patterns, and clearer rate-enforcement handoff policy for future limiter/ban branches. +- Closed the branch with full verification: `bin/phpunit`, `bin/jstest`, and `bin/lint` passed, with only intentional Markdown metadata hardbreaks reported by raw Git whitespace checks. + +### 2026-06-15 to 2026-06-16 feat-security-geoip-observability +- Implemented the GeoIP observability slice: provider-neutral resolver boundary, MaxMind/GeoIP2 local database provider, protected Statistics settings, safe provider diagnostics, Statistics/Admin status rendering, explicit setup defaults, manual Operations-backed database downloads, scheduler callable, and access log/statistics enrichment while preserving `n/a` fallbacks. +- Hardened GeoIP secrets, file handling, and portability through review rounds: no real MaxMind credentials in tests, no logged license-key URLs, project-relative `var/geoip2/GeoLite2-City.mmdb` path, Windows/path traversal rejection, compressed TAR validation, unsafe archive-member rejection, symlink/hardlink rejection, atomic replacement with readable permissions, streamed downloads, unsupported non-City database rejection, and bounded location labels for strict SQL platforms. +- Kept the slice narrow after product review: no latitude/longitude, no persistent GeoIP update-history store, no geo-blocking, no provider dropdown/account ID/locale settings, and no one-off Scheduler task ACL gates; task-level Scheduler policy is deferred to the Admin ACL enforcement matrix while direct GeoIP settings/download controls are Owner-only. +- Closed the branch with full verification and review context: full PHPUnit, JavaScript, lint, container, focused GeoIP/API/UI tests, class-map/worklog/draft updates, and Codex Cloud Review follow-ups. + +### 2026-06-15 feat-security-policy-docs +- Added `dev/draft/security-hardening/policy-defaults.md` as the central first-implementation policy source for Security hardening TTLs, rate-limit thresholds, auto-ban defaults, captcha defaults, privacy ceilings, logging projection posture, and configuration rules. +- Linked policy defaults from the master Security hardening plan, the Security/API/Contact-Mail-Logging drafts, and the affected branch plans; then refined captcha TTLs, website burst/sustained budgets, scheduler trigger limits, high-signal probe limits, recovery login bypass behavior, captcha auto-success policy, and Admin/Owner protections. +- Added cross-cutting Security policy decisions for deterministic enforcement order, block-response semantics, probe-pattern validation, configuration bounds, auditable exemptions, configuration-surface ownership, privacy/storage ceilings, and follow-up coverage for setup/install, CORS preflight, uploads, archives, exports, diagnostics, trusted proxy identity, browser storage, and HTTP security headers. +- Added Admin-vs-Owner authority policy plus the detailed `feat-security-admin-acl-enforcement` branch plan, including default authority matrix, bounded Owner configurability, concrete domain defaults, enforcement boundaries, and test expectations. + +### 2026-06-15 feat-security-planning +- Created the Security hardening planning package: master branch-tree plan plus detailed `feat-security-*` plans for policy docs, GeoIP observability, abuse foundations, rate enforcement, auto-ban, captcha contracts, IconCaptcha, mailer account delivery, and remember-me. +- Recorded core product decisions for `/api/live/**` rate-limit exclusion, Turbo/browser prefetch classification, scoped `reset()` behavior, database-backed passive signals/auto-bans, Owner recovery protection, GeoIP observability, IconCaptcha asset/accessibility rules, PR-readiness checks, and the inspiration-only `sec-lookup` legacy reference. +- Tightened privacy and architecture guardrails around shared client identity/trusted proxies, injectable clocks, degraded storage, race/idempotency, database-backed security event projection as an open question, and a 30-day maximum for queryable IP-derived data. + +### 2026-06-13 to 2026-06-14 feat-symfony-ux-integration +- Added the Symfony UX/UI foundation: namespace-aware Twig components, shared alert stacks, reusable Stimulus/live-polling controllers, notification center behavior, package live endpoints, package-aware cookie consent, local Mercure tooling, and lazy UX integrations. +- Hardened live/API/cookie/Mercure boundaries through repeated review passes, including exact-before-pattern dispatch, reserved live slugs, GET-only package live endpoints, alert topic scoping, consent cookie signing, protected Mercure env handling, URL/link sink validation, and safe fallback polling. +- Added JavaScript behavior coverage and UI/operation overlay refinements while recording follow-ups for public privacy triggers, live endpoint docs/navigation, captcha seed flows, notification preferences, package callbacks, and future LiveComponent filter slices. + +### 2026-06-12 to 2026-06-13 docs-cleanup +- Refreshed repository guidance and context docs: moved binding project rules into `AGENTS.md`, updated `.codex` environment/tooling notes, refreshed dependency recap for Symfony 8.1-era packages, and restored branch-oriented worklog archival rules. +- Improved local developer tooling documentation and commands: expanded `bin/lint` with diff/staged/changed modes, Markdown parsing, extensionless PHP checks, Git whitespace handling, route rendering through `php bin/console render:route`, and Symfony UX icon reference checks. +- Recorded Symfony UX integration notes, icon locking behavior, cache warmup/UX Translator expectations, production-only AssetMapper guidance, and ext-sodium platform requirements while archiving obsolete `.codex` helper context. + ### 2026-06-07 - Completed the API foundation and hardening slice: stateless Bearer API-key authentication, endpoint definitions/handlers, OpenAPI 3.2 generation, public/private navigation, admin/user/content/package endpoints, CORS, trace headers, feature policy settings, response/error schemas, and Message-layer localized feedback. - Hardened API access and review boundaries around disabled/setup/maintenance responses, package-owned route patterns, read-only method gates, endpoint permissions, API-key parsing, deleted users, ACL denial status, retained-deleted account mutations, content revisions, package slug identity, pagination/filtering/sorting, and public published-content status leakage. diff --git a/dev/draft/0.1.x-CoreArchitecture.md b/dev/draft/0.1.x-CoreArchitecture.md index a98ca902..3b7897ad 100644 --- a/dev/draft/0.1.x-CoreArchitecture.md +++ b/dev/draft/0.1.x-CoreArchitecture.md @@ -23,7 +23,7 @@ Database features should remain portable across MariaDB/MySQL, SQLite, and Postg The system should be organized around small vertical feature slices. A feature slice may contain routes/controllers, services, entities or DTOs, form types, validators, Twig templates, translations, tests, and documentation. Each slice should remain discoverable through predictable namespaces, draft links, class map entries, and worklog notes. -Packages should not require the core to know every future feature in advance. Instead, the core should expose documented extension points in three categories: +Extensions should not require the core to know every future feature in advance. Instead, the core should expose documented extension points in three categories: - **Observe:** A module reacts to a lifecycle event without changing the result. - **Extend:** A module contributes additional behavior through a hook, tagged service, or configuration. @@ -62,8 +62,8 @@ The exact namespace layout should evolve as features become real, but the first |-----------|---------| | `App\Core\` | Shared contracts, value objects, result objects, and cross-feature infrastructure. | | `App\Content\` | Static and dynamic content model, content services, field schemas, and rendering coordination. | -| `App\View\` | View context, template helpers, frontend/backend rendering primitives, and package-aware view integration. | -| `App\Core\Package\` | Package manifests, discovery, lifecycle, dependency checks, scopes, and package configuration. | +| `App\View\` | View context, template helpers, frontend/backend rendering primitives, and extension-aware view integration. | +| `App\Core\Extension\` | Extension manifests, discovery, lifecycle, dependency checks, scopes, and extension configuration. | | `App\Security\` | ACL, voters, captcha contracts, rate-limit helpers, and security extension points. | | `App\Editor\` | Administrative editor workflows, previews, diffs, and editing-specific integrations. | | `App\Integration\` | Import/export, API-facing adapters, and collaboration exchange formats. | @@ -87,40 +87,40 @@ The same pattern should be reused for modules and themes where practical. It kee Manifest files should stay simple: -- Use deterministic uppercase keys with package-specific prefixes such as `APP_` or `PACKAGE_`. +- Use deterministic uppercase keys with extension-specific prefixes such as `APP_` or `EXTENSION_`. - Store only metadata, discovery hints, compatibility requirements, update sources, channels, branches, and declared contributions. - Do not store secrets or environment-specific credentials. - Avoid nested structures. When lists are required, prefer simple comma-separated values or repeated documented keys. - Validate required keys and unknown critical values before a module or theme is registered. -- Keep parsing separate from domain validation. The shared manifest parser should understand only syntax and duplicate keys; required keys and closed allowed-key sets belong to per-feature manifest specifications. Package manifests use `PACKAGE_*` keys and validate scope values separately. +- Keep parsing separate from domain validation. The shared manifest parser should understand only syntax and duplicate keys; required keys and closed allowed-key sets belong to per-feature manifest specifications. Extension manifests use `EXTENSION_*` keys and validate scope values separately. -The first package discovery pass should monitor these manifest locations: +The first extension discovery pass should monitor these manifest locations: - `./.manifest` for application metadata using an `APP` manifest namespace. -- `packages/*/.manifest` for extension packages using `PACKAGE_*` keys and `PACKAGE_SCOPE`. -- `var/cache/$APP_ENV/imports/*/.manifest` for imported or staged packages. The import cache remains namespace-neutral at discovery time because future installer and updater workflows may stage different package types there. +- `extensions/*/.manifest` for installable extensions using `EXTENSION_*` keys and `EXTENSION_SCOPE`. +- `var/cache/$APP_ENV/imports/*/.manifest` for imported or staged extensions. The import cache remains namespace-neutral at discovery time because future installer and updater workflows may stage different extension types there. -Package validation should remain separate from installation and dry-run planning. A shared package validator may inspect a discovered package directory, list its contained files to a bounded depth, report included feature surfaces such as templates, assets, PHP files, `src/` PHP classes, Twig, JSON, YAML, CSS, and JavaScript files, and verify required files or directories before a caller builds a feature-specific dry-run plan. Feature layers should supply their own package specs instead of hardcoding theme or module structure in the generic validator. Package specs may request all syntax checks through `withLintingChecks()` for preflight use, or enable individual PHP, Twig, JSON, YAML, CSS, and JavaScript checks for debugging. These checks should use reusable string-based lint providers so later admin file editors and debug tools can run the same diagnostics without going through package import code. The package validator remains an adapter that reads package files, delegates to lint providers, and maps lint diagnostics into package-scoped issues; it does not install, move, migrate, or mutate package data. +Extension validation should remain separate from installation and dry-run planning. A shared extension validator may inspect a discovered extension directory, list its contained files to a bounded depth, report included feature surfaces such as templates, assets, PHP files, `src/` PHP classes, Twig, JSON, YAML, CSS, and JavaScript files, and verify required files or directories before a caller builds a feature-specific dry-run plan. Feature layers should supply their own extension specs instead of hardcoding theme or module structure in the generic validator. Extension specs may request all syntax checks through `withLintingChecks()` for preflight use, or enable individual PHP, Twig, JSON, YAML, CSS, and JavaScript checks for debugging. These checks should use reusable string-based lint providers so later admin file editors and debug tools can run the same diagnostics without going through extension import code. The extension validator remains an adapter that reads extension files, delegates to lint providers, and maps lint diagnostics into extension-scoped issues; it does not install, move, migrate, or mutate extension data. -Package operation planning should also stay neutral. After discovery and validation, callers may provide an explicit list of package files to copy into a target root or staging root. The shared planner may normalize and sort those files, reject unsafe or missing sources, and produce an `ActionQueue` of filesystem copy actions. It should not classify package type, decide activation behavior, infer install destinations, perform migrations, rewrite manifests, or apply rollback policy; those decisions belong to theme, module, importer, updater, or setup workflows. +Extension operation planning should also stay neutral. After discovery and validation, callers may provide an explicit list of extension files to copy into a target root or staging root. The shared planner may normalize and sort those files, reject unsafe or missing sources, and produce an `ActionQueue` of filesystem copy actions. It should not classify extension type, decide activation behavior, infer install destinations, perform migrations, rewrite manifests, or apply rollback policy; those decisions belong to theme, module, importer, updater, or setup workflows. -Dependency resolution for extension packages is intentionally not part of the shared package planner. Package installer workflows should later define how manifest metadata expresses requirements, conflicts, provided capabilities, optional suggestions, compatibility ranges, and update constraints. Those resolvers may use the same manifest, validation, dry-run, diff, and action-queue primitives, but they must own dependency policy because the correct behavior depends on package scopes, installed state, staged packages, administrator choices, and rollback rules. +Dependency resolution for installable extensions is intentionally not part of the shared extension planner. Extension installer workflows should later define how manifest metadata expresses requirements, conflicts, provided capabilities, optional suggestions, compatibility ranges, and update constraints. Those resolvers may use the same manifest, validation, dry-run, diff, and action-queue primitives, but they must own dependency policy because the correct behavior depends on extension scopes, installed state, staged extensions, administrator choices, and rollback rules. -Filesystem helpers should stay small and root-scoped. Core filesystem code should provide a shared relative-path guard for normalizing package/editor/import paths and rejecting traversal, absolute paths, empty paths, and null bytes before callers join paths to a root directory. Bounded file inventory scanning should also be reusable so packages, import caches, exports, backups, and debug tooling report directories and files consistently. +Filesystem helpers should stay small and root-scoped. Core filesystem code should provide a shared relative-path guard for normalizing extension/editor/import paths and rejecting traversal, absolute paths, empty paths, and null bytes before callers join paths to a root directory. Bounded file inventory scanning should also be reusable so extensions, import caches, exports, backups, and debug tooling report directories and files consistently. -Integrity helpers should support package imports, release updates, backups, generated assets, and cache verification without tying those workflows together. The first shared integrity primitive should be an algorithm-scoped checksum value object plus a calculator for strings, files, and deterministic file sets. File-set checksums should include relative paths as well as file contents so changed file names and changed contents both affect the result. +Integrity helpers should support extension imports, release updates, backups, generated assets, and cache verification without tying those workflows together. The first shared integrity primitive should be an algorithm-scoped checksum value object plus a calculator for strings, files, and deterministic file sets. File-set checksums should include relative paths as well as file contents so changed file names and changed contents both affect the result. Operational workflows should expose step-by-step progress through a lightweight action log. The action log is not a persistence layer; it is a value model for setup, dry-run, installer, update, import/export, and backup steps. Entries should carry a stable name, status, start and finish timestamps, optional `Message` diagnostics, and context such as command, exit code, target path, or output excerpts. This keeps CLI output, future UI progress views, and automated workflow summaries aligned. -Dry-run workflows should describe planned changes before they mutate files, configuration, package state, cache state, or runtime dependencies. A dry-run plan should contain typed actions, risk levels, affected paths, and optional structured diffs. Structured diffs should support text before/after payloads for file-like changes and key-value before/after payloads for manifests, configuration, metadata, JSON-object imports, database row-like imports, and installer state. Dry-run plans should also be convertible into action logs so UI previews, CLI summaries, and operation execution logs can share the same vocabulary. +Dry-run workflows should describe planned changes before they mutate files, configuration, extension state, cache state, or runtime dependencies. A dry-run plan should contain typed actions, risk levels, affected paths, and optional structured diffs. Structured diffs should support text before/after payloads for file-like changes and key-value before/after payloads for manifests, configuration, metadata, JSON-object imports, database row-like imports, and installer state. Dry-run plans should also be convertible into action logs so UI previews, CLI summaries, and operation execution logs can share the same vocabulary. -Diff generation should stay separate from dry-run transport. `App\Core\Diff` owns renderer-neutral before/after comparison and emits structured changes such as added, removed, or changed paths. `App\Core\DryRun` consumes those generated diff payloads as optional preview data on planned actions. This keeps future database-backed imports, JSON operation imports, editor previews, and package filesystem imports on the same diff vocabulary without forcing them through package-specific code. +Diff generation should stay separate from dry-run transport. `App\Core\Diff` owns renderer-neutral before/after comparison and emits structured changes such as added, removed, or changed paths. `App\Core\DryRun` consumes those generated diff payloads as optional preview data on planned actions. This keeps future database-backed imports, JSON operation imports, editor previews, and extension filesystem imports on the same diff vocabulary without forcing them through extension-specific code. -Operation execution should bridge dry-run previews and concrete actions without becoming a package installer, updater, or persistence workflow itself. The shared operation contract should let each action expose a typed dry-run preview and execute later as a recoverable `WorkflowResult`. Callers should pass actions as a deterministic `ActionQueue` with a stable name, queue-level context, explicit order, and stop-on-failure behavior. The executor should return an `OperationExecution` containing an `ActionLog` and aggregate result, map thrown exceptions into failed operation issues, and stop on the first non-success result by default. Concrete mutating actions such as copy-file, write-config, run-migration, or update-entity remain feature-owned and should compose this executor only after their own dry-run details are known. +Operation execution should bridge dry-run previews and concrete actions without becoming an extension installer, updater, or persistence workflow itself. The shared operation contract should let each action expose a typed dry-run preview and execute later as a recoverable `WorkflowResult`. Callers should pass actions as a deterministic `ActionQueue` with a stable name, queue-level context, explicit order, and stop-on-failure behavior. The executor should return an `OperationExecution` containing an `ActionLog` and aggregate result, map thrown exceptions into failed operation issues, and stop on the first non-success result by default. Concrete mutating actions such as copy-file, write-config, run-migration, or update-entity remain feature-owned and should compose this executor only after their own dry-run details are known. -Generic filesystem operation actions may live in Core when they remain root-scoped, path-guarded, and feature-neutral. The first reusable actions should cover ensuring directories, writing files, and copying files with dry-run previews, text diffs where useful, overwrite protection, parent-directory creation, and structured filesystem issues. Package installers, setup flows, update workflows, and importers may compose these actions, but package classification, migration decisions, rollback policy, and user confirmation behavior stay in their owning feature layers. +Generic filesystem operation actions may live in Core when they remain root-scoped, path-guarded, and feature-neutral. The first reusable actions should cover ensuring directories, writing files, and copying files with dry-run previews, text diffs where useful, overwrite protection, parent-directory creation, and structured filesystem issues. Extension installers, setup flows, update workflows, and importers may compose these actions, but extension classification, migration decisions, rollback policy, and user confirmation behavior stay in their owning feature layers. -Generic process operation actions may live in Core when they execute explicit argument-list commands rather than shell strings, expose dry-run metadata without leaking environment values, and map process exit codes into recoverable workflow results with output excerpts. Package lifecycle workflows may later compose these actions for cache clearing, asset installation, importmap installation, Tailwind builds, production AssetMapper compilation, migrations, cache rebuilds, and diagnostics while keeping workflow-specific policy outside the command action itself. +Generic process operation actions may live in Core when they execute explicit argument-list commands rather than shell strings, expose dry-run metadata without leaking environment values, and map process exit codes into recoverable workflow results with output excerpts. Extension lifecycle workflows may later compose these actions for cache clearing, asset installation, importmap installation, Tailwind builds, production AssetMapper compilation, migrations, cache rebuilds, and diagnostics while keeping workflow-specific policy outside the command action itself. Core operation and preview value objects should expose renderer-neutral structured payloads for later UI, CLI, import diff, editor, and automation consumers. These payloads should use stable snake_case keys and primitive arrays where practical, but they are transport shapes rather than persisted audit records. Rendering, translation, redaction, filtering, and durable storage remain the responsibility of the consuming feature. @@ -135,11 +135,11 @@ Core operation and preview value objects should expose renderer-neutral structur - Event-based replacement should be used only when a feature genuinely needs several listeners to negotiate or modify a result. Simple one-active-provider replacement should prefer a resolver or configuration-selected provider because that is easier to trace and review. - An event is public only when it is documented as a module or theme extension point, marked as `Observe`, `Extend`, or `Replace`, or listed as a stable extension contract. Undocumented events are internal lifecycle events and may change with their owning feature. -### Package migrations +### Extension migrations -Packages may provide migrations before `1.0.0`, but the mechanism should be consistent from the start. Package migrations should be discovered through package metadata and executed through a single core-owned migration workflow rather than each package running ad-hoc database changes. +Extensions may provide migrations before `1.0.0`, but the mechanism should be consistent from the start. Extension migrations should be discovered through extension metadata and executed through a single core-owned migration workflow rather than each extension running ad-hoc database changes. -The core content model may use variable fieldsets for flexible content structures, but package-owned domain data should prefer package-owned tables when it is more explicit, queryable, and maintainable. Packages should use collision-resistant table names such as `pkg__` instead of storing unrelated domain data inside generic shared fieldset tables. Shared fieldsets should be used for content-like extension data, not as a substitute for clear package persistence boundaries. +The core content model may use variable fieldsets for flexible content structures, but extension-owned domain data should prefer extension-owned tables when it is more explicit, queryable, and maintainable. Extensions should use collision-resistant table names such as `ext__
` instead of storing unrelated domain data inside generic shared fieldset tables. Shared fieldsets should be used for content-like extension data, not as a substitute for clear extension persistence boundaries. The first migration design should prefer Symfony and Doctrine conventions: @@ -148,18 +148,18 @@ The first migration design should prefer Symfony and Doctrine conventions: - Run module migrations through controlled console commands with dry-run and confirmation behavior where applicable. - Track which module migrations have been applied. - Validate that a module is compatible before applying its migrations. -- Prefer explicit package-owned tables for package domain data when a package needs durable structured storage. +- Prefer explicit extension-owned tables for extension domain data when an extension needs durable structured storage. - Allow breaking migration strategy changes before `1.0.0` when drafts, code, tests, and documentation are updated together. ### API boundary -The first API scope should be content interaction only. API endpoints should expose content retrieval and content-oriented operations defined by the content and API drafts. Internal process control, administrative automation, package lifecycle operations, update workflows, backup/restore, and other operational controls should stay out of the initial API surface unless a later draft explicitly adds them. +The first API scope should be content interaction only. API endpoints should expose content retrieval and content-oriented operations defined by the content and API drafts. Internal process control, administrative automation, extension lifecycle operations, update workflows, backup/restore, and other operational controls should stay out of the initial API surface unless a later draft explicitly adds them. ### Configuration model Global configuration should be managed through the admin panel and stored in a database-backed configuration table once persistence exists. Global configuration is administrator-only: reading and writing global settings requires the `Administrators` ACL group unless a later draft explicitly exposes a value elsewhere. -Editor-facing preferences, package settings, and global administrator configuration should stay logically separate so ACLs, purge behavior, and ownership remain clear. User-specific settings such as scoped API tokens belong to the user/profile context, not the global configuration table. Package settings belong to a package-scoped store so package purge can remove them cleanly. Project-specific overrides such as a project or subpage using another theme should be stored as project/content fields in the project hub or relevant content model rather than as separate global config keys. +Editor-facing preferences, extension settings, and global administrator configuration should stay logically separate so ACLs, purge behavior, and ownership remain clear. User-specific settings such as scoped API tokens belong to the user/profile context, not the global configuration table. Extension settings belong to an extension-scoped store so extension purge can remove them cleanly. Project-specific overrides such as a project or subpage using another theme should be stored as project/content fields in the project hub or relevant content model rather than as separate global config keys. Configuration precedence should stay simple: @@ -175,7 +175,7 @@ Protected configuration values such as provider API keys should be readable and Configuration changes should be logged through the normal logging/audit path with safe metadata such as actor, key/group, old/new value redaction where needed, and timestamp. A separate admin audit log remains an implementation option and should be decided when logging retention and log categories are defined. -Simple package setting definitions should carry enough typed metadata for Studio to render generic forms without making package authors build forms: expected value type, optional input type, defaults, option lists, and validation metadata such as required, min/max, min/max length, pattern, or allowed values. The stored package setting remains key/value JSON in the package-scoped store. +Simple extension setting definitions should carry enough typed metadata for Studio to render generic forms without making extension authors build forms: expected value type, optional input type, defaults, option lists, and validation metadata such as required, min/max, min/max length, pattern, or allowed values. The stored extension setting remains key/value JSON in the extension-scoped store. ### Implementation order @@ -183,43 +183,43 @@ Simple package setting definitions should carry enough typed metadata for Studio 2. Implement recoverable error and validation patterns before complex administrative workflows. 3. Define static/dynamic content boundaries before schema fields, editor flows, import/export, or search. 4. Define theme resolution before plugin modules are allowed to contribute templates or assets. -5. Define package discovery and lifecycle before packages can contribute routes, services, permissions, migrations, or assets. +5. Define extension discovery and lifecycle before extensions can contribute routes, services, permissions, migrations, or assets. 6. Define event and Messenger conventions before broad side-effect workflows such as indexing, logging, statistics, or mail dispatch. 7. Add operational features such as backup, restore, self-update, and release packaging only after the core content and module boundaries are stable enough to protect user data. ## Testing & Validation - Run `php -l ` for changed PHP files. - Run targeted PHPUnit tests for changed service, controller, entity, form, validator, or command behavior. -- Run `php bin/console lint:container` after service wiring, configuration, event subscriber, message handler, security, or package lifecycle changes. +- Run `php bin/console lint:container` after service wiring, configuration, event subscriber, message handler, security, or extension lifecycle changes. - Add integration tests for every documented extension point before relying on it from another feature. - Add resolver tests for replaceable strategies, including default selection, priority handling, configuration selection, and invalid contribution handling. - Test configuration precedence for real environment variables, database-backed admin configuration, and `.env` defaults as the `Config` service grows beyond the current database-backed `get()`/`set()` baseline. - Test administrator-only access to global configuration and protected configuration values. -- Test package-scoped settings separately from global configuration, including default fallback and purge cleanup. +- Test extension-scoped settings separately from global configuration, including default fallback and purge cleanup. - Add event or Messenger tests when behavior depends on dispatching, consuming, or ordering side effects. - Run `.codex/compare_translations.php` when user-facing copy is added or changed. - Use `php bin/console render:route /` for rendered Twig review when routes, templates, translations, or editor UI change. -- Keep `dev/CLASSMAP.md` updated for public behavior entry points such as controllers, commands, services with contributor-facing behavior, event subscribers, voters, Twig extensions, and package lifecycle hooks. +- Keep `dev/CLASSMAP.md` updated for public behavior entry points such as controllers, commands, services with contributor-facing behavior, event subscribers, voters, Twig extensions, and extension lifecycle hooks. ## Implementation Notes - **Decision recorded:** Use the proposed namespace grouping as the starting point. Larger namespaces are acceptable when their internals stay modular, small, and easy to review. - **Decision recorded:** Use a lightweight `.manifest`-style metadata format based on `KEY=VALUE` pairs for application, plugin module, and theme metadata unless a later draft identifies a concrete limitation. - **Decision recorded:** Manifest parsing is domain-neutral. Required keys are not hardcoded in the parser and must be supplied by feature-specific manifest specifications or validators. Namespaced specs should receive namespace and short key names separately to avoid duplicate allowlists and keep future app, theme, and module manifests consistent. -- **Decision recorded:** Core package discovery starts with the root app manifest, direct child package manifests under `packages/`, and env-specific cached import manifests under `var/cache/$APP_ENV/imports`. Cached imports are treated as generic manifests until installer/updater workflows classify them. -- **Decision recorded:** Core package validation checks package filesystem requirements only. It reports missing required files or directories, returns a bounded feature inventory for later dry-run planning, and can optionally run PHP, Twig, JSON, YAML, CSS, and JavaScript syntax checks through reusable `App\Core\Lint` providers, but it does not install, move, migrate, or mutate package data. -- **Decision recorded:** Core package operation planning may translate an explicit selected file list into a deterministic ActionQueue, but install destination policy, activation, migrations, rollback, and package-type behavior remain feature-owned. -- **Decision recorded:** Theme/module dependency maps and compatibility resolution are deferred to package-type installer workflows. Core package planning stays dependency-neutral and only provides primitives those workflows may compose. -- **Decision recorded:** Root-scoped filesystem operations should use shared path and inventory helpers instead of ad-hoc string checks. Relative package/editor/import paths must be normalized and rejected when they escape their root. +- **Decision recorded:** Core extension discovery starts with the root app manifest, direct child extension manifests under `extensions/`, and env-specific cached import manifests under `var/cache/$APP_ENV/imports`. Cached imports are treated as generic manifests until installer/updater workflows classify them. +- **Decision recorded:** Core extension validation checks extension filesystem requirements only. It reports missing required files or directories, returns a bounded feature inventory for later dry-run planning, and can optionally run PHP, Twig, JSON, YAML, CSS, and JavaScript syntax checks through reusable `App\Core\Lint` providers, but it does not install, move, migrate, or mutate extension data. +- **Decision recorded:** Core extension operation planning may translate an explicit selected file list into a deterministic ActionQueue, but install destination policy, activation, migrations, rollback, and extension-type behavior remain feature-owned. +- **Decision recorded:** Theme/module dependency maps and compatibility resolution are deferred to extension-type installer workflows. Core extension planning stays dependency-neutral and only provides primitives those workflows may compose. +- **Decision recorded:** Root-scoped filesystem operations should use shared path and inventory helpers instead of ad-hoc string checks. Relative extension/editor/import paths must be normalized and rejected when they escape their root. - **Decision recorded:** Shared integrity helpers should stay algorithm-scoped and deterministic. File-set checksums include relative paths and content, and callers remain responsible for sorting paths when order-independent behavior is required. - **Decision recorded:** Action logs are immutable operation summaries, not persistent audit logs. They carry structured `Message` instances for diagnostics and should not introduce a third generic diagnostics model until UI, CLI, and logging needs require one. -- **Decision recorded:** Dry-run plans should be immutable previews made of typed actions, risk levels, affected paths, optional structured diffs, and ActionLog export. Diff generation lives in `App\Core\Diff`; dry-run actions only carry generated diff payloads. Diffs start with text and key-value payloads because those cover package imports, file editors, manifests, JSON-object imports, database row-like imports, and operation previews without committing to a UI renderer. -- **Decision recorded:** Operation execution is a small bridge between dry-run previews and feature-owned mutating actions. Actions expose `dryRun()` and `execute()`, callers pass them as an immutable `ActionQueue`, the executor emits an ActionLog plus aggregate WorkflowResult, and concrete filesystem/database/package behavior stays out of the generic executor until its owning feature defines it. -- **Decision recorded:** Core may provide root-scoped filesystem operation actions for common file mutations when they are path-guarded and feature-neutral. Package-specific install/update behavior remains outside those generic actions. +- **Decision recorded:** Dry-run plans should be immutable previews made of typed actions, risk levels, affected paths, optional structured diffs, and ActionLog export. Diff generation lives in `App\Core\Diff`; dry-run actions only carry generated diff payloads. Diffs start with text and key-value payloads because those cover extension imports, file editors, manifests, JSON-object imports, database row-like imports, and operation previews without committing to a UI renderer. +- **Decision recorded:** Operation execution is a small bridge between dry-run previews and feature-owned mutating actions. Actions expose `dryRun()` and `execute()`, callers pass them as an immutable `ActionQueue`, the executor emits an ActionLog plus aggregate WorkflowResult, and concrete filesystem/database/extension behavior stays out of the generic executor until its owning feature defines it. +- **Decision recorded:** Core may provide root-scoped filesystem operation actions for common file mutations when they are path-guarded and feature-neutral. Extension-specific install/update behavior remains outside those generic actions. - **Decision recorded:** Core may provide a process operation action for explicit argument-list commands, exit-code mapping, and output excerpts. Workflow-specific command selection and recovery policy remain feature-owned. - **Decision recorded:** Workflow, action-log, dry-run, and operation execution objects may expose structured `toArray()` payloads for transport and debugging, but those payloads are not a durable audit schema or renderer contract. - **Decision recorded:** Replacement behavior is not automatically event-based. Each replacement point must define its own selection mechanism. Events may participate when negotiation is needed, but simple provider replacement should prefer explicit resolvers, service decoration, or configuration. - **Decision recorded:** An event is public only when it is documented as a module or theme extension point or listed as a stable extension contract. Undocumented events are internal lifecycle events and may change with their owning feature. Before `1.0.0`, public events may still change when drafts, tests, documentation, and callers are updated together. -- **Decision recorded:** Package migrations are allowed before `1.0.0`, but must use a single consistent core-owned migration workflow based on Symfony and Doctrine conventions. Package-owned tables are preferred for package domain data; variable fieldsets remain focused on flexible content structures and content-like extension data. +- **Decision recorded:** Extension migrations are allowed before `1.0.0`, but must use a single consistent core-owned migration workflow based on Symfony and Doctrine conventions. Extension-owned tables are preferred for extension domain data; variable fieldsets remain focused on flexible content structures and content-like extension data. - **Decision recorded:** Existing and future orchestration classes should be split into thin facades plus small services wherever this improves modularity, reuse, and context stability. The roughly 300-line file target is an active design pressure for both new code and useful existing-code refactors. - **Decision recorded:** Controllers are HTTP adapters. Application workflows belong in focused services so browser controllers, CLI commands, API endpoints, setup flows, and operational UI can reuse the same behavior without calling each other. - **Decision recorded:** Use `symfony/uid` as the unified UID source instead of custom UUID helpers. Do not add a separate public ID when the stable UID or a unique slug already covers the public reference case; high-write tables may still receive separate storage/index review. diff --git a/dev/draft/0.1.x-ErrorHandlingValidation.md b/dev/draft/0.1.x-ErrorHandlingValidation.md index 303c7f1c..1275fc97 100644 --- a/dev/draft/0.1.x-ErrorHandlingValidation.md +++ b/dev/draft/0.1.x-ErrorHandlingValidation.md @@ -40,13 +40,13 @@ Hard exceptions are still appropriate for programming errors, impossible states, - Use `EventDispatcher` to publish validation or workflow lifecycle events only when another feature or module has a documented reason to observe them. - Use Messenger for delayed verification, indexing, or processing steps that should not block the request. - Provide dry-run or preview modes for high-impact workflows such as imports, module migrations, backups, restores, updates, and release packaging. -- Integrate linting and verification commands into workflows where invalid source input could break rendering or execution, for example template edits, code snippets, manifest changes, and import packages. +- Integrate linting and verification commands into workflows where invalid source input could break rendering or execution, for example template edits, code snippets, manifest changes, and import extensions. - Provide user-definable custom error pages for common HTTP status codes. - Render recoverable HTTP errors through a central response renderer instead of making every caller know template paths. The fallback order is internal content entity `/system/error-pages/{status}`, then `@frontend/error-pages/{status}.html.twig`, then `@frontend/error-pages/default.html.twig`. - Return the correct HTTP status code even when the body is rendered through a content entity or Twig fallback. - Treat `401 Unauthorized` as a login-flow hint for anonymous users: render the frontend login template with an explanatory error context. Authenticated users with insufficient access should receive the normal themed error page for the chosen status. - In `APP_DEBUG`, preserve Symfony's native exception page for thrown exceptions; explicit error responses may expose debug context to Twig variables when an exception was passed to the renderer. -- When module or theme manifests are invalid, skip only the affected package, log the skip with error details, and continue discovering other packages. +- When module or theme manifests are invalid, skip only the affected extension, log the skip with error details, and continue discovering other extensions. - Store failed operation snapshots only when `APP_DEBUG` is enabled. Purge these snapshots automatically when the error is resolved or debug mode is disabled. - Treat dry-run and preview checks as automated safety checks for high-impact operations. They should verify whether the proposed action is safely executable before execution and should not imply that every workflow needs a user-facing diff. @@ -55,7 +55,7 @@ Hard exceptions are still appropriate for programming errors, impossible states, - Functional-test form submission failures to confirm input is preserved and translated messages appear. - Test destructive and high-impact workflows in dry-run or preview mode before testing confirmed execution. - Test that logs include useful identifiers without leaking secrets or raw sensitive payloads. -- Test invalid package manifests skip only the affected package and log useful diagnostics. +- Test invalid extension manifests skip only the affected extension and log useful diagnostics. - Test debug-only failed operation snapshots are purged when resolved or when debug mode is disabled. - Test custom error-page selection for common HTTP status codes. - Test the fallback order from system content entity to status template to default template. @@ -68,14 +68,15 @@ Hard exceptions are still appropriate for programming errors, impossible states, - **Decision recorded:** Treat recoverable workflow failures as explicit result states instead of exceptions. - **Decision recorded:** Keep shared result abstractions minimal until at least two features need the same shape. - **Decision recorded:** Use dry-run or preview checks for imports, module migrations, backups, restores, updates, and release packaging as automated safety checks before execution, without requiring a user-facing diff for every workflow. -- **Decision recorded:** Invalid module or theme manifests should disable only the affected package during discovery and should be logged with error details. +- **Decision recorded:** Invalid module or theme manifests should disable only the affected extension during discovery and should be logged with error details. - **Decision recorded:** Failed operation snapshots are stored only when `APP_DEBUG` is enabled and are automatically purged when the error is resolved or debug mode is disabled. - **Decision recorded:** Error handling includes user-definable custom error pages for common HTTP status codes. -- **Decision recorded:** `App\View\Http\HttpErrorRenderer` owns recoverable HTTP error presentation. Callers should return its response instead of throwing when they can safely continue the request. +- **Decision recorded:** `App\View\Http\HttpErrorRenderer` owns recoverable browser HTTP error presentation through `HttpErrorRenderer::resolve()`. Callers should return its response instead of throwing when they can safely continue the request; API JSON errors stay on the API responder boundary. Callers may force a minimal bare HTML response when a block surface should avoid full error-page rendering. - **Decision recorded:** The HTTP error fallback order is `/system/error-pages/{status}` content, then `@frontend/error-pages/{status}.html.twig`, then `@frontend/error-pages/default.html.twig`. +- **Decision recorded:** Before setup completion, the browser HTTP error resolver returns DB-free minimal HTML `no-store` responses for known `4xx`/`5xx` statuses instead of resolving custom system error content. The minimal HTML includes the status code/text, an optional explicit bare-context line, and a Request ID resolved directly through `AccessRequestMetadata`. - **Decision recorded:** `401` for anonymous users renders the frontend login template with `http_error` Twig variables and still returns status `401`; authenticated users receiving `401` continue through the normal system-content/status-template/default error-page fallback. -- **Decision recorded:** Workflow issues, logs, output, and validation flows use a shared `App\Core\Message\Message` shape with a log level, machine-readable code, translatable key, parameters, and context. Core-owned message constants live in domain-owned `*MessageCode` and `*MessageKey` catalogues that are aggregated by `App\Core\Message\MessageCode` and `App\Core\Message\MessageKey` for tooling and translation checks. Catalogue constant names and values are namespace-bound to their owning scope. Third-party modules and themes may provide their own deterministic package-namespaced codes and keys, but system/core namespaces remain reserved. Domain code may use `MessageException::invalidArgument()` to carry a structured message at hard invariant boundaries without embedding user-facing text in exceptions. -- **Decision recorded:** Recoverable runtime, package, theme, hook, asset, and rendering failures should return `WorkflowResult`/`Message` diagnostics or render a controlled error response wherever possible. Hard throws are reserved for programmer errors, invalid value-object construction, setup/CLI aborts, and guard rails that cannot safely continue in-place. +- **Decision recorded:** Workflow issues, logs, output, and validation flows use a shared `App\Core\Message\Message` shape with a log level, machine-readable code, translatable key, parameters, and context. Core-owned message constants live in domain-owned `*MessageCode` and `*MessageKey` catalogues that are aggregated by `App\Core\Message\MessageCode` and `App\Core\Message\MessageKey` for tooling and translation checks. Catalogue constant names and values are namespace-bound to their owning scope. Third-party modules and themes may provide their own deterministic extension-namespaced codes and keys, but system/core namespaces remain reserved. Domain code may use `MessageException::invalidArgument()` to carry a structured message at hard invariant boundaries without embedding user-facing text in exceptions. +- **Decision recorded:** Recoverable runtime, extension, theme, hook, asset, and rendering failures should return `WorkflowResult`/`Message` diagnostics or render a controlled error response wherever possible. Hard throws are reserved for programmer errors, invalid value-object construction, setup/CLI aborts, and guard rails that cannot safely continue in-place. - **Decision recorded:** Message transport arrays intentionally expose only `level`, `code`, `translation_key`, `parameters`, and `context`. They do not include a pre-rendered `message` string before localization. -- **Decision recorded:** `Message` is the central feedback object for logs, workflow issues, workflow messages, UI toasts, and future localized output. `WorkflowResult` separates state-changing issue messages from non-blocking messages. Issues should usually describe `WARN`, `ERROR`, or `EXCEPTION` diagnostics; messages may carry `SUCCESS`, `INFO`, and `DEBUG` events such as completed tasks, package discovery, process completion, and noisy filesystem details. -- **Decision recorded:** `WARN` is reserved for important but recoverable or expected fallback behavior that does not leave the system broken. `ERROR` marks states that need attention or repair, including faulty packages, invalid package manifests, missing required package files, broken package dependencies, and failed writes. +- **Decision recorded:** `Message` is the central feedback object for logs, workflow issues, workflow messages, UI toasts, and future localized output. `WorkflowResult` separates state-changing issue messages from non-blocking messages. Issues should usually describe `WARN`, `ERROR`, or `EXCEPTION` diagnostics; messages may carry `SUCCESS`, `INFO`, and `DEBUG` events such as completed tasks, extension discovery, process completion, and noisy filesystem details. +- **Decision recorded:** `WARN` is reserved for important but recoverable or expected fallback behavior that does not leave the system broken. `ERROR` marks states that need attention or repair, including faulty extensions, invalid extension manifests, missing required extension files, broken extension dependencies, and failed writes. diff --git a/dev/draft/0.1.x-SetupTestAutomation.md b/dev/draft/0.1.x-SetupTestAutomation.md index 00ca3bda..dd688f4e 100644 --- a/dev/draft/0.1.x-SetupTestAutomation.md +++ b/dev/draft/0.1.x-SetupTestAutomation.md @@ -49,7 +49,7 @@ The test environment should be deterministic. It should use `env:test`, SQLite b - Use `.codex/compare_translations.php` for translation-key drift. - Use `php bin/console render:route /` for Twig and translation review. - Use `php bin/console tailwind:build` when frontend CSS changes; keep `asset-map:compile` production-only. -- Run `php bin/console assets:rebuild` when package changes affect templates, CSS, JavaScript, imported assets, or Tailwind class usage. +- Run `php bin/console assets:rebuild` when extension changes affect templates, CSS, JavaScript, imported assets, or Tailwind class usage. - Keep local frontend watchers optional for development convenience; release/admin workflows should use explicit build commands. - Provide setup or reset commands only when they are deterministic and environment-aware. - Avoid scripts that mutate production-like data unless they require explicit confirmation. @@ -65,14 +65,14 @@ The test environment should be deterministic. It should use `env:test`, SQLite b - Run targeted PHPUnit coverage while developing a feature, then broader suites before release or high-risk merges. - Run `lint:container` after adding commands, services, subscribers, handlers, voters, validators, or configuration. - Verify helper scripts have clear failure output and non-zero exit codes on failure. -- Test automation should cover the command order for package asset rebuilds once lifecycle commands exist. +- Test automation should cover the command order for extension asset rebuilds once lifecycle commands exist. - Test or manually verify the release-readiness path before public releases. - Update `.codex/README.md` whenever helper scripts are added, renamed, or removed. ## Implementation Notes - **Decision recorded:** Keep `.codex/` as the repository-owned automation and agent helper area, but do not require it in release packages. - **Decision recorded:** Provide release-relevant setup through `bin/setup`, while keeping dependency and repository initialization in `bin/init`. -- **Decision recorded:** Provide `bin/init` as a dependency and environment check that verifies Composer packages, importmap assets, Tailwind binary availability, and similar setup prerequisites, and may attempt safe auto-repair such as running Composer install when packages are missing. +- **Decision recorded:** Provide `bin/init` as a dependency and environment check that verifies Composer packages, importmap assets, Tailwind binary availability, and similar setup prerequisites, and may attempt safe auto-repair such as running Composer install when Composer packages are missing. - **Decision recorded:** Make the setup workflow callable from CLI and from a dedicated web setup environment, sharing validation and configuration behavior. - **Decision recorded:** The web setup wizard must remain database-free until the final apply step starts the shared setup runner. Wizard rendering, preflight checks, form-state persistence, step gating, and error routing must not require Doctrine DBAL/ORM connections. - **Decision recorded:** Web setup state is preserved across steps so users can move back to completed steps and change language, site metadata, database settings, or admin data before applying setup. Steps unlock only after their required values pass local validation; final execution still revalidates through the shared setup input and runner. @@ -83,7 +83,7 @@ The test environment should be deterministic. It should use `env:test`, SQLite b - **Implemented baseline:** CLI and web setup share `SetupInputNormalizer` for database driver and URL parsing, database prefix normalization, boolean values, and default admin email derivation, plus `SetupInputValidator` for supported language, default URI, database URL, database prefix, admin credential, and APP_SECRET length checks. Step-scoped web filtering and interactive CLI prompts stay transport-specific adapters around the shared validation rules. - **Decision recorded:** The setup review page should show only wizard-relevant data and planned operational steps, not the full seed internals from the generic dry-run log. - **Decision recorded:** Web setup execution should use the ActionLog/LiveOperation presentation path with one visible operation entry per setup runner step, and failure pages should keep the wizard state so the user can jump back to the relevant step, for example database connection settings after a connection error. -- **Decision recorded:** `APP_SETUP_COMPLETED` must only be marked after every setup runner step has succeeded. Dry-runs, failed writes, failed migrations, failed seeding, failed package discovery, or failed asset rebuilds must leave setup unlocked. +- **Decision recorded:** `APP_SETUP_COMPLETED` must only be marked after every setup runner step has succeeded. Dry-runs, failed writes, failed migrations, failed seeding, failed extension discovery, or failed asset rebuilds must leave setup unlocked. - **Decision deferred:** A later setup mode may support connecting to an existing Studio installation instead of creating a new one. That flow must be explicit, distinguish "new installation" from "use existing installation", authenticate with an active OWNER account and the existing installation's `APP_SECRET`, avoid mutating seed data during discovery/adoption, and only apply needed migrations after the operator confirms the existing installation context. - **Decision recorded:** Test environments must be preconfigured so code review automation and PHPUnit can run without user input. - **Decision recorded:** `bin/init` may use bundled `bin/composer` when the system Composer command is missing or unusable. @@ -98,7 +98,7 @@ The test environment should be deterministic. It should use `env:test`, SQLite b - **Decision recorded:** The default test environment should use SQLite unless a feature needs database-specific behavior. - **Decision recorded:** PHPUnit bootstrap owns `env:test` database initialization directly, applying migrations and deterministic demo seeds without requiring `bin/setup`. Because `var/test/test.db` is shared per process, the lifecycle must reject concurrent PHPUnit processes instead of allowing parallel runs to race over database setup. - **Decision recorded:** The first fixture strategy uses an explicit `tests/Support` database seeder after migrations are applied, then can evolve into fixture builders or dedicated fixture tooling once content entities and schemas are stable. -- **Decision recorded:** Package asset rebuild automation runs Tailwind before AssetMapper compilation and clears cache last. Watchers are optional development tooling, not the production/admin lifecycle mechanism. +- **Decision recorded:** Extension asset rebuild automation runs Tailwind before AssetMapper compilation and clears cache last. Watchers are optional development tooling, not the production/admin lifecycle mechanism. - **Decision recorded:** Public releases need a repeatable release-readiness verification path rather than relying on ad-hoc manual checks. - **Decision recorded:** Setup runner, preflight checks, wizard flow, and CLI/web input handling should be modularized into shared step, check, flow, and normalization services. The seeder may stay central because preset content should remain easy to review and manage. - **Decision recorded:** Setup seeding should stay easy to handle by separating seed execution from seed data where useful. PHP array seed-data files are an acceptable middle ground: DBAL can keep bootstrap reliable, while the data shape becomes easier to inspect and update. diff --git a/dev/draft/0.1.x-StaticDynamicContent.md b/dev/draft/0.1.x-StaticDynamicContent.md index 23238a7b..2094d771 100644 --- a/dev/draft/0.1.x-StaticDynamicContent.md +++ b/dev/draft/0.1.x-StaticDynamicContent.md @@ -49,7 +49,7 @@ Routing should remain explicit and inspectable. Symfony routes should handle adm - Track visibility metadata such as public/private state and optional ACL restrictions when only specific ACL groups may view content. - Track ownership or responsible user/group metadata when workflow, review, or ACL behavior needs it. - Track editing lock metadata through `state_marker` only if concurrent editing protection is implemented. -- Reserve route prefixes for system areas such as `admin/`, `editor/`, `setup/`, `cron/`, `system/`, `user/`, `api/`, `assets/`, `_profiler/`, `_wdt/`, `build/`, `packages/`, `media/`, and `files/`. +- Reserve route prefixes for system areas such as `admin/`, `editor/`, `setup/`, `cron/`, `system/`, `user/`, `api/`, `assets/`, `_profiler/`, `_wdt/`, `build/`, `extensions/`, `media/`, and `files/`. - Treat `/system/...` as an internal content namespace. It may contain content entities used by resolvers, redirects, theme slots, and later custom error pages, but direct browser delivery through the public catch-all route must be blocked. - Use Symfony controllers for admin/content-management routes. - Use a content route resolver for public content rendering. @@ -130,7 +130,7 @@ The first implementation should keep routing, workflow, and permission fields ex | `uid` | Durable schema identity. | | `identifier` | Unique content-type identifier such as `static_page` or `blog_post`. | | `source` | Schema source such as `preset`, `custom`, or `module`. | -| `locked` | Marks immutable presets or package-owned schemas. | +| `locked` | Marks immutable presets or extension-owned schemas. | | `active_version_uid` | Nullable active schema version. `NULL` disables normal use while preserving the schema for admin recovery or cleanup. | | `labels` / `descriptions` | Translatable schema display text. | | `metadata` | Non-critical schema metadata. | @@ -209,7 +209,7 @@ The first implementation should keep routing, workflow, and permission fields ex - **Decision recorded:** Content schemas may store custom Twig markup in the database for rendering the inner content block of schema-driven fieldsets. This is independent from theme template files; the theme provides the outer layout and generic fallback renderer. - **Decision recorded:** User-defined schema Twig must pass validation before it is saved or activated. A safety dictionary for allowed and disallowed Twig features should be considered. - **Decision recorded:** Keep public URLs separate from durable content identity. Each content entry should have a UID, unique scoped slug, optional custom URL path, version history, and default hierarchy-based URL generation. -- **Decision recorded:** Reserve core prefixes early to avoid future URL conflicts. Initial reserved prefixes include `admin/`, `editor/`, `setup/`, `cron/`, `system/`, `user/`, `api/`, `assets/`, `_profiler/`, `_wdt/`, `build/`, `packages/`, `media/`, and `files/`. +- **Decision recorded:** Reserve core prefixes early to avoid future URL conflicts. Initial reserved prefixes include `admin/`, `editor/`, `setup/`, `cron/`, `system/`, `user/`, `api/`, `assets/`, `_profiler/`, `_wdt/`, `build/`, `extensions/`, `media/`, and `files/`. - **Decision recorded:** Root-level content stores the deterministic parent marker `/` instead of `NULL`, so `(parent_uid, slug)` uniqueness remains enforceable across SQLite, PostgreSQL, and MariaDB/MySQL. `/system/...` is an internal-only content namespace. It uses the virtual parent marker `system` instead of requiring a real root entity, so system content can be addressed by resolvers without being delivered directly by the public catch-all route. - **Decision recorded:** Custom error pages should be layered over theme defaults later. Error handlers should first try a matching internal entity such as `/system/error-pages/404`; if it is missing or unavailable, the system theme should render its default error page. - **Decision recorded:** Content hierarchy should start with stable content metadata such as optional parent and order fields on the content entity, not inside variable fieldsets. @@ -218,7 +218,7 @@ The first implementation should keep routing, workflow, and permission fields ex - **Decision recorded:** Treat `title` and `subtitle` as reserved required schema fields in every variable fieldset, not as content metadata or dedicated content columns. - **Implemented baseline:** Added first-pass `ContentItem` and `ContentFieldValue` Doctrine entities plus the consolidated core baseline migration. Entities live under `App\Entity` to keep Symfony's default Doctrine mapping conventions intact, while content-specific enums and routing guards live under `App\Content`. - **Implemented baseline:** Added a public `PublishedContentResolver` read layer that resolves published content by slug, custom URL, or hierarchy path, requires an active revision, applies view ACL rules, loads revision-scoped field values, exposes reserved `title` and `subtitle` helpers, falls back from missing languages and variants to defaults where available, emits warning messages for variant mismatches, and distinguishes missing/unpublished content from private/denied content. -- **Implemented baseline:** Added schema `custom_twig` rendering for the inner public content fieldset. The public content template owns page chrome and package injection slots, while the fieldset renderer uses custom schema Twig when available and falls back to the generic field renderer when custom Twig is empty or invalid. +- **Implemented baseline:** Added schema `custom_twig` rendering for the inner public content fieldset. The public content template owns page chrome and extension injection slots, while the fieldset renderer uses custom schema Twig when available and falls back to the generic field renderer when custom Twig is empty or invalid. - **Implemented baseline:** Added low-priority Symfony routes for `/` and catch-all content paths through `PublicContentController`. The controller maps root URLs to `content.home_path`, validates non-root content paths against reserved prefixes, resolves published content, maps missing, unpublished, deleted, or unavailable content to `404`, maps ACL-denied content to `401`, maps private or reserved-prefix content to `403`, and renders a temporary neutral Twig response until the theme engine owns final public rendering. - **Implemented baseline:** Added unrestricted content path lookup with internal `/system/...` support and a first redirect resolver that resolves internal route targets without changing the browser URL, supports external `http(s)` targets through `302`, follows chains, detects loops, enforces a hop limit, and rejects unsupported URI schemes. - **Decision recorded:** Store variants alongside the canonical content entity. Language variants and generic variants remain separate concepts. @@ -228,10 +228,10 @@ The first implementation should keep routing, workflow, and permission fields ex - **Decision recorded:** Generic variants use reserved marker path URLs such as `/slug/~compact` for public pretty URLs. Query-parameter variants such as `/slug?variant=compact` remain supported as fallback or preview/admin links. - **Decision recorded:** Missing generic variants render the default variant where possible and emit a warning message that later logging can collect. - **Decision recorded:** Missing language variants render the default language when available, with fallback to another available language variant when the default language is not available. -- **Decision recorded:** Dynamic package view injections may attach physical Twig templates to public content render slots such as `before_content` and `after_content`, or to missing variant route suffixes such as `/slug/~comments`, using declarative filters like real-content-only and schema identifiers. Real content variants keep priority over dynamic injected variant routes. +- **Decision recorded:** Dynamic extension view injections may attach physical Twig templates to public content render slots such as `before_content` and `after_content`, or to missing variant route suffixes such as `/slug/~comments`, using declarative filters like real-content-only and schema identifiers. Real content variants keep priority over dynamic injected variant routes. - **Decision recorded:** Content rows should keep current searchable workflow state such as `status`, while reusable `state_marker` rows provide current/last lifecycle metadata such as created, modified, published, unpublished, archived, restored, deleted, locked, and unlocked. Reversals such as unpublishing should update current state (`status` no longer `published`) and replace the relevant marker for fast UI lookup; complete transition history belongs in future audit logs. - **Decision recorded:** Custom Twig remains an intentional product capability for creative schema-driven fieldsets. Schema Twig may be edited only by users with explicit administrative/trusted permissions, must be validated before activation, and may later use sandbox or safety dictionaries. Ordinary content-body fields must not execute unsandboxed Twig. -- **Decision recorded:** Package Twig templates are trusted package code, but packages must not receive arbitrary access to internal users, ACL state, secrets, or protected configuration. Sensitive data access should go through core-provided providers that enforce permissions and redaction. +- **Decision recorded:** Extension Twig templates are trusted extension code, but extensions must not receive arbitrary access to internal users, ACL state, secrets, or protected configuration. Sensitive data access should go through core-provided providers that enforce permissions and redaction. - **Decision recorded:** `ContentItem` should be split before Editor/API growth into smaller route/redirect, ACL, localization/variant, revision activation, and tree/sort boundaries while keeping a coherent content facade where useful. -- **Decision recorded:** Content access control should keep Symfony roles as the primary authorization layer and layer optional ACL groups on top for entity, tree, schema, and package-domain permissions. Role and group logic stay separate and need explicit AND/OR semantics. +- **Decision recorded:** Content access control should keep Symfony roles as the primary authorization layer and layer optional ACL groups on top for entity, tree, schema, and extension-domain permissions. Role and group logic stay separate and need explicit AND/OR semantics. - **Decision: provisional until API:** API-facing content DTO/read-model shape will be finalized in the API feature branch after serializer, permission, fieldset, and public/private metadata decisions are concrete. diff --git a/dev/draft/0.1.x-SystemThemeDesignSystem.md b/dev/draft/0.1.x-SystemThemeDesignSystem.md index 3c71919d..c1ed1126 100644 --- a/dev/draft/0.1.x-SystemThemeDesignSystem.md +++ b/dev/draft/0.1.x-SystemThemeDesignSystem.md @@ -12,7 +12,7 @@ - Keeps the CMS usable as a product instead of only a backend architecture. ## Outline -The CMS needs native system templates that behave like immutable virtual packages with `frontend-theme`, `backend-theme`, and `system-template` scopes. Frontend templates render public project content, user flows, and error pages; backend templates render setup, admin, editor, package management, and operations. Root templates provide shared wrappers and macros. The native package also contains the first public fallback templates, so content remains renderable without an installed package. +The CMS needs native system templates that behave like immutable virtual extensions with `frontend-theme`, `backend-theme`, and `system-template` scopes. Frontend templates render public project content, user flows, and error pages; backend templates render setup, admin, editor, extension management, and operations. Root templates provide shared wrappers and macros. The native extension also contains the first public fallback templates, so content remains renderable without an installed extension. The system UI should be quiet, dense, predictable, and work-focused. It should favor clear navigation, readable forms, stable table layouts, explicit status indicators, and low-friction repeated workflows over decorative marketing surfaces. The goal is to make content and configuration work safe, fast, and understandable. @@ -31,7 +31,7 @@ The first design system should be small but real. It should define enough shared - Place error pages under `templates/frontend/error-pages/**`, including `default.html.twig` and lightweight standalone candidates for `429` and `503`. - Place context-specific partials under `templates/frontend/partials/**`, `templates/backend/partials/**`, or narrower area directories. Do not place generic page partials at template root. - Keep partials intentionally small and override-friendly: layout fragments, brand/navigation fragments, typography headers, feedback states, form labels/help/errors/actions, action buttons/toolbars, and individual field types should be separate templates where practical. -- Place reusable Twig components under the matching Twig namespace component directory: `templates/components/**` for `root:*`, `templates/frontend/components/**` for `frontend:*`, `templates/backend/components/**` for `backend:*`, and provider/package component templates below namespace-aware `components/**` paths so active package template ordering can override or extend them through the existing Twig loader. +- Place reusable Twig components under the matching Twig namespace component directory: `templates/components/**` for `root:*`, `templates/frontend/components/**` for `frontend:*`, `templates/backend/components/**` for `backend:*`, and provider/extension component templates below namespace-aware `components/**` paths so active extension template ordering can override or extend them through the existing Twig loader. - Place reusable Twig macros/functions under namespaced macro files and aggregate provider macro namespaces rather than treating them as replaceable override files. - Provide a base admin layout with sidebar or top navigation, content area, page header, action area, status area, and flash/error rendering. - Provide a setup/login layout for unauthenticated system workflows. @@ -40,12 +40,12 @@ The first design system should be small but real. It should define enough shared - Lock setup after successful installation through a loaded `APP_SETUP_COMPLETED` environment value that setup writes into Composer's dumped `.env.local.php` array. - Protect backend areas with ACL access levels: setup stays public until locked, editor requires level `3+`, and admin requires level `8+`. - Use the shared navigation builder for backend menus only while it remains simple: backend items may target Symfony routes, carry minimum access metadata, and expose active plus active-ancestor state to templates. -- Let packages contribute backend pages through static view injections on the `admin` and `editor` surfaces. Core backend views keep priority over injected package paths, and setup is not a package-facing surface because it locks after installation. +- Let extensions contribute backend pages through static view injections on the `admin` and `editor` surfaces. Core backend views keep priority over injected extension paths, and setup is not an extension-facing surface because it locks after installation. - Define design tokens for spacing, typography, color, borders, focus states, elevation, status colors, and density. - Use existing local font assets and icon assets where appropriate. - Keep unDraw illustration assets compatible with theme colors by preserving `var(--primary-svg-color)` fills and mapping that variable to `var(--color-primary)` in the surrounding system UI where inline rendering is used. - Prefer Symfony Forms and Twig components for repeatable form layouts. -- Provide a small renderer-neutral form-definition and submission layer for generated forms before committing to feature-specific form builders. The same layer should drive Admin Settings and package settings now, and later support public forms such as contact forms. +- Provide a small renderer-neutral form-definition and submission layer for generated forms before committing to feature-specific form builders. The same layer should drive Admin Settings and extension settings now, and later support public forms such as contact forms. - Keep optional provider fields, including captcha, represented as field types that render through stable provider namespaces such as `@provider/captcha/**` instead of hard-coding one provider into native forms. - Provide reusable UI primitives: - Buttons and icon buttons. @@ -62,14 +62,14 @@ The first design system should be small but real. It should define enough shared - Provide responsive behavior for desktop and tablet first, with safe mobile fallback for urgent administration. - Meet baseline accessibility expectations: semantic landmarks, keyboard navigation, visible focus, color contrast, labels, error association, and reduced-motion safety. - Keep JavaScript progressive and feature-specific through Stimulus controllers. Alpine and ApexCharts are not baseline dependencies; prefer Symfony UX, native Stimulus controllers, and the project live-polling helper before adding another frontend runtime. -- Do not let frontend-theme packages override backend templates. -- Do not let packages override `@root` shared templates unless they declare `system-template`. -- Allow active packages to contribute admin navigation entries, dashboard widgets, form sections, or action buttons only through documented extension points. +- Do not let frontend-theme extensions override backend templates. +- Do not let extensions override `@root` shared templates unless they declare `system-template`. +- Allow active extensions to contribute admin navigation entries, dashboard widgets, form sections, or action buttons only through documented extension points. - Keep system UI assets under `assets/styles/**` and `assets/js/**` with context-specific subdirectories such as `frontend`, `backend`, `admin`, `editor`, and `setup`; keep JavaScript Stimulus-first and treat Alpine as an optional later dependency, not an assumed baseline. -- Keep global design tokens under `assets/styles/tokens/**` and shared primitives under `assets/styles/system/**`. Frontend-only styles belong under `assets/styles/frontend/**`; backend/admin/editor/setup styles belong under `assets/styles/backend/**`. This mirrors Twig namespace ownership and keeps future package styles from leaking across shell boundaries. -- Treat `assets/frontend/**` and `assets/backend/**` inside packages as area-specific package asset roots. Package assets outside those folders are shared/global and enter the extension registry only when the package also declares a global runtime scope such as `module`, `captcha-provider`, `editor-provider`, or `system-template`. -- Remember that Tailwind currently aggregates all native and package CSS into one `app.css`. Asset buckets protect rebuild order and ownership, but CSS isolation still depends on area root selectors. Native root selectors should move toward branding-neutral `system-frontend` and `system-backend` names, while package selectors stay under their package slug and surface scope; stricter namespace validation can be added later if package CSS leakage becomes a practical risk. -- Provide temporary shell preview routes under `/demo`, `/demo/backend`, and `/demo/typography` through the optional demo module while the product UI is still forming. These routes are development scaffolding for checking the complete native template set and Markdown typography profiles with placeholder content and can be removed with the package before production readiness. The module declares its routes as a configurable static route set, so the stable parent path defaults to `/demo` but can move through package settings if the site owner wants that path for content. Demo-only translation keys live in the demo module language files and are aggregated only when the package is active. +- Keep global design tokens under `assets/styles/tokens/**` and shared primitives under `assets/styles/system/**`. Frontend-only styles belong under `assets/styles/frontend/**`; backend/admin/editor/setup styles belong under `assets/styles/backend/**`. This mirrors Twig namespace ownership and keeps future extension styles from leaking across shell boundaries. +- Treat `assets/frontend/**` and `assets/backend/**` inside extensions as area-specific extension asset roots. Extension assets outside those folders are shared/global and enter the extension registry only when the extension also declares a global runtime scope such as `module`, `captcha-provider`, `editor-provider`, or `system-template`. +- Remember that Tailwind currently aggregates all native and extension CSS into one `app.css`. Asset buckets protect rebuild order and ownership, but CSS isolation still depends on area root selectors. Native root selectors should move toward branding-neutral `system-frontend` and `system-backend` names, while extension selectors stay under their extension slug and surface scope; stricter namespace validation can be added later if extension CSS leakage becomes a practical risk. +- Provide temporary extension fixture routes under `/demo` and `/demo/typography` through the optional demo module while the product UI is still forming. These routes are development scaffolding for checking configurable extension routes and Markdown typography profiles with placeholder content and can be removed with the extension before production readiness. The module declares its routes as a configurable static route set, so the stable parent path defaults to `/demo` but can move through extension settings if the site owner wants that path for content. Demo-only translation keys live in the demo module language files and are aggregated only when the extension is active. ## Testing & Validation - Render representative system pages with `php bin/console render:route /` once routes exist. @@ -81,38 +81,38 @@ The first design system should be small but real. It should define enough shared - Use browser screenshots for major UI changes once routes exist. ## Implementation Notes -- **Decision recorded:** The CMS native templates behave like immutable virtual `frontend-theme`, `backend-theme`, and `system-template` packages. +- **Decision recorded:** The CMS native templates behave like immutable virtual `frontend-theme`, `backend-theme`, and `system-template` extensions. - **Decision recorded:** The canonical Twig namespaces are `@frontend`, `@backend`, and `@root`. - **Decision recorded:** Root templates are restricted to `base.html.twig` and shared macro/helper directories; area templates live under `frontend/**` or `backend/**`. -- **Decision recorded:** The native package also includes the fallback public theme so the CMS can render without an installed frontend package. +- **Decision recorded:** The native extension also includes the fallback public theme so the CMS can render without an installed frontend extension. - **Decision recorded:** The first system UI should be functional, dense, and work-focused, with reusable primitives but no broad custom UI framework. -- **Decision recorded:** Backend templates are not overridden by frontend-only packages. +- **Decision recorded:** Backend templates are not overridden by frontend-only extensions. - **Decision recorded:** User-route templates such as login, registration, profile, password reset, password change, API keys, and invitation acceptance remain native frontend-scoped templates in early releases and should be structured so they can later move or become capability-controlled without broad controller rewrites. The login route uses the native frontend template and is also reused by anonymous `401` responses. Registration remains config-gated and is linked from the login page only when enabled. - **Decision recorded:** Operation templates, including the action-log overlay, live under `templates/backend/operations/**`. - **Decision recorded:** Error-page templates live under `templates/frontend/error-pages/**`; `429` and `503` may use lightweight standalone templates. -- **Decision recorded:** Packages may contribute system UI elements only through documented extension points. +- **Decision recorded:** Extensions may contribute system UI elements only through documented extension points. - **Decision recorded:** Long-running or high-impact operations should have a reusable action-log panel component in addition to simple progress indicators. - **Decision recorded:** UI alerts use root-scoped Twig components and a stable targeted Mercure contract. Alert producers should publish explicit UI alerts to private user or session topics instead of broadcasting arbitrary structured message-layer entries. - **Decision recorded:** Alerts are managed through a lightweight notification center with badge counts, `auto`/`hidden`/`persistent` modes, quiet text actions, sessionStorage restoration when available, and close semantics that remove active alerts rather than merely hiding the panel. -- **Decision recorded:** Live JSON polling belongs in a reusable Stimulus/helper layer for `/api/live/**` endpoints. Operation forms should default to notification-center runner alerts with optional ActionLog overlay details, and provider packages can reuse the polling helper for later dynamic mechanisms such as captcha seed refreshes. -- **Decision recorded:** Scoped Twig components should mirror the existing Twig namespaces (`root:*`, `frontend:*`, `backend:*`, and provider/package namespaces) so themes and active packages can override or extend reusable UI primitives through the same template path order as partials. -- **Decision recorded:** Global tokens and shared UI primitives are native system assets. Frontend and backend theme packages should use their matching area asset roots for area-specific styles, while root/shared package assets stay in the global extension bucket only for packages that declare a global runtime scope. -- **Decision recorded:** Tailwind builds a single stylesheet for now. Native area CSS and package CSS should therefore use owner/surface root selectors for practical isolation when a rule is not intentionally global. -- **Decision recorded:** Temporary `/demo`, `/demo/backend`, and `/demo/typography` routes may exist before production readiness through the demo module so the native shells, partials, Markdown profiles, status colors, forms, empty states, and operation panels have real render targets without loose Core demo routes. Demo copy belongs to the demo module package language files. -- **Decision recorded:** Packages may declare package-setting-backed configurable static route sets when a feature needs a stable route parent but should not permanently reserve a hard-coded public path. Static content and system views still win over injected package routes during route resolution. +- **Decision recorded:** Live JSON polling belongs in a reusable Stimulus/helper layer for `/api/live/**` endpoints. Operation forms should default to notification-center runner alerts with optional ActionLog overlay details, and provider extensions can reuse the polling helper for later dynamic mechanisms such as captcha seed refreshes. +- **Decision recorded:** Scoped Twig components should mirror the existing Twig namespaces (`root:*`, `frontend:*`, `backend:*`, and provider/extension namespaces) so themes and active extensions can override or extend reusable UI primitives through the same template path order as partials. +- **Decision recorded:** Global tokens and shared UI primitives are native system assets. Frontend and backend theme extensions should use their matching area asset roots for area-specific styles, while root/shared extension assets stay in the global extension bucket only for extensions that declare a global runtime scope. +- **Decision recorded:** Tailwind builds a single stylesheet for now. Native area CSS and extension CSS should therefore use owner/surface root selectors for practical isolation when a rule is not intentionally global. +- **Decision recorded:** Temporary `/demo` and `/demo/typography` routes may exist before production readiness through the demo module so configurable extension routes and Markdown profiles have real render targets without loose Core demo routes. Demo copy belongs to the demo module extension language files. +- **Decision recorded:** Extensions may declare extension-setting-backed configurable static route sets when a feature needs a stable route parent but should not permanently reserve a hard-coded public path. Static content and system views still win over injected extension routes during route resolution. - **Decision recorded:** Native translation sources are split into language-grouped files so UI areas stay small and scoped while Symfony consumes a generated default-domain `messages.*.yaml` runtime catalogue. - **Decision recorded:** External SVG files used through CSS `background-image` cannot reliably inherit CSS custom properties from the host element. Dynamic theme coloring for unDraw assets should therefore use an allowlisted inline SVG renderer or Twig component when real theme-color adaptation is required. Mask-based coloring is acceptable only for single-color icon-like use cases; build-time or cached server-side SVG variants remain fallback options for later public/theme delivery needs. -- **Decision recorded:** Backend routing starts with explicit Symfony routes for setup, admin, and editor. Static package view injections can add admin/editor route targets and menu entries, while core backend views keep priority over matching injected paths. +- **Decision recorded:** Backend routing starts with explicit Symfony routes for setup, admin, and editor. Static extension view injections can add admin/editor route targets and menu entries, while core backend views keep priority over matching injected paths. - **Decision recorded:** Backend navigation reuses the shared navigation builder for parent/child trees, depth slicing, route URL generation, permission filtering, active state, and active-ancestor state as long as it stays modular and understandable. - **Decision recorded:** Setup completion is written to Composer's dumped `.env.local.php` array and checked through the already-loaded environment value, so `/setup` can remain available before database configuration exists and lock itself after successful installation without Doctrine/DBAL. -- **Decision recorded:** Before setup completion, main public requests should redirect to `/setup` through an early DB-free request subscriber so a fresh installation cannot fall through into content, navigation, config, or package queries that require migrated tables. -- **Decision recorded:** Generated forms use a neutral `App\Form` definition/submission layer rather than an admin-only helper, so Admin Settings, package settings, and later public forms can share field definitions, validation attributes, typed value casting, translated validation feedback, and provider-backed captcha fields. +- **Decision recorded:** Before setup completion, main public requests should redirect to `/setup` through an early DB-free request subscriber so a fresh installation cannot fall through into content, navigation, config, or extension queries that require migrated tables. +- **Decision recorded:** Generated forms use a neutral `App\Form` definition/submission layer rather than an admin-only helper, so Admin Settings, extension settings, and later public forms can share field definitions, validation attributes, typed value casting, translated validation feedback, and provider-backed captcha fields. - **Decision recorded:** The shared form builder should remain one central renderer-neutral form layer, but it should align as closely as practical with Symfony Forms and Validator so it does not become a parallel framework. -- **Decision recorded:** Twig helpers should be split by helper family as they stabilize, for example navigation, package, settings form, diagnostics, and markup/Markdown helpers instead of one broad extension. -- **Decision recorded:** Markdown profiles are intentional. The Design profile may allow richer HTML/attributes only for trusted/admin/package content and can later be assigned through ACL or field policy. +- **Decision recorded:** Twig helpers should be split by helper family as they stabilize, for example navigation, extension, settings form, diagnostics, and markup/Markdown helpers instead of one broad extension. +- **Decision recorded:** Markdown profiles are intentional. The Design profile may allow richer HTML/attributes only for trusted/admin/extension content and can later be assigned through ACL or field policy. - **Decision recorded:** Dynamic view injection failures should be visible to administrators or debug contexts while normal production visitors are protected from raw failures. The first implementation keeps rendering graceful and records failed dynamic injection rendering through the message layer with bounded context. - **Decision recorded:** Admin diagnostics may offer detailed exports only in strongly redacted form. Raw secrets, complete environment dumps, cookies, and request payloads are not valid diagnostic output just because the Admin Panel is protected. -- **Decision recorded:** Technical API, Twig helper, form, and CSS naming should be stabilized during refactors where practical. `Studio`/`studio` is branding, not the default selector or technical API namespace. Native system styles should use `system-*`, `system-frontend-*`, `system-backend-*`, or `system-{provider-scope}-*`; package styles should use `{package-slug}-*`, `{package-slug}-frontend-*`, `{package-slug}-backend-*`, or `{package-slug}-{provider-scope}-*`. -- **Decision recorded:** Runtime rebranding should stay concentrated in `.manifest`, branding assets, branding tokens, and later optional branding packages. Hardcoded user-facing or inspectable `studio-*` identifiers should be treated as migration debt unless they are deliberately retained product branding. +- **Decision recorded:** Technical API, Twig helper, form, and CSS naming should be stabilized during refactors where practical. `Studio`/`studio` is branding, not the default selector or technical API namespace. Native system styles should use `system-*`, `system-frontend-*`, `system-backend-*`, or `system-{provider-scope}-*`; extension styles should use `{extension-slug}-*`, `{extension-slug}-frontend-*`, `{extension-slug}-backend-*`, or `{extension-slug}-{provider-scope}-*`. +- **Decision recorded:** Runtime rebranding should stay concentrated in `.manifest`, branding assets, branding tokens, and later optional branding extensions. Hardcoded user-facing or inspectable `studio-*` identifiers should be treated as migration debt unless they are deliberately retained product branding. - **Open:** Decide the first navigation model, dashboard information architecture, and exact component inventory during the first UI implementation slice. - **Open:** Decide whether command palette and contextual help ship in the first admin release or remain second-slice enhancements. diff --git a/dev/draft/0.1.x-ThemeEngine.md b/dev/draft/0.1.x-ThemeEngine.md index a6f59a7a..2977392b 100644 --- a/dev/draft/0.1.x-ThemeEngine.md +++ b/dev/draft/0.1.x-ThemeEngine.md @@ -1,157 +1,157 @@ -# Package-scoped theme engine (Feature Draft) +# Extension-scoped theme engine (Feature Draft) > **Status**: Draft > **Updated**: 2026-05-25 > **Owner**: Core -> **Purpose:** Draft for package-scoped theme resolution, template overrides, asset loading, and local customization behavior. +> **Purpose:** Draft for extension-scoped theme resolution, template overrides, asset loading, and local customization behavior. ## Overview -- Defines how the system chooses template files and assets from core, scoped packages, and local override sources. +- Defines how the system chooses template files and assets from core, scoped extensions, and local override sources. - Covers fallback order, override safety, cache behavior, and rendering integration. - Depends on core architecture and static/dynamic content decisions. - Keeps presentation replaceable without hiding Twig, AssetMapper, or Symfony conventions. ## Outline -The theme engine should resolve presentation files without turning rendering into a separate framework. Twig should remain the template layer, AssetMapper/importmap should remain the default asset layer, and packages with theme scopes should provide a documented fallback and override structure. Templates use logical Twig namespaces instead of relative cross-area imports: `@frontend/...`, `@backend/...`, and `@root/...`. +The theme engine should resolve presentation files without turning rendering into a separate framework. Twig should remain the template layer, AssetMapper/importmap should remain the default asset layer, and extensions with theme scopes should provide a documented fallback and override structure. Templates use logical Twig namespaces instead of relative cross-area imports: `@frontend/...`, `@backend/...`, and `@root/...`. -The core should ship safe default templates for both backend UI and frontend content rendering. This native baseline behaves as immutable virtual `frontend-theme`, `backend-theme`, and `system-template` packages, so the CMS can render content and backend UI even when no installable package is active. A selected package with `frontend-theme` scope may override frontend-facing templates and provide assets. A selected package with `backend-theme` scope may override backend templates. Packages without the matching scope must not override that area. Packages may reference the root namespace for shared fallbacks, but only packages with `system-template` scope may override root-level shared templates. +The core should ship safe default templates for both backend UI and frontend content rendering. This native baseline behaves as immutable virtual `frontend-theme`, `backend-theme`, and `system-template` extensions, so the CMS can render content and backend UI even when no installable extension is active. A selected extension with `frontend-theme` scope may override frontend-facing templates and provide assets. A selected extension with `backend-theme` scope may override backend templates. Extensions without the matching scope must not override that area. Extensions may reference the root namespace for shared fallbacks, but only extensions with `system-template` scope may override root-level shared templates. -Theme-capable packages may also ship PHP classes below a package-owned namespace for Twig functions, Twig extensions, event subscribers, or other documented extension hooks. Package PHP integration must still be explicit, manifest-driven, and validated before activation. `package.php` must never be included during discovery and may only be included after validation and activation. +Theme-capable extensions may also ship PHP classes below an extension-owned namespace for Twig functions, Twig extensions, event subscribers, or other documented extension hooks. Extension PHP integration must still be explicit, manifest-driven, and validated before activation. `extension.php` must never be included during discovery and may only be included after validation and activation. Dynamic schema Twig stored in the database is not part of theme template resolution. The theme provides the outer page layout and generic fieldset fallback renderer; schema rendering may replace or extend only the inner content block for that schema. Because database-backed Twig is not visible to Tailwind's filesystem scan, schema rendering needs a later build input layer that aggregates CSS class usage from active custom schema Twig and exposes it to `tailwind:build`. -Theme-capable packages are intentionally partial. A package may override only the templates and assets allowed by its scopes, while field templates, form fragments, pagination, menus, error pages, and other missing files fall back to the native baseline. Reusable Twig macros should therefore be aggregated and namespaced instead of overwritten globally. Native fallback templates must depend only on native macro namespaces; packages may add their own macro namespaces, but they must not replace the macro files that fallback templates require. +Theme-capable extensions are intentionally partial. An extension may override only the templates and assets allowed by its scopes, while field templates, form fragments, pagination, menus, error pages, and other missing files fall back to the native baseline. Reusable Twig macros should therefore be aggregated and namespaced instead of overwritten globally. Native fallback templates must depend only on native macro namespaces; extensions may add their own macro namespaces, but they must not replace the macro files that fallback templates require. Theme resolution should be deterministic. When several sources provide a template or asset, the resolver should explain which source won and why. This is important for debugging, updates, self-update safety, and LLM-assisted review. -Package activation, deactivation, update, or override changes may change Tailwind class usage and AssetMapper inputs. The production-safe workflow should run through `php bin/console assets:rebuild`: mirror active package assets into the AssetMapper-visible package directory, rewrite package-authored CSS/JS asset references, rewrite the generated CSS/JS registries, install assets, install importmap assets, run Tailwind, compile the asset map only for `APP_ENV=prod`, and clear the cache as the finalizer. A local file watcher may still be useful during development, but administrative package changes should use a deterministic rebuild action with clear logs and failure handling. +Extension activation, deactivation, update, or override changes may change Tailwind class usage and AssetMapper inputs. The production-safe workflow should run through `php bin/console assets:rebuild`: mirror active extension assets into the AssetMapper-visible extension directory, rewrite extension-authored CSS/JS asset references, rewrite the generated CSS/JS registries, install assets, install importmap assets, run Tailwind, compile the asset map only for `APP_ENV=prod`, and clear the cache as the finalizer. A local file watcher may still be useful during development, but administrative extension changes should use a deterministic rebuild action with clear logs and failure handling. -Discovered packages should not become active automatically. Discovery may validate metadata and show the package as available, but activation must be an explicit admin action. This guarantees that manifest validation, compatibility checks, asset rebuilds, cache invalidation, diagnostics, and action logs happen in the right order before the package affects rendering. +Discovered extensions should not become active automatically. Discovery may validate metadata and show the extension as available, but activation must be an explicit admin action. This guarantees that manifest validation, compatibility checks, asset rebuilds, cache invalidation, diagnostics, and action logs happen in the right order before the extension affects rendering. -Package activation should fail safely. If validation, cache invalidation, Tailwind build, or AssetMapper compilation fails, the system should restore the previous active package option where practical and show the failure through the operational action log. This keeps the lifecycle simple without introducing a separate staged asset compiler. +Extension activation should fail safely. If validation, cache invalidation, Tailwind build, or AssetMapper compilation fails, the system should restore the previous active extension option where practical and show the failure through the operational action log. This keeps the lifecycle simple without introducing a separate staged asset compiler. -Package dependency resolution is deferred to the package installer and activation workflow. The shared core package planner should not infer dependencies or compatibility maps. A later resolver should define how package manifests express required core versions, package dependencies, conflicts, optional suggestions, provided capabilities, and update compatibility before an administrator can activate or update a package. +Extension dependency resolution is deferred to the extension installer and activation workflow. The shared core extension planner should not infer dependencies or compatibility maps. A later resolver should define how extension manifests express required core versions, extension dependencies, conflicts, optional suggestions, provided capabilities, and update compatibility before an administrator can activate or update an extension. ## Technical Specifications - Use Twig for templates and keep template names readable. - Use AssetMapper and importmap entrypoints for theme and page assets where possible. - Use `symfonycasts/tailwind-bundle` for Tailwind builds already present in the project. -- Define package manifests using the shared `.manifest` style with `PACKAGE_*` keys. -- Use the package layout `packages//` with `.manifest`, optional `package.php`, `src/`, `templates/`, `assets/`, `config/`, `migrations/`, and optional `languages/`. -- Let packages define their own PHP namespace for classes in `src/`, constrained to a package-owned root namespace. -- Allow package classes to register with documented event hooks or tagged services only after manifest validation and activation. +- Define extension manifests using the shared `.manifest` style with `EXTENSION_*` keys. +- Use the extension layout `extensions//` with `.manifest`, optional `extension.php`, `src/`, `templates/`, `assets/`, `config/`, `migrations/`, and optional `languages/`. +- Let extensions define their own PHP namespace for classes in `src/`, constrained to an extension-owned root namespace. +- Allow extension classes to register with documented event hooks or tagged services only after manifest validation and activation. - Use the current public theme hooks for extension work that belongs outside template fallback: `ViewContextEvent`, `ContentRenderContextEvent`, `ContentRenderedEvent`, `NavigationBuilderEvent`, `ResponseHeadersEvent`, and `OutputGeneratedEvent`. - Use `navigation()` for theme menus. The helper returns normalized parent/child trees, defaults to three levels, and supports explicit depth, absolute start-level, and root-item split-menu slices. - Themes with fully custom menu-building rules may consume the flat `NavigationBuilder::collectItems()` result from PHP and render their own structure instead of using the core `navigation()` tree. -- Do not add template-path or asset-collection hooks while namespace discovery and AssetSync already provide deterministic package contribution paths. +- Do not add template-path or asset-collection hooks while namespace discovery and AssetSync already provide deterministic extension contribution paths. - Template namespace contract: - - `@frontend/...` maps to `templates/frontend/**`; active `frontend-theme` package paths are searched before native templates, while active module/provider package paths are searched after native templates for additive package views. - - `@backend/...` maps to `templates/backend/**`; active `backend-theme` package paths are searched before native templates, while active module/provider package paths are searched after native templates for additive package views. - - `@root/...` maps to shared project templates such as `templates/base.html.twig` and `templates/macros/**`. Active packages may reference this namespace for fallbacks, but only packages with `system-template` scope may override root-level shared templates. - - `@provider/...` maps to active provider package `templates/provider/**` directories before native `templates/provider/**`. A package with `PACKAGE_SCOPE=captcha-provider` contributes `templates/provider/captcha/**`; a package with `PACKAGE_SCOPE=editor-provider` contributes `templates/provider/editor/**`. + - `@frontend/...` maps to `templates/frontend/**`; active `frontend-theme` extension paths are searched before native templates, while active module/provider extension paths are searched after native templates for additive extension views. + - `@backend/...` maps to `templates/backend/**`; active `backend-theme` extension paths are searched before native templates, while active module/provider extension paths are searched after native templates for additive extension views. + - `@root/...` maps to shared project templates such as `templates/base.html.twig` and `templates/macros/**`. Active extensions may reference this namespace for fallbacks, but only extensions with `system-template` scope may override root-level shared templates. + - `@provider/...` maps to active provider extension `templates/provider/**` directories before native `templates/provider/**`. An extension with `EXTENSION_SCOPE=captcha-provider` contributes `templates/provider/captcha/**`; an extension with `EXTENSION_SCOPE=editor-provider` contributes `templates/provider/editor/**`. - The repository template root should contain only `base.html.twig` plus shared directories such as `macros/**`; routable or area-specific templates belong in `frontend/**` or `backend/**`. - Suggested frontend template resolution order: 1. Local project override. - 2. Active package with `frontend-theme` scope. - 3. Active package contribution where an extension point allows it. + 2. Active extension with `frontend-theme` scope. + 3. Active extension contribution where an extension point allows it. 4. Native frontend fallback. - Suggested backend template resolution order: 1. Local project override. - 2. Active package with `backend-theme` scope. - 3. Active package contribution through explicit backend UI extension points. + 2. Active extension with `backend-theme` scope. + 3. Active extension contribution through explicit backend UI extension points. 4. Native backend fallback. - Suggested root/shared template resolution order: 1. Local project override. - 2. Active package with `system-template` scope. + 2. Active extension with `system-template` scope. 3. Native root fallback. - Define whether plugin contributions are allowed per template area, for example public page, editor widget, form field, navigation item, or content block. - Keep admin templates more restrictive than public-facing templates. -- Let package overrides follow the original folder structure. If a file exists in the active scoped package at the corresponding path, it replaces the matching base file for allowed template areas. +- Let extension overrides follow the original folder structure. If a file exists in the active scoped extension at the corresponding path, it replaces the matching base file for allowed template areas. - Use `templates/frontend/**` for frontend content, user, and error-page templates. Use `templates/backend/**` for admin, editor, setup, and operation templates. Keep the root clean: `templates/base.html.twig` is the global wrapper, and shared `templates/macros/**` may be aggregated but must stay namespaced. Do not add generic root-level page templates. -- Mirror the same ownership model in assets: package files below `assets/frontend/**` feed frontend-theme registries only when the package has `frontend-theme`, files below `assets/backend/**` feed backend-theme registries only when the package has `backend-theme`, and package assets outside those area folders are shared/global extension assets only when the package has a global runtime scope. -- Because native and package CSS are currently compiled into one `app.css`, theme styles that are not intentionally global should scope selectors to their rendered area root, for example `.system-frontend` or `.system-backend`. -- CSS classes follow the template namespace they belong to: root/shared templates use `{system|package-slug}-{class}`, provider templates use `{system|package-slug}-{provider-scope}-{class}`, and frontend/backend templates use `{system|package-slug}-{frontend|backend}-{class}`. A template may use root classes plus classes from its own namespace, but not classes from a different rendered area. -- Package translation sources use `languages//*.yaml`; packages that ship translations must include a configured fallback catalogue, and package-owned keys must stay below `pkg..*`. Only active package language files are aggregated into the generated runtime `messages` catalogue during the package rebuild queue. +- Mirror the same ownership model in assets: extension files below `assets/frontend/**` feed frontend-theme registries only when the extension has `frontend-theme`, files below `assets/backend/**` feed backend-theme registries only when the extension has `backend-theme`, and extension assets outside those area folders are shared/global extension assets only when the extension has a global runtime scope. +- Because native and extension CSS are currently compiled into one `app.css`, theme styles that are not intentionally global should scope selectors to their rendered area root, for example `.system-frontend` or `.system-backend`. +- CSS classes follow the template namespace they belong to: root/shared templates use `{system|extension-slug}-{class}`, provider templates use `{system|extension-slug}-{provider-scope}-{class}`, and frontend/backend templates use `{system|extension-slug}-{frontend|backend}-{class}`. A template may use root classes plus classes from its own namespace, but not classes from a different rendered area. +- Extension translation sources use `languages//*.yaml`; extensions that ship translations must include a configured fallback catalogue, and extension-owned keys must stay below `ext..*`. Only active extension language files are aggregated into the generated runtime `messages` catalogue during the extension rebuild queue. - Use `templates/frontend/frontend.html.twig` as the native frontend base layout. Use `templates/backend/admin.html.twig`, `templates/backend/editor.html.twig`, and `templates/backend/setup.html.twig` as backend area layouts. Put optional variants below `templates/frontend/layouts/**` and `templates/backend/layouts/**`. - Use `templates/frontend/content/entity.html.twig` as the generic content-entity fallback renderer. Schema Twig may replace the inner content rendering later. - Use `templates/frontend/error-pages/default.html.twig` as the generic error fallback when no custom system entity and no status-specific error template exists. - Use `templates/backend/operations/**` for the ActionLog overlay and other operation UI fragments. Operation overlays should not require their own full layout. - Use a resolver service for template candidates and asset candidates. -- Use tagged services for additive package contributions such as content block renderers, editor widgets, or navigation renderers. -- Aggregate reusable Twig macro/function files by package namespace rather than letting package files overwrite global helpers. -- Keep native macro namespaces stable for fallback templates, for example `core.form`, `core.ui`, and `core.content`; package macro namespaces should include the package identifier. -- Validate package template paths before activation: any valid package scope may ship additive `templates/frontend/**` and `templates/backend/**` views, `frontend-theme` and `backend-theme` decide whether those views can override native area templates, `system-template` may ship root/shared templates, `templates/macros/core/**` is reserved for system-template, and package-owned macros must live under `templates/macros/{package-slug}/**`. -- Use stable native provider slots where optional providers can contribute markup. Example: native `@frontend/partials/forms/fields/captcha.html.twig` includes `@provider/captcha/field.html.twig`, and native `@backend/editor/fields/richtext.html.twig` includes `@provider/editor/richtext.html.twig`. Missing captcha providers remain a backend no-op/resolved decision; Twig renders the native `templates/provider/captcha/**` fallback when no provider package is active. +- Use tagged services for additive extension contributions such as content block renderers, editor widgets, or navigation renderers. +- Aggregate reusable Twig macro/function files by extension namespace rather than letting extension files overwrite global helpers. +- Keep native macro namespaces stable for fallback templates, for example `core.form`, `core.ui`, and `core.content`; extension macro namespaces should include the extension identifier. +- Validate extension template paths before activation: any valid extension scope may ship additive `templates/frontend/**` and `templates/backend/**` views, `frontend-theme` and `backend-theme` decide whether those views can override native area templates, `system-template` may ship root/shared templates, `templates/macros/core/**` is reserved for system-template, and extension-owned macros must live under `templates/macros/{extension-slug}/**`. +- Use stable native provider slots where optional providers can contribute markup. Example: native `@frontend/partials/forms/fields/captcha.html.twig` includes `@provider/captcha/field.html.twig`, and native `@backend/editor/fields/richtext.html.twig` includes `@provider/editor/richtext.html.twig`. Missing captcha providers remain a backend no-op/resolved decision; Twig renders the native `templates/provider/captcha/**` fallback when no provider extension is active. - Use service decoration or configuration only for explicit replacement points such as a custom template resolver. -- Cache resolved package metadata and invalidate it when package configuration, manifests, or override files change. -- Keep dependency and compatibility checks in the package installer/activation workflow rather than in generic package planning. -- Discover newly extracted packages as inactive/available until an administrator explicitly activates them. -- Require explicit activation before a discovered package contributes templates, assets, PHP classes, Twig extensions, or event subscribers. -- Trigger an explicit asset rebuild after package activation, deactivation, update, or relevant override changes. -- Roll back to the previous active package option when activation fails before the new package can be safely finalized. -- Run package lifecycle rebuilds through `assets:rebuild`. -- Use this command order: package asset mirror and registry rewrite, translation aggregation, `assets:install`, `importmap:install`, `tailwind:build`, production-only `public/assets` cleanup and `asset-map:compile`, then `cache:clear` as the finalizer. -- Treat package templates, CSS sources, JavaScript sources, imported assets, and manifest-declared assets as rebuild inputs. -- Rewrite generated package CSS registries with active package Tailwind `@source` entries before active package CSS `@import` entries so Tailwind can discover classes and compile the final aggregate before AssetMapper serves it. +- Cache resolved extension metadata and invalidate it when extension configuration, manifests, or override files change. +- Keep dependency and compatibility checks in the extension installer/activation workflow rather than in generic extension planning. +- Discover newly extracted extensions as inactive/available until an administrator explicitly activates them. +- Require explicit activation before a discovered extension contributes templates, assets, PHP classes, Twig extensions, or event subscribers. +- Trigger an explicit asset rebuild after extension activation, deactivation, update, or relevant override changes. +- Roll back to the previous active extension option when activation fails before the new extension can be safely finalized. +- Run extension lifecycle rebuilds through `assets:rebuild`. +- Use this command order: extension asset mirror and registry rewrite, translation aggregation, `assets:install`, `importmap:install`, `tailwind:build`, production-only `public/assets` cleanup and `asset-map:compile`, then `cache:clear` as the finalizer. +- Treat extension templates, CSS sources, JavaScript sources, imported assets, and manifest-declared assets as rebuild inputs. +- Rewrite generated extension CSS registries with active extension Tailwind `@source` entries before active extension CSS `@import` entries so Tailwind can discover classes and compile the final aggregate before AssetMapper serves it. - Add a schema-Twig Tailwind aggregation layer before production depends on custom schema classes; the rebuild should generate a deterministic source or safelist artifact from active schema Twig stored in the database. -- Rewrite generated package JavaScript registries with static imports for mirrored active package JavaScript so AssetMapper can map the same deterministic import tree. -- Mirror static package assets such as images, fonts, videos, SVGs, and vendored browser dependencies into the AssetMapper-visible package path. Do not expose source package directories directly. +- Rewrite generated extension JavaScript registries with static imports for mirrored active extension JavaScript so AssetMapper can map the same deterministic import tree. +- Mirror static extension assets such as images, fonts, videos, SVGs, and vendored browser dependencies into the AssetMapper-visible extension path. Do not expose source extension directories directly. - Normalize CSS `url()` references during asset mirroring where needed so referenced fonts and images still resolve after Tailwind writes the built aggregate CSS. -- Normalize package-authored JavaScript static imports during asset mirroring when the mirrored path differs from the source path. Do not rewrite vendored dependency files under `vendor`, `vendors`, or `node_modules`. +- Normalize extension-authored JavaScript static imports during asset mirroring when the mirrored path differs from the source path. Do not rewrite vendored dependency files under `vendor`, `vendors`, or `node_modules`. - Use a development watcher only as a local convenience; do not rely on watchers for production/admin lifecycle changes. - Provide diagnostics for resolved templates and assets in development mode. `SystemDebugCollector` currently records public hook dispatches and Twig namespace path order, then exposes that data through `debug_info()` and an `APP_DEBUG=1` HTML comment. -- Keep package assets namespaced to avoid collisions. +- Keep extension assets namespaced to avoid collisions. - Keep database-backed schema Twig outside the theme file fallback order. -- Package releases are extracted as subdirectories below `packages/`, where their manifest can be discovered before PHP classes, templates, assets, and overrides are loaded. +- Extension releases are extracted as subdirectories below `extensions/`, where their manifest can be discovered before PHP classes, templates, assets, and overrides are loaded. ## Testing & Validation -- Test fallback order for local, scoped package, extension, and core templates. +- Test fallback order for local, scoped extension, extension, and core templates. - Test folder-structure-based overrides replace only allowed base files. - Test partial themes fall back to native field, form, pagination, menu, and error templates when files are missing. -- Test macro aggregation keeps native macro namespaces available when packages add their own macro files. -- Test package PHP classes are loaded only after manifest validation and only through documented hooks. +- Test macro aggregation keeps native macro namespaces available when extensions add their own macro files. +- Test extension PHP classes are loaded only after manifest validation and only through documented hooks. - Test missing templates fall back safely or produce actionable developer errors. -- Test that package contributions are accepted only for allowed template areas. -- Test package template path validation for additive frontend/backend views, root templates, core macros, and package-owned macro paths. -- Test active package manifest validation. -- Test dependency, conflict, and compatibility diagnostics once the package installer defines the manifest keys. -- Test newly discovered packages remain inactive until explicitly activated. -- Test inactive packages do not contribute templates, assets, classes, extensions, or event subscribers. +- Test that extension contributions are accepted only for allowed template areas. +- Test extension template path validation for additive frontend/backend views, root templates, core macros, and extension-owned macro paths. +- Test active extension manifest validation. +- Test dependency, conflict, and compatibility diagnostics once the extension installer defines the manifest keys. +- Test newly discovered extensions remain inactive until explicitly activated. +- Test inactive extensions do not contribute templates, assets, classes, extensions, or event subscribers. - Test that theme resolution does not override database-backed schema Twig behavior. - Test database-backed schema Twig class aggregation before relying on schema-authored Tailwind classes in production builds. -- Test package activation/update triggers the asset rebuild workflow. -- Test failed package activation restores the previous active package option and reports the failure. +- Test extension activation/update triggers the asset rebuild workflow. +- Test failed extension activation restores the previous active extension option and reports the failure. - Test asset rebuild failures leave the previous active assets usable where practical and report actionable errors. - Test `assets:rebuild` reports planned and current steps through the ActionLog payload. -- Test `cache:clear` runs after package asset sync, Tailwind, and production AssetMapper compilation. +- Test `cache:clear` runs after extension asset sync, Tailwind, and production AssetMapper compilation. - Run `php bin/console tailwind:build` after Tailwind-related changes. - Keep `php bin/console asset-map:compile` production-only; do not use it as a local development verification step. - Render representative public routes with `php bin/console render:route /`. ## Implementation Notes - **Decision recorded:** Use Twig and AssetMapper as the first theme engine foundation. -- **Decision recorded:** Use `.manifest` files with `PACKAGE_*` keys for package metadata. -- **Decision recorded:** Packages live under `packages//` and may contain `.manifest`, optional `package.php`, `src/`, `templates/`, `assets/`, `config/`, `migrations/`, and optional `languages/`. -- **Decision recorded:** Packages may ship PHP classes under their own namespace and integrate through documented event hooks, tagged services, or Twig extensions after manifest validation and activation. +- **Decision recorded:** Use `.manifest` files with `EXTENSION_*` keys for extension metadata. +- **Decision recorded:** Extensions live under `extensions//` and may contain `.manifest`, optional `extension.php`, `src/`, `templates/`, `assets/`, `config/`, `migrations/`, and optional `languages/`. +- **Decision recorded:** Extensions may ship PHP classes under their own namespace and integrate through documented event hooks, tagged services, or Twig extensions after manifest validation and activation. - **Decision recorded:** Make local project overrides stronger than active theme files. - **Decision recorded:** Database-backed schema Twig is owned by content schema rendering, not the theme engine. Themes provide outer layout and generic fallback rendering. - **Decision recorded:** Database-backed schema Twig needs a Tailwind build input layer because `tailwind:build` cannot scan custom Twig that only exists in the database. - **Decision recorded:** Theme file overrides follow the original folder structure and replace the corresponding base file only in allowed public template areas. -- **Decision recorded:** The native project templates behave as immutable virtual `frontend-theme`, `backend-theme`, and `system-template` packages, so content, shared fallbacks, and backend UI remain renderable without installed packages. -- **Decision recorded:** Theme-capable packages are allowed to be partial. Missing templates and assets fall back to the native baseline. -- **Decision recorded:** Scope-specific template areas protect backend templates from frontend-only packages and frontend templates from backend-only packages. +- **Decision recorded:** The native project templates behave as immutable virtual `frontend-theme`, `backend-theme`, and `system-template` extensions, so content, shared fallbacks, and backend UI remain renderable without installed extensions. +- **Decision recorded:** Theme-capable extensions are allowed to be partial. Missing templates and assets fall back to the native baseline. +- **Decision recorded:** Scope-specific template areas protect backend templates from frontend-only extensions and frontend templates from backend-only extensions. - **Decision recorded:** Logical Twig names are canonical for area templates: `@frontend/...`, `@backend/...`, and `@root/...`. Relative cross-area imports should not be used in native templates. -- **Decision recorded:** Packages may reference `@root/...`, but they may only override root-level shared templates when their manifest declares `system-template`. -- **Decision recorded:** Package CSS/JS follows the same area boundary as templates: `assets/frontend/**` is frontend-theme-specific, `assets/backend/**` is backend-theme-specific, and root/shared package assets are global extension assets only for packages with a global runtime scope. +- **Decision recorded:** Extensions may reference `@root/...`, but they may only override root-level shared templates when their manifest declares `system-template`. +- **Decision recorded:** Extension CSS/JS follows the same area boundary as templates: `assets/frontend/**` is frontend-theme-specific, `assets/backend/**` is backend-theme-specific, and root/shared extension assets are global extension assets only for extensions with a global runtime scope. - **Decision recorded:** Asset buckets define ownership and rebuild order, not hard CSS sandboxing. CSS rules still need area root selectors until a later validator can enforce stricter selector namespaces. -- **Decision recorded:** Package translations are active-state-gated like assets and templates. Source catalogues live under `packages//languages//*.yaml`, require a configured fallback catalogue when present, must use `pkg..*` keys, and are aggregated into generated Symfony runtime catalogues only for active packages. -- **Implemented baseline:** Translation runtime aggregation is split into source collection, YAML merge/collision handling, and staged runtime-directory writing behind the aggregate action used by package-aware rebuilds. -- **Decision recorded:** Package macro namespaces are directory-based: `templates/macros/core/**` belongs to the system namespace, while packages may add their own macros under `templates/macros/{package-slug}/**`. +- **Decision recorded:** Extension translations are active-state-gated like assets and templates. Source catalogues live under `extensions//languages//*.yaml`, require a configured fallback catalogue when present, must use `ext..*` keys, and are aggregated into generated Symfony runtime catalogues only for active extensions. +- **Implemented baseline:** Translation runtime aggregation is split into source collection, YAML merge/collision handling, and staged runtime-directory writing behind the aggregate action used by extension-aware rebuilds. +- **Decision recorded:** Extension macro namespaces are directory-based: `templates/macros/core/**` belongs to the system namespace, while extensions may add their own macros under `templates/macros/{extension-slug}/**`. - **Decision recorded:** Reusable Twig macros/functions are aggregated by provider namespace instead of overwritten globally. Native fallback templates only depend on native macro namespaces. -- **Decision recorded:** Package release archives are extracted as subdirectories below `packages/`, then discovered through their manifest before classes, templates, assets, or overrides are loaded. -- **Decision recorded:** Discovered packages are inactive by default and require explicit activation before they can affect rendering or register contributions. -- **Decision recorded:** Package dependency maps are intentionally deferred to the package installer and activation workflow; generic Core package planning remains dependency-neutral. -- **Decision recorded:** Failed package activation rolls back to the previous active package option where practical and reports the error through the operational action log. -- **Decision recorded:** Package lifecycle changes run `assets:rebuild`, with AssetMapper compilation only in production and cache clearing as the finalizer. File watchers are only a development convenience. -- **Decision recorded:** `assets:rebuild` is the global rebuild operation for package lifecycle and manual recovery flows. It runs package asset sync before Tailwind and runs `cache:clear` last so streamed or polled ActionLog UIs can recover from cache invalidation using persisted cursors. -- **Decision recorded:** Theme packages should extend rendering through documented context, content-output, navigation, response-header, and final-output hooks where needed; template namespace discovery and package asset sync do not need separate public collection hooks for now. +- **Decision recorded:** Extension release archives are extracted as subdirectories below `extensions/`, then discovered through their manifest before classes, templates, assets, or overrides are loaded. +- **Decision recorded:** Discovered extensions are inactive by default and require explicit activation before they can affect rendering or register contributions. +- **Decision recorded:** Extension dependency maps are intentionally deferred to the extension installer and activation workflow; generic Core extension planning remains dependency-neutral. +- **Decision recorded:** Failed extension activation rolls back to the previous active extension option where practical and reports the error through the operational action log. +- **Decision recorded:** Extension lifecycle changes run `assets:rebuild`, with AssetMapper compilation only in production and cache clearing as the finalizer. File watchers are only a development convenience. +- **Decision recorded:** `assets:rebuild` is the global rebuild operation for extension lifecycle and manual recovery flows. It runs extension asset sync before Tailwind and runs `cache:clear` last so streamed or polled ActionLog UIs can recover from cache invalidation using persisted cursors. +- **Decision recorded:** Theme extensions should extend rendering through documented context, content-output, navigation, response-header, and final-output hooks where needed; template namespace discovery and extension asset sync do not need separate public collection hooks for now. diff --git a/dev/draft/0.2.x-AdminInterfaceSetupUi.md b/dev/draft/0.2.x-AdminInterfaceSetupUi.md index f188a521..b30002b5 100644 --- a/dev/draft/0.2.x-AdminInterfaceSetupUi.md +++ b/dev/draft/0.2.x-AdminInterfaceSetupUi.md @@ -9,18 +9,18 @@ - Defines the first web UI that makes the CMS installable, configurable, and usable by administrators. - Covers setup, login, dashboard, navigation, content management, settings, user/account basics, and system states. - Depends on setup automation, error handling, system theme/design system, content model, and security/ACL baseline. -- Provides the product shell that later schema, editor, package, API, backup, import/export, and update features plug into. +- Provides the product shell that later schema, editor, extension, API, backup, import/export, and update features plug into. ## Outline -A functional CMS release needs a web interface, not just console commands and data models. The first admin interface should provide the minimum complete product shell: setup wizard, login/logout, protected admin layout, dashboard, content overview, package and theme management entry points, settings entry points, account controls, and clear system-state pages. +A functional CMS release needs a web interface, not just console commands and data models. The first admin interface should provide the minimum complete product shell: setup wizard, login/logout, protected admin layout, dashboard, content overview, extension and theme management entry points, settings entry points, account controls, and clear system-state pages. The graphical setup routine should share validation and service behavior with `bin/setup`. It should not be a separate installer logic path. The web setup should guide the user through environment checks, dependency state, database configuration, site metadata, admin user creation, hash-salt generation, and final lock/complete state. The admin shell should be built in vertical slices. Each screen should be useful on its own, use shared system UI components, and respect permissions from the beginning. Empty states and recovery flows are part of the first usable experience, not polish for later. -Admin settings should stay consolidated. The first settings navigation should use focused sections such as General, Dashboard, Users, Mail, Security, Packages, Scheduler, and later Maintenance. Active packages with simple setting definitions should add package sections under Settings > Packages instead of filling the top-level admin navigation. Security may include captcha, logging/security diagnostics, GeoIP settings, and other abuse-control settings. User-owned settings such as scoped API tokens belong in the profile/account area, not global settings. +Admin settings should stay consolidated. The first settings navigation should use focused sections such as General, Dashboard, Users, Mail, Security, Extensions, Scheduler, and later Maintenance. Active extensions with simple setting definitions should add extension sections under Settings > Extensions instead of filling the top-level admin navigation. Security may include captcha, logging/security diagnostics, GeoIP settings, and other abuse-control settings. User-owned settings such as scoped API tokens belong in the profile/account area, not global settings. -The first admin shell should expose administrative placeholders before content-management views are filled in. Dashboard, Package Management, Theme Management, User Management, Scheduler, Backups, Logs, and Settings belong to the Admin area. Content lists, schemas, media editing, import/export, and other authoring workflows belong to the Editor area unless a later draft explicitly promotes a system-level administrative slice. +The first admin shell should expose administrative placeholders before content-management views are filled in. Dashboard, Extension Management, Theme Management, User Management, Scheduler, Backups, Logs, and Settings belong to the Admin area. Content lists, schemas, media editing, import/export, and other authoring workflows belong to the Editor area unless a later draft explicitly promotes a system-level administrative slice. ## Technical Specifications - Provide a graphical setup wizard that uses the same validation and configuration services as CLI setup. @@ -35,15 +35,15 @@ The first admin shell should expose administrative placeholders before content-m - Provide login, logout, password reset/recovery entry points where supported by the security draft. - Provide an admin shell with global navigation, breadcrumbs or page context, user menu, status/flash area, and permission-aware navigation items. - Provide a dashboard with system status, content summary, recent activity, and actionable setup warnings. -- Provide admin settings sections for General, Dashboard, Users, Mail, Security, Packages, and Scheduler, with Maintenance added when operational maintenance settings are implemented. -- Build core settings and package settings through shared typed form definitions and submission handling: field name, label key, default value, value type, input type, option list, validation metadata, optional help metadata, CSRF protection, typed casting, and translated validation feedback. -- Provide package settings sections under Admin Settings for active packages that expose simple typed setting definitions. -- Render simple package settings through the native settings form layer. Packages that need more than typed key/default/option metadata should provide dedicated admin/editor views outside Settings. +- Provide admin settings sections for General, Dashboard, Users, Mail, Security, Extensions, and Scheduler, with Maintenance added when operational maintenance settings are implemented. +- Build core settings and extension settings through shared typed form definitions and submission handling: field name, label key, default value, value type, input type, option list, validation metadata, optional help metadata, CSRF protection, typed casting, and translated validation feedback. +- Provide extension settings sections under Admin Settings for active extensions that expose simple typed setting definitions. +- Render simple extension settings through the native settings form layer. Extensions that need more than typed key/default/option metadata should provide dedicated admin/editor views outside Settings. - Keep user-specific API token management in user profile/account settings rather than global admin settings. - Provide first management screens: - Content list and content detail entry point. - Schema/preset overview entry point once schema features exist. - - Package overview entry point with discovered inactive packages, scopes, active state, explicit activation, and rebuild-assets action. + - Extension overview entry point with discovered inactive extensions, scopes, active state, explicit activation, and rebuild-assets action. - Theme management entry point for theme selection, activation, previews, and variants. - User/account and ACL entry points. - Scheduler overview entry point for scheduled administrative jobs. @@ -52,7 +52,7 @@ The first admin shell should expose administrative placeholders before content-m - Settings entry point. - Provide safe system-state pages for setup required, setup complete, setup locked, maintenance/update, access denied, not found, and server error states. - Use the shared operational action-log shell for setup execution, migration application, dependency checks, asset rebuilds, and other long-running setup/admin actions. -- Provide a manual rebuild-assets admin action that runs `assets:rebuild`, the same ActionLog-backed package asset mirror, registry rewrite, Tailwind, production-only AssetMapper compilation, and final cache-clear sequence used by package lifecycle workflows. +- Provide a manual rebuild-assets admin action that runs `assets:rebuild`, the same ActionLog-backed extension asset mirror, registry rewrite, Tailwind, production-only AssetMapper compilation, and final cache-clear sequence used by extension lifecycle workflows. - Use Symfony Forms, Validator, CSRF protection, voters, translations, Twig, AssetMapper, Tailwind, and Stimulus. - Preserve form input and show actionable validation feedback. - Keep destructive actions behind confirmation and, where needed, dry-run/review screens. @@ -65,9 +65,9 @@ The first admin shell should expose administrative placeholders before content-m - Test permission-aware navigation hides or disables forbidden actions. - Test admin shell renders translated navigation, empty states, flashes, and validation errors. - Test settings navigation exposes only the first consolidated settings sections. -- Test active package settings appear below Admin Settings and are not exposed when the package is inactive. +- Test active extension settings appear below Admin Settings and are not exposed when the extension is inactive. - Test user-specific API token management is not exposed as a global admin setting. -- Test package overview screens show discovered packages as inactive until explicitly activated. +- Test extension overview screens show discovered extensions as inactive until explicitly activated. - Test manual rebuild-assets action is permission-protected and uses the shared action-log shell. - Test setup and admin routes with `php bin/console render:route /` once available. - Run `.codex/compare_translations.php` after UI copy changes. @@ -83,14 +83,14 @@ The first admin shell should expose administrative placeholders before content-m - **Decision recorded:** The first admin release needs a minimal but complete product shell before advanced features can be considered usable. - **Decision recorded:** Admin navigation and dashboard contributions are extension points only when documented. - **Decision recorded:** Graphical setup and admin operations should show structured action logs for larger actions instead of relying only on loading indicators. -- **Decision recorded:** Package discovery screens show new packages as inactive by default and require explicit activation. +- **Decision recorded:** Extension discovery screens show new extensions as inactive by default and require explicit activation. - **Decision recorded:** Admin UI includes a manual rebuild-assets action for recovery and diagnostics. -- **Decision recorded:** First admin settings sections are General, Dashboard, Users, Mail, Security, Packages, and Scheduler. User-specific API tokens belong in profile/account settings. -- **Decision recorded:** Simple package settings live below Admin Settings through a core-rendered settings layer. More complex package workflows should use dedicated package views outside Settings. -- **Decision recorded:** The package settings branch uses `/admin/settings/packages` as an overview and `/admin/settings/packages/{package-slug}` for package-specific pages. Package labels come from package metadata, but route paths use package slugs to avoid collisions with core settings sections. -- **Decision recorded:** Admin Settings forms are generated and submitted through shared form definitions so core settings and package settings use the same renderer-neutral field model, typed casting, validation feedback, and persistence adapters. Captcha fields render through the provider namespace for future captcha-provider packages. -- **Decision recorded:** The Admin area gets placeholders for package management, theme management, user/ACL management, scheduler, backups, logs, and settings; content, schema, media, and import/export workflows remain Editor concerns. +- **Decision recorded:** First admin settings sections are General, Dashboard, Users, Mail, Security, Extensions, and Scheduler. User-specific API tokens belong in profile/account settings. +- **Decision recorded:** Simple extension settings live below Admin Settings through a core-rendered settings layer. More complex extension workflows should use dedicated extension views outside Settings. +- **Decision recorded:** The extension settings branch uses `/admin/settings/extensions` as an overview and `/admin/settings/extensions/{extension-slug}` for extension-specific pages. Extension labels come from extension metadata, but route paths use extension slugs to avoid collisions with core settings sections. +- **Decision recorded:** Admin Settings forms are generated and submitted through shared form definitions so core settings and extension settings use the same renderer-neutral field model, typed casting, validation feedback, and persistence adapters. Captcha fields render through the provider namespace for future captcha-provider extensions. +- **Decision recorded:** The Admin area gets placeholders for extension management, theme management, user/ACL management, scheduler, backups, logs, and settings; content, schema, media, and import/export workflows remain Editor concerns. - **Decision recorded:** Admin controllers should stay thin HTTP adapters. Workflow logic belongs in application services, while read-heavy screens should use separate query/filter/read-model/view-factory layers. -- **Decision recorded:** Package lifecycle and other admin action services should be separated from admin read-model composition so future API endpoints and Admin UI screens do not depend on template-specific array contracts. +- **Decision recorded:** Extension lifecycle and other admin action services should be separated from admin read-model composition so future API endpoints and Admin UI screens do not depend on template-specific array contracts. - **Decision recorded:** Admin diagnostics may include detailed exports for administrators or owners only when strongly redacted. Raw secrets, full environment dumps, cookies, request payloads, and protected configuration values remain excluded. - **Open:** Decide the first dashboard widgets and the minimum account/password recovery flow during the security baseline implementation. diff --git a/dev/draft/0.2.x-EventHooksBuses.md b/dev/draft/0.2.x-EventHooksBuses.md index 6ad7b284..87a8ccfc 100644 --- a/dev/draft/0.2.x-EventHooksBuses.md +++ b/dev/draft/0.2.x-EventHooksBuses.md @@ -14,9 +14,9 @@ ## Outline Events should make lifecycle boundaries visible and extensible without hiding the core control flow. The system should use Symfony EventDispatcher for synchronous lifecycle notification and controlled extension hooks, and Symfony Messenger for asynchronous work or delayed side effects. -Public extension events are part of the package contract only when documented. Internal events may still exist to keep feature code decoupled, but they should remain owned by their feature and may change with it. This distinction prevents accidental public API growth. +Public extension events are part of the extension contract only when documented. Internal events may still exist to keep feature code decoupled, but they should remain owned by their feature and may change with it. This distinction prevents accidental public API growth. -The desired developer experience is similar to classic CMS hook systems: themes and packages can attach to documented lifecycle points, add Twig variables, contribute rendering context, observe workflows, or append small contributions. The implementation should stay Symfony-native instead of introducing a parallel event bus. Studio-owned code only names and documents which Symfony events are public package hooks. +The desired developer experience is similar to classic CMS hook systems: themes and extensions can attach to documented lifecycle points, add Twig variables, contribute rendering context, observe workflows, or append small contributions. The implementation should stay Symfony-native instead of introducing a parallel event bus. Studio-owned code only names and documents which Symfony events are public extension hooks. Hooks should follow the architecture categories: @@ -29,8 +29,8 @@ Hooks should follow the architecture categories: - Use Symfony Messenger for asynchronous messages, longer-running work, and retryable side effects. - Use event class names as the primary public event identifiers. - Avoid a custom generic hook dispatcher. A new hook should usually be a small typed Symfony event class plus one dispatch point. -- Public package hooks must implement `App\Core\Event\PublicEventInterface` and be listed in `App\Core\Event\PublicEventHookRegistry`. -- Public hook descriptors define the hook domain, mode, mutability, stoppability, and summary translation key so admin UI, docs, and package tooling can expose only supported contracts. +- Public extension hooks must implement `App\Core\Event\PublicEventInterface` and be listed in `App\Core\Event\PublicEventHookRegistry`. +- Public hook descriptors define the hook domain, mode, mutability, stoppability, and summary translation key so admin UI, docs, and extension tooling can expose only supported contracts. - Public hook descriptors must be registered by domain-owned `EventHookDescriptorProviderInterface` services near the event owner; the registry aggregates providers and must not become the owning list. - Public hook dispatch points should use `App\Core\Event\PublicEventDispatcher`, not the raw Symfony dispatcher. The helper rejects unregistered public events, converts listener failures into structured operation issues, and is invokable so feature code can use it as a simple callable. - Name event classes by domain and action, for example `ContentPublishedEvent`, `ThemeResolvedEvent`, or `ModuleEnabledEvent`. @@ -51,7 +51,7 @@ Hooks should follow the architecture categories: ## Current Public Hook Surface -The first implementation slice exposes only low-risk theme and package hooks. Packages should subscribe with Symfony's `EventSubscriberInterface` or `#[AsEventListener]` and use the event class name as the event identifier. +The first implementation slice exposes only low-risk theme and extension hooks. Extensions should subscribe with Symfony's `EventSubscriberInterface` or `#[AsEventListener]` and use the event class name as the event identifier. | Event | Domain | Mode | Mutable | Purpose | |-------|--------|------|---------|---------| @@ -61,9 +61,9 @@ The first implementation slice exposes only low-risk theme and package hooks. Pa | `App\Navigation\Event\NavigationBuilderEvent` | `navigation` | `extend` | Yes | Add, remove, or reorder flat navigation items before the builder normalizes tree hierarchy, sort order, and render slices. | | `App\View\Event\ResponseHeadersEvent` | `http` | `extend` | Yes | Adjust HTTP response headers before the response is sent. | | `App\View\Event\OutputGeneratedEvent` | `view` | `extend` | Yes | Adjust generated HTML output after rendering and before the response is sent. | -| `App\Core\Package\Event\PackageAssetSyncStartedEvent` | `package` | `observe` | No | Observe the active package set before package asset synchronization. | -| `App\Core\Package\Event\PackageAssetRegistryBuildEvent` | `package` | `extend` | Yes | Add package asset registry contributions before CSS and JavaScript registries are written. | -| `App\Core\Package\Event\PackageAssetSyncCompletedEvent` | `package` | `observe` | No | Observe package asset synchronization metrics after registry generation. | +| `App\Core\Extension\Event\ExtensionAssetSyncStartedEvent` | `extension` | `observe` | No | Observe the active extension set before extension asset synchronization. | +| `App\Core\Extension\Event\ExtensionAssetRegistryBuildEvent` | `extension` | `extend` | Yes | Add extension asset registry contributions before CSS and JavaScript registries are written. | +| `App\Core\Extension\Event\ExtensionAssetSyncCompletedEvent` | `extension` | `observe` | No | Observe extension asset synchronization metrics after registry generation. | Example subscriber: @@ -71,7 +71,7 @@ Example subscriber: use App\View\ViewContextEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -final class DemoPackageSubscriber implements EventSubscriberInterface +final class DemoExtensionSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { @@ -80,7 +80,7 @@ final class DemoPackageSubscriber implements EventSubscriberInterface public function extendViewContext(ViewContextEvent $event): void { - $event->set('demo_package', ['enabled' => true]); + $event->set('demo_extension', ['enabled' => true]); } } ``` @@ -90,7 +90,7 @@ final class DemoPackageSubscriber implements EventSubscriberInterface Adding a public hook should stay lightweight: 1. Create a small event class in the owning feature namespace. -2. Implement `PublicEventInterface` only when the hook is intended for packages. +2. Implement `PublicEventInterface` only when the hook is intended for extensions. 3. Dispatch the event at the lifecycle boundary with `PublicEventDispatcher`. 4. Add a descriptor through an owning-domain `EventHookDescriptorProviderInterface` service. 5. Add focused tests for dispatch timing and allowed mutation. @@ -102,32 +102,32 @@ Internal events can skip the registry and public documentation. They should rema Reviewers should treat event additions as API design, not just wiring: -- A package-facing hook must implement `PublicEventInterface`, be dispatched through `PublicEventDispatcher`, and appear in the hook registry through a descriptor provider. -- An internal Symfony event must not be documented as a package hook, exposed through Twig/debug listings, or used by packages. +- An extension-facing hook must implement `PublicEventInterface`, be dispatched through `PublicEventDispatcher`, and appear in the hook registry through a descriptor provider. +- An internal Symfony event must not be documented as an extension hook, exposed through Twig/debug listings, or used by extensions. - Public hooks need a clear mode: observe, extend, or replace. If this is unclear, prefer no public hook yet. - Public hook payloads should avoid mutable Doctrine entities unless the hook explicitly owns mutation and persistence behavior. - User-facing hook metadata and failure messages must use `MessageKey` translation keys. -- Listener exceptions should become structured `Message` diagnostics. Package lifecycle code should mark the offending package `faulty` when it can identify the package safely. -- New package hooks need at least one test that proves dispatch timing and one test for allowed mutation or failure handling. +- Listener exceptions should become structured `Message` diagnostics. Extension lifecycle code should mark the offending extension `faulty` when it can identify the extension safely. +- New extension hooks need at least one test that proves dispatch timing and one test for allowed mutation or failure handling. -This keeps the finished product inspectable: administrators and support tools can list public extension points, explain failures, and distinguish stable package API from internal implementation detail. +This keeps the finished product inspectable: administrators and support tools can list public extension points, explain failures, and distinguish stable extension API from internal implementation detail. ## Failure Strategy Public hook dispatch must prefer recoverable failure paths. Listener exceptions should not bubble to users as raw errors when the core can still render, resolve, or report a structured operation failure. - Rendering/context hooks should degrade gracefully, attach hook issues to debug/admin diagnostics, and continue where possible. -- Public content rendering exposes `ContentRenderContextEvent` so themes and packages can add template variables without replacing resolver or controller flow. +- Public content rendering exposes `ContentRenderContextEvent` so themes and extensions can add template variables without replacing resolver or controller flow. - Public content rendering exposes `ContentRenderedEvent` after Twig rendering for content-specific HTML post-processing. Prefer this over global output hooks when the mutation only concerns one content render. - Navigation rendering exposes `NavigationBuilderEvent` so modules can add or adjust flat navigation items with `parentUid` and `sortOrder` without owning the menu storage, tree normalization, or template. - View injection exposes `StaticViewInjectionRegistryEvent` for route/menu contributions and `DynamicViewInjectionRegistryEvent` for content-aware slot or variant-route contributions. These hooks should use physical Twig templates and declarative filters instead of arbitrary render callbacks. -- `ResponseHeadersEvent` is the preferred package hook for headers, meta-adjacent HTTP behavior, and cache hints. +- `ResponseHeadersEvent` is the preferred extension hook for headers, meta-adjacent HTTP behavior, and cache hints. - `OutputGeneratedEvent` is restricted to HTML responses and should be used sparingly for final output adjustments such as debug comments, analytics snippets, or later shortcode-style post-processing. - Operation hooks should return failed `WorkflowResult` values with message-backed issue payloads so ActionLog, CLI JSON, and future UI can expose the failure. - `PublicEventDispatcher` dispatches the internal `App\Core\Event\PublicHookFailedEvent` after converting a listener exception into a structured `Message`. This event is a diagnostic signal, not a lifecycle command. -- Package lifecycle code may subscribe to `PublicHookFailedEvent` later to mark, count, or fault the offending package once listener-to-package ownership can be resolved safely. -- Package lifecycle hooks should mark activation/deactivation as failed and roll back when practical. -- Once package runtime ownership is available, repeated or unrecoverable listener failures should mark the package that registered the listener as `faulty` and expose the structured issue. +- Extension lifecycle code may subscribe to `PublicHookFailedEvent` later to mark, count, or fault the offending extension once listener-to-extension ownership can be resolved safely. +- Extension lifecycle hooks should mark activation/deactivation as failed and roll back when practical. +- Once extension runtime ownership is available, repeated or unrecoverable listener failures should mark the extension that registered the listener as `faulty` and expose the structured issue. - Security-sensitive hooks may block the workflow if continuing would grant access, skip validation, corrupt data, or publish unsafe output. ## Messenger Conventions @@ -137,25 +137,25 @@ Messenger should handle delayed, retryable, or out-of-request work. EventDispatc Recommended product conventions: - Default tests to synchronous transports for deterministic assertions. -- Use asynchronous transports in dev/prod for indexing, mail, notifications, exports, imports, backups, cleanup, package lifecycle finalizers, and scheduled jobs. +- Use asynchronous transports in dev/prod for indexing, mail, notifications, exports, imports, backups, cleanup, extension lifecycle finalizers, and scheduled jobs. - Route only explicit message classes to async. Do not make every message async by default. -- Keep messages small and serializable: UIDs, package names, version numbers, and normalized scalar options instead of entities or services. +- Keep messages small and serializable: UIDs, extension names, version numbers, and normalized scalar options instead of entities or services. - Handlers must be idempotent. A retried handler should be able to detect already-finished work or update the same durable record safely. -- Use retry with backoff for transient infrastructure failures, but disable or require admin review after repeated permanent package-owned failures. -- Package-provided messages and handlers may run only while the package is active. Deactivation should prevent future package jobs from executing and should leave already persisted records inspectable. +- Use retry with backoff for transient infrastructure failures, but disable or require admin review after repeated permanent extension-owned failures. +- Extension-provided messages and handlers may run only while the extension is active. Deactivation should prevent future extension jobs from executing and should leave already persisted records inspectable. - High-impact async work should write ActionLog/audit entries, not only application logs. ## Debug and Admin Listing -`PublicEventHookRegistry` is the aggregation source for debug and admin visibility. Owning domains register descriptors through providers such as content, navigation, package, view, or view-injection hook providers. `event_hooks()` exposes descriptor arrays to Twig so debug templates and future admin diagnostics can render hook metadata without duplicating it. +`PublicEventHookRegistry` is the aggregation source for debug and admin visibility. Owning domains register descriptors through providers such as content, navigation, extension, view, or view-injection hook providers. `event_hooks()` exposes descriptor arrays to Twig so debug templates and future admin diagnostics can render hook metadata without duplicating it. -`App\Debug\SystemDebugCollector` records public hook dispatches and resolved Twig namespace paths when `APP_DEBUG=1`. `ResponseHookSubscriber` appends this data as a compact `system-debug` HTML comment to HTML responses. The collector is intentionally read-only diagnostics; it must not become a control path for package lifecycle or rendering decisions. +`App\Debug\SystemDebugCollector` records public hook dispatches and resolved Twig namespace paths when `APP_DEBUG=1`. `ResponseHookSubscriber` appends this data as a compact `system-debug` HTML comment to HTML responses. The collector is intentionally read-only diagnostics; it must not become a control path for extension lifecycle or rendering decisions. ## Hook Backlog and Non-goals The hook surface should grow from concrete feature boundaries, not from a large speculative hook list. These decisions keep the current product flexible while avoiding public API that has no implementation owner yet: -- `TemplatePathsCollectEvent` and `TemplateNamespaceResolvedEvent` are not needed now. Template namespaces and package template discovery are deterministic and already owned by the theme/package lifecycle. +- `TemplatePathsCollectEvent` and `TemplateNamespaceResolvedEvent` are not needed now. Template namespaces and extension template discovery are deterministic and already owned by the theme/extension lifecycle. - `AssetsCollectEvent` is not needed now. AssetSync and `assets:rebuild` are the authoritative asset contribution path; runtime asset injection should not bypass that pipeline. - `ResponseHeadersEvent` is the current public hook for headers, cache hints, and safe response metadata. - `OutputGeneratedEvent` is the current HTML-only output mutation hook. It can support final debug comments, analytics snippets, or shortcode-style post-processing, but normal rendering work should still prefer Twig context hooks and templates. @@ -163,9 +163,9 @@ The hook surface should grow from concrete feature boundaries, not from a large - `ContentNotFoundEvent` is deferred. The custom error-page resolver should own not-found rendering first; a later hook may be added inside that resolver if modules need error-page-specific context. - `NavigationBuilderEvent` is the public hook for admin or frontend navigation contributions once menu items have been loaded from core storage. Hooks contribute flat `NavigationItem` values; the builder handles hierarchy, ordering, depth limits, and split-menu slices afterward. - `DashboardWidgetCollectEvent` remains a useful future admin hook once dashboard widgets exist. -- `PermissionCollectEvent` is intentionally not part of the current plan. Packages may declare required ACL levels, groups, or manifest capabilities, but package code must not define or weaken the core security model dynamically. -- `ProviderCandidatesCollectEvent` is not preferred. Provider availability should come from package lifecycle state, manifests, tags, and resolver contracts. -- `PackageRuntimeFailureEvent` or a refined successor to `PublicHookFailedEvent` remains useful for lifecycle-owned package faulting once listener-to-package ownership is resolvable. +- `PermissionCollectEvent` is intentionally not part of the current plan. Extensions may declare required ACL levels, groups, or manifest capabilities, but extension code must not define or weaken the core security model dynamically. +- `ProviderCandidatesCollectEvent` is not preferred. Provider availability should come from extension lifecycle state, manifests, tags, and resolver contracts. +- `ExtensionRuntimeFailureEvent` or a refined successor to `PublicHookFailedEvent` remains useful for lifecycle-owned extension faulting once listener-to-extension ownership is resolvable. - `MediaResolveEvent`, form events, `SchedulerTaskCollectEvent`, and `ApiResponseContextEvent` are deferred until the matching media, forms, scheduler, and API response surfaces exist. - A generic `OnOperationsMessage` hook is deferred. The future logger should start with an explicit recorder/service boundary and may emit internal events after a message has been recorded. @@ -179,19 +179,19 @@ The hook surface should grow from concrete feature boundaries, not from a large - Run `lint:container` after adding subscribers, listeners, handlers, or messenger routing. ## Implementation Notes -- **Decision recorded:** Events are public only when documented as package extension points or stable extension contracts. +- **Decision recorded:** Events are public only when documented as extension points or stable extension contracts. - **Decision proposed:** Prefer immutable public event payloads unless mutation is the purpose of the hook. - **Decision recorded:** Use Messenger for side effects that can be delayed, retried, or processed outside the request. - **Decision recorded:** Public event names rely on event class names. Constants may be added later only for compatibility aliases if needed. - **Decision recorded:** Messenger mode is configurable through an environment flag such as `APP_MESSENGER_MODE=sync|async`, with `sync` default for test and `async` default for dev/prod. -- **Decision recorded:** Public package hooks are surfaced through `PublicEventHookRegistry`; unlisted Symfony events remain internal implementation detail even when technically subscribable inside the app. +- **Decision recorded:** Public extension hooks are surfaced through `PublicEventHookRegistry`; unlisted Symfony events remain internal implementation detail even when technically subscribable inside the app. - **Decision recorded:** Public hook descriptors are owned by domain-local provider services and aggregated centrally; adding a public hook should not require editing a monolithic core provider. - **Decision recorded:** Studio will not introduce a custom event bus while Symfony EventDispatcher and Messenger cover the lifecycle and asynchronous cases. -- **Decision recorded:** Public hook listener failures emit an internal `PublicHookFailedEvent`; package deactivation remains package lifecycle logic instead of a dispatcher side effect. -- **Decision recorded:** Public response-header hooks are policy-filtered. Package listeners may add or remove ordinary safe response headers, but they cannot set or remove cookies, authentication headers, hop-by-hop transport headers, content length, or core security headers such as CSP, HSTS, frame, content-type, referrer, and permissions policies. -- **Decision recorded:** Repeated or severe hook and view-injection failures should be attributable to package ownership and may mark the owning package `faulty` once listener/injection ownership can be resolved safely. -- **Decision recorded:** Response header hooks require an allow/deny policy. Packages may add safe headers and signal route-specific errors through explicit codes, but they must not freely define or remove security-sensitive response headers. +- **Decision recorded:** Public hook listener failures emit an internal `PublicHookFailedEvent`; extension deactivation remains extension lifecycle logic instead of a dispatcher side effect. +- **Decision recorded:** Public response-header hooks are policy-filtered. Extension listeners may add or remove ordinary safe response headers, but they cannot set or remove cookies, authentication headers, hop-by-hop transport headers, content length, or core security headers such as CSP, HSTS, frame, content-type, referrer, and permissions policies. +- **Decision recorded:** Repeated or severe hook and view-injection failures should be attributable to extension ownership and may mark the owning extension `faulty` once listener/injection ownership can be resolved safely. +- **Decision recorded:** Response header hooks require an allow/deny policy. Extensions may add safe headers and signal route-specific errors through explicit codes, but they must not freely define or remove security-sensitive response headers. - **Decision recorded:** Rendering and injection hooks should protect normal visitors from raw failures while exposing useful diagnostics to administrators or debug contexts. - **Decision recorded:** Do not add public hooks for template path collection or runtime asset collection while deterministic namespace discovery and AssetSync already cover those extension points. -- **Decision recorded:** Do not let packages dynamically define core permission rules. Package manifests and routes may require existing scopes, ACL levels, or groups, but the security model remains core-owned. +- **Decision recorded:** Do not let extensions dynamically define core permission rules. Extension manifests and routes may require existing scopes, ACL levels, or groups, but the security model remains core-owned. - **Decision recorded:** Defer the operations-message logging hook until the logger exists; prefer an explicit recorder boundary before adding internal events around logging. diff --git a/dev/draft/0.2.x-PluginModules.md b/dev/draft/0.2.x-PluginModules.md index 6c3ef185..7e328d45 100644 --- a/dev/draft/0.2.x-PluginModules.md +++ b/dev/draft/0.2.x-PluginModules.md @@ -1,181 +1,181 @@ -# Package modules and providers (Feature Draft) +# Extension modules and providers (Feature Draft) > **Status**: Draft > **Updated**: 2026-06-05 > **Owner**: Core -> **Purpose:** Draft for installing, discovering, configuring, and safely extending the CMS through packages with module and provider scopes. +> **Purpose:** Draft for installing, discovering, configuring, and safely extending the CMS through extensions with module and provider scopes. ## Overview -- Defines how packages with `module`, `captcha-provider`, or `editor-provider` scopes add or replace functionality. -- Covers package manifests, services, routes, assets, migrations, permissions, and lifecycle hooks. +- Defines how extensions with `module`, `captcha-provider`, or `editor-provider` scopes add or replace functionality. +- Covers extension manifests, services, routes, assets, database contributions, permissions, and lifecycle hooks. - Depends on the core architecture, event hooks, and theme engine drafts. -- Defines the contract between the CMS core and optional first-party or third-party packages. +- Defines the contract between the CMS core and optional first-party or third-party extensions. ## Outline -Plugin modules are no longer a separate package type. A package with `module` scope should extend the CMS without forcing the core to anticipate every future use case. A package may contribute routes, services, templates, assets, field types, editor actions, API resources, permissions, migrations, event subscribers, message handlers, or replaceable providers, but only through documented extension points and only while the whole package is active. +Plugin modules are no longer a separate extension type. An extension with `module` scope should extend the CMS without forcing the core to anticipate every future use case. An extension may contribute routes, services, templates, assets, field types, editor actions, API resources, permissions, database tables, event subscribers, message handlers, or replaceable providers, but only through documented extension points and only while the whole extension is active. -Replaceable provider packages are a central package use case. Captcha providers are the first concrete example: the core exposes a resolver and configuration option, while packages such as IconCaptcha contribute the actual provider service, assets, templates, translations, and validation behavior. If a provider package is disabled, the resolver must fall back according to core configuration rather than leaving workflows broken. +Replaceable provider extensions are a central extension use case. Captcha providers are the first concrete example: the core exposes a resolver and configuration option, while extensions such as IconCaptcha contribute the actual provider service, assets, templates, translations, and validation behavior. If a provider extension is disabled, the resolver must fall back according to core configuration rather than leaving workflows broken. -The first package system should be intentionally conservative. Discovery, metadata validation, compatibility checks, activation state, and dependency ordering should be implemented before packages can make deep runtime changes. Packages should be easy to inspect from their manifest, service definitions, migrations, tests, and class map references. +The first extension system should be intentionally conservative. Discovery, metadata validation, compatibility checks, activation state, and dependency ordering should be implemented before extensions can make deep runtime changes. Extensions should be easy to inspect from their manifest, service definitions, database contribution definitions, tests, and class map references. -The package lifecycle should prefer Symfony-native behavior. A package should integrate through service registration, tagged services, event subscribers, message handlers, route imports, Twig paths, AssetMapper assets, Doctrine migrations, and security voters where applicable. Custom loading logic should stay narrow and focused on discovery, validation, and lifecycle orchestration. +The extension lifecycle should prefer Symfony-native behavior. An extension should integrate through service registration, tagged services, event subscribers, message handlers, route imports, Twig paths, AssetMapper assets, declarative database contributions, and security voters where applicable. Custom loading logic should stay narrow and focused on discovery, validation, and lifecycle orchestration. -Package dependency resolution should be implemented in the package installer and lifecycle workflow, not in the shared package planner. The resolver should later define how manifests express hard requirements, conflicts, optional suggestions, provided capabilities, compatible core versions, compatible package versions, and update constraints. Dependency checks should be able to compare installed packages, disabled packages, available staged packages, and administrator-selected packages before an activation, update, or uninstall action runs. +Extension dependency resolution should be implemented in the extension installer and lifecycle workflow, not in the shared extension planner. The resolver should later define how manifests express hard requirements, conflicts, optional suggestions, provided capabilities, compatible core versions, compatible extension versions, and update constraints. Dependency checks should be able to compare installed extensions, disabled extensions, available staged extensions, and administrator-selected extensions before an activation, update, or uninstall action runs. -Packages must also define how they are removed. Because packages may provide migrations and package-owned tables, uninstall or remove workflows must avoid data leftovers where possible and must ask for explicit confirmation before deleting package-owned data. +Extensions must also define how they are removed. Because extensions may provide extension-owned tables and other owned data, uninstall or remove workflows must avoid data leftovers where possible and must ask for explicit confirmation before deleting extension-owned data. Module lifecycle changes may add or remove Twig templates, Tailwind class usage, JavaScript entrypoints, imported CSS, or static assets. Enabling, disabling, installing, updating, or uninstalling a module with frontend contributions should trigger a controlled asset rebuild instead of relying on a filesystem watcher. The rebuild should run through the same operational action-log pattern as other admin lifecycle operations. -Discovered packages should be registered as available but inactive by default. Discovery may parse manifests, validate compatibility, and show diagnostics, but packages must not contribute services, routes, templates, assets, migrations, permissions, providers, or event subscribers until an administrator explicitly activates them. This keeps activation auditable and guarantees that rebuild and lifecycle workflows are not skipped. +Discovered extensions should be registered as available but inactive by default. Discovery may parse manifests, validate compatibility, and show diagnostics, but extensions must not contribute services, routes, templates, assets, database contributions, permissions, providers, or event subscribers until an administrator explicitly activates them. This keeps activation auditable and guarantees that rebuild and lifecycle workflows are not skipped. -Discovery should not run as request-time background work. An automatic discovery trigger may happen after a fresh container boot or cache rebuild, then later only through explicit lifecycle triggers such as an Admin UI refresh action, an installer completion, or a scheduler-driven update check. Because package validation may run linters and syntax checks, normal triggers should queue discovery through Messenger instead of doing all work in the request or cache-warmer process. A product-level discovery run includes manifest scanning, package validation, and registry reconciliation, even when those responsibilities stay split across focused services such as `PackageDiscovery`, `PackageValidator`, and `PackageRegistryHandler`. +Discovery should not run as request-time background work. An automatic discovery trigger may happen after a fresh container boot or cache rebuild, then later only through explicit lifecycle triggers such as an Admin UI refresh action, an installer completion, or a scheduler-driven update check. Because extension validation may run linters and syntax checks, normal triggers should queue discovery through Messenger instead of doing all work in the request or cache-warmer process. A product-level discovery run includes manifest scanning, extension validation, and registry reconciliation, even when those responsibilities stay split across focused services such as `ExtensionDiscovery`, `ExtensionValidator`, and `ExtensionRegistryHandler`. -Package activation should fail safely. If validation, migration checks, contribution registration, cache invalidation, Tailwind build, or AssetMapper compilation fails, the system should restore the previous active/inactive state where practical and show the failure through the operational action log. This is simpler than a staged compiler model and matches the expectation that lifecycle operations are explicit and recoverable. +Extension activation should fail safely. If validation, database contribution checks, contribution registration, cache invalidation, Tailwind build, or AssetMapper compilation fails, the system should restore the previous active/inactive state where practical and show the failure through the operational action log. This is simpler than a staged compiler model and matches the expectation that lifecycle operations are explicit and recoverable. ## Technical Specifications -- Use `.manifest`-style metadata with `PACKAGE_` keys. -- Use the package layout `packages//` with `.manifest`, optional `package.php`, `src/`, `config/`, `templates/`, `assets/`, `languages/`, and `migrations/` where needed. +- Use `.manifest`-style metadata with `EXTENSION_` keys. +- Use the extension layout `extensions//` with `.manifest`, optional `extension.php`, `src/`, `config/`, `templates/`, `assets/`, `private-assets/`, and `languages/` where needed. - Suggested required keys: - - `PACKAGE_AUTHOR` - - `PACKAGE_SLUG` - - `PACKAGE_NAME` - - `PACKAGE_VERSION` - - `PACKAGE_SCOPE` - - `PACKAGE_DEPENDENCIES` + - `EXTENSION_AUTHOR` + - `EXTENSION_SLUG` + - `EXTENSION_NAME` + - `EXTENSION_VERSION` + - `EXTENSION_SCOPE` + - `EXTENSION_DEPENDENCIES` - Suggested optional keys: - - `PACKAGE_DESCRIPTION` - - `PACKAGE_NAMESPACE` - - `PACKAGE_IMAGE` - - `PACKAGE_LICENSE` - - `PACKAGE_HOMEPAGE` - - `PACKAGE_SOURCE` - - `PACKAGE_CHANNEL` -- `PACKAGE_SCOPE` values are DotEnv-style lists, for example `[module, captcha-provider]`. + - `EXTENSION_DESCRIPTION` + - `EXTENSION_NAMESPACE` + - `EXTENSION_IMAGE` + - `EXTENSION_LICENSE` + - `EXTENSION_HOMEPAGE` + - `EXTENSION_SOURCE` + - `EXTENSION_CHANNEL` +- `EXTENSION_SCOPE` values are DotEnv-style lists, for example `[module, captcha-provider]`. - Current allowed scopes are `module`, `captcha-provider`, and `editor-provider` for this draft; `frontend-theme`, `backend-theme`, and `system-template` are covered by the theme draft. -- A package is activated or deactivated as one unit. If a package combines `module` with a theme or provider scope, switching that single-active scope deactivates the whole package. -- Validate manifests before registering package contributions. +- An extension is activated or deactivated as one unit. If an extension combines `module` with a theme or provider scope, switching that single-active scope deactivates the whole extension. +- Validate manifests before registering extension contributions. - Resolve module dependencies, conflicts, provided capabilities, optional suggestions, and compatibility constraints before enablement, update, or uninstall workflows mutate state. -- `PACKAGE_DEPENDENCIES` lists Studio package dependencies as package slug plus minimum package version pairs, for example `[['hallo-welt', '1.0.1'], ['system', '0.1.0']]`. -- Store active/inactive package state in application configuration or database-backed package state once persistence exists. -- Discover packages from `packages/` or a configured package registry. -- Do not rediscover packages during normal frontend or backend requests. +- `EXTENSION_DEPENDENCIES` lists Studio extension dependencies as extension slug plus minimum extension version pairs, for example `[['hallo-welt', '1.0.1'], ['system', '0.1.0']]`. +- Store active/inactive extension state in application configuration or database-backed extension state once persistence exists. +- Discover extensions from `extensions/` or a configured extension registry. +- Do not rediscover extensions during normal frontend or backend requests. - Run discovery only behind explicit triggers such as Admin UI refresh, installer completion, setup, or scheduler/update workflows. - Defer normal discovery execution through Symfony Messenger because validation can include linter and syntax-check work. -- Treat a product-level discovery run as manifest scan plus package validation plus registry reconciliation. The underlying services may remain separate so scanning, validation, and database synchronization stay independently testable. -- Expose explicit discovery triggers through `App\Core\Package\PackageDiscoveryDispatcher`, which is callable by Admin UI, installer, scheduler, updater, recovery tools, and the `packages:discover` command. -- Keep package discovery out of Symfony cache warmers so cold container builds do not require Messenger or database-backed package state. -- Mark newly discovered packages as inactive/available by default. -- Reconcile discovered filesystem packages into `extension_package`: new packages are registered inactive, missing packages are marked `removed`, valid version/path/manifest mismatches update metadata, and validation failures are marked `faulty`. -- Let the installer stage or extract package files into their dedicated package folders, then trigger discovery. Validation belongs to the discovery/registry workflow, not to archive extraction itself. -- Keep package update execution deferred: a future updater should compare registered versions and manifest source metadata, then use a narrow Git-backed update stub for packages or the system. -- Require explicit admin activation before a discovered package contributes services, routes, templates, assets, migrations, permissions, providers, event subscribers, or message handlers. -- Keep first-party and third-party packages under the same lifecycle rules, but allow stricter trust defaults for third-party packages. +- Treat a product-level discovery run as manifest scan plus extension validation plus registry reconciliation. The underlying services may remain separate so scanning, validation, and database synchronization stay independently testable. +- Expose explicit discovery triggers through `App\Core\Extension\ExtensionDiscoveryDispatcher`, which is callable by Admin UI, installer, scheduler, updater, recovery tools, and the `extensions:discover` command. +- Keep extension discovery out of Symfony cache warmers so cold container builds do not require Messenger or database-backed extension state. +- Mark newly discovered extensions as inactive/available by default. +- Reconcile discovered filesystem extensions into `extension`: new extensions are registered inactive, missing extensions are marked `removed`, valid version/path/manifest mismatches update metadata, and validation failures are marked `faulty`. +- Let the installer stage or extract extension files into their dedicated extension folders, then trigger discovery. Validation belongs to the discovery/registry workflow, not to archive extraction itself. +- Keep extension update execution deferred: a future updater should compare registered versions and manifest source metadata, then use a narrow Git-backed update stub for extensions or the system. +- Require explicit admin activation before a discovered extension contributes services, routes, templates, assets, database contributions, permissions, providers, event subscribers, or message handlers. +- Keep first-party and third-party extensions under the same lifecycle rules, but allow stricter trust defaults for third-party extensions. - Use tagged services for additive contributions. - Use documented contracts/resolvers for replacement contributions. -- Support replaceable provider packages, for example captcha providers, through documented provider contracts, tagged services, and explicit resolver selection. -- Resolve provider availability through package lifecycle state, manifests, tagged services, and resolver contracts instead of a generic provider-candidate collection event. -- Keep provider-package assets, templates, translations, and configuration namespaced to the package. -- Package translation sources must live under `languages//*.yaml`. Packages that ship translations must include a configured fallback catalogue; package-owned keys must stay below `pkg..*`, and only active package language files are aggregated into the generated runtime `messages` catalogue. +- Support replaceable provider extensions, for example captcha providers, through documented provider contracts, tagged services, and explicit resolver selection. +- Resolve provider availability through extension lifecycle state, manifests, tagged services, and resolver contracts instead of a generic provider-candidate collection event. +- Keep provider-extension assets, templates, translations, and configuration namespaced to the extension. +- Extension translation sources must live under `languages//*.yaml`. Extensions that ship translations must include a configured fallback catalogue; extension-owned keys must stay below `ext..*`, and only active extension language files are aggregated into the generated runtime `messages` catalogue. - Provide deterministic fallback behavior when a selected provider is disabled, missing, or uninstalled. -- Use EventDispatcher and Messenger only for documented public package hooks. -- Treat only events listed in `App\Core\Event\PublicEventHookRegistry` as stable package extension contracts. Other Symfony events may exist for internal decoupling but are not package API. -- Let package subscribers target event class names, for example `App\View\ViewContextEvent`, instead of custom string aliases. +- Use EventDispatcher and Messenger only for documented public extension hooks. +- Treat only events listed in `App\Core\Event\PublicEventHookRegistry` as stable extension contracts. Other Symfony events may exist for internal decoupling but are not extension API. +- Let extension subscribers target event class names, for example `App\View\ViewContextEvent`, instead of custom string aliases. - Core-owned public dispatch points use `App\Core\Event\PublicEventDispatcher` so unregistered hooks and listener failures become structured operation issues instead of raw exceptions. -- Core hook failures emit `App\Core\Event\PublicHookFailedEvent` as an internal diagnostic signal. Package lifecycle may subscribe later to mark, count, or fault the offending package when ownership can be identified. -- Mark the offending active package `faulty` when a package-owned listener or handler fails in a way the workflow cannot safely ignore and package ownership can be identified. The fault decision belongs to lifecycle code, not to the dispatcher itself. -- Let packages declare migration paths or namespaces, then execute them through a core-owned migration workflow. -- Package-owned database tables should use collision-resistant names such as `pkg__
`. -- Deactivation must not drop data. Uninstall may offer data removal through a package purge routine. -- Let packages declare route resources only after compatibility and active state are validated. -- Register package routes during cache warmup or an explicit rebuild flow. Avoid magical runtime route changes. -- Keep package assets namespaced. -- Let packages declare frontend contributions that require asset rebuilds, including templates, Tailwind/CSS sources, JavaScript sources, importmap entries, and static assets. -- Package assets must be self-contained; packages should vendor external dependencies instead of relying on global importmap dependency injection. -- Mirror only active package assets into the AssetMapper-visible package directory and aggregate them through generated CSS/JS registries. Inactive package assets under `packages/` must not be exposed by AssetMapper. -- Aggregate only active package language files into generated `translations/runtime/{APP_ENV}/messages.{locale}.yaml` before cache clearing. Inactive package translations under `packages/` must not be loaded by Symfony. Cache warmup also refreshes generated runtime catalogues when the translation source hash or generated catalogue hash drifts. -- Feed active package templates into Tailwind with generated `@source` directives and feed active package CSS into Tailwind with generated `@import` directives so package classes and styles become part of the single built CSS aggregate. -- Mirror static package assets such as images, fonts, videos, SVGs, and vendored browser dependency files without adding them to CSS/JS registries directly. They become public only through references from active templates, CSS, or JavaScript. -- Trigger `assets:rebuild` after package install, activation, deactivation, update, uninstall, or runtime state exits such as active packages becoming `faulty` or `removed`. The rebuild also refreshes generated translation catalogues from active package sources. Run asset-map compilation only in production. +- Core hook failures emit `App\Core\Event\PublicHookFailedEvent` as an internal diagnostic signal. Extension lifecycle may subscribe later to mark, count, or fault the offending extension when ownership can be identified. +- Mark the offending active extension `faulty` when an extension-owned listener or handler fails in a way the workflow cannot safely ignore and extension ownership can be identified. The fault decision belongs to lifecycle code, not to the dispatcher itself. +- Let extensions declare database tables through the core-owned database contribution contract instead of free-form Doctrine migration classes. +- Extension-owned database tables should use collision-resistant names such as `{database-prefix}ext{normalized-slug-length}_{normalized-slug}_{local-table}` so normalized slug prefixes cannot overlap. +- Deactivation must not drop data. Uninstall may offer data removal through an extension purge routine. +- Let extensions declare route resources only after compatibility and active state are validated. +- Register extension routes during cache warmup or an explicit rebuild flow. Avoid magical runtime route changes. +- Keep extension assets namespaced. +- Let extensions declare frontend contributions that require asset rebuilds, including templates, Tailwind/CSS sources, JavaScript sources, importmap entries, and static assets. +- Extension assets must be self-contained; extensions should vendor external dependencies instead of relying on global importmap dependency injection. +- Mirror only active extension assets into the AssetMapper-visible extension directory and aggregate them through generated CSS/JS registries. Inactive extension assets under `extensions/` must not be exposed by AssetMapper. +- Aggregate only active extension language files into generated `translations/runtime/{APP_ENV}/messages.{locale}.yaml` before cache clearing. Inactive extension translations under `extensions/` must not be loaded by Symfony. Cache warmup also refreshes generated runtime catalogues when the translation source hash or generated catalogue hash drifts. +- Feed active extension templates into Tailwind with generated `@source` directives and feed active extension CSS into Tailwind with generated `@import` directives so extension classes and styles become part of the single built CSS aggregate. +- Mirror static extension assets such as images, fonts, videos, SVGs, and vendored browser dependency files without adding them to CSS/JS registries directly. They become public only through references from active templates, CSS, or JavaScript. +- Trigger `assets:rebuild` after extension install, activation, deactivation, update, uninstall, or runtime state exits such as active extensions becoming `faulty` or `removed`. The rebuild also refreshes generated translation catalogues from active extension sources. Run asset-map compilation only in production. - Run asset rebuilds as ActionLog-backed operations with persisted step entries and progress metadata. `cache:clear` should be the finalizer, not the first step, so live log polling can recover from any brief cache invalidation. -- Roll back to the previous package active/inactive state when activation or deactivation fails before it can be safely finalized. -- Activation and deactivation run through `PackageActivator`: faulty or removed packages cannot be activated; activating a real filesystem package with a single-active scope deactivates conflicting active filesystem packages; asset rebuild failures roll affected package statuses back. -- Activation planning runs before mutation. The first pass resolves package dependencies, rejects malformed declarations and circular hard dependencies, verifies installed versions/status, and reports which packages would be activated or deactivated. The second pass executes the validated plan. -- Virtual/native package records such as the `system` package remain active fallback records and are not disabled as single-active conflicts for real packages. -- Route all future service, route, migration, template, and provider loaders through the active-package gate so they only see active real filesystem packages and may optionally filter by scope. -- Runtime failures from public hook listeners may mark an identified active package `faulty`, record failure metadata, and deactivate active reverse dependents with explicit lifecycle messages. If ownership cannot be identified, the failure remains a structured hook issue and no package state is changed. -- Use explicit rebuild actions for admin/package lifecycle changes. File watchers may support local development but must not be required for production correctness. -- Keep package asset rebuilds deferred through Messenger in the normal path; if the rebuild dispatch itself fails after registry sync changed active package state, run one synchronous fallback rebuild and surface the dispatch failure in messages/context. -- Provide a remove/uninstall routine for packages that deletes the package directory and marks the registry row as `removed`. A separate purge cleanup is available only after removal; it deletes package-owned data through confirmed migrations or cleanup steps and then removes the registry row. Until real package-owned migrations exist, purge goes through a cleanup boundary that is intentionally a no-op. -- Load optional package PHP through `package.php` only for active, valid packages. The loader may require package-owned PHP below `src/`, and hard loader or runtime-provider failures should be recorded through lifecycle diagnostics, mark the package `faulty`, deactivate active reverse dependents, and avoid keeping partial runtime contributions from the failed load. -- Keep package permissions declared and mapped through the security draft before routes or admin UI rely on them. -- Provide diagnostics for invalid manifests, missing dependencies, conflicts, disabled packages, and failed contributions. +- Roll back to the previous extension active/inactive state when activation or deactivation fails before it can be safely finalized. +- Activation and deactivation run through `ExtensionActivator`: faulty or removed extensions cannot be activated; activating a real filesystem extension with a single-active scope deactivates conflicting active filesystem extensions; asset rebuild failures roll affected extension statuses back. +- Activation planning runs before mutation. The first pass resolves extension dependencies, rejects malformed declarations and circular hard dependencies, verifies installed versions/status, and reports which extensions would be activated or deactivated. The second pass executes the validated plan. +- Virtual/native extension records such as the `system` extension remain active fallback records and are not disabled as single-active conflicts for real extensions. +- Route all future service, route, database contribution, template, and provider loaders through the active-extension gate so they only see active real filesystem extensions and may optionally filter by scope. +- Runtime failures from public hook listeners may mark an identified active extension `faulty`, record failure metadata, and deactivate active reverse dependents with explicit lifecycle messages. If ownership cannot be identified, the failure remains a structured hook issue and no extension state is changed. +- Use explicit rebuild actions for admin/extension lifecycle changes. File watchers may support local development but must not be required for production correctness. +- Run extension asset rebuilds synchronously inside already-detached lifecycle operations and commands so operation results reflect the actual rebuild outcome. Use queued rebuild dispatch only for explicit out-of-band recovery or non-operation triggers. +- Provide a remove/uninstall routine for extensions that deletes the extension directory and marks the registry row as `removed`. A separate purge cleanup is available only after removal; it deletes extension-owned data through confirmed cleanup steps and then removes the registry row. +- Load optional extension PHP through `extension.php` only for active, valid extensions. The loader may require extension-owned PHP below `src/`, and hard loader or runtime-provider failures should be recorded through lifecycle diagnostics, mark the extension `faulty`, deactivate active reverse dependents, and avoid keeping partial runtime contributions from the failed load. +- Keep extension permissions declared and mapped through the security draft before routes or admin UI rely on them. +- Provide diagnostics for invalid manifests, missing dependencies, conflicts, disabled extensions, and failed contributions. ## Testing & Validation - Test manifest parsing and validation. -- Test package discovery with valid, invalid, disabled, missing-dependency, and conflicting packages. -- Test discovery trigger policy so normal requests do not scan packages, while explicit lifecycle triggers queue scan, validation, and registry reconciliation. -- Test dependency resolution against installed packages, disabled packages, staged packages, conflicts, suggestions, and provided capabilities once the package installer defines the manifest keys. -- Test newly discovered packages remain inactive until explicitly activated. -- Test inactive packages do not contribute services, routes, templates, assets, migrations, permissions, providers, subscribers, or handlers. +- Test extension discovery with valid, invalid, disabled, missing-dependency, and conflicting extensions. +- Test discovery trigger policy so normal requests do not scan extensions, while explicit lifecycle triggers queue scan, validation, and registry reconciliation. +- Test dependency resolution against installed extensions, disabled extensions, staged extensions, conflicts, suggestions, and provided capabilities once the extension installer defines the manifest keys. +- Test newly discovered extensions remain inactive until explicitly activated. +- Test inactive extensions do not contribute services, routes, templates, assets, database contributions, permissions, providers, subscribers, or handlers. - Test additive tagged service contributions. - Test replacement provider selection and fallback behavior. -- Test provider packages can contribute namespaced assets, templates, translations, and services without leaking into core package structure. -- Test selected provider fallback when a provider package is disabled or unavailable. -- Test route, asset, migration, and permission declarations separately. -- Test package lifecycle changes trigger asset rebuilds when frontend contributions changed. -- Test failed package activation or deactivation restores the previous state and reports the failure. -- Test active-package gates so loaders ignore inactive, removed, faulty, virtual, or missing package records. -- Test runtime failures fault only identified active packages. -- Test that disabled packages do not contribute routes, services, templates, or migrations. -- Test package remove/uninstall flows with and without package-owned data deletion. +- Test provider extensions can contribute namespaced assets, templates, translations, and services without leaking into core extension structure. +- Test selected provider fallback when a provider extension is disabled or unavailable. +- Test route, asset, database contribution, and permission declarations separately. +- Test extension lifecycle changes trigger asset rebuilds when frontend contributions changed. +- Test failed extension activation or deactivation restores the previous state and reports the failure. +- Test active-extension gates so loaders ignore inactive, removed, faulty, virtual, or missing extension records. +- Test runtime failures fault only identified active extensions. +- Test that disabled extensions do not contribute routes, services, templates, or database tables. +- Test extension remove/uninstall flows with and without extension-owned data deletion. - Run `lint:container` after service integration changes. -- Add integration fixtures for one minimal first-party package and one intentionally invalid package. +- Add integration fixtures for one minimal first-party extension and one intentionally invalid extension. ## Implementation Notes -- **Decision recorded:** Treat modules and providers as scopes on one Symfony-integrated package model, not as separate package systems. -- **Decision recorded:** Package code is not treated as a full sandbox. Activation validation must classify package capabilities through an adjustable allow/warn/block policy registry covering PHP functions, namespaces/classes, templates, routes, hooks, headers, and similar surfaces. Blocked rules prevent activation; warnings remain visible to administrators. -- **Decision recorded:** Package authors should have one primary `PackageContributions`-style builder/DTO API for common contributions. Provider interfaces and tagged services may remain advanced or internal adapter surfaces. +- **Decision recorded:** Treat modules and providers as scopes on one Symfony-integrated extension model, not as separate extension systems. +- **Decision recorded:** Extension code is not treated as a full sandbox. Activation validation must classify extension capabilities through an adjustable allow/warn/block policy registry covering PHP functions, namespaces/classes, templates, routes, hooks, headers, and similar surfaces. Blocked rules prevent activation; warnings remain visible to administrators. +- **Decision recorded:** Extension authors should have one primary `ExtensionContributions`-style builder/DTO API for common contributions. Provider interfaces and tagged services may remain advanced or internal adapter surfaces. - **Decision recorded:** Require manifest validation before any contribution is registered. -- **Decision recorded:** Keep the `.manifest` `KEY=VALUE` format and strengthen validation instead of turning manifests into broad capability dumps. Complex behavior should be validated through package files and contribution registration, not encoded as opaque manifest capability claims. -- **Decision recorded:** Package lifecycle transitions should use a central transition policy/state machine for activation, deactivation, faulting, removal, purge, and future updates. -- **Decision recorded:** Package dependency maps and compatibility checks belong to the package installer/lifecycle workflow. The shared Core package planner remains dependency-neutral. -- **Decision recorded:** Use one lifecycle for first-party and third-party packages, with stricter trust and review rules for third-party packages. -- **Decision recorded:** Packages live under `packages//` and may contain `.manifest`, optional `package.php`, `src/`, `config/`, `templates/`, `assets/`, `languages/`, and `migrations/`. -- **Decision recorded:** Package translations use the package slug as Symfony domain and are loaded only after activation through the package rebuild mirror. -- **Decision recorded:** Newly discovered packages are inactive by default and require explicit admin activation before contributing anything to the runtime. -- **Decision recorded:** Active/inactive package state should live in database-backed package state once persistence exists, with configuration used only for initial defaults. -- **Decision recorded:** Registry package identifiers use the explicit manifest `PACKAGE_SLUG`, which also defines the target folder for ZIP installs under `packages/{package-slug}`. Human-readable `PACKAGE_NAME` stays manifest metadata and must not replace the stable registry identifier. -- **Decision recorded:** Discovery and registry sync do not activate packages. They only reconcile available, removed, updated, or faulty registry state; activation remains an explicit lifecycle operation. +- **Decision recorded:** Keep the `.manifest` `KEY=VALUE` format and strengthen validation instead of turning manifests into broad capability dumps. Complex behavior should be validated through extension files and contribution registration, not encoded as opaque manifest capability claims. +- **Decision recorded:** Extension lifecycle transitions should use a central transition policy/state machine for activation, deactivation, faulting, removal, purge, and future updates. +- **Decision recorded:** Extension dependency maps and compatibility checks belong to the extension installer/lifecycle workflow. The shared Core extension planner remains dependency-neutral. +- **Decision recorded:** Use one lifecycle for first-party and third-party extensions, with stricter trust and review rules for third-party extensions. +- **Decision recorded:** Extensions live under `extensions//` and may contain `.manifest`, optional `extension.php`, `src/`, `config/`, `templates/`, `assets/`, `private-assets/`, and `languages/`. +- **Decision recorded:** Extension translations use the extension slug as Symfony domain and are loaded only after activation through the extension rebuild mirror. +- **Decision recorded:** Newly discovered extensions are inactive by default and require explicit admin activation before contributing anything to the runtime. +- **Decision recorded:** Active/inactive extension state should live in database-backed extension state once persistence exists, with configuration used only for initial defaults. +- **Decision recorded:** Registry extension identifiers use the explicit manifest `EXTENSION_SLUG`, which also defines the target folder for ZIP installs under `extensions/{extension-slug}`. Human-readable `EXTENSION_NAME` stays manifest metadata and must not replace the stable registry identifier. +- **Decision recorded:** Discovery and registry sync do not activate extensions. They only reconcile available, removed, updated, or faulty registry state; activation remains an explicit lifecycle operation. - **Decision recorded:** Discovery does not run during normal requests. Automatic discovery triggers are limited to fresh container boot or cache rebuild flows; later runs are explicit Admin UI refresh, installer completion, scheduler, or update-check triggers. -- **Decision recorded:** A product-level discovery run includes manifest scanning, `PackageValidator` validation, and registry reconciliation, even if the implementation keeps `PackageDiscovery`, `PackageValidator`, and `PackageRegistryHandler` as separate focused services. -- **Decision recorded:** Normal discovery triggers are deferred through Symfony Messenger because validation can be expensive. `PackageDiscoveryRunner` remains the synchronous executor for the Messenger handler and explicit recovery paths. -- **Decision recorded:** Explicit discovery triggers use the callable `PackageDiscoveryDispatcher`; the manual CLI adapter is `packages:discover`, with `--run-now` reserved for recovery and maintenance. +- **Decision recorded:** A product-level discovery run includes manifest scanning, `ExtensionValidator` validation, and registry reconciliation, even if the implementation keeps `ExtensionDiscovery`, `ExtensionValidator`, and `ExtensionRegistryHandler` as separate focused services. +- **Decision recorded:** Normal discovery triggers are deferred through Symfony Messenger because validation can be expensive. `ExtensionDiscoveryRunner` remains the synchronous executor for the Messenger handler and explicit recovery paths. +- **Decision recorded:** Explicit discovery triggers use the callable `ExtensionDiscoveryDispatcher`; the manual CLI adapter is `extensions:discover`, with `--run-now` reserved for recovery and maintenance. - **Decision recorded:** Cache rebuild discovery uses an optional Symfony cache warmer with `cache_warmup` trigger context and a cache-local lock file. -- **Decision recorded:** The installer stages or extracts packages into their dedicated folders and then triggers discovery. Validation belongs to discovery/registry synchronization. -- **Decision recorded:** Package update execution is deferred. A later updater compares registry versions with manifest source metadata and uses a narrow Git-backed stub for package or system updates. -- **Decision recorded:** Package activation/deactivation owns runtime state changes and asset rebuild coordination. If the rebuild fails, affected package status changes are restored. -- **Implemented:** Package detail rendering, lifecycle review-plan composition, and lifecycle action application are separated behind a small admin facade so future Admin UI and API package surfaces can reuse or replace only the read-model or action layer they need. -- **Decision recorded:** Package dependencies are Studio package registry dependencies, not Composer/vendor dependencies. The `system` package represents the root manifest/runtime package for dependency checks. -- **Decision recorded:** Inactive but valid dependencies are included in the activation plan and activated with the requested package; missing, faulty, removed, or too-old dependencies block activation. -- **Decision recorded:** Single-active scope replacement applies to real filesystem packages under `packages/`; virtual native records such as `system` stay active as fallback providers. -- **Decision recorded:** Failed package lifecycle operations roll back to the previous active/inactive state where practical and report the error through the operational action log. -- **Decision recorded:** Package loaders use `ActivePackageProvider` as their lifecycle gate. Twig namespace registration and package asset sync already share this boundary, and future service, route, migration, provider, and scheduler loaders should do the same so inactive or virtual records cannot accidentally contribute runtime behavior. -- **Decision recorded:** Active package PHP uses an explicit `package.php` runtime loader. Discovery never includes package PHP, `src/` files must stay below the declared `PACKAGE_NAMESPACE` when present, and loader failures mark the package `faulty` instead of surfacing raw request exceptions. -- **Decision recorded:** Package Twig templates are trusted package code, but packages must use core-provided providers for sensitive user, ACL, secret, or protected configuration data. The validator and lifecycle policy should make these boundaries adjustable without building a heavy runtime sandbox. -- **Implemented baseline:** Package templates may override templates only in their declared template scopes. Within a template, `@root` references are always allowed, and scope namespaces are limited to the current template surface: `templates/frontend/**` may reference `@frontend/**`, `templates/backend/**` may reference `@backend/**`, and `templates/provider/{provider}/**` may reference matching `@provider/{provider}/**`. Cross-scope imports, includes, embeds, and extends are rejected during package validation. -- **Decision recorded:** Public hook runtime failures mark an active package `faulty` only when the failing package can be identified. Identified faults also deactivate active reverse dependents with lifecycle messages so logs explain why dependent packages changed state. Unknown listener ownership remains a structured issue for logs/debugging. +- **Decision recorded:** The installer stages or extracts extensions into their dedicated folders and then triggers discovery. Validation belongs to discovery/registry synchronization. +- **Decision recorded:** Extension update execution is deferred. A later updater compares registry versions with manifest source metadata and uses a narrow Git-backed stub for extension or system updates. +- **Decision recorded:** Extension activation/deactivation owns runtime state changes and asset rebuild coordination. If the rebuild fails, affected extension status changes are restored. +- **Implemented:** Extension detail rendering, lifecycle review-plan composition, and lifecycle action application are separated behind a small admin facade so future Admin UI and API extension surfaces can reuse or replace only the read-model or action layer they need. +- **Decision recorded:** Extension dependencies are Studio extension registry dependencies, not Composer/vendor dependencies. The `system` extension represents the root manifest/runtime extension for dependency checks. +- **Decision recorded:** Inactive but valid dependencies are included in the activation plan and activated with the requested extension; missing, faulty, removed, or too-old dependencies block activation. +- **Decision recorded:** Single-active scope replacement applies to real filesystem extensions under `extensions/`; virtual native records such as `system` stay active as fallback providers. +- **Decision recorded:** Failed extension lifecycle operations roll back to the previous active/inactive state where practical and report the error through the operational action log. +- **Decision recorded:** Extension loaders use `ActiveExtensionProvider` as their lifecycle gate. Twig namespace registration and extension asset sync already share this boundary, and future service, route, database contribution, provider, and scheduler loaders should do the same so inactive or virtual records cannot accidentally contribute runtime behavior. +- **Decision recorded:** Active extension PHP uses an explicit `extension.php` runtime loader. Discovery never includes extension PHP, `src/` files must stay below the declared `EXTENSION_NAMESPACE` when present, and loader failures mark the extension `faulty` instead of surfacing raw request exceptions. +- **Decision recorded:** Extension Twig templates are trusted extension code, but extensions must use core-provided providers for sensitive user, ACL, secret, or protected configuration data. The validator and lifecycle policy should make these boundaries adjustable without building a heavy runtime sandbox. +- **Implemented baseline:** Extension templates may override templates only in their declared template scopes. Within a template, `@root` references are always allowed, and scope namespaces are limited to the current template surface: `templates/frontend/**` may reference `@frontend/**`, `templates/backend/**` may reference `@backend/**`, and `templates/provider/{provider}/**` may reference matching `@provider/{provider}/**`. Cross-scope imports, includes, embeds, and extends are rejected during extension validation. +- **Decision recorded:** Public hook runtime failures mark an active extension `faulty` only when the failing extension can be identified. Identified faults also deactivate active reverse dependents with lifecycle messages so logs explain why dependent extensions changed state. Unknown listener ownership remains a structured issue for logs/debugging. - **Decision recorded:** First-party modules use the same validator policy by default. If trusted first-party overrides become necessary, they should be explicit policy entries rather than bypassing validation entirely. -- **Decision recorded:** Package route registration happens during cache warmup or an explicit rebuild flow, not through magical runtime route changes. -- **Decision recorded:** Package removal deletes the package directory and keeps a `removed` registry row. Package purge invokes the cleanup boundary and then deletes the registry row; if the package directory still exists, later discovery can register it again. -- **Decision recorded:** Replaceable providers such as captcha providers are first-class package scopes exposed through core-owned resolver contracts and explicit configuration. -- **Decision recorded:** Provider packages own their assets, templates, translations, and services. Core owns provider selection and fallback behavior. +- **Decision recorded:** Extension route registration happens during cache warmup or an explicit rebuild flow, not through magical runtime route changes. +- **Decision recorded:** Extension removal deletes the extension directory and keeps a `removed` registry row. Extension purge invokes the cleanup boundary and then deletes the registry row; if the extension directory still exists, later discovery can register it again. +- **Decision recorded:** Replaceable providers such as captcha providers are first-class extension scopes exposed through core-owned resolver contracts and explicit configuration. +- **Decision recorded:** Provider extensions own their assets, templates, translations, and services. Core owns provider selection and fallback behavior. - **Decision recorded:** Provider selection should stay resolver- and lifecycle-owned. A generic `ProviderCandidatesCollectEvent` is not planned unless a concrete provider surface proves that resolver contracts are insufficient. -- **Decision recorded:** Provider rendering uses the deterministic `@provider/{provider-type}/**` namespace. Active provider packages contribute `templates/provider/{provider-type}/**`, and native fallbacks live in the same structure under the project template root. +- **Decision recorded:** Provider rendering uses the deterministic `@provider/{provider-type}/**` namespace. Active provider extensions contribute `templates/provider/{provider-type}/**`, and native fallbacks live in the same structure under the project template root. - **Decision recorded:** CodeMirror is the native base editor provider exposed through `@provider/editor/**`; the native rich-text fallback is Markdown editing, and future WYSIWYG providers may replace only `@provider/editor/richtext.html.twig` while CodeMirror remains active for code-oriented editor aliases. -- **Decision recorded:** Package lifecycle changes with frontend contributions trigger `assets:rebuild` once after the operation's package state changes are complete, including package asset sync before Tailwind and final cache clear after production-only AssetMapper compilation. Runtime exits from the active package set, including `faulty` or `removed` states discovered outside an explicit admin operation, queue the rebuild through Messenger instead of running it inline. Watchers are development-only convenience tooling. -- **Decision recorded:** An unchanged `faulty` package is not revalidated by automatic discovery. It stays `faulty` until discovery sees a meaningful package change such as a new manifest version/path, or until an administrator explicitly resets or repairs the lifecycle state. A successful revalidation returns the package to `inactive`, not `active`; reactivation remains an explicit admin lifecycle operation. -- **Decision recorded:** Fault recovery is separate from purge. `PackageFaultResetter` provides the soft admin repair path by validating the current package folder and moving `faulty` to `inactive` without deleting package data, package files, or the registry row. `PackageRemover::purge()` remains the explicit destructive cleanup path for removed packages and future package-owned data cleanup. -- **Decision recorded:** Simple package settings use one core-owned settings layer, not package-owned settings views. Packages register typed setting definitions with defaults and optional metadata; Studio renders the generic package settings UI under Admin Settings. Packages that need workflows beyond simple settings should provide their own Admin/Editor views outside the settings tree. -- **Decision recorded:** Package settings live under `/admin/settings/packages/{package-slug}`. The package slug owns the route path, while `PACKAGE_NAME` is used for labels and titles. `/admin/settings/packages` is an overview page so empty states and all recognized package setting definitions remain debuggable. -- **Implemented baseline:** Package-owned CSS uses stable owner/surface/scope naming. System-owned selectors use `system-*`, `system-frontend-*`, `system-backend-*`, or `system-{provider-scope}-*`; package selectors use `{package-slug}-*`, `{package-slug}-frontend-*`, `{package-slug}-backend-*`, or `{package-slug}-{provider-scope}-*`. `Studio`/`studio` is product branding, not the default technical selector namespace. Package validation allows selectors to use classes from other scopes as context, but each CSS rule must target a package-owned class in the asset file's own scope so packages cannot silently redefine `system-*` or another package namespace. -- **Decision recorded:** Runtime branding should come from `.manifest`, branding assets, and future narrowly scoped branding packages instead of hardcoded technical identifiers. Branding packages remain deferred and should be documented as an optional future extension rather than implied by the first package lifecycle implementation. +- **Decision recorded:** Extension lifecycle changes with frontend contributions trigger `assets:rebuild` once after the operation's extension state changes are complete, including extension asset sync before Tailwind and final cache clear after production-only AssetMapper compilation. Runtime exits from the active extension set, including `faulty` or `removed` states, run the extension-aware rebuild synchronously when they occur inside an operation/command boundary; explicit out-of-band recovery can still queue a rebuild. Watchers are development-only convenience tooling. +- **Decision recorded:** An unchanged `faulty` extension is not revalidated by automatic discovery. It stays `faulty` until discovery sees a meaningful extension change such as a new manifest version/path, or until an administrator explicitly resets or repairs the lifecycle state. A successful revalidation returns the extension to `inactive`, not `active`; reactivation remains an explicit admin lifecycle operation. +- **Decision recorded:** Fault recovery is separate from purge. `ExtensionFaultResetter` provides the soft admin repair path by validating the current extension folder and moving `faulty` to `inactive` without deleting extension data, extension files, or the registry row. `ExtensionRemover::purge()` remains the explicit destructive cleanup path for removed extensions and future extension-owned data cleanup. +- **Decision recorded:** Simple extension settings use one core-owned settings layer, not extension-owned settings views. Extensions register typed setting definitions with defaults and optional metadata; Studio renders the generic extension settings UI under Admin Settings. Extensions that need workflows beyond simple settings should provide their own Admin/Editor views outside the settings tree. +- **Decision recorded:** Extension settings live under `/admin/settings/extensions/{extension-slug}`. The extension slug owns the route path, while `EXTENSION_NAME` is used for labels and titles. `/admin/settings/extensions` is an overview page so empty states and all recognized extension setting definitions remain debuggable. +- **Implemented baseline:** Extension-owned CSS uses stable owner/surface/scope naming. System-owned selectors use `system-*`, `system-frontend-*`, `system-backend-*`, or `system-{provider-scope}-*`; extension selectors use `{extension-slug}-*`, `{extension-slug}-frontend-*`, `{extension-slug}-backend-*`, or `{extension-slug}-{provider-scope}-*`. `Studio`/`studio` is product branding, not the default technical selector namespace. Extension validation allows selectors to use classes from other scopes as context, but each CSS rule must target an extension-owned class in the asset file's own scope so extensions cannot silently redefine `system-*` or another extension namespace. +- **Decision recorded:** Runtime branding should come from `.manifest`, branding assets, and future narrowly scoped branding extensions instead of hardcoded technical identifiers. Branding extensions remain deferred and should be documented as an optional future extension rather than implied by the first extension lifecycle implementation. diff --git a/dev/draft/0.2.x-SecurityAccessControl.md b/dev/draft/0.2.x-SecurityAccessControl.md index 3dcdb5d0..3a371909 100644 --- a/dev/draft/0.2.x-SecurityAccessControl.md +++ b/dev/draft/0.2.x-SecurityAccessControl.md @@ -1,7 +1,7 @@ # Security and access control (Feature Draft) > **Status**: Draft -> **Updated**: 2026-06-05 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for authentication, authorization, ACLs, rate limiting, captchas, and secure administrative flows. @@ -10,6 +10,8 @@ - Covers roles, ACLs, rate limiting, captcha integration, secrets, and module-provided security extensions. - Depends on core architecture, plugin modules, and editor workflows. - Keeps security Symfony-native while leaving explicit replacement points for captcha and future authentication extensions. +- Splits the next hardening work into focused branches through the [security hardening implementation plan](0.2.x-SecurityHardeningPlan.md). +- Uses [security policy defaults](security-hardening/policy-defaults.md) as the first source for Security hardening TTLs, threshold defaults, privacy ceilings, and review requirements. ## Outline Security should use Symfony Security as the primary model. Authentication, authorization, CSRF protection, voters, rate limiting, secrets, and route access rules should remain recognizable Symfony concepts. @@ -49,9 +51,20 @@ Captcha should use a global form field integration. When a workflow includes the - Define exactly one global user role per account, with Public, User, Moderator, Author, Publisher, Curator, Manager, Director, Admin, and Owner mapped to numeric access levels `0` through `9` and inherited Symfony roles. - Use CSRF protection for state-changing browser workflows. - Use Symfony Rate Limiter for application-level throttling such as login attempts, contact forms, API usage, import attempts, and captcha failures. -- Use separate Rate Limiter buckets for different intents, such as login attempts, contact form posts, registration, guest comments, captcha refreshes, captcha failures, API usage, import attempts, and suspicious probes. +- Use separate Rate Limiter buckets for different intents, such as login attempts, contact form posts, registration, guest comments, captcha failures, API usage, import attempts, and suspicious probes. +- Use the Security policy defaults for first limiter thresholds, auto-ban TTLs, captcha TTLs, and IP-retention ceilings. Implementation branches may tune those defaults only with tests, draft updates, and worklog notes. - Avoid using a single coarse per-IP limiter as the only abuse-control decision. +- Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints should stay cheap, tokenized where necessary, no-store, and safe for live polling, captcha refreshes, or lightweight UI refreshes; clear abuse may still feed passive suspicious-behavior signals. +- Classify Turbo/browser prefetch requests separately from deliberate navigations and submissions. Prefetch should not spend the same global abuse budget as a user-initiated request, and expensive or side-effect-adjacent links may disable Turbo prefetching. +- Route rate-limit decisions through a Studio-owned facade so workflows can assign action costs, reset scoped buckets after clear success, and combine Symfony RateLimiter buckets with cross-action abuse signals without binding controllers to Symfony limiter internals. +- Prefer scoped bucket resets for successful human outcomes before designing partial refunds. For example, a successful login may reset the login-attempt bucket for that subject, and a verified provider-backed captcha challenge may reset the relevant challenge or form bucket when the workflow explicitly allows it. +- Track global cross-action abuse signals across website and API activity so several separately limited actions can still trigger progressive handling when they occur in suspicious sequence. - Support progressive abuse handling such as allow, throttle, require captcha, temporary block, and hard block. +- Support active punishment such as draining relevant buckets or applying temporary TTL bans for clear suspicious behavior. Auto-bans may be keyed by IP, visitor ID, API key, or safe combined subjects, must be auditable, and must preserve Owner recovery paths. +- Prefer visitor-ID-backed temporary bans for continuity and use shorter IP-bucket bans only as a secondary layer against cookie-reset bypasses. IP-derived records must remain queryable for less than 30 days. +- Registered users should receive higher ordinary navigation/API limits than anonymous visitors where a workflow does not define an explicit stricter bucket. Active Owner sessions and Owner-owned API keys are exempt from ordinary application rate-limit rejection. +- Suspicious probe paths should be configurable with broad defaults. High-signal probes such as `.env`, VCS metadata, backup/database dumps, common foreign admin panels, and shell probes should return a generic `400` and feed suspicious-signal handling instead of being treated like normal 404 traffic. +- Auto-ban is enabled by default once implemented, but active Admin/Owner session Visitor IDs and IP buckets must not be banned. A recovery login path such as `/user/login?bypass=1` must remain reachable through active Visitor/IP bans, guarded by a dedicated recovery bucket, so a successful login can re-evaluate the subject under authenticated/Admin/Owner policy. - Document that edge/server-level rate limiting is still needed for DoS protection. - Keep secrets out of manifests, docs, logs, fixtures, and committed config. - Generate `APP_SECRET` during setup and store it in `.env.local.php`. @@ -64,6 +77,7 @@ Captcha should use a global form field integration. When a workflow includes the - Make captcha usage configurable per workflow, with contact forms, registration forms, and guest comments as the first expected protected workflows. - Allow provider selection through configuration. With only the first-party module installed, the default choices should be `icon_captcha` and `none`. - Treat provider value `none` and missing provider modules as successful captcha validation so configured forms do not break. +- Treat provider value `none` and missing/disabled provider success as graceful workflow success only. It must not reset rate limits, refill budgets, clear bans, or satisfy captcha-based `429` recovery. - Ensure captcha never replaces CSRF, ACL checks, authentication, or business validation. - Use audit logging for high-impact actions such as publish, delete, import apply, module enable, migration run, backup restore, and update. - Audit schema Twig changes, content-query/list field changes, variant availability changes, and localization routing changes. @@ -75,6 +89,11 @@ Captcha should use a global form field integration. When a workflow includes the - Allow users to belong to multiple ACL groups independently of their exact global role; anonymous visitors use Public access level `0`. - Require every active or inactive user account to keep at least the User role, and ensure at least one active Owner account always remains available. - Owners may manage all roles and groups. Admins may enter Admin but cannot manage peer Admin users, Owner users, or groups at or above their own role level. +- Treat Admin as a delegated operations role and Owner as the site-control role. Admin route access does not automatically grant mutation rights for every Admin feature. +- Require an Admin/Owner action authority matrix for non-user-management Admin features. Admins may view normal dashboards, redacted diagnostics, extension/theme overviews, scheduler status, and non-secret settings; Owners are required for protected secrets, security policy bounds, public API/CORS expansion, scheduler web-trigger/GET-token enablement, extension install/activate/purge/update, backup restore or full-data export/download, self-update/release actions, destructive data/extension purge, peer Admin changes, Owner changes, and emergency operational controls. +- Treat this matrix as a code-owned registry plus seeded bounded Owner overrides for descriptor-approved rows. The Owner-gated `Settings/ACL` view may delegate selected Admin denied, visible, or mutable permissions and may add optional ACL-group states after the Admin surface gate is satisfied. Matching group states override the role/default state and may grant or restrict access; multiple matching groups resolve to the highest explicit group state. The matrix must not allow Admins to grant themselves Owner-equivalent authority, reveal protected secrets, weaken privacy ceilings, bypass the Admin/Editor surface gates, or remove Owner recovery protections. +- Assign ACL matrix rows by the responsibility of the action being performed, not only by the view or menu area where the control is rendered. Editing ACL group definitions uses the ACL-group administration permission, assigning a group to a user account uses the user-management permission, pending account-token review actions use the review permission even from the user-management view, trusted registered Scheduler task run-now uses the Scheduler permission, and continuing a reviewed live operation re-checks the target domain permission. +- Enforce that matrix in services, API handlers, live-operation starters, and scheduler/admin workflow entry points. Permission-aware navigation may hide actions, but it must not be the only guard. - Filter Admin User invitation/edit group choices to groups the acting administrator may assign and the target role is allowed to hold. - Require explicit impact review before ACL group updates and deletes. Confirmed changes run through the shared LiveLog operation overlay and remove deleted group identifiers through domain-owned ACL reference providers for known ACL-bearing records, including user memberships, pending account tokens, content items, schema versions, and site menu items. - Allow entities and schema versions to override inherited ACL behavior with separate capability fields: min-level plus optional group identifiers. Content uses `view`, `edit`, and `manage`; schemas use `use`, `edit`, and `manage`. @@ -86,6 +105,7 @@ Captcha should use a global form field integration. When a workflow includes the - Require modules to declare permissions and route sensitivity before enabling admin routes or state-changing actions. - Treat protected database-backed configuration values, such as the MaxMind API key, as administrator-only settings and never expose them through logs, diagnostics, public exports, or unprivileged UI. - Ensure missing optional security configuration, such as a GeoIP provider key, degrades gracefully instead of producing hard runtime failures. +- Use GeoIP only as optional operational metadata for logs, statistics, diagnostics, and later security review unless a dedicated geo-blocking policy is explicitly designed. - Treat API authentication separately from browser authentication once the API draft is implemented. ## Testing & Validation @@ -94,6 +114,10 @@ Captcha should use a global form field integration. When a workflow includes the - Test CSRF protection on state-changing forms. - Test rate-limited workflows with configured thresholds. - Test separate rate-limit buckets do not block unrelated workflows. +- Test `/api/live/**` stays outside ordinary rate-limit responses while remaining protected by route-specific tokens or access checks where applicable. +- Test Turbo/browser prefetch classification so speculative requests do not spend the same budget as deliberate requests. +- Test successful outcomes reset only the intended limiter buckets. +- Test cross-action abuse signals and temporary bans with IP, visitor ID, API key, authenticated user, and Owner recovery cases. - Test progressive abuse handling transitions where implemented. - Test captcha provider selection and fallback behavior. - Test the global captcha form field passes validation when provider is `none` or no captcha provider is available. @@ -110,6 +134,7 @@ Captcha should use a global form field integration. When a workflow includes the - Test schema Twig editing and activation permissions. - Test the shared ACL resolver for anonymous, level-based, group-based, inherited, and denied decisions. - Test administrative hierarchy guardrails for editing users, assigning groups, creating groups, updating group minimum roles, deleting groups, self-lockout, last-owner protection, and role/group assignment boundaries. +- Test Admin/Owner action authority for settings, extension lifecycle, scheduler controls, backup/restore, imports/exports, diagnostics, security policy, and emergency operational controls as those surfaces become implemented. - Test ACL group impact review and cleanup of deleted identifiers across all known ACL-bearing entities. - Test content-query/list field permissions. - Test ACL-restricted content visibility in public rendering, API output, content-query/list fields, and search results. @@ -124,18 +149,26 @@ Captcha should use a global form field integration. When a workflow includes the - **Decision recorded:** `APP_SECRET` is generated during setup and stored in `.env.local.php`. - **Decision recorded:** Captcha extensibility belongs in core through `CaptchaProvider` and resolver contracts, while IconCaptcha behavior is tracked in its own feature draft before implementation. - **Decision recorded:** Captcha integrates through a global form field that uses the configured captcha provider resolver. -- **Decision recorded:** Captcha provider selection is configurable. `none` is a valid provider value and missing providers must validate successfully to avoid breaking workflows. +- **Decision recorded:** Captcha provider selection is configurable. `none` is a valid provider value and missing providers must validate successfully to avoid breaking workflows, but this graceful success is not verified human success and must not reset rate limits or satisfy captcha-based `429` recovery. - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected workflows to protect with captcha. - **Decision recorded:** Abuse handling should be progressive and use workflow-specific limiters instead of relying on one coarse IP bucket. +- **Decision recorded:** The next security work is split through `feat-security-*` branches described in the security hardening implementation plan. Each branch should be feature-complete for its focused concern rather than accumulating one large security review branch. +- **Decision recorded:** First Security hardening thresholds, TTLs, privacy ceilings, and configuration posture live in the Security policy defaults document and must be updated when an implementation branch changes those policies. +- **Decision recorded:** `/api/live/**` should not receive ordinary rate-limit enforcement. Live endpoints remain low-cost JSON surfaces for polling, captcha refreshes, and UI refreshes; suspicious access may still feed passive abuse signals. +- **Decision recorded:** Rate and abuse checks should pass through a Studio-owned facade. Symfony RateLimiter remains the token bucket/sliding window implementation where practical, while Studio owns request-intent classification, action costs, cross-action signals, scoped resets, and future temporary ban policy. +- **Decision recorded:** Scoped limiter resets are preferred over partial token refunds when a workflow has a clear successful human outcome. +- **Decision recorded:** Turbo/browser prefetch should be classified separately from deliberate navigation and submission traffic so speculative requests do not unfairly drain user-facing buckets. +- **Decision recorded:** Temporary auto-bans are acceptable for clear suspicious behavior when they are TTL-bound, auditable, reviewable, and preserve Owner recovery paths. - **Decision recorded:** Visitor identity should use a first-party, HMAC-protected, rotatable technical visitor cookie. Avoid machine IDs, advertising IDs, cross-site identifiers, and browser/device fingerprinting. When no valid visitor cookie is present, the request uses an `APP_SECRET`-derived fallback visitor ID from source IP and normalized user-agent so cookie-disabled clients do not create a new unique visitor on every request. The response still receives a fresh random signed cookie token. The short-lived identity store keeps cookie hashes and fallback hashes separate: known valid cookie hashes win, then recent fallback hashes win, and newly issued cookies bind to the chosen visitor ID. This intentionally accepts temporary undercounting for same-IP/same-user-agent visitors so the system does not overcount every page view or first-cookie transition. - **Decision recorded:** Session hardening binds authenticated Symfony sessions to the first-party visitor signal. Login requests and legacy sessions without an existing binding initialize the binding; established sessions with a changed or missing visitor signal are treated as likely session duplication and terminated to prevent copied session cookies from staying usable. - **Decision recorded:** Request IDs, visitor IDs, IP buckets, and related metadata may feed rate limiting and suspicious-behavior detection, but they are signals rather than sole proof of identity. Some enforcement may be IP-based and some visitor-based, depending on the abuse pattern. - **Decision recorded:** Captcha does not replace CSRF, ACL, authentication, or business validation. -- **Decision recorded:** Symfony roles remain the primary authorization layer. ACL groups are an optional, separate project/domain permission layer with explicit AND/OR semantics for content trees, schemas, package domains, and similar granular access cases. -- **Decision recorded:** ACL group references use shared access-rule/reference ownership. Every ACL-bearing domain should expose impact and cleanup through a domain-owned provider so group review, cleanup, package usage, and entity/tree permissions do not rely on one ad-hoc cross-domain scanner. +- **Decision recorded:** Symfony roles remain the primary authorization layer. ACL groups are an optional, separate project/domain permission layer with explicit AND/OR semantics for content trees, schemas, extension domains, and similar granular access cases. +- **Decision recorded:** ACL group references use shared access-rule/reference ownership. Every ACL-bearing domain should expose impact and cleanup through a domain-owned provider so group review, cleanup, extension usage, and entity/tree permissions do not rely on one ad-hoc cross-domain scanner. - **Decision recorded:** Require module-declared permissions before module admin routes become active. - **Decision recorded:** Split operational route surfaces into `admin/` for system administration and `editor/` for content authoring, review, publishing, and content-management workflows. `admin/` starts at Admin, while `editor/` starts at Author and should use ACL capability checks so authors, managers, and administrators can access only the authoring functions they are allowed to use. - **Decision recorded:** Backend area checks expose an `AccessRule` boundary so future admin exceptions can add configured ACL groups without changing controller flow. The first router slice keeps admin at the Admin role boundary and keeps Owner as the unrestricted site-owner role. +- **Decision recorded:** Admin and Owner are separate operational authority levels. Admins are delegated operators; Owners control site-wide and recovery-sensitive actions. High-impact Admin features must use an action authority matrix enforced in the service/API/live-operation boundary, not only route prefixes or hidden navigation. Feature/action permissions are grouped by Admin, Editor, and Frontend surfaces; this branch implements the Owner-gated Admin matrix, prepares the shared descriptor model for later Editor use, and reserves Frontend only for explicitly designed future features. - **Decision recorded:** Editing or activating database-backed schema Twig and content-query/list fields is security-sensitive and requires explicit permissions plus audit logging. - **Decision recorded:** Content ACL restrictions may limit visibility to specific ACL groups and must be enforced consistently across rendering, API output, content-query/list fields, search, and import/export previews. - **Decision recorded:** Implement ACL foundations early, including `/admin` access protection and content-record ACL restrictions, to avoid later structural rewrites. @@ -157,7 +190,7 @@ Captcha should use a global form field integration. When a workflow includes the - **Implemented hardening:** Admins can reissue pending account links, which rotates the token hash and stored expiry before writing a new link to the message log. New invitation, registration, and password-reset requests revoke older pending tokens for the same target so stale links stop working. Public registration remains enumeration-safe: existing account emails receive the same UI success state, while the message-log delivery boundary emits `account.registration.existing_account` with the username for the future mailer. - **Implemented hardening:** Authenticated password changes and password-reset completions now issue a password-change notification flow with a one-time `security_review` token. Following the review link disables the account with `inactive` status, consumes the token, audits the dispute, and emits `account.password_change.disputed` for administrator review. - **Implemented foundation:** Visitor identity now uses a first-party technical `system_visitor` cookie with random signed cookie tokens, 30-day lifetime, HMAC validation, a file-backed cookie/fallback alias store, and an APP_SECRET-derived IP/user-agent fallback ID only when no known valid cookie is available. Stored/logged visitor IDs are compact 128-bit values derived with `APP_SECRET`; raw visitor tokens, cookie hashes, and fallback hashes are not stored in access statistics. IP and proxy signals stay separate from the visitor bucket so future rate limiting and blocking can distinguish shared networks from individual browser/device visitors. -- **Decision recorded:** Core cookies are limited to technical first-party values such as visitor identification, session/security binding, language preference, and appearance preference. Advertising or external analytics packages must bring their own isolated cookie strategy and consent flow instead of reusing core technical cookies. A future `ConsentProvider`/cookie-policy registry can let packages declare cookie purposes, retention, vendor scope, and required consent categories while the core consent UI/enforcement layer remains centralized. +- **Decision recorded:** Core cookies are limited to technical first-party values such as visitor identification, session/security binding, language preference, and appearance preference. Advertising or external analytics extensions must bring their own isolated cookie strategy and consent flow instead of reusing core technical cookies. A future `ConsentProvider`/cookie-policy registry can let extensions declare cookie purposes, retention, vendor scope, and required consent categories while the core consent UI/enforcement layer remains centralized. - **Implemented hardening:** Successful logins bind the current Symfony session to the current visitor ID. Authenticated requests with no previous binding initialize the binding for compatibility with already active sessions; authenticated requests where an established binding no longer matches clear the security token, invalidate the session, redirect to login, and audit `auth.session_visitor_mismatch_terminated` with the previous/current visitor IDs. - **Decision deferred:** A future "keep me logged in" checkbox should use Symfony's remember-me foundation with a separate server-side persistent login-token model instead of a bare identity cookie. The browser cookie should contain only an opaque selector/token, while the server stores the hashed token, owning user, expiry, current visitor binding, and revocation state. The remember-me trust window should be 7 days; automatic logins may rotate the token value but should keep the original expiry instead of silently sliding the window. An explicit credential login with the checkbox set can issue a new 7-day token. Successful auto-login creates a fresh Symfony session, binds it to the current `system_visitor` cookie, revokes the token on manual logout and password/status/security events, and audits mismatches or reuse attempts. Backend-calculated signals such as IP buckets, user-agent family, client hints, request cadence, and concurrent use may raise risk, require step-up, or help investigation, but they must not be treated as sole hard identity proof because they are too noisy across legitimate networks and browser/device changes. Normal browser session lifetime may be reduced later, with 60 minutes as an initial candidate, but the final TTL belongs to the dedicated Security hardening slice. - **Decision deferred:** Privacy copy, richer false-positive recovery, re-authentication step-up behavior, and rate-limit policy belong to the dedicated Security hardening slice. Visitor ID remains suitable for statistics and risk signals, and is used as a hard factor only for detecting copied/duplicated authenticated sessions. @@ -165,7 +198,7 @@ Captcha should use a global form field integration. When a workflow includes the - **Implemented hardening:** Account status changes now pass through a lifecycle service. When an account becomes `inactive` or `deleted`, all non-revoked API keys and pending password-reset/security-review tokens are revoked so non-usable accounts do not retain adjacent credentials or recovery links. Users can close their own account from a dedicated confirmation page with password plus explicit confirmation; the flow marks the account deleted, revokes adjacent credentials, logs the user out, redirects to the homepage, and emits `account.closed` with `retention_days` for the future mailer. Deleted accounts are hidden from the main Admin User list and retained in a separate deleted-users view until the configurable `user.deleted_user_retention_days` window has passed; admins can activate or deactivate retained deleted accounts without requiring ACL group repair. - **Implemented hardening:** Deleted accounts can be reactivated through registration or invitation account links. The flow keeps the existing UUID for future content/comment attribution, applies the role stored on the accepted token, ignores token groups that were deleted meanwhile, and replaces previous ACL memberships with the token's remaining groups so old administrative permissions are not restored implicitly. Token acceptance also rechecks that the token email was not claimed by another account before persisting. User-reference rendering has a central `UserIdentityResolver` fallback that returns a generic deleted-user identity when a stored UUID no longer resolves to a persisted account. A reusable `DeletedUserCleanup` service powers the admin cleanup action, reassigns revoked stale API keys to the stable deleted-user account for later API-audit references, and is ready for the future global stale-cleanup scheduler task. - **Implemented hardening:** Usernames must be 5 to 30 ASCII characters, start with a letter, and may contain only letters, digits, hyphens, and underscores. The default-off `user.username_change.enabled` setting controls whether existing users may change their username from the profile screen; invitation and reactivation account setup flows still require choosing a valid free username. Missing or purged user references render through the localized deleted-user identity fallback instead of using a persisted username. -- **Implemented hardening:** Admin user and ACL-group changes now enforce role hierarchy guardrails. Owners can manage all users, roles, and groups; Admins can enter Admin but cannot edit peer Admins or Owners, cannot self-demote below Admin while active, and invitation/edit forms validate both token target roles and assignable groups against the actor. Public registration may proceed without a default ACL group. The guardrails also prevent leaving the installation without at least one active Owner. ACL group updates and deletes require an impact review before confirmation, then run through the shared LiveLog operation overlay like package lifecycle actions; group deletion removes the identifier through domain-owned reference providers for user memberships, pending account tokens, content item ACL fields, schema version ACL fields, and site menu item ACL fields. +- **Implemented hardening:** Admin user and ACL-group changes now enforce role hierarchy guardrails. Owners can manage all users, roles, and groups; Admins can enter Admin but cannot edit peer Admins or Owners, cannot self-demote below Admin while active, and invitation/edit forms validate both token target roles and assignable groups against the actor. Public registration may proceed without a default ACL group. The guardrails also prevent leaving the installation without at least one active Owner. ACL group updates and deletes require an impact review before confirmation, then run through the shared LiveLog operation overlay like extension lifecycle actions; group deletion removes the identifier through domain-owned reference providers for user memberships, pending account tokens, content item ACL fields, schema version ACL fields, and site menu item ACL fields. - **Implemented hardening:** `AppSecretRotationGuard` stores an environment-specific APP_SECRET fingerprint after setup. A later fingerprint mismatch is treated as emergency compromise handling, not routine rotation: it revokes active API keys, creates one-hour password-reset links for active owners as a fallback when `bin/setup --reset-password` is unavailable, then records the new fingerprint and audits the handling. - **Implemented hardening:** APP_SECRET rotation handling is idempotent per environment fingerprint for the current synchronous recovery flow. Once a mismatch is handled, the guard stores the new fingerprint even when owner reset URLs could not be generated for every owner, and the audit context records owner and issued-link counts so operators can see partial recovery. If any owner recovery submission fails, the guard persists normal owner-bound one-time reset tokens, writes an emergency recovery file under `var/recovery/{APP_ENV}/`, and logs a warning with `bin/setup --reset-password` as manual recovery command, the recovery file path, and a `failed_submissions` list containing the affected owner uid, username, email, and failure reason. The emergency file is outside `public/`, uses restrictive permissions where supported, and contains reset paths for the affected owners only, not a universal account token. If future asynchronous mail delivery introduces new failure windows, a dedicated attempt journal may still be added in the Mailer/Security hardening slice. - **Implemented baseline:** `bin/setup --reset-password` can reset a user's password after displaying the matching account and confirming the operation. Interactive setup prompts now require repeated admin password entry to reduce CLI typos. diff --git a/dev/draft/0.2.x-SecurityHardeningPlan.md b/dev/draft/0.2.x-SecurityHardeningPlan.md new file mode 100644 index 00000000..22e7f04b --- /dev/null +++ b/dev/draft/0.2.x-SecurityHardeningPlan.md @@ -0,0 +1,349 @@ +# Security hardening implementation plan + +> **Status**: Draft +> **Updated**: 2026-06-18 +> **Owner**: Core +> **Purpose:** Plan the security hardening feature split so each branch can be implemented, reviewed, and merged as a focused production-ready slice. + +## Overview + +This plan turns the security and access-control decisions into a reviewable branch sequence. The goal is not to create one large security branch. Each branch should ship one coherent security capability with tests, documentation, worklog notes, and narrow integration points. + +The `feat-security` branch is the shared merge base. Feature branches should use the `feat-security-*` prefix and avoid unrelated cleanup. A branch may add extension points needed by later branches, but it should not implement future behavior merely to prove the extension point. + +Detailed handoff plans live in `dev/draft/security-hardening/`. Each branch plan defines the implementation sequence, interfaces, edge cases, tests, documentation updates, non-goals, and acceptance criteria for one reviewable branch. + +First policy defaults live in [security policy defaults](security-hardening/policy-defaults.md). Implementation branches should treat that document as the source for first TTLs, threshold defaults, privacy ceilings, and review requirements until a branch updates it with tested evidence. + +## Git handling + +Codex may create local commits for Security planning and implementation work when the commit scope is thematically clear and reviewable. Pushes are never implied by these plans and require an explicit user instruction. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be used as implementation inspiration for GeoIP, abuse-signal, rate-limit, auto-ban, captcha, and IconCaptcha work because similar features were already proven there. The current Symfony product decisions in this plan and the detail plans have absolute priority over the historical implementation. Do not copy old logic, storage models, identifiers, templates, assets, secrets, or framework-specific shortcuts directly; use the plugin only to understand behavior patterns, edge cases, and operational lessons. + +## Planning decisions + +- Keep Symfony Security, CSRF, Validator, RateLimiter, Messenger, Lock, and existing project message/audit/log layers as the primary foundations. +- Keep `/api/live/**` outside the normal rate-limit enforcement path. Live JSON endpoints should stay cheap, tokenized where needed, no-store, and suitable for operation polling, captcha refreshes, or lightweight UI refreshes. Suspicious live-endpoint traffic may feed passive abuse signals, but live endpoints should not receive ordinary HTML or versioned-API `429` handling. +- Treat Turbo and browser prefetch requests as lower-confidence interaction signals. Prefetch requests should not spend the same global abuse budget as deliberate navigations or submissions, and high-cost links may disable prefetch through `data-turbo-prefetch="false"`. +- Detect Turbo prefetch defensively through `X-Sec-Purpose: prefetch` and browser speculative loading through `Sec-Purpose: prefetch` where available. Missing or spoofable non-`Sec-*` hints must not bypass security checks. +- Use action-aware limiter costs. A failed login, failed captcha, registration submission, password-reset request, API mutation, suspicious probe, or scheduler trigger may consume different costs from different buckets. +- Prefer complete bucket reset for clear success cases before designing partial refunds. For example, successful password login may reset the login-attempt bucket for that subject. Verified provider-backed captcha success may reset or improve specific challenge/form buckets when that behavior is explicit and safe. +- Keep room for future positive adjustments through a Studio-owned rate/abuse facade, but do not expose Symfony RateLimiter details directly to controllers or extensions. +- Recognize cross-action abuse. Separate buckets remain useful, but repeated activity across different guarded workflows should also feed a global subject budget and suspicious-signal store. +- Add progressive punishment as a first-class concept: observe, throttle, require captcha, temporarily block, and hard-block only when signals justify it. +- Support active punishment such as draining a suspicious subject's relevant buckets when clear bot behavior is detected. +- Add score-based temporary auto-bans with TTL for Visitor-ID and stable client-IP subjects. Trusted registered users are never auto-banned, and Owner accounts must never be locked out of all recovery paths. +- Treat GeoIP as operational metadata for logs, statistics, and security review. Missing provider configuration must degrade gracefully. +- Include IconCaptcha in the overall security feature cut, but keep its provider implementation in a dedicated branch after the generic captcha contract. +- Cover adjacent security surfaces through the abuse/rate policy catalogue instead of local ad hoc checks: setup apply, CORS preflight, high-impact admin operations, extension lifecycle, backup/restore, import/export, uploads/archives, diagnostic downloads, and support bundles. +- Separate delegated Admin authority from Owner-only site-control actions through a shared action policy before broadening extension, scheduler, backup, settings, diagnostics, update, and security-management workflows. +- Track HTTP security headers as a production-hardening follow-up if they are not implemented inside an existing response or frontend-delivery branch. + +## Branch sequence + +### `feat-security-policy-docs` + +Record the product decisions from this plan in the relevant drafts and manuals before implementation starts. + +Detailed plan: [policy-docs](security-hardening/policy-docs.md). +Policy defaults: [security policy defaults](security-hardening/policy-defaults.md). + +Scope: + +- Update security, captcha, logging/statistics, API/live, and scheduler notes where their behavior is affected. +- Record first implementation defaults for retention, limiter thresholds, auto-ban TTLs, captcha TTLs, privacy ceilings, and configuration posture. +- Document the branch plan, expected verification shape, and deferred decisions. +- Do not add runtime code. + +Acceptance: + +- Future branches can cite one planning draft instead of re-litigating the same scope. +- Open decisions are explicit and assigned to the branch where they must be resolved. +- First implementation branches have a single policy-defaults reference for initial TTLs and thresholds. + +### `feat-security-geoip-observability` + +Make GeoIP useful for logs, statistics, and security diagnostics without using it for enforcement yet. + +Detailed plan: [geoip-observability](security-hardening/geoip-observability.md). + +Scope: + +- Add or complete a MaxMind/GeoIP2 provider behind the existing GeoIP resolver boundary. +- Store provider configuration as protected administrator-only configuration. +- Keep `NullGeoIpResolver` as the safe fallback when configuration or local databases are missing. +- Add a scheduler-ready GeoIP update task definition and leave it inactive by default until an administrator configures provider credentials and update policy. +- Surface safe GeoIP status in Admin diagnostics. + +Non-goals: + +- No geo-blocking. +- No permanent allow/deny decisions based on country or region. + +Acceptance: + +- Logs and statistics receive normalized GeoIP fields when available and normalized empty fields when unavailable. +- Provider secrets never appear in logs, exports, fixtures, screenshots, or diagnostics. + +### `feat-security-abuse-foundation` + +Introduce the Studio-owned abuse and rate facade without enforcing broad bans. + +Detailed plan: [abuse-foundation](security-hardening/abuse-foundation.md). + +Scope: + +- Add subject resolution for IP, visitor ID, authenticated user, API key, and safe combined keys. +- Add request-intent classification for browser navigation, Turbo prefetch, form submit, API read, API write, scheduler trigger, captcha refresh, captcha failure, login, registration, password reset, contact, import, and suspicious probe. +- Include additional intents for setup apply, CORS preflight, extension/admin operations, upload/archive validation, export/download, backup/restore, self-update, and diagnostics/support-bundle generation where those routes already exist or are introduced by later branches. +- Add a central action cost catalogue with separate website and API families. +- Add database-backed log lookup projections for message, audit, and access logs while keeping 30-day rotating file logs as the durable raw operational fallback. Projections store minimized structured fields only; they do not duplicate full raw log lines. +- Add database-backed passive suspicious-signal recording with TTL cleanup metadata. These records remain observational in this branch and become enforcement inputs only in later branches. +- Move Admin/API log browsing to the database projection, split the Admin Log Viewer into source tabs, keep broad free-text search over hidden identifiers/context, show only meaningful filters per source, and hide `DEBUG`/`INFO` by default through a multi-level filter where levels are meaningful. Message projections keep level data, security signals keep severity, and current access/audit projections omit level fields because they do not carry multiple meaningful levels. +- Exempt `/api/live/**` from normal enforcement while allowing passive signal hooks for clearly abusive patterns. +- Add audit/message events for suspicious signals with redacted context. + +Non-goals: + +- No automatic ban yet. +- No provider-specific captcha behavior. + +Acceptance: + +- Controllers and future extensions can ask one service for classification/cost decisions instead of calling Symfony RateLimiter directly. +- Prefetch requests are detectable and classified separately from deliberate navigation. + +### `feat-security-admin-acl-enforcement` + +Introduce one shared authority policy for Admin-vs-Owner action decisions before broadening high-impact Admin features. + +Detailed plan: [admin-acl-enforcement](security-hardening/admin-acl-enforcement.md). + +Scope: + +- Inventory existing Admin UI/API/live-operation/scheduler surfaces and assign stable action identifiers. +- Encode the first static authority matrix for delegated Admin actions, Owner-only actions, and deny-by-default unknown actions. +- Enforce the matrix in service/API/live-operation boundaries, not only in navigation or controllers. +- Preserve existing user-management guardrails for peer roles, ACL group assignment, self-lockout, and last-Owner protection. +- Keep protected values write-only or status-only unless a dedicated reveal flow adds re-authentication and audit. + +Non-goals: + +- No unbounded or extension-marketplace-style Admin permission editor; this slice ships only the bounded Owner-gated `Settings/ACL` matrix for registered feature/action descriptors. +- No extension marketplace permission model. + +Acceptance: + +- High-impact Admin workflows can distinguish "Admin may view", "Admin may mutate", and "Owner-only". +- Later extension, scheduler, backup, settings, diagnostics, update, and security-management branches can consume one policy instead of inventing local role checks. + +### `feat-security-rate-enforcement` + +Attach concrete Symfony RateLimiter buckets through the Studio facade. + +Detailed plan: [rate-enforcement](security-hardening/rate-enforcement.md). + +Scope: + +- Add named buckets for implemented workflows: login, registration, password reset, website global, API read, API write, scheduler trigger, suspicious probes, and any already-present contact/import/captcha-failure flows. Branches that introduce a later workflow must attach it to the existing policy catalogue instead of inventing a parallel limiter. +- Treat captcha refreshes served through `/api/live/**` as passive abuse signals instead of ordinary rejecting rate-limit buckets. +- Use costed `consume(n)` calls for action-aware spending. +- Use `reset()` for clear successful outcomes such as successful login or verified provider-backed captcha where the reset is scoped and safe. +- Return stable HTML or JSON `429` responses depending on request family. +- Keep `/api/live/**` excluded from ordinary rate-limit responses. +- Implement and test `no-store` for rate-limit, recovery, and error responses touched in this slice, and carry the broader production HTTP security-header follow-up into a dedicated response-hardening/frontend-delivery slice if CSP, `frame-ancestors`, `Referrer-Policy`, `Permissions-Policy`, `X-Content-Type-Options`, and documented route exceptions are still deferred. + +Non-goals: + +- No auto-ban. +- No partial token refund API unless a concrete workflow needs it after `reset()` is evaluated. + +Acceptance: + +- Different workflows can be limited independently and also contribute to a global budget. +- Successful human completion can clear the relevant local bucket when the product policy allows it. + +### `feat-security-auto-ban` + +Add temporary enforcement for sustained suspicious behavior. + +Detailed plan: [auto-ban](security-hardening/auto-ban.md). + +Scope: + +- Score retained source-risk Security signals across a one-hour window, primarily by Visitor ID and secondarily by stable client-IP bucket/HMAC with a laxer threshold multiplier. +- Record low-weight Security signals for `400`, `403`, `404`, and `429` outcomes, correlating probe-plus-`400` evidence so one request is not double-counted and excluding login-required `401` by status alone. +- Store active TTL ban state through cache-flock-backed keys plus a cache-backed active-ban index, with escalation derived from retained ban-trigger `security_signal_event` records. +- Add required Config/Settings defaults for auto-ban enablement, trusted-user minimum access level, and score threshold through the settings/default provider so missing database states fail open. +- Add forced bare `403` ban responses, trusted-user/Owner/API-key recovery protection, active-ban review UI, detail pages backed by filtered Security signals, and Owner-gated manual reset. + +Non-goals: + +- No country-level blocking. +- No permanent invisible deny list. + +Acceptance: + +- Clear repeated suspicious behavior can be temporarily blocked without blocking trusted users or Owner recovery. +- Operators can understand why a subject is blocked from retained Security signals, see expiry, and reset the active ban immediately. + +### `feat-security-captcha-contract` + +Add the generic captcha extension model. + +Detailed plan: [captcha-contract](security-hardening/captcha-contract.md). + +Scope: + +- Define `CaptchaProvider` and resolver contracts. +- Add workflow-level provider selection. +- Add a global captcha form field. +- Treat `none`, missing providers, and disabled providers as successful validation unless a later provider-required policy explicitly changes that workflow. +- Distinguish graceful captcha auto-success from verified provider-backed challenge success. Only verified provider-backed success may reset/improve buckets or satisfy captcha-based `429` recovery. + +Non-goals: + +- No IconCaptcha provider implementation. +- No hard dependency on extension assets. + +Acceptance: + +- Public workflows can include captcha without knowing the provider. +- Captcha remains additive and never replaces CSRF, authentication, ACL, rate limiting, or domain validation. + +### `feat-security-icon-captcha` + +Implement the first-party IconCaptcha provider as a focused provider branch. + +Detailed plan: [icon-captcha](security-hardening/icon-captcha.md). + +Scope: + +- Ship provider-owned assets, templates, JavaScript, translations, services, and validation. +- Use deterministic server-side challenge derivation with one-shot challenge IDs and the bounded TTL from the Security policy defaults. +- Add refresh behavior through `/api/live/**` or another lightweight JSON route. Refresh abuse should feed passive signals, but ordinary live refreshes should not return normal rate-limit `429` responses. +- Keep UI accessible and layout-stable. + +Non-goals: + +- No core-specific IconCaptcha checks in contact, registration, or password forms. +- No copied legacy code, secrets, or hard-coded asset paths from older projects. + +Acceptance: + +- The provider can be enabled, disabled, or replaced without breaking workflows that use the generic captcha field. +- Challenge payloads do not expose secrets, generated answer material, or reusable state. + +### `feat-security-mailer-account-delivery` + +Replace the message-log-only account delivery path with production mail delivery. + +Detailed plan: [mailer-account-delivery](security-hardening/mailer-account-delivery.md). + +Scope: + +- Add provider-based mail-flow registration if it is not already present. +- Add localized Markdown templates, HTML/plain-text rendering, placeholder replacement, and Symfony Messenger queueing. +- Add a conservative transport guard. +- Keep the message-log action-link delivery only as an explicit debug aid with administrative warnings. + +Non-goals: + +- No newsletter or CRM integration. + +Acceptance: + +- Invitation, registration, password reset, password-change review, account closure, and APP_SECRET recovery flows no longer depend on reading clear action URLs from the message log in production. + +### `feat-security-remember-me` + +Add persistent login as an explicit security feature. + +Detailed plan: [remember-me](security-hardening/remember-me.md). + +Scope: + +- Use Symfony remember-me concepts with a server-side persistent token model. +- Store only opaque selector/token material in the browser. +- Bind automatic login to the current visitor signal. +- Revoke tokens on logout, password change, account status changes, APP_SECRET emergency handling, and suspicious reuse. +- Use a seven-day trust window unless implementation evidence supports changing it. +- Include a minimal account-facing token review/revocation surface so persistent login is operable without database access. + +Non-goals: + +- No long-lived identity cookie without server-side revocation. + +Acceptance: + +- Automatic login is auditable, revocable, visitor-bound, and does not bypass account status or role checks. + +## Cross-branch review rules + +- Every branch must include focused tests for allowed, denied, degraded, and redacted behavior. +- Every branch must update this plan or the owning feature draft when the implementation changes product scope. +- Security-sensitive state changes must emit audit or message entries with safe context. +- Protected values must be redacted from logs, ActionLog output, diagnostics, API payloads, and tests. +- Public behavior must remain graceful when optional providers, GeoIP databases, mail transports, or captcha providers are missing. +- Owner lockout risk must be reviewed whenever a branch can deny authentication, sessions, API keys, scheduler access, or admin recovery. +- Security identity must come from one reviewed resolver that uses Symfony's resolved request client IP. Security code must not trust raw `X-Forwarded-*` headers, ad-hoc IP parsing, extension-owned client identity logic, or app-level trusted-proxy settings introduced by Security branches; trusted proxy handling belongs in deployment/webserver configuration. Visitor-ID generation may use raw forwarding-header values only as untrusted differentiation entropy and never as Security subject, GeoIP, ban, or signal evidence. +- TTL, expiry, and cleanup behavior must use an injectable clock/time boundary so tests can cover expiry, replay, and cleanup deterministically. +- Every enforcement branch must define its degraded-storage behavior explicitly. Optional observability features may fail open with redacted diagnostics; hard enforcement must avoid surprise Owner lockout and must audit degraded decisions. +- Race and idempotency behavior must be reviewed for one-shot captcha validation, limiter consumption/reset, auto-ban creation/manual reset, mail token delivery, and remember-me token rotation. +- Each PR must complete the Security PR-readiness checklist below from the actual branch diff. Do not pre-check items from the template without reviewing the changed public entry points, data flows, browser storage, extension boundaries, docs, translations, and verification output for that branch. + +## Security PR-readiness checklist + +- [ ] Security/privacy considerations, public entry points, sessions, secrets, and browser storage reviewed. +- [ ] Extension/module boundaries, access levels, route/API/live endpoint scopes, and collision risks reviewed. +- [ ] Setup/init/CI, cross-platform behavior, disabled-feature fallbacks, and process/env handling reviewed. +- [ ] Project-rules, architecture, naming, and documentation drift reviewed; see issue `#57` for the broader drift-audit expectations. +- [ ] Follow-up tasks captured in `dev/WORKLOG.md`. +- [ ] Translations and user-facing copy updated or explicitly confirmed unchanged. +- [ ] `bin/phpunit`: [RESULT] +- [ ] `bin/jstest`: [RESULT] +- [ ] `bin/lint`: [RESULT] +- [ ] Other checks not already covered by the full suites above: [RESULT] + +## Fixed implementation defaults + +- Auto-ban active state uses cache-flock TTL keys. Admin review, escalation, and reset cutoffs are explained by retained `security_signal_event` records, including ban-trigger and reset signals, rather than a separate durable ban table. +- Auto-ban enforcement applies by default to Visitor-ID and IP source evidence. Trusted registered users at or above the configured trusted-user level, including valid API keys owned by trusted users, are never auto-banned, and Owner accounts must retain recovery access. +- Auto-ban is enabled by default once implemented, but can be disabled through bounded Security settings. Visitor IDs and IP buckets tied to trusted active sessions or trusted-user-owned API keys must not be banned. +- IP-based enforcement is secondary and laxer than Visitor-ID enforcement through a fixed `x2` threshold multiplier. Prefer Visitor-ID-backed scoring for continuity, evaluate IP scoring to reduce cookie/header-mutation bypasses, and keep every active ban TTL at or below 7 days. +- Scoreable request signals are persisted per evaluated source subject, normally Visitor ID and IP bucket, so scoring uses indexed subject reads rather than JSON-context filtering. Score aggregation is write-triggered after signal persistence and reuses that DB path; ordinary non-signal requests perform only the cheap active-ban cache check. If both Visitor and IP thresholds cross from one evaluation, create at most one active ban and prefer the Visitor ban. +- Passive suspicious signals use database-backed short-lived records with redacted normalized subject keys, intent, reason code, weight/count, first/last seen timestamps, expiry, and safe context hash. They are not enforcement by themselves until the rate/ban branches consume them. +- Security subject keys use normalized client identity, visitor ID, API key fingerprint/prefix, authenticated user UID, and safe combined keys produced by the shared resolver. Raw IP strings and raw credentials must not become cross-branch storage keys. +- Raw IP addresses, IP buckets, and stable IP-derived hashes are queryable for at most 30 days across logs, projections, diagnostics, exports, and backups. Longer-term correlation uses visitor IDs, authenticated user IDs, API key fingerprints, or aggregate dimensions. +- Turbo and browser prefetch are classified server-side and count at lower confidence. Disable prefetch only on expensive or side-effect-adjacent links. +- Website global rate policy uses separate deliberate burst and sustained buckets so normal browsing is not measured by one oversized per-minute limit. Turbo/browser prefetch uses a separate lower-confidence observation path instead of spending the same budget as deliberate navigation. +- Registered users receive higher ordinary navigation/API limits than anonymous visitors where the workflow has no explicit bucket. Owner-owned API keys and subjects tied to active Owner sessions are exempt from ordinary application rate-limit rejection. +- Suspicious probe paths are configurable with extensive defaults, return a generic `400`, and allow only one high-signal probe per subject per 10 minutes before draining suspicious buckets or feeding auto-ban decisions. Auto-ban must correlate the probe signal and its `400` response so one request is not double-counted. +- The `/user/login?bypass=1` recovery login render path, resolved through the shared `RequestPathResolver`, must remain reachable even when the current Visitor ID or IP bucket is banned; it uses a dedicated small recovery bucket and successful credential login re-evaluates current bans and limiters under authenticated, trusted-user, Admin, or Owner policy. +- Successful login and verified provider-backed captcha are the first scoped reset candidates. Registration and password reset remain stricter until a branch explicitly proves a safe reset policy. +- Captcha-based reset or `429` recovery requires an active provider-backed challenge. Provider `none`, missing-provider, or disabled-provider auto-success is never human proof and must not reset limits. +- The first Admin ban-review UI should be a compact diagnostics/review surface with active bans, expired cleanup, reason/source signal, expiry, actor context where available, and Owner-gated manual reset. +- IconCaptcha challenge state uses a dedicated Symfony cache pool when practical, falling back to `cache.app` if no dedicated pool exists. The first provider default TTL is 15 minutes, with one-shot invalidation after every validation attempt and scoped failure buckets preventing brute-force guessing. +- IconCaptcha accessibility must not reveal the visual answer through labels. If neutral labels are insufficient, use a provider-owned accessible quiz challenge with a spoken question/task and multiple answer options, generated and validated through the same one-shot challenge, TTL, and abuse-signal rules. +- Account mail delivery uses provider-backed flow metadata, localized Markdown templates, Messenger queueing, and an initial transport guard of one queued message per account-flow action plus configurable worker-side retry/backoff. Debug action-link logging remains disabled outside explicit debug mode. +- Remember-me includes a minimal profile/security UI for listing active persistent tokens and revoking individual tokens or all other tokens. + +## Remaining calibration points + +- Define exact first thresholds while implementing `feat-security-abuse-foundation` and `feat-security-rate-enforcement`; record them as constants/config defaults and tests in those branches. +- Verify the early auto-ban enforcement ordering carefully: active Visitor/IP bans must be resolved before error pages or rate-limit buckets can produce another response, but after authenticated trusted-user and trusted-user-owned API-key context is available to bypass those source bans. +- Database-backed lookup projections for message, audit, access, and passive security signals are the preferred Admin/API read model for Security review and abuse correlation. File logs remain the durable raw source and operator fallback. +- Define backup/export handling for short-retention security data before enabling any database-backed security projection, so restore and support workflows do not reintroduce expired IP-derived records. + +## References + +- [Security and access control](0.2.x-SecurityAccessControl.md) +- [Security policy defaults](security-hardening/policy-defaults.md) +- [IconCaptcha integration](0.4.x-IconCaptcha.md) +- [Contact, mail, and logging](0.4.x-ContactMailLogging.md) +- [API layer](0.4.x-ApiLayer.md) +- [Scheduler](0.4.x-Scheduler.md) +- [Action log and audit snippets](../manual/action-log-audit-snippets.md) diff --git a/dev/draft/0.3.x-NavigationSitemapBuilder.md b/dev/draft/0.3.x-NavigationSitemapBuilder.md index 776fb305..da3167c5 100644 --- a/dev/draft/0.3.x-NavigationSitemapBuilder.md +++ b/dev/draft/0.3.x-NavigationSitemapBuilder.md @@ -27,9 +27,9 @@ Navigation should not be a special hard-coded template fragment. It should be st - Provide theme-facing helpers or services that return normalized menu trees. - The first implementation slice exposes `NavigationBuilder` and `navigation()` for active menu trees, currently backed by the seeded `main` menu and anonymous visibility rules. - `NavigationBuilder::build()` and `navigation()` support `maxDepth` with default `3`, `startLevel` for absolute level slices, and `rootUid` for split menus that render the children of one resolved item. -- `NavigationBuilder::collectItems()` exposes the flat, hook-extended item list before core tree normalization so themes with their own menu builder can reuse core storage and package contributions without using the core render tree. +- `NavigationBuilder::collectItems()` exposes the flat, hook-extended item list before core tree normalization so themes with their own menu builder can reuse core storage and extension contributions without using the core render tree. - URL targets from persisted rows or hooks are normalized before template export. Relative targets and `http`/`https` external links are allowed; unsafe or malformed schemes render as `#`. -- Expose `NavigationBuilderEvent` as the package-facing extension hook for adding, removing, or reordering flat navigation items before the builder normalizes parent/child hierarchy, sibling sort order, and depth slices. +- Expose `NavigationBuilderEvent` as the extension-facing hook for adding, removing, or reordering flat navigation items before the builder normalizes parent/child hierarchy, sibling sort order, and depth slices. - Native account navigation contributes virtual login/profile roots to the main menu through the same hook. Anonymous users see one high-sort-order login link; authenticated users see a profile link with API-key, studio, admin, and logout children filtered by minimum access metadata. Invitation acceptance is not a menu destination; it is reached through a mail-token link. `user.menu.enabled` can disable the system login menu and `user.menu.sort_order` controls its position. - Generate XML/JSON sitemaps from published content and menu state, respecting visibility and ACL rules. - Allow modules to contribute admin navigation entries through module manifests separately from public website menus. @@ -39,7 +39,7 @@ Navigation should not be a special hard-coded template fragment. It should be st - Test entity-linked menu items update when slugs or hierarchy change. - Test visibility, ACL, and localization behavior. - Test theme helper output for active/current states and nested children. -- Test default depth limiting, custom depth limiting, absolute level slices, root-item split menus, package item injection, and deterministic sort order. +- Test default depth limiting, custom depth limiting, absolute level slices, root-item split menus, extension item injection, and deterministic sort order. - Test sitemap output excludes hidden, private, draft, archived, or ACL-restricted content. - Test keyboard-accessible tree editing once the UI exists. @@ -49,8 +49,8 @@ Navigation should not be a special hard-coded template fragment. It should be st - **Decision recorded:** `navigation()` defaults to a stable depth of three levels, while templates may request smaller or larger slices through helper arguments. - **Decision recorded:** Split menus are first-class helper behavior. Templates may render an absolute level with `startLevel` or render only the children below a known item with `rootUid`. - **Decision recorded:** Themes that need fully custom navigation logic should consume `NavigationBuilder::collectItems()` and build their own tree instead of replacing core storage access or querying menu tables directly. -- **Decision recorded:** Modules and packages may extend resolved navigation through `NavigationBuilderEvent` by contributing `NavigationItem` values with `parentUid` and `sortOrder`; core remains the owner of menu storage, tree normalization, and ACL visibility rules. -- **Decision recorded:** Package-facing static view injections should feed route-backed menu entries through the same builder with access-level and access-group metadata, while stored content/menu items and core system views keep priority over injected package routes. +- **Decision recorded:** Modules and extensions may extend resolved navigation through `NavigationBuilderEvent` by contributing `NavigationItem` values with `parentUid` and `sortOrder`; core remains the owner of menu storage, tree normalization, and ACL visibility rules. +- **Decision recorded:** Extension-facing static view injections should feed route-backed menu entries through the same builder with access-level and access-group metadata, while stored content/menu items and core system views keep priority over injected extension routes. - **Decision recorded:** `navigation()` resolves the current actor and request context before building menu trees so frontend templates can receive access-filtered, active-aware navigation without duplicating security or route logic. - **Decision recorded:** Menu changes should be reviewable and publishable, preferably through the shared draft/publish workflow. - **Decision recorded:** Public website menus and admin/module navigation are separate concepts. diff --git a/dev/draft/0.3.x-SchemaContentFields.md b/dev/draft/0.3.x-SchemaContentFields.md index 37d5e84f..53152fd5 100644 --- a/dev/draft/0.3.x-SchemaContentFields.md +++ b/dev/draft/0.3.x-SchemaContentFields.md @@ -11,7 +11,7 @@ - Explains how shipped presets provide a complete default CMS experience while custom schemas extend project-specific modeling. - Covers schema storage, validation, rendering metadata, persistence, and migration concerns. - Depends on the static/dynamic content model and editor experience drafts. -- Separates flexible content fields from package-owned domain tables. +- Separates flexible content fields from extension-owned domain tables. ## Outline Schema-driven fields should let editors model project-specific content without changing database schema for every content type. This is useful for page sections, repeatable content metadata, flexible editor forms, import/export, API output, custom inner content rendering, and generic fallback rendering. @@ -20,7 +20,7 @@ Content schemas are database-backed content-type definitions. A schema defines w The CMS must still be fully usable without custom schema authoring. Shipped factory presets should provide complete, production-ready content types for common website needs such as static pages, list views, blog posts, and project hubs. Custom schemas then let advanced users and site builders extend, copy, or replace those defaults when a project needs a more specific model. -Fieldsets should not become a dumping ground for every kind of package data. Package-owned domain data should prefer package-owned tables and migrations when it needs durable structure, relationships, or efficient queries. Schema fields should focus on content-like extension data that belongs to CMS content items. +Fieldsets should not become a dumping ground for every kind of extension data. Extension-owned domain data should prefer extension-owned tables and migrations when it needs durable structure, relationships, or efficient queries. Schema fields should focus on content-like extension data that belongs to CMS content items. Relationship fields are part of the content model when they describe editorial relationships between content entities. A schema may define direct reference fields for selecting one or more target entities, and controlled query/list fields for resolving dynamic sets such as all memories linked to a character, all locations used in a scene, or all entries matching a project-specific filter. These fields should let users build navigable project graphs without inventing custom tables for every editorial relationship. @@ -95,7 +95,7 @@ Variable fieldsets need efficient assembly. The database should not have to scan ## Implementation Notes - **Decision proposed:** Start with a small core field type set and make field types additive through tagged services. -- **Decision proposed:** Keep schema values content-focused; use package-owned tables for package domain data. +- **Decision proposed:** Keep schema values content-focused; use extension-owned tables for extension domain data. - **Decision recorded:** Field values include content UID, version, language, variant, field identifier, and field content. The field identifier maps the value to the schema definition. - **Decision recorded:** Entity-level flexible metadata is separate from schema field values; `title` and `subtitle` are reserved required base field identifiers that every schema must define. - **Decision recorded:** Schemas may include controlled content-query/list fields, but execution must go through content-query services rather than arbitrary Twig logic. diff --git a/dev/draft/0.4.x-ApiLayer.md b/dev/draft/0.4.x-ApiLayer.md index f8de64bd..80eee7e4 100644 --- a/dev/draft/0.4.x-ApiLayer.md +++ b/dev/draft/0.4.x-ApiLayer.md @@ -9,43 +9,44 @@ - Defines whether and how the CMS exposes API endpoints. - Covers REST conventions, authentication, serialization, permissions, versioning, and module-provided endpoints. - Depends on the content model, security, and import/export drafts. -- Starts with content interaction, a reusable endpoint registry, and package-ready extension contracts. +- Starts with content interaction, a reusable endpoint registry, and extension-ready extension contracts. +- Uses the Security hardening policy defaults for API rate-limit and `/api/live/**` abuse-signal policy. ## Outline -The first API should expose content interaction, not internal process control. External clients should be able to read content and, where explicitly allowed, create or update content through content-oriented operations. Content API responses should preserve the canonical slug hierarchy, language, generic variant, version, and schema field identifiers where applicable. Internal content UIDs, Doctrine identifiers, audit metadata, ACL group details, and editor-only state should not be exposed by default. API access requires user-owned API tokens generated, revoked, and reset by users. Administrative internals such as package lifecycle, update flows, backup/restore, release packaging, and general command execution should stay outside the initial API. +The first API should expose content interaction, not internal process control. External clients should be able to read content and, where explicitly allowed, create or update content through content-oriented operations. Content API responses should preserve the canonical slug hierarchy, language, generic variant, version, and schema field identifiers where applicable. Internal content UIDs, Doctrine identifiers, audit metadata, ACL group details, and editor-only state should not be exposed by default. API access requires user-owned API tokens generated, revoked, and reset by users. Administrative internals such as extension lifecycle, update flows, backup/restore, release packaging, and general command execution should stay outside the initial API. The API should be boring and predictable. REST-style endpoints, Symfony controllers, Serializer, Validator, voters, and clear error formats are enough for the first phase. More advanced API styles can be revisited only when a real need appears. API tokens are user-owned settings. Users should manage their own API tokens from their profile/account configuration, not from global admin configuration. Administrators may need oversight or revocation tools later, but token creation/reset remains tied to the owning user context so audit and ACL behavior stay clear. Tokens do not grant independent application scopes beyond their read-only or read-write status. The owning user's role, ACL groups, and the target domain's own access rules remain the source of truth for what a request may see or change. -Module-provided API resources should be possible through constrained endpoint definitions. Packages should not provide arbitrary controllers or direct Doctrine/DBAL access through the public API. A package may contribute declarative endpoint/resource definitions and safe domain handlers; the API layer validates the definition, registers the route under a package namespace, enforces the same authentication and response contracts, and leaves domain authorization to the handler boundary. +Module-provided API resources should be possible through constrained endpoint definitions. Extensions should not provide arbitrary controllers or direct Doctrine/DBAL access through the public API. An extension may contribute declarative endpoint/resource definitions and safe domain handlers; the API layer validates the definition, registers the route under an extension namespace, enforces the same authentication and response contracts, and leaves domain authorization to the handler boundary. ## Planning Readiness -The API branch should implement the platform foundation and the first content resource set together. The result should be feature-complete enough that future domain endpoints can be attached without changing authentication, response rendering, pagination, error handling, OpenAPI documentation, or package registration contracts. +The API branch should implement the platform foundation and the first content resource set together. The result should be feature-complete enough that future domain endpoints can be attached without changing authentication, response rendering, pagination, error handling, OpenAPI documentation, or extension registration contracts. ## Recorded planning decisions - Use REST-style HTTP endpoints under `/api/v1` as the first public API style. GraphQL can be revisited only when clients need cross-resource graph queries that REST cannot express cleanly. - Use OpenAPI documentation for the public contract. `NelmioApiDocBundle` is an acceptable dependency if it keeps controller/DTO documentation smaller and easier to maintain than a custom documentation generator. - Keep API Platform out of the first implementation unless a later review shows its generated-resource model fits Studio's domain-owned ACL and serialization boundaries without exposing entities. -- Treat `read_only` keys as method-gated API keys: they may authenticate `GET`, `HEAD`, and `OPTIONS` requests only. Mutating methods such as `POST`, `PUT`, `PATCH`, and `DELETE` require an active `read_write` key and still need domain authorization. +- Treat `read_only` keys as method-gated API keys: they may authenticate `GET`, `HEAD`, and safe `OPTIONS` requests only. Bearer preflights use `Access-Control-Request-Method` for this method gate, so `OPTIONS` requests that ask to perform `POST`, `PUT`, `PATCH`, or `DELETE` require an active `read_write` key and still need domain authorization. - Use the API key owner as the authenticated user context. The API must not define a parallel role or group model. A read-write key cannot create, update, publish, delete, or otherwise mutate a resource unless the owning user could do so according to the target domain's ACL rules. - Allow endpoint definitions to opt into anonymous public read access with `allow_public`. The default must be private/authenticated. Public anonymous access is read-only and uses `AccessActor::anonymous()`; invalid Bearer keys still fail authentication instead of falling back to anonymous access. - Return deterministic JSON `503 Service Unavailable` responses for `/api/v1/**` when setup is not complete, maintenance mode is active for non-admin API callers, or Doctrine/DBAL reports database unavailability. The API must not fall through to setup redirects, HTML error pages, or non-deterministic exception output. -- Use domain-owned endpoint providers and handler services. The API layer provides the central `/api/v1/{resourcePath}` dispatch route, authentication, method gating, request parsing, response formatting, errors, pagination helpers, and documentation. Content, packages, and future domains decide which operations exist and enforce their own ACL policy inside endpoint handlers. +- Use domain-owned endpoint providers and handler services. The API layer provides the central `/api/v1/{resourcePath}` dispatch route, authentication, method gating, request parsing, response formatting, errors, pagination helpers, and documentation. Content, extensions, and future domains decide which operations exist and enforce their own ACL policy inside endpoint handlers. - Prefer canonical slug-hierarchy resource identity for content endpoints. Public API responses should not expose content UIDs by default. If a future endpoint needs a stable opaque identifier, it must be explicitly documented and justified by the owning domain. - Follow content ACL rules for published and unpublished content. The API should return exactly the content the token owner may see, including future editor-locked draft/unpublished content once the content domain implements those ACL rules. - Keep drafting, versioning, publish workflows, dynamic fieldset assembly, and editor-only state as internal domain processes unless a dedicated API operation explicitly exposes a safe command boundary. - Use the shared Message layer for localized API error and operation feedback. API error payloads should include stable codes and translation keys; translated human messages may be included when the request locale is known. -- Use separate administrative and package endpoint namespaces. Administrative management endpoints belong below `/api/v1/admin/...`; package-contributed endpoints belong below `/api/v1/packages/{package_slug}/...`, must use package-owned handler keys, and must pass definition validation before registration. -- Use explicit navigation resources for useful parent paths. OpenAPI remains the authoritative machine-readable contract, while parent endpoints such as `/api/v1/packages` may return direct child links and available methods as a Hypermedia-style convenience when the parent has no domain content of its own. +- Use separate administrative and extension endpoint namespaces. Administrative management endpoints belong below `/api/v1/admin/...`; extension-contributed endpoints belong below `/api/v1/extensions/{extension_slug}/...`, must use extension-owned handler keys, and must pass definition validation before registration. +- Use explicit navigation resources for useful parent paths. OpenAPI remains the authoritative machine-readable contract, while parent endpoints such as `/api/v1/extensions` may return direct child links and available methods as a Hypermedia-style convenience when the parent has no domain content of its own. ## Implementation plan 1. Add `App\Api` as the public API foundation namespace with narrowly scoped services and plain DTO/value objects excluded from broad service discovery where appropriate. 2. Add a stateless `api` firewall for `^/api/v1` before the browser `main` firewall. Authenticate supplied `Authorization: Bearer ` headers through the existing API key prefix, HMAC lookup, encrypted payload model, revoked-status checks, and `UserAccountChecker`; allow missing Bearer headers and unrelated non-Bearer authorization schemes to continue so the API layer can decide whether the endpoint permits anonymous public reads. 3. Add an `ApiRequestContext` that carries either the authenticated user, API key UID/prefix/status, request locale, accepted response format, and derived `AccessActor`, or an anonymous `AccessActor` for explicitly public read endpoints. 4. Add endpoint access and method gating before endpoint execution. `allow_public` permits anonymous safe-method reads only; private endpoints require a valid API key; `read_only` keys allow safe methods only; `read_write` keys pass to domain authorization. Authentication failure, revoked key, read-only write attempts, anonymous private access, and domain denial must produce different stable error codes without leaking secrets. -5. Add endpoint definition and handler contracts such as `ApiEndpointProviderInterface`, `ApiEndpointDefinition`, and `ApiEndpointHandlerInterface`. Definitions should declare method, path, route name, owner namespace, handler key, package slug when applicable, operation type, request DTO class if any, response schema metadata, safe-method status, successful status code, and `allow_public`. -6. Register core endpoint providers and handlers through Symfony tags. Reuse the active package runtime contribution registry for package endpoint providers and handlers after adding the API contribution type and validation rules. +5. Add endpoint definition and handler contracts such as `ApiEndpointProviderInterface`, `ApiEndpointDefinition`, and `ApiEndpointHandlerInterface`. Definitions should declare method, path, route name, owner namespace, handler key, extension slug when applicable, operation type, request DTO class if any, response schema metadata, safe-method status, successful status code, and `allow_public`. +6. Register core endpoint providers and handlers through Symfony tags. Reuse the active extension runtime contribution registry for extension endpoint providers and handlers after adding the API contribution type and validation rules. 7. Add setup/database availability handling before API authentication and endpoint execution. Incomplete setup and Doctrine/DBAL availability failures return stable `503` JSON responses with Message-layer codes and `Retry-After`. 8. Add API maintenance handling after Bearer authentication so active admin API keys may bypass maintenance mode while public, anonymous, and non-admin `/api/v1/**` requests receive stable JSON `503` responses. Keep the global HTML maintenance gate bypassing `/api/**` so internal `/api/live/**` operation polling remains available during maintenance. 9. Add an `ApiResponder` around `JsonOutputRenderer` for versioned API responses. It should produce consistent success, error, validation, pagination, and link payloads for `/api/v1/**` while keeping `/api/live/**` outside the stable public contract. @@ -57,9 +58,9 @@ The API branch should implement the platform foundation and the first content re 15. Add optimistic conflict handling for writes using the content version or active revision version. Conflicts should return a stable conflict error with the current version and no internal entity data. 16. Add audit logging for successful mutations and relevant denied mutation attempts with user context, API key UID/prefix, operation name, resource path, and redacted request metadata. 17. Add OpenAPI output for the registered endpoint set. Prefer NelmioApiDocBundle if it can consume controller attributes and DTO metadata without forcing entity exposure; otherwise generate a small OpenAPI document from endpoint definitions. -18. Add package endpoint validation. Package definitions must stay under `/api/v1/packages/{package_slug}/...`, use package-owned operation names, translation keys, and handler keys, avoid direct entity/DBAL exposure, declare safe request/response schemas, and be disabled if validation fails. +18. Add extension endpoint validation. Extension definitions must stay under `/api/v1/extensions/{extension_slug}/...`, use extension-owned operation names, translation keys, and handler keys, avoid direct entity/DBAL exposure, declare safe request/response schemas, and be disabled if validation fails. 19. Add reusable API navigation helpers for explicit parent resources. Navigation responses must list only visible direct children and their methods, avoid replacing OpenAPI as the contract, and keep private endpoints hidden from anonymous callers unless the child endpoint explicitly allows public access. -20. Add focused tests for authentication, method gating, read/write authorization, setup/database/maintenance unavailability, content ACL visibility, pagination, error envelopes, validation errors, OpenAPI exposure, and package endpoint definition validation. +20. Add focused tests for authentication, method gating, read/write authorization, setup/database/maintenance unavailability, content ACL visibility, pagination, error envelopes, validation errors, OpenAPI exposure, and extension endpoint definition validation. ## Technical Specifications - Use the central Symfony API dispatch controller for definition-backed API endpoints. Domains should usually contribute endpoint providers and handler services instead of adding API branches to existing browser controllers. @@ -74,11 +75,13 @@ The API branch should implement the platform foundation and the first content re - Keep API token management in user profile/account settings. - Associate API token usage with the owning user so edits can be audited and ACL checks can use user context. - Prefix API routes predictably and version them from the start, for example `/api/v1`. -- Keep administrative API operations below `/api/v1/admin/...` so management resources can share Admin/Owner gating and remain separate from content, editor, schema, and package-contributed resources. +- Keep administrative API operations below `/api/v1/admin/...` so management resources can share Admin/Owner gating and remain separate from content, editor, schema, and extension-contributed resources. - Prefer explicit parent navigation resources over implicit 404 fallbacks. A parent path may list direct children, methods, operation identifiers, summaries, and tags when that helps clients discover nearby API resources without granting additional access. - Keep content route identity based on canonical slug hierarchy by default. Internal UIDs may be used by repositories and domain services, but they should not be part of the default public API contract. - Reserve `/api/live/**` for application-owned live JSON flows such as captcha seeds, polling, and operation status checks. This branch is intentionally not part of the stable external versioned API contract. - Keep `/api/live/**` reachable during `APP_MAINTENANCE` so setup, admin, and operational live-log polling can continue while the public site is unavailable. +- Keep `/api/live/**` outside ordinary rate-limit enforcement. These endpoints are application-owned lightweight JSON flows; clear abuse may feed passive security signals, but live polling, captcha refreshes, and UI refreshes should not receive the normal website/API `429` response path. +- Apply versioned API read/write rate-limit defaults from the Security policy defaults through the Security hardening rate-enforcement branch. - Use `/api/live/operations/{operationId}?token=&cursor=` for ActionLog polling fallback responses. Payloads should include `operation_id`, `status`, `cursor`, optional `cursor_max`, `entries`, and `next_poll_ms`. - Treat live-operation cursors as monotonic numeric positions emitted by the log producer, not as stable durable identifiers. - Defer public content DTO and serializer contracts to the API feature branch so entity exposure, pagination, error payloads, and versioning rules are decided together. @@ -127,7 +130,7 @@ The API branch should implement the platform foundation and the first content re - Test ActionLog polling honors the supplied numeric cursor and exposes progress hints where available. - Test API responses do not depend on current Twig templates, CSS classes, or frontend markup. - Test list endpoints against documented pagination/filtering/sorting semantics and avoid assertions that depend on database row order without an explicit sort. -- Test package endpoint definitions are accepted only under their package namespace and cannot expose blocked low-level access patterns. +- Test extension endpoint definitions are accepted only under their extension namespace and cannot expose blocked low-level access patterns. - Test API navigation resources with anonymous and authenticated visibility so private child endpoints are not exposed to public callers. - Test endpoint registry wiring so definition-backed endpoints have registered handlers, method/path pairs are unique, and OpenAPI operation IDs remain unique. @@ -138,17 +141,17 @@ The API branch should implement the platform foundation and the first content re **Impact:** Security behavior is reviewable from a small number of framework subscribers instead of being duplicated in every controller. **Recommendation:** Keep this boundary; future domains should only add endpoint definitions and handler ACL, not parallel authentication or method gating. **Priority:** Before API. -- **Area:** Package API contributions. - **Finding:** Package endpoint definitions are constrained to `/api/v1/packages/{package_slug}/...`, package handler keys, and package-scoped OpenAPI tags, but trusted package PHP handlers can still execute arbitrary application code once registered. - **Evidence:** `PackageApiContributionGuard` and `PackageRuntimeContributionRegistry`. - **Impact:** Namespace collisions and route/security bypasses are blocked at the API layer, but low-level Doctrine/DBAL misuse inside package handler implementations cannot be fully prevented by endpoint metadata alone. - **Recommendation:** Before public package API release, add a package API handler context or documented safe handler facade that gives packages controlled domain services and makes direct low-level persistence access unnecessary and reviewable. - **Priority:** Before package API release. +- **Area:** Extension API contributions. + **Finding:** Extension endpoint definitions are constrained to `/api/v1/extensions/{extension_slug}/...`, extension handler keys, and extension-scoped OpenAPI tags, but trusted extension PHP handlers can still execute arbitrary application code once registered. + **Evidence:** `ExtensionApiContributionGuard` and `ExtensionRuntimeContributionRegistry`. + **Impact:** Namespace collisions and route/security bypasses are blocked at the API layer, but low-level Doctrine/DBAL misuse inside extension handler implementations cannot be fully prevented by endpoint metadata alone. + **Recommendation:** Before public extension API release, add an extension API handler context or documented safe handler facade that gives extensions controlled domain services and makes direct low-level persistence access unnecessary and reviewable. + **Priority:** Before extension API release. - **Area:** Public OpenAPI metadata. **Finding:** Operation access metadata uses neutral `x-access` and not product-branded technical names. **Evidence:** `OpenApiDocumentFactory` and `OpenApiDocumentFactoryTest`. **Impact:** The API contract follows the project rule that product branding must not become a default technical namespace. - **Recommendation:** Keep future extension fields neutral or owner-scoped by actual package/system owner. + **Recommendation:** Keep future extension fields neutral or owner-scoped by actual extension/system owner. **Priority:** Now. - **Area:** Secret output. **Finding:** API key HMAC/encrypted payload values are not serialized; newly created keys return `plain_key` only once through the owning user's authenticated self-service endpoint. Invalid key prefixes are validated before key material is generated. @@ -168,17 +171,17 @@ The API branch should implement the platform foundation and the first content re - **Decision recorded:** Content API representations should use canonical slug hierarchy as the public resource identity and include language, variant, version, status, visibility context, and field identifiers where relevant, while protecting internal UIDs, internal audit, and ACL details by default. - **Decision recorded:** Safe required schema fields such as `title` and `subtitle` may be exposed for generic previews, menus, and list views when endpoint scope allows it. - **Decision recorded:** Start REST-style with Symfony controllers and Serializer, using established REST conventions for request/response shape, status codes, validation errors, pagination, filtering, and resource URLs. -- **Decision recorded:** The API branch should prepare and implement read/write foundations so later endpoint additions do not require authentication, response, documentation, or package-extension rewrites. +- **Decision recorded:** The API branch should prepare and implement read/write foundations so later endpoint additions do not require authentication, response, documentation, or extension-extension rewrites. - **Decision recorded:** API authentication uses user-owned API tokens. Users can generate, revoke, and reset tokens; token usage is associated with the owning user for ACL and audit behavior. - **Decision recorded:** API tokens are user-owned profile/account settings, not global admin configuration. - **Decision recorded:** API keys are stored as `prefix`, `hmac_hash`, and `encrypted_key`. The prefix supports display/diagnostics, the HMAC supports lookup, and the encrypted payload supports password-gated reveal flows. Use `APP_SECRET` as the first encryption/HMAC root secret. - **Decision recorded:** API key encryption/HMAC behavior should move to the shared `APP_SECRET`-rooted, context-labeled, versioned secret protector once that foundation exists. - **Decision recorded:** API key lifecycle states are `read_write`, `read_only`, and `revoked`. Status labels and simple permission semantics live on the API key status enum so later UI, CLI, and API handlers can reuse the same rules. -- **Decision recorded:** `read_only` is a method gate. Read-only keys may call `GET`, `HEAD`, and `OPTIONS`; mutating methods require `read_write` and the target domain's ACL approval. +- **Decision recorded:** `read_only` is a method gate. Read-only keys may call `GET`, `HEAD`, and safe `OPTIONS`; mutating methods require `read_write` and the target domain's ACL approval. Bearer preflights are evaluated against `Access-Control-Request-Method`, not only the transport-level `OPTIONS` method, so read-only keys cannot preflight-probe write/admin surfaces indefinitely. - **Decision recorded:** Public anonymous reads are endpoint-definition opt-ins through `allow_public`. Missing Bearer credentials and unrelated non-Bearer authorization schemes may continue as anonymous only for these endpoints, while invalid Bearer credentials remain authentication failures. - **Decision recorded:** `/api/v1/**` availability failures are API responses, not setup redirects or generic HTML failures. Incomplete setup, maintenance mode, and Doctrine/DBAL failures return stable Message-layer `503` JSON payloads with `Retry-After`; maintenance bypass is evaluated after Bearer authentication so only admin-level API keys bypass it. - **Decision recorded:** API availability is configuration-controlled through `api.enabled`. When disabled, `/api/v1/**` returns stable Message-layer `503` JSON, while frontend API-key management is hidden for non-owner users. Owners keep key-management access for operational integrations such as scheduler and cron setup. CORS is configured separately through API settings and should remain closed unless explicit origins are configured. -- **Decision recorded:** API CORS is disabled by default and controlled through API settings. When enabled, only configured origins receive CORS response headers or successful preflight responses; wildcard origins must be configured explicitly. +- **Decision recorded:** API CORS is disabled by default and controlled through API settings. When enabled, only configured origins receive CORS response headers or successful anonymous preflight responses; wildcard origins must be configured explicitly. `OPTIONS` requests with an actual `Authorization` header are not short-circuited by CORS and are classified by `Access-Control-Request-Method` for rate-limit accounting, so credentialed preflight probes cannot bypass API write/admin budgets. Bearer remains the only API authentication scheme; unrelated schemes may continue anonymously only on endpoint-defined public reads outside credentialed preflights. - **Decision recorded:** API requests remain part of operational access logging but are separated in anonymized statistics through the `api` surface plus explicit `api_requests` and `page_requests` aggregate counters. Inbound `X-Correlation-ID` and valid `X-Request-ID` headers are logged as external correlation IDs while the system keeps its own generated request ID for internal joins. Versioned `/api/v1/**` responses expose the internal `X-Request-ID` response header for support/debugging and echo a validated inbound correlation header as `X-Correlation-ID` when present; both headers are documented in OpenAPI and exposed to configured CORS browser clients. - **Decision recorded:** The API should not introduce fine-grained token scopes in the first implementation. Role and group scopes come from the owning user, while key status can only reduce write capability. - **Decision recorded:** Revoked API keys remain audit-relevant after their original user account is purged. Deleted-user cleanup reassigns retained revoked keys to a stable system deleted-user account, and future API-audit review of revoked-key usage should trigger a warning mail to the original affected user when an address is still known. @@ -192,21 +195,22 @@ The API branch should implement the platform foundation and the first content re - **Decision recorded:** OpenAPI documents should track the current stable OpenAPI Specification unless client tooling compatibility forces a downgrade. The first generator emits OpenAPI `3.2.0`, uses the root `.manifest` `APP_NAME`, `APP_DESCRIPTION`, and `APP_LICENSE` as stable product/API metadata, exposes `$self`, names the `/api/v1` server, emits native OpenAPI 3.2 tag metadata with hierarchy hints, keeps shell/domain scope visible in tags without adding route hierarchy, and keeps user-defined `site.title` reserved for optional instance metadata instead of API contract branding. - **Decision recorded:** Use `/api/live/**` for internal live JSON endpoints that are fetched by system or public UI JavaScript and should not consume top-level content routes. - **Decision recorded:** `/api/live/**` remains outside the stable external API contract. External integrations should use versioned routes such as `/api/v1/**`; live routes are application-owned internal JSON flows. +- **Decision recorded:** `/api/live/**` remains outside ordinary rate-limit enforcement so operation polling, captcha refreshes, and lightweight UI flows stay cheap and resilient. Route-specific tokens, access checks, TTLs, no-store headers, and passive abuse signals remain available where appropriate. - **Decision recorded:** The global maintenance gate bypasses `/api/**`; `/api/v1/**` applies its own API maintenance gate after Bearer authentication, while `/api/live/**` remains reachable for operation status and continuation flows. - **Decision recorded:** ActionLog polling under `/api/live/operations/**` uses a numeric `cursor` query parameter plus optional `cursor_max` progress metadata and a per-run token instead of route authentication. - **Decision recorded:** Defer `ApiResponseContextEvent` until real API response builders exist; it should extend safe diagnostics or debug context, not bypass serialization, permission, or redaction rules. -- **Decision recorded:** Administrative API resources use `/api/v1/admin/...` and are gated for Admin/Owner access through the normal API-key owner ACL context. Package-provided API resources use `/api/v1/packages/{package_slug}/...` and must be registered through validated endpoint definitions and typed handlers, not arbitrary public controllers. -- **Decision recorded:** Package endpoint validation must block unsafe low-level access patterns that could bypass domain ACLs, including direct public Doctrine/DBAL entity access. Package contributions must stay under `/api/v1/packages/{package_slug}/...`, use `packages.{package_slug}.` handler keys, and expose at least one OpenAPI tag under `packages-{package_slug}-*`. +- **Decision recorded:** Administrative API resources use `/api/v1/admin/...` and are gated for Admin/Owner access through the normal API-key owner ACL context. Extension-provided API resources use `/api/v1/extensions/{extension_slug}/...` and must be registered through validated endpoint definitions and typed handlers, not arbitrary public controllers. +- **Decision recorded:** Extension endpoint validation must block unsafe low-level access patterns that could bypass domain ACLs, including direct public Doctrine/DBAL entity access. Extension contributions must stay under `/api/v1/extensions/{extension_slug}/...`, use `extensions.{extension_slug}.` handler keys, and expose at least one OpenAPI tag under `extensions-{extension_slug}-*`. - **Decision recorded:** Use `page` and `limit` pagination for the first public list endpoints because it is broadly understood by clients and easy to describe in OpenAPI. Add cursor pagination later only where the domain needs feed-like stability or very large traversal. - **Decision recorded:** Public API list contracts use `limit` in query parameters, filters, pagination metadata, and OpenAPI components. Shared backend read models may keep their browser/UI `per_page` terminology internally, but API handlers normalize at the API boundary and do not expose `per_page` or `total_pages` in versioned `/api/v1/**` responses. - **Decision recorded:** Endpoint definitions may carry explicit minimum-access metadata, and the API derives defaults from the stable tag scope conventions: `backend-admin-*` requires Admin, `backend-editor-*` requires Author, `frontend-user-*` requires an authenticated User, explicitly public safe-method endpoints require Public, and other private endpoints require an authenticated User before domain handlers apply fine-grained ACL. OpenAPI operations expose this as neutral `x-access` metadata, navigation entries include the same access block, and `/api/v1/admin/permissions` returns the admin-only endpoint permissions matrix for review and client discovery. - **Decision recorded:** `allow_public` is only valid for safe methods (`GET`, `HEAD`, `OPTIONS`). Public mutating endpoint definitions are rejected during registration instead of relying on later request handling. -- **Decision recorded:** Definition-backed API routes run through the central `api_v1_endpoint_dispatch` controller. Domains provide endpoint definitions and handler services; packages contribute definitions and typed handlers through `PackageContributions` and are constrained to `/api/v1/packages/{package_slug}/...`. -- **Decision recorded:** Parent navigation is implemented as explicit Hypermedia-style resources, not as a global missing-route fallback. `GET /api/v1` lists top-level API branches visible to the current actor, while `GET /api/v1/packages` lists package API child namespaces and visible methods; anonymous callers only see public children, while authenticated callers can see private endpoint definitions and still rely on domain ACL enforcement for execution. -- **Decision recorded:** The first admin endpoints cover the established admin views as navigable baseline resources: `GET /api/v1/admin`, `/api/v1/admin/settings`, `/api/v1/admin/packages`, `/api/v1/admin/themes`, `/api/v1/admin/users`, `/api/v1/admin/users/groups`, `/api/v1/admin/users/reviews`, `/api/v1/admin/scheduler`, `/api/v1/admin/backups`, `/api/v1/admin/operations`, `/api/v1/admin/logs`, and `/api/v1/admin/statistics`. Settings are grouped by section below `/api/v1/admin/settings/{section}`. Logs are grouped by source below `/api/v1/admin/logs/{log}` and reuse the UI filter model. Live operations expose detail reads below `/api/v1/admin/operations/{operation_id}` and review-gated continuations below `/continue`. +- **Decision recorded:** Definition-backed API routes run through the central `api_v1_endpoint_dispatch` controller. Domains provide endpoint definitions and handler services; extensions contribute definitions and typed handlers through `ExtensionContributions` and are constrained to `/api/v1/extensions/{extension_slug}/...`. +- **Decision recorded:** Parent navigation is implemented as explicit Hypermedia-style resources, not as a global missing-route fallback. `GET /api/v1` lists top-level API branches visible to the current actor, while `GET /api/v1/extensions` lists extension API child namespaces and visible methods; anonymous callers only see public children, while authenticated callers can see private endpoint definitions and still rely on domain ACL enforcement for execution. +- **Decision recorded:** The first admin endpoints cover the established admin views as navigable baseline resources: `GET /api/v1/admin`, `/api/v1/admin/settings`, `/api/v1/admin/extensions`, `/api/v1/admin/themes`, `/api/v1/admin/users`, `/api/v1/admin/users/groups`, `/api/v1/admin/users/reviews`, `/api/v1/admin/scheduler`, `/api/v1/admin/backups`, `/api/v1/admin/operations`, `/api/v1/admin/logs`, and `/api/v1/admin/statistics`. Settings are grouped by section below `/api/v1/admin/settings/{section}`. Logs are grouped by source below `/api/v1/admin/logs/{log}` and reuse the UI filter model. Live operations expose detail reads below `/api/v1/admin/operations/{operation_id}` and review-gated continuations below `/continue`. - **Decision recorded:** User and ACL-group management uses speaking public identities. API collection resources keep short parent paths, while dynamic children are separated under `items/` to avoid collisions with action or sub-resource names, for example `/api/v1/admin/users/items/{username}` and `/api/v1/admin/users/groups/items/{group_identifier}`. User routes use unique usernames, ACL group routes use the unique group `identifier` as the group slug, and list resources expose those values as `id` plus `self` links. Internal UUIDs are retained for persistence, audit, and LiveOperation payloads, but are only shown in user/group detail resources and backend detail views. Backend UI routes use the more readable `details/` separator for the same collision avoidance. -- **Decision recorded:** Package lifecycle API actions are the first admin write baseline. Admin package detail, lifecycle paths, package contributions, package settings, and package registry state address packages by the canonical hyphen-only `PACKAGE_SLUG`; `PACKAGE_NAME` is display metadata, not an identifier. The package validator requires the filesystem package directory basename to match `PACKAGE_SLUG`, so `/api/v1/admin/packages/{package_slug}` stays collision-free without an additional API identifier. `POST /api/v1/admin/packages/{package_slug}/{activate|deactivate|reset-fault|delete|purge}` returns the existing domain review plan until `?confirm=true` is present, then starts the existing package lifecycle LiveOperation and returns `202` with an operation-start resource containing status and continuation links. Settings writes use the existing settings form handler through `PATCH /api/v1/admin/settings/{section}` so validation and persistence stay domain-owned. User administration supports one role plus multiple ACL groups through user detail patches and membership endpoints, while ACL group create/edit/delete and user review actions use explicit review/confirmation resources for security-sensitive changes. User review queues expose registration/invitation token action links below `/api/v1/admin/users/reviews/tokens/{token_uid}` and disputed-account actions below `/api/v1/admin/users/reviews/items/{username}`; execution requires `?confirm=true`. Scheduler task detail, scheduler run-now, and live-operation maintenance actions are exposed through admin API endpoints; destructive or potentially surprising operation maintenance requires `?confirm=true`. Backup execution remains deferred until the backup layer exposes a safe command boundary. -- **Decision recorded:** `GET /api/v1/packages` is a package API navigation endpoint, not an admin management endpoint, and OpenAPI tags must keep package-contribution docs separate from administrative package management docs. Tags use stable shell/domain prefixes without changing route hierarchy: `backend-admin-*` for backend administration views, `backend-editor-*` for backend editor views such as schemas, `frontend-user-*` for profile and self-service resources, `frontend-content-*` for content resources, `packages-{package_slug}-*` for future package-contributed endpoints, `packages-navigation` for the package namespace index, `system-status` for the status/healthcheck endpoint, and `system-*` for shell-independent system resources. The same separation applies to user-facing self-service/profile resources (`frontend-user-*`) versus administrative user resources (`backend-admin-users`): `/api/v1/user` exposes the authenticated user's profile read/update shape plus own API-key list/create/revoke resources, while `/api/v1/admin/users` remains the administrative user, ACL group, and review surface. `GET /api/v1/content` and `GET /api/v1/content/items` provide content API navigation and ACL-aware published public content metadata through the shared content read access policy; anonymous callers see only anonymous-visible content, authenticated API callers may see content allowed by the existing view ACL rules, view role/group rules keep their role-or-group semantics, and explicit content ACL restrictions act as an additional group gate. Draft/editor state remains deferred to the Editor/Content feature slices. `GET /api/v1/schemas` provides author-level active schema metadata, including `custom_twig`, without exposing internal UIDs. +- **Decision recorded:** Extension lifecycle API actions are the first admin write baseline. Admin extension detail, lifecycle paths, extension contributions, extension settings, and extension registry state address extensions by the canonical hyphen-only `EXTENSION_SLUG`; `EXTENSION_NAME` is display metadata, not an identifier. The extension validator requires the filesystem extension directory basename to match `EXTENSION_SLUG`, so `/api/v1/admin/extensions/{extension_slug}` stays collision-free without an additional API identifier. `POST /api/v1/admin/extensions/{extension_slug}/{activate|deactivate|reset-fault|delete|purge}` returns the existing domain review plan until `?confirm=true` is present, then starts the existing extension lifecycle LiveOperation and returns `202` with an operation-start resource containing status and continuation links. Settings writes use the existing settings form handler through `PATCH /api/v1/admin/settings/{section}` so validation and persistence stay domain-owned. User administration supports one role plus multiple ACL groups through user detail patches and membership endpoints, while ACL group create/edit/delete and user review actions use explicit review/confirmation resources for security-sensitive changes. User review queues expose registration/invitation token action links below `/api/v1/admin/users/reviews/tokens/{token_uid}` and disputed-account actions below `/api/v1/admin/users/reviews/items/{username}`; execution requires `?confirm=true`. Scheduler task detail, scheduler run-now, and live-operation maintenance actions are exposed through admin API endpoints; destructive or potentially surprising operation maintenance requires `?confirm=true`. Backup execution remains deferred until the backup layer exposes a safe command boundary. +- **Decision recorded:** `GET /api/v1/extensions` is an extension API navigation endpoint, not an admin management endpoint, and OpenAPI tags must keep extension-contribution docs separate from administrative extension management docs. Tags use stable shell/domain prefixes without changing route hierarchy: `backend-admin-*` for backend administration views, `backend-editor-*` for backend editor views such as schemas, `frontend-user-*` for profile and self-service resources, `frontend-content-*` for content resources, `extensions-{extension_slug}-*` for future extension-contributed endpoints, `extensions-navigation` for the extension namespace index, `system-status` for the status/healthcheck endpoint, and `system-*` for shell-independent system resources. The same separation applies to user-facing self-service/profile resources (`frontend-user-*`) versus administrative user resources (`backend-admin-users`): `/api/v1/user` exposes the authenticated user's profile read/update shape plus own API-key list/create/revoke resources, while `/api/v1/admin/users` remains the administrative user, ACL group, and review surface. `GET /api/v1/content` and `GET /api/v1/content/items` provide content API navigation and ACL-aware published public content metadata through the shared content read access policy; anonymous callers see only anonymous-visible content, authenticated API callers may see content allowed by the existing view ACL rules, view role/group rules keep their role-or-group semantics, and explicit content ACL restrictions act as an additional group gate. Draft/editor state remains deferred to the Editor/Content feature slices. `GET /api/v1/schemas` provides author-level active schema metadata, including `custom_twig`, without exposing internal UIDs. - **Decision recorded:** Content item detail paths use deterministic collection separators instead of exposing internal UIDs. A child is addressed as `/api/v1/content/items/{slug}/items/{child_slug}`, variants as `/variants/{variant}`, child navigation as `/items`, revision navigation as `/revisions`, and variant/revision collections as `/variants` and `/revisions`. Revision collection/detail endpoints are not public content delivery; they require an editor-level API actor (`AccessLevel::PUBLISHER`/level 4) and still pass through the existing content resolver before exposing revision metadata. `?language=` is the explicit language selector; when omitted the API passes the client's preferred language into the existing content language fallback chain. Version-specific reads through `?version=` are registered but return `501` until the Editor/Content slice finalizes revision selection. - **Decision recorded:** Published content item collection reads reserve query parameters for `language`, `variant`, `status=published`, `schema`, `parent`, `page`, `limit`, and `sort`. The current `/api/v1/content/items` tree remains a published-content delivery surface so every returned `self`, child, variant, and revision link can be resolved by the published content resolver. Draft, scheduled, deleted, and `all` status lists must move to a future editor-visible read surface that can serve those linked resources consistently. - **Decision recorded:** Content write operations are prepared as command endpoints and intentionally return stable `501` stubs until domain services implement schema validation, revision creation, state transitions, and audit. The reserved commands are `POST /api/v1/content/items/create`, `POST /api/v1/content/items/{item_path}/items/create`, `POST /api/v1/content/items/{item_path}/edit`, `POST /api/v1/content/items/{item_path}/{validate|diff}`, `POST /api/v1/content/items/{item_path}/delete`, `POST /api/v1/content/items/{item_path}/{publish|unpublish}`, `POST /api/v1/content/items/{item_path}/revisions/{revision}/{publish|unpublish}`, and variant commands below `/variants/{variant}/{create|edit|delete}`. diff --git a/dev/draft/0.4.x-BackupRestore.md b/dev/draft/0.4.x-BackupRestore.md index d7740474..57819870 100644 --- a/dev/draft/0.4.x-BackupRestore.md +++ b/dev/draft/0.4.x-BackupRestore.md @@ -16,7 +16,7 @@ Backup/restore should protect project data and support development/staging cloni The first backup workflow should be conservative: explicit commands, dry-run or validation where useful, confirmation before restore, clear target environment checks, and useful logs. Restores should protect against accidental production data overwrite and should verify compatibility before applying data. -Database cloning for development and staging should be supported in a controlled way. It should handle database data, uploaded files, generated assets where needed, manifests, and package compatibility metadata. +Database cloning for development and staging should be supported in a controlled way. It should handle database data, uploaded files, generated assets where needed, manifests, and extension compatibility metadata. ## Technical Specifications - Provide Symfony Console commands for backup, restore, and clone workflows. @@ -33,7 +33,7 @@ Database cloning for development and staging should be supported in a controlled - Provide a basic restore dry-run diff, for example counts of new, removed, and changed entities where practical. - Log backup and restore operations with actor, source, target, size/summary, and result. - Use Messenger for long-running backup/restore jobs only after job state tracking exists. -- Ensure package-owned tables are included when packages are enabled. +- Ensure extension-owned tables are included when extensions are enabled. - Keep secrets out of portable backup metadata unless explicitly handled by a secure environment-specific process. ## Testing & Validation @@ -43,7 +43,7 @@ Database cloning for development and staging should be supported in a controlled - Test dry-run output for restore. - Test restore dry-run reports basic counts for new, removed, and changed entities where practical. - Test fixture backup and restore in an isolated test environment. -- Test package-owned tables are included when package fixtures exist. +- Test extension-owned tables are included when extension fixtures exist. - Test failed restore behavior and logs. ## Implementation Notes diff --git a/dev/draft/0.4.x-ContactMailLogging.md b/dev/draft/0.4.x-ContactMailLogging.md index 6b9a0ef9..dd90c308 100644 --- a/dev/draft/0.4.x-ContactMailLogging.md +++ b/dev/draft/0.4.x-ContactMailLogging.md @@ -1,7 +1,7 @@ # Contact, mail, and logging (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-25 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for contact forms, mail delivery, logging, statistics, and GeoIP-aware operational insights. @@ -10,17 +10,18 @@ - Covers logging, statistics, GeoIP enrichment, privacy considerations, and operational visibility. - Depends on error handling, security, and configuration decisions. - Provides practical public interaction and operational insight without turning analytics into a surveillance feature. +- Uses the Security policy defaults for IP-derived data retention, security event projection posture, and abuse-correlation privacy ceilings. ## Outline The CMS should provide a reliable contact form and mail delivery foundation for project websites. Contact workflows should be protected by validation, CSRF where applicable, rate limiting, optional captcha, and safe error messages that do not leak delivery internals. -Logging and statistics should help operators understand system behavior, failed workflows, content activity, and abuse patterns. GeoIP enrichment can be useful, but it must be treated as optional operational metadata with privacy and retention controls. If GeoIP is not configured, logs and statistics should continue without hard errors and should use `N/A` or an equivalent empty value for location fields. +Logging and statistics should help operators understand system behavior, failed workflows, content activity, and abuse patterns. GeoIP enrichment can be useful, but it must be treated as optional operational metadata with privacy and retention controls. If GeoIP is not configured, logs and statistics should continue without hard errors and should use `N/A` or an equivalent empty value for location fields. GeoIP belongs in the security hardening plan as an observability feature before it is ever used for enforcement. The first implementation should prefer Symfony Mailer, Monolog, Rate Limiter, Validator, and explicit storage decisions. Module-provided integrations can add newsletter systems, CRM handoff, or alternative logging sinks later. Statistics may evaluate rotated logs over longer time ranges, but rotated logs should anonymize IP addresses while preserving acceptable GeoIP-derived location data. Security logging should distinguish normal operational events from high-signal abuse patterns. Known probe paths, repeated 404s, repeated validation failures, and repeated rate-limit hits may feed suspicious-event statistics, but they should not automatically become permanent block rules without a separate security decision. The logging layer should provide insight; the security layer decides whether to throttle, challenge, temporarily block, or hard-block. -Audit logging should start with a simple proposed event set and remain easy to adjust. The first audit-worthy events should include global configuration changes, protected configuration changes, provider selection changes, package activation/deactivation/update/uninstall, schema changes, import apply, backup restore, user/ACL changes, API token changes, and high-impact maintenance actions. Whether this lives in the general logger or a separate admin audit log can stay open until log retention and log categories are implemented. +Audit logging should start with a simple proposed event set and remain easy to adjust. The first audit-worthy events should include global configuration changes, protected configuration changes, provider selection changes, extension activation/deactivation/update/uninstall, schema changes, import apply, backup restore, user/ACL changes, API token changes, and high-impact maintenance actions. Whether this lives in the general logger or a separate admin audit log can stay open until log retention and log categories are implemented. ## Technical Specifications - Use Symfony Mailer for outbound mail. @@ -30,24 +31,29 @@ Audit logging should start with a simple proposed event set and remain easy to a - Use translated user-facing messages. - Do not expose mail transport errors directly to users. - Log delivery failures with safe context. -- Prefer filesystem-backed structured logs over database-backed operational logs for access, error, and security events. +- Keep filesystem-backed structured logs as the durable raw operational fallback for access, message, and audit events. +- Write database-backed lookup projections for message, audit, and access logs in parallel to the file channels so Admin UI, Admin API, and later Security features can query logs without scanning rotated files. +- Store passive abuse/security signals in `security_signal_event` as a separate domain event stream with policy-bounded retention. - Use stable structured log records that can be emitted as JSONL or converted to JSONL for UI filtering, scripted analysis, and retention jobs. - Start with separate channels for `access`, `error`, and `security`; keep anonymized statistics output separate from raw request-derived logs. - Define a mail template structure that themes may style but not silently break. - Keep statistics events additive and observable. - Use GeoIP through a replaceable adapter or optional service boundary. -- Use the installed MaxMind/GeoIP2 package as the first GeoIP provider implementation. +- Use the installed MaxMind/GeoIP2 extension as the first GeoIP provider implementation. - Treat GeoIP as a provider-backed service with local database storage, scheduled update support, and clear failure handling. +- Use GeoIP for logs, statistics, diagnostics, and later security review only. Geo-blocking or country-based deny policies require a separate explicit security decision. - Configure the MaxMind API key through a protected admin configuration UI and store it in database-backed configuration with access restricted to authorized administrators. - If no MaxMind API key is configured, disable GeoIP lookup, GeoIP database updates, and Geo-blocking without throwing hard errors. - Use `N/A`, `null`, or an equivalent normalized empty value for GeoIP log/statistic fields when GeoIP is disabled or unavailable. - Never write the MaxMind API key to logs, exported diagnostics, screenshots, fixtures, or public configuration. - Define retention controls before storing request-derived statistics. - Keep raw access logs with retraceable IP addresses for at most 30 days by default. +- Keep raw IP addresses, IP buckets, and stable IP-derived hashes queryable for at most 30 days across file logs, database projections, exports, diagnostics, and backup/restore workflows. - Anonymize IP addresses during rotation when logs are retained beyond the raw retention window, or write a parallel anonymized statistics log from the start. - Use log rotation for longer-running statistics. Rotated logs should anonymize IP addresses while retaining non-sensitive aggregate or GeoIP location data where allowed. +- Use the internal first-party visitor ID for longer-term statistics and correlation instead of extending IP retention. - Keep access/security event logs separate from aggregated statistics where practical. -- Use a separate `audit` channel for high-impact administrator actions, starting with authentication events, backend maintenance/package lifecycle events, and settings saves that log changed keys but not submitted values. Keep audit logging configurable through Security settings with an enabled production default and selectable event categories. +- Use a separate `audit` channel for high-impact administrator actions, starting with authentication events, backend maintenance/extension lifecycle events, and settings saves that log changed keys but not submitted values. Keep audit logging configurable through Security settings with an enabled production default and selectable event categories. - Draft first audit-worthy events: global configuration changes, protected configuration changes, provider selection changes, theme lifecycle actions, module lifecycle actions, schema changes, import apply, backup restore, user/ACL changes, API token changes, and high-impact maintenance actions. - Keep log level, retention, rotation, and category/channel rules configurable and easy to change. - Let the future operational logger consume structured message or operation entries through an explicit recorder/service boundary before adding generic message events. @@ -64,6 +70,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Test mail dispatch with Symfony test transports. - Test logs do not include secrets or full sensitive payloads. - Test rotated logs anonymize IP addresses while preserving allowed aggregate/GeoIP data. +- Test raw IP, IP bucket, and stable IP-derived hash retention does not exceed 30 days in file logs, database projections, exports, diagnostics, and cleanup jobs. - Test GeoIP adapter behavior with missing or invalid data. - Test missing MaxMind API key disables GeoIP lookup and Geo-blocking without hard errors. - Test GeoIP-disabled logs use normalized empty location values such as `N/A`. @@ -71,6 +78,7 @@ Audit logging should start with a simple proposed event set and remain easy to a - Test GeoIP update failure handling once scheduled updates are implemented. - Test suspicious-event aggregation for probe paths, repeated 404s, and rate-limit hits. - Test access/security logs remain separable from anonymized aggregate statistics. +- Test any future database-backed security event projection against the same redaction, retention, and disabled-feature fallback rules as the file logs. - Test audit-worthy events are emitted with actor, action, target, timestamp, and safe redaction where implemented. - Run translation comparison after user-facing copy changes. @@ -78,19 +86,22 @@ Audit logging should start with a simple proposed event set and remain easy to a - **Decision recorded:** Implement contact forms with Symfony Forms, Validator, Mailer, Rate Limiter, and optional captcha. - **Decision recorded:** Contact form storage is configurable: mail-only by default, with optional stored submissions. - **Decision recorded:** Treat GeoIP as optional and replaceable, with MaxMind/GeoIP2 as the first provider implementation because it is already installed. +- **Decision recorded:** GeoIP should be completed as an observability slice before any abuse-enforcement or blocking behavior depends on it. - **Decision recorded:** GeoIP should use a provider boundary with local database storage, scheduled update support, and safe failure behavior. - **Decision recorded:** The initial GeoIP boundary is `GeoIpResolverInterface` with a `NullGeoIpResolver` that returns normalized `n/a` fields. Access logging and statistics depend on the interface now, while MaxMind/local database lookup remains a later provider implementation. - **Decision recorded:** The MaxMind API key is configured through protected admin configuration and stored in database-backed configuration with restricted access. - **Decision recorded:** Missing MaxMind API key disables GeoIP lookup, GeoIP database updates, and Geo-blocking gracefully. Logs should continue with normalized empty GeoIP values such as `N/A`. - **Decision recorded:** Keep statistics operational and privacy-conscious. -- **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow packages to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. -- **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context for UI filtering. -- **Implemented baseline:** Admin Logs browsing is split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser` so future export/API/support tooling can reuse smaller pieces. +- **Decision recorded:** Core statistics and access logging may use only first-party technical cookies. The `system_visitor` cookie identifies a browser/device visitor for internal statistics and future security buckets, uses a 30-day lifetime aligned with raw access-log retention, is not a cross-site cookie, and is not available for advertising or external analytics modules. If the cookie is missing or disabled, statistics use an `APP_SECRET`-derived IP/user-agent fallback ID instead of creating a new unique visitor on every request; normalized forwarding-header candidates may be mixed into that fallback only as untrusted differentiation entropy and never as Security identity, GeoIP input, ban key, or signal evidence. Fresh responses still receive random signed visitor-cookie tokens so cookie-capable clients get real per-browser/device uniqueness after the cookie roundtrip. The short-lived visitor identity store keeps cookie hashes and IP/user-agent/forwarding-entropy fallback hashes separate so a newly issued cookie can bridge the first request without making later same-IP/same-browser cookies share one persistent visitor. A future consent interface can allow extensions to register their own cookie policies for advertising or external analytics without weakening the core technical-cookie boundary. +- **Decision recorded:** Prefer filesystem-backed Monolog channels with stable structured context as the raw fallback, and use database-backed lookup projections for Admin filtering and query-heavy Security review. +- **Implemented baseline:** Admin Logs browsing was first split into source discovery, reverse log-line reading, filter matching, entry presentation, and pagination collaborators behind `LogFileBrowser`; the Security Abuse Foundation moves structured message, audit, access, and security-signal browsing to `DatabaseLogBrowser`, then exposes them through `AdminLogBrowser` alongside the Symfony application log as a file-backed Admin/API source with 5000-line reverse tailing and stable synthetic detail IDs. - **Decision recorded:** Separate message, audit, and access file channels; live-operation terminal summaries use the message channel with operation-specific message keys, and raw request-derived logs stay separate from anonymized statistics output. Log files use `var/log/{APP_ENV}/{message|audit|access}-{rotation_date}.log` so filenames stay descriptive without product or system owner prefixes. - **Decision recorded:** Access statistics run as a separate database-backed model in parallel to raw `access` files. Each request may write a short-lived raw access-log entry plus one `access_statistic_event` row with an internally generated request id, first-party cookie-derived anonymized visitor id, method, requested path, resolved route, surface, status, duration, referrer host, preferred language, content metadata, coarse browser family, device type, bot classification, and GeoIP placeholder fields. Raw access logs keep the full user-agent, IP/proxy hints, and an optional safe inbound `correlation_id` for operational review, but IP addresses, user-agents, inbound correlation ids, and raw visitor-cookie tokens are not stored in the statistics table. `X-Request-ID` and `X-Correlation-ID` are never reused as the internal request id. Request id, visitor id, requested path, and resolved route are exposed to Twig error pages as a visitor-shareable debug reference. The latest Admin Logs snapshot is still written below `var/statistics/{environment}/access/latest.json` as a cache/output boundary so a later scheduler or richer statistics module can replace the producer without changing the Admin Logs UI. - **Decision recorded:** The first Admin Logs statistics view supports fixed windows (`1h`, `24h`, `7d`, `30d`, `all`) over stored anonymized statistic events. Long-term compaction into daily/monthly aggregates remains deferred until the statistics feature grows beyond this foundation slice. - **Decision recorded:** Use log rotation for longer statistics windows. Rotated logs should anonymize IP addresses while retaining allowed aggregate and GeoIP-derived location data. - **Decision recorded:** Suspicious events such as probe paths, repeated 404s, rate-limit hits, and repeated failed submissions should feed diagnostics, not automatically become permanent block rules. -- **Decision recorded:** First audit-worthy events should include configuration, protected setting, provider, package lifecycle, schema, import, backup restore, user/ACL, API token, and high-impact maintenance changes. The first implemented events are login success, login failure, logout, backend maintenance action starts/completions, package ZIP verification starts, and package lifecycle action starts/completions. +- **Decision recorded:** First audit-worthy events should include configuration, protected setting, provider, extension lifecycle, schema, import, backup restore, user/ACL, API token, and high-impact maintenance changes. The first implemented events are login success, login failure, logout, backend maintenance action starts/completions, extension ZIP verification starts, and extension lifecycle action starts/completions. - **Decision recorded:** Defer a generic operations-message event until after the logger is implemented; start with explicit recorder/service boundaries and keep audit/access/security logs separate from the live ActionLog overlay. - **Decision recorded:** Built-in raw log channels use 30-day rotating files by default. Long-term statistics stay separate and may move to database-backed aggregates. +- **Decision recorded:** Security features write parallel database-backed lookup projections in addition to the 30-day rotating file logs. File logs remain the durable raw operational fallback because they are simple, inspectable, and safer during database degradation. The DB projection is the primary Admin/API read path, duplicates only redacted/minimized fields instead of full raw log lines, keeps level/severity only where it supports meaningful filtering, purges by bounded retention after successful writes, uses UUID detail identifiers, and degrades without weakening enforcement or hiding file-log diagnostics. +- **Decision recorded:** IP addresses, IP buckets, and stable IP-derived hashes are short-retention security data and must remain queryable for at most 30 days. Longer-term statistics and security correlation should use the internal first-party visitor ID, authenticated user IDs where applicable, API key fingerprints where applicable, or aggregated anonymized dimensions. Backups, exports, diagnostics bundles, and database projections must not silently extend IP retention beyond the raw-log window. diff --git a/dev/draft/0.4.x-FrontendDeliveryCaching.md b/dev/draft/0.4.x-FrontendDeliveryCaching.md index 24ed1741..1c91b7df 100644 --- a/dev/draft/0.4.x-FrontendDeliveryCaching.md +++ b/dev/draft/0.4.x-FrontendDeliveryCaching.md @@ -1,7 +1,7 @@ # Frontend delivery and caching (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-18 > **Owner**: Core > **Purpose:** Draft for public content delivery, snapshot/cache boundaries, HTTP caching, invalidation, and performance-oriented rendering. @@ -18,18 +18,24 @@ The first implementation does not need a complex static-site generator, but it s Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, filesystem cache by default, AssetMapper hashed assets, and optional adapters later. Admin tools should show enough cache and delivery status that operators can recover from stale output without shell access. +The Security rate-limit `panic` profile is also intended as the future switch point for an operational cache panic mode. During DDoS-like or extreme anonymous traffic events, the delivery layer may set a short-lived TTL lock that forces anonymous public traffic onto cache-backed delivery only, while the Security layer applies the strictest rate limits. This mode must protect public read delivery without bypassing authentication, ACL checks, CSRF, probe blocking, audit, or Owner/Admin recovery paths. + ## Technical Specifications - Define public delivery services separate from editor/admin write services. - Use content route resolver output, theme resolver output, schema rendering, media URLs, and resolver context through a controlled read path. +- Keep future locale-prefix path rewriting limited to safe public navigation links. Delivery may rewrite generated read-only `GET` anchors to active locale-prefixed public paths. For generated internal targets that do not support locale prefixes, delivery may instead append or merge an explicit `language` query parameter, including for mutating form actions, API routes, live endpoints, cron/scheduler endpoints, admin/editor mutators, uploads, downloads, and login/logout targets. The query parameter is a language preference, not a routing fallback; existing query parameters must be preserved, and opaque external or signature-protected URLs must only receive it when the generating component owns the full URL contract. +- Treat path classification as separate from routing. `RequestPathResolver` may classify locale-prefixed paths for enforcement and request intent, but it must not be treated as a Symfony route fallback. Localized unsafe routes need explicit route definitions, route generation rules, and focused regression tests. - Provide cache namespaces for public content rendering, navigation/menu data, resolver outputs, schema rendering metadata, media metadata, and API read output where useful. - Use HTTP caching headers such as `ETag`, `Last-Modified`, and `Cache-Control` where safe. - Use AssetMapper hashed asset URLs for long-lived frontend assets. -- Invalidate or rebuild affected cache entries after publish, archive, restore, schema activation, active package change, media change, import apply, or package lifecycle changes. +- Invalidate or rebuild affected cache entries after publish, archive, restore, schema activation, active extension change, media change, import apply, or extension lifecycle changes. - Provide CLI commands for delivery/cache rebuild, warmup, prune, and diagnostics. - Provide admin maintenance UI for cache clear/warmup and delivery status once operational UI exists. - Keep snapshot-like export/read artifacts optional and adapter-backed until a concrete performance need requires them. - Include public delivery behavior in backup/restore and self-update compatibility checks when generated artifacts or metadata are involved. - Keep private or ACL-restricted content out of public cache entries unless the cache key and delivery path are permission-aware. +- Design a cache panic mode that can be activated manually or by a later abuse/operations signal through a bounded TTL lock. While active, anonymous public requests should be served from already-safe public cache entries where possible, cache misses should fail predictably or use an explicitly allowed lightweight fallback, and authenticated/Admin/Owner paths must keep their normal security and recovery behavior. +- Coordinate cache panic mode with the Security `panic` rate-limit profile instead of creating a separate public knob with conflicting semantics. ## Testing & Validation - Test public route rendering uses published content only. @@ -38,11 +44,17 @@ Caching should stay Symfony-native first: HTTP cache headers, `cache.app`, files - Test ACL-restricted content is not leaked through public caches. - Test rebuild/warmup/prune commands. - Test admin maintenance actions for cache and delivery diagnostics once UI exists. +- Test cache panic mode activation, TTL expiry, anonymous cache-hit behavior, cache-miss fallback behavior, and authenticated/Admin/Owner bypass boundaries once implemented. +- Test any future locale-aware delivery rewrite with method-aware route coverage: public `GET` anchors may be localized through a locale prefix, generated internal targets without prefix support may receive a `language` query parameter, and unsafe methods, login-check/logout POST flows, API/live endpoints, admin/editor mutators, and unlocalized routes must not receive path rewrites unless explicitly routed. - Run asset build commands after frontend delivery asset changes. ## Implementation Notes - **Decision recorded:** Public delivery should use a controlled read path separate from mutable editor workflows. - **Decision recorded:** Symfony-native cache and HTTP headers are the first delivery foundation; external cache/CDN integrations remain optional. - **Decision recorded:** Publish and lifecycle events must define cache invalidation or rebuild behavior. +- **Decision recorded:** The Security `panic` profile is the intended coordination point for a future cache panic mode: a bounded TTL lock may force anonymous public traffic to cache-backed delivery during DDoS-like events while strict rate limits remain active. +- **Decision recorded:** Locale-prefix path rewriting must be method-aware and safe-navigation-only, while `language` query propagation is method-agnostic for generated internal targets that do not support locale prefixes. The recovery login render route stays `/user/login?bypass=1`; future frontend delivery must not infer localized POST or recovery aliases from `RequestPathResolver` classification. +- **Open:** Re-evaluate small feature-local Symfony cache uses, including the Abuse Foundation suspicious-probe pattern cache and Admin ACL feature/override/group matrix caches, when the unified caching strategy is implemented. Move them to the shared cache namespace/invalidation model if that produces clearer ownership, diagnostics, or operational controls. - **Open:** Decide whether the first release needs persisted snapshot artifacts or only cache-backed read models. - **Open:** Define default cache TTLs and invalidation namespaces after the first public rendering slice exists. +- **Open:** Define cache panic trigger sources, TTL bounds, lock storage, anonymous cache-miss behavior, operator diagnostics, and the exact coupling to the Security rate-limit mode before implementation. diff --git a/dev/draft/0.4.x-IconCaptcha.md b/dev/draft/0.4.x-IconCaptcha.md index 8cc59c27..76fc377d 100644 --- a/dev/draft/0.4.x-IconCaptcha.md +++ b/dev/draft/0.4.x-IconCaptcha.md @@ -1,13 +1,14 @@ # IconCaptcha integration (Feature Draft) > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Draft for an optional IconCaptcha provider module built on the generic captcha extension contract. ## Overview - Defines the planned IconCaptcha implementation as a replaceable provider module. - Depends on security, setup, error handling, public form workflows, and plugin module extensibility. +- Belongs to the broader security hardening cut, but should be implemented in its own provider branch after the generic captcha contract lands. - Keeps the CMS core focused on a generic captcha contract while allowing IconCaptcha to ship its own services, templates, assets, translations, and validation rules. - Uses the old Grav `sec-lookup` implementation only as inspiration. No old code, secrets, or hard-coded asset paths should be copied into the Symfony project. @@ -18,19 +19,26 @@ The first implementation should avoid coupling public forms directly to IconCapt The core captcha integration should expose a global form field. When a workflow includes that field, the field resolves the configured provider and delegates rendering and validation to it. If the configured provider is `none`, or no captcha provider module is available, the field should validate successfully to avoid breaking public workflows. -The provider module should own its icon and emoji assets. These assets should live inside the module package rather than in the application core so IconCaptcha remains removable and replaceable. The core should only know that the selected provider can render a challenge, validate a submitted answer, and report recoverable validation failures. +The provider module should own its icon and emoji assets. These assets should live inside the module extension rather than in the application core so IconCaptcha remains removable and replaceable. The core should only know that the selected provider can render a challenge, validate a submitted answer, and report recoverable validation failures. + +The asset set must be chosen deliberately before implementation. Prefer permissive open-source-compatible licenses such as MIT, Apache-2.0, CC0, or similarly permissive terms, and record provenance and license notes with the provider extension. Unclear asset provenance blocks the provider branch until the asset is replaced or the license is confirmed. + +Inline-rendered graphics and symbol SVGs must be bot-resistant. Do not expose answer-bearing names through file names, SVG IDs, CSS classes, `data-*` attributes, translation keys, titles, descriptions, hidden text, or ARIA labels. Use opaque challenge-local identifiers for rendered options and neutral control labels that describe interaction state without naming the target symbol. If neutral labels are not sufficient for assistive technology, implement a separate accessible fallback flow instead of leaking the answer through labels. + +The preferred accessible fallback is a quiz-style IconCaptcha mode. Instead of naming the visual icon choices through ARIA, the provider can render a spoken question/task and multiple neutral answer options. The submitted answer is then validated through the same provider challenge model as the visual captcha: one challenge ID, bounded configured TTL, one-shot invalidation, context binding, refresh behavior, and passive abuse signals. Quiz prompts and answer options must come from vetted provider-owned pools, avoid stable answer-bearing DOM metadata, and use opaque option IDs just like visual icon choices. The challenge should be deterministic from server-side state, but unpredictable to the client. A challenge ID, timestamp, request context, and application/module secret can derive the icon pool, target icon, and button order. The server can then recompute the expected answer during validation without persisting the whole challenge payload. The challenge ID must still be marked as used after validation to prevent replay. The frontend should keep the interaction lightweight and accessible: one target icon, a fixed button grid, hidden form fields for challenge metadata, a refresh control, and clear error feedback from the normal form validation layer. A two-slot challenge approach is preferred for smooth refresh behavior: switch immediately to a standby challenge, then refill the hidden slot asynchronously. -IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, route-specific rate limits, form validation, suspicious-request tracking, and optional GeoIP/security policy all remain separate signals. Captcha success should not bypass ACLs, CSRF, or business validation. +IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, route-specific rate limits, form validation, suspicious-request tracking, and optional GeoIP/security policy all remain separate signals. Captcha success should not bypass ACLs, CSRF, or business validation. Rate-limit resets, budget recovery, or captcha-based `429` recovery require verified success from an active provider-backed challenge; provider `none`, missing-provider, or disabled-provider auto-success must not be treated as human proof. ## Technical Specifications -- Implement IconCaptcha as a first-party package with `captcha-provider` scope, for example `packages/icon-captcha/`. -- Store provider assets in the module package, for example `assets/icons/` and `assets/emoji/`. -- Use package-owned templates, styles, JavaScript, translations, and PHP services. -- Keep the package compatible with the module manifest rules from the plugin modules draft. +- Implement IconCaptcha as a first-party extension with `captcha-provider` scope, for example `extensions/icon-captcha/`. +- Store provider assets in the module extension, for example `assets/icons/` and `assets/emoji/`. +- Prefer MIT, Apache-2.0, CC0, or similarly permissive asset licenses, and commit extension-local provenance/license notes for every bundled icon or graphic set. +- Use extension-owned templates, styles, JavaScript, translations, and PHP services. +- Keep the extension compatible with the module manifest rules from the plugin modules draft. - Register IconCaptcha through the generic captcha provider contract, for example a tagged provider service. - Keep provider selection configurable per workflow through core-owned configuration. - Allow workflows to select `none`, `icon_captcha`, or a future module-provided provider where appropriate. @@ -46,20 +54,23 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, - Bind challenge derivation to server-side secret, challenge ID, timestamp, session identifier where available, user agent, and route or workflow key. - Keep the public challenge payload limited to data needed for rendering and validation submission. - Mark challenge IDs as one-shot values in a Symfony cache pool after validation, regardless of success or failure. -- Expire challenges after a short configurable TTL. +- Expire challenges after the bounded TTL defined in the Security policy defaults. - Use cache pools or another Symfony-native storage mechanism for used challenge IDs and short-lived challenge metadata. - Sanitize SVG assets before rendering inline SVG in buttons, or serve them through a trusted static asset path with a fixed allowlist. -- Keep icon identifiers stable and non-sensitive. +- Keep internal icon identifiers stable and non-sensitive, but never render answer-bearing names into public DOM, asset URLs, CSS classes, JavaScript state, translation keys, or ARIA labels. +- Generate challenge-local opaque option IDs so a bot cannot infer the answer from semantic asset names or DOM metadata. - Use fixed-size UI elements so the challenge does not shift the form layout. -- Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and non-visual labels through translations where needed. +- Use accessible controls: real buttons, `aria-pressed` or equivalent state, keyboard navigation, and neutral non-visual labels through translations where needed. Labels must not name the target symbol or correct answer. +- Provide a documented accessible quiz fallback if neutral labels make the visual challenge unsuitable for assistive technology users. +- Keep quiz questions, answer options, and prompt identifiers provider-owned and reviewable, but do not render stable internal prompt keys or correct-answer identifiers into public markup. - Provide a refresh endpoint or provider action that returns a fresh challenge without caching. -- Protect refresh endpoints from aggressive abuse with a route-specific limiter, but avoid charging normal human refreshes too heavily. +- Keep refresh endpoints lightweight. If refreshes are served through `/api/live/**`, aggressive abuse should feed passive security signals instead of normal rate-limit `429` responses. - Refresh challenges after browser back-forward cache restores. - Use Symfony Validator constraints, form-level validation, or a shared captcha form field type for user-facing errors. - Send recoverable captcha errors through the normal validation and error-handling flow. - Treat honeypot failures as suspicious signals that may be silent, while normal missing/expired/wrong captcha input should be recoverable for humans. -- Use Symfony RateLimiter alongside captcha checks. Prefer separate buckets for captcha refreshes, form posts, captcha failures, and suspicious probe traffic. -- Refund or soften form-post rate-limit costs after successful captcha validation so human users are not unnecessarily penalized. +- Use Symfony RateLimiter alongside captcha checks. Prefer separate buckets for form posts, captcha failures, and suspicious probe traffic, while `/api/live/**` challenge refreshes remain passive abuse signals. +- Reset or soften the relevant scoped limiter buckets after verified provider-backed captcha validation when the workflow policy allows it, so human users are not unnecessarily penalized. Provider `none`, missing-provider, or disabled-provider auto-success never resets buckets. - Avoid a single coarse per-IP bucket as the only rate-limit mechanism. - Consider progressive response behavior: allow, throttle, require captcha, then block only for strong signals. - Log provider failures with safe context such as workflow, route, failure code, and anonymized request identifiers where appropriate. @@ -70,7 +81,7 @@ IconCaptcha must work together with other abuse-control layers. Honeypots, CSRF, ## Suggested Module Layout ```text -packages/icon-captcha/ +extensions/icon-captcha/ .manifest assets/ icons/ @@ -137,7 +148,8 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test successful and failed captcha validation paths. - Test recoverable validation feedback on public forms. - Test rate-limited captcha failure behavior where applicable. -- Test successful captcha validation refunds or softens form-post rate-limit costs. +- Test verified captcha validation resets or softens relevant scoped limiter buckets. +- Test provider `none`, missing-provider, and disabled-provider auto-success never resets or softens limiter buckets. - Test that disabling IconCaptcha or selecting another provider does not break workflows using the generic contract. - Test provider value `none` validates successfully. - Test missing or disabled provider modules validate successfully unless a later provider-required policy is introduced. @@ -147,28 +159,35 @@ Recoverable failures should produce translated form errors. Suspicious failures - Test used challenge IDs cannot be replayed. - Test expired challenges fail cleanly. - Test context-bound challenges fail when submitted in an invalid workflow or route context. -- Test refresh endpoint responses are uncached and rate-limited separately from form posts. +- Test refresh endpoint responses are uncached and feed passive abuse signals without normal `/api/live/**` rate-limit rejection. - Test browser back-forward cache recovery refreshes stale challenges. - Test SVG/icon asset loading, allowlisting, and sanitization behavior. - Test keyboard interaction and accessible button state where practical. +- Test rendered DOM, inline SVG, ARIA labels, asset paths, serialized challenge payloads, and frontend state for answer-bearing names or reusable answer material. +- Test accessible quiz fallback success, wrong answer, expiry, replay prevention, context mismatch, refresh behavior, and answer-leak resistance if quiz mode ships in the branch. +- Verify asset license/provenance notes for every bundled graphic and symbol set. - Test translation keys for visible provider labels, buttons, and validation errors. - Test that provider failure logs do not contain secrets, seeds, raw challenge internals, or disallowed personal data. ## Implementation Notes - **Decision recorded:** Do not implement IconCaptcha as part of the first security foundation; implement the generic captcha extension contract first. +- **Decision recorded:** IconCaptcha is part of the overall security hardening feature cut, but the provider implementation should live in a dedicated branch after the generic captcha contract branch. - **Decision recorded:** Implement IconCaptcha as an optional provider module, not as a hard-coded core feature. - **Decision recorded:** Captcha integrates through a global form field that delegates to the configured provider. - **Decision recorded:** Provider selection is configurable. With only the first-party provider installed, choices are `icon_captcha` and `none`. - **Decision recorded:** Provider value `none`, missing providers, and disabled provider modules validate successfully to avoid breaking workflows. - **Decision recorded:** Contact forms, registration forms, and guest comments are the first expected protected workflows. -- **Decision recorded:** Store IconCaptcha SVG and emoji assets inside the IconCaptcha module package when implementation begins. +- **Decision recorded:** Store IconCaptcha SVG and emoji assets inside the IconCaptcha module extension when implementation begins. +- **Decision recorded:** Prefer permissive open-source-compatible asset licenses such as MIT, Apache-2.0, and CC0, with provenance/license notes committed in the provider extension. +- **Decision recorded:** Inline-rendered graphics, symbol SVGs, DOM metadata, and ARIA labels must not reveal answer-bearing names; use opaque option identifiers and neutral labels. +- **Decision recorded:** If neutral ARIA labels are insufficient, prefer a provider-owned quiz challenge with spoken question/task text and multiple answer options over leaking visual answers through labels. - **Decision recorded:** Use the old Grav `sec-lookup` IconCaptcha only as inspiration; do not copy old code or secrets. -- **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a short TTL. +- **Decision recorded:** Prefer deterministic server-side challenge derivation with one-shot challenge IDs and a 15-minute TTL. The longer TTL is intended to support longer form completion time and relies on one-shot invalidation, context binding, captcha-failure buckets, refresh abuse signals, and answer-leak tests to prevent brute-force guessing. - **Decision recorded:** Keep captcha validation integrated with Symfony forms/validators and the shared error-handling model. - **Decision recorded:** Avoid a single coarse per-IP rate limiter; use workflow-specific limiters and progressive abuse handling. -- **Decision recorded:** Successful captcha validation should refund or soften form-post rate-limit costs for human users. -- **Decision recorded:** Keep provider-specific templates theme-compatible but package-owned. +- **Decision recorded:** Successful provider-backed captcha validation should reset or soften relevant scoped limiter buckets for human users when the workflow policy explicitly allows it. Provider `none`, missing-provider, or disabled-provider auto-success does not count as human proof and must not reset buckets. +- **Decision recorded:** Keep provider-specific templates theme-compatible but extension-owned. - **Required decision:** Define the exact generic `CaptchaProvider` interface before implementation. - **Required decision:** Define the provider secret storage location and rotation behavior. -- **Required decision:** Define the exact icon asset set, asset license notes, and SVG sanitization policy. +- **Required decision:** Define the exact icon asset set and SVG sanitization policy before implementation. Asset license/provenance notes are required branch output, not optional follow-up. - **Deferred:** Exact challenge payload shape, frontend controller implementation, and UI styling belong to the implementation phase. diff --git a/dev/draft/0.4.x-MailerDeliveryContract.md b/dev/draft/0.4.x-MailerDeliveryContract.md index af4374fd..75e0d7c0 100644 --- a/dev/draft/0.4.x-MailerDeliveryContract.md +++ b/dev/draft/0.4.x-MailerDeliveryContract.md @@ -9,7 +9,7 @@ The mailer UI must learn available templates and replacement parameters from the mail context, not from whichever feature happens to send a mail. `App\Mail\MailFlowRegistry` is the current single source for mail flow metadata. It stores deterministic flow/template keys, grouping and label translation keys, and the allowed parameter keys for each template. -Later, core features and packages should register mail flows through a provider interface, including default Markdown templates and parameter metadata. Until that extension point exists, the registry is hard-coded with the account-management flows listed below. +Later, core features and extensions should register mail flows through a provider interface, including default Markdown templates and parameter metadata. Until that extension point exists, the registry is hard-coded with the account-management flows listed below. The future Mailer settings page should be deliberately small: a grouped template dropdown, a language dropdown, and one editor for the selected localized template. The editor should show a compact legend of the available replacement keys for the selected flow, for example `email`, `username`, `action_url`, `expires_at`, and `user_uid`. Template labels and groups should be localized through deterministic translation keys, while the flow/template identifiers remain stable machine keys. @@ -26,7 +26,7 @@ Locale selection follows the account-flow contract already implemented: public f - [x] Keep clear action URLs in the stub while no real mailer exists so local account flows remain testable. - [x] Cover registry completeness, locale resolution, and message-log payload shape with focused tests. - [x] Update the worklog, class map, and logging/manual notes. -- [ ] Replace the hard-coded built-in registry map with a provider interface so core features and packages can register mail-flow metadata plus default templates. +- [ ] Replace the hard-coded built-in registry map with a provider interface so core features and extensions can register mail-flow metadata plus default templates. - [ ] Add the Mailer settings UI with template and language selectors, a Markdown editor, and a parameter-key legend. - [ ] Render localized Markdown templates into HTML mail with placeholder replacement and a plain-text fallback. - [ ] Queue generated mail through Symfony Messenger with a conservative transport lock/rate guard. diff --git a/dev/draft/0.4.x-OperationalAdminWorkflows.md b/dev/draft/0.4.x-OperationalAdminWorkflows.md index ab9bba41..ed310f9e 100644 --- a/dev/draft/0.4.x-OperationalAdminWorkflows.md +++ b/dev/draft/0.4.x-OperationalAdminWorkflows.md @@ -9,7 +9,7 @@ - Defines the user-facing workflow pattern for high-impact or long-running admin operations. - Covers dry-run review, confirmation, streamed action logs, progress states, background jobs, retries, cancellation, and recovery output. - Depends on system theme/design system, admin interface, error handling, security/ACL, Messenger conventions, and operational feature drafts. -- Provides a shared UI and service pattern for setup, package installation, asset rebuilds, imports, exports, backups, restores, cache rebuilds, and updates. +- Provides a shared UI and service pattern for setup, extension installation, asset rebuilds, imports, exports, backups, restores, cache rebuilds, and updates. ## Outline Operational workflows should be transparent. A large admin action should not disappear behind a spinner or a vague progress bar. Users need to see what is happening, which step is running, what succeeded, what failed, and what can be retried safely. @@ -18,17 +18,17 @@ The first operational UI should provide an interactive action log pattern. A wor The ActionLog model is the live operation overlay first. It may later feed structured messages before responses return or jobs complete, using message levels such as `success`, `error`, `warning`, `info`, and `debug`. The logger should consume messages, not ActionLog internals. That future logger should be added through an explicit recorder/service boundary; generic operation-message events are deferred until the logger shape exists. Access logs, audit logs, and security logs remain separate layers even when they reuse shared message vocabulary. -The first implemented logging bridge is intentionally small: `MessageReporterInterface` is the direct feedback boundary for creating one structured message, reporting it, and returning it to the caller for UI/API output. `WorkflowResultMessageReporterInterface` receives workflow results from operation boundaries, extracts their structured messages, delegates to `MessageReporterInterface`, and returns the same result unchanged. `OperationExecutor` uses the result bridge for action results, while direct package lifecycle, setup, discovery, asset rebuild dispatch, PHP-loader, and public-hook failure boundaries use the same bridge instead of being forced through an `ActionQueue`. `MonologMessageLogger` writes messages to Monolog's `message` channel as translation keys plus redacted JSON context. +The first implemented logging bridge is intentionally small: `MessageReporterInterface` is the direct feedback boundary for creating one structured message, reporting it, and returning it to the caller for UI/API output. `WorkflowResultMessageReporterInterface` receives workflow results from operation boundaries, extracts their structured messages, delegates to `MessageReporterInterface`, and returns the same result unchanged. `OperationExecutor` uses the result bridge for action results, while direct extension lifecycle, setup, discovery, asset rebuild dispatch, PHP-loader, and public-hook failure boundaries use the same bridge instead of being forced through an `ActionQueue`. `MonologMessageLogger` writes messages to Monolog's `message` channel as translation keys plus redacted JSON context. Messages that pass through the message layer are durable diagnostics. Transient ActionLog entries are UI transport state. A plain step without messages may disappear after the live-operation TTL, while each issue/message emitted through `WorkflowResult`, `ActionLogEntry`, or `MessageReporterInterface` is forwarded to the message log. -This pattern should be shared across features. Setup, imports, backups, package lifecycle changes, asset rebuilds, cache warmups, and self-updates should not each invent separate progress UIs. Each workflow may have feature-specific steps, but the shell, logging, confirmation, and result states should be consistent. +This pattern should be shared across features. Setup, imports, backups, extension lifecycle changes, asset rebuilds, cache warmups, and self-updates should not each invent separate progress UIs. Each workflow may have feature-specific steps, but the shell, logging, confirmation, and result states should be consistent. -Package lifecycle workflows should include an asset alignment step when frontend contributions changed. The operational action should call `assets:rebuild`, mirror active package assets, rewrite generated registries, install assets, install importmap assets, run Tailwind, run AssetMapper compilation only in production, and clear cache as the finalizer so the active template/class set is reflected before normal traffic resumes. +Extension lifecycle workflows should include an asset alignment step when frontend contributions changed. The operational action should call `assets:rebuild`, mirror active extension assets, rewrite generated registries, install assets, install importmap assets, run Tailwind, run AssetMapper compilation only in production, and clear cache as the finalizer so the active template/class set is reflected before normal traffic resumes. -Discovery should not activate new packages. Newly discovered packages should appear as inactive/available, then move through explicit activation workflows that validate compatibility, apply required lifecycle steps, rebuild assets where needed, and record the action log. Administrators should also have a manual "rebuild assets" action for recovery and diagnostics. +Discovery should not activate new extensions. Newly discovered extensions should appear as inactive/available, then move through explicit activation workflows that validate compatibility, apply required lifecycle steps, rebuild assets where needed, and record the action log. Administrators should also have a manual "rebuild assets" action for recovery and diagnostics. -If a package activation fails during validation, contribution registration, asset sync, Tailwind build, AssetMapper compilation, or final cache clear, the workflow should restore the previous active/inactive option where practical and show the failure. This keeps lifecycle behavior simple while avoiding a complex staged compiler model. +If an extension activation fails during validation, contribution registration, asset sync, Tailwind build, AssetMapper compilation, or final cache clear, the workflow should restore the previous active/inactive option where practical and show the failure. This keeps lifecycle behavior simple while avoiding a complex staged compiler model. Resolver rebuilds, collaboration exports, import dry-runs, GeoIP database updates, security-log aggregation, scheduler runs, and captcha/security diagnostics should use the same operational pattern where they are long-running or high-impact. Their reports should be suitable for both the UI action log and CLI output. Optional workflows such as GeoIP updates should become no-ops with clear log output when required configuration, such as a MaxMind API key, is missing. @@ -41,7 +41,7 @@ Resolver rebuilds, collaboration exports, import dry-runs, GeoIP database update - Prefer streamed responses for short request-bound operations and Messenger-backed job status polling or server events for longer background work. - Treat `/api/live/operations/{operationId}?token=&cursor=` as the first implemented ActionLog polling fallback when streaming or server events are unavailable. - Return operation polling payloads with `operation_id`, `status`, `cursor`, optional `cursor_max`, `entries`, and `next_poll_ms`. -- Resolve live operation queues through tagged providers so Core, Admin, and later packages/updaters can add operations without turning the runner into a monolithic switch. +- Resolve live operation queues through tagged providers so Core, Admin, and later extensions/updaters can add operations without turning the runner into a monolithic switch. - Emit monotonically increasing numeric cursors from the log producer so the UI can request only entries after the last rendered cursor. - Include a cursor maximum or comparable progress hint when the operation can estimate total steps, files, records, or work units. - Let the server recommend polling cadence through `next_poll_ms`; active logs may use short intervals, while waiting, hidden-tab, or terminal states should back off or stop. @@ -50,16 +50,16 @@ Resolver rebuilds, collaboration exports, import dry-runs, GeoIP database update - Scrollable live log. - Copy/download log action. - Retry, continue, cancel, or close actions where supported. - - Link to related entity, import batch, backup artifact, update package, or audit entry. + - Link to related entity, import batch, backup artifact, update extension, or audit entry. - Require dry-run or review phases for destructive or high-impact operations before apply. - Keep confirmation screens explicit about what will change and what recovery path exists. - Persist operation summaries in audit logs when the action affects content, configuration, modules, themes, users, data, backups, or release state. - Support resolver rebuild, resolver export, import dry-run/apply, GeoIP update, security-log aggregation, and security diagnostics as first-class operational action candidates. - Support scheduler runs as first-class operational action candidates. `bin/scheduler` and `/cron/run` should report due, successful, failed, disabled, and not-yet-due tasks through comparable CLI or JSON summaries. -- Support package asset alignment as a first-class operational action that runs `assets:rebuild`: package asset mirror and registry rewrite, translation aggregation, `assets:install`, `importmap:install`, `tailwind:build`, production-only `public/assets` cleanup and AssetMapper compilation, then `cache:clear`. -- Trigger asset alignment automatically after package activation/deactivation/update/uninstall when frontend contributions changed. -- Keep discovery separate from activation. Discovery records packages as available but inactive; activation performs runtime contribution and asset alignment. -- Roll back package lifecycle state to the previous option when activation fails before it can be safely finalized. +- Support extension asset alignment as a first-class operational action that runs `assets:rebuild`: extension asset mirror and registry rewrite, translation aggregation, `assets:install`, `importmap:install`, `tailwind:build`, production-only `public/assets` cleanup and AssetMapper compilation, then `cache:clear`. +- Trigger asset alignment automatically after extension activation/deactivation/update/uninstall when frontend contributions changed. +- Keep discovery separate from activation. Discovery records extensions as available but inactive; activation performs runtime contribution and asset alignment. +- Roll back extension lifecycle state to the previous option when activation fails before it can be safely finalized. - Provide a manual admin "rebuild assets" action that runs `assets:rebuild` through the operational action-log workflow. - Report asset rebuild output through the action log and keep failures recoverable where possible. - Persist ActionLog entries before each asset rebuild step and expose current-step and planned-step counts. The UI may use streaming when available, but polling through `/api/live/operations/{operationId}?token=&cursor=` must be able to resume if the final `cache:clear` briefly interrupts requests. @@ -68,7 +68,7 @@ Resolver rebuilds, collaboration exports, import dry-runs, GeoIP database update - Redact protected configuration values such as provider API keys from action logs, reports, and downloadable diagnostics. - Mark queued/running live operations as failed when they stop reporting progress beyond the configured stale threshold, and periodically remove expired terminal run files. - Claim staged live operations atomically before runner execution so duplicate console invocations cannot execute the same operation twice. -- Serialize live runner execution with one global live-operation lock for now so package lifecycle, discovery, asset rebuild, and cache-clear operations cannot mutate shared state concurrently. The lock must be released in runner finalization and stale locks must expire automatically so a crashed runner does not block future operations forever. +- Serialize live runner execution with one global live-operation lock for now so extension lifecycle, discovery, asset rebuild, and cache-clear operations cannot mutate shared state concurrently. The lock must be released in runner finalization and stale locks must expire automatically so a crashed runner does not block future operations forever. - Let the overlay persist the current status URL in session storage so reloads can resume polling the same operation instead of starting another run. - Expose an Admin Operations inspection view that lists queued, running, and recently completed transient run state while the TTL has not expired, shows the current runner lock, offers expired-run cleanup, and permits clearing stale locks. An emergency kill control is allowed only for stale locks when the store has the detached runner PID and the current process command still matches the expected `operations:run ` invocation. - Expose retained operation detail pages from Admin Operations while transient run state still exists. The detail page may show sanitized run metadata, ActionLog entries, and final result issues/messages, but it must not expose run tokens, submitted payloads, or other sensitive transport internals. @@ -89,10 +89,10 @@ Resolver rebuilds, collaboration exports, import dry-runs, GeoIP database update - Test terminal operation states stop or slow polling through `next_poll_ms`. - Test review-required operation results stop polling, expose Continue/Cancel, and start only the provider-declared continuation operation. - Test setup/import/backup/update workflows use the shared action-log shell instead of custom progress-only UI. -- Test discovery does not activate packages or trigger runtime contributions. +- Test discovery does not activate extensions or trigger runtime contributions. - Test explicit activation/enablement triggers validation, contribution registration, and asset alignment where needed. -- Test failed package activation restores the previous lifecycle state where practical. -- Test package lifecycle workflows run asset alignment in the expected command order. +- Test failed extension activation restores the previous lifecycle state where practical. +- Test extension lifecycle workflows run asset alignment in the expected command order. - Test manual admin asset rebuild runs the same command sequence and reports through the action log. - Test asset alignment failures are visible in the action log and do not silently mark the lifecycle operation successful. - Test resolver rebuild/export, import dry-run, GeoIP update, and security-log aggregation can report through the shared action-log pattern when implemented. @@ -101,11 +101,11 @@ Resolver rebuilds, collaboration exports, import dry-runs, GeoIP database update ## Implementation Notes - **Decision recorded:** Complex admin operations should use an interactive action log as the primary feedback pattern, not only a spinner or progress bar. -- **Decision recorded:** Setup, imports, exports, backups, restores, cache/asset rebuilds, package lifecycle changes, and self-updates should share one operational workflow shell. -- **Decision recorded:** Package lifecycle changes that affect frontend contributions automatically trigger `assets:rebuild` through the operational action-log workflow. +- **Decision recorded:** Setup, imports, exports, backups, restores, cache/asset rebuilds, extension lifecycle changes, and self-updates should share one operational workflow shell. +- **Decision recorded:** Extension lifecycle changes that affect frontend contributions automatically trigger `assets:rebuild` through the operational action-log workflow. - **Decision recorded:** Asset rebuild cache clearing runs last. The operation runner must persist step state before the finalizer so live UI polling can resume by cursor if cache invalidation interrupts the transport. -- **Decision recorded:** Discovery only marks packages as inactive/available. Explicit activation is required before runtime contributions are registered. -- **Decision recorded:** Failed package activation rolls back to the previous option where practical instead of introducing a separate staged compiler model. +- **Decision recorded:** Discovery only marks extensions as inactive/available. Explicit activation is required before runtime contributions are registered. +- **Decision recorded:** Failed extension activation rolls back to the previous option where practical instead of introducing a separate staged compiler model. - **Decision recorded:** The admin interface should expose a manual rebuild-assets action that uses the same operational workflow as automatic lifecycle rebuilds. - **Decision recorded:** Resolver rebuild/export, import dry-run/apply, GeoIP updates, security-log aggregation, and security diagnostics should use the shared operational workflow shell when they are long-running or high-impact. - **Decision recorded:** Scheduler execution through `bin/scheduler` and protected `/cron/run` should use the shared operational workflow shell where possible. @@ -135,6 +135,6 @@ Resolver rebuilds, collaboration exports, import dry-runs, GeoIP database update - **Decision recorded:** Compact terminal live-operation summaries are routed through the message layer instead of a separate operation file channel. Summaries include operation id/name, status, timing, step count, message count, issue count, and continuation availability, but do not include payloads, polling tokens, or raw runner output. - **Open:** Decide the primary transport for live logs: streamed HTTP response, Mercure/server-sent events, Messenger-backed status table, or polling-first. - **Open:** Decide which low-risk actions can keep transient logs and which high-impact actions require durable logs. -- **Open:** Define the final provider capability boundary before `1.0.0`. Providers should validate their own payload shape and actor intent, but the core contract should avoid pretend manifest permission flags or overly narrow restrictions that block legitimate package, editor, setup, backup, or updater flows. +- **Open:** Define the final provider capability boundary before `1.0.0`. Providers should validate their own payload shape and actor intent, but the core contract should avoid pretend manifest permission flags or overly narrow restrictions that block legitimate extension, editor, setup, backup, or updater flows. - **Decision recorded:** Target operational compatibility includes Linux, macOS, Windows, Apache, NGINX, IIS, and shared hosting that meets requirements. Untested combinations should be labelled as untested compatibility targets rather than silently treated as unsupported. - **Open:** Revisit the detached runner start mechanism before production release. The current PID-file shell start is intentionally small and works for local/dev flows; production packaging may need a Symfony Process, supervisor, Messenger worker, or platform-specific runner strategy. diff --git a/dev/draft/0.4.x-Scheduler.md b/dev/draft/0.4.x-Scheduler.md index 1d16cb7a..f09ef85b 100644 --- a/dev/draft/0.4.x-Scheduler.md +++ b/dev/draft/0.4.x-Scheduler.md @@ -3,7 +3,7 @@ > **Status**: Draft > **Updated**: 2026-06-05 > **Owner**: Core -> **Purpose:** Draft for configured scheduled maintenance and package tasks through CLI and protected web entry points. +> **Purpose:** Draft for configured scheduled maintenance and extension tasks through CLI and protected web entry points. ## Overview - Provides a scheduler for administrator-defined recurring tasks. @@ -12,7 +12,7 @@ - Uses API-key authentication for web execution. - Uses Symfony Scheduler and Messenger as trigger/dispatch building blocks while keeping Studio-specific database state, policies, and Admin UI in the application. - Reuses operational action logs, message logs, and result summaries where possible. -- Supports core maintenance, package-provided tasks, backups, cleanup, indexing, cache rebuilds, and similar recurring operations. +- Supports core maintenance, extension-provided tasks, backups, cleanup, indexing, cache rebuilds, and similar recurring operations. ## Outline The scheduler should let administrators configure recurring tasks without relying on direct shell access for every operation. A system cron, hosting control panel, uptime monitor, or external scheduler can call `bin/scheduler` or `/cron/run`, and the application decides which configured tasks are due. @@ -32,7 +32,7 @@ The initial task set should cover common maintenance needs: - log rotation and retention-aware cleanup; - stale entity cleanup based on configured retention; - expired account-token cleanup through the existing `account-tokens:cleanup` command; -- package-contributed tasks registered through documented contracts; +- extension-contributed tasks registered through documented contracts; - backup creation; - cache cleanup or rebuild workflows; - search, resolver, or reference-index rebuilds; @@ -54,10 +54,10 @@ Failed tasks should remain due so the next scheduler run can retry them. After t - Let `bin/scheduler` support a human-readable summary and a JSON mode for automation. - Offer an opt-in web-traffic trigger for shared hosts without cron access. It hooks into the post-response Messenger drain, uses the same 60-second cooldown lock, starts detached `bin/scheduler`, and relies on the scheduler run lock instead of enqueueing scheduler messages that could stack up. - Store scheduler task configuration in the database so the Admin UI can activate, deactivate, customize intervals, run now, and inspect registered tasks. -- Register core maintenance tasks for live-operation cleanup, package discovery, statistics snapshot refresh, and cache clearing. They are visible to administrators but inactive by default until explicitly enabled. Package asset sync stays event-driven through package lifecycle and asset rebuild flows instead of running as a periodic scheduler task. +- Register core maintenance tasks for live-operation cleanup, extension discovery, statistics snapshot refresh, and cache clearing. They are visible to administrators but inactive by default until explicitly enabled. Extension asset sync stays event-driven through extension lifecycle and asset rebuild flows instead of running as a periodic scheduler task. - Store task fields such as identifier, label translation key, provider/source, enabled state, cron expression, default cron expression, last successful run timestamp, last attempted run timestamp, next due timestamp, consecutive failure count, latest result summary, and safe metadata. - Use cron expressions as the first interval model. Symfony Scheduler's cron trigger support and `dragonmantank/cron-expression` provide the parsing/calculation layer. -- Validate cron expressions at every configuration boundary: registered tasks require a default cron expression, the Admin UI rejects invalid expressions, package validation requires literal default cron expressions in package PHP scheduler registrations, and the runner disables an active task with a Message Log entry if invalid syntax still reaches stored task state. +- Validate cron expressions at every configuration boundary: registered tasks require a default cron expression, the Admin UI rejects invalid expressions, extension validation requires literal default cron expressions in extension PHP scheduler registrations, and the runner disables an active task with a Message Log entry if invalid syntax still reaches stored task state. - Mark tasks due based on stored `next_due_at`, not by scanning historical logs. - Lock scheduler runs and individual task execution so overlapping scheduler calls do not run the same task twice. - Prefer a portable database-backed lock strategy compatible with SQLite, MariaDB/MySQL, and PostgreSQL. If Symfony Lock is introduced later, it should wrap the same policy rather than bypassing database state. @@ -75,7 +75,7 @@ Failed tasks should remain due so the next scheduler run can retry them. After t - keep the latest failure summary for the Admin UI. - Treat skipped or not-due tasks as successful scheduler outcomes, not operation failures. - Reuse `ActionQueue`, `OperationExecutor`, `ActionLog`, `WorkflowResult`, Symfony Messenger, and Symfony Scheduler trigger primitives for task execution where applicable. -- Allow package-provided tasks only through documented registration hooks or tagged services once package runtime integration exists. Packages register available task definitions; administrators must still activate them. +- Allow extension-provided tasks only through documented registration hooks or tagged services once extension runtime integration exists. Extensions register available task definitions; administrators must still activate them. - Persist task execution history in a small `scheduler_task_run` table so the Admin UI can show recent runs per task without turning message logs into the primary state store. Message logs remain the audit/diagnostic signal, not the scheduler UI read model. - Every scheduler run writes one DEBUG message with job details and elapsed time in context. This keeps noisy minute-by-minute pings filterable at logger level. - Failed jobs write their own WARN/ERROR messages. The scheduler runner itself only writes WARN/ERROR when the scheduler infrastructure fails independently of an individual job. @@ -89,30 +89,30 @@ Failed tasks should remain due so the next scheduler run can retry them. After t - `503` when maintenance or lock contention prevents execution and the caller should retry later. ## Task Registration And Execution Types -- Scheduler tasks are registered by core services or active packages through a collection hook/registry. Registration exposes the task identifier, title/description translation keys, source (`system` or package slug), default cron expression, task type, safe metadata, and policy flags. +- Scheduler tasks are registered by core services or active extensions through a collection hook/registry. Registration exposes the task identifier, title/description translation keys, source (`system` or extension slug), default cron expression, task type, safe metadata, and policy flags. - Administrators activate registered tasks and may override the cron expression per task. Registered tasks default to inactive unless a future migration explicitly seeds a safe core task as active. - A task can execute one of these trusted target types: - Core `ActionQueue` definitions assembled by trusted system code. - Registered service callables, resolved from an allow-list/registry rather than arbitrary user-provided method strings. - Registered Symfony commands with predefined command names and validated argument/option maps. -- Package-provided ActionQueue tasks are hidden and non-executable by default behind a Scheduler setting because they can indirectly perform filesystem/CLI/system work. Core ActionQueue tasks are trusted and exempt from this setting. -- When package ActionQueue tasks are enabled, the Admin UI should show a small warning marker on affected package jobs and require clear confirmation before activation. -- Disabled package jobs are not executable. If a package is deactivated, its scheduler task settings remain stored but hidden from the active task list until the package becomes active again. If a package is purged, its scheduler task settings and run history are removed. +- Extension-provided ActionQueue tasks are hidden and non-executable by default behind a Scheduler setting because they can indirectly perform filesystem/CLI/system work. Core ActionQueue tasks are trusted and exempt from this setting. +- When extension ActionQueue tasks are enabled, the Admin UI should show a small warning marker on affected extension jobs and require clear confirmation before activation. +- Disabled extension jobs are not executable. If an extension is deactivated, its scheduler task settings remain stored but hidden from the active task list until the extension becomes active again. If an extension is purged, its scheduler task settings and run history are removed. ## Admin UI - Provide an Admin UI list of configured scheduler tasks. -- Show all currently registered tasks in a table with these primary columns: job name, source (`system` or package slug), interval/cron expression, last successful run, and status (`active`, `inactive`, or `failed`). +- Show all currently registered tasks in a table with these primary columns: job name, source (`system` or extension slug), interval/cron expression, last successful run, and status (`active`, `inactive`, or `failed`). - Link the job name to a detail view. - The detail view shows all task metadata, current policy state, default interval, customized interval, next due timestamp, last successful run, last attempted run, consecutive failure count, latest result summary, and recent execution history. - Allow administrators to activate, deactivate, run now, reset failure state, and update the task cron expression when policy allows it. - Add Scheduler settings for: - enabling/disabling the Scheduler globally (default enabled); - enabling/disabling GET-token fallback for `/cron/run` (default disabled); - - enabling/disabling package-provided ActionQueue tasks (default disabled). + - enabling/disabling extension-provided ActionQueue tasks (default disabled). - Require explicit confirmation for tasks that are destructive or high-impact. -- Show package-provided tasks only after their package is active and task registration has passed validation. -- Hide package-provided tasks when the package is inactive. Preserve their stored settings for reactivation, but never execute them while inactive. -- Purging a package deletes its scheduler task settings and run history. +- Show extension-provided tasks only after their extension is active and task registration has passed validation. +- Hide extension-provided tasks when the extension is inactive. Preserve their stored settings for reactivation, but never execute them while inactive. +- Purging an extension deletes its scheduler task settings and run history. - The detail view includes a sub-view or embedded log for recent scheduler task runs. Scheduler-specific log output can later be surfaced through the Admin Log Viewer if that proves more useful than a dedicated sub-view. ## Security Notes @@ -122,7 +122,7 @@ Failed tasks should remain due so the next scheduler run can retry them. After t - If GET-token authentication is used, documentation should warn that URLs can appear in logs, browser history, reverse proxies, and monitoring tools. - Use a dedicated read-write API key owned by an active administrator or owner for scheduler calls. Even though the scheduler endpoint only runs administrator-defined tasks, those tasks may mutate application state. - If future task classes require elevated write semantics, the scheduler runner should enforce that per task instead of globally assuming all scheduler callers can mutate state. -- Package-provided tasks are untrusted until registered, validated, and explicitly activated by an administrator. Package-provided ActionQueues require the dedicated global setting. +- Extension-provided tasks are untrusted until registered, validated, and explicitly activated by an administrator. Extension-provided ActionQueues require the dedicated global setting. - Scheduler output must not leak protected config values, API keys, passwords, upstream credentials, backup paths containing secrets, or full exception traces in production. - GET query tokens must be redacted from access/message/audit contexts wherever the request path or query string is rendered. @@ -140,10 +140,10 @@ Failed tasks should remain due so the next scheduler run can retry them. After t - Test manually reactivating a disabled task resets or preserves failure metadata according to the final Admin UI decision. - Test overlapping scheduler runs cannot execute the same task twice. - Test soft execution-budget warnings are logged without hard-stopping a successful task. -- Test package-provided task registration, deactivation hiding, reactivation restoration, purge cleanup, and package ActionQueue policy gating. +- Test extension-provided task registration, deactivation hiding, reactivation restoration, purge cleanup, and extension ActionQueue policy gating. - Test retention cleanup tasks do not delete data outside configured scopes. - Test JSON output remains stable enough for external scheduler monitoring. -- Test invalid cron expressions are rejected by Admin/package validation and cannot crash the scheduler runner. +- Test invalid cron expressions are rejected by Admin/extension validation and cannot crash the scheduler runner. ## Implementation Notes - **Decision recorded:** Scheduler execution should be available through both `bin/scheduler` and `/cron/run`. @@ -153,16 +153,16 @@ Failed tasks should remain due so the next scheduler run can retry them. After t - **Decision recorded:** Failed tasks remain due and are retried on future scheduler runs. - **Decision recorded:** A task is automatically disabled after three consecutive failed runs. - **Decision recorded:** Registered task configuration should live in the database and be configurable from the Admin UI. -- **Decision recorded:** Package-contributed tasks are allowed through documented registration contracts, but activation remains an administrator decision. +- **Decision recorded:** Extension-contributed tasks are allowed through documented registration contracts, but activation remains an administrator decision. - **Decision recorded:** Scheduler runs should reuse Symfony Scheduler/Messenger plus the shared operational result/message model where possible. - **Decision recorded:** Scheduler implementation should use Symfony Scheduler and Messenger as far as practical, with Studio-specific database state, due-task policy, failure policy, and Admin UI kept explicit and thin around those Symfony primitives. - **Decision recorded:** `SchedulerRunner` stays the scheduler execution facade while due-task selection, task-run recording, failure thresholds, and run/result reporting live in focused services. - **Decision recorded:** Scheduler and live-operation locks use `symfony/lock` so the project has one portable lock abstraction rather than several custom file-lock implementations. - **Decision recorded:** Scheduler command and web-triggered child processes must use the central process/environment policy so Dotenv values are propagated and web/CGI request context stays filtered. - **Decision recorded:** The first interval expression model is cron syntax. -- **Decision recorded:** Package manifests stay metadata-only; package scheduler cron validation inspects scheduler registration code instead of adding scheduler definitions to the manifest. +- **Decision recorded:** Extension manifests stay metadata-only; extension scheduler cron validation inspects scheduler registration code instead of adding scheduler definitions to the manifest. - **Decision recorded:** Scheduler run summaries should use a dedicated task-run history table for the Admin detail sub-view. -- **Decision recorded:** Direct job triggering uses stable internal task identifiers, not translated names, because package-provided display names can collide. +- **Decision recorded:** Direct job triggering uses stable internal task identifiers, not translated names, because extension-provided display names can collide. - **Decision recorded:** Only read-write API keys owned by active administrators or owners may trigger `/cron/run`; task-level policy controls configured task behavior. - **Decision recorded:** Scheduler run receipts are DEBUG messages; job failures produce their own WARN/ERROR messages. -- **Decision recorded:** Package jobs are hidden while their package is inactive and removed when the package is purged. +- **Decision recorded:** Extension jobs are hidden while their extension is inactive and removed when the extension is purged. diff --git a/dev/draft/0.5.x-SelfUpdateReleaseWorkflow.md b/dev/draft/0.5.x-SelfUpdateReleaseWorkflow.md index 54b18db3..566ccd40 100644 --- a/dev/draft/0.5.x-SelfUpdateReleaseWorkflow.md +++ b/dev/draft/0.5.x-SelfUpdateReleaseWorkflow.md @@ -28,13 +28,13 @@ Self-update should not be implemented as an opaque automatic mutation. It should - `APP_DESCRIPTION` - `APP_CHANNEL` - `APP_SOURCE` -- Extend manifests carefully for package type, compatibility, checksums, signatures, migration requirements, and dependencies. +- Extend manifests carefully for extension type, compatibility, checksums, signatures, migration requirements, and dependencies. - Support dev-channel update discovery from configured git sources when appropriate. - Support stable-channel update discovery from release packages when packaging is defined. - Run compatibility checks before applying updates. -- Check package compatibility before and after core updates. +- Check extension compatibility before and after core updates. - Require backup or backup acknowledgement before destructive updates. -- Validate package contents before replacing files. +- Validate extension contents before replacing files. - Keep release packaging deterministic and exclude local-only files, caches, secrets, test artifacts when not needed, and development-only generated output. - Provide release dry-run output that lists included/excluded files. - Use Symfony Console commands for update and release workflows. @@ -45,8 +45,8 @@ Self-update should not be implemented as an opaque automatic mutation. It should - Test manifest parsing and compatibility checks. - Test update discovery for configured channels. - Test release packaging dry-runs. -- Test package validation rejects missing required files and unsafe metadata. -- Test update preflight blocks incompatible packages. +- Test extension validation rejects missing required files and unsafe metadata. +- Test update preflight blocks incompatible extensions. - Test migration-required update paths in isolated environments. - Test rollback behavior once rollback is implemented. - Manually verify release packages before public distribution. @@ -55,6 +55,6 @@ Self-update should not be implemented as an opaque automatic mutation. It should - **Decision proposed:** Use git sources for development channels and release packages for stable channels. - **Decision proposed:** Require explicit preflight and backup acknowledgement before self-update applies changes. - **Decision proposed:** Keep release packaging deterministic and script-based. -- **Decision required:** Decide package signature/checksum strategy. +- **Decision required:** Decide extension signature/checksum strategy. - **Decision required:** Decide whether self-update can modify the currently running installation directly or stages updates for administrator confirmation. - **Decision required:** Decide rollback scope before enabling automatic update apply. diff --git a/dev/draft/README.md b/dev/draft/README.md index a08dbb25..2dcba31b 100644 --- a/dev/draft/README.md +++ b/dev/draft/README.md @@ -1,7 +1,7 @@ # Project Outline and Feature Drafts > **Status**: Draft -> **Updated**: 2026-05-20 +> **Updated**: 2026-06-15 > **Owner**: Core > **Purpose:** Description of planned features and technical specification drafts for use as guidance alongside implementation. @@ -19,7 +19,7 @@ Development should stay close to the Symfony application model. Prefer native Sy The core should provide stable boundaries and a small set of documented extension points instead of broad custom frameworks. Feature work should be split into reviewable vertical slices with nearby tests, documentation, class map entries, and worklog notes. Files should stay focused so future contributors and coding agents can inspect, change, and review behavior without loading a large part of the project into context. -Packages should be able to integrate with the system through explicit extension categories: +Extensions should be able to integrate with the system through explicit extension categories: - **Observe:** Modules may react to lifecycle events without changing the core result, for example audit logging, statistics, indexing, or notifications. - **Extend:** Modules may add behavior through documented hooks or tagged services, for example editor actions, export formats, form extensions, template candidates, or navigation entries. @@ -47,8 +47,11 @@ The feature drafts should be created in dependency order. Start with architectur ### 0.2.x security and extension baseline drafts - [Security and access control](0.2.x-SecurityAccessControl.md) +- [Security hardening implementation plan](0.2.x-SecurityHardeningPlan.md) +- [Security policy defaults](security-hardening/policy-defaults.md) +- [Security Admin ACL enforcement plan](security-hardening/admin-acl-enforcement.md) - [Admin interface and setup UI](0.2.x-AdminInterfaceSetupUi.md) -- [Package modules and providers](0.2.x-PluginModules.md) +- [Extension modules and providers](0.2.x-PluginModules.md) - [Event hooks and buses](0.2.x-EventHooksBuses.md) ### 0.3.x structured content and editor drafts @@ -117,7 +120,7 @@ A release should be considered stable and presentable only when the implemented - Setup works from CLI and web setup without hidden local state. - Admin login, ACL protection, and administrator-only configuration are enforced. - Public rendering uses published content only and respects language, variant, visibility, and ACL rules. -- Package lifecycle workflows validate manifests, keep discovered packages inactive by default, rebuild assets, and recover from failures. +- Extension lifecycle workflows validate manifests, keep discovered extensions inactive by default, rebuild assets, and recover from failures. - Content, schema, editor, draft/publish, resolver, media, navigation, import/export, backup/restore, and operational workflows have focused tests. - Operational actions expose understandable action logs, redacted diagnostics, and recovery paths. - Cache, asset, resolver/index, and delivery rebuilds are documented and available from admin or CLI workflows. diff --git a/dev/draft/future-CommunityHub.md b/dev/draft/future-CommunityHub.md index cfb9f67e..79a89885 100644 --- a/dev/draft/future-CommunityHub.md +++ b/dev/draft/future-CommunityHub.md @@ -17,7 +17,7 @@ This feature should not be implemented during the first core phase. Current arch ## Technical Specifications - Future implementation should prefer plugin-module boundaries. -- Community domain data should use package-owned tables instead of generic content fieldsets. +- Community domain data should use extension-owned tables instead of generic content fieldsets. - Permissions, moderation actions, and public forms should build on the security, captcha, rate-limit, and event-hook drafts. - Notifications should use Messenger and documented lifecycle events where applicable. - Public profile and forum routes should not conflict with reserved core route prefixes. diff --git a/dev/draft/future-FirstPartyModulesAdminAddons.md b/dev/draft/future-FirstPartyModulesAdminAddons.md index 44b5620c..faa61b65 100644 --- a/dev/draft/future-FirstPartyModulesAdminAddons.md +++ b/dev/draft/future-FirstPartyModulesAdminAddons.md @@ -18,7 +18,7 @@ The first example is a LogViewer and statistics module. Logging and statistics c Editor integrations are another natural module family. CodeMirror can remain the first core editor integration, while a TinyMCE module may later replace or complement it if the editor provider contract can define the needed feature set cleanly. This keeps the door open for other editor providers without baking one UI editor permanently into core. -Import/export workflows also have a split boundary. Core should define operation formats, validators, parsers, serializers, dry-run behavior, and diff contracts. Optional first-party modules can provide richer admin UI surfaces such as copy/paste import panels with diff view, import presets, export preset management, saved scopes, and LLM collaboration packages. +Import/export workflows also have a split boundary. Core should define operation formats, validators, parsers, serializers, dry-run behavior, and diff contracts. Optional first-party modules can provide richer admin UI surfaces such as copy/paste import panels with diff view, import presets, export preset management, saved scopes, and LLM collaboration extensions. Other candidates include breadcrumbs, lightbox/gallery presentation helpers, specialized media widgets, dashboard widgets, and public-theme helper modules. These should be added only when their contracts are clear enough that they do not become hidden core dependencies. diff --git a/dev/draft/future-Rei3TicketsIntegration.md b/dev/draft/future-Rei3TicketsIntegration.md index 7e5bf710..ce823f1d 100644 --- a/dev/draft/future-Rei3TicketsIntegration.md +++ b/dev/draft/future-Rei3TicketsIntegration.md @@ -13,11 +13,11 @@ ## Outline REI3 tickets integration is a future plugin-module feature for connecting project websites with external ticket workflows. It should remain outside the CMS core unless a later product decision makes ticketing a first-party domain. -Current architecture should keep API adapters, package-owned tables, permissions, background synchronization, and external-service configuration possible without coupling the core content model to REI3-specific concepts. +Current architecture should keep API adapters, extension-owned tables, permissions, background synchronization, and external-service configuration possible without coupling the core content model to REI3-specific concepts. ## Technical Specifications - Future implementation should be a plugin module with its own manifest metadata. -- Ticket-specific data should use package-owned tables. +- Ticket-specific data should use extension-owned tables. - External API credentials must use environment configuration or Symfony secrets, not manifests. - Synchronization should use Messenger when it can run asynchronously. - Public or admin routes should declare module permissions before activation. diff --git a/dev/draft/security-hardening/abuse-foundation.md b/dev/draft/security-hardening/abuse-foundation.md new file mode 100644 index 00000000..69a7a4f9 --- /dev/null +++ b/dev/draft/security-hardening/abuse-foundation.md @@ -0,0 +1,138 @@ +# Abuse foundation branch plan + +> **Status**: Draft +> **Updated**: 2026-06-15 +> **Owner**: Core +> **Purpose:** Define the `feat-security-abuse-foundation` implementation plan. + +## Goal + +Introduce Studio-owned request classification, subject resolution, action costs, and passive suspicious-signal recording before any broad enforcement or auto-ban behavior is enabled. + +Back to [security hardening implementation plan](../0.2.x-SecurityHardeningPlan.md). + +## Git handling + +Codex may create local commits for this branch when each commit has a clear thematic scope. Pushes require explicit user instruction. + +## Dependencies + +- [Security policy defaults](policy-defaults.md). +- Existing visitor identity, access logging, audit logging, API-key authentication, Scheduler API authentication, and `/api/live/**` route boundaries. +- Symfony Request data and Turbo/browser prefetch headers. + +## Legacy inspiration + +The old Grav plugin `sec-lookup` at `/Volumes/Projekte/temp/sec-lookup` may be reviewed for suspicious-request categories, passive-signal examples, and diagnostics language. Current subject-resolution, privacy, database-portability, and no-enforcement decisions in this branch have priority. Do not copy legacy logic or framework-specific request handling directly. + +## Implementation sequence + +1. Add an abuse namespace with value objects for subject, request family, request intent, action cost, and passive signal. +2. Add subject resolution for IP bucket, visitor ID, authenticated user UID, API key UID/prefix, and safe combined subject keys through one reviewed client-identity resolver. +3. Add request-intent classification for browser navigation, Turbo/browser prefetch, form submit, API read, API write, CORS preflight, exact `/cron/run` scheduler trigger, login, registration, password reset, exact setup review apply, extension/admin operation, upload/archive validation, export/download, import, public form submits, and suspicious probe. +4. Add a central action-cost catalogue with website and API families. Costs are symbolic defaults, not limiter calls yet. +5. Add database-backed message, audit, and access log projections in parallel to the existing 30-day rotating file logs. +6. Move Admin/API log browsing from file scanning to the database projection, with one tab/source for message, audit, access, and security-signal events. +7. Add database-backed passive suspicious-signal recording with TTL-ready metadata, cleanup support, and redacted message/audit reporting. +8. Add explicit `/api/live/**` classification: no ordinary enforcement, but passive signal recording can happen for clear abuse patterns. + +## Public interfaces and data decisions + +- Controllers and future extensions call the Studio-owned `AbuseRequestInspector` facade instead of Symfony RateLimiter directly. The facade combines `AbuseSubjectResolver`, `RequestIntentClassifier`, and `ActionCostCatalogue`; later branches may add enforcement around this boundary without moving classification logic into controllers. +- Existing Monolog file channels remain the raw operational fallback with 30-day retention; database log projection tables are lookup/read-model copies for Admin UI, Admin API, and later abuse correlation. +- The first projection uses one table per log family: `message_log_entry`, `audit_log_entry`, and `access_log_entry`; passive security signals use the domain event table `security_signal_event`. +- Projection writes must happen after the normal file-log payload has been normalized/redacted and must never store raw secrets, credentials, API keys, visitor-cookie material, captcha answers, unredacted tokenized URLs, or duplicated full raw log lines. +- Message projections keep a level because message logs carry meaningful severities. Security signals keep severity. Current access and audit projections omit level fields because their existing writers only emit one operational level and the value would not support useful filtering. +- Database projection retention is configurable but bounded to 1-30 days. Purge happens directly after successful writes so expired lookup rows do not persist indefinitely when the scheduler is unavailable. Read paths must also bound selected time windows by the configured retention so quiet sites or recently lowered retention settings cannot expose older projected rows until the next write. +- Setup requests may already write file logs before the database exists. Database log projections and passive-signal storage must check the existing database-ready boundary before any DBAL read/write, including retention-setting lookups; when `APP_SETUP_COMPLETED` is not truthy and no explicit unready override is active, they must no-op instead of touching Doctrine/DBAL. +- Admin Logs and `/api/v1/admin/logs/**` read through `AdminLogBrowser`: structured message, audit, access, and security-signal sources come from database projections, while the Symfony application log remains a deliberate file-backed source because it is not projected into the database. The file-backed source is limited to the existing reverse-tail window and uses stable synthetic file-line IDs for detail links. +- This branch preserves the existing Admin Logs access boundary. Fine-grained visibility/mutation restrictions for security-signal rows, IP-bearing access projections, exports, cleanup actions, and future signal review decisions are deferred to `feat-security-admin-acl-enforcement`, where Owner-only or configurable ACL gates can be applied consistently across Admin UI, Admin API, live operations, and service boundaries. +- Log browsing is split by source tabs. Each tab exposes only meaningful filters and compact columns for that log family. +- The free-text search must remain broad enough to match values not shown in the compact table, including request IDs, visitor IDs, user/API identifiers, subject identifiers, route names, paths, IP-derived fields within retention, and raw redacted context JSON. +- JSON context search must stay portable across SQLite, MariaDB/MySQL, and PostgreSQL. Database-backed log browsing casts JSON context columns to text for broad case-insensitive free-text matching instead of applying string operators directly to JSON-typed columns or materializing the full result set in PHP. +- Log browsing must use explicit bounded page sizes only. The former `all` option is intentionally treated as a 500-row page with normal pagination so large logs cannot silently exhaust memory/time or hide rows behind a misleading one-page view. The effective page must be clamped before fetching rows so UI/API metadata and row contents stay consistent after filters reduce the total. +- The log-level/severity filter is multi-select and appears only for sources where multiple levels are meaningful, such as message and security-signal events. By default, `DEBUG` and `INFO` are hidden for those sources to keep Admin review usable; callers may explicitly include them. Access and audit logs do not expose or apply a level filter. +- Security identity uses Symfony's resolved request client IP as provided by deployment/webserver configuration. This branch does not add app-level trusted-proxy settings and security signals, rate-limit subjects, bans, GeoIP, and audit decisions must not trust raw forwarding headers. Operators should configure trusted reverse proxies at the webserver/Symfony boundary, for example through `mod_remoteip` or equivalent server config. +- Visitor ID generation may use raw forwarding-header values only as untrusted differentiation entropy, for example to reduce accidental visitor merging when the same resolved IP presents different `X-Forwarded-For` chains. Those raw header values must not become Security subject keys, GeoIP inputs, ban keys, or signal evidence. +- Visitor ID remains the primary continuity key for browser traffic so different browsers behind the same untrusted proxy can still receive separate visitor subjects. This is a deliberate trade-off: spoofable forwarding entropy may change a cookie-less fallback Visitor-ID, but it must not change the stable IP-bucket HMAC derived from Symfony's resolved client IP. Later rate-limit and auto-ban enforcement must evaluate Visitor-ID and IP-bucket evidence together, with IP thresholds kept laxer than Visitor-ID thresholds to reduce false positives on shared or untrusted-network IPs while still catching clients that rotate cookies or forwarding entropy. +- Prefetch detection uses `X-Sec-Purpose: prefetch` and `Sec-Purpose: prefetch`; spoofable hints only lower confidence for classification, never bypass checks. +- Signals store only normalized subject keys, intent, reason code, count/weight, timestamps, and safe request metadata. +- Subject resolution emits visitor, IP-bucket, authenticated-user, API-key, safe API-key-prefix, and combined subjects. IP buckets and combined IP subjects are HMAC-derived and never expose raw IP addresses; invalid Bearer tokens may contribute only a validated public prefix, never submitted secret material. +- Probe-path detection is configurable through a simple editable pattern-list setting, not a raw JSON field. The UI should present one regular expression per line and may accept quoted CSV imports for convenience; unquoted newline entries must be preserved as-is so commas inside regex syntax such as `{4,6}` remain valid. Empty or invalid lists fall back to protected defaults for `.env`, VCS metadata, backup/database dumps, common foreign admin panels, upload shells, and known scanner paths. +- The normalized probe-pattern list may use a small Symfony-native cache to keep the passive subscriber out of the request-time configuration hot path. Security settings saves must invalidate that cache, and the future unified caching strategy should re-evaluate whether this feature-local cache should move into a shared namespace/invalidation model. +- Request classification is passive and deterministic. `/api/live/**`, safe browser prefetch, and anonymous CORS preflight receive no ordinary enforcement cost in this branch; credentialed API preflights are classified by their requested method, while suspicious probes, exact setup review apply, and mutating admin/API workflows receive higher symbolic costs for later limiter branches. +- Public-facing unsafe requests that are not classified as a more specific workflow, for example future contact alternatives, comments, forum posts, extension-provided public forms, or other user-submitted public content actions, must fall back to the dedicated `website_form`/`FormSubmit` bucket instead of the cheap navigation bucket. +- Contact forms, captcha challenges, and extension-owned public workflows must not be inferred from invented or path-only slugs such as `/contact` or `/captcha/refresh`. Public content may legitimately use those slugs; later feature branches must opt into special intents through real route names, explicit workflow metadata, or provider-owned `/api/live/**` endpoints. +- `PassiveAbuseSignalSubscriber` records only clear passive signals in this branch, starting with high-signal probe paths and unsafe prefetch attempts. `SuspiciousPayloadSignalSubscriber` adds the same source-subject signal shape for obvious GET/POST abuse payloads such as scalar-only security parameter malforming, clear SQL-injection probes, path traversal, sensitive file probes, JNDI lookups, and script-tag probes. Payload scanning is limited to public/untrusted request surfaces and skips Admin, Editor, Setup, and trusted-user contexts because legitimate code/template/content fields may intentionally contain strings such as `'); + $this->writeFile('private-assets/script.py', 'print("no")'); + + $result = (new ExtensionValidator())->validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4), + ); + + self::assertFalse($result->isSuccess()); + self::assertSame( + ['asset_executable_file', 'asset_executable_file', 'asset_executable_file'], + array_map(static fn ($issue): string => $issue->context()['reason'], $result->issues()), + ); + } + + public function testItAcceptsPrivateAssetsWithoutAddingThemToSyncAssets(): void + { + $this->writeFile('private-assets/icons/icon.svg', ''); + $this->writeFile('private-assets/index.json', '{"icons": ["icon"]}'); + + $result = (new ExtensionValidator())->validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4)->withJsonLinting(), + ); + + self::assertTrue($result->isSuccess()); + + /** @var ExtensionInspection $inspection */ + $inspection = $result->context()['inspection']; + + self::assertSame([], $inspection->assetFiles()); + self::assertSame(['private-assets/index.json'], $inspection->jsonFiles()); + } + + public function testItAllowsCommittedDependencyPayloadsWithMatchingManifests(): void + { + $this->writeFile('composer.json', '{"name": "aavion/demo-extension", "type": "library", "require": {"php": ">=8.4"}}'); + $this->writeFile('composer.lock', $this->emptyComposerLock()); + $this->writeFile('vendor/vendor/extension/src/Broken.php', 'writeFile('assets/package.json', '{"dependencies": {"library": "1.0.0"}}'); + $this->writeFile('assets/package-lock.json', '{"lockfileVersion": 3}'); + $this->writeFile('assets/node_modules/library/broken.js', 'const = ;'); + + $result = (new ExtensionValidator())->validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(6)->withLintingChecks(), + ); + + self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItValidatesExtensionComposerManifestWhenPresent(): void + { + $this->writeFile('composer.json', '{"name": "Invalid Name"}'); + $this->writeFile('composer.lock', $this->emptyComposerLock()); + $this->writeFile('vendor/autoload.php', 'validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4)->withJsonLinting(), + ); + + self::assertFalse($result->isSuccess()); + self::assertSame('extension.policy.blocked_path', $result->firstIssue()?->code()); + self::assertSame('composer_manifest_invalid', $result->firstIssue()?->context()['reason']); + } + + public function testItBlocksDirectPhpCapabilitiesForInstallableExtensions(): void + { + $this->writeFile('extension.php', <<<'PHP' + writeFile('src/Runner.php', <<<'PHP' + validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4), + ); + + self::assertFalse($result->isSuccess()); + + $policyIssues = array_values(array_filter( + $result->issues(), + static fn ($issue): bool => 'extension.policy.blocked_php_capability' === $issue->code(), + )); + + self::assertCount(4, $policyIssues); + self::assertSame(['file_get_contents', 'putenv', 'exec', '\ZipArchive'], array_map( + static fn ($issue): string => $issue->context()['capability'], + $policyIssues, + )); + self::assertSame(['direct_filesystem', 'direct_environment', 'direct_process', 'direct_filesystem'], array_map( + static fn ($issue): string => $issue->context()['reason'], + $policyIssues, + )); + } + + public function testItBlocksDynamicPhpCapabilityBypassesForInstallableExtensions(): void + { + $this->writeFile('extension.php', <<<'PHP' + validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4), + ); + + self::assertFalse($result->isSuccess()); + + $policyIssues = array_values(array_filter( + $result->issues(), + static fn ($issue): bool => 'extension.policy.blocked_php_capability' === $issue->code(), + )); + + self::assertSame(['$reader()', 'call_user_func', 'ReflectionFunction'], array_map( + static fn ($issue): string => $issue->context()['capability'], + $policyIssues, + )); + self::assertSame(['dynamic_callable', 'dynamic_callable', 'dynamic_introspection'], array_map( + static fn ($issue): string => $issue->context()['reason'], + $policyIssues, + )); + } + + public function testItBlocksStringLiteralPhpCallableBypassesForInstallableExtensions(): void + { + $this->writeFile('extension.php', <<<'PHP' + validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4), + ); + + self::assertFalse($result->isSuccess()); + + $policyIssues = array_values(array_filter( + $result->issues(), + static fn ($issue): bool => 'extension.policy.blocked_php_capability' === $issue->code(), + )); + + self::assertSame(['exec()', 'file_get_contents()'], array_map( + static fn ($issue): string => $issue->context()['capability'], + $policyIssues, + )); + self::assertSame(['direct_process', 'direct_filesystem'], array_map( + static fn ($issue): string => $issue->context()['reason'], + $policyIssues, + )); + } + + public function testItBlocksCallableExpressionPhpBypassesForInstallableExtensions(): void + { + $this->writeFile('extension.php', <<<'PHP' + validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4), + ); + + self::assertFalse($result->isSuccess()); + + $policyIssues = array_values(array_filter( + $result->issues(), + static fn ($issue): bool => 'extension.policy.blocked_php_capability' === $issue->code(), + )); + + self::assertSame(['callable_expression()', 'callable_expression()'], array_map( + static fn ($issue): string => $issue->context()['capability'], + $policyIssues, + )); + self::assertSame(['dynamic_callable', 'dynamic_callable'], array_map( + static fn ($issue): string => $issue->context()['reason'], + $policyIssues, + )); + } + + public function testItBlocksRawRequestSuperglobalsForInstallableExtensions(): void + { + $this->writeFile('extension.php', <<<'PHP' + validate( + $this->candidate(), + ExtensionSpec::create()->withInventoryDepth(4), + ); + + self::assertFalse($result->isSuccess()); + + $policyIssues = array_values(array_filter( + $result->issues(), + static fn ($issue): bool => 'extension.policy.blocked_php_capability' === $issue->code(), + )); + + self::assertSame([ + '$_GET', + '$_POST', + '$_COOKIE', + '$_REQUEST', + '$_SESSION', + '$_FILES', + '$_SERVER', + ], array_map( + static fn ($issue): string => $issue->context()['capability'], + $policyIssues, + )); + self::assertSame(array_fill(0, 7, 'direct_request_context'), array_map( + static fn ($issue): string => $issue->context()['reason'], + $policyIssues, + )); + } + + private function candidate(): ExtensionCandidate + { + return new ExtensionCandidate( + ExtensionSource::children('extension', 'extensions'), + $this->extensionDir, + $this->extensionDir.'/.manifest', + new Manifest(['EXTENSION_SLUG' => 'system', 'EXTENSION_NAME' => 'System']), + ); + } + + private function candidateWithScope(string $scope): ExtensionCandidate + { + return $this->candidateWithManifest(['EXTENSION_SCOPE' => $scope]); + } + + /** + * @param array $manifest + */ + private function candidateWithManifest(array $manifest): ExtensionCandidate + { + $slug = $manifest['EXTENSION_SLUG'] ?? 'system'; + if (ExtensionManifestSpec::isValidSlug($slug) && basename($this->extensionDir) !== $slug) { + $targetDir = $this->rootDir.'/'.$slug; + rename($this->extensionDir, $targetDir); + $this->extensionDir = $targetDir; + } + + return new ExtensionCandidate( + ExtensionSource::children('extension', 'extensions'), + $this->extensionDir, + $this->extensionDir.'/.manifest', + new Manifest(['EXTENSION_SLUG' => 'system', 'EXTENSION_NAME' => 'System', ...$manifest]), + ); + } + + private function writeFile(string $relativePath, string $contents): void + { + $this->writeTestFile($this->extensionDir, $relativePath, $contents); + } + + private function emptyComposerLock(): string + { + return <<<'JSON' +{ + "_readme": [], + "content-hash": "test", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} +JSON; + } +} diff --git a/tests/Core/Extension/ExtensionZipInstallerTest.php b/tests/Core/Extension/ExtensionZipInstallerTest.php new file mode 100644 index 00000000..87b9da2d --- /dev/null +++ b/tests/Core/Extension/ExtensionZipInstallerTest.php @@ -0,0 +1,965 @@ +projectDir = (string) self::getContainer()->getParameter('kernel.project_dir'); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + + foreach (self::TEST_EXTENSION_SLUGS as $slug) { + $this->removePath($this->projectDir.'/extensions/'.$slug); + } + + $this->deleteContentFixture(); + + foreach (self::TEST_EXTENSION_DATABASE_SLUGS as $slug) { + $this->deleteExtensionRow($slug); + } + } + + protected function tearDown(): void + { + foreach (self::TEST_EXTENSION_SLUGS as $slug) { + $this->removePath($this->projectDir.'/extensions/'.$slug); + } + + foreach (self::TEST_EXTENSION_DATABASE_SLUGS as $slug) { + $this->deleteExtensionRow($slug); + } + + foreach (self::TEST_INSTALL_IDS as $installId) { + $this->removePath($this->installRoot($installId)); + } + + $this->deleteContentFixture(); + + parent::tearDown(); + } + + public function testItVerifiesZipAndReturnsReviewContinuation(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = 'aaaaaaaaaaaaaaaaaaaaaaaa'; + $this->writeUploadZip($installId, 'zip-install-review'); + + $result = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::RequiresReview, $result->status()); + self::assertSame('zip-install-review', $result->value()['extension']); + self::assertSame( + LiveOperationQueueFactory::EXTENSION_INSTALL_APPLY, + $result->context()['live_operation_continuation']['operation'], + ); + + $this->removePath($this->installRoot($installId)); + } + + public function testItInstallsZipAndRunsDiscovery(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = 'bbbbbbbbbbbbbbbbbbbbbbbb'; + $slug = 'zip-install-apply'; + $this->removePath($this->projectDir.'/extensions/'.$slug); + $this->deleteExtensionRow($slug); + $this->writeUploadZip($installId, $slug); + + $verify = $this->installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); + + $apply = $this->installer()->apply([ + 'install_id' => $installId, + 'extension' => $slug, + 'was_active' => false, + ]); + + self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); + self::assertFileExists($this->projectDir.'/extensions/'.$slug.'/.manifest'); + self::assertInstanceOf(Extension::class, $this->entityManager->getRepository(Extension::class)->findOneBy([ + 'extensionName' => $slug, + ])); + + $this->removePath($this->projectDir.'/extensions/'.$slug); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + } + + public function testItInstallsFlatRootZipIntoManifestSlugDirectory(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '171717171717171717171717'; + $slug = 'zip-install-flat'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->writeUploadZip($installId, $slug, flatRoot: true); + + $verify = $this->installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status(), json_encode($verify->toArray(), JSON_THROW_ON_ERROR)); + self::assertSame($slug, $verify->value()['extension']); + + $apply = $this->installer()->apply([ + 'install_id' => $installId, + 'extension' => $slug, + 'was_active' => false, + ]); + + self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); + self::assertFileExists($target.'/.manifest'); + self::assertFileExists($target.'/README.md'); + self::assertSame(ExtensionStatus::Inactive, $this->extensionStatus($slug)); + + $this->removePath($target); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + } + + public function testItRejectsApplyPayloadWithInvalidInstallId(): void + { + $apply = $this->installer()->apply([ + 'install_id' => '../outside', + 'extension' => 'zip-install-apply', + ]); + + self::assertSame(WorkflowStatus::Invalid, $apply->status()); + self::assertSame('E_INVALID_ARGUMENT', $apply->firstIssue()?->code()); + } + + public function testItRejectsApplyPayloadWhenExtensionDoesNotMatchManifest(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '161616161616161616161616'; + $manifestSlug = 'zip-install-review'; + $payloadSlug = 'zip-install-apply'; + $this->removePath($this->projectDir.'/extensions/'.$manifestSlug); + $this->removePath($this->projectDir.'/extensions/'.$payloadSlug); + $this->deleteExtensionRow($manifestSlug); + $this->deleteExtensionRow($payloadSlug); + $this->writeUploadZip($installId, $manifestSlug); + + $verify = $this->installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); + + $apply = $this->installer()->apply([ + 'install_id' => $installId, + 'extension' => $payloadSlug, + 'was_active' => false, + ]); + + self::assertSame(WorkflowStatus::Invalid, $apply->status()); + self::assertSame('extension.install.zip_invalid', $apply->firstIssue()?->code()); + self::assertSame('extension_mismatch', $apply->firstIssue()?->context()['reason'] ?? null); + self::assertFileDoesNotExist($this->projectDir.'/extensions/'.$manifestSlug); + self::assertFileDoesNotExist($this->projectDir.'/extensions/'.$payloadSlug); + + $this->removePath($this->installRoot($installId)); + } + + public function testItBlocksUnsafeActiveOverwriteBeforeDeactivation(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = 'cccccccccccccccccccccccc'; + $slug = 'zip-install-rollback'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->writeExtensionDirectory($target, $slug, '1.0.0', 'old extension'); + $this->persistExtension($slug, ExtensionStatus::Active); + $this->writeUploadZip( + $installId, + $slug, + dependencies: '[["missing-extension", "1.0.0"]]', + version: '1.1.0', + readme: "new extension\n", + ); + + $verify = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::Blocked, $verify->status()); + self::assertStringContainsString('old extension', (string) file_get_contents($target.'/README.md')); + self::assertSame(ExtensionStatus::Active, $this->extensionStatus($slug)); + + $this->removePath($target); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + } + + public function testItBlocksSameVersionOverwriteForInstalledExtensions(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = 'eeeeeeeeeeeeeeeeeeeeeeee'; + $slug = 'zip-install-rollback'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->writeExtensionDirectory($target, $slug, '1.0.0', 'old extension'); + $this->persistExtension($slug, ExtensionStatus::Inactive); + $this->writeUploadZip($installId, $slug, version: '1.0.0', readme: "same version\n"); + + $verify = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::Blocked, $verify->status()); + self::assertSame('extension.install.version_blocked', $verify->firstIssue()?->code()); + self::assertStringContainsString('old extension', (string) file_get_contents($target.'/README.md')); + self::assertSame(ExtensionStatus::Inactive, $this->extensionStatus($slug)); + + $this->removePath($target); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + } + + public function testItAllowsSameVersionRecoveryForRemovedExtensions(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '999999999999999999999999'; + $slug = 'zip-install-rollback'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->persistExtension($slug, ExtensionStatus::Removed); + $this->writeUploadZip($installId, $slug, version: '1.0.0', readme: "same version recovery\n"); + + $verify = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); + + $this->removePath($target); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + } + + public function testItBlocksOlderExtensionVersionsAgainstRegistry(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = 'ffffffffffffffffffffffff'; + $slug = 'zip-install-rollback'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->writeExtensionDirectory($target, $slug, '1.1.0', 'old extension'); + $this->persistExtension($slug, ExtensionStatus::Removed, version: '1.1.0'); + $this->writeUploadZip($installId, $slug, version: '1.0.0', readme: "older extension\n"); + + $verify = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::Blocked, $verify->status()); + self::assertSame('extension.install.version_blocked', $verify->firstIssue()?->code()); + self::assertStringContainsString('old extension', (string) file_get_contents($target.'/README.md')); + self::assertSame(ExtensionStatus::Removed, $this->extensionStatus($slug)); + + $this->removePath($target); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + } + + public function testItRejectsSymlinkEntriesBeforeExtraction(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '777777777777777777777777'; + $slug = 'zip-install-symlink'; + $root = $this->installRoot($installId); + $this->removePath($root); + mkdir($root, 0775, true); + + $zip = new ZipArchive(); + self::assertTrue(true === $zip->open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); + $zip->addFromString($slug.'/.manifest', <<addFromString($slug.'/assets/host-file.txt', '/etc/passwd'); + self::assertTrue($zip->setExternalAttributesName( + $slug.'/assets/host-file.txt', + ZipArchive::OPSYS_UNIX, + 0o120777 << 16, + )); + $zip->close(); + + $verify = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::Invalid, $verify->status()); + self::assertSame('extension.install.zip_invalid', $verify->firstIssue()?->code()); + self::assertSame('symlink_entry', $verify->firstIssue()?->context()['reason'] ?? null); + + $this->removePath($root); + } + + public function testItRejectsPolicyBlockedExtensionPaths(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '888888888888888888888888'; + $slug = 'zip-install-symlink'; + $root = $this->installRoot($installId); + $this->removePath($root); + mkdir($root, 0775, true); + + $zip = new ZipArchive(); + self::assertTrue(true === $zip->open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); + $zip->addFromString($slug.'/.manifest', <<addFromString($slug.'/public/index.php', 'close(); + + $verify = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::Invalid, $verify->status()); + self::assertSame('extension.policy.blocked_path', $verify->firstIssue()?->code()); + self::assertSame('reserved_project_path', $verify->firstIssue()?->context()['reason']); + + $this->removePath($root); + } + + public function testItRejectsDeepPolicyBlockedExtensionPaths(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '999999999999999999999999'; + $slug = 'zip-install-deep-policy'; + $root = $this->installRoot($installId); + $this->removePath($root); + mkdir($root, 0775, true); + + $zip = new ZipArchive(); + self::assertTrue(true === $zip->open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); + $zip->addFromString($slug.'/.manifest', <<addFromString($slug.'/assets/a/b/c/d/shell.php', 'close(); + + $verify = $this->installer()->verify(['install_id' => $installId]); + + self::assertSame(WorkflowStatus::Invalid, $verify->status()); + self::assertSame('extension.policy.blocked_path', $verify->firstIssue()?->code()); + self::assertSame('asset_executable_file', $verify->firstIssue()?->context()['reason']); + + $this->removePath($root); + } + + public function testItSkipsDevelopmentArtifactsWhenApplyingZip(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '121212121212121212121212'; + $slug = 'zip-install-skip'; + $target = $this->projectDir.'/extensions/'.$slug; + $root = $this->installRoot($installId); + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->removePath($root); + mkdir($root, 0775, true); + + $zip = new ZipArchive(); + self::assertTrue(true === $zip->open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); + $zip->addFromString($slug.'/.manifest', <<addFromString($slug.'/README.md', "# ZIP Install Test\n"); + $zip->addFromString($slug.'/docs/readme.md', "Extension docs\n"); + $zip->addFromString($slug.'/.editorconfig', "root = true\n"); + $zip->addFromString($slug.'/.git/config', "[core]\n"); + $zip->addFromString($slug.'/tests/BrokenTest.php', 'close(); + + $verify = $this->installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status(), json_encode($verify->toArray(), JSON_THROW_ON_ERROR)); + + $apply = $this->installer()->apply([ + 'install_id' => $installId, + 'extension' => $slug, + 'was_active' => false, + ]); + + self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); + self::assertFileExists($target.'/.manifest'); + self::assertFileExists($target.'/docs/readme.md'); + self::assertFileDoesNotExist($target.'/.editorconfig'); + self::assertFileDoesNotExist($target.'/.git/config'); + self::assertFileDoesNotExist($target.'/tests/BrokenTest.php'); + + $this->removePath($target); + $this->removePath($root); + $this->deleteExtensionRow($slug); + } + + public function testItRestoresActiveReverseDependentsAfterSuccessfulOverwrite(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = 'dddddddddddddddddddddddd'; + $slug = 'zip-install-dependent'; + $dependentSlug = 'zip-install-dependent-addon'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->deleteExtensionRow($dependentSlug); + $this->writeExtensionDirectory($target, $slug, '1.0.0', 'old extension'); + $this->writeExtensionDirectory( + $this->projectDir.'/extensions/'.$dependentSlug, + $dependentSlug, + '1.0.0', + 'dependent extension', + sprintf('[["%s", "1.0"]]', $slug), + ); + $this->persistExtension($slug, ExtensionStatus::Active, scopes: [ExtensionScope::Module, ExtensionScope::ContentSchema]); + $this->persistExtension( + $dependentSlug, + ExtensionStatus::Active, + dependencies: sprintf('[["%s", "1.0"]]', $slug), + ); + $this->writeUploadZip($installId, $slug, version: '1.1.0', readme: "new extension\n"); + + $verify = $this->installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); + + $apply = $this->installer()->apply([ + 'install_id' => $installId, + 'extension' => $slug, + 'was_active' => true, + ]); + + self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringContainsString('new extension', (string) file_get_contents($target.'/README.md')); + self::assertSame(ExtensionStatus::Active, $this->extensionStatus($slug)); + self::assertSame(ExtensionStatus::Active, $this->extensionStatus($dependentSlug)); + self::assertSame('1.1.0', $this->extensionVersion($slug)); + + $this->removePath($target); + $this->removePath($this->projectDir.'/extensions/'.$dependentSlug); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + $this->deleteExtensionRow($dependentSlug); + } + + public function testItDeactivatesStaleActiveReverseDependentsWhenOverwritingInactiveExtension(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '141414141414141414141414'; + $slug = 'zip-install-dependent'; + $dependentSlug = 'zip-install-dependent-addon'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->deleteExtensionRow($dependentSlug); + $this->writeExtensionDirectory($target, $slug, '1.0.0', 'old extension'); + $this->writeExtensionDirectory( + $this->projectDir.'/extensions/'.$dependentSlug, + $dependentSlug, + '1.0.0', + 'dependent extension', + sprintf('[["%s", "1.0"]]', $slug), + ); + $this->persistExtension($slug, ExtensionStatus::Inactive); + $this->persistExtension( + $dependentSlug, + ExtensionStatus::Active, + dependencies: sprintf('[["%s", "1.0"]]', $slug), + ); + $this->writeUploadZip($installId, $slug, version: '1.1.0', readme: "new extension\n"); + + $verify = $this->installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); + self::assertContains($dependentSlug, $verify->value()['deactivate']); + + $apply = $this->installer()->apply([ + 'install_id' => $installId, + 'extension' => $slug, + 'was_active' => false, + ]); + + self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringContainsString('new extension', (string) file_get_contents($target.'/README.md')); + self::assertSame(ExtensionStatus::Inactive, $this->extensionStatus($slug)); + self::assertSame(ExtensionStatus::Inactive, $this->extensionStatus($dependentSlug)); + self::assertSame('1.1.0', $this->extensionVersion($slug)); + + $this->removePath($target); + $this->removePath($this->projectDir.'/extensions/'.$dependentSlug); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + $this->deleteExtensionRow($dependentSlug); + } + + public function testItRestoresArchivedContentAfterSuccessfulActiveOverwrite(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '131313131313131313131313'; + $slug = 'zip-install-content'; + $target = $this->projectDir.'/extensions/'.$slug; + $this->removePath($target); + $this->deleteContentFixture(); + $this->deleteExtensionRow($slug); + $this->writeExtensionDirectory($target, $slug, '1.0.0', 'old extension'); + $this->persistExtension($slug, ExtensionStatus::Active); + $extension = $this->entityManager->getRepository(Extension::class)->findOneBy(['extensionName' => $slug]); + self::assertInstanceOf(Extension::class, $extension); + $this->createPublishedExtensionContent($extension); + $this->writeUploadZip($installId, $slug, version: '1.1.0', readme: "new extension\n"); + + $verify = $this->installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); + + $apply = $this->installer()->apply([ + 'install_id' => $installId, + 'extension' => $slug, + 'was_active' => true, + ]); + + self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); + self::assertSame(ExtensionStatus::Active, $this->extensionStatus($slug)); + self::assertSame(ContentStatus::Published, $this->contentStatus('c5000000-0000-7000-8000-000000000001')); + + $this->removePath($target); + $this->removePath($this->installRoot($installId)); + $this->deleteContentFixture(); + $this->deleteExtensionRow($slug); + } + + public function testItRebuildsAssetsAfterActivationRollbackDuringActiveOverwrite(): void + { + if (!class_exists(ZipArchive::class)) { + self::markTestSkipped('ZipArchive is required for extension ZIP installer tests.'); + } + + $installId = '151515151515151515151515'; + $slug = 'zip-install-rollback'; + $target = $this->projectDir.'/extensions/'.$slug; + $assetRebuilder = new RecordingInstallAssetRebuilder(); + $this->removePath($target); + $this->deleteExtensionRow($slug); + $this->writeExtensionDirectory($target, $slug, '1.0.0', 'old extension'); + $this->persistExtension($slug, ExtensionStatus::Active); + $this->writeUploadZip( + $installId, + $slug, + version: '1.1.0', + readme: "new extension\n", + extensionPhp: <<<'PHP' + installer()->verify(['install_id' => $installId]); + self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); + + $apply = $this->applier($assetRebuilder)->apply([ + 'install_id' => $installId, + 'extension' => $slug, + 'was_active' => true, + ]); + + self::assertFalse($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); + self::assertTrue($apply->context()['rollback_asset_rebuild'] ?? false); + self::assertTrue($apply->context()['rollback_asset_rebuild_success'] ?? false); + self::assertSame(['test'], $assetRebuilder->environments); + self::assertStringContainsString('EXTENSION_VERSION=1.0.0', (string) file_get_contents($target.'/.manifest')); + self::assertSame(ExtensionStatus::Active, $this->extensionStatus($slug)); + + $this->removePath($target); + $this->removePath($this->installRoot($installId)); + $this->deleteExtensionRow($slug); + } + + private function installer(): ExtensionZipInstaller + { + $installer = self::getContainer()->get(ExtensionZipInstaller::class); + self::assertInstanceOf(ExtensionZipInstaller::class, $installer); + + return $installer; + } + + private function applier(ExtensionLifecycleAssetRebuilderInterface $assetRebuilder): ExtensionInstallApplier + { + return new ExtensionInstallApplier( + self::getContainer()->get(ExtensionDiscoveryRunner::class), + self::getContainer()->get(ExtensionActivator::class), + self::getContainer()->get(ExtensionInstallFilesystem::class), + self::getContainer()->get(ExtensionInstallPayload::class), + self::getContainer()->get(ExtensionInstallStageReader::class), + self::getContainer()->get(ExtensionInstallRegistry::class), + self::getContainer()->get(ExtensionInstallVersionGuard::class), + self::getContainer()->get(ExtensionReplacementPreflight::class), + self::getContainer()->get(ExtensionInstallRollbacker::class), + self::getContainer()->get(ExtensionReactivationPlanner::class), + $assetRebuilder, + 'test', + ); + } + + private function writeUploadZip( + string $installId, + string $slug, + string $dependencies = '[]', + string $version = '1.0.0', + string $readme = "# ZIP Install Test\n", + ?string $extensionPhp = null, + bool $flatRoot = false, + ): void { + $root = $this->installRoot($installId); + $source = $root.'/source/'.$slug; + $this->removePath($root); + mkdir($source, 0775, true); + file_put_contents($source.'/.manifest', <<open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); + $zip->addFile($source.'/.manifest', $prefix.'.manifest'); + $zip->addFile($source.'/README.md', $prefix.'README.md'); + if (null !== $extensionPhp) { + $zip->addFile($source.'/extension.php', $prefix.'extension.php'); + } + $zip->close(); + } + + private function writeExtensionDirectory( + string $target, + string $slug, + string $version, + string $readme, + string $dependencies = '[]', + ): void + { + mkdir($target, 0775, true); + file_put_contents($target.'/.manifest', <<projectDir.'/var/cache/test/extension-installs/'.$installId; + } + + private function persistExtension( + string $slug, + ExtensionStatus $status, + string $dependencies = '[]', + string $version = '1.0.0', + array $scopes = [ExtensionScope::Module], + ): void + { + $this->entityManager->persist(new Extension( + $this->uuid(), + $scopes, + $slug, + 'extensions/'.$slug, + $status, + [ + 'registry_state' => 'available', + 'manifest' => [ + 'EXTENSION_DEPENDENCIES' => $dependencies, + ], + ], + manifestVersion: $version, + installedVersion: $version, + )); + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + private function extensionStatus(string $slug): ExtensionStatus + { + $extension = $this->entityManager->getRepository(Extension::class)->findOneBy([ + 'extensionName' => $slug, + ]); + + self::assertInstanceOf(Extension::class, $extension); + + return $extension->status(); + } + + private function extensionVersion(string $slug): ?string + { + $extension = $this->entityManager->getRepository(Extension::class)->findOneBy([ + 'extensionName' => $slug, + ]); + + self::assertInstanceOf(Extension::class, $extension); + + return $extension->installedVersion(); + } + + private function createPublishedExtensionContent(Extension $extension): void + { + $schemaSync = new ExtensionContentSchemaSynchronizer($this->entityManager); + $schemaApply = $schemaSync->apply($extension, [ + ExtensionContentSchemaDefinition::create('article', ['en' => 'Article'], [ + 'fields' => [ + ['identifier' => 'title', 'type' => 'string'], + ['identifier' => 'subtitle', 'type' => 'string'], + ['identifier' => 'body', 'type' => 'text'], + ], + ]), + ]); + self::assertTrue($schemaApply->isSuccess(), json_encode($schemaApply->toArray(), JSON_THROW_ON_ERROR)); + + $schema = $this->entityManager->getRepository(ContentSchema::class)->findOneBy([ + 'identifier' => 'ext19_zip_install_content_article', + ]); + self::assertInstanceOf(ContentSchema::class, $schema); + self::assertNotNull($schema->activeVersion()); + + $content = new ContentItem('c5000000-0000-7000-8000-000000000001', 'zip-install-content-item'); + $content->activateRevision(new ContentRevision('c5000000-0000-7000-8000-000000000101', $content, 1, $schema->activeVersion())); + $content->publish(); + $this->entityManager->persist($content); + $this->entityManager->flush(); + $this->entityManager->clear(); + } + + private function contentStatus(string $uid): ContentStatus + { + $content = $this->entityManager->find(ContentItem::class, $uid); + self::assertInstanceOf(ContentItem::class, $content); + + return $content->status(); + } + + private function deleteContentFixture(): void + { + $connection = $this->entityManager->getConnection(); + $connection->executeStatement("DELETE FROM content_revision WHERE content_uid = 'c5000000-0000-7000-8000-000000000001'"); + $connection->executeStatement("DELETE FROM content_item WHERE uid = 'c5000000-0000-7000-8000-000000000001'"); + + $schemaUid = $connection->fetchOne("SELECT uid FROM content_schema WHERE identifier = 'ext19_zip_install_content_article'"); + if (is_string($schemaUid)) { + $connection->executeStatement('UPDATE content_schema SET active_version_uid = NULL WHERE uid = :schema', ['schema' => $schemaUid]); + $connection->executeStatement('DELETE FROM content_schema_version WHERE schema_uid = :schema', ['schema' => $schemaUid]); + $connection->executeStatement('DELETE FROM content_schema WHERE uid = :schema', ['schema' => $schemaUid]); + } + + $this->entityManager->clear(); + } + + private function deleteExtensionRow(string $slug): void + { + $this->entityManager->createQueryBuilder() + ->delete(Extension::class, 'ext') + ->where('ext.extensionName = :extension') + ->setParameter('extension', $slug) + ->getQuery() + ->execute(); + $this->entityManager->clear(); + } + + private function uuid(): string + { + return Uuid::v7()->toRfc4122(); + } + + private function removePath(string $path): void + { + if (!file_exists($path)) { + return; + } + + if (is_file($path) || is_link($path)) { + unlink($path); + + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + if (!$item instanceof \SplFileInfo) { + continue; + } + + $item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($path); + } +} + +final class RecordingInstallAssetRebuilder implements ExtensionLifecycleAssetRebuilderInterface +{ + /** + * @var list + */ + public array $environments = []; + + public function rebuild(string $environment): WorkflowResult + { + $this->environments[] = $environment; + + return WorkflowResult::success(context: ['environment' => $environment]); + } +} diff --git a/tests/Core/Geo/GeoIpResolverTest.php b/tests/Core/Geo/GeoIpResolverTest.php new file mode 100644 index 00000000..3b5026b9 --- /dev/null +++ b/tests/Core/Geo/GeoIpResolverTest.php @@ -0,0 +1,142 @@ + 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('203.0.113.10')->toArray()); + + self::assertSame('maxmind', $resolver->status()->providerKey); + self::assertSame('unconfigured', $resolver->status()->status); + } + + public function testItReportsFallbackStatusWhenNoProviderIsConfigured(): void + { + $resolver = new GeoIpResolver([], new NullGeoIpProvider()); + + self::assertSame('none', $resolver->status()->providerKey); + self::assertSame('disabled', $resolver->status()->status); + } + + public function testItUsesFirstReadyProvider(): void + { + $resolver = new GeoIpResolver([ + new FakeGeoIpProvider(GeoIpProviderStatus::unconfigured('disabled')), + new FakeGeoIpProvider( + GeoIpProviderStatus::ready('maxmind', databaseEdition: 'GeoLite2-City'), + new GeoIpResult('Berlin', 'Berlin', 'Germany', 'Europe'), + ), + ], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'Berlin', + 'state' => 'Berlin', + 'country' => 'Germany', + 'continent' => 'Europe', + ], $resolver->resolve('203.0.113.10')->toArray()); + self::assertSame('maxmind', $resolver->status()->providerKey); + self::assertSame('GeoLite2-City', $resolver->status()->databaseEdition); + } + + public function testItFallsBackWhenReadyProviderThrows(): void + { + $resolver = new GeoIpResolver([ + new FakeGeoIpProvider(GeoIpProviderStatus::ready('maxmind'), throws: true), + ], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('203.0.113.10')->toArray()); + } + + public function testItDoesNotCallProvidersWithoutAnIpAddress(): void + { + $provider = new FakeGeoIpProvider( + GeoIpProviderStatus::ready('maxmind'), + new GeoIpResult('Berlin', 'Berlin', 'Germany', 'Europe'), + ); + $resolver = new GeoIpResolver([$provider], new NullGeoIpProvider()); + + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $resolver->resolve('')->toArray()); + self::assertSame(0, $provider->lookupCount); + } + + public function testProviderStatusContainsOnlySafeDiagnosticFields(): void + { + $status = GeoIpProviderStatus::ready( + 'maxmind', + databaseEdition: 'GeoLite2-City', + databaseBuildDate: '2026-06-15', + ); + + self::assertSame([ + 'provider_key' => 'maxmind', + 'status' => 'ready', + 'database_edition' => 'GeoLite2-City', + 'database_build_date' => '2026-06-15', + 'failure_code' => null, + ], $status->toSafeArray()); + } +} + +final class FakeGeoIpProvider implements GeoIpProviderInterface +{ + public int $lookupCount = 0; + + public function __construct( + private readonly GeoIpProviderStatus $status, + private readonly GeoIpResult $result = new GeoIpResult(), + private readonly bool $throws = false, + ) { + } + + public function key(): string + { + return $this->status->providerKey; + } + + public function status(): GeoIpProviderStatus + { + return $this->status; + } + + public function resolve(?string $ipAddress): GeoIpResult + { + ++$this->lookupCount; + + if ($this->throws) { + throw new RuntimeException('GeoIP provider failure'); + } + + return $this->result; + } +} diff --git a/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php b/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php new file mode 100644 index 00000000..23172bec --- /dev/null +++ b/tests/Core/Geo/HttpMaxMindGeoIpDownloadClientTest.php @@ -0,0 +1,184 @@ +download('https://example.test/geoip.tar.gz', $target); + + self::assertTrue($result->isSuccess()); + self::assertSame('archive-chunk', file_get_contents($target)); + self::assertArrayNotHasKey('buffer', $client->lastOptions); + } finally { + @unlink($target); + @rmdir($workspace); + } + } +} + +final class ChunkedGeoIpHttpClient implements HttpClientInterface +{ + /** @var array */ + public array $lastOptions = []; + + /** + * @param list $chunks + */ + public function __construct(private readonly int $statusCode, private readonly array $chunks) + { + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $this->lastOptions = $options; + + return new ThrowingContentGeoIpResponse($this->statusCode); + } + + public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface + { + $response = $responses instanceof ResponseInterface ? $responses : iterator_to_array($responses)[0]; + + return new ChunkedGeoIpResponseStream($response, array_map( + static fn (string $content): ChunkInterface => new GeoIpResponseChunk($content), + $this->chunks, + )); + } + + public function withOptions(array $options): static + { + return $this; + } +} + +final class ThrowingContentGeoIpResponse implements ResponseInterface +{ + public function __construct(private readonly int $statusCode) + { + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getHeaders(bool $throw = true): array + { + return []; + } + + public function getContent(bool $throw = true): string + { + throw new \LogicException('The download client must stream chunks instead of materializing the response body.'); + } + + public function toArray(bool $throw = true): array + { + return []; + } + + public function cancel(): void + { + } + + public function getInfo(?string $type = null): mixed + { + return null === $type ? ['http_code' => $this->statusCode] : null; + } +} + +final class ChunkedGeoIpResponseStream implements ResponseStreamInterface +{ + private int $position = 0; + + /** + * @param list $chunks + */ + public function __construct(private readonly ResponseInterface $response, private readonly array $chunks) + { + } + + public function current(): ChunkInterface + { + return $this->chunks[$this->position]; + } + + public function next(): void + { + ++$this->position; + } + + public function key(): ResponseInterface + { + return $this->response; + } + + public function valid(): bool + { + return isset($this->chunks[$this->position]); + } + + public function rewind(): void + { + $this->position = 0; + } +} + +final class GeoIpResponseChunk implements ChunkInterface +{ + public function __construct(private readonly string $content) + { + } + + public function isTimeout(): bool + { + return false; + } + + public function isFirst(): bool + { + return false; + } + + public function isLast(): bool + { + return false; + } + + public function getInformationalStatus(): ?array + { + return null; + } + + public function getContent(): string + { + return $this->content; + } + + public function getOffset(): int + { + return 0; + } + + public function getError(): ?string + { + return null; + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php new file mode 100644 index 00000000..046c784d --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpArchiveExtractorTest.php @@ -0,0 +1,120 @@ +workspaceDir = $this->createTemporaryDirectory('maxmind-geoip-archive'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->workspaceDir); + } + + public function testItExtractsReadableDatabaseFromSafeArchive(): void + { + $archivePath = $this->archivePath('safe', [ + 'GeoLite2-City/GeoLite2-City.mmdb' => 'database', + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertTrue($result->isSuccess()); + self::assertSame('database', file_get_contents($result->value()['database_path'])); + } + + public function testItRejectsUnsafeArchivePaths(): void + { + $archivePath = $this->archivePath('unsafe', [ + '../escape.mmdb' => 'database', + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, $result->firstIssue()?->code()); + self::assertSame('unsafe_archive_path', $result->context()['reason'] ?? null); + self::assertFileDoesNotExist(dirname($this->workspaceDir).DIRECTORY_SEPARATOR.'escape.mmdb'); + } + + public function testItRejectsUnsupportedArchiveEntryTypes(): void + { + $archivePath = $this->archivePath('unsupported-type', [ + 'GeoLite2-City/link' => ['type' => '2', 'link' => '/tmp'], + ]); + + $result = (new MaxMindGeoIpArchiveExtractor())->extractDatabase($archivePath, $this->workspaceDir); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_ARCHIVE_INVALID, $result->firstIssue()?->code()); + self::assertSame('unsafe_archive_path', $result->context()['reason'] ?? null); + } + + /** + * @param array $files + */ + private function archivePath(string $name, array $files): string + { + $archivePath = $this->workspaceDir.DIRECTORY_SEPARATOR.$name.'.tar.gz'; + file_put_contents($archivePath, gzencode($this->tarContents($files))); + + return $archivePath; + } + + /** + * @param array $files + */ + private function tarContents(array $files): string + { + $tar = ''; + + foreach ($files as $path => $entry) { + $contents = is_array($entry) ? (string) ($entry['contents'] ?? '') : $entry; + $type = is_array($entry) ? (string) ($entry['type'] ?? '0') : '0'; + $link = is_array($entry) ? (string) ($entry['link'] ?? '') : ''; + $tar .= $this->tarHeader($path, strlen($contents), $type, $link); + $tar .= $contents; + $tar .= str_repeat("\0", (512 - (strlen($contents) % 512)) % 512); + } + + return $tar.str_repeat("\0", 1024); + } + + private function tarHeader(string $path, int $size, string $type = '0', string $link = ''): string + { + $header = str_pad(substr($path, 0, 100), 100, "\0"); + $header .= str_pad('0000644', 8, "\0"); + $header .= str_pad('0000000', 8, "\0"); + $header .= str_pad('0000000', 8, "\0"); + $header .= str_pad(decoct($size), 11, '0', STR_PAD_LEFT)."\0"; + $header .= str_pad(decoct(0), 11, '0', STR_PAD_LEFT)."\0"; + $header .= str_repeat(' ', 8); + $header .= $type[0] ?? '0'; + $header .= str_pad(substr($link, 0, 100), 100, "\0"); + $header .= "ustar\0"; + $header .= "00"; + $header .= str_repeat("\0", 247); + $header = str_pad($header, 512, "\0"); + $checksum = 0; + + for ($index = 0; $index < 512; ++$index) { + $checksum += ord($header[$index]); + } + + return substr_replace($header, str_pad(decoct($checksum), 6, '0', STR_PAD_LEFT)."\0 ", 148, 8); + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpConfigTest.php b/tests/Core/Geo/MaxMindGeoIpConfigTest.php new file mode 100644 index 00000000..a502def3 --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpConfigTest.php @@ -0,0 +1,86 @@ +connection())); + + self::assertFalse($config->enabled()); + self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $config->databasePath()); + self::assertSame(['en'], $config->locales()); + self::assertSame('', $config->licenseKey()); + self::assertFalse($config->hasLicenseKey()); + } + + public function testItUsesConfiguredDefaultLanguageForLocalesAndNormalizesSensitiveSettings(): void + { + $connection = $this->connection(); + $store = new Config($connection); + $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); + $store->set('localization.default_language', 'de', ConfigValueType::String); + $store->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, ' test-license ', ConfigValueType::String, sensitive: true); + + $config = new MaxMindGeoIpConfig($store); + + self::assertTrue($config->enabled()); + self::assertSame(['de', 'en'], $config->locales()); + self::assertSame('test-license', $config->licenseKey()); + self::assertTrue($config->hasLicenseKey()); + self::assertStringContainsString('license_key=test-license', $config->downloadUrl()); + } + + public function testItResolvesSafeProjectRelativeDatabasePath(): void + { + $store = new Config($this->connection()); + $config = new MaxMindGeoIpConfig($store); + $projectDir = 'project-root'; + + self::assertSame( + $projectDir.DIRECTORY_SEPARATOR.'var'.DIRECTORY_SEPARATOR.'geoip2'.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb', + $config->databaseAbsolutePath($projectDir), + ); + + $store->set(MaxMindGeoIpConfig::DATABASE_PATH_KEY, '../secret.mmdb', ConfigValueType::String); + + self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath($projectDir)); + } + + public function testItRejectsAbsoluteOrEscapingDatabasePaths(): void + { + $store = new Config($this->connection()); + $projectDir = 'project-root'; + + foreach ([ + '/secret.mmdb', + '//server/share/secret.mmdb', + '\\\\server\\share\\secret.mmdb', + 'C:/secret.mmdb', + 'C:secret.mmdb', + 'var/../secret.mmdb', + ] as $unsafePath) { + $store->set(MaxMindGeoIpConfig::DATABASE_PATH_KEY, $unsafePath, ConfigValueType::String); + + self::assertNull((new MaxMindGeoIpConfig($store))->databaseAbsolutePath($projectDir), $unsafePath); + } + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php new file mode 100644 index 00000000..835bc4cd --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpDatabaseUpdaterTest.php @@ -0,0 +1,231 @@ +projectDir = $this->createTemporaryDirectory('maxmind-geoip-updater'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectDir); + } + + public function testItFailsWithoutLicenseKey(): void + { + $updater = new MaxMindGeoIpDatabaseUpdater( + new MaxMindGeoIpConfig(new Config($this->connection())), + new SuccessfulGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + + $result = $updater->update('scheduler'); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $result->firstIssue()?->code()); + self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $result->firstIssue()?->translationKey()); + } + + public function testItDownloadsValidatesAndReplacesDatabase(): void + { + $config = $this->configuredConfig('test-license'); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new SuccessfulGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor('new database'), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'old database'); + + $result = $updater->update('admin_ui'); + + self::assertTrue($result->isSuccess()); + self::assertSame(['database_path' => MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH], $result->value()); + self::assertSame('new database', file_get_contents($this->defaultDatabasePath())); + if ('\\' !== DIRECTORY_SEPARATOR) { + self::assertSame('0644', substr(sprintf('%o', fileperms($this->defaultDatabasePath()) ?: 0), -4)); + } + self::assertSame(GeoIpMessageKey::GEOIP_DOWNLOAD_COMPLETED, $result->messages()[0]->translationKey()); + } + + public function testItForwardsDownloadFailureWithoutLeakingLicenseKey(): void + { + $config = $this->configuredConfig('sensitive-test-license'); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new FailingGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + + $result = $updater->update('admin_ui'); + $encoded = json_encode($result->toArray(), JSON_THROW_ON_ERROR); + + self::assertFalse($result->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, $result->firstIssue()?->code()); + self::assertStringNotContainsString('sensitive-test-license', $encoded); + } + + public function testOperationExecutionStateDoesNotLeakLicenseKey(): void + { + $licenseKey = 'sensitive-operation-license'; + $config = $this->configuredConfig($licenseKey); + $updater = new MaxMindGeoIpDatabaseUpdater( + $config, + new FailingGeoIpDownloadClient(), + new SuccessfulGeoIpArchiveExtractor(), + new ValidGeoIpReaderFactory(), + $this->projectDir, + ); + $executor = new OperationExecutor(new SilentWorkflowResultMessageReporter()); + $queue = ActionQueue::create('geoip database update', [ + new MaxMindGeoIpUpdateAction($updater, 'admin_ui'), + ], context: [ + 'operation' => 'geoip.database_update', + 'environment' => 'test', + 'trigger' => 'admin_ui', + ]); + + $execution = $executor->executeQueue($queue); + $encoded = json_encode($execution->toArray(), JSON_THROW_ON_ERROR); + + self::assertFalse($execution->result()->isSuccess()); + self::assertStringNotContainsString($licenseKey, $encoded); + self::assertStringNotContainsString('license_key=', $encoded); + } + + private function configuredConfig(string $licenseKey): MaxMindGeoIpConfig + { + $store = new Config($this->connection()); + $store->set(MaxMindGeoIpConfig::LICENSE_KEY_KEY, $licenseKey, ConfigValueType::String, sensitive: true); + + return new MaxMindGeoIpConfig($store); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private function defaultDatabasePath(): string + { + return $this->projectDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH); + } +} + +final readonly class SilentWorkflowResultMessageReporter implements WorkflowResultMessageReporterInterface +{ + public function report(WorkflowResult $result, array $operationContext = []): WorkflowResult + { + return $result; + } +} + +final readonly class SuccessfulGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + file_put_contents($targetPath, 'archive'); + + return WorkflowResult::success(); + } +} + +final readonly class FailingGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + return WorkflowResult::failed([ + Message::error( + GeoIpMessageCode::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + GeoIpMessageKey::GEOIP_DOWNLOAD_INVALID_LICENSE_KEY, + context: ['stage' => 'download', 'http_status' => 401], + ), + ], ['stage' => 'download', 'http_status' => 401]); + } +} + +final readonly class SuccessfulGeoIpArchiveExtractor implements MaxMindGeoIpArchiveExtractorInterface +{ + public function __construct(private string $databaseContents = 'database') + { + } + + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult + { + $databasePath = $workspaceDir.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb'; + file_put_contents($databasePath, $this->databaseContents); + + return WorkflowResult::success(['database_path' => $databasePath]); + } +} + +final readonly class ValidGeoIpReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + return new ValidGeoIpReader(); + } +} + +final readonly class ValidGeoIpReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public function city(string $ipAddress): City + { + return new City([]); + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => 'GeoLite2-City', + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpProviderTest.php b/tests/Core/Geo/MaxMindGeoIpProviderTest.php new file mode 100644 index 00000000..e1a787de --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpProviderTest.php @@ -0,0 +1,228 @@ +projectDir = $this->createTemporaryDirectory('maxmind-geoip-provider'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectDir); + } + + public function testItStaysDisabledByDefault(): void + { + $factory = new RecordingMaxMindReaderFactory($this->reader()); + $provider = new MaxMindGeoIpProvider(new MaxMindGeoIpConfig(new Config($this->connection())), $factory, $this->projectDir); + + self::assertSame('maxmind', $provider->key()); + self::assertSame('disabled', $provider->status()->status); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(0, $factory->openCount); + } + + public function testItReportsMissingConfiguredDatabaseAsUnconfigured(): void + { + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($this->reader()), $this->projectDir); + + self::assertSame('unconfigured', $provider->status()->status); + self::assertSame('database_missing', $provider->status()->failureCode); + } + + public function testItReadsLocalDatabaseThroughGeoIp2Boundary(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader(); + $factory = new RecordingMaxMindReaderFactory($reader); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), $factory, $this->projectDir); + + self::assertSame('ready', $provider->status()->status); + self::assertSame('GeoLite2-City', $provider->status()->databaseEdition); + self::assertSame('2026-06-15', $provider->status()->databaseBuildDate); + self::assertSame([ + 'city' => 'Berlin', + 'state' => 'Berlin', + 'country' => 'Germany', + 'continent' => 'Europe', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(1, $factory->openCount); + self::assertSame($this->defaultDatabasePath(), $factory->lastDatabasePath); + self::assertSame(['en'], $factory->lastLocales); + self::assertSame(1, $reader->cityLookupCount); + } + + public function testItDoesNotLookupPrivateOrInvalidIpAddresses(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader(); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($reader), $this->projectDir); + + self::assertSame('ready', $provider->status()->status); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('127.0.0.1')->toArray()); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('not-an-ip')->toArray()); + self::assertSame(0, $reader->cityLookupCount); + } + + public function testItReportsUnreadableDatabaseWithoutLeakingPath(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new ThrowingMaxMindReaderFactory(), $this->projectDir); + + $status = $provider->status(); + + self::assertSame('unavailable', $status->status); + self::assertSame('database_unreadable', $status->failureCode); + self::assertStringNotContainsString($this->projectDir, json_encode($status->toSafeArray(), JSON_THROW_ON_ERROR)); + } + + public function testItRejectsReadableNonCityDatabasesBeforeReportingReady(): void + { + $this->writeTestFile($this->projectDir, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, 'fake mmdb'); + $reader = $this->reader('GeoLite2-Country'); + $provider = new MaxMindGeoIpProvider($this->enabledConfig(), new RecordingMaxMindReaderFactory($reader), $this->projectDir); + + $status = $provider->status(); + + self::assertSame('unavailable', $status->status); + self::assertSame('database_unsupported', $status->failureCode); + self::assertSame([ + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $provider->resolve('8.8.8.8')->toArray()); + self::assertSame(0, $reader->cityLookupCount); + } + + private function enabledConfig(): MaxMindGeoIpConfig + { + $store = new Config($this->connection()); + $store->set(MaxMindGeoIpConfig::ENABLED_KEY, true, ConfigValueType::Boolean); + + return new MaxMindGeoIpConfig($store); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private function reader(string $databaseType = 'GeoLite2-City'): RecordingMaxMindReader + { + return new RecordingMaxMindReader(new City([ + 'city' => ['names' => ['en' => 'Berlin']], + 'subdivisions' => [ + ['names' => ['en' => 'Berlin'], 'iso_code' => 'BE'], + ], + 'country' => ['names' => ['en' => 'Germany'], 'iso_code' => 'DE'], + 'continent' => ['names' => ['en' => 'Europe'], 'code' => 'EU'], + ]), $databaseType); + } + + private function defaultDatabasePath(): string + { + return $this->projectDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH); + } +} + +final class RecordingMaxMindReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public int $openCount = 0; + public ?string $lastDatabasePath = null; + /** @var list */ + public array $lastLocales = []; + + public function __construct(private readonly RecordingMaxMindReader $reader) + { + } + + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + ++$this->openCount; + $this->lastDatabasePath = $databasePath; + $this->lastLocales = $locales; + + return $this->reader; + } +} + +final class ThrowingMaxMindReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + throw new RuntimeException('Cannot open local test database.'); + } +} + +final class RecordingMaxMindReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public int $cityLookupCount = 0; + + public function __construct(private readonly City $city, private readonly string $databaseType = 'GeoLite2-City') + { + } + + public function city(string $ipAddress): City + { + ++$this->cityLookupCount; + + return $this->city; + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => $this->databaseType, + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php new file mode 100644 index 00000000..ad47848d --- /dev/null +++ b/tests/Core/Geo/MaxMindGeoIpSchedulerProviderTest.php @@ -0,0 +1,115 @@ +updater()); + $task = $provider->schedulerTasks()[0]; + + self::assertSame('system.geoip2_database_update', $task->identifier()); + self::assertSame(SchedulerTaskType::Callable, $task->type()); + self::assertSame('system.geoip2.database_update', $task->target()); + self::assertSame('0 3 * * *', $task->defaultCronExpression()); + self::assertTrue($task->trusted()); + } + + public function testCallableReportsMissingLicenseKeyAsFailure(): void + { + $provider = new MaxMindGeoIpSchedulerProvider($this->updater()); + $callable = $provider->schedulerCallable('system.geoip2.database_update'); + + self::assertNotNull($callable); + + $execution = $callable(); + + self::assertFalse($execution->isSuccess()); + self::assertSame(GeoIpMessageCode::GEOIP_DOWNLOAD_MISSING_LICENSE_KEY, $execution->messages()[0]->code()); + } + + private function updater(): MaxMindGeoIpDatabaseUpdater + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return new MaxMindGeoIpDatabaseUpdater( + new MaxMindGeoIpConfig(new Config($connection)), + new SchedulerGeoIpDownloadClient(), + new SchedulerGeoIpArchiveExtractor(), + new SchedulerGeoIpReaderFactory(), + sys_get_temp_dir(), + ); + } +} + +final readonly class SchedulerGeoIpDownloadClient implements MaxMindGeoIpDownloadClientInterface +{ + public function download(string $url, string $targetPath): WorkflowResult + { + file_put_contents($targetPath, 'archive'); + + return WorkflowResult::success(); + } +} + +final readonly class SchedulerGeoIpArchiveExtractor implements MaxMindGeoIpArchiveExtractorInterface +{ + public function extractDatabase(string $archivePath, string $workspaceDir): WorkflowResult + { + $databasePath = $workspaceDir.DIRECTORY_SEPARATOR.'GeoLite2-City.mmdb'; + file_put_contents($databasePath, 'database'); + + return WorkflowResult::success(['database_path' => $databasePath]); + } +} + +final readonly class SchedulerGeoIpReaderFactory implements MaxMindGeoIpDatabaseReaderFactoryInterface +{ + public function open(string $databasePath, array $locales): MaxMindGeoIpDatabaseReaderInterface + { + return new SchedulerGeoIpReader(); + } +} + +final readonly class SchedulerGeoIpReader implements MaxMindGeoIpDatabaseReaderInterface +{ + public function city(string $ipAddress): City + { + return new City([]); + } + + public function metadata(): Metadata + { + return new Metadata([ + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1781481600, + 'database_type' => 'GeoLite2-City', + 'languages' => ['en'], + 'description' => ['en' => 'Test database'], + 'ip_version' => 6, + 'node_count' => 1, + 'record_size' => 24, + ]); + } +} diff --git a/tests/Core/Geo/NullGeoIpResolverTest.php b/tests/Core/Geo/NullGeoIpResolverTest.php index fff7b10e..e8df0a54 100644 --- a/tests/Core/Geo/NullGeoIpResolverTest.php +++ b/tests/Core/Geo/NullGeoIpResolverTest.php @@ -20,4 +20,12 @@ public function testItReturnsNormalizedPlaceholders(): void 'continent' => 'n/a', ], $result->toArray()); } + + public function testItReportsDisabledStatus(): void + { + $status = (new NullGeoIpResolver())->status(); + + self::assertSame('none', $status->providerKey); + self::assertSame('disabled', $status->status); + } } diff --git a/tests/Core/Lint/LinterTest.php b/tests/Core/Lint/LinterTest.php index cadd1a03..9fd5eee7 100644 --- a/tests/Core/Lint/LinterTest.php +++ b/tests/Core/Lint/LinterTest.php @@ -23,7 +23,7 @@ public static function validSourceProvider(): iterable { yield 'php' => [new PhpLinter(), ' [new TwigLinter(), '
{{ title }}
']; - yield 'twig extensions' => [new TwigLinter(), '{{ "pkg.demo-module.title"|trans }}{% if item is studio_visible %}{{ package_settings("demo-module")|length }}{% endif %}']; + yield 'twig extensions' => [new TwigLinter(), '{{ "ext.demo-module.title"|trans }}{% if item is studio_visible %}{{ extension_settings("demo-module")|length }}{% endif %}']; yield 'json' => [new JsonLinter(), '{"enabled": true}']; yield 'yaml' => [new YamlLinter(), 'enabled: true']; yield 'css' => [new CssLinter(), 'body { color: red; }']; @@ -66,8 +66,8 @@ public function testItReportsInvalidSource(LinterInterface $linter, string $sour public function testCssLinterAcceptsCommentOnlyRegistryStubs(): void { $result = (new CssLinter())->lint(<<<'CSS' -/* Generated CSS package asset registry. */ -/* Package lifecycle owns this file after activation changes. */ +/* Generated CSS extension asset registry. */ +/* Extension lifecycle owns this file after activation changes. */ CSS); self::assertTrue($result->isSuccess()); diff --git a/tests/Core/Log/AccessLogSubscriberTest.php b/tests/Core/Log/AccessLogSubscriberTest.php index a6b3dbc0..94c4c537 100644 --- a/tests/Core/Log/AccessLogSubscriberTest.php +++ b/tests/Core/Log/AccessLogSubscriberTest.php @@ -13,6 +13,7 @@ use App\Core\Statistics\AccessStatisticsRecorderInterface; use App\Core\Statistics\VisitorIdGenerator; use App\Database\DatabaseReadyState; +use App\Security\AutoBan\AutoBanRequestSubscriber; use App\Setup\SetupCompletionMarker; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -74,6 +75,57 @@ public function testItLogsSetupRequestsWhileDatabaseIsNotReady(): void self::assertSame(['/setup/admin'], $accessLogger->paths); self::assertSame([], $statisticsRecorder->records); } + + public function testItLogsAutoBanForbiddenResponsesForAuditCorrelation(): void + { + $accessLogger = new RecordingAccessLogger(); + $statisticsRecorder = new RecordingAccessStatisticsRecorder(); + $request = Request::create('/missing', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $response = new Response('blocked', Response::HTTP_FORBIDDEN); + + (new AccessLogSubscriber( + $accessLogger, + $statisticsRecorder, + new AccessRequestMetadata(), + new VisitorIdGenerator('test-secret'), + ))->onKernelResponse(new ResponseEvent( + new AccessSubscriberTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + self::assertSame(['/missing'], $accessLogger->paths); + self::assertSame([ + ['path' => '/missing', 'status' => Response::HTTP_FORBIDDEN], + ], $statisticsRecorder->records); + } + + public function testItLogsAutoBanForbiddenResponsesForIgnorablePaths(): void + { + $accessLogger = new RecordingAccessLogger(); + $statisticsRecorder = new RecordingAccessStatisticsRecorder(); + $request = Request::create('/favicon.ico', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $request->attributes->set(AccessRequestMetadata::FORCE_ACCESS_LOG_ATTRIBUTE, true); + $response = new Response('blocked', Response::HTTP_FORBIDDEN); + + (new AccessLogSubscriber( + $accessLogger, + $statisticsRecorder, + new AccessRequestMetadata(), + new VisitorIdGenerator('test-secret'), + ))->onKernelResponse(new ResponseEvent( + new AccessSubscriberTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + )); + + self::assertSame(['/favicon.ico'], $accessLogger->paths); + self::assertSame([], $statisticsRecorder->records); + } } final class RecordingAccessLogger implements AccessLoggerInterface diff --git a/tests/Core/Log/AccessRequestMetadataTest.php b/tests/Core/Log/AccessRequestMetadataTest.php index bfa401a2..7e59186b 100644 --- a/tests/Core/Log/AccessRequestMetadataTest.php +++ b/tests/Core/Log/AccessRequestMetadataTest.php @@ -4,7 +4,13 @@ namespace App\Tests\Core\Log; +use App\Content\Routing\ContentRouteLocalization; +use App\Core\Config\Config; +use App\Core\Config\ConfigValueType; use App\Core\Log\AccessRequestMetadata; +use App\Localization\TranslationLanguageCatalog; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -28,6 +34,8 @@ public function testItDerivesOperationalRequestMetadata(): void self::assertIsInt($metadata->durationMs($request)); self::assertSame('admin', $metadata->surface($request)); self::assertSame('api', $metadata->surface(Request::create('/api/v1/status'))); + self::assertSame('public', $metadata->surface(Request::create('/apiary'))); + self::assertSame('public', $metadata->surface(Request::create('/docs/api/reference'))); self::assertSame('backend_admin_route', $metadata->resolvedRoute($request)); self::assertSame('https://example.org/source', $metadata->referrer($request)); self::assertSame('example.org', $metadata->referrerHost($request)); @@ -89,4 +97,34 @@ public function testItRedactsSensitiveReferrerPathSegments(): void self::assertSame('https://example.org/user/invitation/[redacted]', $metadata->referrer($request)); } + + public function testItGatesLocalizedSurfacePrefixesByRouteLocaleOrEnabledRoutePrefixes(): void + { + $disabled = new AccessRequestMetadata($this->routeLocalization(false)); + $enabled = new AccessRequestMetadata($this->routeLocalization(true)); + $localizedRoute = Request::create('/de/admin/logs'); + $localizedRoute->attributes->set('_locale', 'de'); + + self::assertSame('public', $disabled->surface(Request::create('/de/admin'))); + self::assertSame('public', $disabled->surface(Request::create('/de/api/v1/status'))); + self::assertSame('admin', $disabled->surface($localizedRoute)); + self::assertSame('admin', $enabled->surface(Request::create('/de/admin/logs'))); + self::assertSame('public', $enabled->surface(Request::create('/de/api/v1/status'))); + } + + private function routeLocalization(bool $enabled): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, $enabled, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, new TranslationLanguageCatalog(dirname(__DIR__, 3))); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } } diff --git a/tests/Core/Log/AdminLogBrowserTest.php b/tests/Core/Log/AdminLogBrowserTest.php new file mode 100644 index 00000000..2643018e --- /dev/null +++ b/tests/Core/Log/AdminLogBrowserTest.php @@ -0,0 +1,60 @@ +logDir = $this->createTemporaryDirectory('system-admin-log-browser'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->logDir); + } + + public function testItCombinesApplicationFileLogWithDatabaseSources(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure","request_id":"application-request"} []'.PHP_EOL); + + $browser = new AdminLogBrowser( + new DatabaseLogBrowser($connection), + new LogFileBrowser($this->logDir, 'test'), + ); + + $applicationView = $browser->browse(['source' => 'application', 'level' => 'ERROR', 'q' => 'application-request']); + self::assertSame(['application', 'message', 'audit', 'access', 'security_signal'], array_column($applicationView['sources'], 'key')); + self::assertSame('application', $applicationView['selected_source']); + self::assertTrue($applicationView['capabilities']['level_filter']); + self::assertSame(1, $applicationView['pagination']['total']); + self::assertSame('app.failure', $applicationView['entries'][0]['message']); + + $applicationEntry = $browser->entry('application', $applicationView['entries'][0]['id']); + self::assertNotNull($applicationEntry); + self::assertSame('app.failure', $applicationEntry['message']); + + $staleFilterView = $browser->browse([ + 'source' => 'application', + 'level' => 'ERROR', + 'audit_action' => 'audit.unrelated', + ]); + self::assertSame('', $staleFilterView['filters']['audit_action']); + self::assertSame(1, $staleFilterView['pagination']['total']); + } +} diff --git a/tests/Core/Log/AuditLoggerTest.php b/tests/Core/Log/AuditLoggerTest.php index b9cbce1f..d135e3a3 100644 --- a/tests/Core/Log/AuditLoggerTest.php +++ b/tests/Core/Log/AuditLoggerTest.php @@ -26,9 +26,9 @@ public function testItWritesAuditActionsWithActorContext(): void (new AuditLogger($monolog))->log( AccessActor::fromAccess(9, ['site_operations'], '10000000-0000-7000-8000-000000000001', 'admin'), - 'package.activate', + 'extension.activate', [ - 'package' => 'demo-module', + 'extension' => 'demo-module', 'api_token' => 'secret', ], ); @@ -37,11 +37,11 @@ public function testItWritesAuditActionsWithActorContext(): void self::assertCount(1, $records); self::assertSame(Level::Info, $records[0]->level); - self::assertSame('package.activate', $records[0]->message); + self::assertSame('extension.activate', $records[0]->message); self::assertSame('admin', $records[0]->context['user']); self::assertSame(9, $records[0]->context['user_access_level']); - self::assertSame('package.activate', $records[0]->context['action']); - self::assertSame('demo-module', $records[0]->context['context']['package']); + self::assertSame('extension.activate', $records[0]->context['action']); + self::assertSame('demo-module', $records[0]->context['context']['extension']); self::assertSame('[redacted]', $records[0]->context['context']['api_token']); } diff --git a/tests/Core/Log/ConfigAuditLogPolicyTest.php b/tests/Core/Log/ConfigAuditLogPolicyTest.php index d2aa961d..21d356da 100644 --- a/tests/Core/Log/ConfigAuditLogPolicyTest.php +++ b/tests/Core/Log/ConfigAuditLogPolicyTest.php @@ -17,9 +17,9 @@ public function testItAllowsProductionDefaultAuditCategories(): void $policy = new ConfigAuditLogPolicy(new Config($this->connection())); self::assertTrue($policy->allows('auth.login_success')); - self::assertTrue($policy->allows('backend.action.package_discovery')); + self::assertTrue($policy->allows('backend.action.extension_discovery')); self::assertTrue($policy->allows('operations.cleanup')); - self::assertTrue($policy->allows('package.lifecycle.activate')); + self::assertTrue($policy->allows('extension.lifecycle.activate')); self::assertTrue($policy->allows('settings.core.save')); self::assertTrue($policy->allows('future.audit_event')); } @@ -44,7 +44,7 @@ public function testItFiltersConfiguredCategories(): void $policy = new ConfigAuditLogPolicy($config); self::assertTrue($policy->allows('settings.core.save')); - self::assertFalse($policy->allows('package.lifecycle.activate')); + self::assertFalse($policy->allows('extension.lifecycle.activate')); } public function testItPreservesEmptyAuditCategorySelections(): void diff --git a/tests/Core/Log/DatabaseLogBrowserTest.php b/tests/Core/Log/DatabaseLogBrowserTest.php new file mode 100644 index 00000000..46a55a69 --- /dev/null +++ b/tests/Core/Log/DatabaseLogBrowserTest.php @@ -0,0 +1,347 @@ + 'pdo_sqlite', 'memory' => true]); + $this->createTables($connection); + $now = '2026-06-16 12:00:00'; + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => $now, + 'level' => 'INFO', + 'message' => 'message.test', + 'code' => 'test.message', + 'context' => '{"code":"test.message"}', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => $now, + 'expires_at' => '2026-06-17 12:00:00', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.env', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-1', + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-1', + 'visitor_id' => 'visitor-1', + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{"source":"security.probe.env"}', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000005', + 'occurred_at' => $now, + 'expires_at' => '2026-06-16 11:59:59', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.expired', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-expired', + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-expired', + 'visitor_id' => 'visitor-expired', + 'path' => '/.git/config', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{"source":"security.probe.expired"}', + ]); + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000003', + 'occurred_at' => $now, + 'request_id' => 'request-access', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'surface' => 'admin', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => null, + 'visitor_id' => 'visitor-hidden-search', + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '203.0.113.10', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Example Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => null, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => '{"hidden":"visitor-hidden-search"}', + ]); + $connection->insert('audit_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000004', + 'occurred_at' => $now, + 'user_name' => 'admin', + 'user_uid' => '99999999-0000-7000-8000-000000000100', + 'user_access_level' => 8, + 'action' => 'audit.test', + 'request_id' => 'request-audit', + 'visitor_id' => 'visitor-audit', + 'requested_path' => '/admin/users', + 'resolved_route' => 'backend_admin_route', + 'context' => '{"hidden":"visitor-audit"}', + ]); + $browser = new DatabaseLogBrowser($connection, clock: new MockClock($now)); + $defaultView = $browser->browse(['source' => 'message']); + self::assertSame(0, $defaultView['pagination']['total']); + + $infoView = $browser->browse(['source' => 'message', 'level' => 'INFO']); + self::assertSame(1, $infoView['pagination']['total']); + self::assertSame(['INFO'], $infoView['filters']['levels']); + + $accessView = $browser->browse(['source' => 'access', 'q' => 'visitor-hidden-search']); + self::assertFalse($accessView['capabilities']['level_filter']); + self::assertSame([], $accessView['filters']['levels']); + self::assertSame(1, $accessView['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000003', $accessView['entries'][0]['id']); + self::assertSame('/admin/logs', $accessView['entries'][0]['context']['requested_path']); + self::assertSame('backend_admin_route', $accessView['entries'][0]['context']['resolved_route']); + + $auditView = $browser->browse(['source' => 'audit', 'level' => 'ERROR', 'q' => 'visitor-audit']); + self::assertFalse($auditView['capabilities']['level_filter']); + self::assertSame([], $auditView['filters']['levels']); + self::assertSame(1, $auditView['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000004', $auditView['entries'][0]['id']); + + $view = $browser->browse(['source' => 'security_signal', 'q' => 'security.probe.env']); + self::assertTrue($view['capabilities']['level_filter']); + self::assertTrue($view['capabilities']['signal_reason_filter']); + + self::assertSame(['message', 'audit', 'access', 'security_signal'], array_column($view['sources'], 'key')); + self::assertSame('security_signal', $view['selected_source']); + self::assertSame(1, $view['pagination']['total']); + self::assertSame('99999999-0000-7000-8000-000000000002', $view['entries'][0]['id']); + self::assertSame('probe: security.probe.env', $view['entries'][0]['summary']); + self::assertSame('visitor-1', $view['entries'][0]['context']['subject_identifier']); + self::assertSame(0, $browser->browse(['source' => 'security_signal', 'q' => 'security.probe.expired'])['pagination']['total']); + self::assertNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000005')); + + $entry = $browser->entry('message', '99999999-0000-7000-8000-000000000001'); + + self::assertNotNull($entry); + self::assertSame('message.test', $entry['message']); + } + + public function testItCapsFormerAllPageSizeAtFiveHundredRowsWithPagination(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $now = '2026-06-16 12:00:00'; + + for ($i = 1; $i <= 501; ++$i) { + $connection->insert('message_log_entry', [ + 'uid' => sprintf('99999999-0000-7000-8000-%012d', $i), + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.test', + 'code' => 'test.message', + 'context' => '{"code":"test.message"}', + ]); + } + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'per_page' => 'all', + 'page' => 999, + ]); + + self::assertSame(500, $view['filters']['per_page']); + self::assertSame(2, $view['filters']['page']); + self::assertSame(501, $view['pagination']['total']); + self::assertSame(2, $view['pagination']['total_pages']); + self::assertFalse($view['pagination']['has_next']); + self::assertCount(1, $view['entries']); + } + + public function testItHonorsConfiguredDatabaseRetentionWhenBrowsing(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(190) PRIMARY KEY NOT NULL, value CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => 'logging.database.message_retention_days', + 'value' => '1', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2026-06-16 12:00:00', + 'level' => 'NOTICE', + 'message' => 'message.current', + 'code' => 'test.current', + 'context' => '{}', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => '2026-06-14 12:00:00', + 'level' => 'NOTICE', + 'message' => 'message.expired_by_setting', + 'code' => 'test.expired', + 'context' => '{}', + ]); + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ + 'source' => 'message', + 'time_window' => '30d', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('message.current', $view['entries'][0]['message']); + self::assertNotNull((new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->entry('message', '99999999-0000-7000-8000-000000000001')); + self::assertNull((new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->entry('message', '99999999-0000-7000-8000-000000000002')); + } + + public function testItHonorsConfiguredSecuritySignalRetentionWhenBrowsing(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(190) PRIMARY KEY NOT NULL, value CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => 'security.signals.retention_days', + 'value' => (string) AutoBanPolicy::maxTtlDays(), + ]); + $this->insertSignal($connection, '99999999-0000-7000-8000-000000000001', '2026-06-16 12:00:00', '2026-06-23 12:00:00', 'current'); + $this->insertSignal($connection, '99999999-0000-7000-8000-000000000002', '2026-06-08 11:59:59', '2026-06-23 12:00:00', 'expired_by_setting'); + + $browser = new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')); + $view = $browser->browse([ + 'source' => 'security_signal', + 'time_window' => '30d', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('security.probe.current', $view['entries'][0]['message']); + self::assertNotNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000001')); + self::assertNull($browser->entry('security_signal', '99999999-0000-7000-8000-000000000002')); + } + + public function testItTreatsSqlLikeWildcardsAsLiteralSearchText(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $now = '2026-06-16 12:00:00'; + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.literal_percent_%', + 'code' => 'literal.percent', + 'context' => '{}', + ]); + $connection->insert('message_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000002', + 'occurred_at' => $now, + 'level' => 'NOTICE', + 'message' => 'message.unrelated', + 'code' => 'unrelated', + 'context' => '{}', + ]); + + $view = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'q' => '%', + ]); + + self::assertSame(1, $view['pagination']['total']); + self::assertSame('message.literal_percent_%', $view['entries'][0]['message']); + + $underscoreView = (new DatabaseLogBrowser($connection, clock: new MockClock($now)))->browse([ + 'source' => 'message', + 'q' => '_', + ]); + + self::assertSame(1, $underscoreView['pagination']['total']); + self::assertSame('message.literal_percent_%', $underscoreView['entries'][0]['message']); + } + + public function testItCastsJsonContextAndSearchesCaseInsensitivelyOnPostgreSql(): void + { + $connection = $this->createMock(Connection::class); + $connection->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); + $connection + ->expects(self::exactly(2)) + ->method('fetchOne') + ->willReturnCallback(static function (string $sql): mixed { + if (str_contains($sql, 'config_entry')) { + return false; + } + + self::assertStringContainsString("LOWER(CAST(context AS TEXT)) LIKE ? ESCAPE '!'", $sql); + + return 0; + }); + $connection + ->expects(self::once()) + ->method('fetchAllAssociative') + ->with(self::stringContains("LOWER(CAST(context AS TEXT)) LIKE ? ESCAPE '!'"), self::callback(static fn (array $params): bool => in_array('%scanner%', $params, true))) + ->willReturn([]); + + (new DatabaseLogBrowser($connection, clock: new MockClock('2026-06-16 12:00:00')))->browse([ + 'source' => 'security_signal', + 'q' => 'Scanner', + ]); + } + + private function createTables(\Doctrine\DBAL\Connection $connection): void + { + $connection->executeStatement('CREATE TABLE message_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, level VARCHAR(16) NOT NULL, message VARCHAR(255) NOT NULL, code VARCHAR(160) DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE audit_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, user_name VARCHAR(180) NOT NULL, user_uid VARCHAR(36) DEFAULT NULL, user_access_level INTEGER NOT NULL, action VARCHAR(160) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, requested_path VARCHAR(1024) NOT NULL, resolved_route VARCHAR(190) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + } + + private function insertSignal(Connection $connection, string $uid, string $occurredAt, string $expiresAt, string $reason): void + { + $connection->insert('security_signal_event', [ + 'uid' => $uid, + 'occurred_at' => $occurredAt, + 'expires_at' => $expiresAt, + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.'.$reason, + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'visitor', + 'subject_identifier' => 'visitor-'.$reason, + 'ip_derived' => 0, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'request-'.$reason, + 'visitor_id' => 'visitor-'.$reason, + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{}', + ]); + } +} diff --git a/tests/Core/Log/DatabaseLogProjectorTest.php b/tests/Core/Log/DatabaseLogProjectorTest.php new file mode 100644 index 00000000..758c109b --- /dev/null +++ b/tests/Core/Log/DatabaseLogProjectorTest.php @@ -0,0 +1,159 @@ +setEnvironment(SetupCompletionMarker::KEY, '0'); + $allowState = $this->setEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, '0'); + $connection = $this->createMock(Connection::class); + $connection->expects(self::never())->method('insert'); + $connection->expects(self::never())->method('fetchOne'); + $connection->expects(self::never())->method('executeStatement'); + + try { + $projector = new DatabaseLogProjector( + $connection, + new DatabaseLogRetentionPolicy($connection), + new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-system-project', 'test'), + ); + + $projector->recordAccess([ + 'request_id' => 'setup-request', + 'method' => 'GET', + 'path' => '/setup', + ]); + } finally { + $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); + $this->restoreEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, $allowState); + } + } + + public function testItWritesAndPurgesDatabaseLogProjectionRows(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $this->createTables($connection); + $connection->insert('config_entry', [ + 'config_key' => DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY, + 'value' => '1', + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-16 00:00:00', + 'modified_by' => 'test', + ]); + $connection->insert('access_log_entry', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2000-01-01 00:00:00', + 'request_id' => 'old', + 'correlation_id' => 'n/a', + 'method' => 'GET', + 'path' => '/old', + 'requested_path' => '/old', + 'route' => 'old', + 'resolved_route' => 'old', + 'surface' => 'public', + 'query_string' => '', + 'http_status' => 200, + 'duration_ms' => null, + 'visitor_id' => 'old-visitor', + 'scheme' => 'https', + 'host' => 'example.test', + 'client_ip' => '127.0.0.1', + 'proxy_client_ip' => 'n/a', + 'user_agent' => 'Old Browser', + 'referrer' => 'n/a', + 'referrer_host' => 'n/a', + 'accept_language' => 'en', + 'preferred_language' => 'en', + 'request_content_type' => 'n/a', + 'response_content_type' => 'text/html', + 'response_size' => null, + 'city' => 'n/a', + 'state' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + 'context' => '{}', + ]); + + $projector = new DatabaseLogProjector( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: new MockClock('2026-06-16 12:00:00'), + ); + $projector->recordAccess([ + 'request_id' => 'current', + 'method' => 'GET', + 'path' => '/admin/logs', + 'requested_path' => '/admin/logs', + 'route' => 'backend_admin_route', + 'resolved_route' => 'backend_admin_route', + 'http_status' => 200, + 'client_ip' => '203.0.113.1', + 'city' => str_repeat('x', 120), + ]); + + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM access_log_entry')); + self::assertSame('current', $connection->fetchOne('SELECT request_id FROM access_log_entry')); + self::assertSame('2026-06-16 12:00:00', $connection->fetchOne('SELECT occurred_at FROM access_log_entry')); + self::assertSame(80, strlen((string) $connection->fetchOne('SELECT city FROM access_log_entry'))); + } + + private function createTables(\Doctrine\DBAL\Connection $connection): void + { + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, correlation_id VARCHAR(64) NOT NULL, method VARCHAR(16) NOT NULL, path VARCHAR(1024) NOT NULL, requested_path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, resolved_route VARCHAR(190) NOT NULL, surface VARCHAR(40) NOT NULL, query_string VARCHAR(1024) NOT NULL, http_status INTEGER NOT NULL, duration_ms INTEGER DEFAULT NULL, visitor_id VARCHAR(64) NOT NULL, scheme VARCHAR(10) NOT NULL, host VARCHAR(255) NOT NULL, client_ip VARCHAR(45) NOT NULL, proxy_client_ip VARCHAR(45) NOT NULL, user_agent VARCHAR(500) NOT NULL, referrer VARCHAR(1024) NOT NULL, referrer_host VARCHAR(255) NOT NULL, accept_language VARCHAR(255) NOT NULL, preferred_language VARCHAR(20) NOT NULL, request_content_type VARCHAR(120) NOT NULL, response_content_type VARCHAR(120) NOT NULL, response_size INTEGER DEFAULT NULL, city VARCHAR(80) NOT NULL, state VARCHAR(80) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL, context CLOB NOT NULL)'); + } + + /** + * @return array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server_value' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env_value' => $_ENV[$key] ?? null, + 'getenv_value' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server_value']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env_value']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv_value'] ? putenv($key) : putenv($key.'='.$state['getenv_value']); + } +} diff --git a/tests/Core/Log/DatabaseLogRetentionPolicyTest.php b/tests/Core/Log/DatabaseLogRetentionPolicyTest.php new file mode 100644 index 00000000..8a1a06b4 --- /dev/null +++ b/tests/Core/Log/DatabaseLogRetentionPolicyTest.php @@ -0,0 +1,64 @@ +connection(); + $this->insertConfig($connection, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 1); + + self::assertSame(AutoBanPolicy::maxTtlDays(), (new DatabaseLogRetentionPolicy($connection))->retentionDaysForSignal()); + } + + public function testSecuritySignalRetentionIsCappedAtMaximumRetention(): void + { + $connection = $this->connection(); + $this->insertConfig($connection, DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, 365); + + self::assertSame(DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS, (new DatabaseLogRetentionPolicy($connection))->retentionDaysForSignal()); + } + + public function testLogRetentionSourcesKeepOneDayMinimum(): void + { + $connection = $this->connection(); + $this->insertConfig($connection, DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY, 1); + + self::assertSame(1, (new DatabaseLogRetentionPolicy($connection))->retentionDaysForSource('message')); + } + + public function testDefaultSecuritySignalRetentionIsValidForAutoBanTtl(): void + { + self::assertGreaterThanOrEqual(AutoBanPolicy::maxTtlDays(), DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays()); + self::assertLessThanOrEqual(DatabaseLogRetentionPolicy::MAX_RETENTION_DAYS, DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays()); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private function insertConfig(Connection $connection, string $key, int $value): void + { + $connection->insert('config_entry', [ + 'config_key' => $key, + 'value' => json_encode($value, JSON_THROW_ON_ERROR), + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-18 12:00:00', + 'modified_by' => 'test', + ]); + } +} diff --git a/tests/Core/Log/LogFileBrowserTest.php b/tests/Core/Log/LogFileBrowserTest.php index d1b836f0..97527f4a 100644 --- a/tests/Core/Log/LogFileBrowserTest.php +++ b/tests/Core/Log/LogFileBrowserTest.php @@ -27,8 +27,8 @@ protected function tearDown(): void public function testItReadsAndFiltersSelectedLogFiles(): void { $this->writeTestFile($this->logDir, 'test/message-2099-01-01.log', implode(PHP_EOL, [ - '[2099-01-01T10:00:00.000000+00:00] message.INFO: message.package.discovery_completed {"code":"package.discovery_completed"} []', - '[2099-01-01T10:01:00.000000+00:00] message.ERROR: message.process.command_failed {"code":"process.command_failed","package":"demo-module"} []', + '[2099-01-01T10:00:00.000000+00:00] message.INFO: message.extension.discovery_completed {"code":"extension.discovery_completed"} []', + '[2099-01-01T10:01:00.000000+00:00] message.ERROR: message.process.command_failed {"code":"process.command_failed","extension":"demo-module"} []', '', ])); @@ -67,4 +67,40 @@ public function testItReadsAccessContextColumns(): void self::assertSame('/admin/logs', $view['entries'][0]['context']['path']); self::assertSame('n/a', $view['entries'][0]['context']['country']); } + + public function testItIgnoresAuditActionFiltersForApplicationLogs(): void + { + $this->writeTestFile($this->logDir, 'test.log', '[2099-01-01T10:00:00.000000+00:00] app.ERROR: app.failure {"code":"app.failure"} []'.PHP_EOL); + + $view = (new LogFileBrowser($this->logDir, 'test'))->browse([ + 'source' => 'application', + 'level' => 'ERROR', + 'audit_action' => 'audit.unrelated', + ]); + + self::assertSame('', $view['filters']['audit_action']); + self::assertSame(1, $view['pagination']['total']); + self::assertSame('app.failure', $view['entries'][0]['message']); + } + + public function testItUsesClampedPaginationPageWhenReadingEntries(): void + { + $lines = []; + for ($i = 1; $i <= 26; ++$i) { + $lines[] = sprintf('[2099-01-01T10:%02d:00.000000+00:00] message.ERROR: message.%02d [] []', $i, $i); + } + $this->writeTestFile($this->logDir, 'test/message-2099-01-01.log', implode(PHP_EOL, [...$lines, ''])); + + $view = (new LogFileBrowser($this->logDir, 'test'))->browse([ + 'source' => 'message', + 'level' => 'ERROR', + 'per_page' => 25, + 'page' => 999, + ]); + + self::assertSame(2, $view['filters']['page']); + self::assertSame(2, $view['pagination']['page']); + self::assertCount(1, $view['entries']); + self::assertSame('message.01', $view['entries'][0]['message']); + } } diff --git a/tests/Core/Log/MonologMessageLoggerTest.php b/tests/Core/Log/MonologMessageLoggerTest.php index 3bf0837e..6a7b5696 100644 --- a/tests/Core/Log/MonologMessageLoggerTest.php +++ b/tests/Core/Log/MonologMessageLoggerTest.php @@ -10,8 +10,8 @@ use App\Core\Operation\OperationMessageKey; use App\Core\Operation\Process\ProcessMessageCode; use App\Core\Operation\Process\ProcessMessageKey; -use App\Core\Package\PackageMessageCode; -use App\Core\Package\PackageMessageKey; +use App\Core\Extension\ExtensionMessageCode; +use App\Core\Extension\ExtensionMessageKey; use App\Setup\SetupMessageCode; use App\Setup\SetupMessageKey; use Monolog\Handler\AbstractHandler; @@ -41,11 +41,11 @@ public function testItWritesMessagesToMonologWithStructuredContext(): void ProcessMessageCode::PROCESS_COMMAND_FAILED, ProcessMessageKey::PROCESS_COMMAND_FAILED, ['%command%' => 'bin/console demo'], - ['exit_code' => 1, 'database_password' => 'secret'], + ['exit_code' => 1, 'database_password' => 'secret', 'license_key' => 'maxmind-secret'], ); $message = Message::info( - PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, - PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED, + ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, + ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED, ['%count%' => 1], ['api_token' => 'abc123'], ); @@ -74,6 +74,7 @@ public function testItWritesMessagesToMonologWithStructuredContext(): void self::assertSame('process.command_failed', $records[0]->context['code']); self::assertSame('demo', $records[0]->context['queue']); self::assertSame('[redacted]', $records[0]->context['message_context']['database_password']); + self::assertSame('[redacted]', $records[0]->context['message_context']['license_key']); self::assertSame('[redacted]', $records[0]->context['app_secret']); self::assertSame(Level::Info, $records[1]->level); self::assertSame('[redacted]', $records[1]->context['message_context']['api_token']); @@ -81,7 +82,7 @@ public function testItWritesMessagesToMonologWithStructuredContext(): void public function testItMapsSuccessAndExceptionLevelsToPsrLevels(): void { - $this->logger->log(Message::success(PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED)); + $this->logger->log(Message::success(ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED)); $this->logger->log(Message::exception( OperationMessageCode::OPERATION_EXCEPTION, OperationMessageKey::OPERATION_EXCEPTION, @@ -119,7 +120,7 @@ public function testItLogsSingleMessages(): void public function testItDeduplicatesIdenticalMessageEntries(): void { - $message = Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED, [ + $message = Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED, [ '%count%' => 1, ]); @@ -127,13 +128,13 @@ public function testItDeduplicatesIdenticalMessageEntries(): void [ 'message' => $message, 'context' => [ - 'operation' => 'package.discovery.run', + 'operation' => 'extension.discovery.run', ], ], [ 'message' => $message, 'context' => [ - 'operation' => 'package.discovery.run', + 'operation' => 'extension.discovery.run', ], ], ]); @@ -147,7 +148,7 @@ public function testItKeepsMessageLoggingFailuresNonFatal(): void $monolog->pushHandler(new ThrowingMessageLogHandler()); $logger = new MonologMessageLogger($monolog); - $logger->log(Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED)); + $logger->log(Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED)); self::assertTrue(true); } diff --git a/tests/Core/Log/OperationLoggerTest.php b/tests/Core/Log/OperationLoggerTest.php index 0d3e6708..b1099e0c 100644 --- a/tests/Core/Log/OperationLoggerTest.php +++ b/tests/Core/Log/OperationLoggerTest.php @@ -51,15 +51,15 @@ public function testItUsesReviewAndFailureLevels(): void $logger = new OperationLogger(new MessageReporter($messageLogger)); $logger->logFinished([ - 'operation' => 'package.install.verify', + 'operation' => 'extension.install.verify', 'status' => 'requires_review', 'result' => [ 'status' => 'requires_review', - 'context' => ['live_operation_continuation' => ['operation' => 'package.install.apply']], + 'context' => ['live_operation_continuation' => ['operation' => 'extension.install.apply']], ], ]); $logger->logFinished([ - 'operation' => 'package.install.apply', + 'operation' => 'extension.install.apply', 'status' => 'failed', 'result' => ['status' => 'failed', 'issues' => [['code' => 'failed']]], ]); diff --git a/tests/Core/Manifest/ManifestParserTest.php b/tests/Core/Manifest/ManifestParserTest.php index 30a2b412..701f0f49 100644 --- a/tests/Core/Manifest/ManifestParserTest.php +++ b/tests/Core/Manifest/ManifestParserTest.php @@ -68,7 +68,7 @@ public function testItReportsDuplicateKeys(): void { $result = (new ManifestParser())->parse(<<<'MANIFEST' APP_VERSION=0.1.0 - APP_VERSION=0.2.4 + APP_VERSION=0.2.6 MANIFEST); self::assertFalse($result->isSuccess()); diff --git a/tests/Core/Manifest/ManifestSpecTest.php b/tests/Core/Manifest/ManifestSpecTest.php index 9647be4b..c68866dd 100644 --- a/tests/Core/Manifest/ManifestSpecTest.php +++ b/tests/Core/Manifest/ManifestSpecTest.php @@ -33,10 +33,10 @@ public function testItCanAllowOnlyKnownKeys(): void public function testItBuildsNamespacedSpecsFromShortKeys(): void { - $spec = ManifestSpec::forNamespace('PACKAGE', ['VERSION', 'AUTHOR', 'NAME'], ['NAME', 'VERSION']); + $spec = ManifestSpec::forNamespace('EXTENSION', ['VERSION', 'AUTHOR', 'NAME'], ['NAME', 'VERSION']); - self::assertSame(['PACKAGE_NAME', 'PACKAGE_VERSION'], $spec->requiredKeys()); - self::assertSame(['PACKAGE_VERSION', 'PACKAGE_AUTHOR', 'PACKAGE_NAME'], $spec->allowedKeys()); + self::assertSame(['EXTENSION_NAME', 'EXTENSION_VERSION'], $spec->requiredKeys()); + self::assertSame(['EXTENSION_VERSION', 'EXTENSION_AUTHOR', 'EXTENSION_NAME'], $spec->allowedKeys()); self::assertFalse($spec->allowsUnknownKeys()); } @@ -69,7 +69,7 @@ public function testItRejectsInvalidNamespacedKeyParts(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid manifest key "theme.version".'); - ManifestSpec::forNamespace('PACKAGE', ['theme.version']); + ManifestSpec::forNamespace('EXTENSION', ['theme.version']); } public function testItRejectsRequiredKeysOutsideAllowedKeys(): void diff --git a/tests/Core/Manifest/ManifestValidatorTest.php b/tests/Core/Manifest/ManifestValidatorTest.php index d9733f64..8ebca786 100644 --- a/tests/Core/Manifest/ManifestValidatorTest.php +++ b/tests/Core/Manifest/ManifestValidatorTest.php @@ -87,31 +87,31 @@ public function testItAllowsUnknownKeysWhenSpecIsOpen(): void public function testItValidatesNamespacedRequiredKeys(): void { $manifest = new Manifest([ - 'PACKAGE_AUTHOR' => 'Aavion', - 'PACKAGE_NAME' => 'System', + 'EXTENSION_AUTHOR' => 'Aavion', + 'EXTENSION_NAME' => 'System', ]); - $spec = ManifestSpec::forNamespace('PACKAGE', ['VERSION', 'AUTHOR', 'NAME'], ['NAME', 'VERSION']); + $spec = ManifestSpec::forNamespace('EXTENSION', ['VERSION', 'AUTHOR', 'NAME'], ['NAME', 'VERSION']); $result = (new ManifestValidator())->validate($manifest, $spec); self::assertFalse($result->isSuccess()); self::assertSame('manifest.missing_required_key', $result->firstIssue()?->code()); - self::assertSame(['key' => 'PACKAGE_VERSION'], $result->firstIssue()?->context()); + self::assertSame(['key' => 'EXTENSION_VERSION'], $result->firstIssue()?->context()); } public function testItRejectsUnknownNamespacedKeys(): void { $manifest = new Manifest([ - 'PACKAGE_VERSION' => '1.0.0', - 'PACKAGE_NAME' => 'System', - 'PACKAGE_UNDECLARED' => 'value', + 'EXTENSION_VERSION' => '1.0.0', + 'EXTENSION_NAME' => 'System', + 'EXTENSION_UNDECLARED' => 'value', ]); - $spec = ManifestSpec::forNamespace('PACKAGE', ['VERSION', 'AUTHOR', 'NAME'], ['NAME', 'VERSION']); + $spec = ManifestSpec::forNamespace('EXTENSION', ['VERSION', 'AUTHOR', 'NAME'], ['NAME', 'VERSION']); $result = (new ManifestValidator())->validate($manifest, $spec); self::assertFalse($result->isSuccess()); self::assertSame('manifest.unknown_key', $result->firstIssue()?->code()); - self::assertSame(['key' => 'PACKAGE_UNDECLARED'], $result->firstIssue()?->context()); + self::assertSame(['key' => 'EXTENSION_UNDECLARED'], $result->firstIssue()?->context()); } } diff --git a/tests/Core/Message/MessageCodeTest.php b/tests/Core/Message/MessageCodeTest.php index e567e4e2..16a01164 100644 --- a/tests/Core/Message/MessageCodeTest.php +++ b/tests/Core/Message/MessageCodeTest.php @@ -19,7 +19,7 @@ use App\Core\Operation\Filesystem\FilesystemMessageCode; use App\Core\Operation\OperationMessageCode; use App\Core\Operation\Process\ProcessMessageCode; -use App\Core\Package\PackageMessageCode; +use App\Core\Extension\ExtensionMessageCode; use App\Core\Routing\RoutingMessageCode; use App\Core\Security\SystemSecurityMessageCode; use App\Core\Translation\TranslationMessageCode; @@ -100,12 +100,12 @@ private static function namePrefixes(): array FilesystemMessageCode::class => ['FILESYSTEM_'], OperationMessageCode::class => ['OPERATION_'], ProcessMessageCode::class => ['PROCESS_'], - PackageMessageCode::class => ['PACKAGE_'], + ExtensionMessageCode::class => ['EXTENSION_'], RoutingMessageCode::class => ['ABSOLUTE_URI_'], SystemSecurityMessageCode::class => ['SYSTEM_'], TranslationMessageCode::class => ['TRANSLATION_'], SchedulerMessageCode::class => ['SCHEDULER_'], - SecurityMessageCode::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_'], + SecurityMessageCode::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_', 'AUTO_BAN_'], SetupMessageCode::class => ['SETUP_'], ViewMessageCode::class => ['VIEW_'], ]; @@ -131,12 +131,12 @@ private static function valuePrefixes(): array FilesystemMessageCode::class => ['filesystem.'], OperationMessageCode::class => ['operation.'], ProcessMessageCode::class => ['process.'], - PackageMessageCode::class => ['package.'], + ExtensionMessageCode::class => ['extension.'], RoutingMessageCode::class => ['routing.'], SystemSecurityMessageCode::class => ['system.'], TranslationMessageCode::class => ['translation.'], SchedulerMessageCode::class => ['scheduler.'], - SecurityMessageCode::class => ['acl.', 'user.', 'account.', 'api_key.'], + SecurityMessageCode::class => ['acl.', 'user.', 'account.', 'api_key.', 'rate_limit.', 'auto_ban.'], SetupMessageCode::class => ['setup.'], ViewMessageCode::class => ['view.'], ]; diff --git a/tests/Core/Message/MessageExceptionTest.php b/tests/Core/Message/MessageExceptionTest.php index 85dc22e9..c4d16a2d 100644 --- a/tests/Core/Message/MessageExceptionTest.php +++ b/tests/Core/Message/MessageExceptionTest.php @@ -9,7 +9,7 @@ use App\Core\Message\Message; use App\Core\Message\MessageException; use App\Core\Message\MessageLevel; -use App\Core\Package\PackageMessageKey; +use App\Core\Extension\ExtensionMessageKey; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -34,7 +34,7 @@ public function testItCarriesMessageKeyAndParameters(): void public function testItCanBeCreatedFromAMessage(): void { - $message = Message::success(PackageMessageKey::PACKAGE_REQUIRED_FILE_MISSING); + $message = Message::success(ExtensionMessageKey::EXTENSION_REQUIRED_FILE_MISSING); $exception = MessageException::fromMessage($message); self::assertSame($message, $exception->message()); diff --git a/tests/Core/Message/MessageKeyTest.php b/tests/Core/Message/MessageKeyTest.php index 6c88eca7..d2848a49 100644 --- a/tests/Core/Message/MessageKeyTest.php +++ b/tests/Core/Message/MessageKeyTest.php @@ -18,7 +18,7 @@ use App\Core\Operation\Filesystem\FilesystemMessageKey; use App\Core\Operation\OperationMessageKey; use App\Core\Operation\Process\ProcessMessageKey; -use App\Core\Package\PackageMessageKey; +use App\Core\Extension\ExtensionMessageKey; use App\Core\Routing\RoutingMessageKey; use App\Core\Security\SystemSecurityMessageKey; use App\Core\State\StateMessageKey; @@ -146,7 +146,7 @@ private static function namePrefixes(): array FilesystemMessageKey::class => ['FILESYSTEM_'], OperationMessageKey::class => ['OPERATION_'], ProcessMessageKey::class => ['PROCESS_'], - PackageMessageKey::class => ['PACKAGE_'], + ExtensionMessageKey::class => ['EXTENSION_'], RoutingMessageKey::class => ['ABSOLUTE_URI_'], SystemSecurityMessageKey::class => ['SYSTEM_'], StateMessageKey::class => ['STATE_'], @@ -154,7 +154,7 @@ private static function namePrefixes(): array TranslationMessageKey::class => ['TRANSLATION_'], NavigationMessageKey::class => ['MENU_'], SchedulerMessageKey::class => ['SCHEDULER_'], - SecurityMessageKey::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_'], + SecurityMessageKey::class => ['ACL_', 'USER_', 'ACCOUNT_', 'API_KEY_', 'RATE_LIMIT_', 'AUTO_BAN_'], SetupMessageKey::class => ['SETUP_'], ViewMessageKey::class => ['VIEW_'], ]; @@ -179,7 +179,7 @@ private static function valuePrefixes(): array FilesystemMessageKey::class => ['message.filesystem.'], OperationMessageKey::class => ['message.operation.'], ProcessMessageKey::class => ['message.process.'], - PackageMessageKey::class => ['message.package.'], + ExtensionMessageKey::class => ['message.extension.'], RoutingMessageKey::class => ['message.routing.'], SystemSecurityMessageKey::class => ['message.system.'], StateMessageKey::class => ['message.state.'], @@ -187,7 +187,7 @@ private static function valuePrefixes(): array TranslationMessageKey::class => ['message.translation.'], NavigationMessageKey::class => ['message.menu.'], SchedulerMessageKey::class => ['message.scheduler.'], - SecurityMessageKey::class => ['message.acl.', 'message.user.', 'message.account_', 'message.api_key.'], + SecurityMessageKey::class => ['message.acl.', 'message.user.', 'message.account_', 'message.api_key.', 'message.rate_limit.', 'message.auto_ban.'], SetupMessageKey::class => ['message.setup.'], ViewMessageKey::class => ['message.view.'], ]; diff --git a/tests/Core/Message/MessageReporterTest.php b/tests/Core/Message/MessageReporterTest.php index d7596d34..c860c901 100644 --- a/tests/Core/Message/MessageReporterTest.php +++ b/tests/Core/Message/MessageReporterTest.php @@ -9,8 +9,8 @@ use App\Core\Manifest\ManifestMessageKey; use App\Core\Message\Message; use App\Core\Message\MessageReporter; -use App\Core\Package\PackageMessageCode; -use App\Core\Package\PackageMessageKey; +use App\Core\Extension\ExtensionMessageCode; +use App\Core\Extension\ExtensionMessageKey; use PHPUnit\Framework\TestCase; final class MessageReporterTest extends TestCase @@ -19,12 +19,12 @@ public function testItReturnsReportedMessagesUnchanged(): void { $logger = new RecordingMessageLogger(); $reporter = new MessageReporter($logger); - $message = Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED); + $message = Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED); - self::assertSame($message, $reporter->report($message, ['operation' => 'package.discovery'])); + self::assertSame($message, $reporter->report($message, ['operation' => 'extension.discovery'])); self::assertCount(1, $logger->records); self::assertSame($message, $logger->records[0]['message']); - self::assertSame(['operation' => 'package.discovery'], $logger->records[0]['context']); + self::assertSame(['operation' => 'extension.discovery'], $logger->records[0]['context']); } public function testItReturnsReportedBatchesUnchanged(): void @@ -32,7 +32,7 @@ public function testItReturnsReportedBatchesUnchanged(): void $logger = new RecordingMessageLogger(); $reporter = new MessageReporter($logger); $first = Message::debug(ManifestMessageCode::MANIFEST_PARSED, ManifestMessageKey::MANIFEST_PARSED); - $second = Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED); + $second = Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED); $messages = $reporter->reportBatch([ [ diff --git a/tests/Core/Message/WorkflowResultMessageReporterTest.php b/tests/Core/Message/WorkflowResultMessageReporterTest.php index 08dbf840..bd361b70 100644 --- a/tests/Core/Message/WorkflowResultMessageReporterTest.php +++ b/tests/Core/Message/WorkflowResultMessageReporterTest.php @@ -12,8 +12,8 @@ use App\Core\Message\WorkflowResultMessageReporter; use App\Core\Operation\Process\ProcessMessageCode; use App\Core\Operation\Process\ProcessMessageKey; -use App\Core\Package\PackageMessageCode; -use App\Core\Package\PackageMessageKey; +use App\Core\Extension\ExtensionMessageCode; +use App\Core\Extension\ExtensionMessageKey; use App\Core\Workflow\WorkflowResult; use App\Setup\SetupMessageCode; use App\Setup\SetupMessageKey; @@ -26,14 +26,14 @@ public function testItReturnsTheOriginalResultAfterLogging(): void $messageReporter = new RecordingMessageReporter(); $reporter = new WorkflowResultMessageReporter($messageReporter); $result = WorkflowResult::success(messages: [ - Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED, [ + Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED, [ '%count%' => 1, ]), ]); self::assertSame($result, $reporter->report($result, ['operation' => 'test'])); self::assertCount(1, $messageReporter->records); - self::assertSame(PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED, $messageReporter->records[0]['message']->translationKey()); + self::assertSame(ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED, $messageReporter->records[0]['message']->translationKey()); self::assertSame(['operation' => 'test'], $messageReporter->records[0]['context']['operation_context']); self::assertSame('message', $messageReporter->records[0]['context']['kind']); } @@ -43,7 +43,7 @@ public function testItDoesNotLogTheSameResultObjectTwice(): void $messageReporter = new RecordingMessageReporter(); $reporter = new WorkflowResultMessageReporter($messageReporter); $result = WorkflowResult::success(messages: [ - Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED, [ + Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED, [ '%count%' => 1, ]), ]); diff --git a/tests/Core/Messenger/DeferredMessengerDrainTest.php b/tests/Core/Messenger/DeferredMessengerDrainTest.php index 24c4b0c1..21395a25 100644 --- a/tests/Core/Messenger/DeferredMessengerDrainTest.php +++ b/tests/Core/Messenger/DeferredMessengerDrainTest.php @@ -139,6 +139,31 @@ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $c $this->removeDirectory($projectDir); } + public function testSubscriberDoesNotSkipSchedulerLookalikeRequests(): void + { + $projectDir = $this->createTemporaryProjectDirectory('messenger-drain-scheduler-lookalike'); + $connection = $this->connectionWithMessengerTable(); + $starter = new RecordingDeferredMessengerStarter(); + $settings = $this->schedulerSettings($connection, true); + $drain = new DeferredMessengerDrain($connection, $starter, $projectDir, 'test', schedulerSettings: $settings); + $request = Request::create('/cron/runaway'); + + (new DeferredMessengerDrainSubscriber($drain))->onKernelTerminate(new TerminateEvent( + new class implements HttpKernelInterface { + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } + }, + $request, + new Response(), + )); + + self::assertCount(1, $starter->starts); + + $this->removeDirectory($projectDir); + } + public function testItLogsDispatchFailureWhenDetachedStartFails(): void { $projectDir = $this->createTemporaryProjectDirectory('messenger-drain-scheduler-failure'); diff --git a/tests/Core/Operation/LiveOperationQueueFactoryTest.php b/tests/Core/Operation/LiveOperationQueueFactoryTest.php index a36ff193..c098da8e 100644 --- a/tests/Core/Operation/LiveOperationQueueFactoryTest.php +++ b/tests/Core/Operation/LiveOperationQueueFactoryTest.php @@ -18,12 +18,12 @@ public function testItCreatesSupportedQueues(): void 'environment' => 'test', 'trigger' => 'admin_ui', ]); - $packageDiscovery = $factory->create(LiveOperationQueueFactory::PACKAGE_DISCOVERY, [ + $extensionDiscovery = $factory->create(LiveOperationQueueFactory::EXTENSION_DISCOVERY, [ 'environment' => 'test', 'trigger' => 'admin_ui', ]); - $packageLifecycle = $factory->create(LiveOperationQueueFactory::PACKAGE_LIFECYCLE, [ - 'package' => 'demo-module', + $extensionLifecycle = $factory->create(LiveOperationQueueFactory::EXTENSION_LIFECYCLE, [ + 'extension' => 'demo-module', 'action' => 'activate', 'environment' => 'test', 'trigger' => 'admin_ui', @@ -45,32 +45,39 @@ public function testItCreatesSupportedQueues(): void ], 'trigger' => 'setup_wizard', ]); - $packageInstallVerify = $factory->create(LiveOperationQueueFactory::PACKAGE_INSTALL_VERIFY, [ + $extensionInstallVerify = $factory->create(LiveOperationQueueFactory::EXTENSION_INSTALL_VERIFY, [ 'install_id' => 'aaaaaaaaaaaaaaaaaaaaaaaa', 'trigger' => 'admin_ui', ]); - $packageInstallApply = $factory->create(LiveOperationQueueFactory::PACKAGE_INSTALL_APPLY, [ + $extensionInstallApply = $factory->create(LiveOperationQueueFactory::EXTENSION_INSTALL_APPLY, [ 'install_id' => 'aaaaaaaaaaaaaaaaaaaaaaaa', - 'package' => 'demo-module', + 'extension' => 'demo-module', + 'trigger' => 'admin_ui', + ]); + $geoIpUpdate = $factory->create(LiveOperationQueueFactory::GEOIP_DATABASE_UPDATE, [ + 'environment' => 'test', 'trigger' => 'admin_ui', ]); self::assertTrue($backendCacheClear->isSuccess()); self::assertSame('backend cache clear', $backendCacheClear->value()?->name()); self::assertSame('test', $backendCacheClear->value()?->context()['environment']); - self::assertTrue($packageDiscovery->isSuccess()); - self::assertSame('package discovery', $packageDiscovery->value()?->name()); - self::assertSame('admin_ui', $packageDiscovery->value()?->context()['trigger']); - self::assertTrue($packageLifecycle->isSuccess()); - self::assertSame('activate', $packageLifecycle->value()?->context()['action']); + self::assertTrue($extensionDiscovery->isSuccess()); + self::assertSame('extension discovery', $extensionDiscovery->value()?->name()); + self::assertSame('admin_ui', $extensionDiscovery->value()?->context()['trigger']); + self::assertTrue($extensionLifecycle->isSuccess()); + self::assertSame('activate', $extensionLifecycle->value()?->context()['action']); self::assertTrue($aclGroupApply->isSuccess()); self::assertSame('Delete ACL group and clean references', $aclGroupApply->value()?->actions()[0]->label()); self::assertTrue($setupApply->isSuccess()); self::assertSame('select_language', $setupApply->value()?->actions()[0]->label()); - self::assertTrue($packageInstallVerify->isSuccess()); - self::assertSame('package install verification', $packageInstallVerify->value()?->name()); - self::assertTrue($packageInstallApply->isSuccess()); - self::assertSame('demo-module', $packageInstallApply->value()?->context()['package']); + self::assertTrue($extensionInstallVerify->isSuccess()); + self::assertSame('extension install verification', $extensionInstallVerify->value()?->name()); + self::assertTrue($extensionInstallApply->isSuccess()); + self::assertSame('demo-module', $extensionInstallApply->value()?->context()['extension']); + self::assertTrue($geoIpUpdate->isSuccess()); + self::assertSame('geoip database update', $geoIpUpdate->value()?->name()); + self::assertSame('admin_ui', $geoIpUpdate->value()?->context()['trigger']); } public function testItRejectsUnknownOperationsAndInvalidPayloads(): void @@ -79,8 +86,8 @@ public function testItRejectsUnknownOperationsAndInvalidPayloads(): void $factory = $this->factory(); $unknown = $factory->create('missing.operation'); - $invalidLifecycle = $factory->create(LiveOperationQueueFactory::PACKAGE_LIFECYCLE, [ - 'package' => 'demo-module', + $invalidLifecycle = $factory->create(LiveOperationQueueFactory::EXTENSION_LIFECYCLE, [ + 'extension' => 'demo-module', ]); $invalidAclGroupApply = $factory->create(LiveOperationQueueFactory::ACL_GROUP_APPLY, [ 'group_uid' => 'aaaaaaaa-aaaa-7aaa-aaaa-aaaaaaaaaaaa', diff --git a/tests/Core/Operation/LiveOperationRunStoreTest.php b/tests/Core/Operation/LiveOperationRunStoreTest.php index 53abe4d0..e403fed7 100644 --- a/tests/Core/Operation/LiveOperationRunStoreTest.php +++ b/tests/Core/Operation/LiveOperationRunStoreTest.php @@ -154,18 +154,18 @@ public function testItMarksReviewRequiredRunsAsTerminalAndExposesContinuationSta { $projectDir = $this->createTemporaryDirectory('live-operation-review'); $store = new LiveOperationRunStore($projectDir, 'test'); - $run = $store->create('package.install.dry_run', [], 'Install package dry-run'); + $run = $store->create('extension.install.dry_run', [], 'Install extension dry-run'); $result = WorkflowResult::requiresReview(null, [ Message::info( OperationMessageCode::OPERATION_ACTION_REQUIRED, OperationMessageKey::OPERATION_ACTION_REQUIRED, - ['%operation%' => 'Install package'], + ['%operation%' => 'Install extension'], ), ], [ 'live_operation_continuation' => [ - 'operation' => 'package.install.apply', - 'payload' => ['package' => 'demo-module'], - 'label' => 'Install package', + 'operation' => 'extension.install.apply', + 'payload' => ['extension' => 'demo-module'], + 'label' => 'Install extension', ], ]); @@ -180,9 +180,9 @@ public function testItMarksReviewRequiredRunsAsTerminalAndExposesContinuationSta self::assertTrue($payload['can_continue']); self::assertSame('requires_review', $payload['result']['status']); self::assertSame([ - 'operation' => 'package.install.apply', - 'payload' => ['package' => 'demo-module'], - 'label' => 'Install package', + 'operation' => 'extension.install.apply', + 'payload' => ['extension' => 'demo-module'], + 'label' => 'Install extension', ], $continuation); self::assertNull($store->continuation($run['operation_id'], 'wrong-token')); } diff --git a/tests/Core/Operation/OperationExecutorTest.php b/tests/Core/Operation/OperationExecutorTest.php index dafcfe3c..9e7aa3e7 100644 --- a/tests/Core/Operation/OperationExecutorTest.php +++ b/tests/Core/Operation/OperationExecutorTest.php @@ -14,8 +14,8 @@ use App\Core\Operation\OperationExecutor; use App\Core\Operation\OperationMessageCode; use App\Core\Operation\OperationMessageKey; -use App\Core\Package\PackageMessageCode; -use App\Core\Package\PackageMessageKey; +use App\Core\Extension\ExtensionMessageCode; +use App\Core\Extension\ExtensionMessageKey; use App\Core\Workflow\WorkflowResult; use App\Core\Workflow\WorkflowStatus; use App\Tests\Support\NullWorkflowResultMessageReporter; @@ -30,7 +30,7 @@ public function testItBuildsDryRunPlanFromActionQueue(): void $queue = ActionQueue::create('import', [ new TestOperationAction('copy_file', 'Copy file', WorkflowResult::success()), ], context: [ - 'package' => 'demo', + 'extension' => 'demo', ])->add(new TestOperationAction('write_config', 'Write config', WorkflowResult::success(), DryRunRisk::Medium)); $plan = (new OperationExecutor(new NullWorkflowResultMessageReporter()))->planQueue($queue); @@ -38,7 +38,7 @@ public function testItBuildsDryRunPlanFromActionQueue(): void self::assertSame('import', $plan->name()); self::assertSame(['copy_file' => 1, 'write_config' => 1], $plan->actionCounts()); self::assertSame(DryRunRisk::Medium, $plan->highestRisk()); - self::assertSame(['package' => 'demo'], $plan->context()); + self::assertSame(['extension' => 'demo'], $plan->context()); self::assertCount(2, $queue); } @@ -134,24 +134,24 @@ public function testItCanContinueAfterRecoverableIssues(): void public function testItPreservesReviewRequiredActionContext(): void { $issue = Message::info(OperationMessageCode::OPERATION_ACTION_REQUIRED, OperationMessageKey::OPERATION_ACTION_REQUIRED, [ - '%operation%' => 'Install package', + '%operation%' => 'Install extension', ]); $execution = (new OperationExecutor(new NullWorkflowResultMessageReporter()))->executeQueue(ActionQueue::create('continue', [ new TestOperationAction('review', 'Review change', WorkflowResult::requiresReview(null, [$issue], [ 'live_operation_continuation' => [ - 'operation' => 'package.install.apply', + 'operation' => 'extension.install.apply', 'payload' => ['install_id' => 'abc'], - 'label' => 'Install package', + 'label' => 'Install extension', ], ])), ], context: [ - 'operation' => 'package.install.verify', + 'operation' => 'extension.install.verify', ])); self::assertSame(WorkflowStatus::RequiresReview, $execution->result()->status()); - self::assertSame('package.install.verify', $execution->result()->context()['operation']); - self::assertSame('package.install.apply', $execution->result()->context()['live_operation_continuation']['operation']); + self::assertSame('extension.install.verify', $execution->result()->context()['operation']); + self::assertSame('extension.install.apply', $execution->result()->context()['live_operation_continuation']['operation']); } public function testItPreservesFailedStatusWhenContinuingAfterFailures(): void @@ -217,12 +217,12 @@ public function testItConvertsExceptionsToFailedResults(): void public function testItPassesActionResultsToTheMessageReporter(): void { $logger = new RecordingWorkflowResultMessageReporter(); - $message = Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED, [ + $message = Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED, [ '%count%' => 1, ]); $execution = (new OperationExecutor($logger))->executeQueue(ActionQueue::create('logged queue', [ - new TestOperationAction('discover', 'Discover packages', WorkflowResult::success(context: [ + new TestOperationAction('discover', 'Discover extensions', WorkflowResult::success(context: [ 'database_password' => 'secret', ], messages: [$message])), ])); @@ -232,7 +232,7 @@ public function testItPassesActionResultsToTheMessageReporter(): void self::assertSame([$message], $logger->records[0]['result']->messages()); self::assertSame([ 'queue' => 'logged queue', - 'action' => 'Discover packages', + 'action' => 'Discover extensions', 'type' => 'discover', 'index' => 1, 'total' => 1, diff --git a/tests/Core/Package/PackageActivatorTest.php b/tests/Core/Package/PackageActivatorTest.php deleted file mode 100644 index 025f3aec..00000000 --- a/tests/Core/Package/PackageActivatorTest.php +++ /dev/null @@ -1,479 +0,0 @@ -entityManager = self::getContainer()->get(EntityManagerInterface::class); - $this->connection = $this->entityManager->getConnection(); - $this->assetRebuilder = new FakePackageLifecycleAssetRebuilder(); - $this->connection->beginTransaction(); - } - - protected function tearDown(): void - { - if ($this->connection->isTransactionActive()) { - $this->connection->rollBack(); - } - - if (null !== $this->temporaryProjectDir) { - $this->removeDirectory($this->temporaryProjectDir); - $this->temporaryProjectDir = null; - } - - parent::tearDown(); - } - - public function testItActivatesInactivePackagesAndRunsAssetRebuild(): void - { - $this->insertPackage('demo-module', ['module'], 'inactive'); - - $result = $this->activator()->activate('demo-module', 'test'); - - self::assertTrue($result->isSuccess()); - self::assertSame('active', $this->packageStatus('demo-module')); - self::assertSame(['test'], $this->assetRebuilder->environments); - self::assertSame([[ - 'package' => 'demo-module', - 'action' => 'activated', - 'status' => 'active', - ]], $result->value()['changes']); - } - - public function testActivatedPackageSchedulerTaskCanBeRegisteredAndEnabled(): void - { - $this->temporaryProjectDir = $this->createTemporaryDirectory('system-package-scheduler'); - $this->insertPackage('demo-module', ['module'], 'inactive'); - $this->writeTestFile($this->temporaryProjectDir, 'packages/demo-module/package.php', <<<'PHP' -activator()->activate('demo-module', 'test', rebuildAssets: false)->isSuccess()); - - $runtimeContributions = new PackageRuntimeContributionRegistry(); - $loadResult = (new PackagePhpLoader( - new ActivePackageProvider($this->entityManager), - $this->entityManager, - $this->temporaryProjectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $runtimeContributions, - ))->loadActivePackages(); - self::assertTrue($loadResult->isSuccess()); - - $tasks = (new SchedulerTaskSynchronizer( - new SchedulerTaskRegistry([$runtimeContributions]), - $this->entityManager, - new SchedulerSettings(new Config($this->connection)), - ))->synchronize(); - - self::assertCount(1, $tasks); - self::assertSame('demo-module.cleanup', $tasks[0]->identifier()); - self::assertSame('demo-module', $tasks[0]->source()); - self::assertSame(SchedulerTaskType::Command, $tasks[0]->type()); - self::assertSame('*/20 * * * *', $tasks[0]->cronExpression()); - self::assertFalse($tasks[0]->trusted()); - - $task = $this->entityManager->find(SchedulerTask::class, 'demo-module.cleanup'); - self::assertInstanceOf(SchedulerTask::class, $task); - $task->activate('*/10 * * * *'); - $this->entityManager->flush(); - - $this->entityManager->clear(); - $enabledTask = $this->entityManager->find(SchedulerTask::class, 'demo-module.cleanup'); - self::assertInstanceOf(SchedulerTask::class, $enabledTask); - self::assertSame(SchedulerTaskStatus::Active, $enabledTask->status()); - self::assertSame('*/10 * * * *', $enabledTask->cronExpression()); - } - - public function testItDeactivatesConflictingSingleActiveScopes(): void - { - $this->insertPackage('old-theme', ['frontend-theme'], 'active'); - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive'); - $this->insertPackage('utility-module', ['module'], 'active'); - - $result = $this->activator()->activate('new-theme', 'test', rebuildAssets: false); - - self::assertTrue($result->isSuccess()); - self::assertSame('inactive', $this->packageStatus('old-theme')); - self::assertSame('active', $this->packageStatus('new-theme')); - self::assertSame('active', $this->packageStatus('utility-module')); - self::assertSame(['old-theme', 'new-theme'], array_column($result->value()['changes'], 'package')); - self::assertSame([], $this->assetRebuilder->environments); - } - - public function testItDeactivatesDependentsOfConflictingSingleActiveScopes(): void - { - $this->insertPackage('old-theme', ['frontend-theme'], 'active'); - $this->insertPackage('captcha-provider', ['captcha-provider'], 'active', "[['old-theme', '1.0.0']]"); - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive'); - - $result = $this->activator()->activate('new-theme', 'test', rebuildAssets: false); - - self::assertTrue($result->isSuccess()); - self::assertSame('inactive', $this->packageStatus('old-theme')); - self::assertSame('inactive', $this->packageStatus('captcha-provider')); - self::assertSame('active', $this->packageStatus('new-theme')); - self::assertSame(['captcha-provider', 'old-theme', 'new-theme'], array_column($result->value()['changes'], 'package')); - } - - public function testItRunsOneAssetRebuildForBatchedActivationChanges(): void - { - $this->insertPackage('old-theme', ['frontend-theme'], 'active'); - $this->insertPackage('theme-tools', ['module'], 'inactive'); - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive', "[['theme-tools', '1.0.0']]"); - - $result = $this->activator()->activate('new-theme', 'test'); - - self::assertTrue($result->isSuccess()); - self::assertSame('inactive', $this->packageStatus('old-theme')); - self::assertSame('active', $this->packageStatus('new-theme')); - self::assertSame('active', $this->packageStatus('theme-tools')); - self::assertSame(['test'], $this->assetRebuilder->environments); - self::assertSame(['old-theme', 'new-theme', 'theme-tools'], array_column($result->value()['changes'], 'package')); - } - - public function testItPlansDependenciesAndConflictsBeforeActivation(): void - { - $this->insertPackage('old-theme', ['frontend-theme'], 'active'); - $this->insertPackage('theme-tools', ['module'], 'inactive'); - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive', "[['theme-tools', '1.0.0']]"); - - $result = $this->activator()->planActivation('new-theme'); - - self::assertTrue($result->isSuccess()); - self::assertSame(['new-theme', 'theme-tools'], $result->value()['activate']); - self::assertSame(['old-theme'], $result->value()['deactivate']); - self::assertSame([[ - 'package' => 'theme-tools', - 'required_min_version' => '1.0.0', - 'installed_version' => '1.0.0', - 'status' => 'inactive', - 'required_by' => 'new-theme', - ]], $result->value()['dependencies']); - self::assertSame('package.dependency.resolved', $result->messages()[0]->code()); - self::assertSame(MessageLevel::Debug, $result->messages()[0]->level()); - } - - public function testItActivatesInactiveDependenciesWithTheTargetPackage(): void - { - $this->insertPackage('theme-tools', ['module'], 'inactive'); - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive', "[['theme-tools', '1.0.0']]"); - - $result = $this->activator()->activate('new-theme', 'test', rebuildAssets: false); - - self::assertTrue($result->isSuccess()); - self::assertSame('active', $this->packageStatus('new-theme')); - self::assertSame('active', $this->packageStatus('theme-tools')); - self::assertSame(['new-theme', 'theme-tools'], array_column($result->value()['changes'], 'package')); - } - - public function testItTreatsSystemAsSatisfiedVirtualDependency(): void - { - $systemVersion = (new SystemPackageMetadataProvider(dirname(__DIR__, 3)))->metadata()['version']; - self::assertIsString($systemVersion); - - $this->insertPackage('demo-module', ['module'], 'inactive', sprintf('[["system", "%s"]]', $systemVersion)); - - $result = $this->activatorWithSystemDependencySupport()->planActivation('demo-module'); - - self::assertTrue($result->isSuccess()); - self::assertSame(['demo-module'], $result->value()['activate']); - self::assertSame([[ - 'package' => 'system', - 'required_min_version' => $systemVersion, - 'installed_version' => $systemVersion, - 'status' => 'active', - 'required_by' => 'demo-module', - ]], $result->value()['dependencies']); - } - - public function testItBlocksMissingPackageDependencies(): void - { - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive', "[['missing-tools', '1.0.0']]"); - - $result = $this->activator()->planActivation('new-theme'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.dependency.missing', $result->firstIssue()?->code()); - self::assertSame('inactive', $this->packageStatus('new-theme')); - } - - public function testItBlocksMalformedPackageDependencies(): void - { - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive', '["theme-tools >=1.0"]'); - - $result = $this->activator()->planActivation('new-theme'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.dependency.invalid', $result->firstIssue()?->code()); - self::assertSame('inactive', $this->packageStatus('new-theme')); - } - - public function testItBlocksUnsatisfiedPackageDependencyVersions(): void - { - $this->insertPackage('theme-tools', ['module'], 'active', version: '1.0.0'); - $this->insertPackage('new-theme', ['frontend-theme'], 'inactive', "[['theme-tools', '1.1.0']]"); - - $result = $this->activator()->planActivation('new-theme'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.dependency.version_unsatisfied', $result->firstIssue()?->code()); - self::assertSame('inactive', $this->packageStatus('new-theme')); - } - - public function testItBlocksCircularPackageDependencies(): void - { - $this->insertPackage('demo-module', ['module'], 'inactive', "[['demo-tools', '1.0.0']]"); - $this->insertPackage('demo-tools', ['module'], 'inactive', "[['demo-module', '1.0.0']]"); - - $result = $this->activator()->planActivation('demo-module'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.dependency.cycle', $result->firstIssue()?->code()); - self::assertSame(['demo-module', 'demo-tools', 'demo-module'], $result->firstIssue()?->context()['cycle']); - self::assertSame('inactive', $this->packageStatus('demo-module')); - self::assertSame('inactive', $this->packageStatus('demo-tools')); - } - - public function testItBlocksPackageSelfDependencies(): void - { - $this->insertPackage('demo-module', ['module'], 'inactive', "[['demo-module', '1.0.0']]"); - - $result = $this->activator()->planActivation('demo-module'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.dependency.cycle', $result->firstIssue()?->code()); - self::assertSame(['demo-module', 'demo-module'], $result->firstIssue()?->context()['cycle']); - self::assertSame('inactive', $this->packageStatus('demo-module')); - } - - public function testItDeactivatesActivePackages(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - - $result = $this->activator()->deactivate('demo-module', 'test', rebuildAssets: false); - - self::assertTrue($result->isSuccess()); - self::assertSame('inactive', $this->packageStatus('demo-module')); - self::assertSame([[ - 'package' => 'demo-module', - 'action' => 'deactivated', - 'status' => 'inactive', - ]], $result->value()['changes']); - } - - public function testItPlansDependentCascadeBeforeDeactivation(): void - { - $this->insertPackage('demo-theme', ['frontend-theme'], 'active'); - $this->insertPackage('captcha-provider', ['captcha-provider'], 'active', "[['demo-theme', '1.0.0']]"); - - $result = $this->activator()->planDeactivation('demo-theme'); - - self::assertTrue($result->isSuccess()); - self::assertSame(['captcha-provider', 'demo-theme'], $result->value()['deactivate']); - self::assertSame([ - [ - 'package' => 'captcha-provider', - 'action' => 'deactivated', - 'status' => 'inactive', - ], - [ - 'package' => 'demo-theme', - 'action' => 'deactivated', - 'status' => 'inactive', - ], - ], $result->value()['changes']); - } - - public function testItDeactivatesActiveDependentsWithTheTargetPackage(): void - { - $this->insertPackage('demo-theme', ['frontend-theme'], 'active'); - $this->insertPackage('captcha-provider', ['captcha-provider'], 'active', "[['demo-theme', '1.0.0']]"); - - $result = $this->activator()->deactivate('demo-theme', 'test', rebuildAssets: false); - - self::assertTrue($result->isSuccess()); - self::assertSame('inactive', $this->packageStatus('demo-theme')); - self::assertSame('inactive', $this->packageStatus('captcha-provider')); - self::assertSame(['captcha-provider', 'demo-theme'], array_column($result->value()['changes'], 'package')); - } - - public function testItBlocksFaultyOrRemovedPackages(): void - { - foreach (['faulty', 'removed'] as $status) { - $this->insertPackage('demo-'.$status, ['module'], $status); - - $result = $this->activator()->activate('demo-'.$status, 'test', rebuildAssets: false); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.status_blocked', $result->firstIssue()?->code()); - self::assertSame($status, $this->packageStatus('demo-'.$status)); - } - } - - public function testItRollsBackWhenAssetRebuildFails(): void - { - $this->insertPackage('demo-module', ['module'], 'inactive'); - $this->assetRebuilder->result = WorkflowResult::failed([ - Message::create( - PackageMessageCode::PACKAGE_ASSET_SYNC_FAILED, - PackageMessageKey::PACKAGE_ASSET_SYNC_FAILED, - ['%message%' => 'rebuild failed'], - level: MessageLevel::Error, - ), - ]); - - $result = $this->activator()->activate('demo-module', 'test'); - - self::assertFalse($result->isSuccess()); - self::assertSame('inactive', $this->packageStatus('demo-module')); - self::assertTrue($result->context()['rolled_back']); - } - - public function testItReportsMissingPackages(): void - { - $result = $this->activator()->activate('missing', 'test', rebuildAssets: false); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.not_found', $result->firstIssue()?->code()); - } - - private function activator(): PackageActivator - { - return new PackageActivator($this->entityManager, $this->assetRebuilder, new NullWorkflowResultMessageReporter()); - } - - private function activatorWithSystemDependencySupport(): PackageActivator - { - return new PackageActivator( - $this->entityManager, - $this->assetRebuilder, - new NullWorkflowResultMessageReporter(), - new PackageDependencyResolver($this->entityManager, new SystemPackageMetadataProvider(dirname(__DIR__, 3))), - ); - } - - /** - * @param list $scopes - */ - private function insertPackage( - string $packageName, - array $scopes, - string $status, - string $dependencies = '[]', - string $version = '1.0.0', - ): void { - $this->connection->insert('extension_package', [ - 'uid' => $this->uuid(), - 'package_scopes' => json_encode($scopes, JSON_THROW_ON_ERROR), - 'package_name' => $packageName, - 'path' => 'packages/'.$packageName, - 'manifest_version' => $version, - 'installed_version' => $version, - 'status' => $status, - 'metadata' => json_encode([ - 'registry_state' => 'available', - 'manifest' => [ - 'PACKAGE_DEPENDENCIES' => $dependencies, - ], - ], JSON_THROW_ON_ERROR), - 'modified_at' => '2026-05-25 00:00:00', - ]); - } - - private function packageStatus(string $packageName): string - { - $status = $this->connection->fetchOne( - 'SELECT status FROM extension_package WHERE package_name = :package_name', - ['package_name' => $packageName], - ); - - self::assertIsString($status); - - return $status; - } - - private function uuid(): string - { - return Uuid::v7()->toRfc4122(); - } -} - -final class FakePackageLifecycleAssetRebuilder implements PackageLifecycleAssetRebuilderInterface -{ - /** - * @var list - */ - public array $environments = []; - - /** - * @var WorkflowResult - */ - public WorkflowResult $result; - - public function __construct() - { - $this->result = WorkflowResult::success(context: ['fake' => true]); - } - - public function rebuild(string $environment): WorkflowResult - { - $this->environments[] = $environment; - - return $this->result; - } -} diff --git a/tests/Core/Package/PackageApiContributionGuardTest.php b/tests/Core/Package/PackageApiContributionGuardTest.php deleted file mode 100644 index aa479018..00000000 --- a/tests/Core/Package/PackageApiContributionGuardTest.php +++ /dev/null @@ -1,175 +0,0 @@ -package('demo-module'); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'packages.demo-module.demo', - ['packages-demo-module-demo'], - )); - - self::addToAssertionCount(1); - } - - public function testItRejectsWrongPackageNamespaces(): void - { - $package = $this->package('demo-module'); - - $this->expectException(MessageException::class); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - '/api/v1/pkg/demo-module/demo', - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'packages.demo-module.demo', - ['packages-demo-module-demo'], - )); - } - - public function testItRejectsPackageEndpointPatternsOutsideOwnedNamespace(): void - { - $package = $this->package('demo-module'); - - $this->expectException(MessageException::class); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'packages.demo-module.demo', - ['packages-demo-module-demo'], - pathPattern: '#^/api/v1/.*$#', - )); - } - - public function testItRejectsPackageEndpointPatternsWithEscapingAlternation(): void - { - $package = $this->package('demo-module'); - - $this->expectException(MessageException::class); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'packages.demo-module.demo', - ['packages-demo-module-demo'], - pathPattern: '#^/api/v1/packages/demo-module/.*|^/api/v1/packages/other/#', - )); - } - - public function testItAllowsGroupedPackageEndpointPatternAlternationInsideOwnedNamespace(): void - { - $package = $this->package('demo-module'); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'packages.demo-module.demo', - ['packages-demo-module-demo'], - pathPattern: '#^/api/v1/packages/demo-module/(demo|status)$#', - )); - - self::addToAssertionCount(1); - } - - public function testItRejectsForeignHandlerNamespaces(): void - { - $package = $this->package('demo-module'); - - $this->expectException(MessageException::class); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'pkg.demo-module.demo', - ['packages-demo-module-demo'], - )); - } - - public function testItRejectsForeignTagNamespaces(): void - { - $package = $this->package('demo-module'); - - $this->expectException(MessageException::class); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'packages.demo-module.demo', - ['system-demo'], - )); - } - - public function testItRejectsMissingPackageTags(): void - { - $package = $this->package('demo-module'); - - $this->expectException(MessageException::class); - - PackageApiContributionGuard::assertEndpoint($package, new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModuleContribution', - 'Return demo contribution.', - 'packages.demo-module.demo', - )); - } - - private function package(string $name): ExtensionPackage - { - return new ExtensionPackage( - Uuid::v7()->toRfc4122(), - [PackageScope::Module], - $name, - 'packages/'.$name, - ExtensionPackageStatus::Active, - ); - } -} diff --git a/tests/Core/Package/PackageAssetRegistryBuilderTest.php b/tests/Core/Package/PackageAssetRegistryBuilderTest.php deleted file mode 100644 index 1ec3371d..00000000 --- a/tests/Core/Package/PackageAssetRegistryBuilderTest.php +++ /dev/null @@ -1,84 +0,0 @@ -buildCssRegistry([ - PackageAssetContribution::css('frontend-theme', PackageScope::FrontendTheme, 'assets/packages/frontend-theme/app.css'), - PackageAssetContribution::css('demo-module', PackageScope::Module, 'assets/packages/demo-module/module.css'), - PackageAssetContribution::tailwindSource('demo-module', PackageScope::Module, 'packages/demo-module/templates'), - ], PackageAssetRegistryBuilder::BUCKET_EXTENSION); - - self::assertStringContainsString('@source "../../../packages/demo-module/templates";', $registry); - self::assertStringContainsString('@import "../../packages/demo-module/module.css";', $registry); - self::assertStringNotContainsString('frontend-theme/app.css', $registry); - } - - public function testItBuildsJavaScriptRegistryForThemePackages(): void - { - $builder = new PackageAssetRegistryBuilder(); - - $registry = $builder->buildJavaScriptRegistry([ - PackageAssetContribution::javaScript('demo-module', PackageScope::Module, 'assets/packages/demo-module/module.js'), - PackageAssetContribution::javaScript('site-theme', PackageScope::FrontendTheme, 'assets/packages/site-theme/theme.js'), - ], PackageAssetRegistryBuilder::BUCKET_FRONTEND_THEME); - - self::assertStringContainsString('import "../../packages/site-theme/theme.js";', $registry); - self::assertStringNotContainsString('demo-module/module.js', $registry); - } - - public function testItRejectsUnsafeAssetPaths(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('message.package.asset.contribution_path_traversal'); - - PackageAssetContribution::css('demo-module', PackageScope::Module, '../outside.css'); - } - - public function testItRejectsAssetContributionsWithoutPackageIdentifier(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('message.package.asset.contribution_package_invalid'); - - PackageAssetContribution::css('', PackageScope::Module, 'assets/packages/demo-module/module.css'); - } - - public function testItRejectsUnsupportedAssetContributionTypes(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('message.package.asset.contribution_type_invalid'); - - new PackageAssetContribution('demo-module', PackageScope::Module, 'image', 'assets/packages/demo-module/logo.svg'); - } - - public function testItAcceptsStaticAssetContributionsWithoutRegistryOutput(): void - { - $builder = new PackageAssetRegistryBuilder(); - $contribution = PackageAssetContribution::staticAsset( - 'demo-module', - PackageScope::Module, - 'assets/packages/demo-module/images/logo.svg', - ); - - self::assertSame(PackageAssetContribution::TYPE_STATIC_ASSET, $contribution->type()); - self::assertStringNotContainsString('logo.svg', $builder->buildCssRegistry([ - $contribution, - ], PackageAssetRegistryBuilder::BUCKET_EXTENSION)); - self::assertStringNotContainsString('logo.svg', $builder->buildJavaScriptRegistry([ - $contribution, - ], PackageAssetRegistryBuilder::BUCKET_EXTENSION)); - } -} diff --git a/tests/Core/Package/PackageAssetSyncerTest.php b/tests/Core/Package/PackageAssetSyncerTest.php deleted file mode 100644 index bc04c53e..00000000 --- a/tests/Core/Package/PackageAssetSyncerTest.php +++ /dev/null @@ -1,349 +0,0 @@ -root = $this->createTemporaryDirectory('system-package-assets'); - $this->writeTestFile($this->root, 'assets/packages/.gitignore', "*\n!.gitignore\n!README.md\n"); - $this->writeTestFile($this->root, 'assets/packages/README.md', "Package asset mirror.\n"); - $this->writeTestFile($this->root, 'assets/packages/stale/old.css', 'old'); - } - - protected function tearDown(): void - { - $this->removeDirectory($this->root); - } - - public function testItMirrorsPackageAssetsAndRebuildsRegistries(): void - { - $this->writeTestFile($this->root, 'packages/demo/assets/module.css', '.icon { background-image: url("./images/icon.svg"); }'); - $this->writeTestFile($this->root, 'packages/demo/assets/module.js', 'import helper from "./lib/helper.js";'); - $this->writeTestFile($this->root, 'packages/demo/assets/images/icon.svg', ''); - $this->writeTestFile($this->root, 'packages/demo/assets/lib/helper.js', 'export default {};'); - $this->writeTestFile($this->root, 'packages/demo/assets/vendor/library/index.js', 'import "./chunk.js";'); - $this->writeTestFile($this->root, 'packages/demo/templates/widget.html.twig', '
'); - - $result = (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), - ]); - - self::assertTrue($result->isSuccess()); - self::assertSame(5, $result->context()['mirrored_assets']); - self::assertFileDoesNotExist($this->root.'/assets/packages/stale/old.css'); - self::assertFileExists($this->root.'/assets/packages/.gitignore'); - self::assertFileExists($this->root.'/assets/packages/README.md'); - self::assertSame('', file_get_contents($this->root.'/assets/packages/demo/images/icon.svg')); - self::assertStringContainsString('url("../packages/demo/images/icon.svg")', (string) file_get_contents($this->root.'/assets/packages/demo/module.css')); - self::assertSame('import "./chunk.js";', file_get_contents($this->root.'/assets/packages/demo/vendor/library/index.js')); - - $cssRegistry = (string) file_get_contents($this->root.'/assets/styles/packages/extension.css'); - $javaScriptRegistry = (string) file_get_contents($this->root.'/assets/js/packages/extension.js'); - - self::assertStringContainsString('@source "../../../packages/demo/templates";', $cssRegistry); - self::assertStringContainsString('@import "../../packages/demo/module.css";', $cssRegistry); - self::assertStringContainsString('import "../../packages/demo/module.js";', $javaScriptRegistry); - self::assertStringNotContainsString('vendor/library/index.js', $javaScriptRegistry); - } - - public function testRegistryWriterCreatesMissingEmptyRegistries(): void - { - $root = $this->createTemporaryDirectory('system-package-registry-writer'); - - try { - $writer = new PackageAssetRegistryWriter(new PackageAssetFilesystem($root)); - $writer->ensureRegistryFilesExist(); - - self::assertStringContainsString('Generated CSS package asset registry: extension.', (string) file_get_contents($root.'/assets/styles/packages/extension.css')); - self::assertStringContainsString('Generated CSS package asset registry: frontend-theme.', (string) file_get_contents($root.'/assets/styles/packages/frontend-theme.css')); - self::assertStringContainsString('Generated CSS package asset registry: backend-theme.', (string) file_get_contents($root.'/assets/styles/packages/backend-theme.css')); - self::assertStringContainsString('Generated JavaScript package asset registry: extension.', (string) file_get_contents($root.'/assets/js/packages/extension.js')); - self::assertStringContainsString('Generated JavaScript package asset registry: frontend-theme.', (string) file_get_contents($root.'/assets/js/packages/frontend-theme.js')); - self::assertStringContainsString('Generated JavaScript package asset registry: backend-theme.', (string) file_get_contents($root.'/assets/js/packages/backend-theme.js')); - } finally { - $this->removeDirectory($root); - } - } - - public function testItRoutesFrontendAndBackendThemeAssetsToSeparateBuckets(): void - { - $this->writeTestFile($this->root, 'packages/dual/assets/frontend/app.css', '.front {}'); - $this->writeTestFile($this->root, 'packages/dual/assets/backend/app.css', '.back {}'); - $this->writeTestFile($this->root, 'packages/dual/assets/shared/app.css', '.shared {}'); - $this->writeTestFile($this->root, 'packages/dual/assets/theme.css', '.root {}'); - - (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('dual', 'packages/dual', [PackageScope::FrontendTheme, PackageScope::BackendTheme]), - ]); - - self::assertStringContainsString('@import "../../packages/dual/frontend/app.css";', (string) file_get_contents($this->root.'/assets/styles/packages/frontend-theme.css')); - self::assertStringContainsString('@import "../../packages/dual/backend/app.css";', (string) file_get_contents($this->root.'/assets/styles/packages/backend-theme.css')); - self::assertStringNotContainsString('dual/theme.css', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringNotContainsString('dual/shared/app.css', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringNotContainsString('dual/frontend/app.css', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringNotContainsString('dual/theme.css', (string) file_get_contents($this->root.'/assets/styles/packages/frontend-theme.css')); - self::assertStringNotContainsString('dual/theme.css', (string) file_get_contents($this->root.'/assets/styles/packages/backend-theme.css')); - } - - public function testItAllowsSharedAssetsOnlyForGlobalPackageScopes(): void - { - $this->writeTestFile($this->root, 'packages/theme-module/assets/frontend/app.css', '.front {}'); - $this->writeTestFile($this->root, 'packages/theme-module/assets/theme.css', '.global {}'); - - (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('theme-module', 'packages/theme-module', [PackageScope::FrontendTheme, PackageScope::Module]), - ]); - - self::assertStringContainsString('@import "../../packages/theme-module/frontend/app.css";', (string) file_get_contents($this->root.'/assets/styles/packages/frontend-theme.css')); - self::assertStringContainsString('@import "../../packages/theme-module/theme.css";', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - } - - public function testItDoesNotRouteAreaAssetsForPackagesWithoutMatchingThemeScope(): void - { - $this->writeTestFile($this->root, 'packages/module/assets/frontend/app.css', '.front {}'); - $this->writeTestFile($this->root, 'packages/module/assets/backend/app.css', '.back {}'); - $this->writeTestFile($this->root, 'packages/module/assets/theme.css', '.global {}'); - - (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('module', 'packages/module', [PackageScope::Module]), - ]); - - self::assertStringContainsString('@import "../../packages/module/theme.css";', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringNotContainsString('module/frontend/app.css', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringNotContainsString('module/backend/app.css', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringNotContainsString('module/frontend/app.css', (string) file_get_contents($this->root.'/assets/styles/packages/frontend-theme.css')); - self::assertStringNotContainsString('module/backend/app.css', (string) file_get_contents($this->root.'/assets/styles/packages/backend-theme.css')); - } - - public function testItRegistersModuleJavaScriptEntrypoints(): void - { - $this->writeTestFile($this->root, 'packages/module-assets/assets/app.mjs', 'import "./shared/util.mjs";'); - $this->writeTestFile($this->root, 'packages/module-assets/assets/index.mjs', 'console.log("index");'); - $this->writeTestFile($this->root, 'packages/module-assets/assets/module.mjs', 'console.log("module");'); - $this->writeTestFile($this->root, 'packages/module-assets/assets/theme.mjs', 'console.log("theme");'); - $this->writeTestFile($this->root, 'packages/module-assets/assets/feature.mjs', 'console.log("feature");'); - $this->writeTestFile($this->root, 'packages/module-assets/assets/vendor/library/index.mjs', 'console.log("vendor");'); - - $result = (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('module-assets', 'packages/module-assets', [PackageScope::Module]), - ]); - - self::assertTrue($result->isSuccess()); - self::assertSame(4, $result->context()['javascript_entries']); - - $javaScriptRegistry = (string) file_get_contents($this->root.'/assets/js/packages/extension.js'); - - self::assertStringContainsString('import "../../packages/module-assets/app.mjs";', $javaScriptRegistry); - self::assertStringContainsString('import "../../packages/module-assets/index.mjs";', $javaScriptRegistry); - self::assertStringContainsString('import "../../packages/module-assets/module.mjs";', $javaScriptRegistry); - self::assertStringContainsString('import "../../packages/module-assets/theme.mjs";', $javaScriptRegistry); - self::assertStringNotContainsString('feature.mjs', $javaScriptRegistry); - self::assertStringNotContainsString('vendor/library/index.mjs', $javaScriptRegistry); - } - - public function testItRegistersTemplateOnlyPackagesAsTailwindSources(): void - { - $this->writeTestFile($this->root, 'packages/templates-only/templates/widget.html.twig', '
'); - - $result = (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('templates-only', 'packages/templates-only', [PackageScope::Module]), - ]); - - self::assertTrue($result->isSuccess()); - self::assertSame(0, $result->context()['mirrored_assets']); - self::assertSame(1, $result->context()['tailwind_sources']); - self::assertStringContainsString('@source "../../../packages/templates-only/templates";', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - } - - public function testItDispatchesPackageAssetHooksAndAcceptsRegistryContributions(): void - { - $this->writeTestFile($this->root, 'packages/demo/assets/module.css', '.demo {}'); - $this->writeTestFile($this->root, 'packages/demo/assets/generated.css', '.generated {}'); - - $events = []; - $dispatcher = new EventDispatcher(); - $dispatcher->addListener(PackageAssetSyncStartedEvent::class, static function (PackageAssetSyncStartedEvent $event) use (&$events): void { - $events[] = 'started:'.$event->packages()[0]->identifier(); - }); - $dispatcher->addListener(PackageAssetRegistryBuildEvent::class, static function (PackageAssetRegistryBuildEvent $event) use (&$events): void { - $events[] = 'registry:'.count($event->contributions()); - $event->addContribution(PackageAssetContribution::css( - 'demo', - PackageScope::Module, - 'assets/packages/demo/generated.css', - )); - }); - $dispatcher->addListener(PackageAssetSyncCompletedEvent::class, static function (PackageAssetSyncCompletedEvent $event) use (&$events): void { - $events[] = 'completed:'.$event->metrics()['css_entries']; - }); - - $result = (new PackageAssetSyncer( - $this->root, - eventDispatcher: new PublicEventDispatcher($dispatcher, new PublicEventHookRegistry(), new NullWorkflowResultMessageReporter()), - ))->sync([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), - ]); - - self::assertTrue($result->isSuccess()); - self::assertSame(['started:demo', 'registry:1', 'completed:2'], $events); - self::assertSame(2, $result->context()['css_entries']); - self::assertStringContainsString('@import "../../packages/demo/generated.css";', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - } - - public function testItReportsHookListenerFailuresWithoutThrowing(): void - { - $this->writeTestFile($this->root, 'packages/demo/assets/module.css', '.demo {}'); - - $dispatcher = new EventDispatcher(); - $dispatcher->addListener(PackageAssetRegistryBuildEvent::class, static function (): void { - throw new \RuntimeException('Subscriber failed'); - }); - - $result = (new PackageAssetSyncer( - $this->root, - eventDispatcher: new PublicEventDispatcher($dispatcher, new PublicEventHookRegistry(), new NullWorkflowResultMessageReporter()), - ))->sync([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), - ]); - - self::assertSame(WorkflowStatus::Failed, $result->status()); - self::assertSame('event.hook_listener_failed', $result->firstIssue()?->code()); - self::assertSame(PackageAssetRegistryBuildEvent::class, $result->firstIssue()?->context()['event']); - self::assertFileExists($this->root.'/assets/packages/stale/old.css'); - self::assertDirectoryDoesNotExist($this->root.'/assets/packages/demo'); - } - - public function testItKeepsPreviousMirrorWhenRegistryWriteFails(): void - { - $this->writeTestFile($this->root, 'packages/demo/assets/module.css', '.demo {}'); - $this->writeTestFile($this->root, 'assets/styles/packages/extension.css', 'old extension registry'); - mkdir($this->root.'/assets/styles/packages/frontend-theme.css', 0775, true); - - $result = (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), - ]); - - self::assertSame(WorkflowStatus::Failed, $result->status()); - self::assertSame('old extension registry', file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertFileExists($this->root.'/assets/packages/stale/old.css'); - self::assertDirectoryDoesNotExist($this->root.'/assets/packages/demo'); - self::assertSame([], glob($this->root.'/assets/.packages.tmp-*')); - } - - public function testRegistryWriterRollsBackWhenFollowUpFails(): void - { - $this->writeTestFile($this->root, 'assets/styles/packages/extension.css', 'old extension registry'); - - $writer = new PackageAssetRegistryWriter(new PackageAssetFilesystem($this->root)); - - try { - $writer->writeThen([ - PackageAssetContribution::css('demo', PackageScope::Module, 'assets/packages/demo/module.css'), - ], static function (): void { - throw new \RuntimeException('follow-up failed'); - }); - self::fail('Expected registry write follow-up to fail.'); - } catch (\RuntimeException $error) { - self::assertSame('follow-up failed', $error->getMessage()); - } - - self::assertSame('old extension registry', file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertFileDoesNotExist($this->root.'/assets/styles/packages/frontend-theme.css'); - self::assertFileDoesNotExist($this->root.'/assets/js/packages/extension.js'); - self::assertSame([], glob($this->root.'/assets/styles/packages/*.backup-*')); - self::assertSame([], glob($this->root.'/assets/js/packages/*.backup-*')); - } - - public function testItRemovesDeactivatedPackageMirrorAndRegistryEntries(): void - { - $this->writeTestFile($this->root, 'packages/demo/assets/module.css', '.demo {}'); - $this->writeTestFile($this->root, 'packages/demo/assets/module.js', 'console.log("demo");'); - $this->writeTestFile($this->root, 'packages/demo/templates/widget.html.twig', '
'); - - $syncer = new PackageAssetSyncer($this->root); - $syncer->sync([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), - ]); - - self::assertDirectoryExists($this->root.'/assets/packages/demo'); - self::assertStringContainsString('packages/demo/templates', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringContainsString('packages/demo/module.css', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringContainsString('packages/demo/module.js', (string) file_get_contents($this->root.'/assets/js/packages/extension.js')); - - $syncer->sync([]); - - self::assertDirectoryDoesNotExist($this->root.'/assets/packages/demo'); - self::assertStringNotContainsString('packages/demo', (string) file_get_contents($this->root.'/assets/styles/packages/extension.css')); - self::assertStringNotContainsString('packages/demo', (string) file_get_contents($this->root.'/assets/js/packages/extension.js')); - } - - public function testItFailsWhenMirrorDirectoryIsASymlink(): void - { - $this->removeDirectory($this->root.'/assets/packages'); - mkdir($this->root.'/external-mirror', 0775, true); - $this->createSymlinkOrSkip($this->root.'/external-mirror', $this->root.'/assets/packages'); - - $result = (new PackageAssetSyncer($this->root))->sync([]); - unlink($this->root.'/assets/packages'); - mkdir($this->root.'/assets/packages', 0775, true); - - self::assertSame(WorkflowStatus::Failed, $result->status()); - self::assertSame('package.asset_sync_failed', $result->firstIssue()?->code()); - } - - public function testItFailsWhenPackageAssetRootIsBelowASymlink(): void - { - mkdir($this->root.'/external-package/assets', 0775, true); - $this->writeTestFile($this->root, 'external-package/assets/module.css', '.demo {}'); - mkdir($this->root.'/packages', 0775, true); - $this->createSymlinkOrSkip($this->root.'/external-package', $this->root.'/packages/demo'); - - $result = (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), - ]); - unlink($this->root.'/packages/demo'); - - self::assertSame(WorkflowStatus::Failed, $result->status()); - self::assertSame('package.asset_sync_failed', $result->firstIssue()?->code()); - } - - public function testItFailsWhenPackageTemplateRootIsASymlink(): void - { - mkdir($this->root.'/packages/demo', 0775, true); - mkdir($this->root.'/external-templates', 0775, true); - $this->createSymlinkOrSkip($this->root.'/external-templates', $this->root.'/packages/demo/templates'); - - $result = (new PackageAssetSyncer($this->root))->sync([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), - ]); - unlink($this->root.'/packages/demo/templates'); - - self::assertSame(WorkflowStatus::Failed, $result->status()); - self::assertSame('package.asset_sync_failed', $result->firstIssue()?->code()); - } -} diff --git a/tests/Core/Package/PackageContributionsTest.php b/tests/Core/Package/PackageContributionsTest.php deleted file mode 100644 index 47f9ac6d..00000000 --- a/tests/Core/Package/PackageContributionsTest.php +++ /dev/null @@ -1,47 +0,0 @@ -staticView($staticView) - ->setting($setting) - ->schedulerTask($schedulerTask); - - self::assertSame([$staticView, $setting, $schedulerTask], iterator_to_array($contributions)); - } -} diff --git a/tests/Core/Package/PackageDiscoveryTest.php b/tests/Core/Package/PackageDiscoveryTest.php deleted file mode 100644 index 150aecca..00000000 --- a/tests/Core/Package/PackageDiscoveryTest.php +++ /dev/null @@ -1,146 +0,0 @@ -projectDir = $this->createTemporaryDirectory('system-package-discovery'); - } - - protected function tearDown(): void - { - $this->removeDirectory($this->projectDir); - } - - public function testItDiscoversDefaultPackageLocations(): void - { - $this->writeManifest('.', <<<'MANIFEST' - APP_VERSION=0.1.0 - APP_DATE=2026-05-22 - APP_CHANNEL=dev-latest - APP_SOURCE=https://github.com/aavion/studio.git - MANIFEST); - $this->writeManifest('packages/system-frontend', <<<'MANIFEST' - PACKAGE_AUTHOR=Aavion - PACKAGE_SLUG=system-frontend - PACKAGE_NAME=System Frontend - PACKAGE_VERSION=1.0.0 - PACKAGE_SCOPE=frontend-theme - PACKAGE_DEPENDENCIES=[] - MANIFEST); - $this->writeManifest('packages/contact', <<<'MANIFEST' - PACKAGE_AUTHOR=Aavion - PACKAGE_SLUG=contact - PACKAGE_NAME=Contact - PACKAGE_VERSION=1.0.0 - PACKAGE_SCOPE=[module, captcha-provider] - PACKAGE_DEPENDENCIES=[] - MANIFEST); - $this->writeManifest('var/cache/test/imports/theme-update', <<<'MANIFEST' - PACKAGE_NAME=Theme Update - PACKAGE_VERSION=1.0.1 - MANIFEST); - - $result = (new PackageDiscovery())->discover($this->projectDir, 'test'); - - self::assertTrue($result->isSuccess()); - self::assertCount(4, $result->value()); - self::assertSame(['app', 'package', 'package', 'import'], array_map( - static fn ($candidate): string => $candidate->source()->name(), - $result->value(), - )); - self::assertSame('Contact', $result->value()[1]->manifest()->get('PACKAGE_NAME')); - self::assertSame('System Frontend', $result->value()[2]->manifest()->get('PACKAGE_NAME')); - } - - public function testItTreatsMissingPackageDirectoriesAsEmptySources(): void - { - $this->writeManifest('.', 'APP_VERSION=0.1.0'); - - $result = (new PackageDiscovery())->discover($this->projectDir, 'test'); - - self::assertTrue($result->isSuccess()); - self::assertCount(1, $result->value()); - self::assertSame('app', $result->value()[0]->source()->name()); - } - - public function testItReportsInvalidPackageManifests(): void - { - $this->writeManifest('.', 'APP_VERSION=0.1.0'); - $this->writeManifest('packages/broken', <<<'MANIFEST' - PACKAGE_SLUG=broken - PACKAGE_NAME=Broken - PACKAGE_SCOPE=unsupported-scope - PACKAGE_DEPENDENCIES=[] - PACKAGE_UNDECLARED=value - MANIFEST); - - $result = (new PackageDiscovery())->discover($this->projectDir, 'test'); - - self::assertFalse($result->isSuccess()); - self::assertCount(3, $result->issues()); - self::assertSame('manifest.missing_required_key', $result->issues()[0]->code()); - self::assertSame('PACKAGE_AUTHOR', $result->issues()[0]->context()['key']); - self::assertSame('manifest.missing_required_key', $result->issues()[1]->code()); - self::assertSame('PACKAGE_VERSION', $result->issues()[1]->context()['key']); - self::assertSame('manifest.unknown_key', $result->issues()[2]->code()); - self::assertSame('package', $result->issues()[2]->context()['source']); - } - - public function testItReportsInvalidPackageScopes(): void - { - $this->writeManifest('.', 'APP_VERSION=0.1.0'); - $this->writeManifest('packages/broken-scope', <<<'MANIFEST' - PACKAGE_AUTHOR=Aavion - PACKAGE_SLUG=broken-scope - PACKAGE_NAME=Broken Scope - PACKAGE_VERSION=1.0.0 - PACKAGE_SCOPE=frontend-theme,unsupported-scope - PACKAGE_DEPENDENCIES=[] - MANIFEST); - - $result = (new PackageDiscovery())->discover($this->projectDir, 'test'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scope_invalid', $result->firstIssue()?->code()); - self::assertSame('frontend-theme,unsupported-scope', $result->firstIssue()?->context()['scope']); - } - - public function testImportManifestsAreParsedWithoutNamespaceRestrictions(): void - { - $this->writeManifest('.', 'APP_VERSION=0.1.0'); - $this->writeManifest('var/cache/test/imports/any-package', <<<'MANIFEST' - CUSTOM_NAME=Any Package - CUSTOM_VERSION=1.0.0 - MANIFEST); - - $result = (new PackageDiscovery())->discover($this->projectDir, 'test'); - - self::assertTrue($result->isSuccess()); - self::assertCount(2, $result->value()); - self::assertSame('import', $result->value()[1]->source()->name()); - self::assertSame('Any Package', $result->value()[1]->manifest()->get('CUSTOM_NAME')); - } - - private function writeManifest(string $relativeDirectory, string $contents): void - { - $directory = $this->projectDir.'/'.trim($relativeDirectory, '/'); - if ('.' === $relativeDirectory) { - $directory = $this->projectDir; - } - - $this->writeTestFile($directory, '.manifest', $contents); - } -} diff --git a/tests/Core/Package/PackageFixtureTest.php b/tests/Core/Package/PackageFixtureTest.php deleted file mode 100644 index 028db481..00000000 --- a/tests/Core/Package/PackageFixtureTest.php +++ /dev/null @@ -1,95 +0,0 @@ -fixturePath('packages'); - $result = (new PackageDiscovery())->discover($root, 'test'); - - self::assertTrue($result->isSuccess()); - self::assertSame(['app', 'package', 'package', 'import'], array_map( - static fn (PackageCandidate $candidate): string => $candidate->source()->name(), - $result->value(), - )); - - $validator = new PackageValidator(); - - foreach ($result->value() as $candidate) { - $validationResult = $validator->validate($candidate, $this->specFor($candidate->source()->name())); - - self::assertTrue($validationResult->isSuccess(), sprintf('Fixture package "%s" should validate.', $candidate->directory())); - } - } - - public function testInvalidFixturePackagesExposeExpectedDiagnostics(): void - { - $root = $this->fixturePath('packages-invalid'); - $result = (new PackageDiscovery())->discoverSources($root, [ - PackageSource::children('fixture', '.'), - ]); - - self::assertFalse($result->isSuccess()); - self::assertSame('manifest.invalid_line', $result->firstIssue()?->code()); - - $candidates = $result->context()['candidates']; - self::assertContainsOnlyInstancesOf(PackageCandidate::class, $candidates); - self::assertCount(2, $candidates); - - $byDirectory = []; - foreach ($candidates as $candidate) { - $byDirectory[basename($candidate->directory())] = $candidate; - } - - $missingResult = (new PackageValidator())->validate($byDirectory['missing-package-files'], PackageSpec::create() - ->requireDirectory('templates') - ->requireDirectory('assets')); - - self::assertFalse($missingResult->isSuccess()); - self::assertSame([ - 'package.required_directory_missing', - 'package.required_directory_missing', - ], array_map(static fn ($issue): string => $issue->code(), $missingResult->issues())); - - $lintResult = (new PackageValidator())->validate($byDirectory['broken-lint'], PackageSpec::create()->withLintingChecks()); - - self::assertFalse($lintResult->isSuccess()); - self::assertSame([ - 'package.php_syntax_error', - 'package.twig_syntax_error', - 'package.json_syntax_error', - 'package.yaml_syntax_error', - 'package.css_syntax_error', - 'package.javascript_syntax_error', - ], array_map(static fn ($issue): string => $issue->code(), $lintResult->issues())); - } - - private function specFor(string $source): PackageSpec - { - return match ($source) { - 'app' => PackageSpec::create() - ->requireFile('.manifest'), - 'package' => PackageSpec::create() - ->requireFile('.manifest') - ->withLintingChecks(), - 'import' => PackageSpec::create() - ->requireFile('.manifest') - ->withLintingChecks(), - default => PackageSpec::create(), - }; - } -} diff --git a/tests/Core/Package/PackageLifecycleBoundaryTest.php b/tests/Core/Package/PackageLifecycleBoundaryTest.php deleted file mode 100644 index 148f9f7f..00000000 --- a/tests/Core/Package/PackageLifecycleBoundaryTest.php +++ /dev/null @@ -1,968 +0,0 @@ -entityManager = self::getContainer()->get(EntityManagerInterface::class); - $this->connection = $this->entityManager->getConnection(); - $this->assetRebuilder = new BoundaryPackageLifecycleAssetRebuilder(); - $this->cleanupRunner = new RecordingPackageLifecycleCleanupRunner(); - $this->projectDir = $this->createTemporaryDirectory('system-package-lifecycle-boundary'); - $this->connection->beginTransaction(); - } - - protected function tearDown(): void - { - if ($this->connection->isTransactionActive()) { - $this->connection->rollBack(); - } - - $this->removeDirectory($this->projectDir); - - parent::tearDown(); - } - - public function testActivePackageProviderReturnsOnlyActiveRealPackages(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - $this->insertPackage('demo-theme', ['frontend-theme'], 'active'); - $this->insertPackage('inactive-module', ['module'], 'inactive'); - $this->insertPackage('system-local', ['system-template'], 'active', path: '.'); - - $provider = new ActivePackageProvider($this->entityManager); - - self::assertSame(['demo-module', 'demo-theme'], array_map( - static fn (ExtensionPackage $package): string => $package->packageName(), - $provider->packages(), - )); - self::assertSame(['demo-module'], array_map( - static fn (ExtensionPackage $package): string => $package->packageName(), - $provider->packages(PackageScope::Module), - )); - self::assertSame('demo-theme', $provider->package('demo-theme')?->packageName()); - self::assertNull($provider->package('inactive-module')); - } - - public function testRuntimeHookFailureMarksIdentifiedActivePackageFaulty(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - $this->insertPackage('demo-addon', ['module'], 'active', dependencies: '[["demo-module", "1.0.0"]]'); - - $messageBus = new RecordingMessageBus(); - - $result = (new PackageRuntimeFailureHandler( - $this->entityManager, - new NullWorkflowResultMessageReporter(), - new PackageAssetRebuildDispatcher($messageBus, new NullWorkflowResultMessageReporter()), - 'test', - ))->handleHookFailure(new PublicHookFailedEvent( - new ViewContextEvent([]), - new EventHookDescriptor(ViewContextEvent::class, 'view', EventHookMode::Extend, EventMessageKey::EVENT_HOOK_VIEW_CONTEXT_SUMMARY, mutable: true), - Message::create(EventMessageCode::EVENT_HOOK_LISTENER_FAILED, EventMessageKey::EVENT_HOOK_LISTENER_FAILED, ['%event%' => ViewContextEvent::class]), - new RuntimeException('listener failed'), - ['route' => 'demo'], - 'demo-module', - )); - - self::assertTrue($result->isSuccess()); - self::assertTrue($result->value()['faulty']); - self::assertSame('faulty', $this->packageStatus('demo-module')); - self::assertSame('inactive', $this->packageStatus('demo-addon')); - self::assertSame(['demo-addon'], $result->value()['deactivated_dependents']); - self::assertSame(ViewContextEvent::class, $this->metadata('demo-module')['runtime_failure']['hook']); - self::assertSame('faulty', $this->metadata('demo-module')['registry_state']); - self::assertContains('message.package.lifecycle.dependent_deactivated', array_map( - static fn (Message $message): string => $message->translationKey(), - $result->messages(), - )); - self::assertCount(1, $messageBus->messages()); - self::assertInstanceOf(PackageAssetRebuildMessage::class, $messageBus->messages()[0]); - self::assertSame('test', $messageBus->messages()[0]->environment()); - self::assertSame('package_runtime_failure', $messageBus->messages()[0]->trigger()); - } - - public function testPackagePhpLoaderIncludesOnlyActivePackageLoaders(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - $this->insertPackage('inactive-module', ['module'], 'inactive'); - $this->writeTestFile($this->projectDir, 'packages/demo-module/package.php', <<<'PHP' - packageName()); - }; - PHP); - $this->writeTestFile($this->projectDir, 'packages/inactive-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - ))->loadActivePackages(); - - self::assertTrue($result->isSuccess()); - self::assertSame(['demo-module'], $result->value()['loaded']); - self::assertFileExists($this->projectDir.'/packages/demo-module/loaded.txt'); - self::assertSame('demo-module', file_get_contents($this->projectDir.'/packages/demo-module/called.txt')); - self::assertFileDoesNotExist($this->projectDir.'/packages/inactive-module/loaded.txt'); - } - - public function testPackagePhpLoaderRegistersRuntimeContributions(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/demo-module/package.php', <<<'PHP' - staticView(new StaticViewInjection( - 'pkg-demo-module-route', - ViewSurface::Public, - 'demo-module', - 'pkg.demo-module.widget', - '@frontend/demo-module/frontend.html.twig', - )) - ->configurableStaticViews(new ConfigurableStaticViewInjectionSet( - 'demo-module', - 'demo.route', - ViewSurface::Public, - 'demo', - [ - new ConfigurableStaticViewInjectionRoute( - 'pkg-demo-module-configurable-route', - '', - 'pkg.demo-module.widget', - '@frontend/demo-module/frontend.html.twig', - ), - ], - )) - ->dynamicView(new DynamicViewInjection( - 'pkg-demo-module-after-content', - ViewSurface::Public, - DynamicViewInjectionSlot::AfterContent, - '@frontend/demo-module/after-content.html.twig', - )) - ->setting(new PackageSettingDefinition( - 'demo-module', - 'display.mode', - 'pkg.demo-module.settings.display_mode.label', - 'compact', - )) - ->apiEndpoint(new ApiEndpointDefinition( - 'package', - 'GET', - PackageApiEndpointPath::path($package->packageName(), 'demo'), - 'api_v1_endpoint_dispatch', - 'getDemoModulePackageEndpoint', - 'Return package demo data.', - 'packages.demo-module.demo', - ['packages-demo-module-demo'], - responseSchema: ['type' => 'object'], - )) - ->apiEndpointHandler(new class implements ApiEndpointHandlerInterface { - public function apiEndpointHandlerKey(): string - { - return 'packages.demo-module.demo'; - } - - public function handle(Request $request, ApiEndpointDefinition $endpoint): Response - { - return new JsonResponse(['data' => ['type' => 'package_demo']]); - } - }); - PHP); - $registry = new PackageRuntimeContributionRegistry(); - - $result = (new PackagePhpLoader( - new ActivePackageProvider($this->entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertTrue($result->isSuccess()); - self::assertSame('pkg-demo-module-route', $registry->staticViewInjections()[0]->uid()); - self::assertSame('demo-module', $registry->staticViewInjections()[0]->pathSlug()); - self::assertSame('pkg-demo-module-configurable-route', $registry->staticViewInjections()[1]->uid()); - self::assertSame('demo', $registry->staticViewInjections()[1]->pathSlug()); - self::assertSame('pkg-demo-module-after-content', $registry->dynamicViewInjections()[0]->uid()); - self::assertSame('display.mode', $registry->packageSettings()[0]->key()); - self::assertSame('/api/v1/packages/demo-module/demo', $registry->apiEndpoints()[0]->path()); - self::assertSame('packages.demo-module.demo', $registry->apiEndpointHandlers()[0]->apiEndpointHandlerKey()); - } - - public function testPackagePhpLoaderDoesNotKeepPartialRuntimeContributionsAfterFailure(): void - { - $this->insertPackage('broken-module', ['module'], 'active'); - $messageBus = new RecordingMessageBus(); - $this->writeTestFile($this->projectDir, 'packages/broken-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - new PackageAssetRebuildDispatcher($messageBus, new NullWorkflowResultMessageReporter()), - 'test', - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); - self::assertSame([], $registry->staticViewInjections()); - self::assertSame('faulty', $this->packageStatus('broken-module')); - } - - public function testPackagePhpLoaderAcceptsScopedNecessaryCookieConsentContributions(): void - { - $this->insertPackage('captcha-provider', ['captcha-provider'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/captcha-provider/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertTrue($result->isSuccess()); - self::assertSame('captcha_provider_state', $registry->cookieConsentDefinitions()[0]->name()); - } - - public function testPackagePhpLoaderRejectsUnscopedNecessaryCookieConsentContributions(): void - { - $this->insertPackage('tracking-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/tracking-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); - self::assertSame([], $registry->cookieConsentDefinitions()); - self::assertSame('faulty', $this->packageStatus('tracking-module')); - } - - public function testPackagePhpLoaderRejectsCrossSiteNecessaryCookieConsentContributions(): void - { - $this->insertPackage('captcha-provider', ['captcha-provider'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/captcha-provider/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); - self::assertSame([], $registry->cookieConsentDefinitions()); - self::assertSame('faulty', $this->packageStatus('captcha-provider')); - } - - public function testPackagePhpLoaderRejectsDuplicateCookieConsentContributions(): void - { - $this->insertPackage('cookie-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/cookie-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); - self::assertSame([], $registry->cookieConsentDefinitions()); - self::assertSame('faulty', $this->packageStatus('cookie-module')); - } - - public function testPackagePhpLoaderRejectsReservedCoreCookieConsentContributions(): void - { - $this->insertPackage('session-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/session-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame('message.package.runtime.contribution_unsupported', $result->firstIssue()?->context()['previous_message']['key'] ?? null); - self::assertSame([], $registry->cookieConsentDefinitions()); - self::assertSame('faulty', $this->packageStatus('session-module')); - } - - public function testPackagePhpLoaderRejectsUnsafeOptionalCookiePrivacyUrls(): void - { - $this->insertPackage('tracking-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/tracking-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame(InvalidArgumentException::class, $result->firstIssue()?->context()['exception'] ?? null); - self::assertSame([], $registry->cookieConsentDefinitions()); - self::assertSame('faulty', $this->packageStatus('tracking-module')); - } - - public function testPackagePhpLoaderRejectsElevatedSchedulerContributions(): void - { - $this->insertPackage('scheduler-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/scheduler-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame('message.package.scheduler.source_invalid', $result->firstIssue()?->context()['previous_message']['key'] ?? null); - self::assertSame([], $registry->schedulerTasks()); - self::assertSame('faulty', $this->packageStatus('scheduler-module')); - } - - public function testPackagePhpLoaderKeepsSchedulerExecutionProviders(): void - { - $this->insertPackage('scheduler-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/scheduler-module/package.php', <<<'PHP' - SchedulerTaskExecution::success(['package_callable' => true]) - : null; - } - - public function schedulerActionQueue(string $target): ?ActionQueue - { - return 'scheduler-module.queue' === $target ? ActionQueue::create('scheduler-module.queue') : null; - } - }; - PHP); - $registry = new PackageRuntimeContributionRegistry(); - - $result = (new PackagePhpLoader( - new ActivePackageProvider($this->entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertTrue($result->isSuccess()); - self::assertSame('scheduler-module.cleanup', $registry->schedulerTasks()[0]->identifier()); - self::assertNotNull($registry->schedulerCallable('scheduler-module.cleanup')); - self::assertNull($registry->schedulerCallable('scheduler-module.missing')); - self::assertSame('scheduler-module.queue', $registry->schedulerActionQueue('scheduler-module.queue')?->name()); - self::assertNull($registry->schedulerActionQueue('scheduler-module.missing')); - } - - public function testPackagePhpLoaderConvertsRuntimeProviderFailuresIntoFaults(): void - { - $this->insertPackage('broken-provider-module', ['module'], 'active'); - $messageBus = new RecordingMessageBus(); - $this->writeTestFile($this->projectDir, 'packages/broken-provider-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - new PackageAssetRebuildDispatcher($messageBus, new NullWorkflowResultMessageReporter()), - 'test', - runtimeContributions: $registry, - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame([], $registry->staticViewInjections()); - self::assertSame('faulty', $this->packageStatus('broken-provider-module')); - } - - public function testPackagePhpLoaderCanReloadPackagePhpAcrossLoaderInstances(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/demo-module/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $firstRegistry, - ))->loadActivePackages(); - $secondResult = (new PackagePhpLoader( - new ActivePackageProvider($this->entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - runtimeContributions: $secondRegistry, - ))->loadActivePackages(); - - self::assertTrue($firstResult->isSuccess()); - self::assertTrue($secondResult->isSuccess()); - self::assertSame('pkg-demo-module-reload', $firstRegistry->staticViewInjections()[0]->uid()); - self::assertSame('pkg-demo-module-reload', $secondRegistry->staticViewInjections()[0]->uid()); - } - - public function testPackagePhpLoaderMarksFailingPackagesFaulty(): void - { - $this->insertPackage('broken-module', ['module'], 'active'); - $this->insertPackage('broken-addon', ['module'], 'active', dependencies: '[["broken-module", "1.0.0"]]'); - $messageBus = new RecordingMessageBus(); - $this->writeTestFile($this->projectDir, 'packages/broken-module/package.php', <<<'PHP' - writeTestFile($this->projectDir, 'packages/broken-addon/package.php', <<<'PHP' - entityManager), - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - new PackageAssetRebuildDispatcher($messageBus, new NullWorkflowResultMessageReporter()), - 'test', - ))->loadActivePackages(); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.php_load_failed', $result->firstIssue()?->code()); - self::assertSame('faulty', $this->packageStatus('broken-module')); - self::assertSame('inactive', $this->packageStatus('broken-addon')); - self::assertFileDoesNotExist($this->projectDir.'/packages/broken-addon/loaded.txt'); - self::assertSame('RuntimeException', $this->metadata('broken-module')['runtime_loader']['exception']); - self::assertContains('message.package.lifecycle.dependent_deactivated', array_map( - static fn (Message $message): string => $message->translationKey(), - $result->messages(), - )); - self::assertCount(1, $messageBus->messages()); - self::assertInstanceOf(PackageAssetRebuildMessage::class, $messageBus->messages()[0]); - self::assertSame('package_php_loader_faulty', $messageBus->messages()[0]->trigger()); - } - - public function testPackageAssetRebuildMessageHandlerRunsLifecycleRebuild(): void - { - $result = (new PackageAssetRebuildMessageHandler($this->assetRebuilder))(new PackageAssetRebuildMessage('test', 'faulty')); - - self::assertTrue($result->isSuccess()); - self::assertSame(['test'], $this->assetRebuilder->environments); - } - - public function testPackageRemoverDeletesPackageDirectoryAndMarksRegistryRemoved(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - $this->writeTestFile($this->projectDir, 'packages/demo-module/.manifest', 'PACKAGE_NAME=Demo'); - $this->writeTestFile($this->projectDir, 'packages/demo-module/src/Demo.php', 'remover()->remove('demo-module', 'test'); - - self::assertTrue($result->isSuccess()); - self::assertDirectoryDoesNotExist($this->projectDir.'/packages/demo-module'); - self::assertSame('removed', $this->packageStatus('demo-module')); - self::assertSame(['test'], $this->assetRebuilder->environments); - self::assertSame([], $this->cleanupRunner->packages); - self::assertSame('removed', $this->metadata('demo-module')['registry_state']); - self::assertSame(['demo-module', 'demo-module'], array_column($result->value()['changes'], 'package')); - } - - public function testPackageRemoverDeactivatesActiveDependentsBeforeDeletingPackage(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - $this->insertPackage('demo-addon', ['module'], 'active', dependencies: '[["demo-module", "1.0.0"]]'); - $this->writeTestFile($this->projectDir, 'packages/demo-module/.manifest', 'PACKAGE_NAME=Demo'); - - $plan = $this->remover()->planRemoval('demo-module'); - self::assertTrue($plan->isSuccess()); - self::assertSame(['demo-addon', 'demo-module', 'demo-module'], array_column($plan->value()['changes'], 'package')); - - $result = $this->remover()->remove('demo-module', 'test'); - - self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); - self::assertSame('removed', $this->packageStatus('demo-module')); - self::assertSame('inactive', $this->packageStatus('demo-addon')); - self::assertSame(['test'], $this->assetRebuilder->environments); - self::assertSame(['demo-addon', 'demo-module', 'demo-module'], array_column($result->value()['changes'], 'package')); - } - - public function testPackageRemoverPurgeRunsCleanupAndDeletesRegistryRow(): void - { - $this->insertPackage('demo-module', ['module'], 'removed'); - - $result = $this->remover()->purge('demo-module'); - - self::assertTrue($result->isSuccess()); - self::assertSame(['demo-module'], $this->cleanupRunner->packages); - self::assertFalse($this->packageRowExists('demo-module')); - self::assertSame('purged', $result->value()['changes'][0]['action']); - } - - public function testPackageRemoverPurgeBlocksNonRemovedPackages(): void - { - $this->insertPackage('demo-module', ['module'], 'active'); - - $result = $this->remover()->purge('demo-module'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.status_blocked', $result->firstIssue()?->code()); - self::assertSame([], $this->cleanupRunner->packages); - self::assertTrue($this->packageRowExists('demo-module')); - self::assertSame('active', $this->packageStatus('demo-module')); - } - - public function testPackageFaultResetterReturnsValidatedFaultyPackageToInactive(): void - { - $this->insertPackage('demo-module', ['module'], 'faulty'); - $this->writePackageManifest('demo-module'); - - $result = (new PackageFaultResetter( - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - 'test', - ))->resetFault('demo-module'); - - self::assertTrue($result->isSuccess()); - self::assertSame('inactive', $this->packageStatus('demo-module')); - self::assertSame('fault_reset', $result->value()['changes'][0]['action']); - self::assertSame('package.lifecycle.fault_reset', $result->messages()[1]->code()); - self::assertSame('available', $this->metadata('demo-module')['registry_state']); - self::assertSame('Lifecycle boundary demo package.', $this->metadata('demo-module')['description']); - self::assertArrayHasKey('last_fault', $this->metadata('demo-module')); - } - - public function testPackageFaultResetterKeepsInvalidPackageFaulty(): void - { - $this->insertPackage('demo-module', ['module'], 'faulty'); - $this->writePackageManifest('demo-module'); - $this->writeTestFile($this->projectDir, 'packages/demo-module/src/Broken.php', <<<'PHP' - entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - 'test', - ))->resetFault('demo-module'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.php_syntax_error', $result->firstIssue()?->code()); - self::assertSame('faulty', $this->packageStatus('demo-module')); - } - - public function testPackageFaultResetterBlocksNonFaultyPackage(): void - { - $this->insertPackage('demo-module', ['module'], 'inactive'); - $this->writePackageManifest('demo-module'); - - $result = (new PackageFaultResetter( - $this->entityManager, - $this->projectDir, - new NullWorkflowResultMessageReporter(), - 'test', - ))->resetFault('demo-module'); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.lifecycle.status_blocked', $result->firstIssue()?->code()); - self::assertSame('inactive', $this->packageStatus('demo-module')); - } - - /** - * @param list $scopes - */ - private function insertPackage( - string $packageName, - array $scopes, - string $status, - string $path = '', - string $dependencies = '[]', - ): void { - $this->connection->insert('extension_package', [ - 'uid' => $this->uuid(), - 'package_scopes' => json_encode($scopes, JSON_THROW_ON_ERROR), - 'package_name' => $packageName, - 'path' => '' === $path ? 'packages/'.$packageName : $path, - 'manifest_version' => '1.0.0', - 'installed_version' => '1.0.0', - 'status' => $status, - 'metadata' => json_encode([ - 'registry_state' => 'available', - 'manifest' => [ - 'PACKAGE_DEPENDENCIES' => $dependencies, - ], - ], JSON_THROW_ON_ERROR), - 'modified_at' => '2026-05-25 00:00:00', - ]); - } - - private function remover(): PackageRemover - { - $reporter = new NullWorkflowResultMessageReporter(); - - return new PackageRemover( - $this->entityManager, - new PackageActivator($this->entityManager, $this->assetRebuilder, $reporter), - $this->assetRebuilder, - $this->cleanupRunner, - $this->projectDir, - $reporter, - ); - } - - private function packageStatus(string $packageName): string - { - $status = $this->connection->fetchOne( - 'SELECT status FROM extension_package WHERE package_name = :package_name', - ['package_name' => $packageName], - ); - - self::assertIsString($status); - - return $status; - } - - private function writePackageManifest(string $packageName): void - { - $this->writeTestFile($this->projectDir, 'packages/'.$packageName.'/.manifest', <<<'MANIFEST' - PACKAGE_AUTHOR=Aavion Test Fixtures - PACKAGE_SLUG=demo-module - PACKAGE_NAME=Demo Module - PACKAGE_DESCRIPTION=Lifecycle boundary demo package. - PACKAGE_VERSION=1.0.1 - PACKAGE_SCOPE=module - PACKAGE_DEPENDENCIES=[] - MANIFEST); - } - - private function packageRowExists(string $packageName): bool - { - return false !== $this->connection->fetchOne( - 'SELECT 1 FROM extension_package WHERE package_name = :package_name', - ['package_name' => $packageName], - ); - } - - /** - * @return array - */ - private function metadata(string $packageName): array - { - $metadata = $this->connection->fetchOne( - 'SELECT metadata FROM extension_package WHERE package_name = :package_name', - ['package_name' => $packageName], - ); - - self::assertIsString($metadata); - $decoded = json_decode($metadata, true, flags: JSON_THROW_ON_ERROR); - self::assertIsArray($decoded); - - return $decoded; - } - - private function uuid(): string - { - return Uuid::v7()->toRfc4122(); - } -} - -final class RecordingPackageLifecycleCleanupRunner implements PackageLifecycleCleanupRunnerInterface -{ - /** - * @var list - */ - public array $packages = []; - - public function cleanup(ExtensionPackage $package): WorkflowResult - { - $this->packages[] = $package->packageName(); - - return WorkflowResult::success([ - 'package' => $package->packageName(), - 'actions' => [], - ], [ - 'package' => $package->packageName(), - 'actions' => [], - ]); - } -} - -final class BoundaryPackageLifecycleAssetRebuilder implements PackageLifecycleAssetRebuilderInterface -{ - /** - * @var list - */ - public array $environments = []; - - public function rebuild(string $environment): WorkflowResult - { - $this->environments[] = $environment; - - return WorkflowResult::success(context: ['fake' => true]); - } -} diff --git a/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php b/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php deleted file mode 100644 index 3af5a04b..00000000 --- a/tests/Core/Package/PackageLifecycleCleanupRunnerTest.php +++ /dev/null @@ -1,46 +0,0 @@ -get(PackageSettings::class); - $runner = new PackageLifecycleCleanupRunner($settings); - - $settings->set('cleanup-module', 'display.mode', 'compact', ConfigValueType::String); - $settings->set('neighbor-module', 'display.mode', 'comfortable', ConfigValueType::String); - - $result = $runner->cleanup(new ExtensionPackage( - '10000000-0000-7000-8000-000000000611', - [PackageScope::Module], - 'cleanup-module', - 'packages/cleanup-module', - ExtensionPackageStatus::Removed, - )); - - self::assertTrue($result->isSuccess()); - self::assertSame([ - [ - 'action' => 'delete_package_settings', - 'count' => 1, - ], - ], $result->value()['actions']); - self::assertSame('fallback', $settings->get('cleanup-module', 'display.mode', 'fallback')); - self::assertSame('comfortable', $settings->get('neighbor-module', 'display.mode', 'fallback')); - - $settings->removePackage('neighbor-module'); - } -} diff --git a/tests/Core/Package/PackageLiveContributionGuardTest.php b/tests/Core/Package/PackageLiveContributionGuardTest.php deleted file mode 100644 index 774ed13b..00000000 --- a/tests/Core/Package/PackageLiveContributionGuardTest.php +++ /dev/null @@ -1,212 +0,0 @@ -package('captcha-pack'); - - PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - PackageLiveEndpointPath::path($package->packageName(), 'seed'), - 'api_live_package_dispatch', - 'getCaptchaSeed', - 'Return a captcha seed.', - 'packages.captcha-pack.live.seed', - )); - - self::addToAssertionCount(1); - } - - public function testItRejectsReservedSystemLiveSlugs(): void - { - $package = $this->package('alerts'); - - $this->expectException(MessageException::class); - - PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - PackageLiveEndpointPath::path($package->packageName(), 'demo'), - 'api_live_package_dispatch', - 'getAlertDemo', - 'Return alert demo payload.', - 'packages.alerts.live.demo', - )); - } - - public function testItRejectsPathsOutsideOwnedLiveNamespace(): void - { - $package = $this->package('captcha-pack'); - - $this->expectException(MessageException::class); - - PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - '/api/live/other-pack/seed', - 'api_live_package_dispatch', - 'getCaptchaSeed', - 'Return a captcha seed.', - 'packages.captcha-pack.live.seed', - )); - } - - public function testItRejectsPackageLiveRootPaths(): void - { - $package = $this->package('captcha-pack'); - - $this->expectException(MessageException::class); - - PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - '/api/live/captcha-pack/', - 'api_live_package_dispatch', - 'getCaptchaRoot', - 'Return a captcha root payload.', - 'packages.captcha-pack.live.root', - )); - } - - public function testPackageLivePathHelperRejectsEmptyResourcePaths(): void - { - $this->expectException(MessageException::class); - - PackageLiveEndpointPath::path('captcha-pack', ''); - } - - public function testItRejectsLivePatternsThatEscapeOwnedNamespace(): void - { - $package = $this->package('captcha-pack'); - - $this->expectException(MessageException::class); - - PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - PackageLiveEndpointPath::path($package->packageName(), 'seed'), - 'api_live_package_dispatch', - 'getCaptchaSeed', - 'Return a captcha seed.', - 'packages.captcha-pack.live.seed', - pathPattern: '#^/api/live/captcha-pack/.*|^/api/live/other-pack/#', - )); - } - - public function testItAllowsGroupedLivePatternAlternationInsideOwnedNamespace(): void - { - $package = $this->package('captcha-pack'); - - PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - PackageLiveEndpointPath::path($package->packageName(), 'seed'), - 'api_live_package_dispatch', - 'getCaptchaSeed', - 'Return a captcha seed.', - 'packages.captcha-pack.live.seed', - pathPattern: '#^/api/live/captcha-pack/(seed|refresh)$#', - )); - - self::addToAssertionCount(1); - } - - public function testItRejectsForeignHandlerNamespaces(): void - { - $package = $this->package('captcha-pack'); - - $this->expectException(MessageException::class); - - PackageLiveContributionGuard::assertEndpoint($package, new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - PackageLiveEndpointPath::path($package->packageName(), 'seed'), - 'api_live_package_dispatch', - 'getCaptchaSeed', - 'Return a captcha seed.', - 'packages.captcha-pack.seed', - )); - } - - public function testItRejectsMutatingLiveEndpointMethods(): void - { - $this->expectException(MessageException::class); - $this->expectExceptionMessage(ApiMessageKey::API_ENDPOINT_METHOD_INVALID); - - new LiveEndpointDefinition( - 'package', - Request::METHOD_POST, - PackageLiveEndpointPath::path('captcha-pack', 'seed'), - 'api_live_package_dispatch', - 'refreshCaptchaSeed', - 'Refresh a captcha seed.', - 'packages.captcha-pack.live.seed', - minimumAccessLevel: AccessLevel::PUBLIC, - ); - } - - public function testRuntimeRegistryExposesLiveEndpointAndHandlerContributions(): void - { - $package = $this->package('captcha-pack'); - $definition = new LiveEndpointDefinition( - 'package', - Request::METHOD_GET, - PackageLiveEndpointPath::path($package->packageName(), 'seed'), - 'api_live_package_dispatch', - 'getCaptchaSeed', - 'Return a captcha seed.', - 'packages.captcha-pack.live.seed', - ); - $handler = new class implements LiveEndpointHandlerInterface { - public function liveEndpointHandlerKey(): string - { - return 'packages.captcha-pack.live.seed'; - } - - public function handleLiveRequest(Request $request, LiveEndpointDefinition $endpoint): Response - { - return new JsonResponse(['next_poll_ms' => 0]); - } - }; - - $registry = new PackageRuntimeContributionRegistry(); - $registry->add($package, [$definition, $handler]); - - self::assertSame([$definition], $registry->liveEndpoints()); - self::assertSame([$handler], $registry->liveEndpointHandlers()); - } - - private function package(string $name): ExtensionPackage - { - return new ExtensionPackage( - Uuid::v7()->toRfc4122(), - [PackageScope::Module], - $name, - 'packages/'.$name, - ExtensionPackageStatus::Active, - ); - } -} diff --git a/tests/Core/Package/PackageOperationPlannerTest.php b/tests/Core/Package/PackageOperationPlannerTest.php deleted file mode 100644 index 9512860a..00000000 --- a/tests/Core/Package/PackageOperationPlannerTest.php +++ /dev/null @@ -1,140 +0,0 @@ -packageDir = $this->createTemporaryDirectory('system-package-planner-source'); - $this->targetDir = $this->createTemporaryDirectory('system-package-planner-target'); - $this->writePackageFile('.manifest', 'PACKAGE_NAME=Demo'); - } - - protected function tearDown(): void - { - $this->removeDirectory($this->packageDir); - $this->removeDirectory($this->targetDir); - } - - public function testItCreatesDeterministicCopyQueue(): void - { - $this->writePackageFile('templates/base.html.twig', '
'); - $this->writePackageFile('assets/app.css', 'body {}'); - - $result = (new PackageOperationPlanner(new NullWorkflowResultMessageReporter()))->copyFiles( - $this->candidate(), - $this->targetDir, - ['templates/base.html.twig', 'assets/app.css', 'assets/app.css'], - 'import package files', - 'packages/system', - ); - - self::assertTrue($result->isSuccess()); - self::assertSame([ - 'assets/app.css', - 'templates/base.html.twig', - ], $result->value()->context()['files']); - self::assertSame('packages/system', $result->value()->context()['target_prefix']); - self::assertCount(2, $result->value()); - - $plan = (new OperationExecutor(new NullWorkflowResultMessageReporter()))->planQueue($result->value()); - - self::assertSame('import package files', $plan->name()); - self::assertSame(['copy_file' => 2], $plan->actionCounts()); - self::assertSame([ - 'assets/app.css', - 'packages/system/assets/app.css', - ], $plan->actions()[0]->paths()); - } - - public function testPlannedQueueCanCopyPackageFilesToTargetRoot(): void - { - $this->writePackageFile('assets/app.css', 'body { color: red; }'); - - $queue = (new PackageOperationPlanner(new NullWorkflowResultMessageReporter()))->copyFiles( - $this->candidate(), - $this->targetDir, - ['assets/app.css'], - targetPrefix: 'packages/system', - )->value(); - - $execution = (new OperationExecutor(new NullWorkflowResultMessageReporter()))->executeQueue($queue); - - self::assertTrue($execution->result()->isSuccess()); - self::assertSame('body { color: red; }', file_get_contents($this->targetDir.'/packages/system/assets/app.css')); - } - - public function testItReportsMissingSourceFilesBeforeCreatingQueue(): void - { - $result = (new PackageOperationPlanner(new NullWorkflowResultMessageReporter()))->copyFiles( - $this->candidate(), - $this->targetDir, - ['missing.txt'], - ); - - self::assertSame(WorkflowStatus::Invalid, $result->status()); - self::assertSame('package.copy_source_missing', $result->firstIssue()?->code()); - self::assertSame(['missing.txt'], $result->context()['files']); - } - - public function testItReportsSymbolicSourceFilesBeforeCreatingQueue(): void - { - $this->writePackageFile('real.txt', 'real'); - $this->createSymlinkOrSkip($this->packageDir.'/real.txt', $this->packageDir.'/linked.txt'); - - $result = (new PackageOperationPlanner(new NullWorkflowResultMessageReporter()))->copyFiles( - $this->candidate(), - $this->targetDir, - ['linked.txt'], - ); - - self::assertSame(WorkflowStatus::Invalid, $result->status()); - self::assertSame('package.copy_source_symlink', $result->firstIssue()?->code()); - } - - public function testItRejectsUnsafePackageFilePaths(): void - { - $this->expectException(InvalidArgumentException::class); - - (new PackageOperationPlanner(new NullWorkflowResultMessageReporter()))->copyFiles( - $this->candidate(), - $this->targetDir, - ['../outside.txt'], - ); - } - - private function candidate(): PackageCandidate - { - return new PackageCandidate( - PackageSource::children('import', 'imports'), - $this->packageDir, - $this->packageDir.'/.manifest', - new Manifest(['PACKAGE_NAME' => 'Demo']), - ); - } - - private function writePackageFile(string $relativePath, string $contents): void - { - $this->writeTestFile($this->packageDir, $relativePath, $contents); - } -} diff --git a/tests/Core/Package/PackageRegistryHandlerTest.php b/tests/Core/Package/PackageRegistryHandlerTest.php deleted file mode 100644 index 8c684a37..00000000 --- a/tests/Core/Package/PackageRegistryHandlerTest.php +++ /dev/null @@ -1,409 +0,0 @@ -projectDir = $this->createTemporaryDirectory('system-package-registry'); - $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); - $this->connection = $this->entityManager->getConnection(); - $this->connection->beginTransaction(); - } - - protected function tearDown(): void - { - if ($this->connection->isTransactionActive()) { - $this->connection->rollBack(); - } - - $this->removeDirectory($this->projectDir); - - parent::tearDown(); - } - - public function testItRegistersNewPackagesAsInactive(): void - { - $this->writePackageManifest('demo-module', '1.0.0'); - - $result = $this->handler()->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess()); - self::assertSame([[ - 'package' => 'demo-module', - 'action' => 'registered', - 'status' => 'inactive', - ]], $result->value()); - - $row = $this->packageRow('demo-module'); - self::assertSame('inactive', $row['status']); - self::assertSame('packages/demo-module', $row['path']); - self::assertSame('1.0.0', $row['manifest_version']); - self::assertSame('1.0.0', $row['installed_version']); - self::assertSame(['module'], json_decode((string) $row['package_scopes'], true, flags: JSON_THROW_ON_ERROR)); - self::assertSame('Demo Module', $this->metadata($row)['display_name']); - self::assertSame('Registry handler demo package.', $this->metadata($row)['description']); - self::assertSame('MIT', $this->metadata($row)['license']); - self::assertSame('assets/preview.svg', $this->metadata($row)['image']); - } - - public function testItMarksMissingFilesystemPackagesAsRemoved(): void - { - $this->insertPackage('missing-module', 'packages/missing-module', '1.0.0', 'active'); - $messageBus = new RecordingMessageBus(); - - $result = $this->handler($messageBus)->synchronize([]); - - self::assertTrue($result->isSuccess()); - self::assertSame([[ - 'package' => 'missing-module', - 'action' => 'removed', - 'status' => 'removed', - ]], $result->value()); - - $row = $this->packageRow('missing-module'); - self::assertSame('removed', $row['status']); - self::assertSame('packages/missing-module', $this->metadata($row)['removed_path']); - self::assertCount(1, $messageBus->messages()); - self::assertInstanceOf(PackageAssetRebuildMessage::class, $messageBus->messages()[0]); - self::assertSame('package_registry_state_exit', $messageBus->messages()[0]->trigger()); - } - - public function testItDeactivatesActiveDependentsWhenPackageIsMarkedRemoved(): void - { - $this->insertPackage('missing-module', 'packages/missing-module', '1.0.0', 'active'); - $this->insertPackage( - 'dependent-module', - 'packages/dependent-module', - '1.0.0', - 'active', - dependencies: '[["missing-module", "1.0.0"]]', - ); - $this->writePackageManifest('dependent-module', '1.0.0', '[["missing-module", "1.0.0"]]'); - $messageBus = new RecordingMessageBus(); - - $result = $this->handler($messageBus)->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); - self::assertSame('removed', $this->packageRow('missing-module')['status']); - self::assertSame('inactive', $this->packageRow('dependent-module')['status']); - self::assertContains([ - 'package' => 'dependent-module', - 'action' => 'deactivated', - 'status' => 'inactive', - ], $result->value()); - self::assertCount(1, $messageBus->messages()); - } - - public function testItUpdatesVersionMismatches(): void - { - $this->insertPackage('demo-module', 'packages/demo-module', '1.0.0', 'inactive', installedVersion: '1.0.0'); - $this->writePackageManifest('demo-module', '1.1.0'); - - $result = $this->handler()->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess()); - self::assertSame([[ - 'package' => 'demo-module', - 'action' => 'updated', - 'status' => 'inactive', - ]], $result->value()); - - $row = $this->packageRow('demo-module'); - self::assertSame('1.1.0', $row['manifest_version']); - self::assertSame('1.1.0', $row['installed_version']); - self::assertSame('1.1.0', $this->metadata($row)['manifest']['PACKAGE_VERSION']); - } - - public function testItQueuesAssetRebuildWhenActivePackageUpdates(): void - { - $this->insertPackage('demo-module', 'packages/demo-module', '1.0.0', 'active', installedVersion: '1.0.0'); - $this->writePackageManifest('demo-module', '1.1.0'); - $messageBus = new RecordingMessageBus(); - - $result = $this->handler($messageBus)->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess()); - self::assertSame([[ - 'package' => 'demo-module', - 'action' => 'updated', - 'status' => 'active', - ]], $result->value()); - self::assertSame('active', $this->packageRow('demo-module')['status']); - self::assertCount(1, $messageBus->messages()); - self::assertInstanceOf(PackageAssetRebuildMessage::class, $messageBus->messages()[0]); - self::assertSame('package_registry_state_exit', $messageBus->messages()[0]->trigger()); - } - - public function testItFallsBackToSynchronousAssetRebuildWhenDeferredDispatchFails(): void - { - $this->insertPackage('demo-module', 'packages/demo-module', '1.0.0', 'active', installedVersion: '1.0.0'); - $this->writePackageManifest('demo-module', '1.1.0'); - $assetRebuilder = new RegistryHandlerPackageLifecycleAssetRebuilder(); - - $result = $this->handler(new FailingRegistryHandlerMessageBus(), $assetRebuilder)->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); - self::assertSame(['test'], $assetRebuilder->environments); - self::assertTrue($result->context()['asset_rebuild']['value']['fallback_completed']); - self::assertFalse($result->context()['asset_rebuild']['context']['stale_risk']); - self::assertContains('message.package.asset_rebuild_queue_failed', array_map( - static fn ($message): string => $message->translationKey(), - $result->messages(), - )); - } - - public function testItFailsWhenDeferredAndFallbackAssetRebuildsFail(): void - { - $this->insertPackage('demo-module', 'packages/demo-module', '1.0.0', 'active', installedVersion: '1.0.0'); - $this->writePackageManifest('demo-module', '1.1.0'); - $assetRebuilder = new RegistryHandlerPackageLifecycleAssetRebuilder(WorkflowResult::failed([ - Message::error( - PackageMessageCode::PACKAGE_ASSET_SYNC_FAILED, - PackageMessageKey::PACKAGE_ASSET_SYNC_FAILED, - ['%message%' => 'fallback failed'], - ), - ])); - - $result = $this->handler(new FailingRegistryHandlerMessageBus(), $assetRebuilder)->synchronize($this->candidates()); - - self::assertFalse($result->isSuccess()); - self::assertSame(['test'], $assetRebuilder->environments); - self::assertFalse($result->context()['asset_rebuild']['value']['fallback_completed']); - self::assertTrue($result->context()['asset_rebuild']['context']['stale_risk']); - self::assertSame('package.asset_sync_failed', $result->firstIssue()?->code()); - } - - public function testItMarksValidationFailuresAsFaulty(): void - { - $this->writePackageManifest('broken-module', '1.0.0'); - $this->writeTestFile($this->projectDir, 'packages/broken-module/src/Broken.php', 'handler()->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess()); - self::assertSame([[ - 'package' => 'broken-module', - 'action' => 'faulty', - 'status' => 'faulty', - ]], $result->value()); - - $row = $this->packageRow('broken-module'); - $metadata = $this->metadata($row); - self::assertSame('faulty', $row['status']); - self::assertSame('faulty', $metadata['registry_state']); - self::assertSame(1, $metadata['validation']['issue_count']); - self::assertSame('package.php_syntax_error', $metadata['validation']['issues'][0]['code']); - } - - public function testItQueuesAssetRebuildWhenActivePackageBecomesFaulty(): void - { - $this->insertPackage('broken-module', 'packages/broken-module', '1.0.0', 'active'); - $this->writePackageManifest('broken-module', '1.0.0'); - $this->writeTestFile($this->projectDir, 'packages/broken-module/src/Broken.php', 'handler($messageBus)->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess()); - self::assertSame('faulty', $this->packageRow('broken-module')['status']); - self::assertCount(1, $messageBus->messages()); - self::assertInstanceOf(PackageAssetRebuildMessage::class, $messageBus->messages()[0]); - self::assertSame('test', $messageBus->messages()[0]->environment()); - self::assertSame('package_registry_state_exit', $messageBus->messages()[0]->trigger()); - } - - public function testItKeepsFaultyPackagesFaultyWithoutVersionChange(): void - { - $this->insertPackage('broken-module', 'packages/broken-module', '1.0.0', 'faulty'); - $this->writePackageManifest('broken-module', '1.0.0'); - $messageBus = new RecordingMessageBus(); - - $result = $this->handler($messageBus)->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess()); - self::assertSame([], $result->value()); - self::assertSame('faulty', $this->packageRow('broken-module')['status']); - self::assertSame([], $messageBus->messages()); - } - - public function testItRevalidatesFaultyPackagesAfterVersionChange(): void - { - $this->insertPackage('broken-module', 'packages/broken-module', '1.0.0', 'faulty'); - $this->writePackageManifest('broken-module', '1.1.0'); - - $result = $this->handler()->synchronize($this->candidates()); - - self::assertTrue($result->isSuccess()); - self::assertSame([[ - 'package' => 'broken-module', - 'action' => 'updated', - 'status' => 'inactive', - ]], $result->value()); - self::assertSame('inactive', $this->packageRow('broken-module')['status']); - } - - private function handler(?MessageBusInterface $messageBus = null, ?PackageLifecycleAssetRebuilderInterface $assetRebuilder = null): PackageRegistryHandler - { - return new PackageRegistryHandler( - $this->entityManager, - $this->projectDir, - assetRebuildDispatcher: null === $messageBus ? null : new PackageAssetRebuildDispatcher($messageBus, new NullWorkflowResultMessageReporter()), - assetRebuildFallback: $assetRebuilder, - environment: 'test', - ); - } - - /** - * @return list - */ - private function candidates(): array - { - $result = (new PackageDiscovery())->discoverSources($this->projectDir, [ - PackageSource::children('package', 'packages'), - ]); - - self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); - - return $result->value(); - } - - private function writePackageManifest(string $slug, string $version, string $dependencies = '[]'): void - { - $this->writeTestFile($this->projectDir, 'packages/'.$slug.'/.manifest', <<connection->insert('extension_package', [ - 'uid' => $this->uuid(), - 'package_scopes' => json_encode(['module'], JSON_THROW_ON_ERROR), - 'package_name' => $packageName, - 'path' => $path, - 'manifest_version' => $version, - 'installed_version' => $installedVersion, - 'status' => $status, - 'metadata' => json_encode([ - 'registry_state' => 'available', - 'manifest' => [ - 'PACKAGE_DEPENDENCIES' => $dependencies, - ], - ], JSON_THROW_ON_ERROR), - 'modified_at' => '2026-05-25 00:00:00', - ]); - } - - /** - * @return array - */ - private function packageRow(string $packageName): array - { - $row = $this->connection->fetchAssociative( - 'SELECT * FROM extension_package WHERE package_name = :package_name', - ['package_name' => $packageName], - ); - - self::assertIsArray($row); - - return $row; - } - - /** - * @param array $row - * - * @return array - */ - private function metadata(array $row): array - { - $metadata = json_decode((string) $row['metadata'], true, flags: JSON_THROW_ON_ERROR); - self::assertIsArray($metadata); - - return $metadata; - } - - private function uuid(): string - { - return Uuid::v7()->toRfc4122(); - } -} - -final class FailingRegistryHandlerMessageBus implements MessageBusInterface -{ - public function dispatch(object $message, array $stamps = []): Envelope - { - throw new RuntimeException('queue unavailable'); - } -} - -final class RegistryHandlerPackageLifecycleAssetRebuilder implements PackageLifecycleAssetRebuilderInterface -{ - /** - * @var list - */ - public array $environments = []; - - /** - * @param WorkflowResult|null $result - */ - public function __construct(private ?WorkflowResult $result = null) - { - } - - public function rebuild(string $environment): WorkflowResult - { - $this->environments[] = $environment; - - return $this->result ?? WorkflowResult::success(context: ['fallback' => true]); - } -} diff --git a/tests/Core/Package/PackageScopeTest.php b/tests/Core/Package/PackageScopeTest.php deleted file mode 100644 index 6fb23483..00000000 --- a/tests/Core/Package/PackageScopeTest.php +++ /dev/null @@ -1,36 +0,0 @@ -expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid package scope "unknown".'); - - PackageScope::fromManifestValue('[module, unknown]'); - } -} diff --git a/tests/Core/Package/PackageSettingRegistryTest.php b/tests/Core/Package/PackageSettingRegistryTest.php deleted file mode 100644 index 8359a3fd..00000000 --- a/tests/Core/Package/PackageSettingRegistryTest.php +++ /dev/null @@ -1,120 +0,0 @@ - true], - sortOrder: 20, - ), - new PackageSettingDefinition('inactive-module', 'display.mode', 'Display mode', 'compact'), - new PackageSettingDefinition('active-module', 'feature.enabled', 'Feature enabled', true, ConfigValueType::Boolean, sortOrder: 10), - ])], - new StaticActivePackageProvider([ - new ExtensionPackage( - '10000000-0000-7000-8000-000000000601', - [PackageScope::Module], - 'active-module', - 'packages/active-module', - ExtensionPackageStatus::Active, - [ - 'display_name' => 'Active Module', - 'description' => 'Adds configurable active module behavior.', - ], - ), - ]), - ); - - $definitions = $registry->definitions(); - - self::assertSame(['feature.enabled', 'display.mode'], array_map( - static fn (PackageSettingDefinition $definition): string => $definition->key(), - $definitions, - )); - self::assertSame(FormInputType::Checkbox, $definitions[0]->inputType()); - self::assertSame(FormInputType::Select, $definitions[1]->inputType()); - self::assertSame(['required' => true], $definitions[1]->validation()); - self::assertSame('select', $definitions[1]->toArray()['input_type']); - self::assertSame(['compact', 'comfortable'], $definitions[1]->toArray()['options']); - self::assertSame(['compact' => 'compact', 'comfortable' => 'comfortable'], $definitions[1]->formField()->options()); - self::assertSame([ - 'active-module' => [ - 'label' => 'Active Module', - 'description' => 'Adds configurable active module behavior.', - 'path' => '/admin/settings/packages/active-module', - ], - ], $registry->packagesWithDefinitions()); - } -} - -final readonly class StaticPackageSettingProvider implements PackageSettingProviderInterface -{ - /** - * @param list $definitions - */ - public function __construct(private array $definitions) - { - } - - public function packageSettings(): array - { - return $this->definitions; - } -} - -final readonly class StaticActivePackageProvider implements ActivePackageProviderInterface -{ - /** - * @param list $packages - */ - public function __construct(private array $packages) - { - } - - public function packages(?PackageScope $scope = null): array - { - if (null === $scope) { - return $this->packages; - } - - return array_values(array_filter( - $this->packages, - static fn (ExtensionPackage $package): bool => $package->hasScope($scope), - )); - } - - public function package(string $packageName): ?ExtensionPackage - { - foreach ($this->packages as $package) { - if ($package->packageName() === $packageName) { - return $package; - } - } - - return null; - } -} diff --git a/tests/Core/Package/PackageSettingsFormHandlerTest.php b/tests/Core/Package/PackageSettingsFormHandlerTest.php deleted file mode 100644 index 356b0e55..00000000 --- a/tests/Core/Package/PackageSettingsFormHandlerTest.php +++ /dev/null @@ -1,114 +0,0 @@ -get(Connection::class)->delete('package_setting_entry', ['package_name' => 'form-module']); - } - - parent::tearDown(); - } - - public function testItPersistsTypedPackageSettingsFromSubmittedFormValues(): void - { - self::bootKernel(); - $settings = self::getContainer()->get(PackageSettings::class); - $handler = new PackageSettingsFormHandler( - new PackageSettingRegistry( - [new FormHandlerPackageSettingProvider([ - new PackageSettingDefinition('form-module', 'display.mode', 'Display mode', 'compact', ConfigValueType::String, options: ['compact', 'comfortable']), - new PackageSettingDefinition('form-module', 'feature.enabled', 'Feature enabled', false, ConfigValueType::Boolean, inputType: FormInputType::Checkbox), - ])], - new FormHandlerActivePackageProvider([ - new ExtensionPackage( - '10000000-0000-7000-8000-000000000777', - [PackageScope::Module], - 'form-module', - 'packages/form-module', - ExtensionPackageStatus::Active, - ), - ]), - ), - $settings, - new FormSubmissionHandler(), - ); - - $result = $handler->submit('form-module', [ - 'display.mode' => 'comfortable', - 'feature.enabled' => '1', - ], 'test'); - - self::assertTrue($result->isValid()); - self::assertSame('comfortable', $settings->get('form-module', 'display.mode')); - self::assertTrue($settings->get('form-module', 'feature.enabled')); - } -} - -final readonly class FormHandlerPackageSettingProvider implements PackageSettingProviderInterface -{ - /** - * @param list $definitions - */ - public function __construct(private array $definitions) - { - } - - public function packageSettings(): array - { - return $this->definitions; - } -} - -final readonly class FormHandlerActivePackageProvider implements ActivePackageProviderInterface -{ - /** - * @param list $packages - */ - public function __construct(private array $packages) - { - } - - public function packages(?PackageScope $scope = null): array - { - if (null === $scope) { - return $this->packages; - } - - return array_values(array_filter( - $this->packages, - static fn (ExtensionPackage $package): bool => $package->hasScope($scope), - )); - } - - public function package(string $packageName): ?ExtensionPackage - { - foreach ($this->packages as $package) { - if ($package->packageName() === $packageName) { - return $package; - } - } - - return null; - } -} diff --git a/tests/Core/Package/PackageSettingsTest.php b/tests/Core/Package/PackageSettingsTest.php deleted file mode 100644 index 8ab95187..00000000 --- a/tests/Core/Package/PackageSettingsTest.php +++ /dev/null @@ -1,45 +0,0 @@ -get(Connection::class)->delete('package_setting_entry', ['package_name' => self::PACKAGE]); - } - - parent::tearDown(); - } - - public function testItStoresReadsAndDeletesPackageScopedSettings(): void - { - self::bootKernel(); - $settings = self::getContainer()->get(PackageSettings::class); - - self::assertSame('blue', $settings->get(self::PACKAGE, 'theme.variant', 'blue')); - self::assertTrue($settings->set(self::PACKAGE, 'theme.variant', 'green', ConfigValueType::String)); - self::assertSame('green', $settings->get(self::PACKAGE, 'theme.variant', 'blue')); - self::assertSame(1, $settings->removePackage(self::PACKAGE)); - self::assertSame('blue', $settings->get(self::PACKAGE, 'theme.variant', 'blue')); - } - - public function testItReturnsDefaultsForInvalidKeys(): void - { - self::bootKernel(); - $settings = self::getContainer()->get(PackageSettings::class); - - self::assertFalse($settings->set(self::PACKAGE, 'Invalid Key', true)); - self::assertSame('fallback', $settings->get(self::PACKAGE, 'Invalid Key', 'fallback')); - } -} diff --git a/tests/Core/Package/PackageValidatorTest.php b/tests/Core/Package/PackageValidatorTest.php deleted file mode 100644 index c1ddfcde..00000000 --- a/tests/Core/Package/PackageValidatorTest.php +++ /dev/null @@ -1,1217 +0,0 @@ -rootDir = $this->createTemporaryDirectory('system-package-validator'); - $this->packageDir = $this->rootDir.'/system'; - mkdir($this->packageDir, 0777, true); - $this->writeFile('.manifest', 'PACKAGE_NAME=System'); - } - - protected function tearDown(): void - { - $this->removeDirectory($this->rootDir); - } - - public function testItAcceptsPackagesWithRequiredFilesAndDirectories(): void - { - $this->writeFile('templates/base.html.twig', 'layout'); - $this->writeFile('assets/app.css', 'body {}'); - - $candidate = $this->candidate(); - $spec = PackageSpec::create() - ->requireFile('.manifest') - ->requireFile('templates/base.html.twig') - ->requireDirectory('assets'); - - $result = (new PackageValidator())->validate($candidate, $spec); - - self::assertTrue($result->isSuccess()); - self::assertSame($candidate, $result->value()); - self::assertContains('.manifest', $result->context()['inventory']); - self::assertContains('templates/base.html.twig', $result->context()['inventory']); - self::assertContains('assets/', $result->context()['inventory']); - } - - public function testItExposesPackageInspectionFeatures(): void - { - $this->writeFile('templates/base.html.twig', '
'); - $this->writeFile('assets/app.css', 'body {}'); - $this->writeFile('assets/app.js', 'export default true;'); - $this->writeFile('assets/images/logo.svg', ''); - $this->writeFile('assets/fonts/demo.woff2', 'font'); - $this->writeFile('data/package.yaml', 'enabled: true'); - $this->writeFile('data/package.json', '{"enabled": true}'); - $this->writeFile('src/PackageExtension.php', 'writeFile('tools/helper.php', 'validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - self::assertInstanceOf(PackageInspection::class, $result->context()['inspection']); - - /** @var PackageInspection $inspection */ - $inspection = $result->context()['inspection']; - - self::assertTrue($inspection->hasTemplates()); - self::assertTrue($inspection->hasAssets()); - self::assertTrue($inspection->hasPhpFiles()); - self::assertTrue($inspection->hasSourcePhpFiles()); - self::assertTrue($inspection->hasTwigFiles()); - self::assertTrue($inspection->hasJsonFiles()); - self::assertTrue($inspection->hasYamlFiles()); - self::assertTrue($inspection->hasCssFiles()); - self::assertTrue($inspection->hasJavaScriptFiles()); - self::assertTrue($inspection->hasStaticAssetFiles()); - self::assertSame(['templates/base.html.twig'], $inspection->templateFiles()); - self::assertSame(['assets/app.css', 'assets/app.js', 'assets/fonts/demo.woff2', 'assets/images/logo.svg'], $inspection->assetFiles()); - self::assertSame(['src/PackageExtension.php'], $inspection->sourcePhpFiles()); - self::assertSame(['src/PackageExtension.php', 'tools/helper.php'], $inspection->phpFiles()); - self::assertSame(['data/package.json'], $inspection->jsonFiles()); - self::assertSame(['data/package.yaml'], $inspection->yamlFiles()); - self::assertSame(['assets/app.css'], $inspection->cssFiles()); - self::assertSame(['assets/app.js'], $inspection->javaScriptFiles()); - self::assertSame(['assets/fonts/demo.woff2', 'assets/images/logo.svg'], $inspection->staticAssetFiles()); - } - - public function testItReportsMissingRequiredFilesAndDirectories(): void - { - $candidate = $this->candidate(); - $spec = PackageSpec::create() - ->requireFile('templates/base.html.twig') - ->requireDirectory('assets'); - - $result = (new PackageValidator())->validate($candidate, $spec); - - self::assertFalse($result->isSuccess()); - self::assertCount(2, $result->issues()); - self::assertSame('package.required_file_missing', $result->issues()[0]->code()); - self::assertSame('templates/base.html.twig', $result->issues()[0]->context()['requirement']); - self::assertSame('package.required_directory_missing', $result->issues()[1]->code()); - self::assertSame('assets', $result->issues()[1]->context()['requirement']); - self::assertContains('.manifest', $result->context()['inventory']); - } - - public function testItRequiresPackageSlugForPackageCandidates(): void - { - $candidate = new PackageCandidate( - PackageSource::children('package', 'packages'), - $this->packageDir, - $this->packageDir.'/.manifest', - new Manifest(['PACKAGE_NAME' => 'System']), - ); - - $result = (new PackageValidator())->validate($candidate, PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('manifest.missing_required_key', $result->firstIssue()?->code()); - self::assertSame('PACKAGE_SLUG', $result->firstIssue()?->context()['key']); - } - - public function testItRejectsInvalidPackageSlugForPackageCandidates(): void - { - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => '../system']), - PackageSpec::create(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.identifier.invalid', $result->firstIssue()?->code()); - self::assertSame('PACKAGE_SLUG', $result->firstIssue()?->context()['key']); - } - - public function testItRejectsSlashSeparatedPackageSlugs(): void - { - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'vendor/package']), - PackageSpec::create(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.identifier.invalid', $result->firstIssue()?->code()); - self::assertSame('PACKAGE_SLUG', $result->firstIssue()?->context()['key']); - } - - public function testItRejectsPackageSlugThatDoesNotMatchDirectoryName(): void - { - $result = (new PackageValidator())->validate( - new PackageCandidate( - PackageSource::children('package', 'packages'), - $this->packageDir, - $this->packageDir.'/.manifest', - new Manifest(['PACKAGE_SLUG' => 'other-system', 'PACKAGE_NAME' => 'System']), - ), - PackageSpec::create(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.identifier.invalid', $result->firstIssue()?->code()); - self::assertSame('PACKAGE_SLUG', $result->firstIssue()?->context()['key']); - self::assertSame('system', $result->firstIssue()?->context()['expected_slug']); - } - - public function testItRejectsMalformedPackageDependencies(): void - { - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_DEPENDENCIES' => '["demo-base >=1.0"]']), - PackageSpec::create(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.dependency.invalid', $result->firstIssue()?->code()); - self::assertSame('PACKAGE_DEPENDENCIES', $result->firstIssue()?->context()['key']); - } - - public function testItRejectsInvalidSchedulerTaskCronExpressions(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scheduler.cron_invalid', $result->firstIssue()?->code()); - self::assertSame('src/SchedulerTasks.php', $result->firstIssue()?->context()['file']); - self::assertSame('not a cron', $result->firstIssue()?->context()['value']); - } - - public function testItRejectsInvalidSchedulerTaskCronExpressionsThroughImportAliases(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scheduler.cron_invalid', $result->firstIssue()?->code()); - self::assertSame('not a cron', $result->firstIssue()?->context()['value']); - } - - public function testItRejectsInvalidSchedulerTaskCronExpressionsThroughGroupedImportAliases(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scheduler.cron_invalid', $result->firstIssue()?->code()); - self::assertSame('not a cron', $result->firstIssue()?->context()['value']); - } - - public function testItIgnoresSchedulerExamplesInsideComments(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItIgnoresSchedulerExamplesInsideStrings(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItReadsNamedSchedulerCronArgumentWithoutFalsePositives(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItReadsNamedSchedulerCronArgumentAtTopLevelOnly(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItIgnoresSchedulerCommentsInsideCallArguments(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItIgnoresUnrelatedSchedulerTaskDefinitionClasses(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItReadsFullyQualifiedSchedulerTaskDefinitions(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scheduler.cron_invalid', $result->firstIssue()?->code()); - } - - public function testItReadsSchedulerDefinitionsThroughNamespaceAliases(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scheduler.cron_invalid', $result->firstIssue()?->code()); - } - - public function testItReadsSchedulerDefinitionsThroughRootNamespaceAliases(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scheduler.cron_invalid', $result->firstIssue()?->code()); - } - - public function testItAcceptsPackageSchedulerProviderWithLiteralCronExpression(): void - { - $this->writeFile('src/DemoSchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItAcceptsPackageSchedulerProviderWithShortPackageSource(): void - { - $this->writeFile('src/DemoSchedulerTasks.php', <<<'PHP' -validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'ai']), - PackageSpec::create(), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItReadsSchedulerCronByArgumentPosition(): void - { - $this->writeFile('src/DemoSchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertTrue($result->isSuccess()); - } - - public function testItRejectsSchedulerTaskRegistrationsWithoutLiteralDefaultCron(): void - { - $this->writeFile('src/SchedulerTasks.php', <<<'PHP' -validate($this->candidate(), PackageSpec::create()); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.scheduler.cron_invalid', $result->firstIssue()?->code()); - self::assertSame('', $result->firstIssue()?->context()['value']); - } - - public function testItLimitsInventoryDepth(): void - { - $this->writeFile('one/two/three/file.txt', 'nested'); - - $candidate = $this->candidate(); - $spec = PackageSpec::create()->withInventoryDepth(1); - - $result = (new PackageValidator())->validate($candidate, $spec); - - self::assertTrue($result->isSuccess()); - self::assertContains('one/', $result->context()['inventory']); - self::assertContains('one/two/', $result->context()['inventory']); - self::assertNotContains('one/two/three/', $result->context()['inventory']); - self::assertNotContains('one/two/three/file.txt', $result->context()['inventory']); - } - - public function testItRejectsUnsafeRequirementPaths(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Package requirement path "../outside.txt" must be relative and stay inside the package.'); - - PackageSpec::create()->requireFile('../outside.txt'); - } - - public function testItCanLintPhpFiles(): void - { - $this->writeFile('src/Valid.php', 'writeFile('src/Broken.php', 'validate( - $this->candidate(), - PackageSpec::create()->withPhpLinting(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.php_syntax_error', $result->firstIssue()?->code()); - self::assertSame('src/Broken.php', $result->firstIssue()?->context()['file']); - } - - public function testItAcceptsPackageSourceFilesWithinDeclaredNamespace(): void - { - $this->writeFile('src/Root.php', 'writeFile('src/Nested.php', 'validate( - $this->candidateWithManifest(['PACKAGE_NAMESPACE' => 'Demo\\Package']), - PackageSpec::create(), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItRejectsPackageSourceFilesOutsideDeclaredNamespace(): void - { - $this->writeFile('src/Foreign.php', 'validate( - $this->candidateWithManifest(['PACKAGE_NAMESPACE' => 'Demo\\Package']), - PackageSpec::create(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.php_namespace_invalid', $result->firstIssue()?->code()); - self::assertSame('Other\\Package', $result->firstIssue()?->context()['namespace']); - self::assertSame('Demo\\Package', $result->firstIssue()?->context()['expected_namespace']); - } - - public function testItCanLintTwigFiles(): void - { - $this->writeFile('templates/valid.html.twig', '
{{ title }}
'); - $this->writeFile('templates/broken.html.twig', '
{% if title %}
'); - - $result = (new PackageValidator())->validate( - $this->candidate(), - PackageSpec::create()->withTwigLinting(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.twig_syntax_error', $result->firstIssue()?->code()); - self::assertSame('templates/broken.html.twig', $result->firstIssue()?->context()['file']); - } - - public function testItAllowsAreaTemplatesForAdditivePackages(): void - { - $this->writeFile('templates/frontend/captcha/field.html.twig', ''); - $this->writeFile('templates/backend/module/settings.html.twig', '
'); - - $result = (new PackageValidator())->validate( - $this->candidateWithScope('[module, captcha-provider]'), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItAllowsProviderTemplatesForMatchingProviderScope(): void - { - $this->writeFile('templates/provider/captcha/field.html.twig', ''); - $this->writeFile('templates/provider/editor/richtext.html.twig', ''); - - $result = (new PackageValidator())->validate( - $this->candidateWithScope('[captcha-provider, editor-provider]'), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItAllowsTemplatesWithinDeclaredOverrideScopes(): void - { - $this->writeFile('templates/frontend/page.html.twig', '
'); - $this->writeFile('templates/backend/dashboard.html.twig', '
'); - $this->writeFile('templates/base.html.twig', '
'); - $this->writeFile('templates/macros/core/ui.html.twig', '{% macro badge(label) %}{{ label }}{% endmacro %}'); - $this->writeFile('templates/macros/system/forms.html.twig', '{% macro field(label) %}{{ label }}{% endmacro %}'); - - $result = (new PackageValidator())->validate( - $this->candidateWithScope('[frontend-theme, backend-theme, system-template, module]'), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItRejectsRootAndMacroTemplatesOutsideDeclaredPackageScopes(): void - { - $this->writeFile('templates/frontend/page.html.twig', '
'); - $this->writeFile('templates/backend/dashboard.html.twig', '
'); - $this->writeFile('templates/base.html.twig', '
'); - $this->writeFile('templates/macros/core/ui.html.twig', '{% macro badge(label) %}{{ label }}{% endmacro %}'); - $this->writeFile('templates/macros/other-package/forms.html.twig', '{% macro field(label) %}{{ label }}{% endmacro %}'); - $this->writeFile('templates/macros/forms.html.twig', '{% macro field(label) %}{{ label }}{% endmacro %}'); - $this->writeFile('templates/provider/captcha/field.html.twig', ''); - - $result = (new PackageValidator())->validate( - $this->candidateWithScope('module'), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame([ - 'templates/base.html.twig', - 'templates/macros/core/ui.html.twig', - 'templates/macros/forms.html.twig', - 'templates/macros/other-package/forms.html.twig', - 'templates/provider/captcha/field.html.twig', - ], array_map(static fn ($issue): string => $issue->context()['file'], $result->issues())); - self::assertSame('package.template_path_invalid', $result->firstIssue()?->code()); - } - - public function testItAllowsPackageOwnedMacroNamespaceWithoutThemeScope(): void - { - $this->writeFile('templates/macros/system/forms.html.twig', '{% macro field(label) %}{{ label }}{% endmacro %}'); - - $result = (new PackageValidator())->validate( - $this->candidateWithScope('module'), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItAllowsPackageOwnedMacroNamespaceUsingManifestSlugWhenDirectoryDiffers(): void - { - $this->writeFile('templates/macros/demo-module/forms.html.twig', '{% macro field(label) %}{{ label }}{% endmacro %}'); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module', 'PACKAGE_SCOPE' => 'module']), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItCanRunAllLintingChecks(): void - { - $this->writeFile('src/Valid.php', 'writeFile('templates/valid.html.twig', '
{{ title }}
'); - $this->writeFile('data/valid.json', '{"enabled": true}'); - $this->writeFile('data/valid.yaml', 'enabled: true'); - $this->writeFile('assets/valid.css', 'body { color: red; }'); - $this->writeFile('assets/valid.js', 'export default true;'); - - $result = (new PackageValidator())->validate( - $this->candidate(), - PackageSpec::create()->withLintingChecks(), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItAcceptsPackageOwnedCssClasses(): void - { - $this->writeFile('assets/module.css', <<<'CSS' -.system-panel .demo-module-card, -.demo-module-card, -.demo-module-card.demo-module-card-active { - color: red; - background-image: url("../images/icon.svg"); -} -CSS); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), - PackageSpec::create(), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItAcceptsTailwindDirectivesInPackageCssSyntaxChecks(): void - { - $this->writeFile('assets/module.css', <<<'CSS' -.demo-module-card { - @apply grid gap-4 rounded-lg border p-4; -} -CSS); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), - PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), - ); - - self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); - } - - public function testItRejectsCssSyntaxErrorsNextToTailwindDirectives(): void - { - $this->writeFile('assets/module.css', <<<'CSS' -.demo-module-card { - @apply grid gap-4 rounded-lg border p-4; - color red; -} -CSS); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), - PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.css_syntax_error', $result->issues()[0]->code()); - } - - public function testItAcceptsModernCssAtRulesInPackageCssSyntaxChecks(): void - { - $this->writeFile('assets/module.css', <<<'CSS' -.demo-module-card { - transform: var(--tw-rotate-x,) var(--tw-rotate-y,); - @custom-variant demo-module-dark (&:where(.demo-module-dark, .demo-module-dark *)); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, currentcolor 50%, transparent); - } - @media (width >= 40rem) { - max-width: 40rem; - } -} -CSS); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), - PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), - ); - - self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); - } - - public function testItRejectsCssSyntaxErrorsInsideModernAtRules(): void - { - $this->writeFile('assets/module.css', <<<'CSS' -.demo-module-card { - @supports (color: color-mix(in lab, red, red)) { - color red; - } -} -CSS); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), - PackageSpec::create()->withInventoryDepth(4)->withCssLinting(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.css_syntax_error', $result->issues()[0]->code()); - } - - public function testItRejectsCssRulesTargetingClassesOutsidePackageNamespace(): void - { - $this->writeFile('assets/module.css', <<<'CSS' -.system-panel, -.other-package-card { - color: red; -} -CSS); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), - PackageSpec::create(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame( - ['system-panel', 'other-package-card'], - array_map(static fn ($issue): string => $issue->context()['class'], $result->issues()), - ); - self::assertSame('package.css_namespace_invalid', $result->firstIssue()?->code()); - self::assertSame('demo-module-', $result->firstIssue()?->context()['expected_prefix']); - } - - public function testItAcceptsPackageTranslationFilesInOwnedNamespace(): void - { - $this->writeFile('languages/en/messages.yaml', "pkg:\n system:\n title: Demo\n"); - - $result = (new PackageValidator())->validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4)->withYamlLinting(), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItAcceptsPackageTranslationFilesUsingManifestSlugWhenDirectoryDiffers(): void - { - $this->writeFile('languages/en/messages.yaml', "pkg:\n demo-module:\n title: Demo\n"); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module']), - PackageSpec::create()->withInventoryDepth(4)->withYamlLinting(), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItRequiresFallbackLocaleWhenPackageTranslationsExist(): void - { - $this->writeFile('languages/de/messages.yaml', "pkg:\n system:\n title: Demo\n"); - - $result = (new PackageValidator())->validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4)->withYamlLinting(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.translation_fallback_missing', $result->firstIssue()?->code()); - self::assertSame('languages/en', $result->firstIssue()?->context()['file']); - } - - public function testItAcceptsPrimaryLanguageForRegionalTranslationFallback(): void - { - $this->writeFile('languages/de/messages.yaml', "pkg:\n system:\n title: Demo\n"); - - $validator = new PackageValidator( - translationNamespaceValidator: new PackageTranslationNamespaceValidator(fallbackLocale: 'de_DE'), - ); - - $result = $validator->validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4)->withYamlLinting(), - ); - - self::assertTrue($result->isSuccess()); - } - - public function testItRejectsPackageTranslationFilesOutsideOwnedNamespace(): void - { - $this->writeFile('languages/en/messages.yaml', "ui:\n app:\n name: Demo\n"); - - $result = (new PackageValidator())->validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4)->withYamlLinting(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.translation_namespace_invalid', $result->firstIssue()?->code()); - self::assertSame('languages/en/messages.yaml', $result->firstIssue()?->context()['file']); - } - - public function testItRejectsPackageCssTargetClassesOutsideTheAssetScope(): void - { - $this->writeFile('assets/frontend/app.css', <<<'CSS' -.demo-module-card, -.system-panel .demo-module-backend-card { - color: red; -} -CSS); - - $result = (new PackageValidator())->validate( - $this->candidateWithManifest(['PACKAGE_SLUG' => 'demo-module', 'PACKAGE_SCOPE' => 'frontend-theme']), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame(['demo-module-card', 'demo-module-backend-card'], array_map( - static fn ($issue): string => $issue->context()['class'], - $result->issues(), - )); - self::assertSame('demo-module-frontend-', $result->firstIssue()?->context()['expected_prefix']); - } - - public function testItRejectsTemplateReferencesOutsideTheTemplateScope(): void - { - $this->writeFile('templates/frontend/page.html.twig', <<<'TWIG' -{% extends '@backend/admin.html.twig' %} -{% include '@root/partials/brand/_brand.html.twig' %} -TWIG); - - $result = (new PackageValidator())->validate( - $this->candidateWithScope('module'), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame('package.template_reference_invalid', $result->firstIssue()?->code()); - self::assertSame('@backend/admin.html.twig', $result->firstIssue()?->context()['reference']); - } - - public function testItReportsStructuredSyntaxErrors(): void - { - $this->writeFile('data/broken.json', '{'); - $this->writeFile('data/broken.yaml', 'enabled: ['); - $this->writeFile('assets/broken.css', 'body { color: ; }'); - $this->writeFile('assets/broken.js', 'const = ;'); - - $result = (new PackageValidator())->validate( - $this->candidate(), - PackageSpec::create()->withLintingChecks(), - ); - - self::assertFalse($result->isSuccess()); - self::assertSame([ - 'package.json_syntax_error', - 'package.yaml_syntax_error', - 'package.css_syntax_error', - 'package.javascript_syntax_error', - ], array_map(static fn ($issue): string => $issue->code(), $result->issues())); - } - - public function testItCanRunIndividualLintingChecks(): void - { - $this->writeFile('data/broken.json', '{'); - $this->writeFile('assets/broken.js', 'const = ;'); - - $result = (new PackageValidator())->validate( - $this->candidate(), - PackageSpec::create()->withJsonLinting(), - ); - - self::assertFalse($result->isSuccess()); - self::assertCount(1, $result->issues()); - self::assertSame('package.json_syntax_error', $result->firstIssue()?->code()); - } - - public function testItBlocksReservedPackagePaths(): void - { - $this->writeFile('.env.local', 'APP_SECRET=leaked'); - $this->writeFile('public/index.php', 'writeFile('vendor/autoload.php', 'validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertFalse($result->isSuccess()); - self::assertNotEmpty($result->issues()); - self::assertSame( - ['package.policy.blocked_path'], - array_values(array_unique(array_map(static fn ($issue): string => $issue->code(), $result->issues()))), - ); - - $reasons = array_values(array_unique(array_map(static fn ($issue): string => $issue->context()['reason'], $result->issues()))); - self::assertContains('environment_file', $reasons); - self::assertContains('reserved_project_path', $reasons); - } - - public function testItWarnsAboutNonRuntimePackagePaths(): void - { - $this->writeFile('docs/readme.md', 'notes'); - $this->writeFile('tests/PackageTest.php', 'validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertTrue($result->isSuccess()); - self::assertGreaterThanOrEqual(2, count($result->messages())); - self::assertSame([ - 'package.policy.warned_path', - 'package.policy.warned_path', - ], array_map(static fn ($message): string => $message->code(), array_slice($result->messages(), 0, 2))); - self::assertSame('non_runtime_payload', $result->messages()[0]->context()['reason']); - } - - public function testItBlocksDirectPhpCapabilitiesForInstallablePackages(): void - { - $this->writeFile('package.php', <<<'PHP' - writeFile('src/Runner.php', <<<'PHP' - validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertFalse($result->isSuccess()); - - $policyIssues = array_values(array_filter( - $result->issues(), - static fn ($issue): bool => 'package.policy.blocked_php_capability' === $issue->code(), - )); - - self::assertCount(4, $policyIssues); - self::assertSame(['file_get_contents', 'putenv', 'exec', '\ZipArchive'], array_map( - static fn ($issue): string => $issue->context()['capability'], - $policyIssues, - )); - self::assertSame(['direct_filesystem', 'direct_environment', 'direct_process', 'direct_filesystem'], array_map( - static fn ($issue): string => $issue->context()['reason'], - $policyIssues, - )); - } - - public function testItBlocksDynamicPhpCapabilityBypassesForInstallablePackages(): void - { - $this->writeFile('package.php', <<<'PHP' - validate( - $this->candidate(), - PackageSpec::create()->withInventoryDepth(4), - ); - - self::assertFalse($result->isSuccess()); - - $policyIssues = array_values(array_filter( - $result->issues(), - static fn ($issue): bool => 'package.policy.blocked_php_capability' === $issue->code(), - )); - - self::assertSame(['$reader()', 'call_user_func', 'ReflectionFunction'], array_map( - static fn ($issue): string => $issue->context()['capability'], - $policyIssues, - )); - self::assertSame(['dynamic_callable', 'dynamic_callable', 'dynamic_introspection'], array_map( - static fn ($issue): string => $issue->context()['reason'], - $policyIssues, - )); - } - - private function candidate(): PackageCandidate - { - return new PackageCandidate( - PackageSource::children('package', 'packages'), - $this->packageDir, - $this->packageDir.'/.manifest', - new Manifest(['PACKAGE_SLUG' => 'system', 'PACKAGE_NAME' => 'System']), - ); - } - - private function candidateWithScope(string $scope): PackageCandidate - { - return $this->candidateWithManifest(['PACKAGE_SCOPE' => $scope]); - } - - /** - * @param array $manifest - */ - private function candidateWithManifest(array $manifest): PackageCandidate - { - $slug = $manifest['PACKAGE_SLUG'] ?? 'system'; - if (PackageManifestSpec::isValidSlug($slug) && basename($this->packageDir) !== $slug) { - $targetDir = $this->rootDir.'/'.$slug; - rename($this->packageDir, $targetDir); - $this->packageDir = $targetDir; - } - - return new PackageCandidate( - PackageSource::children('package', 'packages'), - $this->packageDir, - $this->packageDir.'/.manifest', - new Manifest(['PACKAGE_SLUG' => 'system', 'PACKAGE_NAME' => 'System', ...$manifest]), - ); - } - - private function writeFile(string $relativePath, string $contents): void - { - $this->writeTestFile($this->packageDir, $relativePath, $contents); - } -} diff --git a/tests/Core/Package/PackageZipInstallerTest.php b/tests/Core/Package/PackageZipInstallerTest.php deleted file mode 100644 index 495a114f..00000000 --- a/tests/Core/Package/PackageZipInstallerTest.php +++ /dev/null @@ -1,525 +0,0 @@ -projectDir = (string) self::getContainer()->getParameter('kernel.project_dir'); - $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); - - foreach (self::TEST_PACKAGE_SLUGS as $slug) { - $this->removePath($this->projectDir.'/packages/'.$slug); - } - - foreach (self::TEST_PACKAGE_DATABASE_SLUGS as $slug) { - $this->deletePackageRow($slug); - } - } - - protected function tearDown(): void - { - foreach (self::TEST_PACKAGE_SLUGS as $slug) { - $this->removePath($this->projectDir.'/packages/'.$slug); - } - - foreach (self::TEST_PACKAGE_DATABASE_SLUGS as $slug) { - $this->deletePackageRow($slug); - } - - foreach (self::TEST_INSTALL_IDS as $installId) { - $this->removePath($this->installRoot($installId)); - } - - parent::tearDown(); - } - - public function testItVerifiesZipAndReturnsReviewContinuation(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = 'aaaaaaaaaaaaaaaaaaaaaaaa'; - $this->writeUploadZip($installId, 'zip-install-review'); - - $result = $this->installer()->verify(['install_id' => $installId]); - - self::assertSame(WorkflowStatus::RequiresReview, $result->status()); - self::assertSame('zip-install-review', $result->value()['package']); - self::assertSame( - LiveOperationQueueFactory::PACKAGE_INSTALL_APPLY, - $result->context()['live_operation_continuation']['operation'], - ); - - $this->removePath($this->installRoot($installId)); - } - - public function testItInstallsZipAndRunsDiscovery(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = 'bbbbbbbbbbbbbbbbbbbbbbbb'; - $slug = 'zip-install-apply'; - $this->removePath($this->projectDir.'/packages/'.$slug); - $this->deletePackageRow($slug); - $this->writeUploadZip($installId, $slug); - - $verify = $this->installer()->verify(['install_id' => $installId]); - self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); - - $apply = $this->installer()->apply([ - 'install_id' => $installId, - 'package' => $slug, - 'was_active' => false, - ]); - - self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); - self::assertFileExists($this->projectDir.'/packages/'.$slug.'/.manifest'); - self::assertInstanceOf(ExtensionPackage::class, $this->entityManager->getRepository(ExtensionPackage::class)->findOneBy([ - 'packageName' => $slug, - ])); - - $this->removePath($this->projectDir.'/packages/'.$slug); - $this->removePath($this->installRoot($installId)); - $this->deletePackageRow($slug); - } - - public function testItBlocksUnsafeActiveOverwriteBeforeDeactivation(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = 'cccccccccccccccccccccccc'; - $slug = 'zip-install-rollback'; - $target = $this->projectDir.'/packages/'.$slug; - $this->removePath($target); - $this->deletePackageRow($slug); - $this->writePackageDirectory($target, $slug, '1.0.0', 'old package'); - $this->persistPackage($slug, ExtensionPackageStatus::Active); - $this->writeUploadZip( - $installId, - $slug, - dependencies: '[["missing-package", "1.0.0"]]', - version: '1.1.0', - readme: "new package\n", - ); - - $verify = $this->installer()->verify(['install_id' => $installId]); - - self::assertSame(WorkflowStatus::Blocked, $verify->status()); - self::assertStringContainsString('old package', (string) file_get_contents($target.'/README.md')); - self::assertSame(ExtensionPackageStatus::Active, $this->packageStatus($slug)); - - $this->removePath($target); - $this->removePath($this->installRoot($installId)); - $this->deletePackageRow($slug); - } - - public function testItBlocksSameVersionOverwriteForInstalledPackages(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = 'eeeeeeeeeeeeeeeeeeeeeeee'; - $slug = 'zip-install-rollback'; - $target = $this->projectDir.'/packages/'.$slug; - $this->removePath($target); - $this->deletePackageRow($slug); - $this->writePackageDirectory($target, $slug, '1.0.0', 'old package'); - $this->persistPackage($slug, ExtensionPackageStatus::Inactive); - $this->writeUploadZip($installId, $slug, version: '1.0.0', readme: "same version\n"); - - $verify = $this->installer()->verify(['install_id' => $installId]); - - self::assertSame(WorkflowStatus::Blocked, $verify->status()); - self::assertSame('package.install.version_blocked', $verify->firstIssue()?->code()); - self::assertStringContainsString('old package', (string) file_get_contents($target.'/README.md')); - self::assertSame(ExtensionPackageStatus::Inactive, $this->packageStatus($slug)); - - $this->removePath($target); - $this->removePath($this->installRoot($installId)); - $this->deletePackageRow($slug); - } - - public function testItAllowsSameVersionRecoveryForRemovedPackages(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = '999999999999999999999999'; - $slug = 'zip-install-rollback'; - $target = $this->projectDir.'/packages/'.$slug; - $this->removePath($target); - $this->deletePackageRow($slug); - $this->persistPackage($slug, ExtensionPackageStatus::Removed); - $this->writeUploadZip($installId, $slug, version: '1.0.0', readme: "same version recovery\n"); - - $verify = $this->installer()->verify(['install_id' => $installId]); - - self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); - - $this->removePath($target); - $this->removePath($this->installRoot($installId)); - $this->deletePackageRow($slug); - } - - public function testItBlocksOlderPackageVersionsAgainstRegistry(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = 'ffffffffffffffffffffffff'; - $slug = 'zip-install-rollback'; - $target = $this->projectDir.'/packages/'.$slug; - $this->removePath($target); - $this->deletePackageRow($slug); - $this->writePackageDirectory($target, $slug, '1.1.0', 'old package'); - $this->persistPackage($slug, ExtensionPackageStatus::Removed, version: '1.1.0'); - $this->writeUploadZip($installId, $slug, version: '1.0.0', readme: "older package\n"); - - $verify = $this->installer()->verify(['install_id' => $installId]); - - self::assertSame(WorkflowStatus::Blocked, $verify->status()); - self::assertSame('package.install.version_blocked', $verify->firstIssue()?->code()); - self::assertStringContainsString('old package', (string) file_get_contents($target.'/README.md')); - self::assertSame(ExtensionPackageStatus::Removed, $this->packageStatus($slug)); - - $this->removePath($target); - $this->removePath($this->installRoot($installId)); - $this->deletePackageRow($slug); - } - - public function testItRejectsSymlinkEntriesBeforeExtraction(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = '777777777777777777777777'; - $slug = 'zip-install-symlink'; - $root = $this->installRoot($installId); - $this->removePath($root); - mkdir($root, 0775, true); - - $zip = new ZipArchive(); - self::assertTrue(true === $zip->open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); - $zip->addFromString($slug.'/.manifest', <<addFromString($slug.'/assets/host-file.txt', '/etc/passwd'); - self::assertTrue($zip->setExternalAttributesName( - $slug.'/assets/host-file.txt', - ZipArchive::OPSYS_UNIX, - 0o120777 << 16, - )); - $zip->close(); - - $verify = $this->installer()->verify(['install_id' => $installId]); - - self::assertSame(WorkflowStatus::Invalid, $verify->status()); - self::assertSame('package.install.zip_invalid', $verify->firstIssue()?->code()); - self::assertSame('symlink_entry', $verify->firstIssue()?->context()['reason'] ?? null); - - $this->removePath($root); - } - - public function testItRejectsPolicyBlockedPackagePaths(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = '888888888888888888888888'; - $slug = 'zip-install-symlink'; - $root = $this->installRoot($installId); - $this->removePath($root); - mkdir($root, 0775, true); - - $zip = new ZipArchive(); - self::assertTrue(true === $zip->open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); - $zip->addFromString($slug.'/.manifest', <<addFromString($slug.'/public/index.php', 'close(); - - $verify = $this->installer()->verify(['install_id' => $installId]); - - self::assertSame(WorkflowStatus::Invalid, $verify->status()); - self::assertSame('package.policy.blocked_path', $verify->firstIssue()?->code()); - self::assertSame('reserved_project_path', $verify->firstIssue()?->context()['reason']); - - $this->removePath($root); - } - - public function testItRestoresActiveReverseDependentsAfterSuccessfulOverwrite(): void - { - if (!class_exists(ZipArchive::class)) { - self::markTestSkipped('ZipArchive is required for package ZIP installer tests.'); - } - - $installId = 'dddddddddddddddddddddddd'; - $slug = 'zip-install-dependent'; - $dependentSlug = 'zip-install-dependent-addon'; - $target = $this->projectDir.'/packages/'.$slug; - $this->removePath($target); - $this->deletePackageRow($slug); - $this->deletePackageRow($dependentSlug); - $this->writePackageDirectory($target, $slug, '1.0.0', 'old package'); - $this->writePackageDirectory( - $this->projectDir.'/packages/'.$dependentSlug, - $dependentSlug, - '1.0.0', - 'dependent package', - sprintf('[["%s", "1.0.0"]]', $slug), - ); - $this->persistPackage($slug, ExtensionPackageStatus::Active); - $this->persistPackage( - $dependentSlug, - ExtensionPackageStatus::Active, - dependencies: sprintf('[["%s", "1.0.0"]]', $slug), - ); - $this->writeUploadZip($installId, $slug, version: '1.1.0', readme: "new package\n"); - - $verify = $this->installer()->verify(['install_id' => $installId]); - self::assertSame(WorkflowStatus::RequiresReview, $verify->status()); - - $apply = $this->installer()->apply([ - 'install_id' => $installId, - 'package' => $slug, - 'was_active' => true, - ]); - - self::assertTrue($apply->isSuccess(), json_encode($apply->toArray(), JSON_THROW_ON_ERROR)); - self::assertStringContainsString('new package', (string) file_get_contents($target.'/README.md')); - self::assertSame(ExtensionPackageStatus::Active, $this->packageStatus($slug)); - self::assertSame(ExtensionPackageStatus::Active, $this->packageStatus($dependentSlug)); - self::assertSame('1.1.0', $this->packageVersion($slug)); - - $this->removePath($target); - $this->removePath($this->projectDir.'/packages/'.$dependentSlug); - $this->removePath($this->installRoot($installId)); - $this->deletePackageRow($slug); - $this->deletePackageRow($dependentSlug); - } - - private function installer(): PackageZipInstaller - { - $installer = self::getContainer()->get(PackageZipInstaller::class); - self::assertInstanceOf(PackageZipInstaller::class, $installer); - - return $installer; - } - - private function writeUploadZip( - string $installId, - string $slug, - string $dependencies = '[]', - string $version = '1.0.0', - string $readme = "# ZIP Install Test\n", - ): void { - $root = $this->installRoot($installId); - $source = $root.'/source/'.$slug; - $this->removePath($root); - mkdir($source, 0775, true); - file_put_contents($source.'/.manifest', <<open($root.'/upload.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE)); - $zip->addFile($source.'/.manifest', $slug.'/.manifest'); - $zip->addFile($source.'/README.md', $slug.'/README.md'); - $zip->close(); - } - - private function writePackageDirectory( - string $target, - string $slug, - string $version, - string $readme, - string $dependencies = '[]', - ): void - { - mkdir($target, 0775, true); - file_put_contents($target.'/.manifest', <<projectDir.'/var/cache/test/package-installs/'.$installId; - } - - private function persistPackage( - string $slug, - ExtensionPackageStatus $status, - string $dependencies = '[]', - string $version = '1.0.0', - ): void - { - $this->entityManager->persist(new ExtensionPackage( - $this->uuid(), - [PackageScope::Module], - $slug, - 'packages/'.$slug, - $status, - [ - 'registry_state' => 'available', - 'manifest' => [ - 'PACKAGE_DEPENDENCIES' => $dependencies, - ], - ], - manifestVersion: $version, - installedVersion: $version, - )); - $this->entityManager->flush(); - $this->entityManager->clear(); - } - - private function packageStatus(string $slug): ExtensionPackageStatus - { - $package = $this->entityManager->getRepository(ExtensionPackage::class)->findOneBy([ - 'packageName' => $slug, - ]); - - self::assertInstanceOf(ExtensionPackage::class, $package); - - return $package->status(); - } - - private function packageVersion(string $slug): ?string - { - $package = $this->entityManager->getRepository(ExtensionPackage::class)->findOneBy([ - 'packageName' => $slug, - ]); - - self::assertInstanceOf(ExtensionPackage::class, $package); - - return $package->installedVersion(); - } - - private function deletePackageRow(string $slug): void - { - $this->entityManager->createQueryBuilder() - ->delete(ExtensionPackage::class, 'pkg') - ->where('pkg.packageName = :package') - ->setParameter('package', $slug) - ->getQuery() - ->execute(); - $this->entityManager->clear(); - } - - private function uuid(): string - { - return Uuid::v7()->toRfc4122(); - } - - private function removePath(string $path): void - { - if (!file_exists($path)) { - return; - } - - if (is_file($path) || is_link($path)) { - unlink($path); - - return; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $item) { - if (!$item instanceof \SplFileInfo) { - continue; - } - - $item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); - } - - rmdir($path); - } -} diff --git a/tests/Core/Process/DetachedProcessStarterTest.php b/tests/Core/Process/DetachedProcessStarterTest.php index 204d183c..b0545807 100644 --- a/tests/Core/Process/DetachedProcessStarterTest.php +++ b/tests/Core/Process/DetachedProcessStarterTest.php @@ -31,6 +31,83 @@ public function testItStartsDetachedProcessesWithFilteredEnvironmentAndOutputFil self::assertFileExists($pidPath); } + public function testItPreservesInheritedLocksByDefault(): void + { + if ('\\' === DIRECTORY_SEPARATOR) { + self::markTestSkipped('POSIX file descriptor inheritance is not applicable on Windows.'); + } + + $root = $this->createTemporaryDirectory('detached-process-preserved-lock'); + $outputPath = $root.'/logs/process.log'; + $pidPath = $root.'/pids/process.pid'; + $lockPath = $root.'/phpunit.lock'; + $lock = fopen($lockPath, 'c'); + self::assertIsResource($lock); + self::assertTrue(flock($lock, LOCK_EX)); + + try { + $started = (new DetachedProcessStarter())->start( + [PHP_BINARY, '-r', 'sleep(10);'], + $root, + $outputPath, + $pidPath, + ); + + self::assertTrue($started); + self::assertTrue($this->waitForFile($pidPath)); + + fclose($lock); + + self::assertSame('locked', $this->lockState($lockPath)); + } finally { + if (is_resource($lock)) { + fclose($lock); + } + + $this->stopPidFile($pidPath); + } + + self::assertTrue($this->waitForLockState($lockPath, 'free')); + } + + public function testItCanCloseInheritedLocksForPersistentDetachedChildren(): void + { + if ('\\' === DIRECTORY_SEPARATOR) { + self::markTestSkipped('POSIX file descriptor inheritance is not applicable on Windows.'); + } + + $root = $this->createTemporaryDirectory('detached-process-closed-lock'); + $outputPath = $root.'/logs/process.log'; + $pidPath = $root.'/pids/process.pid'; + $lockPath = $root.'/phpunit.lock'; + $lock = fopen($lockPath, 'c'); + self::assertIsResource($lock); + self::assertTrue(flock($lock, LOCK_EX)); + + try { + $started = (new DetachedProcessStarter())->start( + [PHP_BINARY, '-r', 'sleep(10);'], + $root, + $outputPath, + $pidPath, + closeInheritedFileDescriptors: true, + ); + + self::assertTrue($started); + self::assertTrue($this->waitForFile($pidPath)); + + fclose($lock); + + self::assertSame('free', $this->lockState($lockPath)); + } finally { + if (is_resource($lock)) { + fclose($lock); + } + + $this->stopPidFile($pidPath); + } + } + private function waitForFileContains(string $path, string $needle): bool { for ($attempt = 0; $attempt < 50; ++$attempt) { @@ -43,4 +120,62 @@ private function waitForFileContains(string $path, string $needle): bool return false; } + + private function waitForFile(string $path): bool + { + for ($attempt = 0; $attempt < 50; ++$attempt) { + if (is_file($path)) { + return true; + } + + usleep(100_000); + } + + return false; + } + + private function lockState(string $path): string + { + $process = proc_open( + [PHP_BINARY, '-r', '$f = fopen($argv[1], "c"); echo flock($f, LOCK_EX | LOCK_NB) ? "free" : "locked";', $path], + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + self::assertIsResource($process); + + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return trim((string) $output); + } + + private function waitForLockState(string $path, string $expected): bool + { + for ($attempt = 0; $attempt < 50; ++$attempt) { + if ($expected === $this->lockState($path)) { + return true; + } + + usleep(100_000); + } + + return false; + } + + private function stopPidFile(string $pidPath): void + { + if (!is_file($pidPath)) { + return; + } + + $pid = trim((string) file_get_contents($pidPath)); + if (function_exists('posix_kill') && 1 === preg_match('/^\d+$/', $pid)) { + @posix_kill((int) $pid, 15); + } + } } diff --git a/tests/Core/Routing/IgnorableRequestPathMatcherTest.php b/tests/Core/Routing/IgnorableRequestPathMatcherTest.php new file mode 100644 index 00000000..750dfd2f --- /dev/null +++ b/tests/Core/Routing/IgnorableRequestPathMatcherTest.php @@ -0,0 +1,40 @@ + + */ + public static function pathCases(): iterable + { + yield 'assets root' => ['/assets', true]; + yield 'assets child' => ['/assets/app.css', true]; + yield 'assets sibling' => ['/assets-preview', false]; + yield 'build root' => ['/build', true]; + yield 'profiler child' => ['/_profiler/123', true]; + yield 'toolbar sibling' => ['/_wdtfoo', false]; + yield 'favicon' => ['/favicon.ico', true]; + yield 'robots' => ['/robots.txt', true]; + yield 'touch icon' => ['/apple-touch-icon.png', true]; + yield 'site manifest' => ['/site.webmanifest', true]; + yield 'sitemap' => ['/sitemap.xml', true]; + yield 'well-known security' => ['/.well-known/security.txt', true]; + yield 'well-known webfinger' => ['/.well-known/webfinger', true]; + yield 'well-known unknown child' => ['/.well-known/random-scanner', false]; + yield 'application route' => ['/missing', false]; + } + + #[DataProvider('pathCases')] + public function testItMatchesIgnorableStaticAndToolingPaths(string $path, bool $expected): void + { + self::assertSame($expected, (new IgnorableRequestPathMatcher())->matches($path)); + } +} diff --git a/tests/Core/Routing/PathScopeMatcherTest.php b/tests/Core/Routing/PathScopeMatcherTest.php new file mode 100644 index 00000000..29fb0cfd --- /dev/null +++ b/tests/Core/Routing/PathScopeMatcherTest.php @@ -0,0 +1,58 @@ + + */ + public static function prefixCases(): iterable + { + yield 'exact path' => ['/api/v1', '/api/v1', true]; + yield 'child path' => ['/api/v1/status', '/api/v1', true]; + yield 'lookalike path' => ['/api/v10/status', '/api/v1', false]; + yield 'segment sibling' => ['/cronjobs', '/cron', false]; + yield 'prefix without leading slash' => ['/build/app.js', 'build', true]; + yield 'root does not match every path' => ['/docs', '/', false]; + yield 'root matches only root' => ['/', '/', true]; + } + + #[DataProvider('prefixCases')] + public function testMatchesPrefixUsesSegmentBoundaries(string $path, string $prefix, bool $matches): void + { + self::assertSame($matches, (new PathScopeMatcher())->matchesPrefix($path, $prefix)); + } + + public function testMatchesAnyPrefixUsesSameSegmentRules(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesAnyPrefix('/_wdt/token', '/assets', '/_wdt')); + self::assertFalse($matcher->matchesAnyPrefix('/_wdtfoo', '/assets', '/_wdt')); + } + + public function testMatchesSegmentsPinsExplicitPathParts(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesSegments('/api/v1/content', 'api', 'v1')); + self::assertFalse($matcher->matchesSegments('/api/v10/content', 'api', 'v1')); + self::assertFalse($matcher->matchesSegments('/de/api/v1/content', 'api', 'v1')); + } + + public function testMatchesExactSegmentsRejectsChildrenAndLocalizedLookalikes(): void + { + $matcher = new PathScopeMatcher(); + + self::assertTrue($matcher->matchesExactSegments('/cron/run', 'cron', 'run')); + self::assertFalse($matcher->matchesExactSegments('/cron/run/extra', 'cron', 'run')); + self::assertFalse($matcher->matchesExactSegments('/de/cron/run', 'cron', 'run')); + } +} diff --git a/tests/Core/Routing/RequestPathResolverTest.php b/tests/Core/Routing/RequestPathResolverTest.php new file mode 100644 index 00000000..8eb644ce --- /dev/null +++ b/tests/Core/Routing/RequestPathResolverTest.php @@ -0,0 +1,74 @@ +attributes->set('_locale', 'de'); + $login = Request::create('/de/user/login'); + $login->attributes->set('_locale', 'de'); + $content = Request::create('/de/about'); + $content->attributes->set('_locale', 'de'); + + self::assertSame(['admin', 'settings', 'security'], $resolver->segments($admin)); + self::assertSame(['user', 'login'], $resolver->segments($login)); + self::assertSame(['de', 'about'], $resolver->segments($content)); + } + + public function testItDoesNotStripLocalePrefixForPrefixlessTechnicalScopes(): void + { + $resolver = new RequestPathResolver(); + $api = Request::create('/de/api/v1/status'); + $api->attributes->set('_locale', 'de'); + $cron = Request::create('/de/cron/run'); + $cron->attributes->set('_locale', 'de'); + + self::assertFalse($resolver->matches($api, 'api', 'v1')); + self::assertFalse($resolver->matchesExact($cron, 'cron', 'run')); + self::assertFalse($resolver->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + self::assertFalse($resolver->matches(Request::create('/apiary'), 'api')); + } + + public function testItUsesEnabledRoutePrefixLanguages(): void + { + $resolver = new RequestPathResolver($this->routeLocalization(true)); + + self::assertTrue($resolver->matches(Request::create('/de/admin/logs'), 'admin')); + self::assertFalse($resolver->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + self::assertFalse($resolver->matchesExact(Request::create('/de/cron/run'), 'cron', 'run')); + self::assertFalse($resolver->matches(Request::create('/fr/api/v1/status'), 'api', 'v1')); + self::assertFalse((new RequestPathResolver($this->routeLocalization(false)))->matches(Request::create('/de/api/v1/status'), 'api', 'v1')); + } + + private function routeLocalization(bool $enabled): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, $enabled, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, new TranslationLanguageCatalog(dirname(__DIR__, 3))); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php b/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php index cac46004..f76cb644 100644 --- a/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php +++ b/tests/Core/Statistics/DatabaseAccessStatisticsRecorderTest.php @@ -5,6 +5,9 @@ namespace App\Tests\Core\Statistics; use App\Core\Log\AccessRequestMetadata; +use App\Core\Geo\GeoIpResolverInterface; +use App\Core\Geo\GeoIpProviderStatus; +use App\Core\Geo\GeoIpResult; use App\Core\Geo\NullGeoIpResolver; use App\Core\Config\Config; use App\Core\Config\ConfigValueType; @@ -130,6 +133,27 @@ public function testItRedactsTokenizedPathSegments(): void self::assertStringNotContainsString('test-token', implode(' ', array_map('strval', $row))); } + public function testItBoundsGeoIpLabelsBeforeRecording(): void + { + $label = str_repeat('x', 120); + + (new DatabaseAccessStatisticsRecorder( + $this->connection, + new VisitorIdGenerator('test-secret'), + new UserAgentClassifier(), + new AccessRequestMetadata(), + new StaticGeoIpResolver(new GeoIpResult($label, $label, $label, $label)), + ))->record(Request::create('/docs', 'GET'), new Response('', 200)); + + $row = $this->connection->fetchAssociative('SELECT city, state, country, continent FROM access_statistic_event'); + + self::assertIsArray($row); + self::assertSame(80, strlen((string) $row['city'])); + self::assertSame(80, strlen((string) $row['state'])); + self::assertSame(80, strlen((string) $row['country'])); + self::assertSame(80, strlen((string) $row['continent'])); + } + public function testItDoesNotThrowWhenStatisticsTableIsUnavailable(): void { $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); @@ -234,3 +258,20 @@ public function testItDeletesStatisticEventsOlderThanThreeMonths(): void self::assertSame(1, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM access_statistic_event WHERE path = ?', ['/docs'])); } } + +final readonly class StaticGeoIpResolver implements GeoIpResolverInterface +{ + public function __construct(private GeoIpResult $result) + { + } + + public function resolve(?string $ipAddress): GeoIpResult + { + return $this->result; + } + + public function status(): GeoIpProviderStatus + { + return GeoIpProviderStatus::ready('test'); + } +} diff --git a/tests/Core/Statistics/VisitorIdGeneratorTest.php b/tests/Core/Statistics/VisitorIdGeneratorTest.php index 576d4393..8159332a 100644 --- a/tests/Core/Statistics/VisitorIdGeneratorTest.php +++ b/tests/Core/Statistics/VisitorIdGeneratorTest.php @@ -92,6 +92,55 @@ public function testItKeepsCookieLessVisitorsStableByIpAndUserAgent(): void ); } + public function testItUsesForwardingHeaderEntropyOnlyForCookieLessVisitorFallbacks(): void + { + $generator = new VisitorIdGenerator('test-secret'); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $firstRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $secondRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ]); + $matchingRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + self::assertSame($generator->generate($firstRequest), $generator->generate($matchingRequest)); + self::assertNotSame($generator->generate($firstRequest), $generator->generate($secondRequest)); + self::assertSame('203.0.113.10', $generator->sourceIp($firstRequest)); + } + + public function testItSeparatesRecentFallbacksBehindSameIpWhenForwardingEntropyDiffers(): void + { + $generator = $this->generator(); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $firstRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $secondRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ]); + $matchingRequest = Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + self::assertSame($generator->generate($firstRequest), $generator->generate($matchingRequest)); + self::assertNotSame($generator->generate($firstRequest), $generator->generate($secondRequest)); + } + public function testItUsesPendingCookieVisitorIdsWithoutAStore(): void { $request = Request::create('/docs', server: [ diff --git a/tests/Core/TranslationCatalogueAggregatorTest.php b/tests/Core/TranslationCatalogueAggregatorTest.php index 2108c17f..ded98f56 100644 --- a/tests/Core/TranslationCatalogueAggregatorTest.php +++ b/tests/Core/TranslationCatalogueAggregatorTest.php @@ -4,8 +4,8 @@ namespace App\Tests\Core; -use App\Core\Package\PackageAssetSyncPackage; -use App\Core\Package\PackageScope; +use App\Core\Extension\ExtensionAssetSyncTarget; +use App\Core\Extension\ExtensionScope; use App\Core\Translation\TranslationCatalogueAggregator; use App\Core\Translation\TranslationCatalogueCollisionException; use App\Core\Translation\TranslationMessageKey; @@ -22,7 +22,7 @@ final class TranslationCatalogueAggregatorTest extends TestCase protected function setUp(): void { - $this->root = $this->createTemporaryDirectory('system-package-translations'); + $this->root = $this->createTemporaryDirectory('system-extension-translations'); $this->writeTestFile($this->root, 'translations/languages/en/ui.yaml', "ui:\n app:\n name: Studio\n"); $this->writeTestFile($this->root, 'translations/languages/de/ui.yaml', "ui:\n app:\n name: Studio\n"); $this->writeTestFile($this->root, 'translations/runtime/test/messages.fr.yaml', "stale: true\n"); @@ -34,14 +34,14 @@ protected function tearDown(): void $this->removeDirectory($this->root); } - public function testItAggregatesCoreAndActivePackageTranslationCatalogues(): void + public function testItAggregatesCoreAndActiveExtensionTranslationCatalogues(): void { - $this->writeTestFile($this->root, 'packages/demo/languages/en/demo.yaml', "pkg:\n demo:\n label: Demo\n"); - $this->writeTestFile($this->root, 'packages/demo/languages/de/demo.yaml', "pkg:\n demo:\n label: Demo\n"); - $this->writeTestFile($this->root, 'packages/inactive/languages/en/inactive.yaml', "pkg:\n inactive:\n label: Hidden\n"); + $this->writeTestFile($this->root, 'extensions/demo/languages/en/demo.yaml', "ext:\n demo:\n label: Demo\n"); + $this->writeTestFile($this->root, 'extensions/demo/languages/de/demo.yaml', "ext:\n demo:\n label: Demo\n"); + $this->writeTestFile($this->root, 'extensions/inactive/languages/en/inactive.yaml', "ext:\n inactive:\n label: Hidden\n"); $result = $this->aggregator()->aggregate([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), + new ExtensionAssetSyncTarget('demo', 'extensions/demo', [ExtensionScope::Module]), ]); self::assertTrue($result->isSuccess()); @@ -55,16 +55,16 @@ public function testItAggregatesCoreAndActivePackageTranslationCatalogues(): voi $english = Yaml::parseFile($this->root.'/translations/runtime/test/messages.en.yaml'); self::assertSame('Studio', $english['ui']['app']['name']); - self::assertSame('Demo', $english['pkg']['demo']['label']); - self::assertArrayNotHasKey('inactive', $english['pkg']); + self::assertSame('Demo', $english['ext']['demo']['label']); + self::assertArrayNotHasKey('inactive', $english['ext']); } - public function testItIgnoresNonPackageTranslationPaths(): void + public function testItIgnoresNonExtensionTranslationPaths(): void { - $this->writeTestFile($this->root, 'external/demo/languages/en/demo.yaml', "pkg:\n demo: true\n"); + $this->writeTestFile($this->root, 'external/demo/languages/en/demo.yaml', "ext:\n demo: true\n"); $result = $this->aggregator()->aggregate([ - new PackageAssetSyncPackage('demo', 'external/demo', [PackageScope::Module]), + new ExtensionAssetSyncTarget('demo', 'external/demo', [ExtensionScope::Module]), ]); self::assertTrue($result->isSuccess()); @@ -74,10 +74,10 @@ public function testItIgnoresNonPackageTranslationPaths(): void public function testItRejectsTranslationKeyCollisions(): void { - $this->writeTestFile($this->root, 'packages/demo/languages/en/demo.yaml', "ui:\n app:\n name: Override\n"); + $this->writeTestFile($this->root, 'extensions/demo/languages/en/demo.yaml', "ui:\n app:\n name: Override\n"); $result = $this->aggregator()->aggregate([ - new PackageAssetSyncPackage('demo', 'packages/demo', [PackageScope::Module]), + new ExtensionAssetSyncTarget('demo', 'extensions/demo', [ExtensionScope::Module]), ]); self::assertFalse($result->isSuccess()); diff --git a/tests/Core/Validation/IdentifierSpecTest.php b/tests/Core/Validation/IdentifierSpecTest.php new file mode 100644 index 00000000..126b6c43 --- /dev/null +++ b/tests/Core/Validation/IdentifierSpecTest.php @@ -0,0 +1,113 @@ + + */ + public static function invalidSharedSlugShapes(): iterable + { + yield 'empty' => ['']; + yield 'uppercase' => ['Demo']; + yield 'underscore' => ['demo_module']; + yield 'leading hyphen' => ['-demo']; + yield 'trailing hyphen' => ['demo-']; + yield 'double hyphen' => ['demo--module']; + yield 'space' => ['demo module']; + yield 'too long' => [str_repeat('a', IdentifierSpec::MAX_SLUG_LENGTH + 1)]; + } + + #[DataProvider('invalidSharedSlugShapes')] + public function testSharedSlugSpecsRejectUnsafeShapes(string $slug): void + { + self::assertFalse(IdentifierSpec::isOwnerSlug($slug)); + self::assertFalse(IdentifierSpec::isContentSlug($slug)); + } +} diff --git a/tests/Core/Workflow/WorkflowResultTest.php b/tests/Core/Workflow/WorkflowResultTest.php index cea16660..cf07c5f3 100644 --- a/tests/Core/Workflow/WorkflowResultTest.php +++ b/tests/Core/Workflow/WorkflowResultTest.php @@ -8,8 +8,8 @@ use App\Core\Message\Message; use App\Core\Message\MessageLevel; use App\Core\Operation\OperationMessageKey; -use App\Core\Package\PackageMessageCode; -use App\Core\Package\PackageMessageKey; +use App\Core\Extension\ExtensionMessageCode; +use App\Core\Extension\ExtensionMessageKey; use App\Core\Workflow\WorkflowResult; use App\Core\Workflow\WorkflowStatus; use InvalidArgumentException; @@ -19,7 +19,7 @@ final class WorkflowResultTest extends TestCase { public function testSuccessResultCarriesValueAndContext(): void { - $message = Message::info(PackageMessageCode::PACKAGE_DISCOVERY_COMPLETED, PackageMessageKey::PACKAGE_DISCOVERY_COMPLETED, [ + $message = Message::info(ExtensionMessageCode::EXTENSION_DISCOVERY_COMPLETED, ExtensionMessageKey::EXTENSION_DISCOVERY_COMPLETED, [ '%count%' => 1, ]); $result = WorkflowResult::success('theme-default', [ diff --git a/tests/Entity/CoreDatabaseModelTest.php b/tests/Entity/CoreDatabaseModelTest.php index 69886cc2..fd0a059e 100644 --- a/tests/Entity/CoreDatabaseModelTest.php +++ b/tests/Entity/CoreDatabaseModelTest.php @@ -8,15 +8,15 @@ use App\Core\Access\AccessMessageKey; use App\Core\Config\ConfigValueType; use App\Core\Message\MessageException; -use App\Core\Package\ExtensionPackageStatus; -use App\Core\Package\PackageScope; +use App\Core\Extension\ExtensionStatus; +use App\Core\Extension\ExtensionScope; use App\Core\State\StateMessageKey; use App\Entity\AccountToken; use App\Entity\AclGroup; use App\Entity\ApiKey; use App\Entity\ConfigEntry; -use App\Entity\ExtensionPackage; -use App\Entity\PackageSettingEntry; +use App\Entity\Extension; +use App\Entity\ExtensionSettingEntry; use App\Entity\SiteMenu; use App\Entity\SiteMenuItem; use App\Entity\StateMarker; @@ -189,16 +189,16 @@ public function testItRejectsEmptyStateMarkerMetadataKeys(): void ); } - public function testItModelsConfigPackagesAndMenus(): void + public function testItModelsConfigExtensionsAndMenus(): void { $config = new ConfigEntry('content.cleanup.trash_retention_days', 30, ConfigValueType::Integer); - $packageSetting = new PackageSettingEntry('demo-package', 'theme.variant', 'green', ConfigValueType::String); - $package = new ExtensionPackage( + $extensionSetting = new ExtensionSettingEntry('demo-extension', 'theme.variant', 'green', ConfigValueType::String); + $extension = new Extension( '55555555-5555-7555-8555-555555555555', - [PackageScope::FrontendTheme, PackageScope::Module], - 'demo-package', - 'packages/demo', - ExtensionPackageStatus::Active, + [ExtensionScope::FrontendTheme, ExtensionScope::Module], + 'demo-extension', + 'extensions/demo', + ExtensionStatus::Active, ); $menu = new SiteMenu('66666666-6666-7666-8666-666666666666', 'main', ['en' => 'Main']); $item = new SiteMenuItem( @@ -217,14 +217,14 @@ public function testItModelsConfigPackagesAndMenus(): void $config->replaceValue(0.75, ConfigValueType::Float); self::assertSame(0.75, $config->value()); self::assertSame(ConfigValueType::Float, $config->valueType()); - self::assertSame('demo-package', $packageSetting->packageName()); - self::assertSame('theme.variant', $packageSetting->key()); - self::assertSame('green', $packageSetting->value()); - self::assertSame(ConfigValueType::String, $packageSetting->valueType()); - self::assertSame([PackageScope::FrontendTheme, PackageScope::Module], $package->scopes()); - self::assertSame(['frontend-theme', 'module'], $package->scopeValues()); - self::assertTrue($package->hasScope(PackageScope::FrontendTheme)); - self::assertSame(ExtensionPackageStatus::Active, $package->status()); + self::assertSame('demo-extension', $extensionSetting->extensionName()); + self::assertSame('theme.variant', $extensionSetting->key()); + self::assertSame('green', $extensionSetting->value()); + self::assertSame(ConfigValueType::String, $extensionSetting->valueType()); + self::assertSame([ExtensionScope::FrontendTheme, ExtensionScope::Module], $extension->scopes()); + self::assertSame(['frontend-theme', 'module'], $extension->scopeValues()); + self::assertTrue($extension->hasScope(ExtensionScope::FrontendTheme)); + self::assertSame(ExtensionStatus::Active, $extension->status()); self::assertSame('main', $menu->identifier()); self::assertSame(NavigationTargetType::CONTENT, $item->targetType()); self::assertSame(AccessLevel::PUBLIC, $item->viewMinLevel()); diff --git a/tests/Fixtures/packages-invalid/broken-lint/.manifest b/tests/Fixtures/extensions-invalid/broken-lint/.manifest similarity index 100% rename from tests/Fixtures/packages-invalid/broken-lint/.manifest rename to tests/Fixtures/extensions-invalid/broken-lint/.manifest diff --git a/tests/Fixtures/packages-invalid/broken-lint/assets/broken.css b/tests/Fixtures/extensions-invalid/broken-lint/assets/broken.css similarity index 100% rename from tests/Fixtures/packages-invalid/broken-lint/assets/broken.css rename to tests/Fixtures/extensions-invalid/broken-lint/assets/broken.css diff --git a/tests/Fixtures/packages-invalid/broken-lint/assets/broken.js b/tests/Fixtures/extensions-invalid/broken-lint/assets/broken.js similarity index 100% rename from tests/Fixtures/packages-invalid/broken-lint/assets/broken.js rename to tests/Fixtures/extensions-invalid/broken-lint/assets/broken.js diff --git a/tests/Fixtures/packages-invalid/broken-lint/config/broken.json b/tests/Fixtures/extensions-invalid/broken-lint/config/broken.json similarity index 100% rename from tests/Fixtures/packages-invalid/broken-lint/config/broken.json rename to tests/Fixtures/extensions-invalid/broken-lint/config/broken.json diff --git a/tests/Fixtures/packages-invalid/broken-lint/config/broken.yaml b/tests/Fixtures/extensions-invalid/broken-lint/config/broken.yaml similarity index 100% rename from tests/Fixtures/packages-invalid/broken-lint/config/broken.yaml rename to tests/Fixtures/extensions-invalid/broken-lint/config/broken.yaml diff --git a/tests/Fixtures/packages-invalid/broken-lint/src/Broken.php b/tests/Fixtures/extensions-invalid/broken-lint/src/Broken.php similarity index 100% rename from tests/Fixtures/packages-invalid/broken-lint/src/Broken.php rename to tests/Fixtures/extensions-invalid/broken-lint/src/Broken.php diff --git a/tests/Fixtures/packages-invalid/broken-lint/templates/broken.html.twig b/tests/Fixtures/extensions-invalid/broken-lint/templates/broken.html.twig similarity index 100% rename from tests/Fixtures/packages-invalid/broken-lint/templates/broken.html.twig rename to tests/Fixtures/extensions-invalid/broken-lint/templates/broken.html.twig diff --git a/tests/Fixtures/packages-invalid/broken-manifest/.manifest b/tests/Fixtures/extensions-invalid/broken-manifest/.manifest similarity index 100% rename from tests/Fixtures/packages-invalid/broken-manifest/.manifest rename to tests/Fixtures/extensions-invalid/broken-manifest/.manifest diff --git a/tests/Fixtures/extensions-invalid/missing-extension-files/.manifest b/tests/Fixtures/extensions-invalid/missing-extension-files/.manifest new file mode 100644 index 00000000..8c03587d --- /dev/null +++ b/tests/Fixtures/extensions-invalid/missing-extension-files/.manifest @@ -0,0 +1,6 @@ +EXTENSION_AUTHOR=Aavion Test Fixtures +EXTENSION_SLUG=missing-extension-files +EXTENSION_NAME=Missing Extension Files +EXTENSION_VERSION=0.1.0 +EXTENSION_SCOPE=frontend-theme +EXTENSION_DEPENDENCIES=[] diff --git a/tests/Fixtures/packages/.manifest b/tests/Fixtures/extensions/.manifest similarity index 65% rename from tests/Fixtures/packages/.manifest rename to tests/Fixtures/extensions/.manifest index 01521878..d2082bb8 100644 --- a/tests/Fixtures/packages/.manifest +++ b/tests/Fixtures/extensions/.manifest @@ -1,4 +1,4 @@ APP_VERSION=0.1.0-fixture APP_DATE=2026-05-22 APP_CHANNEL=test-fixture -APP_SOURCE=tests/Fixtures/packages +APP_SOURCE=tests/Fixtures/extensions diff --git a/tests/Fixtures/extensions/extensions/demo-module/.manifest b/tests/Fixtures/extensions/extensions/demo-module/.manifest new file mode 100644 index 00000000..5e006af2 --- /dev/null +++ b/tests/Fixtures/extensions/extensions/demo-module/.manifest @@ -0,0 +1,8 @@ +EXTENSION_AUTHOR=Aavion Test Fixtures +EXTENSION_SLUG=demo-module +EXTENSION_NAME=Demo Module +EXTENSION_DESCRIPTION=Demo module extension used to exercise extension lifecycle, assets, settings, and future admin UI flows. +EXTENSION_VERSION=0.1.0 +EXTENSION_SCOPE=module +EXTENSION_DEPENDENCIES=[] +EXTENSION_NAMESPACE=DemoModule diff --git a/tests/Fixtures/packages/packages/demo-module/assets/module.css b/tests/Fixtures/extensions/extensions/demo-module/assets/module.css similarity index 100% rename from tests/Fixtures/packages/packages/demo-module/assets/module.css rename to tests/Fixtures/extensions/extensions/demo-module/assets/module.css diff --git a/tests/Fixtures/packages/packages/demo-module/assets/module.js b/tests/Fixtures/extensions/extensions/demo-module/assets/module.js similarity index 100% rename from tests/Fixtures/packages/packages/demo-module/assets/module.js rename to tests/Fixtures/extensions/extensions/demo-module/assets/module.js diff --git a/tests/Fixtures/extensions/extensions/demo-module/config/services.yaml b/tests/Fixtures/extensions/extensions/demo-module/config/services.yaml new file mode 100644 index 00000000..aa71ca01 --- /dev/null +++ b/tests/Fixtures/extensions/extensions/demo-module/config/services.yaml @@ -0,0 +1,2 @@ +services: + App\Tests\Fixtures\Extensions\DemoModule\DemoModule: ~ diff --git a/tests/Fixtures/packages/packages/demo-module/src/DemoModule.php b/tests/Fixtures/extensions/extensions/demo-module/src/DemoModule.php similarity index 100% rename from tests/Fixtures/packages/packages/demo-module/src/DemoModule.php rename to tests/Fixtures/extensions/extensions/demo-module/src/DemoModule.php diff --git a/tests/Fixtures/packages/packages/demo-module/templates/macros/demo-module/widget.html.twig b/tests/Fixtures/extensions/extensions/demo-module/templates/macros/demo-module/widget.html.twig similarity index 100% rename from tests/Fixtures/packages/packages/demo-module/templates/macros/demo-module/widget.html.twig rename to tests/Fixtures/extensions/extensions/demo-module/templates/macros/demo-module/widget.html.twig diff --git a/tests/Fixtures/extensions/extensions/demo-theme/.manifest b/tests/Fixtures/extensions/extensions/demo-theme/.manifest new file mode 100644 index 00000000..ed27ddde --- /dev/null +++ b/tests/Fixtures/extensions/extensions/demo-theme/.manifest @@ -0,0 +1,8 @@ +EXTENSION_AUTHOR=Aavion Test Fixtures +EXTENSION_SLUG=demo-theme +EXTENSION_NAME=Demo Theme +EXTENSION_DESCRIPTION=Demo frontend theme extension used to exercise scoped assets, template namespaces, settings, and future admin UI flows. +EXTENSION_VERSION=0.1.0 +EXTENSION_SCOPE=frontend-theme +EXTENSION_DEPENDENCIES=[] +EXTENSION_NAMESPACE=DemoTheme diff --git a/tests/Fixtures/packages/packages/demo-theme/assets/app.css b/tests/Fixtures/extensions/extensions/demo-theme/assets/app.css similarity index 100% rename from tests/Fixtures/packages/packages/demo-theme/assets/app.css rename to tests/Fixtures/extensions/extensions/demo-theme/assets/app.css diff --git a/tests/Fixtures/packages/packages/demo-theme/assets/app.js b/tests/Fixtures/extensions/extensions/demo-theme/assets/app.js similarity index 100% rename from tests/Fixtures/packages/packages/demo-theme/assets/app.js rename to tests/Fixtures/extensions/extensions/demo-theme/assets/app.js diff --git a/tests/Fixtures/packages/packages/demo-theme/config/theme.json b/tests/Fixtures/extensions/extensions/demo-theme/config/theme.json similarity index 100% rename from tests/Fixtures/packages/packages/demo-theme/config/theme.json rename to tests/Fixtures/extensions/extensions/demo-theme/config/theme.json diff --git a/tests/Fixtures/packages/packages/demo-theme/config/theme.yaml b/tests/Fixtures/extensions/extensions/demo-theme/config/theme.yaml similarity index 100% rename from tests/Fixtures/packages/packages/demo-theme/config/theme.yaml rename to tests/Fixtures/extensions/extensions/demo-theme/config/theme.yaml diff --git a/tests/Fixtures/packages/packages/demo-theme/src/PackageExtension.php b/tests/Fixtures/extensions/extensions/demo-theme/src/ThemeExtension.php similarity index 67% rename from tests/Fixtures/packages/packages/demo-theme/src/PackageExtension.php rename to tests/Fixtures/extensions/extensions/demo-theme/src/ThemeExtension.php index 1f545a6f..c3519f94 100644 --- a/tests/Fixtures/packages/packages/demo-theme/src/PackageExtension.php +++ b/tests/Fixtures/extensions/extensions/demo-theme/src/ThemeExtension.php @@ -4,6 +4,6 @@ namespace DemoTheme; -final class PackageExtension +final class ThemeExtension { } diff --git a/tests/Fixtures/packages/packages/demo-theme/templates/frontend/base.html.twig b/tests/Fixtures/extensions/extensions/demo-theme/templates/frontend/base.html.twig similarity index 100% rename from tests/Fixtures/packages/packages/demo-theme/templates/frontend/base.html.twig rename to tests/Fixtures/extensions/extensions/demo-theme/templates/frontend/base.html.twig diff --git a/tests/Fixtures/extensions/var/cache/test/imports/demo-import/.manifest b/tests/Fixtures/extensions/var/cache/test/imports/demo-import/.manifest new file mode 100644 index 00000000..01b5d3e8 --- /dev/null +++ b/tests/Fixtures/extensions/var/cache/test/imports/demo-import/.manifest @@ -0,0 +1,3 @@ +EXTENSION_NAME=Demo Import +EXTENSION_VERSION=0.1.0 +EXTENSION_AUTHOR=Aavion Test Fixtures diff --git a/tests/Fixtures/packages/var/cache/test/imports/demo-import/package.json b/tests/Fixtures/extensions/var/cache/test/imports/demo-import/extension.json similarity index 100% rename from tests/Fixtures/packages/var/cache/test/imports/demo-import/package.json rename to tests/Fixtures/extensions/var/cache/test/imports/demo-import/extension.json diff --git a/tests/Fixtures/packages/var/cache/test/imports/demo-import/files/example.txt b/tests/Fixtures/extensions/var/cache/test/imports/demo-import/files/example.txt similarity index 100% rename from tests/Fixtures/packages/var/cache/test/imports/demo-import/files/example.txt rename to tests/Fixtures/extensions/var/cache/test/imports/demo-import/files/example.txt diff --git a/tests/Fixtures/packages-invalid/missing-package-files/.manifest b/tests/Fixtures/packages-invalid/missing-package-files/.manifest deleted file mode 100644 index e552d7d2..00000000 --- a/tests/Fixtures/packages-invalid/missing-package-files/.manifest +++ /dev/null @@ -1,6 +0,0 @@ -PACKAGE_AUTHOR=Aavion Test Fixtures -PACKAGE_SLUG=missing-package-files -PACKAGE_NAME=Missing Package Files -PACKAGE_VERSION=0.1.0 -PACKAGE_SCOPE=frontend-theme -PACKAGE_DEPENDENCIES=[] diff --git a/tests/Fixtures/packages/packages/demo-module/.manifest b/tests/Fixtures/packages/packages/demo-module/.manifest deleted file mode 100644 index 92c7ee5e..00000000 --- a/tests/Fixtures/packages/packages/demo-module/.manifest +++ /dev/null @@ -1,8 +0,0 @@ -PACKAGE_AUTHOR=Aavion Test Fixtures -PACKAGE_SLUG=demo-module -PACKAGE_NAME=Demo Module -PACKAGE_DESCRIPTION=Demo module package used to exercise package lifecycle, assets, settings, and future admin UI flows. -PACKAGE_VERSION=0.1.0 -PACKAGE_SCOPE=module -PACKAGE_DEPENDENCIES=[] -PACKAGE_NAMESPACE=DemoModule diff --git a/tests/Fixtures/packages/packages/demo-module/config/services.yaml b/tests/Fixtures/packages/packages/demo-module/config/services.yaml deleted file mode 100644 index 5c501907..00000000 --- a/tests/Fixtures/packages/packages/demo-module/config/services.yaml +++ /dev/null @@ -1,2 +0,0 @@ -services: - App\Tests\Fixtures\Packages\DemoModule\DemoModule: ~ diff --git a/tests/Fixtures/packages/packages/demo-theme/.manifest b/tests/Fixtures/packages/packages/demo-theme/.manifest deleted file mode 100644 index cff2cf01..00000000 --- a/tests/Fixtures/packages/packages/demo-theme/.manifest +++ /dev/null @@ -1,8 +0,0 @@ -PACKAGE_AUTHOR=Aavion Test Fixtures -PACKAGE_SLUG=demo-theme -PACKAGE_NAME=Demo Theme -PACKAGE_DESCRIPTION=Demo frontend theme package used to exercise scoped assets, template namespaces, settings, and future admin UI flows. -PACKAGE_VERSION=0.1.0 -PACKAGE_SCOPE=frontend-theme -PACKAGE_DEPENDENCIES=[] -PACKAGE_NAMESPACE=DemoTheme diff --git a/tests/Fixtures/packages/var/cache/test/imports/demo-import/.manifest b/tests/Fixtures/packages/var/cache/test/imports/demo-import/.manifest deleted file mode 100644 index ad4280c5..00000000 --- a/tests/Fixtures/packages/var/cache/test/imports/demo-import/.manifest +++ /dev/null @@ -1,3 +0,0 @@ -PACKAGE_NAME=Demo Import -PACKAGE_VERSION=0.1.0 -PACKAGE_AUTHOR=Aavion Test Fixtures diff --git a/tests/Form/FormSubmissionHandlerTest.php b/tests/Form/FormSubmissionHandlerTest.php index 531a4ed8..a3146200 100644 --- a/tests/Form/FormSubmissionHandlerTest.php +++ b/tests/Form/FormSubmissionHandlerTest.php @@ -19,19 +19,19 @@ public function testItCastsSubmittedValuesAndValidatesOptions(): void new FormFieldDefinition('title', 'Title', '', validation: ['required' => true, 'max_length' => 20]), new FormFieldDefinition('enabled', 'Enabled', false, ConfigValueType::Boolean), new FormFieldDefinition('sort_order', 'Sort order', 900, ConfigValueType::Integer, FormInputType::Number, validation: ['min' => 0, 'max' => 999]), - new FormFieldDefinition('widgets', 'Widgets', [], ConfigValueType::Json, FormInputType::MultiSelect, options: ['system_status' => 'System status', 'packages' => 'Packages']), + new FormFieldDefinition('widgets', 'Widgets', [], ConfigValueType::Json, FormInputType::MultiSelect, options: ['system_status' => 'System status', 'extensions' => 'Extensions']), ], [ 'title' => 'Example', 'enabled' => '1', 'sort_order' => '42', - 'widgets' => ['system_status', 'packages'], + 'widgets' => ['system_status', 'extensions'], ]); self::assertTrue($result->isValid()); self::assertSame('Example', $result->value('title')); self::assertTrue($result->value('enabled')); self::assertSame(42, $result->value('sort_order')); - self::assertSame(['system_status', 'packages'], $result->value('widgets')); + self::assertSame(['system_status', 'extensions'], $result->value('widgets')); } public function testItCollectsValidationErrorsWithoutThrowing(): void diff --git a/tests/Live/LiveEndpointRegistryTest.php b/tests/Live/LiveEndpointRegistryTest.php index 69be71d4..f12cfcd6 100644 --- a/tests/Live/LiveEndpointRegistryTest.php +++ b/tests/Live/LiveEndpointRegistryTest.php @@ -18,13 +18,13 @@ public function testItPrefersExactEndpointBeforeMatchingPatternEndpoint(): void $this->endpoint( '/api/live/demo-pack/items', 'listItems', - 'packages.demo-pack.live.items', + 'extensions.demo-pack.live.items', '#^/api/live/demo-pack/items(?:/.*)?$#', ), $this->endpoint( '/api/live/demo-pack/items/special', 'specialItems', - 'packages.demo-pack.live.items_special', + 'extensions.demo-pack.live.items_special', ), ])]); @@ -40,13 +40,13 @@ public function testItPrefersMoreSpecificPatternEndpoint(): void $this->endpoint( '/api/live/demo-pack/items', 'listItems', - 'packages.demo-pack.live.items', + 'extensions.demo-pack.live.items', '#^/api/live/demo-pack/items(?:/.*)?$#', ), $this->endpoint( '/api/live/demo-pack/items/special', 'specialChildren', - 'packages.demo-pack.live.items_special_children', + 'extensions.demo-pack.live.items_special_children', '#^/api/live/demo-pack/items/special(?:/.*)?$#', ), ])]); @@ -80,10 +80,10 @@ public function liveEndpoints(): array private function endpoint(string $path, string $operationId, string $handlerKey, ?string $pathPattern = null): LiveEndpointDefinition { return new LiveEndpointDefinition( - 'package', + 'extension', Request::METHOD_GET, $path, - 'api_live_package_dispatch', + 'api_live_extension_dispatch', $operationId, 'Run a demo live endpoint.', $handlerKey, diff --git a/tests/Localization/CoreTranslationBootstrapperTest.php b/tests/Localization/CoreTranslationBootstrapperTest.php index f2a45937..9c2cd772 100644 --- a/tests/Localization/CoreTranslationBootstrapperTest.php +++ b/tests/Localization/CoreTranslationBootstrapperTest.php @@ -25,7 +25,7 @@ protected function tearDown(): void $this->removeDirectory($this->root); } - public function testItGeneratesCoreMessagesCataloguesWithoutPackageLookup(): void + public function testItGeneratesCoreMessagesCataloguesWithoutExtensionLookup(): void { $this->writeTestFile($this->root, 'translations/languages/en/message.yaml', "message:\n setup:\n ok: Ready\n"); $this->writeTestFile($this->root, 'translations/languages/en/ui.yaml', "ui:\n app:\n name: Studio\n"); diff --git a/tests/Navigation/NavigationBuilderTest.php b/tests/Navigation/NavigationBuilderTest.php index 4e03e473..6c561b5b 100644 --- a/tests/Navigation/NavigationBuilderTest.php +++ b/tests/Navigation/NavigationBuilderTest.php @@ -195,16 +195,16 @@ static function (NavigationBuilderEvent $event): void { )); $event->addItem(new NavigationItem( '30000000-0000-7000-8000-000000000993', - 'Packages', + 'Extensions', 'url', - '/packages', + '/extensions', sortOrder: 25, )); $event->addItem(new NavigationItem( '30000000-0000-7000-8000-000000000994', - 'Package Child', + 'Extension Child', 'url', - '/packages/child', + '/extensions/child', '30000000-0000-7000-8000-000000000993', 10, )); @@ -213,9 +213,9 @@ static function (NavigationBuilderEvent $event): void { $navigation = self::getContainer()->get(NavigationBuilder::class)->build('main', 'en', actor: AccessActor::anonymous()); - self::assertSame(['Home', 'About', 'Packages', 'News', 'ui.user.login.title'], array_column($navigation, 'label')); + self::assertSame(['Home', 'About', 'Extensions', 'News', 'ui.user.login.title'], array_column($navigation, 'label')); self::assertSame(['Alpha', 'Beta'], array_column($navigation[1]['children'], 'label')); - self::assertSame('Package Child', $navigation[2]['children'][0]['label']); + self::assertSame('Extension Child', $navigation[2]['children'][0]['label']); } public function testItExposesCollectedFlatItemsForCustomBuilders(): void @@ -359,13 +359,13 @@ public function testItBuildsBackendViewsFromRegistry(): void $navigation = self::getContainer()->get(NavigationBuilder::class)->build( 'backend.admin', actor: AccessActor::fromAccess(8), - activeUrl: '/admin/packages', + activeUrl: '/admin/extensions', activeRoute: 'backend_admin_route', ); self::assertSame([ 'admin.navigation.dashboard', - 'admin.navigation.packages', + 'admin.navigation.extensions', 'admin.navigation.themes', 'admin.navigation.users', 'admin.navigation.scheduler', @@ -377,7 +377,7 @@ public function testItBuildsBackendViewsFromRegistry(): void ], array_column($navigation, 'label')); self::assertSame([ '/admin', - '/admin/packages', + '/admin/extensions', '/admin/themes', '/admin/users', '/admin/scheduler', @@ -389,6 +389,25 @@ public function testItBuildsBackendViewsFromRegistry(): void ], array_column($navigation, 'url')); self::assertFalse($navigation[0]['active']); self::assertTrue($navigation[1]['active']); + self::assertSame([ + 'admin.navigation.general_settings', + 'admin.navigation.dashboard_settings', + 'admin.navigation.user_settings', + 'admin.navigation.mail_settings', + 'admin.navigation.statistics_settings', + 'admin.navigation.logging_settings', + 'admin.navigation.extension_settings', + 'admin.navigation.scheduler_settings', + 'admin.navigation.system_info', + ], array_column($navigation[9]['children'], 'label')); + + $ownerNavigation = self::getContainer()->get(NavigationBuilder::class)->build( + 'backend.admin', + actor: AccessActor::fromAccess(9), + activeUrl: '/admin/settings', + activeRoute: 'backend_admin_route', + ); + self::assertSame([ 'admin.navigation.general_settings', 'admin.navigation.dashboard_settings', @@ -396,11 +415,13 @@ public function testItBuildsBackendViewsFromRegistry(): void 'admin.navigation.mail_settings', 'admin.navigation.security_settings', 'admin.navigation.statistics_settings', + 'admin.navigation.logging_settings', 'admin.navigation.api_settings', - 'admin.navigation.package_settings', + 'admin.navigation.acl_settings', + 'admin.navigation.extension_settings', 'admin.navigation.scheduler_settings', 'admin.navigation.system_info', - ], array_column($navigation[9]['children'], 'label')); + ], array_column($ownerNavigation[9]['children'], 'label')); } public function testItFiltersNavigationItemsByAccessLevel(): void diff --git a/tests/Operations/InitScriptTest.php b/tests/Operations/InitScriptTest.php index d72681c1..eaae3135 100644 --- a/tests/Operations/InitScriptTest.php +++ b/tests/Operations/InitScriptTest.php @@ -67,6 +67,10 @@ public function testInitScriptCoversRequiredInitializationSteps(): void self::assertStringContainsString("'tailwind:build'", $contents); self::assertStringContainsString("'cache:warmup'", $contents); self::assertStringContainsString("'asset-map:compile'", $contents); + self::assertStringContainsString('composerLockPackageSectionsForEarlyPlatformChecks()', $contents); + self::assertStringContainsString("['packages', 'packages-dev']", $contents); + self::assertStringContainsString("['packages']", $contents); + self::assertStringContainsString("in_array(\$environment, ['dev', 'test'], true)", $contents); self::assertStringNotContainsString("'doctrine:migrations:migrate'", $contents); self::assertStringNotContainsString("'doctrine:schema:validate'", $contents); self::assertStringContainsString('bootEnv($this->projectDir.\'/.env\')', $contents); diff --git a/tests/Operations/SqliteMigrationTest.php b/tests/Operations/SqliteMigrationTest.php index 014cbd1e..1c48b059 100644 --- a/tests/Operations/SqliteMigrationTest.php +++ b/tests/Operations/SqliteMigrationTest.php @@ -38,6 +38,10 @@ public function testMigrationsApplyToConfiguredSqliteDatabase(): void self::assertContains('ui_alert_inbox', $tables); self::assertContains('config_entry', $tables); self::assertContains('state_marker', $tables); + self::assertContains('message_log_entry', $tables); + self::assertContains('audit_log_entry', $tables); + self::assertContains('access_log_entry', $tables); + self::assertContains('security_signal_event', $tables); self::assertContains('access_statistic_event', $tables); self::assertContains('content_schema', $tables); self::assertContains('content_revision', $tables); @@ -99,6 +103,10 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void static fn ($index): string => $index->getName(), $schema->getTable('ui_alert_inbox')->getIndexes(), ); + $signalIndexes = array_map( + static fn ($index): string => $index->getName(), + $schema->getTable('security_signal_event')->getIndexes(), + ); $userGroupForeignKeys = array_map( static fn ($foreignKey): string => $foreignKey->getName(), $schema->getTable('user_acl_group')->getForeignKeys(), @@ -110,6 +118,9 @@ public function testPrefixedMigrationsUsePrefixedSchemaObjectNames(): void self::assertContains('studio_pk_ui_alert_inbox', $alertIndexes); self::assertContains('studio_idx_ui_alert_inbox_topic_cursor', $alertIndexes); self::assertContains('studio_idx_ui_alert_inbox_expires_at', $alertIndexes); + self::assertContains('studio_pk_security_signal_event', $signalIndexes); + self::assertContains('studio_idx_security_signal_subject_at', $signalIndexes); + self::assertContains('studio_idx_security_signal_request_reason', $signalIndexes); self::assertContains('studio_fk_user_acl_group_user', $userGroupForeignKeys); self::assertContains('studio_fk_user_acl_group_group', $userGroupForeignKeys); } finally { @@ -195,9 +206,13 @@ private function initialMigrationTables(): array 'messenger_messages', 'ui_alert_inbox', 'config_entry', - 'package_setting_entry', + 'extension_setting_entry', 'scheduler_task', 'scheduler_task_run', + 'message_log_entry', + 'audit_log_entry', + 'access_log_entry', + 'security_signal_event', 'state_marker', 'access_statistic_event', 'acl_group', @@ -205,7 +220,7 @@ private function initialMigrationTables(): array 'user_acl_group', 'account_token', 'api_key', - 'extension_package', + 'extension', 'site_menu', 'site_menu_item', 'content_schema', diff --git a/tests/Scheduler/SchedulerRunnerTest.php b/tests/Scheduler/SchedulerRunnerTest.php index ff92d2ae..bdbf0c47 100644 --- a/tests/Scheduler/SchedulerRunnerTest.php +++ b/tests/Scheduler/SchedulerRunnerTest.php @@ -9,10 +9,10 @@ use App\Core\Log\MessageLoggerInterface; use App\Core\Message\Message; use App\Core\Message\MessageException; -use App\Core\Package\ActivePackageProviderInterface; -use App\Core\Package\ExtensionPackageStatus; -use App\Core\Package\PackageScope; -use App\Entity\ExtensionPackage; +use App\Core\Extension\ActiveExtensionProviderInterface; +use App\Core\Extension\ExtensionStatus; +use App\Core\Extension\ExtensionScope; +use App\Entity\Extension; use App\Entity\SchedulerTask; use App\Entity\SchedulerTaskRun; use App\Scheduler\SchedulerLockFactory; @@ -66,20 +66,20 @@ public function testItSynchronizesRegisteredTasksInactiveByDefault(): void self::assertNotNull($tasks[0]->nextDueAt()); } - public function testItHidesUntrustedPackageActionQueuesWhenDisabled(): void + public function testItHidesUntrustedExtensionActionQueuesWhenDisabled(): void { - $tasks = $this->synchronizer(new TestPackageActionQueueSchedulerTaskProvider())->synchronize(); + $tasks = $this->synchronizer(new TestExtensionActionQueueSchedulerTaskProvider())->synchronize(); self::assertSame([], $tasks); } - public function testItReportsForcedPackageActionQueueTaskAsSkippedWhenPolicyBlocksIt(): void + public function testItReportsForcedExtensionActionQueueTaskAsSkippedWhenPolicyBlocksIt(): void { $task = new SchedulerTask(new SchedulerTaskDefinition( 'demo.action_queue', 'admin.scheduler.tasks.demo.label', 'admin.scheduler.tasks.demo.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::ActionQueue, 'demo.queue', '* * * * *', @@ -91,8 +91,8 @@ public function testItReportsForcedPackageActionQueueTaskAsSkippedWhenPolicyBloc $payload = $this->runner( new TestSchedulerTaskExecutor(true), - new TestPackageActionQueueSchedulerTaskProvider(), - new MutableActivePackageProvider(['demo-package']), + new TestExtensionActionQueueSchedulerTaskProvider(), + new MutableActiveExtensionProvider(['demo-extension']), )->run('demo.action_queue', true)->toArray(); self::assertSame('completed', $payload['status']); @@ -105,20 +105,20 @@ public function testItReportsForcedPackageActionQueueTaskAsSkippedWhenPolicyBloc public function testSystemSchedulerTaskDefinitionsWinIdentifierCollisions(): void { $registry = new SchedulerTaskRegistry([ - new TestCollidingPackageSchedulerTaskProvider(), + new TestCollidingExtensionSchedulerTaskProvider(), new TestSchedulerTaskProvider(), ]); self::assertSame('system', $registry->definition('system.test_task')?->source()); } - public function testItDoesNotShowTasksWhosePackageNoLongerRegistersThem(): void + public function testItDoesNotShowTasksWhoseExtensionNoLongerRegistersThem(): void { $staleTask = new SchedulerTask(new SchedulerTaskDefinition( 'demo.stale_task', 'admin.scheduler.tasks.demo.label', 'admin.scheduler.tasks.demo.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::Command, 'demo:test', '* * * * *', @@ -133,15 +133,15 @@ public function testItDoesNotShowTasksWhosePackageNoLongerRegistersThem(): void self::assertSame(['system.test_task'], array_map(static fn (SchedulerTask $task): string => $task->identifier(), $tasks)); } - public function testItDoesNotRunActiveDueTasksFromInactivePackages(): void + public function testItDoesNotRunActiveDueTasksFromInactiveExtensions(): void { - $this->synchronizer(new TestPackageCommandSchedulerTaskProvider())->synchronize(); + $this->synchronizer(new TestExtensionCommandSchedulerTaskProvider())->synchronize(); $task = $this->entityManager->find(SchedulerTask::class, 'demo.command'); self::assertInstanceOf(SchedulerTask::class, $task); $task->activate('* * * * *'); $this->entityManager->flush(); - $payload = $this->runner(new TestSchedulerTaskExecutor(true), new TestPackageCommandSchedulerTaskProvider())->run()->toArray(); + $payload = $this->runner(new TestSchedulerTaskExecutor(true), new TestExtensionCommandSchedulerTaskProvider())->run()->toArray(); self::assertSame('completed', $payload['status']); self::assertSame([], $payload['tasks']); @@ -208,7 +208,7 @@ public function testItReportsForcedInactiveTaskAsSkipped(): void public function testItRechecksTaskEligibilityBeforeExecutingStaleDueTasks(): void { - $activePackages = new MutableActivePackageProvider(['demo-package']); + $activeExtensions = new MutableActiveExtensionProvider(['demo-extension']); $this->synchronizer(new TestMixedSchedulerTaskProvider())->synchronize(); foreach (['system.first_task' => '-2 minutes', 'demo.command' => '-1 minute'] as $identifier => $dueAt) { @@ -220,9 +220,9 @@ public function testItRechecksTaskEligibilityBeforeExecutingStaleDueTasks(): voi $this->entityManager->flush(); $payload = $this->runner( - new TestPackageDeactivatingSchedulerTaskExecutor($activePackages), + new TestExtensionDeactivatingSchedulerTaskExecutor($activeExtensions), new TestMixedSchedulerTaskProvider(), - $activePackages, + $activeExtensions, )->run()->toArray(); self::assertSame('completed', $payload['status']); @@ -249,7 +249,13 @@ public function testItTimestampsEachDueTaskWhenItStarts(): void $this->entityManager->flush(); $this->runner(new TestDelayedSchedulerTaskExecutor(), new TestMultipleSchedulerTaskProvider())->run(); - $runs = $this->entityManager->getRepository(SchedulerTaskRun::class)->findBy([], ['startedAt' => 'ASC']); + $runs = array_values(array_filter( + $this->entityManager->getRepository(SchedulerTaskRun::class)->findBy([], ['startedAt' => 'ASC']), + static fn (SchedulerTaskRun $run): bool => in_array($run->task()->identifier(), [ + 'system.first_task', + 'system.second_task', + ], true), + )); self::assertCount(2, $runs); self::assertGreaterThan( @@ -360,7 +366,7 @@ public function testDefinitionSyncDeactivatesNewUntrustedActionQueues(): void 'admin.scheduler.tasks.sync.description', 'demo:test', '* * * * *', - 'demo-package', + 'demo-extension', false, )); $task->activate('* * * * *'); @@ -369,7 +375,7 @@ public function testDefinitionSyncDeactivatesNewUntrustedActionQueues(): void 'demo.sync_task', 'admin.scheduler.tasks.sync.label', 'admin.scheduler.tasks.sync.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::ActionQueue, 'demo.queue', '* * * * *', @@ -388,7 +394,7 @@ public function testDefinitionSyncDeactivatesChangedUntrustedActionQueues(): voi 'demo.sync_task', 'admin.scheduler.tasks.sync.label', 'admin.scheduler.tasks.sync.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::ActionQueue, 'demo.old_queue', '* * * * *', @@ -400,7 +406,7 @@ public function testDefinitionSyncDeactivatesChangedUntrustedActionQueues(): voi 'demo.sync_task', 'admin.scheduler.tasks.sync.label', 'admin.scheduler.tasks.sync.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::ActionQueue, 'demo.new_queue', '* * * * *', @@ -440,7 +446,7 @@ public function testTaskDefinitionsRejectInvalidDefaultCronExpressions(): void ); } - public function testTaskDefinitionsAcceptShortPackageSources(): void + public function testTaskDefinitionsAcceptShortExtensionSources(): void { $definition = SchedulerTaskDefinition::command( 'ai.cleanup', @@ -484,11 +490,11 @@ private function synchronizer(?SchedulerTaskProviderInterface $provider = null): private function runner( SchedulerTaskExecutorInterface $executor, ?SchedulerTaskProviderInterface $provider = null, - ?ActivePackageProviderInterface $activePackageProvider = null, + ?ActiveExtensionProviderInterface $activeExtensionProvider = null, ): SchedulerRunner { $settings = new SchedulerSettings(new Config($this->entityManager->getConnection())); - $activePackages = $activePackageProvider ?? new TestActivePackageProvider(); + $activeExtensions = $activeExtensionProvider ?? new TestActiveExtensionProvider(); $messageLogger = new TestSchedulerMessageLogger(); $reporter = new SchedulerRunReporter($messageLogger); @@ -499,7 +505,7 @@ private function runner( new LockFactory(new FlockStore(sys_get_temp_dir().'/system-scheduler-test-'.bin2hex(random_bytes(4)))), 'test', ), - new SchedulerDueTaskSelector($settings, $activePackages), + new SchedulerDueTaskSelector($settings, $activeExtensions), new SchedulerTaskRunRecorder($this->entityManager, [$executor], new UuidFactory(), $reporter), $reporter, ); @@ -561,7 +567,7 @@ public function schedulerTasks(): array 'demo.command', 'admin.scheduler.tasks.demo.label', 'admin.scheduler.tasks.demo.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::Command, 'demo:test', '* * * * *', @@ -620,9 +626,9 @@ public function execute(SchedulerTask $task): SchedulerTaskExecution } } -final readonly class TestPackageDeactivatingSchedulerTaskExecutor implements SchedulerTaskExecutorInterface +final readonly class TestExtensionDeactivatingSchedulerTaskExecutor implements SchedulerTaskExecutorInterface { - public function __construct(private MutableActivePackageProvider $activePackageProvider) + public function __construct(private MutableActiveExtensionProvider $activeExtensionProvider) { } @@ -634,14 +640,14 @@ public function supports(SchedulerTask $task): bool public function execute(SchedulerTask $task): SchedulerTaskExecution { if ('system.first_task' === $task->identifier()) { - $this->activePackageProvider->deactivate('demo-package'); + $this->activeExtensionProvider->deactivate('demo-extension'); } return SchedulerTaskExecution::success(['test' => true]); } } -final readonly class TestPackageActionQueueSchedulerTaskProvider implements SchedulerTaskProviderInterface +final readonly class TestExtensionActionQueueSchedulerTaskProvider implements SchedulerTaskProviderInterface { public function schedulerTasks(): array { @@ -650,7 +656,7 @@ public function schedulerTasks(): array 'demo.action_queue', 'admin.scheduler.tasks.demo.label', 'admin.scheduler.tasks.demo.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::ActionQueue, 'demo.queue', '* * * * *', @@ -660,7 +666,7 @@ public function schedulerTasks(): array } } -final readonly class TestPackageCommandSchedulerTaskProvider implements SchedulerTaskProviderInterface +final readonly class TestExtensionCommandSchedulerTaskProvider implements SchedulerTaskProviderInterface { public function schedulerTasks(): array { @@ -669,7 +675,7 @@ public function schedulerTasks(): array 'demo.command', 'admin.scheduler.tasks.demo.label', 'admin.scheduler.tasks.demo.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::Command, 'demo:test', '* * * * *', @@ -679,7 +685,7 @@ public function schedulerTasks(): array } } -final readonly class TestCollidingPackageSchedulerTaskProvider implements SchedulerTaskProviderInterface +final readonly class TestCollidingExtensionSchedulerTaskProvider implements SchedulerTaskProviderInterface { public function schedulerTasks(): array { @@ -688,7 +694,7 @@ public function schedulerTasks(): array 'system.test_task', 'admin.scheduler.tasks.demo.label', 'admin.scheduler.tasks.demo.description', - 'demo-package', + 'demo-extension', SchedulerTaskType::Command, 'demo:test', '* * * * *', @@ -709,52 +715,52 @@ public function logBatch(iterable $records): void } } -final readonly class TestActivePackageProvider implements ActivePackageProviderInterface +final readonly class TestActiveExtensionProvider implements ActiveExtensionProviderInterface { - public function packages(?PackageScope $scope = null): array + public function extensions(?ExtensionScope $scope = null): array { return []; } - public function package(string $packageName): ?ExtensionPackage + public function extension(string $extensionName): ?Extension { return null; } } -final class MutableActivePackageProvider implements ActivePackageProviderInterface +final class MutableActiveExtensionProvider implements ActiveExtensionProviderInterface { /** - * @param list $packageNames + * @param list $extensionNames */ - public function __construct(private array $packageNames) + public function __construct(private array $extensionNames) { } - public function deactivate(string $packageName): void + public function deactivate(string $extensionName): void { - $this->packageNames = array_values(array_filter( - $this->packageNames, - static fn (string $activePackage): bool => $activePackage !== $packageName, + $this->extensionNames = array_values(array_filter( + $this->extensionNames, + static fn (string $activeExtension): bool => $activeExtension !== $extensionName, )); } - public function packages(?PackageScope $scope = null): array + public function extensions(?ExtensionScope $scope = null): array { - return array_map(static fn (string $packageName): ExtensionPackage => new ExtensionPackage( - self::uuidFor($packageName), - [PackageScope::Module], - $packageName, - 'packages/'.str_replace('.', '-', $packageName), - ExtensionPackageStatus::Active, - ), $this->packageNames); + return array_map(static fn (string $extensionName): Extension => new Extension( + self::uuidFor($extensionName), + [ExtensionScope::Module], + $extensionName, + 'extensions/'.str_replace('.', '-', $extensionName), + ExtensionStatus::Active, + ), $this->extensionNames); } - public function package(string $packageName): ?ExtensionPackage + public function extension(string $extensionName): ?Extension { - foreach ($this->packages() as $package) { - if ($package->packageName() === $packageName) { - return $package; + foreach ($this->extensions() as $extension) { + if ($extension->extensionName() === $extensionName) { + return $extension; } } diff --git a/tests/Security/Abuse/AbuseSubjectResolverTest.php b/tests/Security/Abuse/AbuseSubjectResolverTest.php new file mode 100644 index 00000000..cc39f68c --- /dev/null +++ b/tests/Security/Abuse/AbuseSubjectResolverTest.php @@ -0,0 +1,206 @@ + '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + + $resolution = $resolver->resolve($request); + $visitor = $resolution->first(AbuseSubjectType::Visitor); + $ipBucket = $resolution->first(AbuseSubjectType::IpBucket); + $encoded = json_encode($resolution->toArray(), JSON_THROW_ON_ERROR); + + self::assertNotNull($visitor); + self::assertSame($visitor, $resolution->primary()); + self::assertNotNull($ipBucket); + self::assertTrue($ipBucket->ipDerived()); + self::assertStringNotContainsString('203.0.113.10', $encoded); + self::assertStringNotContainsString('198.51.100.10', $encoded); + } + + public function testItKeepsIpBucketStableWhenCookieLessForwardingEntropyChanges(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $baseServer = [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Shared Browser/1.0', + ]; + $first = $resolver->resolve(Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ])); + $second = $resolver->resolve(Request::create('/docs', server: [ + ...$baseServer, + 'HTTP_X_FORWARDED_FOR' => '198.51.100.11, 203.0.113.10', + ])); + + self::assertNotSame( + $first->first(AbuseSubjectType::Visitor)?->identifier(), + $second->first(AbuseSubjectType::Visitor)?->identifier(), + ); + self::assertSame( + $first->first(AbuseSubjectType::IpBucket)?->identifier(), + $second->first(AbuseSubjectType::IpBucket)?->identifier(), + ); + } + + public function testItAddsAuthenticatedApiKeyAndUserSubjectsFromApiContext(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/api/v1/content', server: ['REMOTE_ADDR' => '203.0.113.10']); + $user = new UserAccount('99999999-0000-7000-8000-000000000101', 'owner', 'owner@example.test', 'hash', role: UserRole::Owner); + $apiKey = new ApiKey( + '99999999-0000-7000-8000-000000000201', + 'testkey', + str_repeat('a', 64), + 'encrypted', + $user, + ApiKeyStatus::ReadWrite, + ); + ApiRequestContext::fromApiKey($apiKey)->attachTo($request); + + $resolution = $resolver->resolve($request); + + self::assertSame($user->uid(), $resolution->first(AbuseSubjectType::User)?->identifier()); + self::assertSame($apiKey->uid(), $resolution->first(AbuseSubjectType::ApiKey)?->identifier()); + self::assertSame('testkey', $resolution->first(AbuseSubjectType::ApiKey)?->context()['prefix']); + } + + public function testItKeepsInvalidBearerTokensToSafePrefixSubjects(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/api/v1/content', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer publicPrefix.secret-token-material', + ]); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::ApiKeyPrefix); + + self::assertNotNull($subject); + self::assertSame('publicPrefix', $subject->identifier()); + self::assertStringNotContainsString('secret-token-material', json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsRedactedSchedulerCredentialSubjects(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $bearer = Request::create('/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer scheduler.secret-token-material', + ]); + $query = Request::create('/cron/run?auth=scheduler.secret-token-material'); + + $bearerSubject = $resolver->resolve($bearer)->first(AbuseSubjectType::SchedulerCredential); + $querySubject = $resolver->resolve($query)->first(AbuseSubjectType::SchedulerCredential); + + self::assertNotNull($bearerSubject); + self::assertNotNull($querySubject); + self::assertSame($bearerSubject->identifier(), $querySubject->identifier()); + self::assertStringNotContainsString('scheduler.secret-token-material', json_encode($bearerSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItDoesNotAddSchedulerCredentialSubjectsForLocalizedCronLookalikes(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/de/cron/run?auth=scheduler.secret-token-material'); + $request->attributes->set('_locale', 'de'); + + self::assertNull($resolver->resolve($request)->first(AbuseSubjectType::SchedulerCredential)); + } + + public function testItAddsRedactedSubmittedAccountSubjectsForAuthWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $login = Request::create('/user/login', 'POST', ['username' => 'Admin']); + $reset = Request::create('/user/reset-password', 'POST', ['email' => 'ADMIN@Example.TEST']); + + $loginSubject = $resolver->resolve($login)->first(AbuseSubjectType::SubmittedAccount); + $resetSubject = $resolver->resolve($reset)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($loginSubject); + self::assertNotNull($resetSubject); + self::assertSame('login', $loginSubject->context()['scope']); + self::assertSame('password_reset_email', $resetSubject->context()['scope']); + self::assertStringNotContainsString('Admin', json_encode($loginSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('ADMIN@Example.TEST', json_encode($resetSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsRedactedSubmittedTokenSubjectsForAccountTokenWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $invitationToken = str_repeat('a', 64); + $resetToken = str_repeat('b', 64); + $reviewToken = str_repeat('c', 64); + + $invitationSubject = $resolver->resolve(Request::create('/user/invitation/'.$invitationToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + $resetSubject = $resolver->resolve(Request::create('/user/reset-password/'.$resetToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + $reviewSubject = $resolver->resolve(Request::create('/user/security-review/'.$reviewToken, 'POST'))->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($invitationSubject); + self::assertNotNull($resetSubject); + self::assertNotNull($reviewSubject); + self::assertSame('registration_token', $invitationSubject->context()['scope']); + self::assertSame('password_reset_token', $resetSubject->context()['scope']); + self::assertSame('security_review_token', $reviewSubject->context()['scope']); + self::assertStringNotContainsString($invitationToken, json_encode($invitationSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString($resetToken, json_encode($resetSubject->toArray(), JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString($reviewToken, json_encode($reviewSubject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsSubmittedTokenSubjectsFromRouteAttributesForLocalizedAccountTokenWorkflows(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $token = str_repeat('d', 64); + $request = Request::create('/de/user/security-review/'.$token, 'POST'); + $request->attributes->set('_route', 'user_security_review'); + $request->attributes->set('_locale', 'de'); + $request->attributes->set('token', $token); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($subject); + self::assertSame('security_review_token', $subject->context()['scope']); + self::assertStringNotContainsString($token, json_encode($subject->toArray(), JSON_THROW_ON_ERROR)); + } + + public function testItAddsSubmittedAccountSubjectsFromLocalizedPathSegments(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/de/user/login', 'POST', ['username' => 'Admin']); + $request->attributes->set('_locale', 'de'); + + $subject = $resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount); + + self::assertNotNull($subject); + self::assertSame('login', $subject->context()['scope']); + self::assertNull($resolver->resolve(Request::create('/de/user/login', 'POST', ['username' => 'Admin']))->first(AbuseSubjectType::SubmittedAccount)); + } + + public function testItDoesNotAddSubmittedAccountSubjectsForLookalikePaths(): void + { + $resolver = new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'); + $request = Request::create('/user/login-extra', 'POST', ['username' => 'Admin']); + + self::assertNull($resolver->resolve($request)->first(AbuseSubjectType::SubmittedAccount)); + } +} diff --git a/tests/Security/Abuse/ActionCostCatalogueTest.php b/tests/Security/Abuse/ActionCostCatalogueTest.php new file mode 100644 index 00000000..73a49d5a --- /dev/null +++ b/tests/Security/Abuse/ActionCostCatalogueTest.php @@ -0,0 +1,74 @@ +costFor($classifier->classify(Request::create('/api/live/alerts'))); + $prefetch = $catalogue->costFor($classifier->classify(Request::create('/docs', server: [ + 'HTTP_SEC_PURPOSE' => 'prefetch', + ]))); + + self::assertSame('live_api', $live->bucketFamily()); + self::assertSame(0, $live->credits()); + self::assertFalse($live->ordinaryEnforcement()); + self::assertSame('website_prefetch', $prefetch->bucketFamily()); + self::assertFalse($prefetch->ordinaryEnforcement()); + } + + public function testItAssignsHigherSymbolicCostsToSuspiciousAndMutatingTraffic(): void + { + $classifier = new RequestIntentClassifier(); + $catalogue = new ActionCostCatalogue(); + + $probe = $catalogue->costFor($classifier->classify(Request::create('/.env'))); + $apiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/content/items', 'POST'))); + $adminApiWrite = $catalogue->costFor($classifier->classify(Request::create('/api/v1/admin/operations/cleanup', 'POST'))); + $schedulerTrigger = $catalogue->costFor($classifier->classify(Request::create('/cron/run'))); + $schedulerNotFound = $catalogue->costFor($classifier->classify(Request::create('/cron/not-found'))); + $setupWizard = $catalogue->costFor($classifier->classify(Request::create('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]))); + $setupApply = $catalogue->costFor($classifier->classify(Request::create('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))); + + self::assertSame('suspicious_probe', $probe->bucketFamily()); + self::assertSame(10, $probe->credits()); + self::assertSame('api_write', $apiWrite->bucketFamily()); + self::assertSame(5, $apiWrite->credits()); + self::assertSame('admin_mutation', $adminApiWrite->bucketFamily()); + self::assertSame(8, $adminApiWrite->credits()); + self::assertSame('scheduler', $schedulerTrigger->bucketFamily()); + self::assertSame('website', $schedulerNotFound->bucketFamily()); + self::assertSame('setup', $setupWizard->bucketFamily()); + self::assertSame(1, $setupWizard->credits()); + self::assertSame('setup_apply', $setupApply->bucketFamily()); + self::assertSame(8, $setupApply->credits()); + } + + public function testItExposesUniqueBucketFamilyCostsForPolicyBudgets(): void + { + $catalogue = new ActionCostCatalogue(); + $costs = $catalogue->uniqueCreditsByBucketFamily(); + + self::assertSame(5, $costs['registration']); + self::assertSame(3, $costs['password_reset']); + self::assertSame(5, $costs['api_write']); + self::assertSame(10, $costs['suspicious_probe']); + self::assertArrayNotHasKey('live_api', $costs); + self::assertArrayNotHasKey('api_preflight', $costs); + } +} diff --git a/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php new file mode 100644 index 00000000..909d6d2b --- /dev/null +++ b/tests/Security/Abuse/PassiveAbuseSignalSubscriberTest.php @@ -0,0 +1,233 @@ + 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $metadata = new AccessRequestMetadata(); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + $request = Request::create('/.env', server: [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_USER_AGENT' => 'Scanner/1.0', + 'HTTP_X_FORWARDED_FOR' => '198.51.100.10, 203.0.113.10', + ]); + $metadata->markStarted($request); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 400), + )); + + self::assertSame(2, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + + self::assertSame('probe', $row['signal_type']); + self::assertSame('security.signal.suspicious_probe', $row['reason_code']); + self::assertSame('visitor', $row['subject_type']); + self::assertSame($visitorIds->generate($request), $row['visitor_id']); + self::assertSame($row['visitor_id'], $row['subject_identifier']); + self::assertSame(1, (int) $connection->fetchOne("SELECT COUNT(*) FROM security_signal_event WHERE subject_type = 'ip_bucket'")); + self::assertIsString($context['ip_bucket'] ?? null); + self::assertStringNotContainsString('203.0.113.10', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('198.51.100.10', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + } + + public function testItDoesNotRecordOrdinaryNavigationSignals(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create('/docs'), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 200), + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItDoesNotRecordMissingWellKnownStaticPaths(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ); + + foreach (['/favicon.ico', '/apple-touch-icon.png', '/.well-known/security.txt'] as $path) { + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create($path, server: ['REMOTE_ADDR' => '203.0.113.10']), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 404), + )); + } + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItRecordsErrorStatusSignalsButExcludesLoginRequired401(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create('/missing', server: ['REMOTE_ADDR' => '203.0.113.10']), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 404), + )); + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']), + HttpKernelInterface::MAIN_REQUEST, + new Response('', 401), + )); + + self::assertSame(2, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame(2, (int) $connection->fetchOne("SELECT COUNT(*) FROM security_signal_event WHERE reason_code = 'security.signal.error_http_status'")); + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event WHERE http_status = 401')); + } + + public function testItDoesNotRecordAutoBanEnforcementResponses(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + new AccessRequestMetadata(), + ); + $request = Request::create('/missing', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 403), + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItSanitizesTokenizedPathsBeforeRecordingSignals(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $metadata = new AccessRequestMetadata(); + $subscriber = new PassiveAbuseSignalSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + $token = str_repeat('a', 64); + $request = Request::create('/user/reset-password/'.$token, 'POST', server: [ + 'HTTP_SEC_PURPOSE' => 'prefetch', + ]); + $request->attributes->set('_route', 'user_password_reset_token'); + $request->attributes->set('token', $token); + $metadata->markStarted($request); + + $subscriber->onKernelResponse(new ResponseEvent( + new PassiveAbuseSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('', 200), + )); + + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + self::assertSame('/user/reset-password/[redacted]', $row['path']); + self::assertStringNotContainsString($token, json_encode($row, JSON_THROW_ON_ERROR)); + } +} + +final class PassiveAbuseSignalTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} diff --git a/tests/Security/Abuse/RequestIntentClassifierTest.php b/tests/Security/Abuse/RequestIntentClassifierTest.php new file mode 100644 index 00000000..ebf5092d --- /dev/null +++ b/tests/Security/Abuse/RequestIntentClassifierTest.php @@ -0,0 +1,391 @@ + + */ + public static function requestCases(): iterable + { + yield 'live api cheap json' => [ + Request::create('/api/live/alerts'), + RequestFamily::LiveApi, + RequestIntent::LiveApi, + ]; + yield 'localized api-like content path is browser navigation' => [ + self::localizedRequest('/de/api/live/alerts', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'api write' => [ + Request::create('/api/v1/content/items', 'POST'), + RequestFamily::Api, + RequestIntent::ApiWrite, + ]; + yield 'admin api operation mutation is admin mutation' => [ + Request::create('/api/v1/admin/operations/cleanup', 'POST'), + RequestFamily::Api, + RequestIntent::AdminOperation, + ]; + yield 'admin api settings mutation is settings mutation' => [ + Request::create('/api/v1/admin/settings/security', 'PATCH'), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'admin api scheduler mutation is admin mutation' => [ + Request::create('/api/v1/admin/scheduler/system.live_operation_cleanup', 'PATCH'), + RequestFamily::Api, + RequestIntent::AdminOperation, + ]; + yield 'localized admin api-like content path is form submit' => [ + self::localizedRequest('/de/api/v1/admin/extensions/demo/reset-fault', 'POST', 'de'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'apiary public content is not api' => [ + Request::create('/apiary'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'administer public content is not admin' => [ + Request::create('/administer', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'localized admin is admin' => [ + self::localizedRequest('/de/admin/settings/security', 'POST', 'de'), + RequestFamily::Admin, + RequestIntent::SettingsMutation, + ]; + yield 'admin extension upload uses upload archive bucket before broad extension bucket' => [ + Request::create('/admin/extensions/upload', 'POST'), + RequestFamily::Admin, + RequestIntent::UploadArchiveValidation, + ]; + yield 'admin download uses download diagnostics bucket even for safe method' => [ + Request::create('/admin/logs/download'), + RequestFamily::Admin, + RequestIntent::ExportDownload, + ]; + yield 'public path containing reserved segment is public' => [ + Request::create('/docs/api/reference', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'future content download path is ordinary navigation' => [ + self::contentRequest('/download'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'future localized content export path is ordinary form submit' => [ + self::contentRequest('/de/export', 'POST', 'de'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'future public extension form post gets website form intent' => [ + self::contentRequest('/forum/thread/welcome', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'contact-like content slug is ordinary navigation' => [ + self::contentRequest('/contact-us'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'contact content slug is ordinary navigation' => [ + self::contentRequest('/contact'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'contact content post is ordinary form submit' => [ + self::contentRequest('/contact', 'POST'), + RequestFamily::Browser, + RequestIntent::FormSubmit, + ]; + yield 'captcha refresh content slug is ordinary navigation' => [ + self::contentRequest('/captcha/refresh'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'localized captcha refresh content slug is ordinary navigation' => [ + self::contentRequest('/de/captcha/refresh', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'admin path containing settings only as part of a segment is generic admin' => [ + Request::create('/admin/content/site-settings', 'POST'), + RequestFamily::Admin, + RequestIntent::AdminOperation, + ]; + yield 'cors preflight' => [ + Request::create('/api/v1/content/items', 'OPTIONS'), + RequestFamily::Api, + RequestIntent::CorsPreflight, + ]; + yield 'authorization options request is charged as api read' => [ + Request::create('/api/v1/content/items', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + ]), + RequestFamily::Api, + RequestIntent::ApiRead, + ]; + yield 'authorization options request honors requested unsafe api method' => [ + Request::create('/api/v1/content/items', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', + ]), + RequestFamily::Api, + RequestIntent::ApiWrite, + ]; + yield 'authorization options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Basic unrelated', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'malformed bearer options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer ', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'empty bearer options request honors requested unsafe admin method' => [ + Request::create('/api/v1/admin/settings/security', 'OPTIONS', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + ]), + RequestFamily::Api, + RequestIntent::SettingsMutation, + ]; + yield 'turbo prefetch' => [ + Request::create('/docs', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), + RequestFamily::Browser, + RequestIntent::TurboPrefetch, + ]; + yield 'recovery login bypass ignores spoofed prefetch' => [ + Request::create('/user/login?bypass=1', server: ['HTTP_SEC_PURPOSE' => 'prefetch']), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; + yield 'admin download ignores spoofed prefetch' => [ + Request::create('/admin/logs/download', server: ['HTTP_PURPOSE' => 'prefetch']), + RequestFamily::Admin, + RequestIntent::ExportDownload, + ]; + yield 'scheduler trigger' => [ + Request::create('/cron/run'), + RequestFamily::Scheduler, + RequestIntent::SchedulerTrigger, + ]; + yield 'localized cron-like content path is browser navigation' => [ + self::localizedRequest('/de/cron/run', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'scheduler reserved non-run path is ordinary navigation' => [ + Request::create('/cron/not-found'), + RequestFamily::Scheduler, + RequestIntent::BrowserNavigation, + ]; + yield 'scheduler trigger requires exact path' => [ + Request::create('/cron/run/extra'), + RequestFamily::Scheduler, + RequestIntent::BrowserNavigation, + ]; + yield 'setup wizard post is setup navigation' => [ + Request::create('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]), + RequestFamily::Setup, + RequestIntent::BrowserNavigation, + ]; + yield 'setup apply' => [ + Request::create('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]), + RequestFamily::Setup, + RequestIntent::SetupApply, + ]; + yield 'setup apply requires exact review path' => [ + Request::create('/setup/review/extra', 'POST', [ + '_setup_action' => 'apply', + ]), + RequestFamily::Setup, + RequestIntent::BrowserNavigation, + ]; + yield 'settings mutation' => [ + Request::create('/admin/settings/security', 'POST'), + RequestFamily::Admin, + RequestIntent::SettingsMutation, + ]; + yield 'admin user password reset is acl mutation' => [ + Request::create('/admin/users/details/example-user/password-reset', 'POST'), + RequestFamily::Admin, + RequestIntent::UserAclMutation, + ]; + yield 'admin extension reset fault is extension mutation' => [ + Request::create('/admin/extensions/demo/reset-fault', 'POST'), + RequestFamily::Admin, + RequestIntent::ExtensionAdminOperation, + ]; + yield 'admin operation post is generic admin mutation' => [ + Request::create('/admin/operations', 'POST'), + RequestFamily::Admin, + RequestIntent::AdminOperation, + ]; + yield 'login form render is ordinary navigation' => [ + Request::create('/user/login'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'recovery login bypass uses recovery intent' => [ + Request::create('/user/login?bypass=1'), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; + yield 'localized recovery login bypass uses recovery intent' => [ + self::localizedRequest('/de/user/login?bypass=1', 'GET', 'de'), + RequestFamily::Browser, + RequestIntent::RecoveryLogin, + ]; + yield 'recovery login bypass post stays login intent' => [ + Request::create('/user/login?bypass=1', 'POST'), + RequestFamily::Browser, + RequestIntent::Login, + ]; + yield 'registration form render is ordinary navigation' => [ + Request::create('/user/register'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'password reset form render is ordinary navigation' => [ + Request::create('/user/reset-password'), + RequestFamily::Browser, + RequestIntent::BrowserNavigation, + ]; + yield 'public login post is login intent' => [ + Request::create('/user/login', 'POST'), + RequestFamily::Browser, + RequestIntent::Login, + ]; + yield 'public password reset stays public reset intent' => [ + Request::create('/user/password-reset', 'POST'), + RequestFamily::Browser, + RequestIntent::PasswordReset, + ]; + $tokenReset = Request::create('/user/reset-password/'.str_repeat('a', 64), 'POST'); + $tokenReset->attributes->set('_route', 'user_password_reset_token'); + yield 'public reset token route is password reset' => [ + $tokenReset, + RequestFamily::Browser, + RequestIntent::PasswordReset, + ]; + $invitation = Request::create('/user/invitation/'.str_repeat('b', 64), 'POST'); + $invitation->attributes->set('_route', 'user_invitation_accept'); + yield 'public invitation token route is registration' => [ + $invitation, + RequestFamily::Browser, + RequestIntent::Registration, + ]; + yield 'suspicious env probe' => [ + Request::create('/.env'), + RequestFamily::Browser, + RequestIntent::SuspiciousProbe, + ]; + } + + #[DataProvider('requestCases')] + public function testItClassifiesRequestIntent(Request $request, RequestFamily $family, RequestIntent $intent): void + { + $profile = (new RequestIntentClassifier(routeLocalization: $this->routeLocalization()))->classify($request); + + self::assertSame($family, $profile->family()); + self::assertSame($intent, $profile->intent()); + } + + public function testItClassifiesOrdinaryUploadRoutesAsUploadArchiveValidation(): void + { + $profile = (new RequestIntentClassifier())->classify(Request::create('/admin/extensions/upload', 'POST')); + + self::assertSame(RequestIntent::UploadArchiveValidation, $profile->intent()); + self::assertFalse($profile->suspiciousProbe()); + } + + public function testItDoesNotStripLanguageSlugsWhenRoutePrefixesAreDisabled(): void + { + $classifier = new RequestIntentClassifier(routeLocalization: $this->disabledRouteLocalization()); + $profile = $classifier->classify(self::contentRequest('/de/admin', 'POST')); + $apiProfile = $classifier->classify(self::contentRequest('/de/api/v1/content/items', 'POST')); + + self::assertSame(RequestFamily::Browser, $profile->family()); + self::assertSame(RequestIntent::FormSubmit, $profile->intent()); + self::assertSame(RequestFamily::Browser, $apiProfile->family()); + self::assertSame(RequestIntent::FormSubmit, $apiProfile->intent()); + } + + private function routeLocalization(): ContentRouteLocalization + { + $config = new Config($this->connection()); + $config->set(ContentRouteLocalization::ENABLED_KEY, true, ConfigValueType::Boolean); + + return new ContentRouteLocalization($config, $this->languageCatalog()); + } + + private function disabledRouteLocalization(): ContentRouteLocalization + { + return new ContentRouteLocalization(new Config($this->connection()), $this->languageCatalog()); + } + + private function languageCatalog(): TranslationLanguageCatalog + { + return new TranslationLanguageCatalog(dirname(__DIR__, 3)); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } + + private static function contentRequest(string $path, string $method = 'GET', ?string $locale = null): Request + { + $request = Request::create($path, $method); + $request->attributes->set('_route', 'content_show'); + if (null !== $locale) { + $request->attributes->set('_locale', $locale); + } + + return $request; + } + + private static function localizedRequest(string $path, string $method, string $locale): Request + { + $request = Request::create($path, $method); + $request->attributes->set('_locale', $locale); + + return $request; + } +} diff --git a/tests/Security/Abuse/SecuritySignalRecorderTest.php b/tests/Security/Abuse/SecuritySignalRecorderTest.php new file mode 100644 index 00000000..03fba595 --- /dev/null +++ b/tests/Security/Abuse/SecuritySignalRecorderTest.php @@ -0,0 +1,141 @@ +setEnvironment(SetupCompletionMarker::KEY, '0'); + $allowState = $this->setEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, '0'); + $connection = $this->createMock(Connection::class); + $connection->expects(self::never())->method('insert'); + $connection->expects(self::never())->method('fetchOne'); + $connection->expects(self::never())->method('executeStatement'); + + try { + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-system-project', 'test'), + ); + + self::assertFalse($recorder->record('probe', 'security.probe.setup', 'visitor', 'visitor-id')); + } finally { + $this->restoreEnvironment(SetupCompletionMarker::KEY, $setupState); + $this->restoreEnvironment(DatabaseReadyState::ALLOW_UNREADY_KEY, $allowState); + } + } + + public function testItRecordsSignalsWithSharedRetentionAndPurgesExpiredRows(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->insert('config_entry', [ + 'config_key' => DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY, + 'value' => '7', + 'value_type' => 'integer', + 'sensitive' => 0, + 'modified_at' => '2026-06-16 00:00:00', + 'modified_by' => 'test', + ]); + $connection->insert('security_signal_event', [ + 'uid' => '99999999-0000-7000-8000-000000000001', + 'occurred_at' => '2000-01-01 00:00:00', + 'expires_at' => '2000-01-02 00:00:00', + 'signal_type' => 'probe', + 'reason_code' => 'security.probe.old', + 'severity' => 'WARNING', + 'confidence' => 90, + 'subject_type' => 'ip_hash', + 'subject_identifier' => 'old', + 'ip_derived' => 1, + 'request_family' => 'browser', + 'request_intent' => 'suspicious_probe', + 'request_id' => 'old', + 'visitor_id' => 'n/a', + 'path' => '/.env', + 'route' => 'n/a', + 'http_status' => 400, + 'context' => '{}', + ]); + + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: new MockClock('2026-06-16 12:00:00'), + ); + self::assertTrue($recorder->record( + 'probe', + 'security.probe.env', + 'ip_hash', + 'hash-value', + ipDerived: true, + severity: 'warning', + confidence: 140, + requestFamily: 'browser', + requestIntent: 'suspicious_probe', + path: '/.env', + httpStatus: 400, + )); + + self::assertSame(1, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame('security.probe.env', $connection->fetchOne('SELECT reason_code FROM security_signal_event')); + self::assertSame(100, (int) $connection->fetchOne('SELECT confidence FROM security_signal_event')); + self::assertSame(1, (int) $connection->fetchOne('SELECT ip_derived FROM security_signal_event')); + self::assertSame('2026-06-16 12:00:00', $connection->fetchOne('SELECT occurred_at FROM security_signal_event')); + self::assertSame('2026-06-23 12:00:00', $connection->fetchOne('SELECT expires_at FROM security_signal_event')); + } + + /** + * @return array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} + */ + private function setEnvironment(string $key, string $value): array + { + $state = [ + 'server_exists' => array_key_exists($key, $_SERVER), + 'server_value' => $_SERVER[$key] ?? null, + 'env_exists' => array_key_exists($key, $_ENV), + 'env_value' => $_ENV[$key] ?? null, + 'getenv_value' => getenv($key), + ]; + + $_SERVER[$key] = $value; + $_ENV[$key] = $value; + putenv($key.'='.$value); + + return $state; + } + + /** + * @param array{server_exists: bool, server_value: mixed, env_exists: bool, env_value: mixed, getenv_value: string|false} $state + */ + private function restoreEnvironment(string $key, array $state): void + { + if ($state['server_exists']) { + $_SERVER[$key] = $state['server_value']; + } else { + unset($_SERVER[$key]); + } + + if ($state['env_exists']) { + $_ENV[$key] = $state['env_value']; + } else { + unset($_ENV[$key]); + } + + false === $state['getenv_value'] ? putenv($key) : putenv($key.'='.$state['getenv_value']); + } +} diff --git a/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php new file mode 100644 index 00000000..940f6d8b --- /dev/null +++ b/tests/Security/Abuse/SuspiciousPayloadSignalSubscriberTest.php @@ -0,0 +1,257 @@ +connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $metadata = new AccessRequestMetadata(); + $request = Request::create('/search', 'GET', [ + 'q' => "x' UNION SELECT password FROM users --", + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $metadata->markStarted($request); + + $this->subscriber($connection, $visitorIds, $metadata)->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(2, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + $row = $connection->fetchAssociative("SELECT * FROM security_signal_event WHERE subject_type = 'visitor'"); + self::assertIsArray($row); + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + + self::assertSame('payload_probe', $row['signal_type']); + self::assertSame('security.signal.suspicious_payload', $row['reason_code']); + self::assertSame($visitorIds->generate($request), $row['visitor_id']); + self::assertContains('sql_union_select', $context['payload_signatures']); + self::assertSame('q', $context['payload_parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + } + + public function testItRecordsMalformedSecurityParameterSignals(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $request = Request::create('/user/login', 'POST', [ + 'username' => ['owner'], + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $request->attributes->set('_route', 'user_login'); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + $row = $connection->fetchAssociative('SELECT * FROM security_signal_event'); + self::assertIsArray($row); + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + self::assertContains('malformed_parameter', $context['payload_signatures']); + self::assertSame('username', $context['payload_parameters'][0]['name']); + } + + public function testItRecordsJsonApiPayloadSignals(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $content = json_encode([ + 'filter' => [ + 'query' => "x' UNION SELECT password FROM users --", + ], + ], JSON_THROW_ON_ERROR); + $request = Request::create( + '/api/v1/search', + 'POST', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => (string) strlen($content), + 'REMOTE_ADDR' => '203.0.113.10', + ], + content: $content, + ); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(2, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + $row = $connection->fetchAssociative("SELECT * FROM security_signal_event WHERE subject_type = 'visitor'"); + self::assertIsArray($row); + $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR); + self::assertContains('sql_union_select', $context['payload_signatures']); + self::assertSame('json', $context['payload_parameters'][0]['source']); + self::assertSame('filter.query', $context['payload_parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode([$row, $context], JSON_THROW_ON_ERROR)); + } + + public function testItSkipsAutoBanEnforcementRequests(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $request = Request::create('/search', 'GET', [ + 'q' => "x' UNION SELECT password FROM users --", + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItSkipsAdminEditorPayloadsThatMayContainCustomCode(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $request = Request::create('/admin/content/schemas', 'POST', [ + 'custom_twig' => '', + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata(), $this->tokenStorageWithManager())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItSkipsAdminJsonPayloadsThatMayContainCustomCode(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $content = json_encode([ + 'custom_twig' => '', + ], JSON_THROW_ON_ERROR); + $request = Request::create( + '/admin/content/schemas', + 'POST', + server: [ + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => (string) strlen($content), + 'REMOTE_ADDR' => '203.0.113.10', + ], + content: $content, + ); + + $this->subscriber($connection, $visitorIds, new AccessRequestMetadata(), $this->tokenStorageWithManager())->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + + self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + } + + public function testItScoresUnauthenticatedApplicationSurfacePayloadProbes(): void + { + $connection = $this->connection(); + $visitorIds = new VisitorIdGenerator('test-secret'); + $subscriber = $this->subscriber($connection, $visitorIds, new AccessRequestMetadata()); + + foreach (['/admin', '/editor', '/setup'] as $path) { + $request = Request::create($path, 'GET', [ + 'file' => '../../etc/passwd', + ], server: [ + 'REMOTE_ADDR' => '203.0.113.10', + ]); + + $subscriber->onKernelRequest(new RequestEvent( + new SuspiciousPayloadSignalTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + )); + } + + self::assertSame(6, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event')); + self::assertSame(6, (int) $connection->fetchOne("SELECT COUNT(*) FROM security_signal_event WHERE reason_code = 'security.signal.suspicious_payload'")); + } + + private function subscriber( + Connection $connection, + VisitorIdGenerator $visitorIds, + AccessRequestMetadata $metadata, + ?TokenStorage $tokenStorage = null, + ): SuspiciousPayloadSignalSubscriber + { + return new SuspiciousPayloadSignalSubscriber( + new SuspiciousRequestPayloadMatcher(), + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, $tokenStorage ?? new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)), + $metadata, + ); + } + + private function tokenStorageWithManager(): TokenStorage + { + $user = new UserAccount('99999999-0000-7000-8000-000000000201', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $storage = new TokenStorage(); + $storage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + + return $storage; + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + + return $connection; + } +} + +final class SuspiciousPayloadSignalTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} diff --git a/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php new file mode 100644 index 00000000..5a77c555 --- /dev/null +++ b/tests/Security/Abuse/SuspiciousProbePathMatcherTest.php @@ -0,0 +1,99 @@ +isProbe('/.env')); + self::assertTrue($matcher->isProbe('/wp-login.php')); + self::assertTrue($matcher->isProbe('/backup-2026.sql')); + self::assertTrue($matcher->isProbe('/package-lock.json')); + self::assertFalse($matcher->isProbe('/admin/extensions/upload')); + self::assertFalse($matcher->isProbe('/extension-lock.json')); + } + + public function testItUsesConfiguredPatternsWhenProvided(): void + { + $matcher = new SuspiciousProbePathMatcher(patterns: [ + '#/custom-probe(?:/|$)#i', + ]); + + self::assertTrue($matcher->isProbe('/custom-probe')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItFallsBackToDefaultsWhenConfiguredPatternsAreInvalid(): void + { + $matcher = new SuspiciousProbePathMatcher(patterns: [ + '#unterminated', + '', + ]); + + self::assertTrue($matcher->isProbe('/.git/config')); + } + + public function testItParsesConfiguredPatternTextAsNewlineAndCsvValues(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, "#/custom-one(?:/|$)#i\n\"#/custom-two(?:/|$)#i\",\"#/custom-three(?:/|$)#i\"", ConfigValueType::String); + $matcher = new SuspiciousProbePathMatcher($config); + + self::assertTrue($matcher->isProbe('/custom-one')); + self::assertTrue($matcher->isProbe('/custom-two')); + self::assertTrue($matcher->isProbe('/custom-three')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItPreservesCommasInsideOneLineRegexPatterns(): void + { + $config = new Config($this->connection()); + $config->set(SuspiciousProbePathMatcher::PATTERNS_KEY, '#/dump-[0-9]{4,6}\.sql$#', ConfigValueType::String); + $matcher = new SuspiciousProbePathMatcher($config); + + self::assertTrue($matcher->isProbe('/dump-2026.sql')); + self::assertFalse($matcher->isProbe('/.env')); + } + + public function testItCachesConfiguredPatternsForTheServiceLifetime(): void + { + $connection = $this->createMock(Connection::class); + $cache = new ArrayAdapter(); + $connection + ->expects(self::once()) + ->method('fetchOne') + ->with('SELECT value FROM config_entry WHERE config_key = ?', [SuspiciousProbePathMatcher::PATTERNS_KEY]) + ->willReturn(json_encode('#/cached-probe$#', JSON_THROW_ON_ERROR)); + + self::assertTrue((new SuspiciousProbePathMatcher(new Config($connection), cache: $cache))->isProbe('/cached-probe')); + self::assertTrue((new SuspiciousProbePathMatcher(new Config($connection), cache: $cache))->isProbe('/cached-probe')); + } + + public function testDefaultPatternTextContainsOneEditablePatternPerLine(): void + { + $lines = array_filter(explode("\n", SuspiciousProbePathMatcher::defaultPatternText())); + + self::assertSame(SuspiciousProbePathMatcher::DEFAULT_PATTERNS, array_values($lines)); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} diff --git a/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php new file mode 100644 index 00000000..36ddf895 --- /dev/null +++ b/tests/Security/Abuse/SuspiciousRequestPayloadMatcherTest.php @@ -0,0 +1,96 @@ +match(Request::create('/search', 'GET', [ + 'q' => "x' UNION SELECT password FROM users --", + 'file' => '../../etc/passwd', + ])); + + self::assertIsArray($match); + self::assertContains('sql_union_select', $match['signatures']); + self::assertContains('sensitive_file_probe', $match['signatures']); + self::assertSame('q', $match['parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode($match, JSON_THROW_ON_ERROR)); + self::assertStringNotContainsString('/etc/passwd', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItDetectsMalformedSecurityParametersButAllowsOrdinaryArrays(): void + { + $matcher = new SuspiciousRequestPayloadMatcher(); + + $malformed = $matcher->match(Request::create('/user/login', 'POST', [ + 'username' => ['owner'], + ])); + $ordinary = $matcher->match(Request::create('/search', 'GET', [ + 'tags' => ['one', 'two'], + ])); + + self::assertIsArray($malformed); + self::assertContains('malformed_parameter', $malformed['signatures']); + self::assertSame('username', $malformed['parameters'][0]['name']); + self::assertNull($ordinary); + } + + public function testItDetectsJsonBodyAttackSignaturesWithoutReturningRawPayloads(): void + { + $content = json_encode([ + 'filter' => [ + 'query' => "x' UNION SELECT password FROM users --", + ], + ], JSON_THROW_ON_ERROR); + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, + )); + + self::assertIsArray($match); + self::assertContains('sql_union_select', $match['signatures']); + self::assertSame('json', $match['parameters'][0]['source']); + self::assertSame('filter.query', $match['parameters'][0]['name']); + self::assertStringNotContainsString('UNION SELECT', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItDetectsJsonLikeRawBodyAttackSignatures(): void + { + $content = '{"query":"../../etc/passwd"'; + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, + )); + + self::assertIsArray($match); + self::assertContains('sensitive_file_probe', $match['signatures']); + self::assertSame('raw_body', $match['parameters'][0]['source']); + self::assertSame('body', $match['parameters'][0]['name']); + self::assertStringNotContainsString('/etc/passwd', json_encode($match, JSON_THROW_ON_ERROR)); + } + + public function testItSkipsOversizedJsonBodiesBeforePayloadScanning(): void + { + $content = '{"query":"../../etc/passwd","padding":"'.str_repeat('x', 9000).'"}'; + + $match = (new SuspiciousRequestPayloadMatcher())->match(Request::create( + '/api/v1/search', + 'POST', + server: ['CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => (string) strlen($content)], + content: $content, + )); + + self::assertNull($match); + } +} diff --git a/tests/Security/AutoBan/AutoBanAdminBrowserTest.php b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php new file mode 100644 index 00000000..d83ea9b7 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanAdminBrowserTest.php @@ -0,0 +1,144 @@ +connection(); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-detail-history'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + for ($i = 0; $i < 105; ++$i) { + $this->insertSignal($connection, $subject, sprintf('old-%03d', $i), sprintf('2026-06-18 11:%02d:%02d', intdiv($i, 60), $i % 60)); + } + $this->insertSignal($connection, $subject, 'reset', '2026-06-18 12:00:00', AutoBanScoreCatalogue::SIGNAL_RESET); + $this->insertSignal($connection, $subject, 'new-001', '2026-06-18 12:01:00'); + $this->insertSignal($connection, $subject, 'new-002', '2026-06-18 12:02:00'); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertCount(100, $detail['signals']); + self::assertSame('new-002', $detail['signals'][0]['uid']); + self::assertSame('new-001', $detail['signals'][1]['uid']); + self::assertContains('old-104', array_column($detail['signals'], 'uid')); + } + + public function testDetailShowsGeoFromLatestBanTriggerRequest(): void + { + $connection = $this->connection(); + $this->createAccessLogTable($connection); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::IP, 'ip-bucket-detail-geo', true); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + $this->insertSignal($connection, $subject, 'trigger-old', '2026-06-18 12:01:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + $this->insertSignal($connection, $subject, 'trigger-new', '2026-06-18 12:02:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + $connection->insert('access_log_entry', [ + 'uid' => 'access-old', + 'occurred_at' => '2026-06-18 12:01:00', + 'request_id' => 'request-trigger-old', + 'country' => 'DE', + 'continent' => 'EU', + ]); + $connection->insert('access_log_entry', [ + 'uid' => 'access-new', + 'occurred_at' => '2026-06-18 12:02:00', + 'request_id' => 'request-trigger-new', + 'country' => 'NL', + 'continent' => 'EU', + ]); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertSame([ + 'request_id' => 'request-trigger-new', + 'country' => 'NL', + 'continent' => 'EU', + ], $detail['trigger_geo']); + } + + public function testDetailUsesSafeGeoPlaceholdersWhenAccessLogIsUnavailable(): void + { + $connection = $this->connection(); + $clock = new MockClock('2026-06-18 13:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-detail-no-geo'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + $this->insertSignal($connection, $subject, 'trigger-new', '2026-06-18 12:02:00', AutoBanScoreCatalogue::SIGNAL_TRIGGERED); + + $detail = (new AutoBanAdminBrowser($store, $connection, clock: $clock))->detail($ban->key()); + self::assertNotNull($detail); + + self::assertSame([ + 'request_id' => 'n/a', + 'country' => 'n/a', + 'continent' => 'n/a', + ], $detail['trigger_geo']); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + + return $connection; + } + + private function createAccessLogTable(Connection $connection): void + { + $connection->executeStatement('CREATE TABLE access_log_entry (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, request_id VARCHAR(64) NOT NULL, country VARCHAR(80) NOT NULL, continent VARCHAR(80) NOT NULL)'); + } + + private function insertSignal( + Connection $connection, + AutoBanSubject $subject, + string $uid, + string $occurredAt, + string $reasonCode = AutoBanScoreCatalogue::SIGNAL_ERROR_HIT, + ): void { + $connection->insert('security_signal_event', [ + 'uid' => $uid, + 'occurred_at' => $occurredAt, + 'expires_at' => '2026-06-25 00:00:00', + 'signal_type' => 'http_error', + 'reason_code' => $reasonCode, + 'severity' => 'NOTICE', + 'confidence' => 40, + 'subject_type' => $subject->type(), + 'subject_identifier' => $subject->identifier(), + 'ip_derived' => 0, + 'request_family' => 'frontend', + 'request_intent' => 'navigation', + 'request_id' => 'request-'.$uid, + 'visitor_id' => $subject->identifier(), + 'path' => '/missing', + 'route' => 'n/a', + 'http_status' => 404, + 'context' => '{}', + ]); + } +} diff --git a/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php new file mode 100644 index 00000000..c0761753 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanOwnerAlertNotifierTest.php @@ -0,0 +1,172 @@ +connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $connection->insert('user_account', [ + 'uid' => 'admin-uid', + 'role' => UserRole::Admin->value, + 'status' => UserAccountStatus::Active->value, + ]); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy(new Config($connection)), $connection, $alerts); + $ban = $this->ban(); + + $notifier->notifyBanTriggered($ban); + + self::assertCount(1, $alerts->userAlerts); + self::assertSame('owner-uid', $alerts->userAlerts[0]['user']); + self::assertSame(UiAlertDelivery::Queue, $alerts->userAlerts[0]['delivery']); + self::assertInstanceOf(UiAlertTranslation::class, $alerts->userAlerts[0]['alert']); + self::assertSame('admin.auto_bans.alerts.triggered', $alerts->userAlerts[0]['alert']->translationKey()); + self::assertSame('hidden', $alerts->userAlerts[0]['presentation']?->mode()); + self::assertStringStartsWith('auto-ban-triggered-'.$ban->key().'-', (string) $alerts->userAlerts[0]['presentation']?->id()); + self::assertSame([ + ['label' => 'Review', 'href' => '/admin/security/auto-bans'], + ], $alerts->userAlerts[0]['presentation']?->actions()); + } + + public function testRepeatBanAlertsUseDifferentPresentationIds(): void + { + $connection = $this->connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy(new Config($connection)), $connection, $alerts); + $ban = $this->ban(); + + $notifier->notifyBanTriggered($ban); + $notifier->notifyBanTriggered($ban); + + self::assertCount(2, $alerts->userAlerts); + self::assertNotSame( + $alerts->userAlerts[0]['presentation']?->id(), + $alerts->userAlerts[1]['presentation']?->id(), + ); + } + + public function testItSkipsOwnerAlertsWhenDeliveryIsDisabled(): void + { + $connection = $this->connection(); + $connection->insert('user_account', [ + 'uid' => 'owner-uid', + 'role' => UserRole::Owner->value, + 'status' => UserAccountStatus::Active->value, + ]); + $config = new Config($connection); + $config->set(AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY, false, ConfigValueType::Boolean); + $alerts = new RecordingAutoBanAlertDispatcher(); + $notifier = new AutoBanOwnerAlertNotifier(new AutoBanPolicy($config), $connection, $alerts); + + $notifier->notifyBanTriggered($this->ban()); + + self::assertSame([], $alerts->userAlerts); + } + + private function ban(): ActiveAutoBan + { + return new ActiveAutoBan( + 'abc123abc123abc123abc123abc123abc123abcd', + 'visitor', + 'visitor-alert', + DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2026-06-18 12:00:00'), + DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', '2026-06-18 13:00:00'), + 3600, + ); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE user_account (uid VARCHAR(36) PRIMARY KEY NOT NULL, role VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL)'); + + return $connection; + } +} + +final class RecordingAutoBanAlertDispatcher implements UiAlertDispatcherInterface +{ + /** + * @var list + */ + public array $userAlerts = []; + + public function addAlert( + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Direct, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } + + public function addAlertToTopic( + string $topic, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } + + public function addAlertToUser( + UserAccount|UserInterface|string $user, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + $this->userAlerts[] = [ + 'user' => is_string($user) ? $user : $user->getUserIdentifier(), + 'alert' => $alert, + 'delivery' => $delivery, + 'presentation' => $presentation, + ]; + + return true; + } + + public function addAlertToSession( + SessionInterface|string $session, + UiAlert|Message|UiAlertTranslation $alert, + UiAlertDelivery|UiAlertDeliveryOptions $delivery = UiAlertDelivery::Queue, + ?UiAlertPresentation $presentation = null, + ): bool { + return true; + } +} diff --git a/tests/Security/AutoBan/AutoBanPolicyTest.php b/tests/Security/AutoBan/AutoBanPolicyTest.php new file mode 100644 index 00000000..7be39829 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanPolicyTest.php @@ -0,0 +1,77 @@ + 'pdo_sqlite', 'memory' => true]), + databaseReadyState: new DatabaseReadyState(new SetupCompletionMarker(), sys_get_temp_dir().'/missing-auto-ban-config', 'test'), + defaultProvider: new CoreConfigDefaultProvider(new CoreSettingsRegistry( + new TranslationLanguageCatalog($projectDir), + new SystemExtensionMetadataProvider($projectDir), + )), + )); + + self::assertFalse($policy->enabled()); + } + + public function testItUsesPersistedSetupEnabledValueWhenConfigStorageIsAvailable(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + + self::assertTrue((new AutoBanPolicy($config))->enabled()); + } + + public function testItBoundsPersistedScoreThreshold(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::SCORE_THRESHOLD_KEY, 1, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MIN_SCORE_THRESHOLD, (new AutoBanPolicy($config))->visitorThreshold()); + + $config->set(AutoBanPolicy::SCORE_THRESHOLD_KEY, 10001, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MAX_SCORE_THRESHOLD, (new AutoBanPolicy($config))->visitorThreshold()); + } + + public function testItBoundsPersistedTrustedAccessLevelToRegisteredUsers(): void + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 0, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MIN_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 10, ConfigValueType::Integer); + + self::assertSame(AutoBanPolicy::MAX_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + + $config->set(AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY, 'invalid', ConfigValueType::String); + + self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, (new AutoBanPolicy($config))->trustedAccessLevel()); + } +} diff --git a/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php new file mode 100644 index 00000000..40950646 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanRequestSubscriberTest.php @@ -0,0 +1,641 @@ + '203.0.113.10']); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-ban'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + $response = $event->getResponse(); + self::assertInstanceOf(Response::class, $response); + self::assertSame(403, $response->getStatusCode()); + self::assertSame('3600', $response->headers->get('Retry-After')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + self::assertStringContainsString('Request blocked due to suspicious activity.', (string) $response->getContent()); + self::assertStringContainsString('request-ban', (string) $response->getContent()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + self::assertTrue($request->attributes->getBoolean(AccessRequestMetadata::FORCE_ACCESS_LOG_ATTRIBUTE)); + } + + public function testActiveVisitorBanOverridesEarlierProbeResponseAndSkipsPassiveSignals(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/.env', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + $event->setResponse(new Response('', 400)); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertSame('3600', $event->getResponse()?->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testActiveVisitorBanMarksProbeRequestsBeforeRateLimitConsumption(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/.env', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestProbeCandidate($event); + + self::assertFalse($event->hasResponse()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE)); + } + + public function testIgnorablePathsDoNotBypassActiveBansWhenTheyReachSymfony(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/favicon.ico', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testApiRequestsWithoutTrustedBearerDoNotAuthenticateThroughActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/v1/status', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid.invalid-secret', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertSame('3600', $event->getResponse()?->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testApiPreflightsDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer valid-owner-key', + 'HTTP_ORIGIN' => 'https://client.example', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSchedulerTriggersWithoutTrustedKeyDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/cron/run', server: [ + 'HTTP_AUTHORIZATION' => 'Bearer invalid-scheduler-key', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testProtectedBrowserSurfacesWithoutPreviousSessionAreBlockedBeforeFirewallAccessControl(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + + foreach (['/admin', '/editor/content', '/user/profile'] as $path) { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create($path, server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthBrowserSourceBan($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode(), $path); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE), $path); + } + } + + public function testProtectedBrowserSurfacesWithPreviousSessionWaitForTrustedAwareGuard(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + $request->cookies->set($session->getName(), 'previous-session-id'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestPreAuthBrowserSourceBan($event); + + self::assertFalse($event->hasResponse()); + self::assertFalse($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSecurityHandlerBlocksPreviousSessionNonTrustedBrowserAccess(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $session = new Session(new MockArraySessionStorage()); + $request->setSession($session); + $request->cookies->set($session->getName(), 'previous-session-id'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000103', 'member', 'member@example.test', 'hash', role: UserRole::User); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $subscriber = $this->subscriber($visitorIds, $store, $clock, $tokenStorage); + $handler = new HttpErrorSecurityHandler($this->renderer(), $subscriber); + + $response = $handler->handle($request, new AccessDeniedException('Access denied.')); + + self::assertSame(403, $response->getStatusCode()); + self::assertSame('3600', $response->headers->get('Retry-After')); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testSecurityHandlerKeepsTrustedBrowserAccessOutsideAutoBanEnforcement(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000104', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $subscriber = $this->subscriber($visitorIds, $store, $clock, $tokenStorage); + + self::assertNull($subscriber->responseForSecurityHandler($request)); + } + + public function testErrorResponsesAreOverriddenBeforePassiveSignalScoring(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + + foreach ([400, 401, 403, 404, 429] as $statusCode) { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/member-only-page', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new ResponseEvent( + new AutoBanRequestTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + new Response('error response', $statusCode), + ); + + $this->subscriber($visitorIds, $store, $clock)->onKernelResponseErrorStatus($event); + + self::assertSame(403, $event->getResponse()->getStatusCode(), (string) $statusCode); + self::assertSame('3600', $event->getResponse()->headers->get('Retry-After'), (string) $statusCode); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE), (string) $statusCode); + } + } + + public function testRecoveryLoginBypassIsReachableDespiteActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/de/user/login?bypass=1', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_locale', 'de'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testEarlyLoginGuardBlocksOrdinaryLoginSubmissionsBeforeAuthentication(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', ['username' => 'owner'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequestLogin($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testEarlyLoginGuardKeepsMarkedRecoverySubmissionsReachable(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens)->onKernelRequestLogin($event); + + self::assertFalse($event->hasResponse()); + } + + public function testRecoveryLoginFailuresAfterActiveBanReturnBareForbiddenWithoutSideEffects(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-recovery-failure-ban'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + $subscriber = $this->subscriber($visitorIds, $store, $clock, csrfTokens: $csrfTokens); + $requestEvent = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequestLogin($requestEvent); + + self::assertFalse($requestEvent->hasResponse()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + self::assertSame($ban->key(), $request->attributes->get(AutoBanRequestSubscriber::RECOVERY_ACTIVE_BAN_KEY_ATTRIBUTE)); + + $failure = new LoginFailureEvent( + new AuthenticationException('Invalid credentials.'), + new AutoBanRequestTestAuthenticator(), + $request, + null, + 'main', + ); + $subscriber->onLoginFailure($failure); + + self::assertSame(403, $failure->getResponse()?->getStatusCode()); + self::assertSame('3600', $failure->getResponse()?->headers->get('Retry-After')); + self::assertStringContainsString('request-recovery-failure-ban', (string) $failure->getResponse()?->getContent()); + } + + public function testRecoveryLoginSubmissionsCanEstablishTrustedContextDespiteActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'owner', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000101', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage, $csrfTokens)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testRecoveryLoginSubmissionsWithoutTrustedContextAreRecheckedAfterAuthentication(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $csrfTokens = new CsrfTokenManager(); + $request = Request::create('/user/login', 'POST', [ + 'username' => 'member', + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => (string) $csrfTokens->getToken(AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_ID), + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000102', 'member', 'member@example.test', 'hash', role: UserRole::User); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage, $csrfTokens)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testOrdinaryLoginSubmissionsDoNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', ['username' => 'owner'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + } + + public function testMalformedLoginFieldsDoNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login', 'POST', [ + 'username' => ['owner'], + AutoBanRequestSubscriber::RECOVERY_LOGIN_TOKEN_FIELD => ['invalid'], + ], server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testMalformedRecoveryQueryDoesNotBypassActiveBan(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/user/login?bypass[]=1', server: ['REMOTE_ADDR' => '203.0.113.10']); + $request->attributes->set('_route', 'user_login'); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testLiveEndpointsDoNotBypassActiveBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/api/live/status', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock)->onKernelRequest($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + } + + public function testPostSignalGuardBlocksBansCreatedAfterTheFinalPreSignalGuard(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/search', 'GET', ['q' => 'probe'], server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $subscriber = $this->subscriber($visitorIds, $store, $clock); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequest($event); + + self::assertFalse($event->hasResponse()); + + $store->ban($subject, 3600); + $subscriber->onKernelRequestAfterSignalWrites($event); + + self::assertSame(403, $event->getResponse()?->getStatusCode()); + self::assertTrue($request->attributes->getBoolean(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE)); + } + + public function testTrustedUsersBypassActiveVisitorBans(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $visitorIds = new VisitorIdGenerator('test-secret'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.10']); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, $visitorIds->generate($request)); + $store->ban($subject, 3600); + $tokenStorage = new TokenStorage(); + $user = new UserAccount('99999999-0000-7000-8000-000000000001', 'manager', 'manager@example.test', 'hash', role: UserRole::Manager); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + $event = new RequestEvent(new AutoBanRequestTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST); + + $this->subscriber($visitorIds, $store, $clock, $tokenStorage)->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testSubscriberRunsAfterSecurityContextButBeforeOrdinaryRateLimit(): void + { + $autoBan = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $autoBanResponse = AutoBanRequestSubscriber::getSubscribedEvents()[KernelEvents::RESPONSE]; + $rateLimit = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $payloadSignals = SuspiciousPayloadSignalSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + $passiveSignals = PassiveAbuseSignalSubscriber::getSubscribedEvents()[KernelEvents::RESPONSE]; + + self::assertSame(['onKernelRequestPreAuthSourceBan', 4098], $autoBan[0]); + self::assertSame(['onKernelRequestProbeCandidate', 4097], $autoBan[1]); + self::assertSame(['onKernelRequestLogin', 16], $autoBan[2]); + self::assertSame(['onKernelRequestPreAuthBrowserSourceBan', 9], $autoBan[3]); + self::assertSame(['onKernelRequest', 4], $autoBan[4]); + self::assertSame(['onKernelRequestAfterSignalWrites', 1], $autoBan[5]); + self::assertGreaterThan($rateLimit[0][1], $autoBan[0][1]); + self::assertGreaterThan($rateLimit[0][1], $autoBan[1][1]); + self::assertGreaterThan(8, $autoBan[3][1]); + self::assertSame(['onKernelRequestOrdinary', 3], $rateLimit[1]); + self::assertGreaterThan($rateLimit[1][1], $autoBan[3][1]); + self::assertGreaterThan($autoBan[5][1], $payloadSignals[1]); + self::assertLessThan($autoBan[4][1], $payloadSignals[1]); + self::assertSame(['onKernelResponseErrorStatus', -299], $autoBanResponse); + self::assertGreaterThan($passiveSignals[1], $autoBanResponse[1]); + } + + private function subscriber( + VisitorIdGenerator $visitorIds, + AutoBanStore $store, + MockClock $clock, + ?TokenStorage $tokenStorage = null, + ?CsrfTokenManager $csrfTokens = null, + ): AutoBanRequestSubscriber { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + + return new AutoBanRequestSubscriber( + new AbuseRequestInspector( + new AbuseSubjectResolver($visitorIds, $tokenStorage ?? new TokenStorage(), 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ), + new AutoBanPolicy($config), + $store, + $this->renderer(), + new AccessRequestMetadata(), + clock: $clock, + csrfTokens: $csrfTokens, + ); + } + + private function renderer(): HttpErrorRenderer + { + return new HttpErrorRenderer( + new Environment(new ArrayLoader()), + (new \ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(Security::class))->newInstanceWithoutConstructor(), + new SetupCompletionMarker(), + new AccessRequestMetadata(), + sys_get_temp_dir(), + 'test', + ); + } +} + +final class AutoBanRequestTestKernel implements HttpKernelInterface +{ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } +} + +final class AutoBanRequestTestAuthenticator implements AuthenticatorInterface +{ + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(Request $request): Passport + { + throw new AuthenticationException('Not used by this test.'); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + throw new AuthenticationException('Not used by this test.'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return null; + } +} diff --git a/tests/Security/AutoBan/AutoBanResetServiceTest.php b/tests/Security/AutoBan/AutoBanResetServiceTest.php new file mode 100644 index 00000000..fef8f0d2 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanResetServiceTest.php @@ -0,0 +1,72 @@ +ban($subject, 3600); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), function (ActiveAutoBan $released) use ($store, $subject): bool { + self::assertNull($store->active($subject)); + + return $released->key() !== ''; + }); + + self::assertInstanceOf(ActiveAutoBan::class, $released); + self::assertNull($store->active($subject)); + } + + public function testItRestoresActiveStateWhenResetSignalCannotBeRecorded(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::IP, 'ip-reset-service', true); + $ban = $store->ban($subject, 3600, ['score' => 100]); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), static fn (): bool => false); + + self::assertNull($released); + $restored = $store->active($subject); + self::assertInstanceOf(ActiveAutoBan::class, $restored); + self::assertSame($ban->key(), $restored->key()); + self::assertTrue($restored->context()['restored_after_failed_reset_signal'] ?? false); + } + + public function testItSerializesReleaseAndCutoffAgainstBanCreation(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-reset-race'); + $ban = $store->ban($subject, 3600, ['score' => 100]); + self::assertNotNull($ban); + + $released = (new AutoBanResetService($store))->releaseAndRecord($ban->key(), function () use ($store, $subject): bool { + self::assertNull($store->active($subject)); + self::assertNull($store->createOrReturnActive($subject, 7200, ['score' => 200])); + + return true; + }); + + self::assertInstanceOf(ActiveAutoBan::class, $released); + self::assertNull($store->active($subject)); + } +} diff --git a/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php new file mode 100644 index 00000000..eca62345 --- /dev/null +++ b/tests/Security/AutoBan/AutoBanSignalEvaluatorTest.php @@ -0,0 +1,248 @@ +stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-1'); + + $this->recordProbe($recorder, $visitor, 'request-1'); + + self::assertNull($store->active($visitor)); + + $this->recordProbe($recorder, $visitor, 'request-2'); + + self::assertNotNull($store->active($visitor)); + } + + public function testQualifyingFloorCountsDistinctRequestsInsteadOfSignalRows(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-same-request'); + + $this->recordPayloadProbe($recorder, $visitor, 'request-1'); + $this->recordError($recorder, $visitor, 'request-1'); + + self::assertNull($store->active($visitor)); + + $this->recordError($recorder, $visitor, 'request-2'); + + self::assertNotNull($store->active($visitor)); + } + + public function testIpSubjectUsesLaxerThresholdMultiplier(): void + { + [$recorder, $store] = $this->stack(); + $ip = new AutoBanSubject(AutoBanSubject::IP, 'ip-bucket-1', true); + + for ($i = 1; $i <= 28; ++$i) { + $this->recordError($recorder, $ip, 'request-'.$i); + } + + self::assertNull($store->active($ip)); + + $this->recordError($recorder, $ip, 'request-29'); + + self::assertNotNull($store->active($ip)); + } + + public function testResetSignalInvalidatesEarlierScoreEvidence(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-reset'); + + $this->recordProbe($recorder, $visitor, 'request-1'); + $this->recordProbe($recorder, $visitor, 'request-2'); + self::assertNotNull($store->active($visitor)); + + $store->reset($visitor->key()); + $recorder->record( + 'auto_ban', + AutoBanScoreCatalogue::SIGNAL_RESET, + $visitor->type(), + $visitor->identifier(), + requestId: 'request-reset', + ); + + $this->recordProbe($recorder, $visitor, 'request-3'); + + self::assertNull($store->active($visitor)); + } + + public function testSameEvaluationPrefersVisitorBanOverIpBan(): void + { + [$recorder, $store] = $this->stack(); + $visitor = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-shared'); + $ip = new AutoBanSubject(AutoBanSubject::IP, 'ip-shared', true); + + $this->recordProbe($recorder, $visitor, 'request-1'); + $this->recordProbe($recorder, $ip, 'request-1'); + $this->recordProbe($recorder, $visitor, 'request-2'); + $this->recordProbe($recorder, $ip, 'request-2'); + + self::assertNotNull($store->active($visitor)); + self::assertNull($store->active($ip)); + } + + /** + * @return array{0: SecuritySignalRecorder, 1: AutoBanStore, 2: Connection} + */ + private function stack(): array + { + $connection = $this->connection(); + $config = new Config($connection); + $config->set(AutoBanPolicy::ENABLED_KEY, AutoBanPolicy::SETUP_ENABLED, ConfigValueType::Boolean); + $clock = new MockClock('2026-06-18 12:00:00'); + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: $clock); + $evaluator = new AutoBanSignalEvaluator( + $connection, + new DatabaseLogRetentionPolicy($connection), + new AutoBanPolicy($config), + new AutoBanScoreCatalogue(), + $store, + clock: $clock, + ); + $recorder = new SecuritySignalRecorder( + $connection, + new DatabaseLogRetentionPolicy($connection), + clock: $clock, + autoBanSignals: $evaluator, + ); + + return [$recorder, $store, $connection]; + } + + private function recordProbe(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'probe', + AutoBanScoreCatalogue::SIGNAL_SUSPICIOUS_PROBE, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'WARNING', + confidence: 95, + requestId: $requestId, + visitorId: 'visitor-context', + httpStatus: 400, + ); + } + + private function recordError(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'http_error', + AutoBanScoreCatalogue::SIGNAL_ERROR_HIT, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'NOTICE', + confidence: 40, + requestId: $requestId, + visitorId: 'visitor-context', + httpStatus: 404, + ); + } + + private function recordPayloadProbe(SecuritySignalRecorder $recorder, AutoBanSubject $subject, string $requestId): void + { + $recorder->record( + 'payload_probe', + AutoBanScoreCatalogue::SIGNAL_SUSPICIOUS_PAYLOAD, + $subject->type(), + $subject->identifier(), + ipDerived: $subject->ipDerived(), + severity: 'WARNING', + confidence: 90, + requestId: $requestId, + visitorId: 'visitor-context', + ); + } + + public function testInvalidActiveBanPayloadFailsOpenWithMessage(): void + { + $clock = new MockClock('2026-06-18 12:00:00'); + $cache = new ArrayAdapter(); + $messages = new RecordingAutoBanMessageReporter(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), $messages, $clock); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-invalid-payload'); + $item = $cache->getItem('security.auto_ban.active.'.$subject->key()); + $item->set([ + 'key' => $subject->key(), + 'subject_type' => $subject->type(), + 'subject_identifier' => $subject->identifier(), + 'created_at' => 'not-a-date', + 'expires_at' => '2026-06-18 13:00:00', + 'ttl_seconds' => 3600, + ]); + $cache->save($item); + + self::assertNull($store->active($subject)); + self::assertSame(SecurityMessageCode::AUTO_BAN_PAYLOAD_INVALID, $messages->records[0]['message']->code()); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)'); + $connection->executeStatement('CREATE INDEX idx_security_signal_subject_at ON security_signal_event (subject_type, subject_identifier, occurred_at)'); + + return $connection; + } +} + +final class RecordingAutoBanMessageReporter implements MessageReporterInterface +{ + /** + * @var list}> + */ + public array $records = []; + + public function report(Message $message, array $context = []): Message + { + $this->records[] = ['message' => $message, 'context' => $context]; + + return $message; + } + + public function reportBatch(iterable $records): array + { + $messages = []; + foreach ($records as $record) { + $message = $record['message']; + if (!$message instanceof Message) { + continue; + } + + $messages[] = $this->report($message, $record['context'] ?? []); + } + + return $messages; + } +} diff --git a/tests/Security/AutoBan/AutoBanStoreTest.php b/tests/Security/AutoBan/AutoBanStoreTest.php new file mode 100644 index 00000000..4114810f --- /dev/null +++ b/tests/Security/AutoBan/AutoBanStoreTest.php @@ -0,0 +1,111 @@ +ban($subject, 3600)); + self::assertNull($store->active($subject)); + self::assertSame([], $store->activeBans()); + } + + public function testResetFailsWhenActiveCacheDeleteFails(): void + { + $cache = new DeleteFailingAutoBanCache(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-delete-failure'); + $ban = $store->ban($subject, 3600); + self::assertNotNull($ban); + + self::assertNull($store->reset($ban->key())); + self::assertNotNull($store->active($subject)); + } + + public function testBanRollbackClearsActiveStateWhenIndexAndDeleteFail(): void + { + $cache = new IndexAndDeleteFailingAutoBanCache(); + $store = new AutoBanStore($cache, new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-index-delete-failure'); + + self::assertNull($store->ban($subject, 3600)); + self::assertNull($store->active($subject)); + self::assertSame([], $store->activeBans()); + } + + public function testCreateOrReturnActiveMarksExistingBanAsNotCreated(): void + { + $store = new AutoBanStore(new ArrayAdapter(), new LockFactory(new InMemoryStore()), clock: new MockClock('2026-06-18 12:00:00')); + $subject = new AutoBanSubject(AutoBanSubject::VISITOR, 'visitor-existing'); + + $first = $store->createOrReturnActive($subject, 3600); + self::assertNotNull($first); + self::assertTrue($first->created()); + + $second = $store->createOrReturnActive($subject, 3600); + self::assertNotNull($second); + self::assertFalse($second->created()); + self::assertSame($first->ban()->key(), $second->ban()->key()); + } +} + +final class IndexFailingAutoBanCache extends ArrayAdapter +{ + public function save(CacheItemInterface $item): bool + { + if ('security.auto_ban.index.v1' === $item->getKey()) { + return false; + } + + return parent::save($item); + } +} + +final class DeleteFailingAutoBanCache extends ArrayAdapter +{ + public function deleteItem(mixed $key): bool + { + if (is_string($key) && str_starts_with($key, 'security.auto_ban.active.')) { + return false; + } + + return parent::deleteItem($key); + } +} + +final class IndexAndDeleteFailingAutoBanCache extends ArrayAdapter +{ + public function save(CacheItemInterface $item): bool + { + if ('security.auto_ban.index.v1' === $item->getKey()) { + return false; + } + + return parent::save($item); + } + + public function deleteItem(mixed $key): bool + { + if (is_string($key) && str_starts_with($key, 'security.auto_ban.active.')) { + return false; + } + + return parent::deleteItem($key); + } +} diff --git a/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php b/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php new file mode 100644 index 00000000..ca94debe --- /dev/null +++ b/tests/Security/RateLimit/RateLimitAuthenticationSubscriberTest.php @@ -0,0 +1,75 @@ + 'Bearer invalid.invalid', + 'REMOTE_ADDR' => '203.0.113.10', + ]); + $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true); + $event = new LoginFailureEvent( + new AuthenticationException('Invalid credentials.'), + new RateLimitAuthenticationTestAuthenticator(), + $request, + null, + 'api', + ); + + (new RateLimitAuthenticationSubscriber( + (new \ReflectionClass(RateLimitResetService::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(), + (new \ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(), + 'prod', + ))->onLoginFailure($event); + + self::assertNull($event->getResponse()); + } +} + +final class RateLimitAuthenticationTestAuthenticator implements AuthenticatorInterface +{ + public function supports(Request $request): ?bool + { + return true; + } + + public function authenticate(Request $request): Passport + { + throw new AuthenticationException('Not used by this test.'); + } + + public function createToken(Passport $passport, string $firewallName): TokenInterface + { + throw new AuthenticationException('Not used by this test.'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return null; + } +} diff --git a/tests/Security/RateLimit/RateLimitEnforcerTest.php b/tests/Security/RateLimit/RateLimitEnforcerTest.php new file mode 100644 index 00000000..c609fcb2 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitEnforcerTest.php @@ -0,0 +1,826 @@ +connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config, cachePool: new FailingCachePool()); + + for ($i = 0; $i < 40; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + } + + public function testStorageFailureFailsOpenWithDiagnosticsFlag(): void + { + $messages = new RecordingRateLimitMessageReporter(); + $result = $this->enforcer(cachePool: new FailingCachePool(), messages: $messages)->check($this->request('/home')); + + self::assertTrue($result->isAllowed()); + self::assertTrue($result->storageDegraded()); + self::assertSame(SecurityMessageCode::RATE_LIMIT_STORAGE_DEGRADED, $messages->records[0]['message']->code()); + } + + public function testLoginWorkflowRejectsBeforeWebsiteBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLoginFormRendersDoNotSpendLoginWorkflowBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 6; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login'))->isAllowed()); + } + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testRecoveryLoginBypassUsesDedicatedBucketWithoutWebsiteBudget(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + + $result = $enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.recovery_login', $result->diagnosticsLabel()); + + $localizedRecovery = $this->request('/de/user/login?bypass=1'); + $localizedRecovery->attributes->set('_route', 'user_login'); + $localizedRecovery->attributes->set('_locale', 'de'); + + self::assertFalse($enforcer->check($localizedRecovery, RateLimitEnforcementStage::Ordinary)->isAllowed()); + } + + public function testPanicRecoveryLoginRenderAndSubmitFitBudgets(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1'), RateLimitEnforcementStage::Ordinary)->isAllowed()); + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'recovery-owner', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure)->isAllowed()); + } + + public function testBypassLoginPostUsesLoginFailureBudget(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login?bypass=1', 'POST', [ + 'username' => 'manual-bypass', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure)->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login?bypass=1', 'POST', [ + 'username' => 'manual-bypass', + 'password' => 'wrong', + ]), RateLimitEnforcementStage::AuthenticationFailure); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLoginAttemptsShareSubmittedAccountAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'shared-admin', + 'password' => 'wrong', + ], $this->server('203.0.113.'.(20 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'shared-admin', + 'password' => 'wrong', + ], $this->server('203.0.113.99'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testLocalLoginExhaustionDoesNotSpendSubmittedAccountBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'local-block', + 'password' => 'wrong', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ]))->isAllowed()); + } + + for ($i = 0; $i < 5; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ], $this->server('203.0.113.'.(60 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/login', 'POST', [ + 'username' => 'victim-account', + 'password' => 'wrong', + ], $this->server('203.0.113.90'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.login', $result->diagnosticsLabel()); + } + + public function testPasswordResetAttemptsShareSubmittedEmailAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'target@example.test', + ], $this->server('203.0.113.'.(40 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'TARGET@EXAMPLE.TEST', + ], $this->server('203.0.113.100'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testPasswordResetTokenAttemptsShareSubmittedTokenAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + $token = str_repeat('a', 64); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password/'.$token, 'POST', [], $this->server('203.0.113.'.(100 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password/'.$token, 'POST', [], $this->server('203.0.113.110'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testLocalPasswordResetExhaustionDoesNotSpendSubmittedEmailBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'local-block@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 2; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'victim@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'victim@example.test', + ], $this->server('203.0.113.'.(70 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'VICTIM@EXAMPLE.TEST', + ], $this->server('203.0.113.95'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testLocalRegistrationExhaustionDoesNotSpendSubmittedEmailBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'local-block@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 2; ++$i) { + self::assertFalse($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'victim-registration@example.test', + ]))->isAllowed()); + } + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'victim-registration@example.test', + ], $this->server('203.0.113.'.(80 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'VICTIM-REGISTRATION@EXAMPLE.TEST', + ], $this->server('203.0.113.96'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testInvitationTokenAttemptsShareSubmittedTokenAcrossVisitors(): void + { + $enforcer = $this->enforcer(); + $token = str_repeat('b', 64); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/invitation/'.$token, 'POST', [], $this->server('203.0.113.'.(120 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/invitation/'.$token, 'POST', [], $this->server('203.0.113.130'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testWebsiteExhaustionDoesNotSpendRegistrationAccountBucket(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $blocked = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'global-victim@example.test', + ])); + + self::assertFalse($blocked->isAllowed()); + self::assertSame('security.rate.website_burst', $blocked->diagnosticsLabel()); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'global-victim@example.test', + ], $this->server('203.0.113.'.(140 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/register', 'POST', [ + 'email' => 'GLOBAL-VICTIM@EXAMPLE.TEST', + ], $this->server('203.0.113.150'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.registration', $result->diagnosticsLabel()); + } + + public function testWebsiteExhaustionDoesNotSpendPasswordResetAccountBucket(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $blocked = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'global-reset@example.test', + ])); + + self::assertFalse($blocked->isAllowed()); + self::assertSame('security.rate.website_burst', $blocked->diagnosticsLabel()); + + for ($i = 0; $i < 3; ++$i) { + self::assertTrue($enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'global-reset@example.test', + ], $this->server('203.0.113.'.(160 + $i))))->isAllowed()); + } + + $result = $enforcer->check($this->request('/user/reset-password', 'POST', [ + 'email' => 'GLOBAL-RESET@EXAMPLE.TEST', + ], $this->server('203.0.113.170'))); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.password_reset', $result->diagnosticsLabel()); + } + + public function testOwnerIsExemptFromOrdinaryRateLimitRejection(): void + { + $tokenStorage = $this->tokenStorage(UserRole::Owner); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + for ($i = 0; $i < 40; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + } + + public function testOwnerApiContextDoesNotBypassAuthenticationFailureBudgets(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $request = $this->request('/api/v1/admin/settings/general', 'PATCH'); + $this->apiContext(ApiKeyStatus::ReadWrite, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::AuthenticationFailure); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testAuthenticatedUsersReceiveWebsiteMultiplier(): void + { + $tokenStorage = $this->tokenStorage(UserRole::User); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + for ($i = 0; $i < RateLimitPolicyCatalogue::AUTHENTICATED_MULTIPLIER * 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/home'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/home')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + } + + public function testSchedulerRequestsAreNotOwnerExempt(): void + { + $tokenStorage = $this->tokenStorage(UserRole::Owner); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + self::assertTrue($enforcer->check($this->request('/cron/run', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSchedulerIntervalOnlyAppliesToCronRun(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/cron/not-found', 'GET'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/de/cron/run', 'GET'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/cron/run', 'GET'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'GET')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testAdminNavigationFallsBackToWebsiteBuckets(): void + { + $enforcer = $this->enforcer(); + + for ($i = 0; $i < 30; ++$i) { + self::assertTrue($enforcer->check($this->request('/admin/logs'))->isAllowed()); + } + + $result = $enforcer->check($this->request('/admin/logs')); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + } + + public function testStrictSchedulerIntervalRejectsSecondRunWithinFifteenMinutes(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Strict->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + self::assertTrue($enforcer->check($this->request('/cron/run', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertGreaterThanOrEqual(1, $result->retryAfterSeconds() ?? 0); + } + + public function testSchedulerIntervalUsesStableSubmittedCredentialAcrossVisitorChanges(): void + { + $enforcer = $this->enforcer(); + + self::assertTrue($enforcer->check($this->request('/cron/run?auth=scheduler-token', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.77', + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run?auth=scheduler-token', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.77', + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSchedulerIntervalKeepsIpAnchorForAuthenticatedUsersWithRotatingCredentials(): void + { + $tokenStorage = $this->tokenStorage(UserRole::User); + $enforcer = $this->enforcer(tokenStorage: $tokenStorage); + + self::assertTrue($enforcer->check($this->request('/cron/run?auth=scheduler-token-a', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.78', + 'HTTP_USER_AGENT' => 'SchedulerProbe/1', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/cron/run?auth=scheduler-token-b', 'GET', [], [ + 'REMOTE_ADDR' => '203.0.113.78', + 'HTTP_USER_AGENT' => 'SchedulerProbe/2', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.scheduler', $result->diagnosticsLabel()); + } + + public function testSuspiciousProbeStillBlocksInOffModeWithoutStorage(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config, cachePool: new FailingCachePool()); + + $result = $enforcer->check($this->request('/.env')); + + self::assertFalse($result->isAllowed()); + self::assertTrue($result->suspiciousProbe()); + self::assertFalse($result->storageDegraded()); + } + + public function testAdminAuthFailureBudgetUsesIpSecondaryAcrossVisitorChanges(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $result = $enforcer->check($this->request('/api/v1/admin/settings/general', 'PATCH', [], [ + 'REMOTE_ADDR' => '203.0.113.88', + 'HTTP_USER_AGENT' => 'AdminProbe/'.$i, + 'HTTP_AUTHORIZATION' => sprintf('Bearer admin%02d.invalid-secret', $i), + ]), RateLimitEnforcementStage::AuthenticationFailure); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeyMutationsAreNotOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 16; ++$i) { + $request = $this->request('/api/v1/content/items', 'POST'); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.api_write', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeyUnsafePreflightsAreNotOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $request = $this->request('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Bearer read-only-owner', + ]); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + $result = $enforcer->check($request, RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadOnlyOwnerApiKeySafePreflightsRemainOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 20; ++$i) { + $request = $this->request('/api/v1/status', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET', + 'HTTP_AUTHORIZATION' => 'Bearer read-only-owner', + ]); + $this->apiContext(ApiKeyStatus::ReadOnly, UserRole::Owner)->attachTo($request); + self::assertTrue($enforcer->check($request, RateLimitEnforcementStage::Ordinary)->isAllowed()); + } + } + + public function testCredentialedNonBearerPreflightsSpendRequestedMethodBucket(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < 8; ++$i) { + $result = $enforcer->check($this->request('/api/v1/admin/settings/general', 'OPTIONS', server: [ + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'PATCH', + 'HTTP_AUTHORIZATION' => 'Basic credential-probe', + ]), RateLimitEnforcementStage::Ordinary); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.admin_mutation', $result->diagnosticsLabel()); + } + + public function testReadWriteOwnerApiKeyMutationsRemainOwnerExempt(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 20; ++$i) { + $request = $this->request('/api/v1/content/items', 'POST'); + $this->apiContext(ApiKeyStatus::ReadWrite, UserRole::Owner)->attachTo($request); + self::assertTrue($enforcer->check($request, RateLimitEnforcementStage::Ordinary)->isAllowed()); + } + } + + public function testPanicAdminMutationConsumesWebsiteBucketWithoutStorageDegradation(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $messages = new RecordingRateLimitMessageReporter(); + $enforcer = $this->enforcer(config: $config, messages: $messages); + + self::assertTrue($enforcer->check($this->request('/admin/settings/security', 'POST'))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/admin/settings/security', 'POST'))->isAllowed()); + + $result = $enforcer->check($this->request('/admin/settings/security', 'POST')); + + self::assertFalse($result->isAllowed()); + self::assertFalse($result->storageDegraded()); + self::assertSame('security.rate.website_burst', $result->diagnosticsLabel()); + self::assertSame([], $messages->records); + } + + public function testSetupWizardPostsDoNotSpendSetupApplyBudget(): void + { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + + for ($i = 0; $i < 12; ++$i) { + self::assertTrue($enforcer->check($this->request('/setup/database', 'POST', [ + '_setup_action' => 'test_database', + ]))->isAllowed()); + } + + self::assertTrue($enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))->isAllowed()); + self::assertTrue($enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ]))->isAllowed()); + + $result = $enforcer->check($this->request('/setup/review', 'POST', [ + '_setup_action' => 'apply', + ])); + + self::assertFalse($result->isAllowed()); + self::assertSame('security.rate.setup_apply', $result->diagnosticsLabel()); + } + + public function testRepresentativeRequestPathsReachExpectedBuckets(): void + { + $cases = [ + ['/user/register', 'POST', ['email' => 'registration@example.test'], 'security.rate.registration', 4], + ['/user/reset-password', 'POST', ['email' => 'reset@example.test'], 'security.rate.password_reset', 4], + ['/contact', 'POST', [], 'security.rate.website_form', 3], + ['/api/v1/content/items', 'GET', [], 'security.rate.api_public_read', 31], + ['/api/v1/content/items', 'POST', [], 'security.rate.api_write', 16], + ['/cron/run', 'POST', [], 'security.rate.scheduler', 2], + ['/setup/review', 'POST', ['_setup_action' => 'apply'], 'security.rate.setup_apply', 3], + ['/admin/settings/security', 'POST', [], 'security.rate.website_burst', 8], + ['/admin/extensions/upload', 'POST', [], 'security.rate.website_burst', 6], + ['/admin/logs/download', 'GET', [], 'security.rate.website_burst', 8], + ]; + + foreach ($cases as [$path, $method, $parameters, $label, $attempts]) { + $config = new Config($this->connection()); + $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String); + $enforcer = $this->enforcer(config: $config); + $result = null; + + for ($i = 0; $i < $attempts; ++$i) { + $result = $enforcer->check($this->request($path, $method, $parameters)); + } + + self::assertNotNull($result); + self::assertFalse($result->isAllowed(), $path); + self::assertSame($label, $result->diagnosticsLabel(), $path); + } + } + + private function enforcer(?Config $config = null, ?TokenStorage $tokenStorage = null, ?CacheItemPoolInterface $cachePool = null, ?RecordingRateLimitMessageReporter $messages = null): RateLimitEnforcer + { + $tokenStorage ??= new TokenStorage(); + $inspector = new AbuseRequestInspector( + new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), $tokenStorage, 'test-secret'), + new RequestIntentClassifier(), + new ActionCostCatalogue(), + ); + + return new RateLimitEnforcer( + $inspector, + $config ?? new Config($this->connection()), + new RateLimitPolicyCatalogue(), + new RateLimitSubjectSelector(), + new RateLimitLimiterFactory($cachePool ?? new ArrayAdapter()), + $messages ?? new RecordingRateLimitMessageReporter(), + ); + } + + /** + * @param array $parameters + * @param array $server + */ + private function request(string $path, string $method = 'GET', array $parameters = [], array $server = []): Request + { + return Request::create($path, $method, $parameters, server: [ + ...$this->server('203.0.113.9'), + ...$server, + ]); + } + + /** + * @return array + */ + private function server(string $ip): array + { + return [ + 'REMOTE_ADDR' => $ip, + 'HTTP_USER_AGENT' => 'RateLimitEnforcerTest-'.$ip, + ]; + } + + private function tokenStorage(UserRole $role): TokenStorage + { + $user = new UserAccount( + '99999999-0000-7000-8000-000000000001', + 'rate_limit_'.$role->value, + 'rate-limit-'.$role->value.'@example.test', + 'hash', + role: $role, + ); + $tokenStorage = new TokenStorage(); + $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles())); + + return $tokenStorage; + } + + private function apiContext(ApiKeyStatus $status, UserRole $role): ApiRequestContext + { + $user = new UserAccount( + '99999999-0000-7000-8000-000000000101', + 'rate_limit_api_'.$role->value, + 'rate-limit-api-'.$role->value.'@example.test', + 'hash', + role: $role, + ); + + return ApiRequestContext::fromApiKey(new ApiKey( + '99999999-0000-7000-8000-000000000201', + 'rlapi', + str_repeat('a', 64), + 'encrypted', + $user, + $status, + )); + } + + private function connection(): Connection + { + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)'); + + return $connection; + } +} + +final class FailingCachePool implements CacheItemPoolInterface +{ + public function getItem(string $key): CacheItemInterface + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function getItems(array $keys = []): iterable + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function hasItem(string $key): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function clear(): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function deleteItem(string $key): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function deleteItems(array $keys): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function save(CacheItemInterface $item): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function saveDeferred(CacheItemInterface $item): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } + + public function commit(): bool + { + throw new \RuntimeException('rate limiter storage unavailable'); + } +} diff --git a/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php new file mode 100644 index 00000000..820a59b1 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitLimiterFactoryTest.php @@ -0,0 +1,85 @@ +descriptor('website.deliberate.burst', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $panic); + + $factory = new RateLimitLimiterFactory(new ArrayAdapter()); + $subjectKey = 'website.deliberate.burst:visitor:profile-isolation'; + + for ($i = 0; $i < $panic->limit() - 1; ++$i) { + self::assertTrue($factory->consume($standard, $subjectKey, 1)); + } + + for ($i = 0; $i < $panic->limit(); ++$i) { + self::assertTrue($factory->consume($panic, $subjectKey, 1)); + } + + self::assertInstanceOf(\DateTimeImmutable::class, $factory->consume($panic, $subjectKey, 1)); + } + + public function testConsumeUsesConfiguredLockFactory(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $descriptor = $catalogue->descriptor('login.failure', RateLimitProfile::Standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $descriptor); + + $lockFactory = new TrackingRateLimitLockFactory(); + $factory = new RateLimitLimiterFactory(new ArrayAdapter(), $lockFactory); + + self::assertTrue($factory->consume($descriptor, 'login.failure:visitor:lock-test', 1)); + self::assertGreaterThanOrEqual(1, $lockFactory->createdLocks); + } + + public function testAcceptsChecksCapacityWithoutSpendingCredits(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $descriptor = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Standard); + self::assertInstanceOf(RateLimitBucketDescriptor::class, $descriptor); + + $factory = new RateLimitLimiterFactory(new ArrayAdapter()); + $subjectKey = 'scheduler.interval:visitor:accepts-test'; + + self::assertTrue($factory->accepts($descriptor, $subjectKey, 1)); + self::assertTrue($factory->accepts($descriptor, $subjectKey, 1)); + self::assertTrue($factory->consume($descriptor, $subjectKey, 1)); + self::assertInstanceOf(\DateTimeImmutable::class, $factory->accepts($descriptor, $subjectKey, 1)); + } +} + +final class TrackingRateLimitLockFactory extends LockFactory +{ + public int $createdLocks = 0; + + public function __construct() + { + parent::__construct(new InMemoryStore()); + } + + public function createLock(string $resource, ?float $ttl = 300.0, bool $autoRelease = true): SharedLockInterface + { + ++$this->createdLocks; + + return parent::createLock($resource, $ttl, $autoRelease); + } +} diff --git a/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php new file mode 100644 index 00000000..0111d196 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitPolicyCatalogueTest.php @@ -0,0 +1,236 @@ +descriptor('login.failure'); + $burst = $catalogue->descriptor('website.deliberate.burst'); + $probe = $catalogue->descriptor('suspicious.probe'); + $captcha = $catalogue->descriptor('captcha.failure'); + + self::assertNotNull($login); + self::assertSame('login', $login->bucketFamily()); + self::assertSame(5, $login->limit()); + self::assertSame(900, $login->windowSeconds()); + self::assertTrue($login->resettable()); + self::assertNotNull($burst); + self::assertSame(30, $burst->limit()); + self::assertSame(60, $burst->windowSeconds()); + self::assertNotNull($probe); + self::assertSame(10, $probe->limit()); + self::assertSame(10, $probe->minimumLimit()); + self::assertSame(600, $probe->windowSeconds()); + self::assertNotNull($captcha); + self::assertTrue($captcha->resettable()); + } + + public function testStrictAndPanicProfilesDeriveFromStandardDescriptors(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Standard); + $strict = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(30, $standard->limit()); + self::assertSame(60, $standard->windowSeconds()); + self::assertSame(16, $strict->limit()); + self::assertSame(90, $strict->windowSeconds()); + self::assertSame(16, $panic->limit()); + self::assertSame(120, $panic->windowSeconds()); + } + + public function testRecoveryBucketsStayStableAcrossProfiles(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Standard); + $panic = $catalogue->descriptor('recovery.login.minute', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($panic); + self::assertSame($standard->limit(), $panic->limit()); + self::assertSame($standard->windowSeconds(), $panic->windowSeconds()); + } + + public function testProbeScalingKeepsOneActionFloorWhileExtendingWindow(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $strict = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('suspicious.probe', RateLimitProfile::Panic); + + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(10, $strict->limit()); + self::assertSame(900, $strict->windowSeconds()); + self::assertSame(10, $panic->limit()); + self::assertSame(1200, $panic->windowSeconds()); + } + + public function testPolicyUsesActionCostsAsCreditMultipliers(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $registration = $catalogue->descriptor('registration.hour'); + $apiWrite = $catalogue->descriptor('api.write'); + + self::assertNotNull($registration); + self::assertSame(15, $registration->limit()); + self::assertSame(10, $registration->minimumLimit()); + self::assertNotNull($apiWrite); + self::assertSame(300, $apiWrite->limit()); + self::assertSame(10, $apiWrite->minimumLimit()); + } + + public function testDescriptorsOwnEnforcementStages(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $login = $catalogue->descriptor('login.failure'); + $recovery = $catalogue->descriptor('recovery.login.minute'); + $apiWrite = $catalogue->descriptor('api.write'); + $probe = $catalogue->descriptor('suspicious.probe'); + + self::assertNotNull($login); + self::assertTrue($login->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + self::assertFalse($login->handlesStage(RateLimitEnforcementStage::Ordinary)); + + self::assertNotNull($recovery); + self::assertTrue($recovery->handlesStage(RateLimitEnforcementStage::Ordinary)); + self::assertFalse($recovery->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + + self::assertNotNull($apiWrite); + self::assertTrue($apiWrite->handlesStage(RateLimitEnforcementStage::Ordinary)); + self::assertTrue($apiWrite->handlesStage(RateLimitEnforcementStage::AuthenticationFailure)); + + self::assertNotNull($probe); + self::assertTrue($probe->handlesStage(RateLimitEnforcementStage::SuspiciousProbe)); + self::assertFalse($probe->handlesStage(RateLimitEnforcementStage::Ordinary)); + } + + public function testDescriptorsOwnSubjectPolicy(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $login = $catalogue->descriptor('login.failure'); + $adminMutation = $catalogue->descriptor('admin.mutation'); + $scheduler = $catalogue->descriptor('scheduler.interval'); + $website = $catalogue->descriptor('website.deliberate.burst'); + + self::assertNotNull($login); + self::assertTrue($login->subjectPolicy()->submittedAccountScope()); + + self::assertNotNull($adminMutation); + self::assertSame([ + AbuseSubjectType::ApiKey, + AbuseSubjectType::User, + AbuseSubjectType::Visitor, + AbuseSubjectType::IpBucket, + ], $adminMutation->subjectPolicy()->preferredTypes()); + + self::assertNotNull($scheduler); + self::assertTrue($scheduler->subjectPolicy()->ipSecondary()); + self::assertTrue($scheduler->subjectPolicy()->ipSecondaryWithAuthenticatedSubject()); + + self::assertNotNull($website); + self::assertTrue($website->subjectPolicy()->authenticatedMultiplier()); + } + + public function testProfileScalingKeepsMinimumCostedActionsAvailable(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + foreach ($catalogue->descriptors(RateLimitProfile::Panic) as $descriptor) { + self::assertGreaterThanOrEqual($descriptor->minimumLimit(), $descriptor->limit(), $descriptor->name()); + } + } + + public function testDescriptorFloorsCoverTwoActionsExceptExplicitIntervalPolicies(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + $expectedMinimums = [ + 'login.failure' => 2, + 'recovery.login.minute' => 2, + 'recovery.login.hour' => 2, + 'registration.hour' => 10, + 'registration.day' => 10, + 'password_reset.hour' => 6, + 'password_reset.day' => 6, + 'captcha.failure' => 2, + 'website.deliberate.burst' => 16, + 'website.deliberate.sustained' => 16, + 'website.form' => 4, + 'website.prefetch.minute' => 2, + 'website.prefetch.sustained' => 2, + 'api.read' => 2, + 'api.public_read' => 2, + 'api.write' => 10, + 'scheduler.interval' => 1, + 'setup.apply' => 16, + 'admin.mutation' => 16, + 'upload_archive.validation' => 16, + 'download_diagnostics' => 8, + 'suspicious.probe' => 10, + ]; + + foreach ($expectedMinimums as $name => $minimum) { + $descriptor = $catalogue->descriptor($name); + self::assertNotNull($descriptor, $name); + self::assertSame($minimum, $descriptor->minimumLimit(), $name); + } + } + + public function testWebsiteBurstFloorCoversTwoHighCostCompanionActions(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $panic = $catalogue->descriptor('website.deliberate.burst', RateLimitProfile::Panic); + + self::assertNotNull($panic); + self::assertSame(16, $panic->minimumLimit()); + self::assertSame(16, $panic->limit()); + } + + public function testSchedulerProfileIntervalsUseExplicitCronPolicy(): void + { + $catalogue = new RateLimitPolicyCatalogue(); + + $standard = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Standard); + $strict = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Strict); + $panic = $catalogue->descriptor('scheduler.interval', RateLimitProfile::Panic); + + self::assertNotNull($standard); + self::assertNotNull($strict); + self::assertNotNull($panic); + self::assertSame(1, $standard->limit()); + self::assertSame(60, $standard->windowSeconds()); + self::assertSame(1, $strict->limit()); + self::assertSame(900, $strict->windowSeconds()); + self::assertSame(1, $panic->limit()); + self::assertSame(3600, $panic->windowSeconds()); + } + + public function testOffProfileDoesNotConsumeLimiterStorage(): void + { + self::assertFalse(RateLimitProfile::Off->consumesLimiterStorage()); + self::assertTrue(RateLimitProfile::Standard->consumesLimiterStorage()); + self::assertSame(RateLimitProfile::Standard, RateLimitProfile::fromMixed('unknown')); + } +} diff --git a/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php new file mode 100644 index 00000000..d45b9a66 --- /dev/null +++ b/tests/Security/RateLimit/RateLimitRequestSubscriberTest.php @@ -0,0 +1,378 @@ +previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null; + $this->previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null; + $this->previousPutenvValue = getenv(SetupCompletionMarker::KEY); + $_SERVER[SetupCompletionMarker::KEY] = '1'; + } + + protected function tearDown(): void + { + unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); + + if (null !== $this->previousServerValue) { + $_SERVER[SetupCompletionMarker::KEY] = $this->previousServerValue; + } + + if (null !== $this->previousEnvValue) { + $_ENV[SetupCompletionMarker::KEY] = $this->previousEnvValue; + } + + is_string($this->previousPutenvValue) + ? putenv(SetupCompletionMarker::KEY.'='.$this->previousPutenvValue) + : putenv(SetupCompletionMarker::KEY); + } + + /** + * @return iterable + */ + public static function excludedPathCases(): iterable + { + yield 'live api root' => ['/api/live', true]; + yield 'live api child' => ['/api/live/status', true]; + yield 'live api sibling' => ['/api/live-status', false]; + yield 'assets child' => ['/assets/app.css', true]; + yield 'assets sibling' => ['/assets-preview', false]; + yield 'build child' => ['/build/app.js', true]; + yield 'build sibling' => ['/builder', false]; + yield 'favicon' => ['/favicon.ico', true]; + yield 'touch icon' => ['/apple-touch-icon.png', true]; + yield 'well-known security' => ['/.well-known/security.txt', true]; + yield 'profiler root' => ['/_profiler', true]; + yield 'profiler child' => ['/_profiler/123', true]; + yield 'profiler sibling' => ['/_profilerfoo', false]; + yield 'toolbar child' => ['/_wdt/123', true]; + yield 'toolbar sibling' => ['/_wdtfoo', false]; + } + + #[DataProvider('excludedPathCases')] + public function testExcludedPathUsesSegmentBoundaries(string $path, bool $excluded): void + { + $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new PathScopeMatcher()); + $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths'); + $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); + + self::assertSame($excluded, $method->invoke($subscriber, Request::create($path))); + } + + public function testExcludedRequestDoesNotUseLocalizedTechnicalPathSegments(): void + { + $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor(); + $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths'); + $paths->setValue($subscriber, new PathScopeMatcher()); + $ignorablePaths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'ignorablePaths'); + $ignorablePaths->setValue($subscriber, new IgnorableRequestPathMatcher()); + $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'excludedRequest'); + $localized = Request::create('/de/api/live/status'); + $localized->attributes->set('_locale', 'de'); + + self::assertFalse($method->invoke($subscriber, $localized)); + self::assertFalse($method->invoke($subscriber, Request::create('/de/api/live/status'))); + } + + public function testProbePriorityRunsBeforeResponseProducingGates(): void + { + $events = RateLimitRequestSubscriber::getSubscribedEvents()[KernelEvents::REQUEST]; + + self::assertSame(['onKernelRequestProbe', 4096], $events[0]); + self::assertGreaterThan(1024, $events[0][1]); + self::assertGreaterThan(768, $events[0][1]); + self::assertGreaterThan(512, $events[0][1]); + self::assertGreaterThan(256, $events[0][1]); + } + + public function testProbeHookSkipsFullEnforcerForNonProbePaths(): void + { + $enforcer = (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(); + $responses = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(); + $subscriber = new RateLimitRequestSubscriber( + $enforcer, + $responses, + 'prod', + new SetupCompletionMarker(), + dirname(__DIR__, 3), + new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS), + ); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + Request::create('/home'), + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertFalse($event->hasResponse()); + } + + public function testProbeHookSkipsConsumptionWhenActiveAutoBanAlreadyMatched(): void + { + $enforcer = (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(); + $responses = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor(); + $subscriber = new RateLimitRequestSubscriber( + $enforcer, + $responses, + 'prod', + new SetupCompletionMarker(), + dirname(__DIR__, 3), + new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS), + ); + $request = Request::create('/.env'); + $request->attributes->set(AutoBanRequestSubscriber::PROBE_RATE_LIMIT_SKIP_ATTRIBUTE, true); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertFalse($event->hasResponse()); + } + + public function testProbeHookUsesBareResponseBeforeSetupCompletion(): void + { + unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]); + putenv(SetupCompletionMarker::KEY); + $subscriber = $this->subscriberWithRealEnforcer(); + $event = new RequestEvent( + new RateLimitRequestSubscriberTestKernel(), + Request::create('/.env'), + HttpKernelInterface::MAIN_REQUEST, + ); + + $subscriber->onKernelRequestProbe($event); + + self::assertTrue($event->hasResponse()); + self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode()); + self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent()); + self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent()); + self::assertStringContainsString('
Request-ID:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+    }
+
+    public function testProbeHookUsesForcedBareResponseAfterSetupCompletion(): void
+    {
+        $subscriber = $this->subscriberWithRealEnforcer();
+        $event = new RequestEvent(
+            new RateLimitRequestSubscriberTestKernel(),
+            Request::create('/.env'),
+            HttpKernelInterface::MAIN_REQUEST,
+        );
+
+        $subscriber->onKernelRequestProbe($event);
+
+        self::assertTrue($event->hasResponse());
+        self::assertSame(Response::HTTP_BAD_REQUEST, $event->getResponse()->getStatusCode());
+        self::assertStringContainsString('400 - Bad Request', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('Invalid Request', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+    }
+
+    public function testOrdinaryHookSkipsSetupWizardBeforeSetupCompletion(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+        $subscriber = $this->subscriberWithUninitializedEnforcer();
+        $event = new RequestEvent(
+            new RateLimitRequestSubscriberTestKernel(),
+            Request::create('/setup/database', 'POST', ['_setup_action' => 'test_database']),
+            HttpKernelInterface::MAIN_REQUEST,
+        );
+
+        $subscriber->onKernelRequestOrdinary($event);
+
+        self::assertFalse($event->hasResponse());
+    }
+
+    public function testSetupApplyRequestIsNotSkippedBeforeSetupCompletion(): void
+    {
+        $subscriber = (new ReflectionClass(RateLimitRequestSubscriber::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitRequestSubscriber::class, 'paths');
+        $paths->setValue($subscriber, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitRequestSubscriber::class, 'setupApplyRequest');
+
+        self::assertTrue($method->invoke($subscriber, Request::create('/setup/review', 'POST', [
+            '_setup_action' => 'apply',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/database', 'POST', [
+            '_setup_action' => 'test_database',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/review/extra', 'POST', [
+            '_setup_action' => 'apply',
+        ])));
+        self::assertFalse($method->invoke($subscriber, Request::create('/setup/review', 'GET', [
+            '_setup_action' => 'apply',
+        ])));
+    }
+
+    public function testSetupApplyBeforeSetupCompletionUsesBareTooManyRequestsResponse(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+        $subscriber = $this->subscriberWithRealEnforcer();
+        $event = null;
+
+        for ($i = 0; $i < 6; ++$i) {
+            $event = new RequestEvent(
+                new RateLimitRequestSubscriberTestKernel(),
+                Request::create('/setup/review', 'POST', ['_setup_action' => 'apply'], server: [
+                    'REMOTE_ADDR' => '203.0.113.54',
+                    'HTTP_USER_AGENT' => 'SetupApplyLimiterTest',
+                ]),
+                HttpKernelInterface::MAIN_REQUEST,
+            );
+
+            $subscriber->onKernelRequestOrdinary($event);
+        }
+
+        self::assertNotNull($event);
+        self::assertTrue($event->hasResponse());
+        self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $event->getResponse()->getStatusCode());
+        self::assertStringContainsString('429 - Too Many Requests', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('retry-after:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('
Request-ID:', (string) $event->getResponse()->getContent());
+        self::assertStringContainsString('no-store', (string) $event->getResponse()->headers->get('Cache-Control'));
+        self::assertNotNull($event->getResponse()->headers->get('Retry-After'));
+    }
+
+    private function subscriberWithUninitializedEnforcer(): RateLimitRequestSubscriber
+    {
+        return new RateLimitRequestSubscriber(
+            (new ReflectionClass(RateLimitEnforcer::class))->newInstanceWithoutConstructor(),
+            $this->responseRenderer(),
+            'prod',
+            new SetupCompletionMarker(),
+            dirname(__DIR__, 3),
+            new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+        );
+    }
+
+    private function subscriberWithRealEnforcer(): RateLimitRequestSubscriber
+    {
+        $inspector = new AbuseRequestInspector(
+            new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+            new RequestIntentClassifier(),
+            new ActionCostCatalogue(),
+        );
+        $enforcer = new RateLimitEnforcer(
+            $inspector,
+            new Config($this->connection()),
+            new RateLimitPolicyCatalogue(),
+            new RateLimitSubjectSelector(),
+            new RateLimitLimiterFactory(new ArrayAdapter()),
+            new class implements MessageReporterInterface {
+                public function report(Message $message, array $context = []): Message
+                {
+                    return $message;
+                }
+
+                public function reportBatch(iterable $records): array
+                {
+                    $messages = [];
+                    foreach ($records as $record) {
+                        $messages[] = $record['message'];
+                    }
+
+                    return $messages;
+                }
+            },
+        );
+
+        return new RateLimitRequestSubscriber(
+            $enforcer,
+            $this->responseRenderer(),
+            'prod',
+            new SetupCompletionMarker(),
+            dirname(__DIR__, 3),
+            new SuspiciousProbePathMatcher(patterns: SuspiciousProbePathMatcher::DEFAULT_PATTERNS),
+        );
+    }
+
+    private function responseRenderer(): RateLimitResponseRenderer
+    {
+        return new RateLimitResponseRenderer(
+            new HttpErrorRenderer(
+                (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(),
+                (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(),
+                new SetupCompletionMarker(),
+                new AccessRequestMetadata(),
+                dirname(__DIR__, 3),
+                'test',
+            ),
+            (new ReflectionClass(ApiResponder::class))->newInstanceWithoutConstructor(),
+            new AccessRequestMetadata(),
+        );
+    }
+
+    private function connection(): Connection
+    {
+        $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+        $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+
+        return $connection;
+    }
+}
+
+final class RateLimitRequestSubscriberTestKernel implements HttpKernelInterface
+{
+    public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response
+    {
+        return new Response();
+    }
+}
diff --git a/tests/Security/RateLimit/RateLimitResetServiceTest.php b/tests/Security/RateLimit/RateLimitResetServiceTest.php
new file mode 100644
index 00000000..992d02c2
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResetServiceTest.php
@@ -0,0 +1,243 @@
+services();
+        $request = $this->request('/user/login', 'POST');
+
+        for ($i = 0; $i < 5; ++$i) {
+            self::assertTrue($enforcer->check($request)->isAllowed());
+        }
+
+        self::assertFalse($enforcer->check($request)->isAllowed());
+        self::assertTrue($resets->resetLoginAttempts($request));
+        self::assertTrue($enforcer->check($request)->isAllowed());
+    }
+
+    public function testLoginSuccessResetClearsSubmittedAccountLoginAttempts(): void
+    {
+        [$enforcer, $resets] = $this->services();
+
+        for ($i = 0; $i < 5; ++$i) {
+            self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+                'username' => 'shared-admin',
+                'password' => 'wrong',
+            ], [
+                'REMOTE_ADDR' => '203.0.113.'.(20 + $i),
+            ]))->isAllowed());
+        }
+
+        self::assertFalse($enforcer->check($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'wrong',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.90',
+        ]))->isAllowed());
+
+        self::assertTrue($resets->resetLoginAttempts($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'correct',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.91',
+        ])));
+
+        self::assertTrue($enforcer->check($this->request('/user/login', 'POST', [
+            'username' => 'shared-admin',
+            'password' => 'wrong',
+        ], [
+            'REMOTE_ADDR' => '203.0.113.92',
+        ]))->isAllowed());
+    }
+
+    public function testLoginSuccessResetUsesActiveProfileDescriptor(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Strict->value, ConfigValueType::String);
+        [$enforcer, $resets] = $this->services(config: $config);
+        $request = $this->request('/user/login', 'POST');
+
+        self::assertTrue($enforcer->check($request)->isAllowed());
+        self::assertTrue($enforcer->check($request)->isAllowed());
+        self::assertFalse($enforcer->check($request)->isAllowed());
+        self::assertTrue($resets->resetLoginAttempts($request));
+        self::assertTrue($enforcer->check($request)->isAllowed());
+    }
+
+    public function testCaptchaResetRequiresVerifiedProviderBackedSuccess(): void
+    {
+        [, $resets] = $this->services();
+        $request = $this->request('/captcha/submit', 'POST');
+
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'none', true));
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($request, 'turnstile', false));
+        self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+    }
+
+    public function testCaptchaResetUsesActiveProfileDescriptor(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Panic->value, ConfigValueType::String);
+        $cache = new ArrayAdapter();
+        $factory = new RateLimitLimiterFactory($cache);
+        [, $resets] = $this->services(config: $config, factory: $factory);
+        $request = $this->request('/captcha/submit', 'POST');
+        $catalogue = new RateLimitPolicyCatalogue();
+        $descriptor = $catalogue->descriptor('captcha.failure', RateLimitProfile::Panic);
+        self::assertNotNull($descriptor);
+        $inspector = $this->inspector();
+        $selector = new RateLimitSubjectSelector();
+        $subjectKeys = $selector->subjectKeys($descriptor, $inspector->inspect($request)['subjects']);
+        self::assertNotSame([], $subjectKeys);
+
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertInstanceOf(\DateTimeImmutable::class, $factory->consume($descriptor, $subjectKeys[0], 1));
+        self::assertTrue($resets->resetVerifiedCaptchaFailure($request, 'turnstile', true));
+        self::assertTrue($factory->consume($descriptor, $subjectKeys[0], 1));
+    }
+
+    public function testOffModeDoesNotTouchResetStorage(): void
+    {
+        $config = new Config($this->connection());
+        $config->set(RateLimitPolicyCatalogue::MODE_KEY, RateLimitProfile::Off->value, ConfigValueType::String);
+        [, $resets] = $this->services(config: $config, factory: new RateLimitLimiterFactory(new ResetFailingCachePool()));
+
+        self::assertFalse($resets->resetLoginAttempts($this->request('/user/login', 'POST')));
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+    }
+
+    public function testResetFailureReportsThroughMessageLayer(): void
+    {
+        $messages = new RecordingRateLimitMessageReporter();
+        [, $resets] = $this->services(factory: new RateLimitLimiterFactory(new ResetFailingCachePool()), messages: $messages);
+
+        self::assertFalse($resets->resetVerifiedCaptchaFailure($this->request('/captcha/submit', 'POST'), 'turnstile', true));
+        self::assertSame(SecurityMessageCode::RATE_LIMIT_RESET_DEGRADED, $messages->records[0]['message']->code());
+        self::assertSame('security.rate_limit.reset', $messages->records[0]['context']['operation']);
+    }
+
+    /**
+     * @return array{0: RateLimitEnforcer, 1: RateLimitResetService}
+     */
+    private function services(?Config $config = null, ?RateLimitLimiterFactory $factory = null, ?RecordingRateLimitMessageReporter $messages = null): array
+    {
+        $inspector = $this->inspector();
+        $catalogue = new RateLimitPolicyCatalogue();
+        $selector = new RateLimitSubjectSelector();
+        $factory ??= new RateLimitLimiterFactory(new ArrayAdapter());
+        $config ??= new Config($this->connection());
+        $messages ??= new RecordingRateLimitMessageReporter();
+
+        return [
+            new RateLimitEnforcer($inspector, $config, $catalogue, $selector, $factory, $messages),
+            new RateLimitResetService($inspector, $config, $catalogue, $selector, $factory, $messages),
+        ];
+    }
+
+    private function inspector(): AbuseRequestInspector
+    {
+        return new AbuseRequestInspector(
+            new AbuseSubjectResolver(new VisitorIdGenerator('test-secret'), new TokenStorage(), 'test-secret'),
+            new RequestIntentClassifier(),
+            new ActionCostCatalogue(),
+        );
+    }
+
+    /**
+     * @param array $parameters
+     * @param array $server
+     */
+    private function request(string $path, string $method, array $parameters = [], array $server = []): Request
+    {
+        return Request::create($path, $method, $parameters, server: [
+            'REMOTE_ADDR' => '203.0.113.50',
+            'HTTP_USER_AGENT' => 'RateLimitResetServiceTest',
+            ...$server,
+        ]);
+    }
+
+    private function connection(): Connection
+    {
+        $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+        $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) NOT NULL PRIMARY KEY, value CLOB NOT NULL, value_type VARCHAR(32) NOT NULL, sensitive BOOLEAN NOT NULL DEFAULT 0, modified_at DATETIME DEFAULT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+
+        return $connection;
+    }
+}
+
+final class ResetFailingCachePool implements CacheItemPoolInterface
+{
+    public function getItem(string $key): CacheItemInterface
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function getItems(array $keys = []): iterable
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function hasItem(string $key): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function clear(): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function deleteItem(string $key): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function deleteItems(array $keys): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function save(CacheItemInterface $item): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function saveDeferred(CacheItemInterface $item): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+
+    public function commit(): bool
+    {
+        throw new \RuntimeException('rate limiter storage unavailable');
+    }
+}
diff --git a/tests/Security/RateLimit/RateLimitResponseRendererTest.php b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
new file mode 100644
index 00000000..1747003b
--- /dev/null
+++ b/tests/Security/RateLimit/RateLimitResponseRendererTest.php
@@ -0,0 +1,51 @@
+
+     */
+    public static function jsonSurfaceCases(): iterable
+    {
+        yield 'api v1' => ['/api/v1/status', true];
+        yield 'cron root' => ['/cron', true];
+        yield 'cron child' => ['/cron/run', true];
+        yield 'cron lookalike content' => ['/cronjobs', false];
+        yield 'browser content' => ['/docs', false];
+    }
+
+    #[DataProvider('jsonSurfaceCases')]
+    public function testJsonSurfaceUsesPathBoundaries(string $path, bool $json): void
+    {
+        $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+        $paths->setValue($renderer, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+
+        self::assertSame($json, $method->invoke($renderer, Request::create($path)));
+    }
+
+    public function testJsonSurfaceDoesNotUseLocalizedTechnicalPathSegments(): void
+    {
+        $renderer = (new ReflectionClass(RateLimitResponseRenderer::class))->newInstanceWithoutConstructor();
+        $paths = new \ReflectionProperty(RateLimitResponseRenderer::class, 'paths');
+        $paths->setValue($renderer, new PathScopeMatcher());
+        $method = new \ReflectionMethod(RateLimitResponseRenderer::class, 'jsonSurface');
+        $localized = Request::create('/de/cron/run');
+        $localized->attributes->set('_locale', 'de');
+
+        self::assertFalse($method->invoke($renderer, $localized));
+        self::assertFalse($method->invoke($renderer, Request::create('/de/cron/run')));
+    }
+}
diff --git a/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
new file mode 100644
index 00000000..fd7b477e
--- /dev/null
+++ b/tests/Security/RateLimit/RecordingRateLimitMessageReporter.php
@@ -0,0 +1,37 @@
+}>
+     */
+    public array $records = [];
+
+    public function report(Message $message, array $context = []): Message
+    {
+        $this->records[] = [
+            'message' => $message,
+            'context' => $context,
+        ];
+
+        return $message;
+    }
+
+    public function reportBatch(iterable $records): array
+    {
+        $messages = [];
+
+        foreach ($records as $record) {
+            $messages[] = $this->report($record['message'], $record['context'] ?? []);
+        }
+
+        return $messages;
+    }
+}
diff --git a/tests/Security/SessionVisitorBindingSubscriberTest.php b/tests/Security/SessionVisitorBindingSubscriberTest.php
index c01ad736..fba12de2 100644
--- a/tests/Security/SessionVisitorBindingSubscriberTest.php
+++ b/tests/Security/SessionVisitorBindingSubscriberTest.php
@@ -5,10 +5,20 @@
 namespace App\Tests\Security;
 
 use App\Core\Access\AccessActor;
+use App\Core\Log\AccessRequestMetadata;
 use App\Core\Log\AuditLoggerInterface;
+use App\Core\Log\DatabaseLogRetentionPolicy;
 use App\Core\Statistics\VisitorIdGenerator;
 use App\Entity\UserAccount;
+use App\Security\Abuse\AbuseRequestInspector;
+use App\Security\Abuse\AbuseSubjectResolver;
+use App\Security\Abuse\ActionCostCatalogue;
+use App\Security\Abuse\RequestIntentClassifier;
+use App\Security\Abuse\SecuritySignalRecorder;
+use App\Security\AutoBan\AutoBanRequestSubscriber;
 use App\Security\SessionVisitorBindingSubscriber;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\DriverManager;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -81,6 +91,91 @@ public function testItTerminatesSessionsWhenTheBoundVisitorChanges(): void
         self::assertSame($currentVisitorId, $auditLogger->records[0]['context']['current_visitor_id']);
     }
 
+    public function testItRecordsSecuritySignalWhenTheBoundVisitorChanges(): void
+    {
+        $tokenStorage = new TokenStorage();
+        $user = $this->user();
+        $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles()));
+        $auditLogger = new RecordingSessionAuditLogger();
+        $generator = new VisitorIdGenerator('test-secret');
+        $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']);
+        $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'request-session-mismatch');
+        $session = new Session(new MockArraySessionStorage());
+        $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234');
+        $request->setSession($session);
+        $connection = $this->signalConnection();
+
+        $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST);
+
+        (new SessionVisitorBindingSubscriber(
+            $tokenStorage,
+            $generator,
+            $auditLogger,
+            new AbuseRequestInspector(
+                new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'),
+                new RequestIntentClassifier(),
+                new ActionCostCatalogue(),
+            ),
+            new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)),
+            new AccessRequestMetadata(),
+        ))->onKernelRequest($event);
+
+        $row = $connection->fetchAssociative('SELECT * FROM security_signal_event');
+        self::assertIsArray($row);
+        self::assertSame('session', $row['signal_type']);
+        self::assertSame('security.signal.session_visitor_mismatch', $row['reason_code']);
+        self::assertSame('ERROR', $row['severity']);
+        self::assertSame(90, (int) $row['confidence']);
+        self::assertSame('user', $row['subject_type']);
+        self::assertSame($user->uid(), $row['subject_identifier']);
+        self::assertSame('request-session-mismatch', $row['request_id']);
+        self::assertSame($generator->generate($request), $row['visitor_id']);
+
+        $context = json_decode((string) $row['context'], true, flags: JSON_THROW_ON_ERROR);
+        self::assertSame('previousVisitorId1234', $context['previous_visitor_id']);
+        self::assertSame($generator->generate($request), $context['current_visitor_id']);
+        self::assertSame(1, $context['change_count']);
+        self::assertIsString($context['ip_bucket']);
+        self::assertNotSame('', $context['ip_bucket']);
+    }
+
+    public function testItDoesNotOverrideAutoBanResponsesOrRecordSignals(): void
+    {
+        $tokenStorage = new TokenStorage();
+        $user = $this->user();
+        $tokenStorage->setToken(new UsernamePasswordToken($user, 'main', $user->getRoles()));
+        $auditLogger = new RecordingSessionAuditLogger();
+        $generator = new VisitorIdGenerator('test-secret');
+        $request = Request::create('/admin', server: ['REMOTE_ADDR' => '203.0.113.42']);
+        $request->attributes->set(AutoBanRequestSubscriber::PASSIVE_SIGNAL_SKIP_ATTRIBUTE, true);
+        $session = new Session(new MockArraySessionStorage());
+        $session->set(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID, 'previousVisitorId1234');
+        $request->setSession($session);
+        $connection = $this->signalConnection();
+        $event = new RequestEvent(new SessionBindingTestKernel(), $request, HttpKernelInterface::MAIN_REQUEST);
+        $event->setResponse(new Response('blocked', Response::HTTP_FORBIDDEN));
+
+        (new SessionVisitorBindingSubscriber(
+            $tokenStorage,
+            $generator,
+            $auditLogger,
+            new AbuseRequestInspector(
+                new AbuseSubjectResolver($generator, $tokenStorage, 'test-secret'),
+                new RequestIntentClassifier(),
+                new ActionCostCatalogue(),
+            ),
+            new SecuritySignalRecorder($connection, new DatabaseLogRetentionPolicy($connection)),
+            new AccessRequestMetadata(),
+        ))->onKernelRequest($event);
+
+        self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
+        self::assertSame('blocked', $event->getResponse()?->getContent());
+        self::assertNotNull($tokenStorage->getToken());
+        self::assertSame('previousVisitorId1234', $session->get(SessionVisitorBindingSubscriber::SESSION_VISITOR_ID));
+        self::assertSame([], $auditLogger->records);
+        self::assertSame(0, (int) $connection->fetchOne('SELECT COUNT(*) FROM security_signal_event'));
+    }
+
     public function testItKeepsSessionsWhenTheBoundVisitorMatches(): void
     {
         $tokenStorage = new TokenStorage();
@@ -119,6 +214,15 @@ private function user(): UserAccount
             'hash',
         );
     }
+
+    private function signalConnection(): Connection
+    {
+        $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
+        $connection->executeStatement('CREATE TABLE config_entry (config_key VARCHAR(160) PRIMARY KEY NOT NULL, value CLOB NOT NULL, value_type VARCHAR(255) NOT NULL, sensitive BOOLEAN NOT NULL, modified_at DATETIME NOT NULL, modified_by VARCHAR(180) DEFAULT NULL)');
+        $connection->executeStatement('CREATE TABLE security_signal_event (uid VARCHAR(36) PRIMARY KEY NOT NULL, occurred_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, signal_type VARCHAR(80) NOT NULL, reason_code VARCHAR(120) NOT NULL, severity VARCHAR(16) NOT NULL, confidence INTEGER NOT NULL, subject_type VARCHAR(40) NOT NULL, subject_identifier VARCHAR(190) NOT NULL, ip_derived BOOLEAN NOT NULL, request_family VARCHAR(40) NOT NULL, request_intent VARCHAR(80) NOT NULL, request_id VARCHAR(64) NOT NULL, visitor_id VARCHAR(64) NOT NULL, path VARCHAR(1024) NOT NULL, route VARCHAR(190) NOT NULL, http_status INTEGER DEFAULT NULL, context CLOB NOT NULL)');
+
+        return $connection;
+    }
 }
 
 final class RecordingSessionAuditLogger implements AuditLoggerInterface
diff --git a/tests/Setup/SetupDefaultSeedTest.php b/tests/Setup/SetupDefaultSeedTest.php
index 13566de4..0d71f25e 100644
--- a/tests/Setup/SetupDefaultSeedTest.php
+++ b/tests/Setup/SetupDefaultSeedTest.php
@@ -5,11 +5,17 @@
 namespace App\Tests\Setup;
 
 use App\Api\ApiFeaturePolicy;
+use App\Core\AdminAcl\AdminFeatureDefaults;
+use App\Core\AdminAcl\AdminFeatureOverrideStore;
 use App\Core\Config\ConfigDefaultProviderInterface;
+use App\Core\Geo\MaxMindGeoIpConfig;
+use App\Core\Log\DatabaseLogRetentionPolicy;
 use App\Setup\DatabaseDriver;
 use App\Setup\SetupDefaultSeed;
 use App\Setup\SetupInput;
 use App\Scheduler\SchedulerSettings;
+use App\Security\Abuse\SuspiciousProbePathMatcher;
+use App\Security\AutoBan\AutoBanPolicy;
 use App\Security\UserFlowConfig;
 use PHPUnit\Framework\TestCase;
 
@@ -29,6 +35,17 @@ public function testItBuildsInputAwareConfigDefaults(): void
         self::assertTrue($settings[ApiFeaturePolicy::ENABLED_KEY]);
         self::assertFalse($settings[ApiFeaturePolicy::CORS_ENABLED_KEY]);
         self::assertSame([], $settings[ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY]);
+        self::assertFalse($settings[MaxMindGeoIpConfig::ENABLED_KEY]);
+        self::assertSame(MaxMindGeoIpConfig::DEFAULT_DATABASE_PATH, $settings[MaxMindGeoIpConfig::DATABASE_PATH_KEY]);
+        self::assertSame('', $settings[MaxMindGeoIpConfig::LICENSE_KEY_KEY]);
+        self::assertSame(DatabaseLogRetentionPolicy::DEFAULT_LOG_RETENTION_DAYS, $settings[DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY]);
+        self::assertSame(DatabaseLogRetentionPolicy::defaultSecuritySignalRetentionDays(), $settings[DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY]);
+        self::assertSame(SuspiciousProbePathMatcher::defaultPatternText(), $settings[SuspiciousProbePathMatcher::PATTERNS_KEY]);
+        self::assertTrue($settings[AutoBanPolicy::ENABLED_KEY]);
+        self::assertSame(AutoBanPolicy::DEFAULT_TRUSTED_ACCESS_LEVEL, $settings[AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY]);
+        self::assertSame(AutoBanPolicy::DEFAULT_SCORE_THRESHOLD, $settings[AutoBanPolicy::SCORE_THRESHOLD_KEY]);
+        self::assertTrue($settings[AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY]);
+        self::assertSame((new AdminFeatureDefaults())->overrides(), $settings[AdminFeatureOverrideStore::CONFIG_KEY]);
     }
 
     public function testItUsesCentralConfigDefaultsForSetupSeededSettings(): void
@@ -64,15 +81,28 @@ public function testEverySetupConfigKeyHasACentralDefaultExceptSetupInputValues(
             UserFlowConfig::REGISTRATION_MODE_KEY,
             \App\Core\Log\ConfigAuditLogPolicy::ENABLED_KEY,
             \App\Core\Log\ConfigAuditLogPolicy::EVENTS_KEY,
+            DatabaseLogRetentionPolicy::MESSAGE_LOG_RETENTION_DAYS_KEY,
+            DatabaseLogRetentionPolicy::AUDIT_LOG_RETENTION_DAYS_KEY,
+            DatabaseLogRetentionPolicy::ACCESS_LOG_RETENTION_DAYS_KEY,
+            DatabaseLogRetentionPolicy::SECURITY_SIGNAL_RETENTION_DAYS_KEY,
+            SuspiciousProbePathMatcher::PATTERNS_KEY,
+            AutoBanPolicy::ENABLED_KEY,
+            AutoBanPolicy::TRUSTED_ACCESS_LEVEL_KEY,
+            AutoBanPolicy::SCORE_THRESHOLD_KEY,
+            AutoBanPolicy::NEW_BAN_OWNER_ALERTS_KEY,
             \App\Core\Statistics\AccessStatisticsPolicy::ENABLED_KEY,
             \App\Core\Statistics\AccessStatisticsPolicy::RESPECT_DO_NOT_TRACK_KEY,
+            MaxMindGeoIpConfig::ENABLED_KEY,
+            MaxMindGeoIpConfig::DATABASE_PATH_KEY,
+            MaxMindGeoIpConfig::LICENSE_KEY_KEY,
             ApiFeaturePolicy::ENABLED_KEY,
             ApiFeaturePolicy::CORS_ENABLED_KEY,
             ApiFeaturePolicy::CORS_ALLOWED_ORIGINS_KEY,
             SchedulerSettings::ENABLED_KEY,
             SchedulerSettings::GET_AUTH_ENABLED_KEY,
-            SchedulerSettings::PACKAGE_ACTION_QUEUES_ENABLED_KEY,
+            SchedulerSettings::EXTENSION_ACTION_QUEUES_ENABLED_KEY,
             SchedulerSettings::WEB_TRIGGER_ENABLED_KEY,
+            AdminFeatureOverrideStore::CONFIG_KEY,
         ];
         $inputOnlyKeys = [
             'site.title',
diff --git a/tests/Setup/SetupRunnerTest.php b/tests/Setup/SetupRunnerTest.php
index dc28d08f..e04989f7 100644
--- a/tests/Setup/SetupRunnerTest.php
+++ b/tests/Setup/SetupRunnerTest.php
@@ -7,6 +7,7 @@
 use App\Core\ActionLog\ActionLog;
 use App\Core\Asset\AssetMessageCode;
 use App\Core\Asset\AssetMessageKey;
+use App\Core\Geo\MaxMindGeoIpConfig;
 use App\Core\Process\PhpCliBinaryPreferenceStore;
 use App\Core\Process\PhpCliBinaryValidator;
 use App\Database\DatabaseReadyState;
@@ -113,7 +114,7 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
             ['composer', 'dump-env', 'test'],
             [PHP_BINARY, $this->root.'/bin/console', 'doctrine:migrations:migrate', '--no-interaction', '--env=test'],
             [PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'],
-            [PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'],
+            [PHP_BINARY, $this->root.'/bin/console', 'extensions:discover', '--run-now', '--trigger=setup', '--env=test'],
             [PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'],
             [PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'],
             [PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'],
@@ -121,6 +122,10 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
 
         $pdo = new PDO('sqlite:'.$databasePath);
         $configRows = $pdo->query('SELECT config_key, value FROM config_entry')->fetchAll(PDO::FETCH_KEY_PAIR);
+        $geoIpLicenseSensitive = $pdo->query(sprintf(
+            "SELECT sensitive FROM config_entry WHERE config_key = '%s'",
+            MaxMindGeoIpConfig::LICENSE_KEY_KEY,
+        ))->fetchColumn();
         $aclGroups = $pdo->query('SELECT identifier, min_role FROM acl_group WHERE json_extract(metadata, "$.seeded_by") = "setup" ORDER BY min_role')->fetchAll(PDO::FETCH_ASSOC);
         $adminUser = $pdo->query("SELECT password_hash, role FROM user_account WHERE username = 'admin'")->fetch(PDO::FETCH_ASSOC);
         $stateMarkers = $pdo->query("SELECT marker_key, marker_value FROM state_marker WHERE subject_type = 'user_account' ORDER BY marker_key")->fetchAll(PDO::FETCH_KEY_PAIR);
@@ -129,6 +134,7 @@ public function testItRunsSetupAndSeedsConfigurationAndAdmin(): void
         $homeTitle = $pdo->query(sprintf("SELECT field_content FROM content_field_value WHERE revision_uid = '%s' AND field_identifier = 'title' AND language = '%s'", $seed->homeContentRevision()['uid'], $input->language()))->fetchColumn();
 
         self::assertSame($seed->configMap($input), $this->decodedConfigRows($configRows, array_keys($seed->configMap($input))));
+        self::assertSame(1, (int) $geoIpLicenseSensitive);
         self::assertSame(array_map(static fn (array $group): array => [
             'identifier' => $group['identifier'],
             'min_role' => $group['min_role'],
@@ -512,7 +518,7 @@ public function testItFallsBackToSystemComposerWhenBundledComposerIsUnavailable(
             ['composer', 'dump-env', 'test'],
             [PHP_BINARY, $this->root.'/bin/console', 'doctrine:migrations:migrate', '--no-interaction', '--env=test'],
             [PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'],
-            [PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'],
+            [PHP_BINARY, $this->root.'/bin/console', 'extensions:discover', '--run-now', '--trigger=setup', '--env=test'],
             [PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'],
             [PHP_BINARY, $this->root.'/bin/console', 'mercure:stop', '--env=test'],
             [PHP_BINARY, $this->root.'/bin/console', 'mercure:health', '--env=test'],
@@ -561,8 +567,8 @@ public function testDryRunReturnsPlannedChangesWithoutWriting(): void
         self::assertSame($seed->contentSchema()['identifier'], $entries[6]['context']['schema']);
         self::assertSame('clear_cache', $entries[7]['name']);
         self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'cache:clear', '--env=test'], $entries[7]['context']['command']);
-        self::assertSame('run_package_discovery', $entries[8]['name']);
-        self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'packages:discover', '--run-now', '--trigger=setup', '--env=test'], $entries[8]['context']['command']);
+        self::assertSame('run_extension_discovery', $entries[8]['name']);
+        self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'extensions:discover', '--run-now', '--trigger=setup', '--env=test'], $entries[8]['context']['command']);
         self::assertSame('run_asset_rebuild', $entries[9]['name']);
         self::assertSame([PHP_BINARY, $this->root.'/bin/console', 'assets:rebuild', '--trigger=setup', '--env=test', '--json'], $entries[9]['context']['command']);
         self::assertSame('run_mercure_health', $entries[10]['name']);
diff --git a/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php b/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php
index a4ddf318..85ae8eb3 100644
--- a/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php
+++ b/tests/Support/DatabaseSeed/TestDatabaseExtensionSeeder.php
@@ -8,10 +8,10 @@ final class TestDatabaseExtensionSeeder
 {
     public static function seed(TestDatabaseSeedWriter $writer): void
     {
-        $writer->insert('extension_package', [
+        $writer->insert('extension', [
             'uid' => '00000000-0000-7000-8000-000000000401',
-            'package_scopes' => $writer->json(['frontend-theme', 'backend-theme', 'system-template']),
-            'package_name' => 'system',
+            'extension_scopes' => $writer->json(['frontend-theme', 'backend-theme', 'system-template']),
+            'extension_name' => 'system',
             'path' => '.',
             'manifest_version' => '1',
             'installed_version' => '0.1.0-dev',
diff --git a/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php b/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php
index c83ba3cf..ba427e9f 100644
--- a/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php
+++ b/tests/Support/DatabaseSeed/TestDatabaseSchemaSeeder.php
@@ -32,7 +32,7 @@ public static function seed(TestDatabaseSeedWriter $writer): void
                 'description' => $writer->json($schema['description']),
                 'definition' => $writer->json($schema['definition']),
                 'custom_twig' => null,
-                'definition_hash' => hash('sha256', $writer->json($schema['definition'])),
+                'definition_hash' => self::definitionHash($schema['title'], $schema['description'], $schema['definition'], null),
                 'use_min_level' => 0,
                 'use_group_identifiers' => null,
                 'edit_min_level' => 3,
@@ -103,6 +103,21 @@ private static function articleDefinition(): array
         ];
     }
 
+    /**
+     * @param array $title
+     * @param array $description
+     * @param array $definition
+     */
+    private static function definitionHash(array $title, array $description, array $definition, ?string $customTwig): string
+    {
+        return hash('sha256', json_encode([
+            'title' => $title,
+            'description' => $description,
+            'definition' => $definition,
+            'custom_twig' => $customTwig,
+        ], JSON_THROW_ON_ERROR));
+    }
+
     private function __construct()
     {
     }
diff --git a/tests/Support/TestSuiteLifecycle.php b/tests/Support/TestSuiteLifecycle.php
index a64976a2..ee54da57 100644
--- a/tests/Support/TestSuiteLifecycle.php
+++ b/tests/Support/TestSuiteLifecycle.php
@@ -22,6 +22,7 @@ public static function initialize(): void
 
     public static function cleanup(): void
     {
+        self::stopMercureHub(dirname(__DIR__, 2));
         self::removeDirectory(self::temporaryRoot());
     }
 
@@ -133,6 +134,18 @@ private static function runConsoleCommand(string $projectRoot, array $arguments)
         }
     }
 
+    private static function stopMercureHub(string $projectRoot): void
+    {
+        try {
+            self::runConsoleCommand($projectRoot, [
+                'mercure:stop',
+                '--env=test',
+            ]);
+        } catch (RuntimeException) {
+            return;
+        }
+    }
+
     private static function removeDirectory(string $directory): void
     {
         if (!is_dir($directory)) {
diff --git a/tests/View/Alert/MercureUiAlertPublisherTest.php b/tests/View/Alert/MercureUiAlertPublisherTest.php
index a4a8f388..fc40bb9c 100644
--- a/tests/View/Alert/MercureUiAlertPublisherTest.php
+++ b/tests/View/Alert/MercureUiAlertPublisherTest.php
@@ -66,13 +66,13 @@ public function testItTranslatesStructuredMessagesBeforePublishing(): void
         $hub = new RecordingHub();
         $publisher = $this->publisher($hub);
 
-        $publisher->publishToSession('session-id', Message::success('message.package.discovery_completed', ['%package%' => 'Demo']));
+        $publisher->publishToSession('session-id', Message::success('message.extension.discovery_completed', ['%extension%' => 'Demo']));
 
         $payload = json_decode($hub->update?->getData() ?? '{}', true, 512, JSON_THROW_ON_ERROR);
-        self::assertSame('message.package.discovery_completed', $payload['message']);
+        self::assertSame('message.extension.discovery_completed', $payload['message']);
         self::assertSame('success', $payload['level']);
         self::assertSame(CommonMessageCode::SUCCESS, $payload['code']);
-        self::assertSame('message.package.discovery_completed', $payload['translation_key']);
+        self::assertSame('message.extension.discovery_completed', $payload['translation_key']);
         self::assertArrayNotHasKey('context', $payload);
     }
 
diff --git a/tests/View/Alert/UiAlertTest.php b/tests/View/Alert/UiAlertTest.php
index 920cd454..60c7b59d 100644
--- a/tests/View/Alert/UiAlertTest.php
+++ b/tests/View/Alert/UiAlertTest.php
@@ -56,21 +56,21 @@ public function testItCanAttachStableDedupeId(): void
     public function testItDoesNotSerializeDiagnosticContext(): void
     {
         $alert = UiAlert::translated(
-            'Package failed.',
+            'Extension failed.',
             'error',
-            'package.runtime.failure',
-            'message.package.runtime_failure',
+            'extension.runtime.failure',
+            'message.extension.runtime_failure',
             ['path' => '/srv/example/private.log', 'exception' => 'RuntimeException'],
         );
 
         self::assertArrayNotHasKey('context', $alert->toArray());
-        self::assertSame('message.package.runtime_failure', $alert->toArray()['translation_key']);
+        self::assertSame('message.extension.runtime_failure', $alert->toArray()['translation_key']);
     }
 
     public function testPresentationFiltersUnsafeActionLinks(): void
     {
         $alert = UiAlert::fromLevel('info', 'Saved')->withPresentation(UiAlertPresentation::persistent(actions: [
-            UiAlertAction::link('Open', '/admin/packages', '_blank'),
+            UiAlertAction::link('Open', '/admin/extensions', '_blank'),
             UiAlertAction::link('Script', 'javascript:alert(1)'),
             ['label' => 'Data', 'href' => 'data:text/html,boom'],
             ['label' => 'Protocol-relative', 'href' => '//evil.example.test/path'],
@@ -80,7 +80,7 @@ public function testPresentationFiltersUnsafeActionLinks(): void
         ]));
 
         self::assertSame([
-            ['label' => 'Open', 'href' => '/admin/packages', 'target' => '_blank'],
+            ['label' => 'Open', 'href' => '/admin/extensions', 'target' => '_blank'],
             ['label' => 'External', 'href' => 'https://example.test/privacy', 'target' => '_self'],
             ['label' => 'Event', 'event' => 'operation-overlay:show', 'detail' => ['id' => 'operation-1']],
         ], $alert->toArray()['actions']);
@@ -89,13 +89,13 @@ public function testPresentationFiltersUnsafeActionLinks(): void
     public function testDirectAlertActionsUseTheSameLinkPolicy(): void
     {
         $alert = UiAlert::fromLevel('info', 'Saved', actions: [
-            ['label' => 'Open', 'href' => '/admin/packages'],
+            ['label' => 'Open', 'href' => '/admin/extensions'],
             ['label' => 'Script', 'href' => 'javascript:alert(1)'],
             ['label' => 'Event', 'event' => 'operation-overlay:show'],
         ]);
 
         self::assertSame([
-            ['label' => 'Open', 'href' => '/admin/packages'],
+            ['label' => 'Open', 'href' => '/admin/extensions'],
             ['label' => 'Event', 'event' => 'operation-overlay:show'],
         ], $alert->toArray()['actions']);
     }
diff --git a/tests/View/Alert/WorkflowResultAlertSelectorTest.php b/tests/View/Alert/WorkflowResultAlertSelectorTest.php
index 2941e30f..f097fc1a 100644
--- a/tests/View/Alert/WorkflowResultAlertSelectorTest.php
+++ b/tests/View/Alert/WorkflowResultAlertSelectorTest.php
@@ -17,9 +17,9 @@ public function testItKeepsSuccessSeverityWhenSuccessResultStartsWithDiagnosticM
     {
         $selector = new WorkflowResultAlertSelector();
         $message = Message::debug(
-            'package.dependency.resolved',
-            'message.package.dependency.resolved',
-            ['%package%' => 'demo'],
+            'extension.dependency.resolved',
+            'message.extension.dependency.resolved',
+            ['%extension%' => 'demo'],
             ['internal' => true],
         );
 
@@ -27,16 +27,16 @@ public function testItKeepsSuccessSeverityWhenSuccessResultStartsWithDiagnosticM
 
         self::assertSame(MessageLevel::Success, $alert->level());
         self::assertSame(CommonMessageCode::SUCCESS, $alert->code());
-        self::assertSame('message.package.dependency.resolved', $alert->translationKey());
-        self::assertSame(['%package%' => 'demo'], $alert->parameters());
+        self::assertSame('message.extension.dependency.resolved', $alert->translationKey());
+        self::assertSame(['%extension%' => 'demo'], $alert->parameters());
         self::assertSame([], $alert->context());
     }
 
     public function testItPrefersExplicitSuccessMessages(): void
     {
         $selector = new WorkflowResultAlertSelector();
-        $debug = Message::debug('package.dependency.resolved', 'message.package.dependency.resolved');
-        $success = Message::success('message.package.lifecycle.activated', ['%package%' => 'demo']);
+        $debug = Message::debug('extension.dependency.resolved', 'message.extension.dependency.resolved');
+        $success = Message::success('message.extension.lifecycle.activated', ['%extension%' => 'demo']);
 
         $alert = $selector->fromResult(WorkflowResult::success(messages: [$debug, $success]));
 
@@ -46,7 +46,7 @@ public function testItPrefersExplicitSuccessMessages(): void
     public function testItUsesFirstIssueForFailedResults(): void
     {
         $selector = new WorkflowResultAlertSelector();
-        $issue = Message::error('package.lifecycle.not_found', 'message.package.lifecycle.not_found');
+        $issue = Message::error('extension.lifecycle.not_found', 'message.extension.lifecycle.not_found');
 
         $alert = $selector->fromResult(WorkflowResult::failed([$issue]));
 
diff --git a/tests/View/PackageMacroRegistryTest.php b/tests/View/ExtensionMacroRegistryTest.php
similarity index 63%
rename from tests/View/PackageMacroRegistryTest.php
rename to tests/View/ExtensionMacroRegistryTest.php
index e98b68f8..fc1cd13f 100644
--- a/tests/View/PackageMacroRegistryTest.php
+++ b/tests/View/ExtensionMacroRegistryTest.php
@@ -4,17 +4,17 @@
 
 namespace App\Tests\View;
 
-use App\View\PackageMacroRegistry;
+use App\View\ExtensionMacroRegistry;
 use PHPUnit\Framework\TestCase;
 
-final class PackageMacroRegistryTest extends TestCase
+final class ExtensionMacroRegistryTest extends TestCase
 {
     public function testItExposesNamespacedCoreMacroTemplates(): void
     {
-        $registry = new PackageMacroRegistry();
+        $registry = new ExtensionMacroRegistry();
 
         self::assertSame('@root/macros/core/ui.html.twig', $registry->template('core', 'ui'));
         self::assertSame('@root/macros/core/form.html.twig', $registry->template('core', 'form'));
-        self::assertNull($registry->template('package', 'missing'));
+        self::assertNull($registry->template('extension', 'missing'));
     }
 }
diff --git a/tests/View/Http/HttpErrorRendererTest.php b/tests/View/Http/HttpErrorRendererTest.php
new file mode 100644
index 00000000..a6e6a391
--- /dev/null
+++ b/tests/View/Http/HttpErrorRendererTest.php
@@ -0,0 +1,125 @@
+previousServerValue = $_SERVER[SetupCompletionMarker::KEY] ?? null;
+        $this->previousEnvValue = $_ENV[SetupCompletionMarker::KEY] ?? null;
+        $this->previousPutenvValue = getenv(SetupCompletionMarker::KEY);
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+        putenv(SetupCompletionMarker::KEY);
+    }
+
+    protected function tearDown(): void
+    {
+        unset($_SERVER[SetupCompletionMarker::KEY], $_ENV[SetupCompletionMarker::KEY]);
+
+        if (null !== $this->previousServerValue) {
+            $_SERVER[SetupCompletionMarker::KEY] = $this->previousServerValue;
+        }
+
+        if (null !== $this->previousEnvValue) {
+            $_ENV[SetupCompletionMarker::KEY] = $this->previousEnvValue;
+        }
+
+        is_string($this->previousPutenvValue)
+            ? putenv(SetupCompletionMarker::KEY.'='.$this->previousPutenvValue)
+            : putenv(SetupCompletionMarker::KEY);
+    }
+
+    /**
+     * @return iterable
+     */
+    public static function setupBareStatusCases(): iterable
+    {
+        foreach (Response::$statusTexts as $statusCode => $statusText) {
+            if ($statusCode >= 400 && $statusCode < 600) {
+                yield sprintf('%d %s', $statusCode, $statusText) => [$statusCode];
+            }
+        }
+    }
+
+    #[DataProvider('setupBareStatusCases')]
+    public function testItReturnsBareKnownErrorResponsesBeforeSetupCompletion(int $statusCode): void
+    {
+        $response = $this->renderer()->resolve($statusCode, Request::create('/setup/missing'));
+
+        self::assertSame($statusCode, $response->getStatusCode());
+        self::assertStringContainsString(sprintf(
+            '%d - %s',
+            $statusCode,
+            htmlspecialchars(Response::$statusTexts[$statusCode], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
+        ), (string) $response->getContent());
+        self::assertStringContainsString('
Request-ID:', (string) $response->getContent());
+        self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
+        self::assertStringContainsString('text/html', (string) $response->headers->get('Content-Type'));
+    }
+
+    public function testBareResponseKeepsAdditionalHeadersAndContext(): void
+    {
+        $request = Request::create('/setup/review');
+        $request->attributes->set('_access_request_id', 'req-test-123');
+        $response = $this->renderer()->bare(Response::HTTP_TOO_MANY_REQUESTS, $request, [
+            'bare_context' => 'retry-after: 60',
+        ], ['Retry-After' => '60']);
+
+        self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
+        self::assertStringContainsString('429 - Too Many Requests', (string) $response->getContent());
+        self::assertStringContainsString('

retry-after: 60

', (string) $response->getContent()); + self::assertStringContainsString('
Request-ID: req-test-123
', (string) $response->getContent()); + self::assertSame('60', $response->headers->get('Retry-After')); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + public function testResolveCanForceBareResponseAfterSetupCompletion(): void + { + $_SERVER[SetupCompletionMarker::KEY] = '1'; + $request = Request::create('/blocked'); + $request->attributes->set(AccessRequestMetadata::REQUEST_ID_ATTRIBUTE, 'req-forced'); + $response = $this->renderer()->resolve(Response::HTTP_FORBIDDEN, $request, [ + 'bare_context' => '', + ], forceBare: true); + + self::assertSame(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + self::assertStringContainsString('403 - Forbidden', (string) $response->getContent()); + self::assertStringContainsString('

<blocked>

', (string) $response->getContent()); + self::assertStringContainsString('
Request-ID: req-forced
', (string) $response->getContent()); + self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control')); + } + + private function renderer(): HttpErrorRenderer + { + return new HttpErrorRenderer( + (new ReflectionClass(Environment::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(PublishedContentResolver::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(ContentFieldsetRenderer::class))->newInstanceWithoutConstructor(), + (new ReflectionClass(Security::class))->newInstanceWithoutConstructor(), + new SetupCompletionMarker(), + new AccessRequestMetadata(), + dirname(__DIR__, 2), + 'test', + ); + } +} diff --git a/tests/View/Http/ResponseHookSubscriberTest.php b/tests/View/Http/ResponseHookSubscriberTest.php index 2860ec12..15f1f503 100644 --- a/tests/View/Http/ResponseHookSubscriberTest.php +++ b/tests/View/Http/ResponseHookSubscriberTest.php @@ -42,7 +42,7 @@ public function testItRejectsUnsafeResponseHeaderHookChanges(): void { $dispatcher = new EventDispatcher(); $dispatcher->addListener(ResponseHeadersEvent::class, static function (ResponseHeadersEvent $event): void { - $event->setHeader('Set-Cookie', 'session=package-owned'); + $event->setHeader('Set-Cookie', 'session=extension-owned'); $event->setHeader('X-Bad-Value', "first\r\nsecond"); $event->setHeader('X-Frame-Options', 'ALLOWALL'); $event->removeHeader('Content-Security-Policy'); @@ -68,7 +68,7 @@ public function testItAppliesHtmlOutputHookChanges(): void { $dispatcher = new EventDispatcher(); $dispatcher->addListener(OutputGeneratedEvent::class, static function (OutputGeneratedEvent $event): void { - $event->appendContent(''); + $event->appendContent(''); }); $response = new Response('', 200, [ 'Content-Type' => 'text/html; charset=UTF-8', @@ -77,7 +77,7 @@ public function testItAppliesHtmlOutputHookChanges(): void $this->subscriber($dispatcher)->onKernelResponse($this->responseEvent($response)); - self::assertSame('', $response->getContent()); + self::assertSame('', $response->getContent()); self::assertFalse($response->headers->has('Content-Length')); } diff --git a/tests/View/SystemPackageMetadataProviderTest.php b/tests/View/SystemExtensionMetadataProviderTest.php similarity index 85% rename from tests/View/SystemPackageMetadataProviderTest.php rename to tests/View/SystemExtensionMetadataProviderTest.php index 710ef4ff..1ec62481 100644 --- a/tests/View/SystemPackageMetadataProviderTest.php +++ b/tests/View/SystemExtensionMetadataProviderTest.php @@ -4,16 +4,16 @@ namespace App\Tests\View; -use App\View\SystemPackageMetadataProvider; +use App\View\SystemExtensionMetadataProvider; use PHPUnit\Framework\TestCase; -final class SystemPackageMetadataProviderTest extends TestCase +final class SystemExtensionMetadataProviderTest extends TestCase { - public function testItExposesSystemPackageMetadataFromRootManifest(): void + public function testItExposesSystemExtensionMetadataFromRootManifest(): void { $projectDir = dirname(__DIR__, 2); $manifest = $this->rootManifest($projectDir); - $metadata = (new SystemPackageMetadataProvider($projectDir))->metadata(); + $metadata = (new SystemExtensionMetadataProvider($projectDir))->metadata(); self::assertSame('system', $metadata['identifier']); self::assertSame($manifest['APP_NAME'], $metadata['name']); diff --git a/tests/View/Template/ExtensionTemplatePathConfiguratorTest.php b/tests/View/Template/ExtensionTemplatePathConfiguratorTest.php new file mode 100644 index 00000000..95ff596b --- /dev/null +++ b/tests/View/Template/ExtensionTemplatePathConfiguratorTest.php @@ -0,0 +1,216 @@ +root = $this->createTemporaryDirectory('system-template-paths'); + } + + protected function tearDown(): void + { + $this->removeDirectory($this->root); + } + + public function testItRegistersScopedExtensionPathsOnTwigFilesystemLoader(): void + { + $this->writeTestFile($this->root, 'templates/frontend/.keep', ''); + $this->writeTestFile($this->root, 'templates/backend/.keep', ''); + $this->writeTestFile($this->root, 'templates/.keep', ''); + $this->writeTestFile($this->root, 'extensions/theme/templates/frontend/.keep', ''); + $this->writeTestFile($this->root, 'extensions/backend/templates/backend/.keep', ''); + $this->writeTestFile($this->root, 'extensions/system/templates/.keep', ''); + $this->writeTestFile($this->root, 'extensions/module/templates/.keep', ''); + $this->writeTestFile($this->root, 'extensions/module/templates/frontend/.keep', ''); + $this->writeTestFile($this->root, 'extensions/module/templates/backend/.keep', ''); + $this->writeTestFile($this->root, 'extensions/captcha/templates/provider/.keep', ''); + $this->writeTestFile($this->root, 'extensions/editor/templates/provider/.keep', ''); + $this->writeTestFile($this->root, 'templates/provider/.keep', ''); + + $loader = new FilesystemLoader(); + $twig = new Environment($loader); + $configurator = new ExtensionTemplatePathConfigurator( + $twig, + new StaticExtensionProvider([ + $this->extension('theme', [ExtensionScope::FrontendTheme]), + $this->extension('backend', [ExtensionScope::BackendTheme]), + $this->extension('system', [ExtensionScope::SystemTemplate]), + $this->extension('module', [ExtensionScope::Module]), + $this->extension('captcha', [ExtensionScope::CaptchaProvider]), + $this->extension('editor', [ExtensionScope::EditorProvider]), + ]), + new ExtensionTemplatePathResolver($this->root), + ); + + $configurator->configure(); + + self::assertSame([ + $this->root.'/extensions/theme/templates/frontend', + $this->root.'/templates/frontend', + $this->root.'/extensions/module/templates/frontend', + ], $loader->getPaths('frontend')); + self::assertSame([ + $this->root.'/extensions/backend/templates/backend', + $this->root.'/templates/backend', + $this->root.'/extensions/module/templates/backend', + ], $loader->getPaths('backend')); + self::assertSame([ + $this->root.'/extensions/system/templates', + $this->root.'/templates', + $this->root.'/extensions/theme/templates', + $this->root.'/extensions/backend/templates', + $this->root.'/extensions/module/templates', + $this->root.'/extensions/captcha/templates', + $this->root.'/extensions/editor/templates', + ], $loader->getPaths('root')); + self::assertSame([ + $this->root.'/extensions/captcha/templates/provider', + $this->root.'/extensions/editor/templates/provider', + $this->root.'/templates/provider', + ], $loader->getPaths('provider')); + } + + public function testItLetsProviderNamespaceResolveActiveProviderBeforeNativeFallback(): void + { + $this->writeTestFile($this->root, 'extensions/captcha/templates/provider/captcha/field.html.twig', 'captcha provider'); + $this->writeTestFile($this->root, 'templates/provider/captcha/field.html.twig', 'captcha native'); + $this->writeTestFile($this->root, 'templates/provider/editor/richtext.html.twig', 'editor native'); + + $loader = new FilesystemLoader(); + $twig = new Environment($loader); + $configurator = new ExtensionTemplatePathConfigurator( + $twig, + new StaticExtensionProvider([ + $this->extension('captcha', [ExtensionScope::CaptchaProvider]), + ]), + new ExtensionTemplatePathResolver($this->root), + ); + + $configurator->configure(); + + self::assertSame('captcha provider', $twig->render('@provider/captcha/field.html.twig')); + self::assertSame('editor native', $twig->render('@provider/editor/richtext.html.twig')); + } + + public function testItRegistersExtensionPathsBeforeIconConsoleCommands(): void + { + $this->writeTestFile($this->root, 'templates/.keep', ''); + $this->writeTestFile($this->root, 'extensions/module/templates/.keep', ''); + + $loader = new FilesystemLoader(); + $twig = new Environment($loader); + $configurator = new ExtensionTemplatePathConfigurator( + $twig, + new StaticExtensionProvider([ + $this->extension('module', [ExtensionScope::Module]), + ]), + new ExtensionTemplatePathResolver($this->root), + ); + + $configurator->onConsoleCommand($this->consoleEvent('ux:icons:lock')); + + self::assertContains($this->root.'/extensions/module/templates', $loader->getPaths('root')); + } + + public function testItDoesNotRegisterExtensionPathsForUnrelatedConsoleCommands(): void + { + $this->writeTestFile($this->root, 'templates/.keep', ''); + $this->writeTestFile($this->root, 'extensions/module/templates/.keep', ''); + + $loader = new FilesystemLoader(); + $twig = new Environment($loader); + $configurator = new ExtensionTemplatePathConfigurator( + $twig, + new StaticExtensionProvider([ + $this->extension('module', [ExtensionScope::Module]), + ]), + new ExtensionTemplatePathResolver($this->root), + ); + + $configurator->onConsoleCommand($this->consoleEvent('cache:clear')); + + self::assertSame([], $loader->getPaths('root')); + } + + /** + * @param list $scopes + */ + private function extension(string $name, array $scopes): Extension + { + return new Extension( + $this->uuid(), + $scopes, + $name, + 'extensions/'.$name, + ); + } + + private function uuid(): string + { + return Uuid::v7()->toRfc4122(); + } + + private function consoleEvent(string $commandName): ConsoleCommandEvent + { + return new ConsoleCommandEvent(new Command($commandName), new ArrayInput([]), new NullOutput()); + } +} + +final readonly class StaticExtensionProvider implements ActiveExtensionProviderInterface +{ + /** + * @param list $extensions + */ + public function __construct(private array $extensions) + { + } + + /** + * @return list + */ + public function extensions(?ExtensionScope $scope = null): array + { + if (null === $scope) { + return $this->extensions; + } + + return array_values(array_filter( + $this->extensions, + static fn (Extension $extension): bool => $extension->hasScope($scope), + )); + } + + public function extension(string $extensionName): ?Extension + { + foreach ($this->extensions as $extension) { + if ($extension->extensionName() === $extensionName) { + return $extension; + } + } + + return null; + } +} diff --git a/tests/View/Template/ExtensionTemplatePathResolverTest.php b/tests/View/Template/ExtensionTemplatePathResolverTest.php new file mode 100644 index 00000000..f8e2cd8b --- /dev/null +++ b/tests/View/Template/ExtensionTemplatePathResolverTest.php @@ -0,0 +1,107 @@ +pathsForNamespace(TemplateNamespace::Frontend, [ + $this->extension('blog', [ExtensionScope::Module]), + $this->extension('captcha', [ExtensionScope::CaptchaProvider]), + $this->extension('theme', [ExtensionScope::FrontendTheme]), + ]); + + self::assertSame([ + '/project/extensions/theme/templates/frontend', + '/project/templates/frontend', + '/project/extensions/blog/templates/frontend', + '/project/extensions/captcha/templates/frontend', + ], $paths); + } + + public function testItOrdersBackendOverridesBeforeNativeFallback(): void + { + $resolver = new ExtensionTemplatePathResolver('/project'); + $paths = $resolver->pathsForNamespace(TemplateNamespace::Backend, [ + $this->extension('module', [ExtensionScope::Module]), + $this->extension('editor', [ExtensionScope::EditorProvider]), + $this->extension('theme', [ExtensionScope::BackendTheme]), + ]); + + self::assertSame([ + '/project/extensions/theme/templates/backend', + '/project/templates/backend', + '/project/extensions/module/templates/backend', + '/project/extensions/editor/templates/backend', + ], $paths); + } + + public function testItKeepsRootOverridesBehindSystemTemplateScope(): void + { + $resolver = new ExtensionTemplatePathResolver('/project'); + $paths = $resolver->pathsForNamespace('@root', [ + $this->extension('plain', [ExtensionScope::Module]), + $this->extension('system', [ExtensionScope::SystemTemplate]), + ]); + + self::assertSame([ + '/project/extensions/system/templates', + '/project/templates', + '/project/extensions/plain/templates', + ], $paths); + } + + public function testItBuildsProviderPathsBeforeNativeFallback(): void + { + $resolver = new ExtensionTemplatePathResolver('/project'); + $paths = $resolver->providerPaths([ + $this->extension('module', [ExtensionScope::Module]), + $this->extension('turnstile', [ExtensionScope::CaptchaProvider]), + $this->extension('tinymce', [ExtensionScope::EditorProvider]), + ]); + + self::assertSame([ + '/project/extensions/turnstile/templates/provider', + '/project/extensions/tinymce/templates/provider', + '/project/templates/provider', + ], $paths); + } + + public function testItRejectsUnknownNamespaces(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('message.view.template_namespace.unsupported'); + + (new ExtensionTemplatePathResolver('/project'))->pathsForNamespace('unknown', []); + } + + /** + * @param list $scopes + */ + private function extension(string $name, array $scopes): Extension + { + return new Extension( + $this->uuid(), + $scopes, + $name, + 'extensions/'.$name, + ); + } + + private function uuid(): string + { + return Uuid::v7()->toRfc4122(); + } +} diff --git a/tests/View/Template/PackageTemplatePathConfiguratorTest.php b/tests/View/Template/PackageTemplatePathConfiguratorTest.php deleted file mode 100644 index ee43f041..00000000 --- a/tests/View/Template/PackageTemplatePathConfiguratorTest.php +++ /dev/null @@ -1,216 +0,0 @@ -root = $this->createTemporaryDirectory('system-template-paths'); - } - - protected function tearDown(): void - { - $this->removeDirectory($this->root); - } - - public function testItRegistersScopedPackagePathsOnTwigFilesystemLoader(): void - { - $this->writeTestFile($this->root, 'templates/frontend/.keep', ''); - $this->writeTestFile($this->root, 'templates/backend/.keep', ''); - $this->writeTestFile($this->root, 'templates/.keep', ''); - $this->writeTestFile($this->root, 'packages/theme/templates/frontend/.keep', ''); - $this->writeTestFile($this->root, 'packages/backend/templates/backend/.keep', ''); - $this->writeTestFile($this->root, 'packages/system/templates/.keep', ''); - $this->writeTestFile($this->root, 'packages/module/templates/.keep', ''); - $this->writeTestFile($this->root, 'packages/module/templates/frontend/.keep', ''); - $this->writeTestFile($this->root, 'packages/module/templates/backend/.keep', ''); - $this->writeTestFile($this->root, 'packages/captcha/templates/provider/.keep', ''); - $this->writeTestFile($this->root, 'packages/editor/templates/provider/.keep', ''); - $this->writeTestFile($this->root, 'templates/provider/.keep', ''); - - $loader = new FilesystemLoader(); - $twig = new Environment($loader); - $configurator = new PackageTemplatePathConfigurator( - $twig, - new StaticPackageProvider([ - $this->package('theme', [PackageScope::FrontendTheme]), - $this->package('backend', [PackageScope::BackendTheme]), - $this->package('system', [PackageScope::SystemTemplate]), - $this->package('module', [PackageScope::Module]), - $this->package('captcha', [PackageScope::CaptchaProvider]), - $this->package('editor', [PackageScope::EditorProvider]), - ]), - new PackageTemplatePathResolver($this->root), - ); - - $configurator->configure(); - - self::assertSame([ - $this->root.'/packages/theme/templates/frontend', - $this->root.'/templates/frontend', - $this->root.'/packages/module/templates/frontend', - ], $loader->getPaths('frontend')); - self::assertSame([ - $this->root.'/packages/backend/templates/backend', - $this->root.'/templates/backend', - $this->root.'/packages/module/templates/backend', - ], $loader->getPaths('backend')); - self::assertSame([ - $this->root.'/packages/system/templates', - $this->root.'/templates', - $this->root.'/packages/theme/templates', - $this->root.'/packages/backend/templates', - $this->root.'/packages/module/templates', - $this->root.'/packages/captcha/templates', - $this->root.'/packages/editor/templates', - ], $loader->getPaths('root')); - self::assertSame([ - $this->root.'/packages/captcha/templates/provider', - $this->root.'/packages/editor/templates/provider', - $this->root.'/templates/provider', - ], $loader->getPaths('provider')); - } - - public function testItLetsProviderNamespaceResolveActiveProviderBeforeNativeFallback(): void - { - $this->writeTestFile($this->root, 'packages/captcha/templates/provider/captcha/field.html.twig', 'captcha provider'); - $this->writeTestFile($this->root, 'templates/provider/captcha/field.html.twig', 'captcha native'); - $this->writeTestFile($this->root, 'templates/provider/editor/richtext.html.twig', 'editor native'); - - $loader = new FilesystemLoader(); - $twig = new Environment($loader); - $configurator = new PackageTemplatePathConfigurator( - $twig, - new StaticPackageProvider([ - $this->package('captcha', [PackageScope::CaptchaProvider]), - ]), - new PackageTemplatePathResolver($this->root), - ); - - $configurator->configure(); - - self::assertSame('captcha provider', $twig->render('@provider/captcha/field.html.twig')); - self::assertSame('editor native', $twig->render('@provider/editor/richtext.html.twig')); - } - - public function testItRegistersPackagePathsBeforeIconConsoleCommands(): void - { - $this->writeTestFile($this->root, 'templates/.keep', ''); - $this->writeTestFile($this->root, 'packages/module/templates/.keep', ''); - - $loader = new FilesystemLoader(); - $twig = new Environment($loader); - $configurator = new PackageTemplatePathConfigurator( - $twig, - new StaticPackageProvider([ - $this->package('module', [PackageScope::Module]), - ]), - new PackageTemplatePathResolver($this->root), - ); - - $configurator->onConsoleCommand($this->consoleEvent('ux:icons:lock')); - - self::assertContains($this->root.'/packages/module/templates', $loader->getPaths('root')); - } - - public function testItDoesNotRegisterPackagePathsForUnrelatedConsoleCommands(): void - { - $this->writeTestFile($this->root, 'templates/.keep', ''); - $this->writeTestFile($this->root, 'packages/module/templates/.keep', ''); - - $loader = new FilesystemLoader(); - $twig = new Environment($loader); - $configurator = new PackageTemplatePathConfigurator( - $twig, - new StaticPackageProvider([ - $this->package('module', [PackageScope::Module]), - ]), - new PackageTemplatePathResolver($this->root), - ); - - $configurator->onConsoleCommand($this->consoleEvent('cache:clear')); - - self::assertSame([], $loader->getPaths('root')); - } - - /** - * @param list $scopes - */ - private function package(string $name, array $scopes): ExtensionPackage - { - return new ExtensionPackage( - $this->uuid(), - $scopes, - $name, - 'packages/'.$name, - ); - } - - private function uuid(): string - { - return Uuid::v7()->toRfc4122(); - } - - private function consoleEvent(string $commandName): ConsoleCommandEvent - { - return new ConsoleCommandEvent(new Command($commandName), new ArrayInput([]), new NullOutput()); - } -} - -final readonly class StaticPackageProvider implements ActivePackageProviderInterface -{ - /** - * @param list $packages - */ - public function __construct(private array $packages) - { - } - - /** - * @return list - */ - public function packages(?PackageScope $scope = null): array - { - if (null === $scope) { - return $this->packages; - } - - return array_values(array_filter( - $this->packages, - static fn (ExtensionPackage $package): bool => $package->hasScope($scope), - )); - } - - public function package(string $packageName): ?ExtensionPackage - { - foreach ($this->packages as $package) { - if ($package->packageName() === $packageName) { - return $package; - } - } - - return null; - } -} diff --git a/tests/View/Template/PackageTemplatePathResolverTest.php b/tests/View/Template/PackageTemplatePathResolverTest.php deleted file mode 100644 index c7f5a6f4..00000000 --- a/tests/View/Template/PackageTemplatePathResolverTest.php +++ /dev/null @@ -1,107 +0,0 @@ -pathsForNamespace(TemplateNamespace::Frontend, [ - $this->package('blog', [PackageScope::Module]), - $this->package('captcha', [PackageScope::CaptchaProvider]), - $this->package('theme', [PackageScope::FrontendTheme]), - ]); - - self::assertSame([ - '/project/packages/theme/templates/frontend', - '/project/templates/frontend', - '/project/packages/blog/templates/frontend', - '/project/packages/captcha/templates/frontend', - ], $paths); - } - - public function testItOrdersBackendOverridesBeforeNativeFallback(): void - { - $resolver = new PackageTemplatePathResolver('/project'); - $paths = $resolver->pathsForNamespace(TemplateNamespace::Backend, [ - $this->package('module', [PackageScope::Module]), - $this->package('editor', [PackageScope::EditorProvider]), - $this->package('theme', [PackageScope::BackendTheme]), - ]); - - self::assertSame([ - '/project/packages/theme/templates/backend', - '/project/templates/backend', - '/project/packages/module/templates/backend', - '/project/packages/editor/templates/backend', - ], $paths); - } - - public function testItKeepsRootOverridesBehindSystemTemplateScope(): void - { - $resolver = new PackageTemplatePathResolver('/project'); - $paths = $resolver->pathsForNamespace('@root', [ - $this->package('plain', [PackageScope::Module]), - $this->package('system', [PackageScope::SystemTemplate]), - ]); - - self::assertSame([ - '/project/packages/system/templates', - '/project/templates', - '/project/packages/plain/templates', - ], $paths); - } - - public function testItBuildsProviderPathsBeforeNativeFallback(): void - { - $resolver = new PackageTemplatePathResolver('/project'); - $paths = $resolver->providerPaths([ - $this->package('module', [PackageScope::Module]), - $this->package('turnstile', [PackageScope::CaptchaProvider]), - $this->package('tinymce', [PackageScope::EditorProvider]), - ]); - - self::assertSame([ - '/project/packages/turnstile/templates/provider', - '/project/packages/tinymce/templates/provider', - '/project/templates/provider', - ], $paths); - } - - public function testItRejectsUnknownNamespaces(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('message.view.template_namespace.unsupported'); - - (new PackageTemplatePathResolver('/project'))->pathsForNamespace('unknown', []); - } - - /** - * @param list $scopes - */ - private function package(string $name, array $scopes): ExtensionPackage - { - return new ExtensionPackage( - $this->uuid(), - $scopes, - $name, - 'packages/'.$name, - ); - } - - private function uuid(): string - { - return Uuid::v7()->toRfc4122(); - } -} diff --git a/tests/View/Twig/ViewTwigExtensionTest.php b/tests/View/Twig/ViewTwigExtensionTest.php index 07db3cac..28c2146d 100644 --- a/tests/View/Twig/ViewTwigExtensionTest.php +++ b/tests/View/Twig/ViewTwigExtensionTest.php @@ -4,6 +4,7 @@ namespace App\Tests\View\Twig; +use App\Core\Manifest\ManifestParser; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Twig\Environment; @@ -14,11 +15,12 @@ public function testItExposesViewGlobalsFunctionsAndMarkdownFilter(): void self::bootKernel(); $twig = self::getContainer()->get(Environment::class); + $systemVersion = $this->rootManifestVersion(); $html = $twig->createTemplate( - '{{ view_context().system_package.name }}|{{ macro_template("core", "ui") }}|{{ event_hooks()|length }}|{{ navigation("main")|length }}|{{ debug_info().hooks is defined ? "debug" : "missing" }}|{{ package_setting("demo-module", "missing.key", "fallback") }}|{{ footer_copyright("backend") }}|{{ "**ok**"|render_markdown }}', + '{{ view_context().system_extension.name }}|{{ macro_template("core", "ui") }}|{{ event_hooks()|length }}|{{ navigation("main")|length }}|{{ debug_info().hooks is defined ? "debug" : "missing" }}|{{ extension_setting("demo-module", "missing.key", "fallback") }}|{{ footer_copyright("backend") }}|{{ "**ok**"|render_markdown }}', )->render(); - self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) 0.2.4|

ok

', $html); + self::assertSame('Studio|@root/macros/core/ui.html.twig|11|4|debug|fallback|Powered by [Studio](https://www.aavion.media) '.$systemVersion.'|

ok

', $html); } public function testItRendersSafeHtmlAttributes(): void @@ -64,6 +66,16 @@ public function testItRendersCodemirrorSyntaxProviderAliases(): void self::assertStringContainsString('data-code-editor-tab-size-value="2"', $html); } + private function rootManifestVersion(): string + { + $projectDir = (string) self::getContainer()->getParameter('kernel.project_dir'); + $result = (new ManifestParser())->parse((string) file_get_contents($projectDir.'/.manifest')); + + self::assertTrue($result->isSuccess(), json_encode($result->toArray(), JSON_THROW_ON_ERROR)); + + return (string) $result->value()->get('APP_VERSION'); + } + public function testItRendersGranularFormAndActionPartials(): void { self::bootKernel(); diff --git a/tests/View/ViewContextProviderTest.php b/tests/View/ViewContextProviderTest.php index 2ce47bef..eca2ec02 100644 --- a/tests/View/ViewContextProviderTest.php +++ b/tests/View/ViewContextProviderTest.php @@ -10,8 +10,8 @@ use App\Core\Event\PublicEventDispatcher; use App\Core\Event\PublicEventHookRegistry; use App\Localization\TranslationLanguageCatalog; -use App\View\PackageMacroRegistry; -use App\View\SystemPackageMetadataProvider; +use App\View\ExtensionMacroRegistry; +use App\View\SystemExtensionMetadataProvider; use App\View\ViewContextEvent; use App\View\ViewContextProvider; use App\Tests\Support\NullWorkflowResultMessageReporter; @@ -21,24 +21,24 @@ final class ViewContextProviderTest extends TestCase { - public function testItDispatchesViewContextForPackageExtensions(): void + public function testItDispatchesViewContextForExtensions(): void { $dispatcher = new EventDispatcher(); $dispatcher->addListener(ViewContextEvent::class, static function (ViewContextEvent $event): void { - $event->set('package_demo', ['enabled' => true]); + $event->set('extension_demo', ['enabled' => true]); }); $context = (new ViewContextProvider( - new SystemPackageMetadataProvider(dirname(__DIR__, 2)), - new PackageMacroRegistry(), + new SystemExtensionMetadataProvider(dirname(__DIR__, 2)), + new ExtensionMacroRegistry(), $this->localization(), new PublicEventDispatcher($dispatcher, new PublicEventHookRegistry(), new NullWorkflowResultMessageReporter()), ))->context(); - self::assertSame('Studio', $context['system_package']['name']); + self::assertSame('Studio', $context['system_extension']['name']); self::assertSame('de', $context['default_locale']); self::assertSame('@root/macros/core/content.html.twig', $context['macro_namespaces']['core']['content']); - self::assertSame(['enabled' => true], $context['package_demo']); + self::assertSame(['enabled' => true], $context['extension_demo']); } private function localization(): ContentRouteLocalization diff --git a/tests/assets/alert_payload.test.mjs b/tests/assets/alert_payload.test.mjs index b8fed53d..7eac3cd5 100644 --- a/tests/assets/alert_payload.test.mjs +++ b/tests/assets/alert_payload.test.mjs @@ -132,7 +132,7 @@ test('createAlertElement filters unsafe action links before rendering and storag id: 'client-alert', message: 'Client alert', actions: [ - { label: 'Open', href: '/admin/packages', target: '_blank' }, + { label: 'Open', href: '/admin/extensions', target: '_blank' }, { label: 'Script', href: 'javascript:alert(1)' }, { label: 'Hostless http', href: 'http:evil.example.test' }, { label: 'External', href: 'https://example.test/privacy', target: '_self' }, @@ -143,13 +143,13 @@ test('createAlertElement filters unsafe action links before rendering and storag const payload = JSON.parse(alert.dataset.alertPayload); assert.equal(actions.length, 3); - assert.equal(actions[0].href, '/admin/packages'); + assert.equal(actions[0].href, '/admin/extensions'); assert.equal(actions[0].target, '_blank'); assert.equal(actions[0].rel, 'noopener noreferrer'); assert.equal(actions[1].href, 'https://example.test/privacy'); assert.equal(actions[2].dataset.alertActionEvent, 'operation-overlay:show'); assert.deepEqual(payload.actions, [ - { label: 'Open', href: '/admin/packages', target: '_blank' }, + { label: 'Open', href: '/admin/extensions', target: '_blank' }, { label: 'External', href: 'https://example.test/privacy', target: '_self' }, { label: 'Event', event: 'operation-overlay:show', detail: { id: 'operation-1' } }, ]); diff --git a/tests/assets/controller_foundation.test.mjs b/tests/assets/controller_foundation.test.mjs index 590cfb34..b87db789 100644 --- a/tests/assets/controller_foundation.test.mjs +++ b/tests/assets/controller_foundation.test.mjs @@ -255,7 +255,7 @@ test('operation overlay marks running detail actions as reusable', () => { controller.updateOperationAlert({ status: 'running', progress: { index: 1, total: 3 }, - label: 'Package registry refresh', + label: 'Extension registry refresh', }); assert.equal(alertPayload.actions[0].event, 'operation-overlay:show'); @@ -263,7 +263,7 @@ test('operation overlay marks running detail actions as reusable', () => { controller.updateOperationAlert({ status: 'success', - label: 'Package registry refresh', + label: 'Extension registry refresh', }); assert.equal(alertPayload.actions[0].detail.keepAlert, false); diff --git a/translations/languages/de/admin.yaml b/translations/languages/de/admin.yaml index eef80e73..c368b005 100644 --- a/translations/languages/de/admin.yaml +++ b/translations/languages/de/admin.yaml @@ -16,7 +16,7 @@ admin: users: 'Benutzer' user_groups: 'ACL-Gruppen' user_reviews: 'Reviews' - packages: 'Packages' + extensions: 'Extensions' themes: 'Themes' scheduler: 'Scheduler' backups: 'Backups' @@ -29,32 +29,36 @@ admin: user_settings: 'Benutzer' mail_settings: 'Mail' security_settings: 'Sicherheit' - package_settings: 'Pakete' + logging_settings: 'Logs' + extension_settings: 'Extensions' statistics_settings: 'Statistiken' api_settings: 'API' + acl_settings: 'ACL' scheduler_settings: 'Scheduler' system_info: 'System-Info' actions: - package_discovery: + extension_discovery: label: 'Registry aktualisieren' asset_rebuild: label: 'Assets neu bauen' cache_clear: label: 'Cache leeren' + geoip_database_update: + label: 'GeoIP2-Datenbank laden' dashboard: title: 'Admin-Dashboard' foundation_title: 'Backend-Fundament' foundation_text: 'Administrative Routen werden jetzt durch den nativen Backend-Router aufgelöst. Funktionale Seiten können hier andocken, sobald Authentifizierung, Navigation und Zugriffsprüfungen bereit sind.' - packages: - title: 'Paketverwaltung' - foundation_title: 'Paket-View-Fundament' - foundation_text: 'Die Paketverwaltung dockt hier an, sobald Installations-, Update-, Aktivierungs- und Cleanup-Workflows verdrahtet sind.' - registry_title: 'Registrierte Pakete' - registry_text: 'In der Registry gefundene Pakete werden hier aufgelistet. Lifecycle-Aktionen docken an, sobald Operation-Flows verdrahtet sind.' - empty: 'Noch sind keine Pakete registriert.' + extensions: + title: 'Extensionverwaltung' + foundation_title: 'Extension-View-Fundament' + foundation_text: 'Die Extensionverwaltung dockt hier an, sobald Installations-, Update-, Aktivierungs- und Cleanup-Workflows verdrahtet sind.' + registry_title: 'Registrierte Extensions' + registry_text: 'In der Registry gefundene Extensions werden hier aufgelistet. Lifecycle-Aktionen docken an, sobald Operation-Flows verdrahtet sind.' + empty: 'Noch sind keine Extensions registriert.' unknown_value: 'Unbekannt' table: - package: 'Paket' + extension: 'Extension' status: 'Status' scopes: 'Scopes' version: 'Version' @@ -78,59 +82,59 @@ admin: none: 'Keine Aktionen' install: label: 'Installieren' - title: 'Paket installieren' - text: 'Lade ein Paket-ZIP hoch. Das Paket-Manifest wird zuerst geprüft und die finale Installation wartet im ActionLog auf Bestätigung.' - file: 'Paket-ZIP' - submit: 'Paket prüfen' + title: 'Extension installieren' + text: 'Lade ein Extension-ZIP hoch. Das Extension-Manifest wird zuerst geprüft und die finale Installation wartet im ActionLog auf Bestätigung.' + file: 'Extension-ZIP' + submit: 'Extension prüfen' close: 'Schließen' meta: author: 'Von %author%' detail: - overview_title: 'Paketübersicht' + overview_title: 'Extensionübersicht' immutable: 'Unveränderlich' description: 'Beschreibung' author: 'Autor' license: 'Lizenz' dependencies: 'Abhängigkeiten' - dependencies_empty: 'Keine Paketabhängigkeiten' + dependencies_empty: 'Keine Extensionabhängigkeiten' homepage: 'Homepage' source: 'Quelle' readme_title: 'README' lifecycle: review_title: 'Änderungen prüfen' - immutable: 'Dieses Systempaket ist unveränderlich und kann seinen Lifecycle-State nicht ändern.' + immutable: 'Diese System-Extension ist unveränderlich und kann ihren Lifecycle-State nicht ändern.' unavailable: 'Diese Lifecycle-Aktion ist nicht verfügbar.' - no_changes: 'Diese Aktion muss keinen Paketstatus ändern.' + no_changes: 'Diese Aktion muss keinen Extension-Status ändern.' cancel: 'Abbrechen' table: action: 'Aktion' activate: label: 'Aktivieren' - title: '%package% aktivieren' - text: 'Prüfe Abhängigkeiten und Konflikte, bevor dieses Paket aktiviert wird.' - submit: 'Paket aktivieren' + title: '%extension% aktivieren' + text: 'Prüfe Abhängigkeiten und Konflikte, bevor diese Extension aktiviert wird.' + submit: 'Extension aktivieren' deactivate: label: 'Deaktivieren' - title: '%package% deaktivieren' - text: 'Prüfe die geplante Statusänderung, bevor dieses Paket deaktiviert wird.' - submit: 'Paket deaktivieren' + title: '%extension% deaktivieren' + text: 'Prüfe die geplante Statusänderung, bevor diese Extension deaktiviert wird.' + submit: 'Extension deaktivieren' reset_fault: label: 'Fehler zurücksetzen' - title: 'Fehler für %package% zurücksetzen' - text: 'Das Paket wird erneut validiert und bei erfolgreicher Wiederherstellung auf inaktiv gesetzt.' + title: 'Fehler für %extension% zurücksetzen' + text: 'Die Extension wird erneut validiert und bei erfolgreicher Wiederherstellung auf inaktiv gesetzt.' submit: 'Fehler zurücksetzen' purge: label: 'Daten löschen' - title: 'Daten für %package% löschen' - text: 'Prüfe den Cleanup paketbezogener Daten, bevor diese Paketregistrierung zurückgesetzt wird.' - warning: 'Dieser Schritt ist irreversibel. Paketeigene Einstellungen und spätere paketeigene Cleanup-Ziele werden gelöscht.' + title: 'Daten für %extension% löschen' + text: 'Prüfe den Cleanup extensionbezogener Daten, bevor diese Extension-Registrierung zurückgesetzt wird.' + warning: 'Dieser Schritt ist irreversibel. Extension-eigene Einstellungen und spätere extension-eigene Cleanup-Ziele werden gelöscht.' submit: 'Daten löschen' delete: - label: 'Paket löschen' - title: '%package% löschen' - text: 'Prüfe die Dateisystem-Entfernung, bevor dieses Paket von der Festplatte gelöscht wird.' - warning: 'Dieser Schritt ist irreversibel. Die Paketdateien werden gelöscht und der Registry-Eintrag bleibt als entfernt bestehen, bis Daten separat gelöscht werden.' - submit: 'Paket löschen' + label: 'Extension löschen' + title: '%extension% löschen' + text: 'Prüfe die Dateisystem-Entfernung, bevor diese Extension von der Festplatte gelöscht wird.' + warning: 'Dieser Schritt ist irreversibel. Die Extension-Dateien werden gelöscht und der Registry-Eintrag bleibt als entfernt bestehen, bis Daten separat gelöscht werden.' + submit: 'Extension löschen' themes: title: 'Theme-Verwaltung' foundation_title: 'Theme-Verwaltungsfundament' @@ -145,7 +149,7 @@ admin: text: 'Themes für Administrations- und Editor-Shells sowie der native System-Fallback werden hier aufgelistet.' type: system: 'System' - package: 'Paket' + extension: 'Extension' quick: use: 'Verwenden' active: 'Aktiv' @@ -472,9 +476,9 @@ admin: ui_alert_inbox_cleanup: label: 'UI-Alert-Inbox-Cleanup' description: 'Entfernt zwischengespeicherte Benachrichtigungen nach Ablauf ihrer Zustell-Aufbewahrung.' - package_discovery: - label: 'Paket-Discovery' - description: 'Findet Pakete und aktualisiert die Paket-Registry.' + extension_discovery: + label: 'Extension-Discovery' + description: 'Findet Extensions und aktualisiert die Extension-Registry.' statistics_snapshot: label: 'Statistik-Snapshot' description: 'Aktualisiert den gespeicherten Zugriffsstatistik-Snapshot, wenn Statistiken aktiviert sind.' @@ -484,6 +488,9 @@ admin: mercure_health: label: 'Mercure-Health' description: 'Prüft den Mercure-Hub und startet den lokalen Hub, sofern unterstützt.' + geoip2_database_update: + label: 'GeoIP2-Datenbankupdate' + description: 'Lädt die lokale MaxMind GeoIP2-City-Datenbank herunter und ersetzt sie atomar, wenn ein License-Key konfiguriert ist.' table: job: 'Job' source: 'Quelle' @@ -511,17 +518,17 @@ admin: failure_count: 'Fehler in Folge' run_url: 'Direkte Ausführungs-URL' run_now_inactive: 'Aktiviere diesen Job, bevor du ihn manuell ausführst.' - package_action_queue_warning: 'Dieser Paket-Job kann eine ActionQueue ausführen und wird über die Scheduler-Einstellung für Paket-ActionQueues gesteuert.' + extension_action_queue_warning: 'Dieser Extension-Job kann eine ActionQueue ausführen und wird über die Scheduler-Einstellung für Extension-ActionQueues gesteuert.' form: title: 'Job-Einstellungen' enabled: 'Diesen Job automatisch ausführen' cron_expression: 'Cron-Ausdruck' - confirm_package_action_queue: 'Ich verstehe, dass dieser Paket-Job eine ActionQueue ausführen kann.' + confirm_extension_action_queue: 'Ich verstehe, dass dieser Extension-Job eine ActionQueue ausführen kann.' saved: 'Scheduler-Job gespeichert.' errors: invalid_csrf: 'Das Scheduler-Formular ist abgelaufen. Bitte versuche es erneut.' cron_invalid: 'Gib einen gültigen Cron-Ausdruck ein.' - package_action_queue_confirmation_required: 'Bestätige den Paket-ActionQueue-Hinweis, bevor du diesen Job aktivierst.' + extension_action_queue_confirmation_required: 'Bestätige den Extension-ActionQueue-Hinweis, bevor du diesen Job aktivierst.' runs: title: 'Letzte Läufe' empty: 'Dieser Job wurde noch nicht ausgeführt.' @@ -590,22 +597,24 @@ admin: logs: title: 'Logs' foundation_title: 'Log- und Audit-Fundament' - foundation_text: 'Operations-Logs, Audit-Ansichten, Paketdiagnostik und sicherheitsrelevante Ereignisse docken hier an.' + foundation_text: 'Operations-Logs, Audit-Ansichten, Extensiondiagnostik und sicherheitsrelevante Ereignisse docken hier an.' empty_value: 'n/a' sources: + label: 'Logquellen' application: 'Anwendung' message: 'Meldungen' audit: 'Audit' access: 'Zugriffe' + security_signal: 'Security-Signale' filters: title: 'Filter' source: 'Log' level: 'Level' - any_level: 'Alle Level' search: 'Suche' window: 'Zeitraum' match: 'Treffer' audit_action: 'Audit-Aktion' + signal_reason: 'Signal-Grund' per_page: 'Zeilen' limit: 'Limit' contains: 'Enthält' @@ -620,8 +629,6 @@ admin: entries: title: 'Einträge' empty: 'Keine Log-Einträge entsprechen den aktuellen Filtern.' - no_files: 'Für diese Auswahl existiert noch keine Log-Datei.' - files: 'Gelesen wird: %files%' open: 'Details' page_summary: 'Seite %page% von %pages% · %total% Einträge' previous: 'Zurück' @@ -637,6 +644,8 @@ admin: location: 'Ort' user: 'Nutzer' action: 'Aktion' + security_signal: 'Signal' + subject: 'Subjekt' channel: 'Channel' context: 'Kontext' details: 'Details' @@ -645,6 +654,31 @@ admin: context: 'Kontext' no_context: 'Es ist kein strukturierter Kontext verfügbar.' raw: 'Rohzeile' + auto_bans: + active: + title: 'Aktive Auto-Bans' + empty: 'Keine aktiven Auto-Bans.' + open: 'Prüfen' + open_list: 'Aktive Auto-Bans öffnen' + disabled: 'Auto-Ban-Enforcement ist deaktiviert. Bestehende TTL-States bleiben bis zum Ablauf erhalten, werden aber nicht durchgesetzt.' + columns: + subject: 'Subjekt' + created_at: 'Erstellt' + expires_at: 'Läuft ab' + ttl: 'TTL-Sekunden' + details: 'Details' + detail: + title: 'Auto-Ban-Detail' + signals: 'Zugehörige Security-Signale' + no_signals: 'Für diesen aktiven Bann sind keine aufbewahrten Signale verfügbar.' + trigger_country: 'Auslöse-Land' + trigger_continent: 'Auslöse-Kontinent' + reset: + submit: 'Auto-Ban zurücksetzen' + saved: 'Auto-Ban zurückgesetzt.' + failed: 'Auto-Ban konnte nicht zurückgesetzt werden.' + alerts: + triggered: 'Auto-Ban für %subject% erstellt.' statistics: title: 'Statistiken' access_title: 'Zugriffsstatistiken' @@ -675,8 +709,12 @@ admin: all: 'Alle gespeicherten Events' settings: title: 'Einstellungen' + acl: + title: 'ACL' + foundation_title: 'ACL-Matrix' + foundation_text: 'Owner-gesteuerte Feature-Berechtigungen für Admin-, Editor- und zukünftige Frontend-Surfaces.' redirect_title: 'Einstellungsbereiche' - redirect_text: 'Allgemeine Einstellungen sind der erste eingebaute Einstellungsbereich. Aktive Pakete mit einfachen Setting-Definitionen erscheinen unter den Paket-Einstellungen.' + redirect_text: 'Allgemeine Einstellungen sind der erste eingebaute Einstellungsbereich. Aktive Extensions mit einfachen Setting-Definitionen erscheinen unter den Extension-Einstellungen.' general: title: 'Allgemeine Einstellungen' foundation_title: 'Allgemeines Konfigurationsfundament' @@ -696,23 +734,41 @@ admin: security: title: 'Sicherheits-Einstellungen' foundation_title: 'Sicherheits-Konfigurationsfundament' - foundation_text: 'Sicherheits-, Captcha-, Logging- und Missbrauchsschutz-Einstellungen docken hier an.' + foundation_text: 'Sicherheits-, Captcha-, passive Signal-Retention- und Missbrauchsschutz-Einstellungen docken hier an.' + logging: + title: 'Log-Einstellungen' + foundation_title: 'Datenbank-Log-Projektionen' + foundation_text: 'Message-, Audit- und Access-Logs behalten separat ihre festen rotierenden File-Logs. Diese Einstellungen steuern, wie lange die durchsuchbaren Datenbank-Kopien verfügbar bleiben.' statistics: title: 'Statistik-Einstellungen' foundation_title: 'Statistik-Konfiguration' - foundation_text: 'Aufzeichnung und Anzeige von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.' + foundation_text: 'Aufzeichnung, Anzeige und GeoIP-Anreicherung von Zugriffsstatistiken können unabhängig vom Raw-Access-Logging deaktiviert werden. Raw-Access-Logs bleiben für operative Sicherheit verfügbar und werden 30 Tage aufbewahrt.' + geoip: + status: + title: 'GeoIP2-Status' + provider: 'Provider' + state: 'Status' + database_edition: 'Datenbank-Edition' + database_build_date: 'Datenbank-Build-Datum' + failure_code: 'Fehlercode' + values: + ready: 'Bereit' + disabled: 'Deaktiviert' + unconfigured: 'Nicht konfiguriert' + unavailable: 'Nicht verfügbar' + unknown: 'Unbekannt' api: title: 'API-Einstellungen' foundation_title: 'API-Verfügbarkeit' foundation_text: 'API-Zugriff kann für normale Nutzer deaktiviert werden, während Owner das Key-Management für operative Integrationen wie Scheduler-Aktivierung behalten.' - packages: - title: 'Paket-Einstellungen' - foundation_title: 'Pakete mit Einstellungen' - empty: 'Noch kein aktives Paket stellt Einstellungen bereit.' - package: - title: 'Einstellungen für %package%' - foundation_title: 'Paket-Einstellungen' - empty: 'Dieses Paket stellt keine konfigurierbaren Einstellungen bereit.' + extensions: + title: 'Extension-Einstellungen' + foundation_title: 'Extensions mit Einstellungen' + empty: 'Noch keine aktive Extension stellt Einstellungen bereit.' + extension: + title: 'Einstellungen für %extension%' + foundation_title: 'Extension-Einstellungen' + empty: 'Diese Extension stellt keine konfigurierbaren Einstellungen bereit.' options: '%count% Option(en)' validation: '%count% Validierungsregel(n)' scheduler: @@ -738,6 +794,153 @@ admin: required: 'Dieses Feld ist erforderlich.' save_failed: 'Die Einstellungen konnten nicht gespeichert werden.' default_acl_group_unavailable: 'Trage eine bestehende ACL-Gruppe mit Mindestrolle User oder niedriger ein oder lasse das Feld leer.' + fields: + captcha_enabled: + label: 'Captcha aktivieren' + captcha_provider: + label: 'Captcha-Provider' + captcha_preview: + label: 'Captcha-Vorschau' + rate_limit_mode: + label: 'Ratenbegrenzung' + auto_ban_enabled: + label: 'Auto-Ban aktivieren' + help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.' + auto_ban_trusted_access_level: + label: 'Trusted-User-Level' + help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.' + auto_ban_score_threshold: + label: 'Auto-Ban-Score-Schwelle' + help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.' + auto_ban_new_ban_owner_alerts: + label: 'Alerts für neue Auto-Bans aktivieren' + help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.' + audit_enabled: + label: 'Audit-Logging aktivieren' + audit_events: + label: 'Audit-Ereigniskategorien' + security_signal_retention_days: + label: 'Retention für Security-Signale' + help: 'Retention für passive Security-Signale in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Signale Request-bezogene Identifier enthalten können.' + security_probe_path_patterns: + label: 'Pfadmuster für verdächtige Probes' + help: 'Ein regulärer Ausdruck pro Zeile. Quoted-CSV-Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' + options: + captcha: + none: 'Kein Captcha-Provider' + rate_limit_mode: + off: 'Aus' + standard: 'Standard' + strict: 'Streng' + panic: 'Panik' + access_level: + user: 'Benutzer' + moderator: 'Moderator' + author: 'Autor' + publisher: 'Publisher' + curator: 'Kurator' + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' + audit: + authentication: 'Authentifizierungsereignisse' + backend_actions: 'Backend-Wartungsaktionen' + operations: 'Operations-Wartungsaktionen' + extensions: 'Extension-Lifecycle-Aktionen' + settings: 'Einstellungsänderungen' + other: 'Weitere zukünftige Audit-Ereignisse' + acl: + actions: + save: 'ACL-Matrix speichern' + categories: + diagnostics: 'Diagnostik' + operations: 'Operationen' + extensions: 'Extensions' + extension_settings: 'Extension-Einstellungen' + settings: 'Einstellungen' + system: 'System' + users: 'Benutzer' + empty: 'Für diese Surface sind noch keine Feature-Flags registriert.' + groups: + empty: 'Keine ACL-Gruppen können diese Surface gewähren.' + non_configurable: 'Read-only' + read_only_action: 'Du darfst diese Aktion prüfen, aber die aktuelle ACL-Policy erlaubt keine Mutation.' + states: + inherit: 'Erben' + denied: 'Verweigert' + visible: 'Sichtbar' + mutable: 'Mutierbar' + surfaces: + label: 'ACL-Surfaces' + admin: 'Admin' + admin_text: 'Administrative Feature-Flags. ACL-Gruppen können Berechtigungen nur an Benutzer vergeben, die bereits das Admin-Surface-Gate passieren.' + editor: 'Editor' + editor_text: 'Für zukünftige Editor-Feature-Flags vorbereitet. Editor-Provider können hier später Zeilen registrieren.' + frontend: 'Frontend' + frontend_text: 'Reserviert für ausdrücklich entworfene zukünftige Frontend-Features wie Inline-Editing.' + table: + admin_state: 'Admin' + default: 'Default' + feature: 'Feature' + groups: 'ACL-Gruppen-Grants' + features: + admin_settings_security: + label: 'Sicherheits-Einstellungen' + description: 'Security-Policy, Audit-Kategorien, Signal-Retention und verdächtige Probe-Pfade.' + admin_settings_logging: + label: 'Log-Retention-Einstellungen' + description: 'Datenbank-Lookup-Retention für Message-, Audit- und Access-Log-Projektionen.' + admin_settings_statistics: + label: 'Statistik-Einstellungen' + description: 'Aufzeichnung und Anzeige von Zugriffsstatistiken.' + admin_settings_statistics_geoip: + label: 'GeoIP-Einstellungen' + description: 'GeoIP-Aktivierung, lokaler Datenbankpfad, geschützter MaxMind-License-Key und Status.' + admin_settings_api: + label: 'API-Einstellungen' + description: 'Globale API-Verfügbarkeit und CORS-Erweiterungen.' + admin_settings_scheduler: + label: 'Scheduler-Einstellungen' + description: 'Scheduler-Aktivierung, Web-Triggering, GET-Authentifizierung und Extension-Action-Queues.' + admin_settings_extensions: + label: 'Extension-Einstellungen' + description: 'Core-Einstellungen für Extension-Updates und extension-eigene Einstellungsseiten.' + admin_settings_extension: + description: 'Extension-eigene Einstellungsseite für eine aktive Extension.' + admin_logs: + label: 'Logs' + description: 'Administrative Log-Review-Flächen.' + admin_extensions: + label: 'Extensions und Themes' + description: 'Extension- und Themeverwaltung inklusive Installation, Aktivierung, Deaktivierung, Reparatur, Löschung und Purge-Workflows.' + admin_extensions_self_update: + label: 'System-Extension-Self-Update' + description: 'Owner-only Self-Update-Workflows für die System-Extension.' + admin_backup_restore: + label: 'Backup, Export und Restore' + description: 'Full-Data-Backup, Export, Download und Restore-Workflows.' + admin_operations: + label: 'Operationen' + description: 'Live-Operation-Inspektion und operative Statusflächen.' + admin_actions_maintenance: + label: 'Wartungsaktionen' + description: 'Manuelle Wartungsaktionen wie Cache-Clear und Asset-Rebuild.' + admin_scheduler: + label: 'Scheduler' + description: 'Scheduler-Task-Inspektion und Run-Controls.' + admin_users: + label: 'Benutzer' + description: 'Benutzeradministration und Account-Verwaltung.' + admin_users_acl: + label: 'Benutzer-ACL-Gruppen' + description: 'ACL-Gruppenadministration und Membership-Review.' + admin_users_review: + label: 'Benutzer-Reviews' + description: 'Queues für Einladungen, Registrierungen und Benutzer-Reviews.' + admin_support: + label: 'Support-Bundles' + description: 'Erzeugung und Download von Diagnose- oder Support-Bundles.' fields: site_title: label: 'Seitentitel' @@ -786,10 +989,50 @@ admin: label: 'Captcha-Provider' captcha_preview: label: 'Captcha-Vorschau' + rate_limit_mode: + label: 'Ratenbegrenzung' + auto_ban_enabled: + label: 'Auto-Ban aktivieren' + help: 'Blockiert wiederholt verdächtige Visitor/IP-Aktivität vorübergehend. Trusted User bleiben ausgenommen.' + auto_ban_trusted_access_level: + label: 'Trusted-User-Level' + help: 'Registrierte Benutzer ab diesem Access-Level werden nie automatisch gebannt.' + auto_ban_score_threshold: + label: 'Auto-Ban-Score-Schwelle' + help: 'Score, der innerhalb einer Stunde erreicht werden muss, bevor ein neuer Visitor-Bann entstehen kann. Reine IP-Banns verwenden intern eine höhere Schwelle.' + auto_ban_new_ban_owner_alerts: + label: 'Alerts für neue Auto-Bans aktivieren' + help: 'Stellt Owner-Accounts eine versteckte Warnung zu, wenn ein neuer aktiver Auto-Ban erzeugt wird.' audit_enabled: label: 'Audit-Logging aktivieren' audit_events: label: 'Audit-Ereigniskategorien' + message_log_retention_days: + label: 'Datenbank-Retention für Meldungslogs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. File-Logs behalten separat ihre feste 30-Tage-Rotation.' + audit_log_retention_days: + label: 'Datenbank-Retention für Audit-Logs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Audit-Daten Request-bezogene Identifier enthalten können.' + access_log_retention_days: + label: 'Datenbank-Retention für Access-Logs' + help: 'Retention der Datenbank-Lookup-Kopie in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Access-Logs IP-abgeleitete Daten enthalten können.' + security_signal_retention_days: + label: 'Retention für Security-Signale' + help: 'Retention für passive Security-Signale in Tagen. Werte über 30 Tagen sind nicht erlaubt, weil Signale Request-bezogene Identifier enthalten können.' + security_probe_path_patterns: + label: 'Pfadmuster für verdächtige Probes' + help: 'Ein regulärer Ausdruck pro Zeile. Quoted-CSV-Imports werden akzeptiert. Ungültige oder leere Listen fallen auf die geschützten Defaults zurück.' + geoip_enabled: + label: 'GeoIP-Lookups aktivieren' + help: 'Wenn deaktiviert oder nicht verfügbar, behalten Logs und Statistiken normalisierte n/a-Ortswerte.' + geoip_license_link: + label: 'Kostenlosen GeoLite2-License-Key bei MaxMind erhalten' + geoip_database_path: + label: 'MaxMind-Datenbankpfad' + help: 'Projektrelativer Pfad zur lokalen .mmdb-Datenbank. Downloads werden atomar in diesen Pfad geschrieben.' + geoip_license_key: + label: 'MaxMind-License-Key' + help: 'Wird als sensitive Konfiguration gespeichert. Ein gespeicherter Key aktiviert die GeoIP2-Download-Aktion auf dieser Seite.' statistics_enabled: label: 'Zugriffsstatistiken aktivieren' statistics_respect_dnt: @@ -803,34 +1046,44 @@ admin: api_cors_allowed_origins: label: 'Erlaubte API-CORS-Origins' help: 'JSON-Array exakter Origins wie ["https://client.example"]. Der Wildcard "*" sollte nur bewusst verwendet werden.' - package_update_interval: + extension_update_interval: label: 'Update-Check-Intervall' - package_auto_updates: - label: 'Automatische Paket-Updates erlauben' + extension_auto_updates: + label: 'Automatische Extension-Updates erlauben' scheduler_enabled: label: 'Scheduler aktivieren' scheduler_get_auth_enabled: label: 'GET-Authentifizierung für /cron/run erlauben' help: 'Bearer-Authentifizierung bleibt bevorzugt. Aktiviere GET-Authentifizierung nur für Hosting-Scheduler, die keine Header senden können.' - scheduler_package_action_queues_enabled: - label: 'Scheduler-Action-Queues aus Paketen erlauben' - help: 'Action-Queues aus Paketen können Datei- oder Prozessoperationen ausführen. Core-Scheduler-Queues gelten separat als vertrauenswürdig.' + scheduler_extension_action_queues_enabled: + label: 'Scheduler-Action-Queues aus Extensions erlauben' + help: 'Action-Queues aus Extensions können Datei- oder Prozessoperationen ausführen. Core-Scheduler-Queues gelten separat als vertrauenswürdig.' scheduler_web_trigger_enabled: label: 'Scheduler durch Seitenaufrufe starten' help: 'Startet nach geeigneten Seitenaufrufen höchstens einmal pro Minute einen losgelösten Scheduler-Lauf. Nutze einen echten Cronjob, wenn verfügbar.' options: dashboard: system_status: 'Systemstatus' - packages: 'Paketstatus' + extensions: 'Extension-Status' recent_activity: 'Letzte Aktivität' setup_warnings: 'Setup-Warnungen' captcha: none: 'Kein Captcha-Provider' + rate_limit_mode: + off: 'Aus' + standard: 'Standard' + strict: 'Streng' + panic: 'Panik' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentifizierungsereignisse' backend_actions: 'Backend-Wartungsaktionen' operations: 'Operations-Wartungsaktionen' - packages: 'Paket-Lifecycle-Aktionen' + extensions: 'Extension-Lifecycle-Aktionen' settings: 'Einstellungsänderungen' other: 'Weitere zukünftige Audit-Ereignisse' registration: diff --git a/translations/languages/de/editor.yaml b/translations/languages/de/editor.yaml index 0943f24a..7d53e8b3 100644 --- a/translations/languages/de/editor.yaml +++ b/translations/languages/de/editor.yaml @@ -10,6 +10,6 @@ editor: title: 'Editor-Dashboard' status: 'Content-Arbeitsbereich' foundation_title: 'Editor-Fundament' - foundation_text: 'Content-Routen können an diese Shell andocken, sobald Editor-Workflows, Schema-Werkzeuge und Package-Beiträge verdrahtet sind.' + foundation_text: 'Content-Routen können an diese Shell andocken, sobald Editor-Workflows, Schema-Werkzeuge und Extension-Beiträge verdrahtet sind.' message: title: 'Editor-Route' diff --git a/translations/languages/de/message.yaml b/translations/languages/de/message.yaml index 75db020e..dc708dc8 100644 --- a/translations/languages/de/message.yaml +++ b/translations/languages/de/message.yaml @@ -8,97 +8,108 @@ message: unknown_key: 'Manifest-Key "%key%" wird hier nicht unterstützt.' parsed: 'Manifest wurde geparst.' validated: 'Manifest wurde validiert.' - package: + extension: identifier: - invalid: 'Paket-Identifier "%identifier%" enthält nicht unterstützte Zeichen.' - manifest_unreadable: 'Paket-Manifest "%path%" kann nicht gelesen werden.' - required_file_missing: 'Erforderliche Paketdatei "%path%" fehlt.' - required_directory_missing: 'Erforderliches Paketverzeichnis "%path%" fehlt.' - file_unreadable: 'Paketdatei "%path%" kann nicht gelesen werden.' + invalid: 'Extension-Identifier "%identifier%" enthält nicht unterstützte Zeichen.' + manifest_unreadable: 'Extension-Manifest "%path%" kann nicht gelesen werden.' + required_file_missing: 'Erforderliche Extension-Datei "%path%" fehlt.' + required_directory_missing: 'Erforderliches Extension-Verzeichnis "%path%" fehlt.' + file_unreadable: 'Extension-Datei "%path%" kann nicht gelesen werden.' php_syntax_error: 'PHP-Syntaxprüfung für "%path%" fehlgeschlagen.' - php_namespace_invalid: 'PHP-Quelle "%path%" muss den Paket-Namespace "%expected_namespace%" verwenden.' + php_namespace_invalid: 'PHP-Quelle "%path%" muss den Extension-Namespace "%expected_namespace%" verwenden.' twig_syntax_error: 'Twig-Syntaxprüfung für "%path%" fehlgeschlagen.' - translation_fallback_missing: 'Paket-Translation-Quellen für "%package%" müssen mindestens einen Fallback-Katalog unter "languages/%locale%" enthalten.' - translation_namespace_invalid: 'Paket-Translation-Datei "%path%" muss Keys unterhalb von "pkg.%package%" definieren.' + translation_fallback_missing: 'Extension-Translation-Quellen für "%extension%" müssen mindestens einen Fallback-Katalog unter "languages/%locale%" enthalten.' + translation_namespace_invalid: 'Extension-Translation-Datei "%path%" muss Keys unterhalb von "ext.%extension%" definieren.' json_syntax_error: 'JSON-Syntaxprüfung für "%path%" fehlgeschlagen.' yaml_syntax_error: 'YAML-Syntaxprüfung für "%path%" fehlgeschlagen.' css_syntax_error: 'CSS-Syntaxprüfung für "%path%" fehlgeschlagen.' - css_namespace_invalid: 'CSS-Klasse "%class%" in "%path%" muss den paket-eigenen Prefix "%expected_prefix%" verwenden.' + css_namespace_invalid: 'CSS-Klasse "%class%" in "%path%" muss den extension-eigenen Prefix "%expected_prefix%" verwenden.' javascript_syntax_error: 'JavaScript-Syntaxprüfung für "%path%" fehlgeschlagen.' - scope_invalid: 'Paket-Scope "%scope%" wird nicht unterstützt.' - template_path_invalid: 'Paket-Template "%path%" ist durch Root-, Provider- oder Makro-Namespace-Regeln für Paket-Scope "%scope%" nicht erlaubt.' - template_reference_invalid: 'Paket-Template "%path%" darf Template "%reference%" außerhalb des eigenen Template-Scopes nicht referenzieren.' + scope_invalid: 'Extension-Scope "%scope%" wird nicht unterstützt.' + template_path_invalid: 'Extension-Template "%path%" ist durch Root-, Provider- oder Makro-Namespace-Regeln für Extension-Scope "%scope%" nicht erlaubt.' + template_reference_invalid: 'Extension-Template "%path%" darf Template "%reference%" außerhalb des eigenen Template-Scopes nicht referenzieren.' policy: - blocked_path: 'Paketpfad "%path%" wird durch die Paket-Policy blockiert (%reason%).' - warned_path: 'Paketpfad "%path%" sollte durch die Paket-Policy geprüft werden (%reason%).' - blocked_php_capability: 'Paket-PHP-Datei "%path%" nutzt die blockierte Capability "%capability%" (%reason%). Nutze stattdessen einen dokumentierten Extension-Point.' - copy_source_missing: 'Paket-Kopierquelle "%path%" fehlt.' - copy_source_symlink: 'Paket-Kopierquelle "%path%" darf kein Symlink sein.' - asset_sync_completed: 'Paket-Asset-Sync hat %assets% Asset(s) aus %packages% Paket(en) gespiegelt.' - asset_sync_failed: 'Paket-Asset-Sync fehlgeschlagen: %message%' - asset_rebuild_queued: 'Paket-Asset-Rebuild wurde durch "%trigger%" eingereiht.' - asset_rebuild_queue_failed: 'Paket-Asset-Rebuild konnte durch "%trigger%" nicht eingereiht werden.' + blocked_path: 'Extension-Pfad "%path%" wird durch die Extension-Policy blockiert (%reason%).' + warned_path: 'Extension-Pfad "%path%" sollte durch die Extension-Policy geprüft werden (%reason%).' + blocked_php_capability: 'Extension-PHP-Datei "%path%" nutzt die blockierte Capability "%capability%" (%reason%). Nutze stattdessen einen dokumentierten Extension-Point.' + copy_source_missing: 'Extension-Kopierquelle "%path%" fehlt.' + copy_source_symlink: 'Extension-Kopierquelle "%path%" darf kein Symlink sein.' + asset_sync_completed: 'Extension-Asset-Sync hat %assets% Asset(s) aus %extensions% Extension(en) gespiegelt.' + asset_sync_failed: 'Extension-Asset-Sync fehlgeschlagen: %message%' + asset_rebuild_queued: 'Extension-Asset-Rebuild wurde durch "%trigger%" eingereiht.' + asset_rebuild_queue_failed: 'Extension-Asset-Rebuild konnte durch "%trigger%" nicht eingereiht werden.' asset: - contribution_package_invalid: 'Paket-Asset-Contribution benötigt einen Paket-Identifier.' - contribution_type_invalid: 'Paket-Asset-Contribution-Typ "%type%" wird nicht unterstützt.' - contribution_path_invalid: 'Paket-Asset-Pfad "%path%" muss projektrelativ sein.' - contribution_path_traversal: 'Paket-Asset-Pfad "%path%" darf keine übergeordneten Verzeichnisse durchlaufen.' - discovery_queued: 'Paket-Discovery wurde durch "%trigger%" eingereiht.' - discovery_queue_failed: 'Paket-Discovery konnte durch "%trigger%" nicht eingereiht werden.' - discovery_completed: 'Paket-Discovery hat %count% Kandidat(en) gefunden.' - validation_completed: 'Paket "%package%" hat die Validierung bestanden.' - copy_plan_created: 'Paket-Kopierplan enthält %count% Datei(en).' + contribution_extension_invalid: 'Extension-Asset-Contribution benötigt einen Extension-Identifier.' + contribution_type_invalid: 'Extension-Asset-Contribution-Typ "%type%" wird nicht unterstützt.' + contribution_path_invalid: 'Extension-Asset-Pfad "%path%" muss projektrelativ sein.' + contribution_path_traversal: 'Extension-Asset-Pfad "%path%" darf keine übergeordneten Verzeichnisse durchlaufen.' + database: + contribution_invalid: 'Extension-Datenbank-Contribution ist ungültig (%reason%).' + sync_completed: 'Datenbank-Sync für Extension "%extension%" hat %count% Tabelle(n) erstellt.' + purge_completed: 'Datenbank-Purge für Extension "%extension%" hat %count% Tabelle(n) gelöscht.' + content_schema: + contribution_invalid: 'Extension-Content-Schema-Contribution ist ungültig (%reason%).' + sync_completed: 'Content-Schema-Sync für Extension "%extension%" hat %count% Schema/Schemata erstellt oder versioniert.' + purge_completed: 'Content-Schema-Purge für Extension "%extension%" hat %count% Schema/Schemata gelöscht.' + purge_retained: 'Content-Schema-Purge für Extension "%extension%" hat %count% referenzierte Schema/Schemata behalten.' + content_archived: '%count% Content-Element(e) mit deaktivierten Extension-Schemata wurden archiviert.' + discovery_queued: 'Extension-Discovery wurde durch "%trigger%" eingereiht.' + discovery_queue_failed: 'Extension-Discovery konnte durch "%trigger%" nicht eingereiht werden.' + discovery_completed: 'Extension-Discovery hat %count% Kandidat(en) gefunden.' + validation_completed: 'Extension "%extension%" hat die Validierung bestanden.' + copy_plan_created: 'Extension-Kopierplan enthält %count% Datei(en).' registry: - sync_completed: 'Paket-Registry-Sync hat %count% Änderung(en) erfasst.' - registered: 'Paket "%package%" wurde inaktiv registriert.' - updated: 'Registry-Metadaten für Paket "%package%" wurden aktualisiert.' - removed: 'Paket "%package%" fehlt im Dateisystem und wurde als entfernt markiert.' - faulty: 'Paket "%package%" hat die Validierung nicht bestanden und wurde als fehlerhaft markiert.' + sync_completed: 'Extension-Registry-Sync hat %count% Änderung(en) erfasst.' + registered: 'Extension "%extension%" wurde inaktiv registriert.' + updated: 'Registry-Metadaten für Extension "%extension%" wurden aktualisiert.' + removed: 'Extension "%extension%" fehlt im Dateisystem und wurde als entfernt markiert.' + faulty: 'Extension "%extension%" hat die Validierung nicht bestanden und wurde als fehlerhaft markiert.' install: - upload_invalid: 'Der Paket-Upload fehlt, ist ungültig oder keine ZIP-Datei.' - zip_invalid: 'Das Paket-ZIP konnte nicht sicher gelesen werden.' - root_invalid: 'Das Paket-ZIP muss genau ein Paket-Root mit .manifest-Datei enthalten.' - ready: 'Paket "%package%" Version %version% kann jetzt installiert werden. Fortfahren?' - overwrite: 'Paket "%package%" existiert bereits und wird bei aktivem Status deaktiviert, anschließend überschrieben und danach per Discovery neu registriert.' - version_blocked: 'Paket "%package%" Version %version% kann die registrierte Version %installed_version% nicht ersetzen. Downgrades sind blockiert und Same-Version-Replacements sind nur für fehlerhafte oder entfernte Pakete erlaubt; entferne und purge das bestehende Paket zuerst, wenn dieser Austausch beabsichtigt ist.' - completed: 'Paket "%package%" wurde installiert.' + upload_invalid: 'Der Extension-Upload fehlt, ist ungültig oder keine ZIP-Datei.' + zip_invalid: 'Das Extension-ZIP konnte nicht sicher gelesen werden.' + root_invalid: 'Das Extension-ZIP muss genau ein Extension-Root mit .manifest-Datei enthalten.' + ready: 'Extension "%extension%" Version %version% kann jetzt installiert werden. Fortfahren?' + overwrite: 'Extension "%extension%" existiert bereits und wird bei aktivem Status deaktiviert, anschließend überschrieben und danach per Discovery neu registriert.' + version_blocked: 'Extension "%extension%" Version %version% kann die registrierte Version %installed_version% nicht ersetzen. Downgrades sind blockiert und Same-Version-Replacements sind nur für fehlerhafte oder entfernte Extensions erlaubt; entferne und purge das bestehende Extension zuerst, wenn dieser Austausch beabsichtigt ist.' + completed: 'Extension "%extension%" wurde installiert.' lifecycle: - not_found: 'Paket "%package%" ist nicht registriert.' - status_blocked: 'Paket "%package%" kann den Lifecycle-Status nicht wechseln, solange es "%status%" ist.' - activated: 'Paket "%package%" wurde aktiviert.' - deactivated: 'Paket "%package%" wurde deaktiviert.' - dependent_deactivated: 'Paket "%package%" wurde deaktiviert, weil die Abhängigkeit "%dependency%" nicht mehr verfügbar ist.' - cleanup_completed: 'Paket "%package%"-Cleanup abgeschlossen.' - removed: 'Paket "%package%" wurde entfernt.' - purged: 'Registry-Eintrag für Paket "%package%" wurde gelöscht.' - fault_reset: 'Fehlerstatus für Paket "%package%" wurde nach der Validierung zurückgesetzt.' - runtime_failure: 'Paket "%package%" wurde nach einem Runtime-Fehler als fehlerhaft markiert.' - php_load_failed: 'PHP-Loader für Paket "%package%" ist fehlgeschlagen und das Paket wurde als fehlerhaft markiert.' - rolled_back: 'Paket-Lifecycle-Änderungen wurden für %count% Paket(e) zurückgerollt.' + not_found: 'Extension "%extension%" ist nicht registriert.' + status_blocked: 'Extension "%extension%" kann den Lifecycle-Status nicht wechseln, solange es "%status%" ist.' + single_active_conflict: 'Extensions "%extensions%" können nicht gemeinsam aktiviert werden, weil Scope "%scope%" nur eine aktive Extension erlaubt.' + activated: 'Extension "%extension%" wurde aktiviert.' + deactivated: 'Extension "%extension%" wurde deaktiviert.' + dependent_deactivated: 'Extension "%extension%" wurde deaktiviert, weil die Abhängigkeit "%dependency%" nicht mehr verfügbar ist.' + cleanup_completed: 'Extension "%extension%"-Cleanup abgeschlossen.' + removed: 'Extension "%extension%" wurde entfernt.' + purged: 'Registry-Eintrag für Extension "%extension%" wurde gelöscht.' + fault_reset: 'Fehlerstatus für Extension "%extension%" wurde nach der Validierung zurückgesetzt.' + runtime_failure: 'Extension "%extension%" wurde nach einem Runtime-Fehler als fehlerhaft markiert.' + php_load_failed: 'PHP-Loader für Extension "%extension%" ist fehlgeschlagen und das Extension wurde als fehlerhaft markiert.' + rolled_back: 'Extension-Lifecycle-Änderungen wurden für %count% Extension(e) zurückgerollt.' runtime: - contribution_unsupported: 'Paket "%package%" hat einen nicht unterstützten Runtime-Contribution-Typ "%type%" zurückgegeben.' + contribution_unsupported: 'Extension "%extension%" hat einen nicht unterstützten Runtime-Contribution-Typ "%type%" zurückgegeben.' live: - endpoint_path_invalid: 'Paket "%package%" wollte den Live-Endpunkt-Pfad "%path%" außerhalb des eigenen /api/live/{package}/-Namespace registrieren.' - endpoint_handler_invalid: 'Paket "%package%" wollte den Live-Endpunkt-Handler "%handler%" außerhalb des eigenen Handler-Namespace registrieren.' - endpoint_reserved: 'Paket "%package%" kann keine Live-Endpunkte mit reserviertem System-Slug "%slug%" registrieren.' + endpoint_path_invalid: 'Extension "%extension%" wollte den Live-Endpunkt-Pfad "%path%" außerhalb des eigenen /api/live/{extension}/-Namespace registrieren.' + endpoint_handler_invalid: 'Extension "%extension%" wollte den Live-Endpunkt-Handler "%handler%" außerhalb des eigenen Handler-Namespace registrieren.' + endpoint_reserved: 'Extension "%extension%" kann keine Live-Endpunkte mit reserviertem System-Slug "%slug%" registrieren.' setting: - read_failed: 'Paket-Einstellung "%package%:%key%" konnte nicht gelesen werden.' - write_failed: 'Paket-Einstellung "%package%:%key%" konnte nicht geschrieben werden.' - delete_failed: 'Paket-Einstellungen für "%package%" konnten nicht gelöscht werden.' - value_invalid: 'Paket-Einstellung "%package%:%key%" enthält einen ungültigen gespeicherten Wert.' + read_failed: 'Extension-Einstellung "%extension%:%key%" konnte nicht gelesen werden.' + write_failed: 'Extension-Einstellung "%extension%:%key%" konnte nicht geschrieben werden.' + delete_failed: 'Extension-Einstellungen für "%extension%" konnten nicht gelöscht werden.' + value_invalid: 'Extension-Einstellung "%extension%:%key%" enthält einen ungültigen gespeicherten Wert.' dependency: - missing: 'Paket-Abhängigkeit "%package%" für "%required_by%" ist nicht installiert.' - invalid: 'Paket "%package%" deklariert fehlerhafte Paket-Abhängigkeiten.' - version_unsatisfied: 'Paket-Abhängigkeit "%package%" erfordert mindestens Version %required_version%; installiert ist %installed_version%.' - status_blocked: 'Paket-Abhängigkeit "%package%" kann nicht genutzt werden, solange sie "%status%" ist.' - cycle: 'Zirkuläre Paket-Abhängigkeit erkannt: %cycle%.' - resolved: 'Paket "%package%" hat %count% Abhängigkeits-Eintrag(e) aufgelöst.' + missing: 'Extension-Abhängigkeit "%extension%" für "%required_by%" ist nicht installiert.' + invalid: 'Extension "%extension%" deklariert fehlerhafte Extension-Abhängigkeiten.' + version_unsatisfied: 'Extension-Abhängigkeit "%extension%" erfordert mindestens Version %required_version%; installiert ist %installed_version%.' + status_blocked: 'Extension-Abhängigkeit "%extension%" kann nicht genutzt werden, solange sie "%status%" ist.' + cycle: 'Zirkuläre Extension-Abhängigkeit erkannt: %cycle%.' + resolved: 'Extension "%extension%" hat %count% Abhängigkeits-Eintrag(e) aufgelöst.' scheduler: - cron_invalid: 'Paket "%package%" deklariert einen ungültigen Scheduler-Cron-Ausdruck.' - source_invalid: 'Paket-Scheduler-Task "%task%" aus "%package%" muss den Paketnamen als Source verwenden, nicht "%source%".' - trusted_blocked: 'Paket-Scheduler-Task "%task%" aus "%package%" darf keine vertrauenswürdige Ausführung anfordern.' + cron_invalid: 'Extension "%extension%" deklariert einen ungültigen Scheduler-Cron-Ausdruck.' + source_invalid: 'Extension-Scheduler-Task "%task%" aus "%extension%" muss den Extensionnamen als Source verwenden, nicht "%source%".' + trusted_blocked: 'Extension-Scheduler-Task "%task%" aus "%extension%" darf keine vertrauenswürdige Ausführung anfordern.' translation: - aggregate_completed: 'Translation-Aggregation hat %files% Datei(en) für %locales% Locale(s) aus Core und %packages% aktiven Paket(en) zusammengeführt.' + aggregate_completed: 'Translation-Aggregation hat %files% Datei(en) für %locales% Locale(s) aus Core und %extensions% aktiven Extension(en) zusammengeführt.' aggregate_failed: 'Translation-Aggregation konnte "%path%" nicht schreiben.' statistics: record_failed: 'Zugriffsstatistiken konnten den aktuellen Request nicht aufzeichnen.' @@ -106,12 +117,34 @@ message: snapshot_store_failed: 'Speicherung des Zugriffsstatistik-Snapshots ist fehlgeschlagen.' cleanup_failed: 'Aufbewahrungs-Cleanup der Zugriffsstatistiken ist fehlgeschlagen.' trace_id_invalid: 'Zugriffsstatistiken haben eine ungültige %label% erhalten.' + geoip: + download: + missing_license_key: 'Der GeoIP2-Datenbankdownload benötigt einen MaxMind-License-Key.' + invalid_license_key: 'MaxMind hat den GeoIP2-Datenbankdownload abgelehnt. Prüfe den konfigurierten License-Key.' + server_unreachable: 'Der MaxMind-Downloadserver konnte nicht erreicht werden. Versuche es später erneut.' + failed: 'Der GeoIP2-Datenbankdownload ist fehlgeschlagen. Prüfe den Diagnosekontext für den HTTP-Status.' + archive_invalid: 'Das heruntergeladene GeoIP2-Archiv konnte nicht entpackt werden.' + database_missing: 'Das heruntergeladene GeoIP2-Archiv enthielt keine Datenbankdatei.' + database_invalid: 'Die heruntergeladene GeoIP2-Datenbank konnte nicht validiert werden.' + write_failed: 'Die GeoIP2-Datenbank konnte nicht geschrieben werden.' + completed: 'GeoIP2-Datenbank wurde unter "%path%" aktualisiert.' account_app_secret_rotation: manual_owner_reset_required: 'APP_SECRET-Rotation-Recovery konnte nicht alle Owner-Reset-Links zustellen. Nutze die Emergency-Recovery-Datei, sofern vorhanden, oder führe "%command%" für die gelisteten Owner manuell aus.' + rate_limit: + exceeded: 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.' + request_rejected: 'Die Anfrage konnte nicht akzeptiert werden.' + storage_degraded: 'Rate-Limit-Speicher war nicht verfügbar; die Anfrage wurde zugelassen.' + reset_degraded: 'Rate-Limit-Reset-Speicher war nicht verfügbar.' + auto_ban: + storage_degraded: 'Auto-Ban-Speicher war nicht verfügbar; Enforcement ist fail-open weitergelaufen.' + evaluation_degraded: 'Auto-Ban-Score-Auswertung war nicht verfügbar; die Anfrage wurde zugelassen.' + payload_invalid: 'Auto-Ban-State enthielt eine ungültige Payload und wurde ignoriert.' + reset_released: 'Auto-Ban wurde gelöst.' + alert_delivery_degraded: 'Auto-Ban-Owner-Alert-Zustellung war nicht verfügbar.' event: hook: invalid: 'Event-Hook "%event%" ist keine gültige öffentliche Hook-Definition.' - unregistered: 'Event-Hook "%event%" ist nicht als öffentlicher Paket-Hook registriert.' + unregistered: 'Event-Hook "%event%" ist nicht als öffentlicher Extension-Hook registriert.' listener_failed: 'Event-Hook "%event%" ist beim Benachrichtigen eines Listeners fehlgeschlagen.' view_context: summary: 'Erweitert den universellen Twig-View-Kontext vor dem Rendering.' @@ -122,19 +155,19 @@ message: navigation_builder: summary: 'Erweitert Navigationspunkte, bevor Baumhierarchie, Sortierung und Render-Slices aufgelöst werden.' static_view_injection_registry: - summary: 'Ergänzt statische Paket-View-Injections, bevor Routen und Menüeinträge aufgelöst werden.' + summary: 'Ergänzt statische Extension-View-Injections, bevor Routen und Menüeinträge aufgelöst werden.' dynamic_view_injection_registry: - summary: 'Ergänzt content-bewusste dynamische Paket-View-Injections, bevor Content-Slots und Varianten gerendert werden.' + summary: 'Ergänzt content-bewusste dynamische Extension-View-Injections, bevor Content-Slots und Varianten gerendert werden.' response_headers: summary: 'Passt HTTP-Response-Header an, bevor die Response gesendet wird.' output_generated: summary: 'Passt generierten HTML-Output nach dem Rendering und vor dem Senden der Response an.' - package_asset_sync_started: - summary: 'Beobachtet die aktiven Pakete, bevor Paket-Assets synchronisiert werden.' - package_asset_registry_build: - summary: 'Ergänzt Paket-Asset-Registry-Beiträge, bevor CSS- und JavaScript-Registries geschrieben werden.' - package_asset_sync_completed: - summary: 'Beobachtet Paket-Asset-Sync-Metriken nach dem Schreiben der Registries.' + extension_asset_sync_started: + summary: 'Beobachtet die aktiven Extensions, bevor Extension-Assets synchronisiert werden.' + extension_asset_registry_build: + summary: 'Ergänzt Extension-Asset-Registry-Beiträge, bevor CSS- und JavaScript-Registries geschrieben werden.' + extension_asset_sync_completed: + summary: 'Beobachtet Extension-Asset-Sync-Metriken nach dem Schreiben der Registries.' view: dynamic_injection: render_failed: 'Dynamische View-Injection "%uid%" ist beim Rendern von "%template%" fehlgeschlagen.' diff --git a/translations/languages/de/setup.yaml b/translations/languages/de/setup.yaml index a24b8f79..4579859e 100644 --- a/translations/languages/de/setup.yaml +++ b/translations/languages/de/setup.yaml @@ -211,6 +211,10 @@ setup: label: 'Website-URL' registration_mode: label: 'Benutzerregistrierung' + options: + disabled: 'Deaktiviert' + admin_approval: 'Admin-Freigabe' + auto_approval: 'Automatische Freigabe' username_change_enabled: label: 'Benutzernamenänderungen erlauben' route_prefixes_enabled: @@ -279,7 +283,7 @@ setup: url: 'Gib eine gültige URL ein.' review: title: 'Setup prüfen' - text: 'Setup schreibt jetzt den Environment-Override, führt Migrationen aus, legt Standarddaten an, startet Paket-Discovery, baut Assets neu und sperrt die Setup-Route erst nach Erfolg.' + text: 'Setup schreibt jetzt den Environment-Override, führt Migrationen aus, legt Standarddaten an, startet Extension-Discovery, baut Assets neu und sperrt die Setup-Route erst nach Erfolg.' none: 'Keine' enabled: 'Aktiviert' disabled: 'Deaktiviert' @@ -316,6 +320,6 @@ setup: seed_initial_content: 'Startinhalt anlegen' mark_setup_completed: 'Setup als abgeschlossen markieren' clear_cache: 'Cache leeren' - run_package_discovery: 'Paket-Discovery ausführen' + run_extension_discovery: 'Extension-Discovery ausführen' run_asset_rebuild: 'Assets neu bauen' run_mercure_health: 'Mercure-Hub prüfen' diff --git a/translations/languages/de/ui.yaml b/translations/languages/de/ui.yaml index e35dc84b..f5a25ba4 100644 --- a/translations/languages/de/ui.yaml +++ b/translations/languages/de/ui.yaml @@ -226,6 +226,9 @@ ui: 403: title: 'Zugriff verweigert' message: 'Diese Route ist geschützt oder nicht für direkten Zugriff verfügbar.' + 400: + title: 'Ungültige Anfrage' + message: 'Die Anfrage konnte nicht akzeptiert werden.' 404: title: 'Seite nicht gefunden' message: 'Die angefragte Seite wurde nicht gefunden oder ist nicht veröffentlicht.' diff --git a/translations/languages/en/admin.yaml b/translations/languages/en/admin.yaml index c9bf50b3..4dc44329 100644 --- a/translations/languages/en/admin.yaml +++ b/translations/languages/en/admin.yaml @@ -16,7 +16,7 @@ admin: users: 'Users' user_groups: 'ACL groups' user_reviews: 'Reviews' - packages: 'Packages' + extensions: 'Extensions' themes: 'Themes' scheduler: 'Scheduler' backups: 'Backups' @@ -29,32 +29,36 @@ admin: user_settings: 'Users' mail_settings: 'Mail' security_settings: 'Security' - package_settings: 'Packages' + logging_settings: 'Logs' + extension_settings: 'Extensions' statistics_settings: 'Statistics' api_settings: 'API' + acl_settings: 'ACL' scheduler_settings: 'Scheduler' system_info: 'System information' actions: - package_discovery: + extension_discovery: label: 'Update registry' asset_rebuild: label: 'Rebuild assets' cache_clear: label: 'Clear cache' + geoip_database_update: + label: 'Download GeoIP2 database' dashboard: title: 'Admin dashboard' foundation_title: 'Backend foundation' foundation_text: 'Administrative routes are now resolved through the native backend router. Functional pages can attach here once authentication, navigation, and access checks are ready.' - packages: - title: 'Package management' - foundation_title: 'Package view foundation' - foundation_text: 'Package management will attach here once install, update, activation, and cleanup workflows are wired.' - registry_title: 'Registered packages' - registry_text: 'Packages discovered in the registry are listed here. Lifecycle actions will attach once operation flows are wired.' - empty: 'No packages are registered yet.' + extensions: + title: 'Extension management' + foundation_title: 'Extension view foundation' + foundation_text: 'Extension management will attach here once install, update, activation, and cleanup workflows are wired.' + registry_title: 'Registered extensions' + registry_text: 'Extensions discovered in the registry are listed here. Lifecycle actions will attach once operation flows are wired.' + empty: 'No extensions are registered yet.' unknown_value: 'Unknown' table: - package: 'Package' + extension: 'Extension' status: 'Status' scopes: 'Scopes' version: 'Version' @@ -78,59 +82,59 @@ admin: none: 'No actions' install: label: 'Install' - title: 'Install package' - text: 'Upload a package ZIP. The package manifest is verified first and the final installation waits for confirmation in the ActionLog.' - file: 'Package ZIP' - submit: 'Verify package' + title: 'Install extension' + text: 'Upload an extension ZIP. The extension manifest is verified first and the final installation waits for confirmation in the ActionLog.' + file: 'Extension ZIP' + submit: 'Verify extension' close: 'Close' meta: author: 'By %author%' detail: - overview_title: 'Package overview' + overview_title: 'Extension overview' immutable: 'Immutable' description: 'Description' author: 'Author' license: 'License' dependencies: 'Dependencies' - dependencies_empty: 'No package dependencies' + dependencies_empty: 'No extension dependencies' homepage: 'Homepage' source: 'Source' readme_title: 'README' lifecycle: review_title: 'Review changes' - immutable: 'This system package is immutable and cannot change lifecycle state.' + immutable: 'This system extension is immutable and cannot change lifecycle state.' unavailable: 'This lifecycle action is not available.' - no_changes: 'This action does not need to change package state.' + no_changes: 'This action does not need to change extension state.' cancel: 'Cancel' table: action: 'Action' activate: label: 'Activate' - title: 'Activate %package%' - text: 'Review dependency and conflict changes before activating this package.' - submit: 'Activate package' + title: 'Activate %extension%' + text: 'Review dependency and conflict changes before activating this extension.' + submit: 'Activate extension' deactivate: label: 'Deactivate' - title: 'Deactivate %package%' - text: 'Review the planned state change before deactivating this package.' - submit: 'Deactivate package' + title: 'Deactivate %extension%' + text: 'Review the planned state change before deactivating this extension.' + submit: 'Deactivate extension' reset_fault: label: 'Reset fault' - title: 'Reset fault for %package%' - text: 'The package will be validated again and returned to inactive if recovery succeeds.' + title: 'Reset fault for %extension%' + text: 'The extension will be validated again and returned to inactive if recovery succeeds.' submit: 'Reset fault' purge: label: 'Delete data' - title: 'Delete data for %package%' - text: 'Review package-owned data cleanup before resetting this package registration.' - warning: 'This step is irreversible. Package-owned settings and future package-owned data cleanup targets will be deleted.' + title: 'Delete data for %extension%' + text: 'Review extension-owned data cleanup before resetting this extension registration.' + warning: 'This step is irreversible. Extension-owned settings and future extension-owned data cleanup targets will be deleted.' submit: 'Delete data' delete: - label: 'Delete package' - title: 'Delete %package%' - text: 'Review the filesystem removal before deleting this package from disk.' - warning: 'This step is irreversible. The package files will be deleted and the registry row will stay as removed until data is deleted separately.' - submit: 'Delete package' + label: 'Delete extension' + title: 'Delete %extension%' + text: 'Review the filesystem removal before deleting this extension from disk.' + warning: 'This step is irreversible. The extension files will be deleted and the registry row will stay as removed until data is deleted separately.' + submit: 'Delete extension' themes: title: 'Theme management' foundation_title: 'Theme management foundation' @@ -145,7 +149,7 @@ admin: text: 'Administration and editor shell themes plus the native system fallback are listed here.' type: system: 'System' - package: 'Package' + extension: 'Extension' quick: use: 'Use' active: 'Active' @@ -472,9 +476,9 @@ admin: ui_alert_inbox_cleanup: label: 'UI alert inbox cleanup' description: 'Removes queued UI alerts after their delivery retention expires.' - package_discovery: - label: 'Package discovery' - description: 'Discovers packages and updates the package registry.' + extension_discovery: + label: 'Extension discovery' + description: 'Discovers extensions and updates the extension registry.' statistics_snapshot: label: 'Statistics snapshot' description: 'Refreshes the stored access statistics snapshot when statistics are enabled.' @@ -484,6 +488,9 @@ admin: mercure_health: label: 'Mercure health' description: 'Checks the Mercure hub and starts the local hub when supported.' + geoip2_database_update: + label: 'GeoIP2 database update' + description: 'Downloads and atomically replaces the local MaxMind GeoIP2 City database when a license key is configured.' table: job: 'Job' source: 'Source' @@ -511,17 +518,17 @@ admin: failure_count: 'Consecutive failures' run_url: 'Direct run URL' run_now_inactive: 'Activate this job before running it manually.' - package_action_queue_warning: 'This package job can run an ActionQueue and is controlled by the package ActionQueue scheduler setting.' + extension_action_queue_warning: 'This extension job can run an ActionQueue and is controlled by the extension ActionQueue scheduler setting.' form: title: 'Job settings' enabled: 'Run this job automatically' cron_expression: 'Cron expression' - confirm_package_action_queue: 'I understand that this package job can run an ActionQueue.' + confirm_extension_action_queue: 'I understand that this extension job can run an ActionQueue.' saved: 'Scheduler job saved.' errors: invalid_csrf: 'The scheduler form expired. Please try again.' cron_invalid: 'Enter a valid cron expression.' - package_action_queue_confirmation_required: 'Confirm the package ActionQueue warning before enabling this job.' + extension_action_queue_confirmation_required: 'Confirm the extension ActionQueue warning before enabling this job.' runs: title: 'Recent runs' empty: 'This job has not run yet.' @@ -590,22 +597,24 @@ admin: logs: title: 'Logs' foundation_title: 'Log and audit foundation' - foundation_text: 'Operational logs, audit views, package diagnostics, and security-relevant events will attach here.' + foundation_text: 'Operational logs, audit views, extension diagnostics, and security-relevant events will attach here.' empty_value: 'n/a' sources: + label: 'Log sources' application: 'Application' message: 'Messages' audit: 'Audit' access: 'Access' + security_signal: 'Security signals' filters: title: 'Filters' source: 'Log' level: 'Level' - any_level: 'Any level' search: 'Search' window: 'Time window' match: 'Match' audit_action: 'Audit action' + signal_reason: 'Signal reason' per_page: 'Rows' limit: 'Limit' contains: 'Contains' @@ -620,8 +629,6 @@ admin: entries: title: 'Entries' empty: 'No log entries match the current filters.' - no_files: 'No log file exists for this selection yet.' - files: 'Reading: %files%' open: 'Details' page_summary: 'Page %page% of %pages% · %total% entries' previous: 'Previous' @@ -637,6 +644,8 @@ admin: location: 'Location' user: 'User' action: 'Action' + security_signal: 'Signal' + subject: 'Subject' channel: 'Channel' context: 'Context' details: 'Details' @@ -645,6 +654,31 @@ admin: context: 'Context' no_context: 'No structured context is available.' raw: 'Raw line' + auto_bans: + active: + title: 'Active auto-bans' + empty: 'No active auto-bans.' + open: 'Review' + open_list: 'Open active auto-bans' + disabled: 'Auto-ban enforcement is disabled. Existing TTL states are retained until expiry but are not enforced.' + columns: + subject: 'Subject' + created_at: 'Created' + expires_at: 'Expires' + ttl: 'TTL seconds' + details: 'Details' + detail: + title: 'Auto-ban detail' + signals: 'Related security signals' + no_signals: 'No retained signals are available for this active ban.' + trigger_country: 'Trigger country' + trigger_continent: 'Trigger continent' + reset: + submit: 'Reset auto-ban' + saved: 'Auto-ban reset.' + failed: 'Auto-ban could not be reset.' + alerts: + triggered: 'Auto-ban created for %subject%.' statistics: title: 'Statistics' access_title: 'Access statistics' @@ -675,8 +709,12 @@ admin: all: 'All stored events' settings: title: 'Settings' + acl: + title: 'ACL' + foundation_title: 'ACL matrix' + foundation_text: 'Owner-controlled feature permissions for administrative, editor, and future frontend surfaces.' redirect_title: 'Settings sections' - redirect_text: 'General settings are the first built-in settings section. Active packages with simple setting definitions appear under package settings.' + redirect_text: 'General settings are the first built-in settings section. Active extensions with simple setting definitions appear under extension settings.' general: title: 'General settings' foundation_title: 'General configuration foundation' @@ -696,23 +734,41 @@ admin: security: title: 'Security settings' foundation_title: 'Security configuration foundation' - foundation_text: 'Security, captcha, logging, and abuse-control settings will attach here.' + foundation_text: 'Security, captcha, passive-signal retention, and abuse-control settings will attach here.' + logging: + title: 'Log settings' + foundation_title: 'Database log projections' + foundation_text: 'Message, audit, and access logs keep their fixed rotating file logs separately. These settings control how long the searchable database copies stay available.' statistics: title: 'Statistics settings' foundation_title: 'Statistics configuration' - foundation_text: 'Access statistics recording and display can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.' + foundation_text: 'Access statistics recording, display, and GeoIP enrichment can be disabled independently from raw access logging. Raw access logs remain available for operational security and are retained for 30 days.' + geoip: + status: + title: 'GeoIP2 status' + provider: 'Provider' + state: 'State' + database_edition: 'Database edition' + database_build_date: 'Database build date' + failure_code: 'Failure code' + values: + ready: 'Ready' + disabled: 'Disabled' + unconfigured: 'Unconfigured' + unavailable: 'Unavailable' + unknown: 'Unknown' api: title: 'API settings' foundation_title: 'API availability' foundation_text: 'API access can be disabled for normal users while owners keep key management available for operational integrations such as scheduler activation.' - packages: - title: 'Package settings' - foundation_title: 'Packages with settings' - empty: 'No active package exposes settings yet.' - package: - title: '%package% settings' - foundation_title: 'Package settings' - empty: 'This package does not expose configurable settings.' + extensions: + title: 'Extension settings' + foundation_title: 'Extensions with settings' + empty: 'No active extension exposes settings yet.' + extension: + title: '%extension% settings' + foundation_title: 'Extension settings' + empty: 'This extension does not expose configurable settings.' options: '%count% option(s)' validation: '%count% validation rule(s)' scheduler: @@ -738,6 +794,153 @@ admin: required: 'This field is required.' save_failed: 'The settings could not be saved.' default_acl_group_unavailable: 'Enter an existing ACL group with minimum role User or lower, or leave the field empty.' + fields: + captcha_enabled: + label: 'Enable captcha' + captcha_provider: + label: 'Captcha provider' + captcha_preview: + label: 'Captcha preview' + rate_limit_mode: + label: 'Rate limiting' + auto_ban_enabled: + label: 'Enable auto-ban' + help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.' + auto_ban_trusted_access_level: + label: 'Trusted user level' + help: 'Registered users at or above this access level are never auto-banned.' + auto_ban_score_threshold: + label: 'Auto-ban score threshold' + help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.' + auto_ban_new_ban_owner_alerts: + label: 'Enable alerts for newly decided auto-bans' + help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.' + audit_enabled: + label: 'Enable audit logging' + audit_events: + label: 'Audit event categories' + security_signal_retention_days: + label: 'Security signal retention' + help: 'Retention for passive security signals in days. Values above 30 days are not allowed because signals can contain request-derived identifiers.' + security_probe_path_patterns: + label: 'Suspicious probe path patterns' + help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.' + options: + captcha: + none: 'No captcha provider' + rate_limit_mode: + off: 'Off' + standard: 'Standard' + strict: 'Strict' + panic: 'Panic' + access_level: + user: 'User' + moderator: 'Moderator' + author: 'Author' + publisher: 'Publisher' + curator: 'Curator' + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' + audit: + authentication: 'Authentication events' + backend_actions: 'Backend maintenance actions' + operations: 'Operations maintenance actions' + extensions: 'Extension lifecycle actions' + settings: 'Settings changes' + other: 'Other future audit events' + acl: + actions: + save: 'Save ACL matrix' + categories: + diagnostics: 'Diagnostics' + operations: 'Operations' + extensions: 'Extensions' + extension_settings: 'Extension settings' + settings: 'Settings' + system: 'System' + users: 'Users' + empty: 'No feature flags are registered for this surface yet.' + groups: + empty: 'No ACL groups can grant this surface.' + non_configurable: 'Read-only' + read_only_action: 'You may review this action, but mutation is not allowed by the current ACL policy.' + states: + inherit: 'Inherit' + denied: 'Denied' + visible: 'Visible' + mutable: 'Mutable' + surfaces: + label: 'ACL surfaces' + admin: 'Admin' + admin_text: 'Administrative feature flags. ACL groups can grant permissions only to users that already pass the Admin surface gate.' + editor: 'Editor' + editor_text: 'Prepared for future editor feature flags. Editor providers can register rows here later.' + frontend: 'Frontend' + frontend_text: 'Reserved for explicitly designed future frontend features such as inline editing.' + table: + admin_state: 'Admin' + default: 'Default' + feature: 'Feature' + groups: 'ACL group grants' + features: + admin_settings_security: + label: 'Security settings' + description: 'Security policy, audit categories, signal retention, and suspicious probe patterns.' + admin_settings_logging: + label: 'Log retention settings' + description: 'Database lookup retention for message, audit, and access log projections.' + admin_settings_statistics: + label: 'Statistics settings' + description: 'Access statistics recording and display settings.' + admin_settings_statistics_geoip: + label: 'GeoIP settings' + description: 'GeoIP enablement, local database path, protected MaxMind license key, and status.' + admin_settings_api: + label: 'API settings' + description: 'Global API availability and CORS expansion settings.' + admin_settings_scheduler: + label: 'Scheduler settings' + description: 'Scheduler enablement, web triggering, GET authentication, and extension action queues.' + admin_settings_extensions: + label: 'Extension settings' + description: 'Core extension-update settings and extension-owned settings pages.' + admin_settings_extension: + description: 'Extension-owned settings page for an active extension.' + admin_logs: + label: 'Logs' + description: 'Administrative log review surfaces.' + admin_extensions: + label: 'Extensions and themes' + description: 'Extension and theme management, including install, activation, deactivation, repair, deletion, and purge workflows.' + admin_extensions_self_update: + label: 'System extension self-update' + description: 'Owner-only system extension self-update workflows.' + admin_backup_restore: + label: 'Backup, export, and restore' + description: 'Full-data backup, export, download, and restore workflows.' + admin_operations: + label: 'Operations' + description: 'Live operation inspection and operational status surfaces.' + admin_actions_maintenance: + label: 'Maintenance actions' + description: 'Manual maintenance actions such as cache clear and asset rebuild.' + admin_scheduler: + label: 'Scheduler' + description: 'Scheduler task inspection and run controls.' + admin_users: + label: 'Users' + description: 'User administration and account management.' + admin_users_acl: + label: 'User ACL groups' + description: 'ACL group administration and membership review.' + admin_users_review: + label: 'User reviews' + description: 'User invitation, registration, and review queues.' + admin_support: + label: 'Support bundles' + description: 'Diagnostic or support-bundle generation and download.' fields: site_title: label: 'Site title' @@ -786,10 +989,50 @@ admin: label: 'Captcha provider' captcha_preview: label: 'Captcha preview' + rate_limit_mode: + label: 'Rate limiting' + auto_ban_enabled: + label: 'Enable auto-ban' + help: 'Temporarily blocks repeated suspicious Visitor/IP activity. Trusted users remain exempt.' + auto_ban_trusted_access_level: + label: 'Trusted user level' + help: 'Registered users at or above this access level are never auto-banned.' + auto_ban_score_threshold: + label: 'Auto-ban score threshold' + help: 'Score required within one hour before a new Visitor ban can be created. IP-only bans use a higher internal threshold.' + auto_ban_new_ban_owner_alerts: + label: 'Enable alerts for newly decided auto-bans' + help: 'Queues a hidden warning for Owner accounts when a new active auto-ban is created.' audit_enabled: label: 'Enable audit logging' audit_events: label: 'Audit event categories' + message_log_retention_days: + label: 'Message log database retention' + help: 'Database lookup copy retention in days. File logs keep their fixed 30-day rotation separately.' + audit_log_retention_days: + label: 'Audit log database retention' + help: 'Database lookup copy retention in days. Values above 30 days are not allowed because audit data can contain request-derived identifiers.' + access_log_retention_days: + label: 'Access log database retention' + help: 'Database lookup copy retention in days. Values above 30 days are not allowed because access logs can contain IP-derived data.' + security_signal_retention_days: + label: 'Security signal retention' + help: 'Retention for passive security signals in days. Values above 30 days are not allowed because signals can contain request-derived identifiers.' + security_probe_path_patterns: + label: 'Suspicious probe path patterns' + help: 'One regular expression per line. Quoted CSV imports are accepted. Invalid or empty lists fall back to the protected defaults.' + geoip_enabled: + label: 'Enable GeoIP lookups' + help: 'When disabled or unavailable, logs and statistics keep normalized n/a location values.' + geoip_license_link: + label: 'Get a free GeoLite2 license key from MaxMind' + geoip_database_path: + label: 'MaxMind database path' + help: 'Project-relative path to the local .mmdb database. Downloads are written atomically to this path.' + geoip_license_key: + label: 'MaxMind license key' + help: 'Stored as sensitive configuration. Saving a key enables the GeoIP2 database download action on this page.' statistics_enabled: label: 'Enable access statistics' statistics_respect_dnt: @@ -803,34 +1046,44 @@ admin: api_cors_allowed_origins: label: 'Allowed API CORS origins' help: 'JSON array of exact origins such as ["https://client.example"]. The wildcard "*" should only be used intentionally.' - package_update_interval: + extension_update_interval: label: 'Update check interval' - package_auto_updates: - label: 'Allow automatic package updates' + extension_auto_updates: + label: 'Allow automatic extension updates' scheduler_enabled: label: 'Enable scheduler' scheduler_get_auth_enabled: label: 'Allow GET authentication for /cron/run' help: 'Bearer authentication remains preferred. Enable GET authentication only for hosting schedulers that cannot send headers.' - scheduler_package_action_queues_enabled: - label: 'Allow package scheduler action queues' - help: 'Package action queues may run filesystem or process operations. Core scheduler queues are trusted separately.' + scheduler_extension_action_queues_enabled: + label: 'Allow extension scheduler action queues' + help: 'Extension action queues may run filesystem or process operations. Core scheduler queues are trusted separately.' scheduler_web_trigger_enabled: label: 'Run scheduler from web traffic' help: 'Starts a detached scheduler run after eligible page requests at most once per minute. Use a real cron job when available.' options: dashboard: system_status: 'System status' - packages: 'Package status' + extensions: 'Extension status' recent_activity: 'Recent activity' setup_warnings: 'Setup warnings' captcha: none: 'No captcha provider' + rate_limit_mode: + off: 'Off' + standard: 'Standard' + strict: 'Strict' + panic: 'Panic' + access_level: + manager: 'Manager' + director: 'Director' + admin: 'Admin' + owner: 'Owner' audit: authentication: 'Authentication events' backend_actions: 'Backend maintenance actions' operations: 'Operations maintenance actions' - packages: 'Package lifecycle actions' + extensions: 'Extension lifecycle actions' settings: 'Settings changes' other: 'Other future audit events' registration: diff --git a/translations/languages/en/editor.yaml b/translations/languages/en/editor.yaml index ab322aab..4c645aec 100644 --- a/translations/languages/en/editor.yaml +++ b/translations/languages/en/editor.yaml @@ -10,6 +10,6 @@ editor: title: 'Editor dashboard' status: 'Content workspace' foundation_title: 'Editor foundation' - foundation_text: 'Content routes can attach to this shell once editor workflows, schema tools, and package contributions are wired.' + foundation_text: 'Content routes can attach to this shell once editor workflows, schema tools, and extension contributions are wired.' message: title: 'Editor route' diff --git a/translations/languages/en/message.yaml b/translations/languages/en/message.yaml index d88650cc..01137d17 100644 --- a/translations/languages/en/message.yaml +++ b/translations/languages/en/message.yaml @@ -8,97 +8,108 @@ message: unknown_key: 'Manifest key "%key%" is not supported here.' parsed: 'Manifest was parsed.' validated: 'Manifest was validated.' - package: + extension: identifier: - invalid: 'Package identifier "%identifier%" contains unsupported characters.' - manifest_unreadable: 'Package manifest "%path%" cannot be read.' - required_file_missing: 'Required package file "%path%" is missing.' - required_directory_missing: 'Required package directory "%path%" is missing.' - file_unreadable: 'Package file "%path%" cannot be read.' + invalid: 'Extension identifier "%identifier%" contains unsupported characters.' + manifest_unreadable: 'Extension manifest "%path%" cannot be read.' + required_file_missing: 'Required extension file "%path%" is missing.' + required_directory_missing: 'Required extension directory "%path%" is missing.' + file_unreadable: 'Extension file "%path%" cannot be read.' php_syntax_error: 'PHP syntax check failed for "%path%".' - php_namespace_invalid: 'PHP source "%path%" must use package namespace "%expected_namespace%".' + php_namespace_invalid: 'PHP source "%path%" must use extension namespace "%expected_namespace%".' twig_syntax_error: 'Twig syntax check failed for "%path%".' - translation_fallback_missing: 'Package translation sources for "%package%" must include at least one fallback catalogue under "languages/%locale%".' - translation_namespace_invalid: 'Package translation file "%path%" must define keys below "pkg.%package%".' + translation_fallback_missing: 'Extension translation sources for "%extension%" must include at least one fallback catalogue under "languages/%locale%".' + translation_namespace_invalid: 'Extension translation file "%path%" must define keys below "ext.%extension%".' json_syntax_error: 'JSON syntax check failed for "%path%".' yaml_syntax_error: 'YAML syntax check failed for "%path%".' css_syntax_error: 'CSS syntax check failed for "%path%".' - css_namespace_invalid: 'CSS class "%class%" in "%path%" must use the package-owned prefix "%expected_prefix%".' + css_namespace_invalid: 'CSS class "%class%" in "%path%" must use the extension-owned prefix "%expected_prefix%".' javascript_syntax_error: 'JavaScript syntax check failed for "%path%".' - scope_invalid: 'Package scope "%scope%" is not supported.' - template_path_invalid: 'Package template "%path%" is not allowed by root, provider, or macro namespace rules for package scope "%scope%".' - template_reference_invalid: 'Package template "%path%" must not reference template "%reference%" outside its own template scope.' + scope_invalid: 'Extension scope "%scope%" is not supported.' + template_path_invalid: 'Extension template "%path%" is not allowed by root, provider, or macro namespace rules for extension scope "%scope%".' + template_reference_invalid: 'Extension template "%path%" must not reference template "%reference%" outside its own template scope.' policy: - blocked_path: 'Package path "%path%" is blocked by package policy (%reason%).' - warned_path: 'Package path "%path%" should be reviewed by package policy (%reason%).' - blocked_php_capability: 'Package PHP file "%path%" uses blocked capability "%capability%" (%reason%). Use a documented extension point instead.' - copy_source_missing: 'Package copy source "%path%" is missing.' - copy_source_symlink: 'Package copy source "%path%" must not be a symlink.' - asset_sync_completed: 'Package asset sync mirrored %assets% asset(s) from %packages% package(s).' - asset_sync_failed: 'Package asset sync failed: %message%' - asset_rebuild_queued: 'Package asset rebuild was queued by "%trigger%".' - asset_rebuild_queue_failed: 'Package asset rebuild could not be queued by "%trigger%".' + blocked_path: 'Extension path "%path%" is blocked by extension policy (%reason%).' + warned_path: 'Extension path "%path%" should be reviewed by extension policy (%reason%).' + blocked_php_capability: 'Extension PHP file "%path%" uses blocked capability "%capability%" (%reason%). Use a documented extension point instead.' + copy_source_missing: 'Extension copy source "%path%" is missing.' + copy_source_symlink: 'Extension copy source "%path%" must not be a symlink.' + asset_sync_completed: 'Extension asset sync mirrored %assets% asset(s) from %extensions% extension(s).' + asset_sync_failed: 'Extension asset sync failed: %message%' + asset_rebuild_queued: 'Extension asset rebuild was queued by "%trigger%".' + asset_rebuild_queue_failed: 'Extension asset rebuild could not be queued by "%trigger%".' asset: - contribution_package_invalid: 'Package asset contribution requires a package identifier.' - contribution_type_invalid: 'Package asset contribution type "%type%" is not supported.' - contribution_path_invalid: 'Package asset path "%path%" must be project-relative.' - contribution_path_traversal: 'Package asset path "%path%" must not traverse parent directories.' - discovery_queued: 'Package discovery was queued by "%trigger%".' - discovery_queue_failed: 'Package discovery could not be queued by "%trigger%".' - discovery_completed: 'Package discovery found %count% candidate(s).' - validation_completed: 'Package "%package%" passed validation.' - copy_plan_created: 'Package copy plan contains %count% file(s).' + contribution_extension_invalid: 'Extension asset contribution requires an extension identifier.' + contribution_type_invalid: 'Extension asset contribution type "%type%" is not supported.' + contribution_path_invalid: 'Extension asset path "%path%" must be project-relative.' + contribution_path_traversal: 'Extension asset path "%path%" must not traverse parent directories.' + database: + contribution_invalid: 'Extension database contribution is invalid (%reason%).' + sync_completed: 'Extension "%extension%" database sync created %count% table(s).' + purge_completed: 'Extension "%extension%" database purge dropped %count% table(s).' + content_schema: + contribution_invalid: 'Extension content schema contribution is invalid (%reason%).' + sync_completed: 'Extension "%extension%" content schema sync created or versioned %count% schema(s).' + purge_completed: 'Extension "%extension%" content schema purge deleted %count% schema(s).' + purge_retained: 'Extension "%extension%" content schema purge retained %count% referenced schema(s).' + content_archived: '%count% content item(s) using deactivated extension schemas were archived.' + discovery_queued: 'Extension discovery was queued by "%trigger%".' + discovery_queue_failed: 'Extension discovery could not be queued by "%trigger%".' + discovery_completed: 'Extension discovery found %count% candidate(s).' + validation_completed: 'Extension "%extension%" passed validation.' + copy_plan_created: 'Extension copy plan contains %count% file(s).' registry: - sync_completed: 'Package registry sync recorded %count% change(s).' - registered: 'Package "%package%" was registered as inactive.' - updated: 'Package "%package%" registry metadata was updated.' - removed: 'Package "%package%" is missing on disk and was marked as removed.' - faulty: 'Package "%package%" failed validation and was marked as faulty.' + sync_completed: 'Extension registry sync recorded %count% change(s).' + registered: 'Extension "%extension%" was registered as inactive.' + updated: 'Extension "%extension%" registry metadata was updated.' + removed: 'Extension "%extension%" is missing on disk and was marked as removed.' + faulty: 'Extension "%extension%" failed validation and was marked as faulty.' install: - upload_invalid: 'The package upload is missing, invalid, or not a ZIP file.' - zip_invalid: 'The package ZIP could not be read safely.' - root_invalid: 'The package ZIP must contain one package root with a .manifest file.' - ready: 'Package "%package%" version %version% can now be installed. Continue?' - overwrite: 'Package "%package%" already exists and will be deactivated if active, then overwritten before discovery runs.' - version_blocked: 'Package "%package%" version %version% cannot replace registered version %installed_version%. Downgrades are blocked, and same-version replacements are only allowed for faulty or removed packages; remove and purge the existing package first if this replacement is intentional.' - completed: 'Package "%package%" was installed.' + upload_invalid: 'The extension upload is missing, invalid, or not a ZIP file.' + zip_invalid: 'The extension ZIP could not be read safely.' + root_invalid: 'The extension ZIP must contain one extension root with a .manifest file.' + ready: 'Extension "%extension%" version %version% can now be installed. Continue?' + overwrite: 'Extension "%extension%" already exists and will be deactivated if active, then overwritten before discovery runs.' + version_blocked: 'Extension "%extension%" version %version% cannot replace registered version %installed_version%. Downgrades are blocked, and same-version replacements are only allowed for faulty or removed extensions; remove and purge the existing extension first if this replacement is intentional.' + completed: 'Extension "%extension%" was installed.' lifecycle: - not_found: 'Package "%package%" is not registered.' - status_blocked: 'Package "%package%" cannot change lifecycle state while it is "%status%".' - activated: 'Package "%package%" was activated.' - deactivated: 'Package "%package%" was deactivated.' - dependent_deactivated: 'Package "%package%" was deactivated because dependency "%dependency%" became unavailable.' - cleanup_completed: 'Package "%package%" cleanup completed.' - removed: 'Package "%package%" was removed.' - purged: 'Package "%package%" registry entry was deleted.' - fault_reset: 'Package "%package%" fault state was reset after validation.' - runtime_failure: 'Package "%package%" was marked faulty after a runtime failure.' - php_load_failed: 'Package "%package%" PHP loader failed and the package was marked faulty.' - rolled_back: 'Package lifecycle changes were rolled back for %count% package(s).' + not_found: 'Extension "%extension%" is not registered.' + status_blocked: 'Extension "%extension%" cannot change lifecycle state while it is "%status%".' + single_active_conflict: 'Extensions "%extensions%" cannot be activated together because scope "%scope%" allows only one active extension.' + activated: 'Extension "%extension%" was activated.' + deactivated: 'Extension "%extension%" was deactivated.' + dependent_deactivated: 'Extension "%extension%" was deactivated because dependency "%dependency%" became unavailable.' + cleanup_completed: 'Extension "%extension%" cleanup completed.' + removed: 'Extension "%extension%" was removed.' + purged: 'Extension "%extension%" registry entry was deleted.' + fault_reset: 'Extension "%extension%" fault state was reset after validation.' + runtime_failure: 'Extension "%extension%" was marked faulty after a runtime failure.' + php_load_failed: 'Extension "%extension%" PHP loader failed and the extension was marked faulty.' + rolled_back: 'Extension lifecycle changes were rolled back for %count% extension(s).' runtime: - contribution_unsupported: 'Package "%package%" returned unsupported runtime contribution type "%type%".' + contribution_unsupported: 'Extension "%extension%" returned unsupported runtime contribution type "%type%".' live: - endpoint_path_invalid: 'Package "%package%" tried to register live endpoint path "%path%" outside its own /api/live/{package}/ namespace.' - endpoint_handler_invalid: 'Package "%package%" tried to register live endpoint handler "%handler%" outside its own handler namespace.' - endpoint_reserved: 'Package "%package%" cannot register live endpoints with reserved system slug "%slug%".' + endpoint_path_invalid: 'Extension "%extension%" tried to register live endpoint path "%path%" outside its own /api/live/{extension}/ namespace.' + endpoint_handler_invalid: 'Extension "%extension%" tried to register live endpoint handler "%handler%" outside its own handler namespace.' + endpoint_reserved: 'Extension "%extension%" cannot register live endpoints with reserved system slug "%slug%".' setting: - read_failed: 'Package setting "%package%:%key%" could not be read.' - write_failed: 'Package setting "%package%:%key%" could not be written.' - delete_failed: 'Package settings for "%package%" could not be deleted.' - value_invalid: 'Package setting "%package%:%key%" contains an invalid stored value.' + read_failed: 'Extension setting "%extension%:%key%" could not be read.' + write_failed: 'Extension setting "%extension%:%key%" could not be written.' + delete_failed: 'Extension settings for "%extension%" could not be deleted.' + value_invalid: 'Extension setting "%extension%:%key%" contains an invalid stored value.' dependency: - missing: 'Package dependency "%package%" required by "%required_by%" is not installed.' - invalid: 'Package "%package%" declares malformed package dependencies.' - version_unsatisfied: 'Package dependency "%package%" requires at least version %required_version%; installed version is %installed_version%.' - status_blocked: 'Package dependency "%package%" cannot be used while it is "%status%".' - cycle: 'Circular package dependency detected: %cycle%.' - resolved: 'Package "%package%" resolved %count% dependency item(s).' + missing: 'Extension dependency "%extension%" required by "%required_by%" is not installed.' + invalid: 'Extension "%extension%" declares malformed extension dependencies.' + version_unsatisfied: 'Extension dependency "%extension%" requires at least version %required_version%; installed version is %installed_version%.' + status_blocked: 'Extension dependency "%extension%" cannot be used while it is "%status%".' + cycle: 'Circular extension dependency detected: %cycle%.' + resolved: 'Extension "%extension%" resolved %count% dependency item(s).' scheduler: - cron_invalid: 'Package "%package%" declares an invalid scheduler cron expression.' - source_invalid: 'Package scheduler task "%task%" from "%package%" must use the package name as source instead of "%source%".' - trusted_blocked: 'Package scheduler task "%task%" from "%package%" must not request trusted execution.' + cron_invalid: 'Extension "%extension%" declares an invalid scheduler cron expression.' + source_invalid: 'Extension scheduler task "%task%" from "%extension%" must use the extension name as source instead of "%source%".' + trusted_blocked: 'Extension scheduler task "%task%" from "%extension%" must not request trusted execution.' translation: - aggregate_completed: 'Translation aggregation merged %files% file(s) for %locales% locale(s) from core and %packages% active package(s).' + aggregate_completed: 'Translation aggregation merged %files% file(s) for %locales% locale(s) from core and %extensions% active extension(s).' aggregate_failed: 'Translation aggregation could not write "%path%".' statistics: record_failed: 'Access statistics could not record the current request.' @@ -106,12 +117,34 @@ message: snapshot_store_failed: 'Access statistics snapshot storage failed.' cleanup_failed: 'Access statistics retention cleanup failed.' trace_id_invalid: 'Access statistics received an invalid %label%.' + geoip: + download: + missing_license_key: 'GeoIP2 database download needs a MaxMind license key.' + invalid_license_key: 'MaxMind rejected the GeoIP2 database download. Check the configured license key.' + server_unreachable: 'The MaxMind download server could not be reached. Try again later.' + failed: 'The GeoIP2 database download failed. Check the diagnostic context for the HTTP status.' + archive_invalid: 'The downloaded GeoIP2 archive could not be extracted.' + database_missing: 'The downloaded GeoIP2 archive did not contain a database file.' + database_invalid: 'The downloaded GeoIP2 database could not be validated.' + write_failed: 'The GeoIP2 database could not be written.' + completed: 'GeoIP2 database was updated at "%path%".' account_app_secret_rotation: manual_owner_reset_required: 'APP_SECRET rotation recovery could not deliver every owner reset link. Use the emergency recovery file when available, or run "%command%" manually for the listed owners.' + rate_limit: + exceeded: 'Too many requests. Please wait a moment before trying again.' + request_rejected: 'The request could not be accepted.' + storage_degraded: 'Rate-limit storage was unavailable; the request was allowed.' + reset_degraded: 'Rate-limit reset storage was unavailable.' + auto_ban: + storage_degraded: 'Auto-ban storage was unavailable; enforcement failed open.' + evaluation_degraded: 'Auto-ban score evaluation was unavailable; the request was allowed.' + payload_invalid: 'Auto-ban state contained an invalid payload and was ignored.' + reset_released: 'Auto-ban released.' + alert_delivery_degraded: 'Auto-ban owner alert delivery was unavailable.' event: hook: invalid: 'Event hook "%event%" is not a valid public hook definition.' - unregistered: 'Event hook "%event%" is not registered as a public package hook.' + unregistered: 'Event hook "%event%" is not registered as a public extension hook.' listener_failed: 'Event hook "%event%" failed while notifying a listener.' view_context: summary: 'Extend the universal Twig view context before rendering.' @@ -122,19 +155,19 @@ message: navigation_builder: summary: 'Extend navigation items before tree hierarchy, sort order, and render slices are resolved.' static_view_injection_registry: - summary: 'Add static package view injections before route and menu entries are resolved.' + summary: 'Add static extension view injections before route and menu entries are resolved.' dynamic_view_injection_registry: - summary: 'Add content-aware dynamic package view injections before content slots and variants are rendered.' + summary: 'Add content-aware dynamic extension view injections before content slots and variants are rendered.' response_headers: summary: 'Adjust HTTP response headers before the response is sent.' output_generated: summary: 'Adjust generated HTML output after rendering and before the response is sent.' - package_asset_sync_started: - summary: 'Observe the active package set before package assets are synchronized.' - package_asset_registry_build: - summary: 'Add package asset registry contributions before CSS and JavaScript registries are written.' - package_asset_sync_completed: - summary: 'Observe package asset synchronization metrics after registries are written.' + extension_asset_sync_started: + summary: 'Observe the active extension set before extension assets are synchronized.' + extension_asset_registry_build: + summary: 'Add extension asset registry contributions before CSS and JavaScript registries are written.' + extension_asset_sync_completed: + summary: 'Observe extension asset synchronization metrics after registries are written.' view: dynamic_injection: render_failed: 'Dynamic view injection "%uid%" failed while rendering "%template%".' diff --git a/translations/languages/en/setup.yaml b/translations/languages/en/setup.yaml index fedaf852..8b23f36c 100644 --- a/translations/languages/en/setup.yaml +++ b/translations/languages/en/setup.yaml @@ -211,6 +211,10 @@ setup: label: 'Site URL' registration_mode: label: 'User registration' + options: + disabled: 'Disabled' + admin_approval: 'Admin approval' + auto_approval: 'Auto approval' username_change_enabled: label: 'Allow username changes' route_prefixes_enabled: @@ -279,7 +283,7 @@ setup: url: 'Enter a valid URL.' review: title: 'Review setup' - text: 'Setup will now write the environment override, apply migrations, seed defaults, run package discovery, rebuild assets, and lock the setup route only after success.' + text: 'Setup will now write the environment override, apply migrations, seed defaults, run extension discovery, rebuild assets, and lock the setup route only after success.' none: 'None' enabled: 'Enabled' disabled: 'Disabled' @@ -316,6 +320,6 @@ setup: seed_initial_content: 'Seed initial content' mark_setup_completed: 'Mark setup completed' clear_cache: 'Clear cache' - run_package_discovery: 'Run package discovery' + run_extension_discovery: 'Run extension discovery' run_asset_rebuild: 'Rebuild assets' run_mercure_health: 'Check Mercure hub' diff --git a/translations/languages/en/ui.yaml b/translations/languages/en/ui.yaml index 864c372e..bc4002b6 100644 --- a/translations/languages/en/ui.yaml +++ b/translations/languages/en/ui.yaml @@ -226,6 +226,9 @@ ui: 403: title: 'Access denied' message: 'This route is protected or not available for direct access.' + 400: + title: 'Bad request' + message: 'The request could not be accepted.' 404: title: 'Page not found' message: 'The requested page could not be found or is not published.'