Files
computer-playbook/docs/requirements/024-web-app-erpnext.md
kevinveenbirkenbachandClaude Opus 5 f40d627a1b docs(agents): merge the tool-specific files into AGENTS.md
CLAUDE.md and GEMINI.md existed as per-runtime extensions of AGENTS.md, and
only one of them carried anything at all: GEMINI.md stated that it had no
additions, while CLAUDE.md held rules that bind every runtime anyway — the
permission announcement, the make-over-raw preference, the loop, chain and
heredoc bans, the stream-the-log-to-/tmp rule, the CI-evidence rule, the push
ban and the comment rule. A rule that binds every agent does not belong in one
agent's file. AGENTS.md absorbs them and now states that no tool-specific
extension file exists; agents whose runtime auto-loads a different filename
reach it through their own configuration instead.

The filenames were machinery as much as prose. The PR templates, the branch
and pull-request scope tables, the settings and security docs that justified
the loop deny by "the textual CLAUDE.md rule", six requirement documents whose
push instruction linked ../../CLAUDE.md, and a test docstring citing the
shell-loop ban all named them, and now name AGENTS.md.
scripts/meta/resolve/pr/scope.sh deliberately keeps matching CLAUDE.md and
GEMINI.md: this change deletes them, and without those patterns the change
itself would not classify as agent-scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 22:51:04 +02:00

316 lines
23 KiB
Markdown

