# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview TeamPass is a collaborative on-premise password manager built in PHP. It emphasizes security through multi-layer encryption, comprehensive audit logging, and granular role-based access control. **Tech Stack:** - PHP 8.1+ (strict typing throughout) - MySQL 5.7+ / MariaDB 10.7+ - MeekroDB for database abstraction - Symfony components (Session, PasswordHasher, HttpFoundation) - AdminLTE 3 / Bootstrap / jQuery frontend - Defuse PHP Encryption for data encryption ## Development Commands Run everything from the repository root. `composer.json` sets `vendor-dir` to `app/vendor`, so the Composer binaries live under `app/vendor/bin/`, not `vendor/bin/`. ```bash php app/vendor/bin/phpstan analyse --memory-limit=2G # PHPStan level 4 (config: phpstan.neon) php _tools/phpunit.phar # Unit tests php app/vendor/bin/composer-license-checker # License compliance php .github/scripts/check_table_prefix.php # No hard-coded "teampass_" table name composer install # PHP dependencies php app/scripts/background_tasks___handler.php # Background tasks ``` A full PHPStan run takes several minutes — launch it in the background rather than blocking on it. **Run the tests with `_tools/phpunit.phar`, never with `app/vendor/phpunit/phpunit/phpunit`.** The Composer-installed entry point resolves its classes through `app/vendor/composer/`, and that directory is committed in its **production** form (`composer install --no-dev`), which maps no dev package. Symptom: `Class "PHPUnit\TextUI\Application" not found` with the package sitting right there. `composer dump-autoload` does not help — `phpunit/phpunit` is absent from `installed.json`. Worse, the obvious repair (`composer install`) rewrites the autoloader into its dev form, and the mandatory `git checkout -- app/vendor/composer/` that must follow disarms the tests again. The loop has no stable exit. The phar has none of that coupling — it carries its own dependencies and never reads `app/vendor/composer/`, exactly like `app/vendor/bin/phpstan` does with `phpstan.phar`. It lives in `_tools/`, which is gitignored (`/_tools/*`) and excluded from the admin integrity check (`$excludeDirs` in `getAllFiles()`), so no checkout, merge or release can remove it. ```bash php _tools/phpunit.phar # expect: OK (~1500 tests, all green) php app/vendor/bin/phpstan analyse --memory-limit=2G ``` If `_tools/phpunit.phar` is missing (fresh clone — it is untracked by design), reinstall it and verify the signature. Keep the version aligned with `.github/workflows/quality.yml`, which runs whatever `composer install` resolves for `phpunit/phpunit: ^10.5`: ```bash mkdir -p _tools curl -sL -o _tools/phpunit.phar https://phar.phpunit.de/phpunit-10.5.64.phar curl -sL -o /tmp/phpunit.phar.asc https://phar.phpunit.de/phpunit-10.5.64.phar.asc gpg --keyserver hkps://keys.openpgp.org \ --recv-keys D8406D0D82947747293778314AA394086372C20A # Sebastian Bergmann gpg --verify /tmp/phpunit.phar.asc _tools/phpunit.phar # expect: Good signature chmod +x _tools/phpunit.phar ``` **`composer install` is still needed for the other dev tools** (`composer-license-checker`, and PHPStan on a fresh clone). It leaves the same trap behind, so the old recovery still applies to them — and only to them: ```bash rm -rf app/vendor/phpstan app/vendor/phpunit app/vendor/symfony/cache composer install git checkout -- app/vendor/composer/ # restore the production autoloader ``` The `rm -rf` is not optional: any checkout or merge deletes the untracked dev packages while leaving generated residue behind (`phpstan/phpstan/turbo-ext/`, the Redis proxies under `symfony/cache/Traits/`, the `Xdebug*` files); the directory survives, Composer only checks that a package directory *exists*, so it considers them installed and skips them. The final restore is mandatory before committing: shipping the dev autoloader fatals every installation (the bug 3.2.1.7 had to fix), and the `Committed autoloader is production-only` CI job rejects it. Full rationale in `.claude/skills/prepare-release/SKILL.md` §7. Database schema: initial install via `/install/install.php`, upgrades via `/install/upgrade.php` + `/install/upgrade_run_*.php`. ## Architecture Overview **Entry Points:** - Web: `/index.php` → page routing via `?page=` parameter - API: `/api/index.php` → JWT-authenticated REST - Install: `/install/install.php` or `/install/upgrade.php` **Request flow:** `index.php` → `/pages/*.php` (HTML) → `/pages/*.js.php` (JS) → AJAX → `/sources/*.queries.php` (JSON response) **Web root & proxy shims:** the web root is `public/`; the real handlers live in `app/sources/`. Each served `public/sources/*.queries.php` is a tiny proxy that defines `TEAMPASS_ROOT` and `require`s its `app/sources/` counterpart. **Rule: every new `app/sources/*.queries.php` needs a matching `public/sources/` shim.** Without it the POST hits a missing file, falls through to the front controller (which runs `csrfProtector::init()`), and is rejected with a misleading `403 Access Forbidden by CSRFProtector` — the CSRF token was never the problem, the request just never reached a real handler. **Directory Structure:** ``` /sources/ - Backend AJAX handlers (*.queries.php) + core.php, identify.php, main.functions.php /pages/ - Frontend templates (*.php) and JS (*.js.php) /includes/ - Config, core libs, teampassclasses/ /api/ - REST API (Controller/Api/, Model/, inc/) /install/ - Installer and upgrade scripts /scripts/ - Background tasks (cron) /vendor/ - Composer dependencies ``` **Custom TeamPass Classes** (in `/includes/libraries/teampassclasses/`, PSR-4): - `SessionManager` — Symfony Session + EncryptedSessionProxy (Redis opt-in or filesystem) - `ConfigManager` — settings from `teampass_misc` DB table, APCu-cached 60s; call `invalidateCache()` after writes - `PasswordManager` — bcrypt/argon2 via Symfony PasswordHasher - `Encryption` — AES-256-CBC for client-server comms - `NestedTree` — MPTT folder hierarchy (`nleft`/`nright`/`nlevel`); never update these columns manually - `CryptoManager` — single entry point for all RSA/AES crypto; never call phpseclib directly **Dual-location classes:** **every** `teampassclasses` package (`ConfigManager`, `SessionManager`, `CryptoManager`, `LdapExtra`, …) exists in both `app/includes/libraries/teampassclasses/` and `app/vendor/teampassclasses/`. Always edit both — **only the `vendor/` copy is autoloaded** (`app/vendor/composer/autoload_psr4.php`), so editing `includes/libraries/` alone produces a change with zero runtime effect. Sentinel tests: `tests/Unit/CryptoManagerCopiesInSyncTest.php`, `tests/Unit/LdapExtraCopiesInSyncTest.php`. ## Database Layer: MeekroDB ```php DB::query('SELECT * FROM ' . prefixTable('users') . ' WHERE id=%i', $userId); DB::queryFirstRow('SELECT * FROM ' . prefixTable('items') . ' WHERE id=%i', $itemId); DB::insert(prefixTable('items'), ['label' => 'Test', 'password' => $encrypted]); DB::update(prefixTable('users'), ['timestamp' => time()], 'id=%i', $userId); DB::delete(prefixTable('log_items'), 'id_item=%i', $itemId); // %s=string %i=integer %l=literal(table/col) %ls=array for IN ``` **Rule: never write a table name literally — always build it with `prefixTable()`.** The administrator chooses the table prefix at install time (`DB_PREFIX` in `app/config/settings.php`); `teampass_` is only the default. A query holding `teampass_items` hits a missing table on every other installation, and `db_error_handler()` throws an **uncaught** exception that aborts the whole page — half the Tools page silently disappeared this way (issue #5347). The rule covers every position a table name can take: `FROM`/`JOIN`, the first argument of `DB::insert|update|delete|replace`, a `SHOW TABLES LIKE` pattern, and the admin-facing text that tells an operator which table to back up (feed it `DB_PREFIX` through `sprintf()`). Two things that merely *look* like table names and must be left alone: the `encryption_type` column **value** `teampass_aes`, and setting keys such as `teampass_version`. CI enforces this (`table-prefix-guard` in `.github/workflows/quality.yml`); run it locally with `php .github/scripts/check_table_prefix.php`. It reads the reference table list from the installer, so a newly added table is covered with no list to maintain. **Key Tables** (logical names — always pass them through `prefixTable()`): `users`, `items`, `nested_tree`, `misc`, `log_items`, `sharekeys_items`, `roles_title` ## Authentication and Session Management **Login Flow:** `login.php` → `sources/identify.php` → DB/LDAP/OAuth2 validation → `SessionManager::getSession()` → set session vars → load encryption keys **Key session vars:** ```php $session = SessionManager::getSession(); $session->get('user-id'); // User ID $session->get('user-login'); // Username $session->get('user-admin'); // Admin flag (1/0) $session->get('user-roles'); // Semicolon-separated role IDs $session->get('user-accessible_folders'); // Array of folder IDs $session->get('user-privatekey'); // User's private encryption key $session->get('user-session_duration'); // Expiration timestamp $session->get('key'); // Random session encryption key ``` Session validation on every request via `sources/core.php` (checks `user-session_duration` + `key_tempo`). MFA: Google Authenticator (TOTP), Duo Security, YubiKey, AGSES. ## Encryption — Critical Rules > Full architecture details: @.claude/docs/architecture-encryption.md **Rule: always use `decryptUserObjectKeyWithMigration()` in new code** — never call `rsaDecrypt()` directly for sharekeys. This transparently upgrades phpseclib v1 → v3 on access. **Rule: applies to custom field sharekeys too** — every read path on `sharekeys_fields` must use `decryptUserObjectKeyWithMigration()`. The SELECT must include `increment_id`. **Rule: always encrypt before INSERT for custom fields** — never insert plaintext and update afterwards. A failed UPDATE leaves plaintext with `encryption_type='not_set'`, silently bypassing decryption. **Encryption version:** `encryption_version=1` = phpseclib v1 (SHA-1/OAEP, legacy), `encryption_version=3` = phpseclib v3 (SHA-256/OAEP, current). ## WebSocket > Full architecture details: @.claude/docs/architecture-websocket.md **Rule: always call the high-level helpers** (`emitItemEvent`, `emitFolderEvent`, etc.) after any write on items/folders in `sources/*.queries.php`. Never insert into `teampass_websocket_events` directly. ## Email Templates > Admin guide: `docs/manage/email-templates.md` — design notes: `workReadmeFiles/emails-templates-customization-plan.md` Emails are language strings, customizable per language by an admin. `Language::get()` checks `teampass_emails_templates` **before** the language files (`override[lang] → override[english] → file[lang] → file[english] → key`); the table is a pure diff, an empty table = shipped behaviour. Kill switch: `emails_templates_enabled`. **Rule: a new email is only customizable once it has an entry in `app/config/emails_templates.php`** — the catalog is the allow-list `Language::get()` checks, so a key absent from it is never looked up in the DB (and the admin page cannot reach it). Declare `subject_key`, `body_key`, `tokens` (exactly what the call site substitutes) and `required_tokens`. **Rule: `Language` exists in two copies** (`includes/libraries/` + `vendor/`, only the latter autoloaded) — edit both; `tests/Unit/EmailsTemplatesCatalogTest.php` fails otherwise. **Rule: use `getShipped()`, not `get()`, when you need the text *without* the customization.** **Rule: an empty override is not an override** — the resolver ignores it, so `background_tasks___worker.php`'s key-as-fallback guard keeps working. Save-time normalization + token validation live in the DB-free `app/sources/emails_templates_logic.php` (unit-tested by `tests/Unit/EmailsTemplatesLogicTest.php`). ## PHP-FPM > Full architecture details: @.claude/docs/architecture-php-fpm.md **Rule: spawn background tasks with `getPHPBinary()`** — it resolves a real PHP CLI binary under FPM (never `php-fpm` / `'false'`). **Rule: `tpFinishRequestEarly()` only after the full response is echoed** — later output is not delivered. Admin settings: `cli_php_binary_path`, `enable_fastcgi_finish_request`. ## Item Revisions & Offline Sync > Full architecture details: @.claude/docs/architecture-item-revisions.md Every item carries a monotonic `revision`, allocated from the `teampass_items_revisions` journal whose `AUTO_INCREMENT` key **is** the global sequence. It lets an offline client detect staleness, decide which side is newer, and pull only what changed (`GET /api/v1/item/changes`). **Rule: the bump rides on `logItems()`** — a new item write path that bypasses it (raw `DB::insert(log_items)`, hard delete, bulk field operations) must call `bumpItemRevision()` explicitly, and **before** the row disappears. **Rule: reads and ciphertext-only rewrites never bump** — re-encrypting does not change the plaintext a client caches. **Rule: the journal is not a history** (that is `log_items`), and its setting `offline_sync_window_days` is a sync window, never a "retention": pruning it loses nothing, a client outside the window just does a full resync. ## API > Full reference: @.claude/docs/api-reference.md > Item mutation idempotency architecture: @.claude/docs/architecture-api-idempotency.md Controllers in `/api/Controller/Api/`. JWT auth via `Authorization: Bearer `. Key endpoints: `/api/authorize`, `/api/item/get`, `/api/item/create`, `/api/item/getOtp`, `/api/folder/listFolders`. ## LAPR (Linux Account Password Rotation) > Full architecture details: @.claude/docs/architecture-lapr.md Agentless SSH rotation of local Linux account passwords (release 3.2.2, feature `feature/lapr-mvp1`). Pages `lapr_endpoints|lapr_accounts|lapr_policies|admin_lapr`, handlers `sources/lapr_*.queries.php`, SSH class `TeampassClasses\Lapr\LAPRSshService` (require_once, not PSR-4), background traits `LAPRSshTestTrait|LAPRDiscoverTrait|LAPRRotationTrait`. **Rule: all SSH work runs in background traits** — never in a `*.queries.php` request thread. **Rule: never log a secret** — `laprAuditLog()`/`action_details` are whitelisted, never a password. **Rule: read a credential/item as the server via `laprReadItemPasswordAsTpUser()`** (TP_USER chain, migration-aware) — non-personal items only. **Rule: write a rotated item password by mirroring `laprUpdateItemPassword()`** (pw_iv + sharekey fan-out via `apiUserId=TP_USER_ID` + history `old_value` + `emitItemEvent`). **Rule: gate every operational handler with `laprCheckPermission()`** (`lapr_enabled` + **non-admin** + `can_manage_lapr`) — TeamPass administrators configure LAPR through `admin_lapr` only; the operational pages depend on item access, which admins do not have, so `laprUserCanWriteFolder()`/`laprUserCanReadFolder()` reject them too. **Rule: read LAPR item roles through `laprGetItemRelations($itemIds, $SETTINGS)`** — it is module-aware (returns `[]` when `lapr_enabled != 1`), so disabling LAPR never leaves items frozen; the delete/move guards (`laprItemsDeletionBlocker()`, `laprItemsPersonalMoveBlocker()`) build on it and must be applied to **every** write path, single **and** mass. Host-key mismatch **blocks** rotation (D4); `username_cache` is hard-validated (R1) and generated passwords filtered for `chpasswd` safety (R9). ## Licence Trial (self-service extension trial) > Full architecture details: @.claude/docs/architecture-licence-trial.md Settings → API → **Licence** lets an administrator request a 30-day extension trial from `licence.teampass.net` (release 3.2.2). Decisions in the DB-free `app/sources/licence_trial_logic.php`, transport and state in `app/sources/licence.functions.php`, handlers in `admin.queries.php` (`get_licence_panel`, `refresh_licence_status`, `request_licence_trial`, `send_licence_trial_link`). **Rule: the TeamPass server is the caller** — answers are RSA-signed and must be verified on the **raw body**; a body that does not verify is discarded (except a 5xx, reported as unreachable). **Rule: never poll in the background** — one shared budget of 6 `info.php` calls/hour covers the dashboard widget, the Licence tab and the manual button; a cache hit never consumes it. **Rule: validate the FQDN before the POST** — a trial is granted once per (FQDN, product) forever, and `browser_extension_fqdn` legitimately holds `localhost` on local installs. **Rule: the extension key must never change once a licence exists** — the licence server has no update route. **Rule: `202` is a success, and a resent link kills the previous one** — both must be stated in the interface, they are the top support drivers. **Rule: an instance with no outbound access requests through the link, never through a POST it cannot make** — `licenceTrialOfflineRequestUrl()` builds a link to `trial-request.php` on the licence server (source in `_things/licence-server-api/`), carried out by e-mail, clipboard or QR; the link holds the licence key and must never point anywhere else. Sending it is a trace (`offline_link_sent_at`), not a state transition, and the instance will never see the activation — the extension validates from the browser, so that costs nothing. ## Browser Extension Auto-Configuration > Full architecture details: @.claude/docs/architecture-extension-autoconfig.md One-click setup of the browser extension from the web app: a same-origin `window.postMessage` bridge detects the extension (content script on ``) and pushes a config bundle; a downloadable JSON file is the fallback. Credentials use token mode (a durable PAT) — **the password is never transmitted**. **Rule: both PAT gates (issuance in `users.queries.php`, consumption in `AuthModel::getUserAuthByToken`) are relaxed only behind the admin toggle `extension_token_all_auth_types`** (default `0`, Settings → API → Browser extension). Off ⇒ OAuth2-only behaviour preserved. **Rule: the auto-config PAT is durable** (`expires_at = NULL`), not single-use — token mode reuses it for silent re-auth; only the bundle has a 24h staleness window. **Rule: reload the unpacked extension fully after changing `content/`, `background/`, or `confirm/`** — Chrome does not hot-reload them. ## Security Considerations 1. **Parameterized queries always:** ```php DB::query('SELECT * FROM %l WHERE id=%i', 'teampass_items', $itemId); // GOOD DB::query("SELECT * FROM teampass_items WHERE id=" . $itemId); // NEVER ``` 2. **Session validation on every sensitive operation:** ```php $session = SessionManager::getSession(); if (!$session->has('user-id')) { echo json_encode(['error' => 'Not authenticated']); exit; } ``` 3. **CSRF:** `owasp/csrf-protector-php` — tokens validated on all state-changing requests 4. **XSS:** `voku/anti-xss` (server) + DOMPurify (client) + `htmlspecialchars()` 5. **Input:** `elegantweb/sanitizer` for user input 6. **Never commit** `includes/config/settings.php` or `TEAMPASS_SECRETS/SECUREFILE` 7. **Never use** `exec()`/`shell_exec()`/`system()` with user input 8. **Path traversal:** validate file paths with `realpath()` 9. **Timing attacks:** use `hash_equals()` for secret comparisons ## Code Patterns and Conventions PHP Code must pass **PHPStan level 4**. - `declare(strict_types=1)` in all new PHP files - Custom classes: `TeampassClasses\*` namespace - Constants: defined in `/app/config/include.php` **Standard AJAX handler** (`/sources/*.queries.php`): ```php has('user-id')) { echo json_encode(['error' => 'Not authenticated']); exit; } $type = filter_input(INPUT_POST, 'type', FILTER_SANITIZE_STRING); switch ($type) { case 'action_name': echo json_encode(['status' => 'success', 'data' => $result]); break; default: echo json_encode(['error' => 'Unknown action']); } ``` **JavaScript** (per `.eslintrc`): single quotes, no semicolons, 2-space indent, `const` over `let`, arrow functions, ES6. **Folder tree:** always use `NestedTree` class — never manually update `nleft`/`nright`/`nlevel`. ## Testing and Debugging A PHPUnit suite lives in `tests/` (74 test classes, `phpunit.xml` at the repository root) — it covers the DB-free logic modules and the sentinel tests that guard the dual-location class copies. See the Development Commands caveat before running it. Debugging: PHP error logging, TeamPass Admin > Logs, MeekroDB query hooks, browser console + network tab. **Manual testing checklist:** multiple user roles (admin, standard, read-only), personal folders on/off, encryption scenarios, audit log creation, LDAP/OAuth2 if auth changed. ## Common Development Workflows **New setting:** INSERT into `teampass_misc`, access via `ConfigManager::getSetting()`, add to upgrade script, call `ConfigManager::invalidateCache()` after writes. **New page:** `/pages/mypage.php` + `/pages/mypage.js.php` + `app/sources/mypage.queries.php` + **proxy shim `public/sources/mypage.queries.php`** + route in `index.php` + permission check in `sources/core.php`. **New API endpoint:** controller in `/api/Controller/Api/`, route in `/api/index.php`, JWT validation, JSON response. **DB schema change:** new `/install/upgrade_run_X.X.X.php`, update version in `/app/config/include.php`, test upgrade path. **PR from GitHub:** 1. Analyse the original issue and the PR changes 2. Confirm the fix is appropriate 3. Ensure PHPStan level 4 compatibility ## Important Files - `index.php` — entry point, session validation, page routing - `sources/core.php` — session init and validation (loaded by all backends) - `sources/main.functions.php` — shared utility functions (150KB+) - `sources/identify.php` — login authentication logic - `includes/config/include.php` — application constants - `includes/config/settings.php` — DB credentials (never commit) - `install/upgrade.php` — version detection and upgrade orchestration - `api/index.php` — API router with JWT validation - `scripts/background_tasks___handler.php` — cron job entry point ## Version Information Constants in `/app/config/include.php`: `TP_VERSION` (major.minor), `TP_VERSION_MINOR` (patch). Upgrades via `/install/upgrade_run_*.php`. ## IMPORTANT Rules - Always prioritize minimal modifications. - Never suggest a complete refactor. - Respect the existing coding style. - New functions must remain compatible with PHP 8.2. - Secure all user inputs (SQLi, XSS). - Do not invent things; rely strictly on factual data. ### SQL Compatibility (ONLY_FULL_GROUP_BY) MySQL default since 5.7.5. Rules: - Every non-aggregated SELECT column must be in GROUP BY, or functionally dependent on the GROUP BY key - Never `SELECT *` with partial GROUP BY - Prefer `SELECT DISTINCT` over GROUP BY when no aggregation needed - Aliases defined in SELECT can be referenced in GROUP BY (MySQL extension, valid) ## Expected Style - Readable code, useful but not excessive comments - No external libraries without explicit request - Comments in English ## Commit - Commit messages must always be in English - Use simple and concise sentences ## PR Review Conventions - PR branches must target `pr-XXXX` with XXX = GitHub ID - All public functions must have a docblock - Variable names in English only - No `var_dump()` or `console.log()` in production - Watch for impacts on `install` and `upgrade` ## MCP Tools: code-review-graph **IMPORTANT: This project has a knowledge graph. ALWAYS use the code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore the codebase.** The graph is faster, cheaper (fewer tokens), and gives you structural context (callers, dependents, test coverage) that file scanning cannot. ### When to use graph tools FIRST - **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep - **Understanding impact**: `get_impact_radius` instead of manually tracing imports - **Code review**: `detect_changes` + `get_review_context` instead of reading entire files - **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for - **Architecture questions**: `get_architecture_overview` + `list_communities` Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. ### Key Tools | Tool | Use when | | ------ | ---------- | | `detect_changes` | Reviewing code changes — gives risk-scored analysis | | `get_review_context` | Need source snippets for review — token-efficient | | `get_impact_radius` | Understanding blast radius of a change | | `get_affected_flows` | Finding which execution paths are impacted | | `query_graph` | Tracing callers, callees, imports, tests, dependencies | | `semantic_search_nodes` | Finding functions/classes by name or keyword | | `get_architecture_overview` | Understanding high-level codebase structure | | `refactor_tool` | Planning renames, finding dead code | ### Workflow 1. The graph auto-updates on file changes (via hooks). 2. Use `detect_changes` for code review. 3. Use `get_affected_flows` to understand impact. 4. Use `query_graph` pattern="tests_for" to check coverage.