# 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 app/vendor/bin/composer-license-checker            # License compliance
composer install                                       # PHP dependencies
php app/scripts/background_tasks___handler.php         # Background tasks
php app/vendor/phpunit/phpunit/phpunit                 # Unit tests — see caveat below
```

A full PHPStan run takes several minutes — launch it in the background rather than blocking on it.

**PHPUnit does not run from a fresh checkout — repair the toolchain first.** The dev
dependencies are untracked, so any checkout or merge deletes them while leaving generated
residue behind (`phpstan/phpstan/turbo-ext/`, the Redis proxies under `symfony/cache/Traits/`,
the `Xdebug*` files). Their directory survives, Composer only checks that a package directory
*exists*, so it considers them installed and skips them. On top of that the committed
`app/vendor/composer/` is the **production** autoloader (`composer install --no-dev`) and 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`.

```bash
rm -rf app/vendor/phpstan app/vendor/phpunit app/vendor/symfony/cache
composer install
php app/vendor/phpunit/phpunit/phpunit          # expect: OK (1344 tests, 38419 assertions)
php app/vendor/bin/phpstan analyse --memory-limit=2G
git checkout -- app/vendor/composer/            # LAST — it disarms the toolchain again
```

The `rm -rf` is not optional: without it `composer install` repairs nothing for those three
packages. The order of the last three lines is not free either — `composer install` rewrites
`app/vendor/composer/` into its *dev* form, which is what makes the tools runnable, and the
final `git checkout` puts the production form back. Run the toolchain **between** the two.
That restore is mandatory before committing: shipping the dev autoloader fatals every
installation (the bug 3.2.1.7 had to fix). 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 %l WHERE id=%i', 'teampass_users', $userId);
DB::queryFirstRow('SELECT * FROM %l WHERE id=%i', 'teampass_items', $itemId);
DB::insert('teampass_items', ['label' => 'Test', 'password' => $encrypted]);
DB::update('teampass_users', ['timestamp' => time()], 'id=%i', $userId);
DB::delete('teampass_log_items', 'id_item=%i', $itemId);
// %s=string  %i=integer  %l=literal(table/col)  %ls=array for IN
```

**Key Tables:** `teampass_users`, `teampass_items`, `teampass_nested_tree`, `teampass_misc`, `teampass_log_items`, `teampass_sharekeys_items`, `teampass_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

Controllers in `/api/Controller/Api/`. JWT auth via `Authorization: Bearer <token>`. 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).

## 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 `<all_urls>`) 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
<?php
declare(strict_types=1);
require_once 'core.php';
$session = SessionManager::getSession();
if (!$session->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.