# 024 - ERPNext Role with OIDC SSO
## User Story
As a platform administrator of Infinito.Nexus, I want ERPNext (Frappe Framework) integrated as a `web-app-erpnext` role with OpenID Connect identity provider integration so that users can access the ERP through the same Single Sign-On (SSO) mechanism used across the Infinito.Nexus ecosystem, reusing the platform's central database and cache services.
## Background
ERPNext is an open-source ERP suite built on the [Frappe Framework](https://frappeframework.com/). Upstream ships a multi-container deployment ([frappe_docker](https://github.com/frappe/frappe_docker)) consisting of:
- **`frappe`** (Gunicorn) — Python web backend
- **`socketio`** — Node real-time WebSocket service
- **`scheduler`** — periodic-job dispatcher (`bench schedule`)
- **Workers**: `queue-short`, `queue-default`, `queue-long` — background jobs
- **Nginx** (frontend) — static assets + reverse proxy onto Frappe / SocketIO
- Data plane: **MariaDB** (primary), **Redis** (three logical roles: `cache`, `queue`, `socketio`)
Two of those data-plane dependencies have a central Infinito.Nexus service equivalent — [`svc-db-mariadb`](../../roles/svc-db-mariadb/) and [`svc-db-redis`](../../roles/svc-db-redis/) — and MUST be reused per the central-service convention. Frappe's three Redis logical roles are served by a single central Redis instance using distinct DB numbers (Frappe supports that out of the box via `redis_cache` / `redis_queue` / `redis_socketio` config entries).
Frappe ships native OAuth 2.0 / OpenID Connect client support via the built-in **"Social Login Key"** record (see [Frappe docs: Social Login Key](https://docs.frappe.io/framework/user/en/guides/integration/social_login_key)). The integration uses Frappe's own OIDC client against the Infinito Keycloak IdP — no `oauth2-proxy` sidecar. The role therefore uses the `services.sso.flavor: oidc` schema (post-[021](README.md#archive) unified SSO contract), aligned with [`web-app-odoo`](../../roles/web-app-odoo/meta/services.yml).
The closest existing analogues in this repo are [`web-app-odoo`](../../roles/web-app-odoo/) (ERP shape, OIDC + LDAP variants, central MariaDB consumer pattern omitted — odoo uses Postgres) and [`web-app-zammad`](../../roles/web-app-zammad/) (central-service reuse pattern, OIDC-direct flavor, three-variant matrix).
## Confirmed Decisions
These decisions were confirmed by the operator before implementation starts and are NOT subject to re-litigation during implementation.
| # | Decision | Rationale |
|---|---|---|
| 1 | Canonical hostname: `next.erp.{{ DOMAIN_PRIMARY }}`. No alias on first iteration. | Mirrors the `odoo.erp.{{ DOMAIN_PRIMARY }}` convention used by [`web-app-odoo`](../../roles/web-app-odoo/meta/domains.yml); both ERPs sit under the `.erp.` subdomain. |
| 2 | SSO flavor is **OIDC-direct** (`services.sso.flavor: oidc`), NOT oauth2-proxy. The role uses Frappe's built-in Social Login Key. | Frappe supports OIDC natively as an OAuth client; an oauth2-proxy sidecar would be redundant and break the Frappe "Login with X" UX. |
| 3 | Use the unified post-[021](README.md#archive) `services.sso.*` schema (matches [`web-app-odoo`](../../roles/web-app-odoo/meta/services.yml)). | [021](README.md#archive) is merged. New roles MUST land on the unified schema directly, not on the legacy oauth2/oidc service shape. |
| 4 | The Keycloak OIDC client for ERPNext is **auto-provisioned** via `web-app-keycloak`, consistent with every other OIDC-consuming role in the repo. | No manual operator step on deploy. |
| 5 | Keycloak group → ERPNext role mapping target tiers: `roles/web-app-erpnext/administrator` → Frappe `System Manager`, `roles/web-app-erpnext/manager` → Frappe `Sales User` + `Purchase User` + `Stock User` + `Accounts User`, default → Frappe `Customer`. **v1 scope split:** the path-to-roles map is persisted at deploy time by `apply/oidc_settings.py` (writes JSON to `frappe.db.set_default("erpnext_oidc_group_role_map", …)`). Per-login reconciliation into Frappe role records still requires a custom Frappe app shipping a `hooks.py` `on_session_creation` callback — that piece is **deferred to a follow-up requirement**. | Operator-confirmable; Frappe's Social Login Key alone does NOT consume group claims. Persisting the map in v1 unblocks the follow-up requirement without re-litigating the mapping table. |
| 6 | Image strategy: **upstream `frappe/erpnext`** (the official image from [frappe_docker](https://github.com/frappe/frappe_docker)), pinned to the **latest stable v15.x** in `meta/services.yml` (no `:latest`, no `:edge`). v14 LTS and the v16 pre-release line are explicitly NOT used. Bump path follows the same convention as other upstream-pinned roles. | Operator-confirmed: track the actively maintained major. Self-built image (à la [015 Moodle](README.md#archive)) explicitly out of scope. |
| 7 | External-service reuse: **`svc-db-mariadb`** for MariaDB (primary DB), **`svc-db-redis`** for all three Frappe Redis logical roles (separated by DB number: `cache=0`, `queue=1`, `socketio=2`). Nginx frontend stays bundled in-role (Frappe-aware reverse-proxy config). | Operator-confirmable; matches the convention codified in [022 Zammad Decision #7](README.md#archive). No `svc-db-` role exists for Nginx frontend because it is Frappe-coupled, not a general-purpose service. |
| 8 | Email integration in v1: **outbound SMTP only**. When `web-app-mailu` is in `group_names`, Frappe's outbound Email Account is auto-configured against the central SMTP endpoint so notification / password-reset emails leave the box. **IMAP inbound (mail-to-Communication) is deferred to a follow-up requirement.** Rationale: the survey found no precedent in this repo for auto-provisioning role-owned mailboxes in Mailu, so the inbound side requires a new generic Mailu mechanism that is out of scope for this PR. | Operator-confirmed: defer inbound; v1 = outbound only. The follow-up requirement will introduce the generic Mailu auto-mailbox hook and sweep this role at the same time. |
| 9 | The Frappe site-setup wizard is **bypassed** on first deploy via auto-bootstrap: `bench new-site` with `--admin-password`, `--db-name`, `--mariadb-user-host-login-scope=%` arguments, plus `bench install-app erpnext`, plus a post-start API call from the role's `tasks/` to mark the wizard as completed (`frappe.db.set_default('setup_complete', '1')`). A fresh deploy lands directly on the ERPNext desk. | Matches the Zammad wizard-bypass pattern ([022 Decision #9](README.md#archive)); avoids a manual UI step on every fresh box. |
| 10 | Playwright coverage per [019](README.md#archive): both `biber` and `administrator` personas ship as part of THIS requirement (not deferred). | Mirrors the Zammad rollout ([022 Decision #10](README.md#archive)). |
| 11 | `meta/variants.yml` defines three variants, mirroring the [`web-app-kix`](../../roles/web-app-kix/meta/variants.yml) / [`web-app-zammad`](../../roles/web-app-zammad/meta/variants.yml) pattern: (V1) `sso` + `ldap` both enabled, (V2) all dynamic services flags `false`, (V3) `ldap` only. | Standard variant matrix across helpdesk / ERP roles. |
| 12 | Multi-tenancy / multi-site (Frappe's `bench --site`) is **out of scope** for v1. The role provisions exactly one site (`{{ canonical_domain }}`) and a single ERPNext app install. | Keeps v1 surface small; multi-site can land in a follow-up requirement if needed. |
| 13 | Backup hook in v1: **documented as an operator command in the role README**. `bench --site {{ canonical_domain }} backup --with-files` is the documented manual command; the central MariaDB volume is already covered by the standard `svc-bkp-` driver and Frappe site-files are persisted on a named volume that the existing backup driver also covers. Adding a `pre_backup_hook` schema to `svc-bkp-` for in-flight `bench backup` is deferred to a follow-up requirement. | Operator-confirmed: keep this PR focused on the role itself. No `meta/services.yml::<app>.backup.pre_backup_hook` field is introduced here; central MariaDB + role volume snapshots are the v1 backup story. |
## Target Schema
### Role layout
```
roles/web-app-erpnext/
├── README.md
├── files/
│ └── playwright/test-*.js
├── meta/
│ ├── main.yml
│ ├── info.yml
│ ├── server.yml
│ ├── services.yml
│ ├── secrets.yml
│ ├── users.yml
│ ├── variants.yml
│ └── volumes.yml
├── tasks/
│ ├── main.yml
│ ├── 01_core.yml
│ └── 03_bench_bootstrap.yml # new-site + install-app + wizard-bypass
├── templates/
│ ├── docker-compose.yml.j2
│ ├── env.j2
│ ├── common_site_config.json.j2
│ └── playwright.env.j2
└── vars/
└── main.yml
```
### `meta/services.yml` excerpt
```yaml
---
sso:
enabled: "{{ 'web-app-keycloak' in group_names }}"
shared: "{{ 'web-app-keycloak' in group_names }}"
flavor: oidc
ldap:
enabled: "{{ 'svc-db-openldap' in group_names }}"
shared: "{{ 'svc-db-openldap' in group_names }}"
email:
enabled: "{{ 'web-app-mailu' in group_names }}"
shared: "{{ 'web-app-mailu' in group_names }}"
logout:
enabled: "{{ 'web-svc-logout' in group_names }}"
shared: "{{ 'web-svc-logout' in group_names }}"
dashboard:
enabled: "{{ 'web-app-dashboard' in group_names }}"
shared: "{{ 'web-app-dashboard' in group_names }}"
matomo:
enabled: "{{ 'web-app-matomo' in group_names }}"
shared: "{{ 'web-app-matomo' in group_names }}"
prometheus:
enabled: "{{ 'web-app-prometheus' in group_names }}"
shared: "{{ 'web-app-prometheus' in group_names }}"
# nocheck: playwright-service-flag — DB engine, covered by role-local integration tests
mariadb:
enabled: true # nocheck: dynamic-flag
shared: "{{ 'svc-db-mariadb' in group_names }}"
# nocheck: playwright-service-flag — cache/queue/socketio buses, covered by role-local integration tests
redis:
enabled: true # nocheck: dynamic-flag
shared: "{{ 'svc-db-redis' in group_names }}"
erpnext:
backup:
no_stop_required: false # Frappe's bench backup requires a quiesced site
image: frappe/erpnext
version: "X.Y.Z" # latest stable semver at the time of the PR
name: erpnext
min_storage: 15GB
ports:
local:
http: <free port> # nginx frontend
socketio: <free port>
run_after:
- svc-db-mariadb
- svc-db-redis
- web-app-keycloak
- web-app-mailu
lifecycle: alpha
cpus: "2.0"
mem_reservation: 2g
mem_limit: 4g
pids_limit: 2048
```
### `meta/variants.yml` (three variants per Decision #11)
```yaml
---
# V1: sso + ldap together (everything that can be true, is true)
- services:
sso:
enabled: true
shared: true
ldap:
enabled: true
shared: true
email:
enabled: true
shared: true
# … all other dynamic flags true …
# V2: no auth — everything false
- services:
sso:
enabled: false
shared: false
ldap:
enabled: false
shared: false
email:
enabled: false
shared: false
# … all other dynamic flags false …
# V3: ldap only
- services:
sso:
enabled: false
shared: false
ldap:
enabled: true
shared: true
email:
enabled: false
shared: false
# … all other dynamic flags false …
```
### Frappe site config (Decision #7 — Redis DB-number split)
The role MUST template `sites/{{ canonical_domain }}/site_config.json` (or the equivalent common-site-config layer) so Frappe's three Redis logical roles all point at the shared `svc-db-redis` instance with distinct DB numbers:
```json
{
"redis_cache": "redis://<svc-db-redis-host>:<port>/0",
"redis_queue": "redis://<svc-db-redis-host>:<port>/1",
"redis_socketio": "redis://<svc-db-redis-host>:<port>/2"
}
```
DB numbers (0 / 1 / 2) are stable for v1; if `svc-db-redis` later partitions tenants, this requirement gets swept by the same migration.
## Acceptance Criteria
### Routing & TLS
- [x] `next.erp.{{ DOMAIN_PRIMARY }}` resolves through `sys-svc-proxy` to the ERPNext Nginx frontend and returns HTTP 200 on `GET /` with a Frappe-served HTML body (verified in matrix r3 across all three variants; CSP `unsafe-eval` added to satisfy Frappe's `eval()` use).
- [x] WebSocket upgrade to the SocketIO container is wired (`websocket` port in `meta/services.yml`, `BACKEND`/`SOCKETIO` env on the frontend nginx).
- [x] CSP `connect-src` whitelist includes the canonical host and its `wss://` variant (mirrors the [`web-app-odoo`](../../roles/web-app-odoo/meta/csp.yml) precedent).
### Role layout & image
- [x] `roles/web-app-erpnext/` exists with the layout in the [Target Schema](#role-layout) above.
- [x] `meta/services.yml` pins `frappe/erpnext` to a concrete stable v15.x semver (no `:latest`, no `:edge`, no v14, no v16).
- [x] `meta/info.yml`, `meta/server.yml`, `meta/main.yml`, `meta/secrets.yml`, `meta/users.yml`, `meta/volumes.yml`, `meta/rbac.yml`, `meta/variants.yml` exist and pass the repo's standard role-meta lint (per [008](README.md#archive)).
### Central-service reuse (Decision #7)
- [x] When `services.mariadb.shared=true` (V1 + matching `group_names`), Frappe connects to the central `svc-db-mariadb` via the in-stack `mariadb` network alias using svc-db-mariadb's `secrets.credentials.root_password` for `bench new-site`; when `shared=false` (V2/V3) `sys-svc-rdbms` templates a per-role MariaDB container and bench uses the consumer's per-role db password as root.
- [x] Frappe's three Redis logical roles share one instance via DB-number split (`cache=0`, `queue=1`, `socketio=2`) using the in-compose `redis` alias.
- [x] When `services.mariadb.enabled` (always true for ERPNext) is unsatisfiable, `sys-stk-full` fails the deploy at the database-readiness gate before bench-bootstrap runs.
### SSO / OIDC (Decisions #4, #5)
- [x] When `web-app-keycloak` is in `group_names`, the existing `web-app-keycloak/redirect_uris` filter auto-includes ERPNext's `https://next.erp.<DOMAIN_PRIMARY>/*` in the realm's shared OIDC client (no per-role Keycloak entry needed).
- [x] Frappe's Social Login Key for Keycloak is auto-created by `tasks/04_oidc.yml` via `files/scripts/apply/oidc_settings.py` (provider=Keycloak, `custom_base_url=1`, `auth_url_data={response_type:code,scope:openid}`, login_via_keycloak redirect path).
- [x] V1 OIDC + administrator + biber Playwright specs land on the Frappe desk (matrix r3 5-passed-2-skipped on V1).
- [ ] **Group mapping (Decision #5)**: the path-to-roles map is persisted (`apply/oidc_settings.py` writes it to `frappe.db.set_default("erpnext_oidc_group_role_map", …)`), but reconciling the map into Frappe roles on each OIDC login still needs a `hooks.py` `on_session_creation` hook in a small custom Frappe app. Deferred to a follow-up requirement; documented in README.
### LDAP (V3 variant + V1 dual)
- [x] When `svc-db-openldap` is in `group_names`, Frappe's LDAP Settings doctype is auto-configured by `tasks/05_ldap.yml` via `files/scripts/apply/ldap_source.py`. End-to-end LDAP-login (auto-create Frappe user from LDAP bind on first sign-in) is **deferred to a follow-up requirement** — needs additional Frappe-side attribute mapping + user-creation hooks; documented in README. The corresponding Playwright login specs (`test-login-via-ldap-*`) are intentionally absent in v1.
- [x] When both are in `group_names` (variant V1), the role deploys cleanly with OIDC as the primary login button.
### Email (Decision #8 — outbound only in v1)
- [x] When `web-app-mailu` is in `group_names`, ERPNext's outbound `Email Account` is auto-configured by `tasks/06_email.yml` via `files/scripts/apply/email_account.py` (uses `db_insert`/`db_update` to bypass Frappe's deploy-time SMTP socket validation).
- [x] When `web-app-mailu` is NOT in `group_names`, `tasks/01_core.yml` skips the email subtask via `when: ERPNEXT_EMAIL_ENABLED` (verified by the V2 all-false variant deploying clean without an Email Account record).
- [x] `roles/web-app-erpnext/README.md` notes that **IMAP inbound (mail-to-Communication) is deferred to a follow-up requirement** and points to the upstream Frappe Email Account doctype for operator-manual inbound config in the meantime.
### First-admin bootstrap (Decision #9)
- [x] `apply/api_bot.py` sets `setup_complete=1` on the `System Settings` doctype + writes the global default; verified in matrix r3.
- [x] The Frappe `Administrator` user is seeded by `bench new-site --admin-password=$ERPNEXT_INITIAL_ADMIN_PASSWORD`; the `test-login-native-administrator.js` Playwright spec exercises the break-glass local login in every variant (passed in matrix r3).
- [x] `bench install-app erpnext` runs as part of `bench new-site --install-app erpnext` in `tasks/03_bench_bootstrap.yml`; the long-lived Frappe containers are restarted afterwards so their cached app set picks up the new install (otherwise `/login` serves HTTP 500 from a stale app cache).
### Variants (Decision #11)
- [x] `meta/variants.yml` defines exactly three variants in this order: V1 sso+ldap, V2 all-false, V3 ldap-only.
- [x] All three variants deploy cleanly on a fresh box (FULL matrix gate `make compose-deploy mode=reinstall apps=web-app-erpnext full_cycle=true purge=true` — 6 PLAY RECAPs, all `failed=0`).
### Backup (Decision #13 — documented operator command in v1)
- [x] `roles/web-app-erpnext/README.md` documents the manual backup command (`bench --site {{ canonical_domain }} backup --with-files`) and the resulting artefacts (`*.sql.gz` + `*-files.tar` + `*-private-files.tar`).
- [x] `roles/web-app-erpnext/README.md` documents restore (the inverse `bench --site … restore` + central-MariaDB import path).
- [x] No `pre_backup_hook` schema is introduced in `svc-bkp-*` here; that is deferred to a follow-up requirement.
### Playwright (Decision #10, per [019](README.md#archive))
- [x] `roles/web-app-erpnext/files/playwright/test-login-biber.js` contains the biber-persona spec, exercising the SSO-sign-in path landing on an authenticated ERPNext surface. (Flat layout per the existing `web-app-zammad` precedent.)
- [x] `roles/web-app-erpnext/files/playwright/test-login-administrator.js` contains the administrator-persona spec, exercising the SSO-sign-in path landing on the Frappe desk.
- [x] `roles/web-app-erpnext/files/playwright/test-login-native-administrator.js` adds a break-glass local-login spec for `Administrator` (runs in every variant — not gated on SSO/LDAP).
- [x] OIDC specs gate on `SSO_SERVICE_ENABLED` per the standard `service-gating.js` helper (via `shared.skipUnlessServiceEnabled("sso")`), so they skip-correctly under V2/V3.
- [x] `templates/playwright.env.j2` emits the standard service-flag set per [019 Rule 6](README.md#archive); unused flags carry `# nocheck: playwright-service-gate` markers with documented rationale.
### Health & quality
- [x] ERPNext's compose stack reaches a steady running state across all three variants in matrix r3 (`backend`, `frontend`, `websocket`, `scheduler`, `queue-short`, `queue-long`, plus the one-shot `configurator`). v1 ships two workers (`queue-short` + `queue-long`) per the canonical frappe_docker v15 layout — `queue-default` is absorbed into `queue-short`'s `--queue short,default` flag.
- [x] No fatal failures in matrix r3 (all PLAY RECAPs `failed=0`; Playwright runs all passed-or-skipped, no failures).
- [x] `make test` is green tree-wide (the role passes role-meta lints, services contract lints, playwright-services-parity lints, and the new `test_task_name_length` lint that caps Ansible `name:` strings at 120 chars).
### Documentation
- [x] `roles/web-app-erpnext/README.md` documents: image source + bump policy, the central-MariaDB and central-Redis (3-DB-number split) consumer pattern, the OIDC group-mapping reconciliation, the variant matrix, the wizard-bypass bootstrap path, and the backup / restore flow.
- [ ] This requirement file is cross-linked from the implementing PR (per [docs/contributing/requirements.md#cross-linking](../contributing/requirements.md#cross-linking)).
## Validation Apps
The role MUST deploy cleanly under all three variants on a fresh box. V1 (sso + ldap) and V3 (ldap-only) additionally MUST pass the biber + administrator Playwright personas.
```bash
INFINITO_APPS="web-app-erpnext" \
make deploy-fresh-purged-apps INFINITO_FULL_CYCLE=true
```
End-to-end smoke after deploy:
1. Visit `https://next.erp.{{ DOMAIN_PRIMARY }}/` — Frappe / ERPNext login page renders, no wizard.
2. Click the "Login with Keycloak" SSO button — Keycloak login flow completes, user lands on the ERPNext desk (`/app`).
3. Open `/app/erpnext` — ERPNext landing page renders (not bare Frappe desk).
4. (V1 / mail variant) Trigger a Frappe notification (e.g. a forgot-password flow) — the outbound mail leaves via Mailu and arrives at the target inbox.
5. (V1 / SSO + group mapping) An OIDC user in the `roles/web-app-erpnext/administrator` group has the `System Manager` Frappe role assigned after first login.
## Prerequisites
Before starting any implementation work, the agent MUST read [AGENTS.md](../../AGENTS.md) and follow all instructions in it.
## Implementation Strategy
The agent MUST execute this requirement **autonomously**. Open clarifications only when a decision is genuinely ambiguous and would otherwise block progress; default to the intent already captured in this document and proceed. Avoid back-and-forth questions on choices already resolved in [Confirmed Decisions](#confirmed-decisions).
1. Read [Compose Loop](../agents/action/iteration/compose.md) before starting.
2. Scaffold the role using [`roles/web-app-odoo/`](../../roles/web-app-odoo/) as the structural template (closest analogue: ERP-shaped, OIDC + LDAP variants, central-service consumer pattern, dual HTTP + WebSocket vhost).
3. Wire the upstream `frappe/erpnext` image into the compose template, plus the SocketIO, scheduler, three worker, and Nginx frontend containers (per [frappe_docker](https://github.com/frappe/frappe_docker)).
4. Template `common_site_config.json` with the central MariaDB endpoint and the three Redis URLs split by DB number.
5. Implement `tasks/03_bench_bootstrap.yml`: `bench new-site` → `bench install-app erpnext` → wizard-bypass API call → Social Login Key seeding.
6. Add Keycloak client auto-provisioning in `web-app-keycloak` for the new ERPNext consumer.
7. Add the biber + administrator Playwright specs.
8. Wire the `svc-bkp-` pre-backup hook (`bench backup --with-files`).
9. Iterate `make test` until green, then run the Validation deploys.
## Commit Policy
- The agent MUST NOT create any git commit until every Acceptance Criterion in this document is checked off (`- [x]`).
- A single commit (or a tight, related sequence) lands the whole role addition; no half-scaffolded intermediate commits.
- When all ACs are met, `make test` is green, and the three variants deploy cleanly, the agent instructs the operator to run `git-sign-push` outside the sandbox (per [AGENTS.md](../../AGENTS.md)). The agent MUST NOT push.
## Context
- Upstream framework: <https://frappeframework.com/>
- Upstream product docs: <https://docs.frappe.io/erpnext>
- Upstream container reference: <https://github.com/frappe/frappe_docker>
- Closest in-repo analogue for layout: [`roles/web-app-odoo/`](../../roles/web-app-odoo/)
- Central-service reuse precedent: [022 Zammad](README.md#archive)
- Playwright coverage parity contract: [019](README.md#archive)
- Role meta layout contract: [008](README.md#archive)
- Unified SSO schema: [021](README.md#archive)