# Configuration reference **To find one setting: search this page for its exact key.** Every key appears in exactly one table. Nothing here has to be read in order. Two places, and the split is deliberate: - **Environment variables** hold **secrets and deploy-time wiring**: database credentials, signing keys, CORS origins, ports, API keys. Read once at boot by `shopdb/config.py`, which is authoritative - where this page and that file disagree, the file wins. - **The Settings table** holds **site preferences**: branding, ServiceNow links, search toggles, facility identity. Seeded by `flask seed settings`, edited in the UI under Settings or the setup wizard, changeable at runtime with no restart. `build_default_settings` in `shopdb/core/api/settings.py` is authoritative for the defaults. Rule of thumb: if leaking it would be a security incident, it is an environment variable. Never put a secret in the Settings table. **Part 1, environment variables:** [Flask core](#flask-core) | [Database](#database) | [Auth rate limiting](#authentication-rate-limiting) | [Collector ingest](#collector-ingest-adr-006) | [Zabbix](#zabbix-printer-supply-monitoring) | [Employee directory DB](#employee-directory-database-optional-read-only) | [docker-compose only](#docker-compose-only) **Part 2, Settings keys:** [site](#site) (facility name, timezone, base URL, retention) | [branding](#branding) | [printing](#printing) (QR and label targets) | [map](#map) (superseded) | [integrations](#integrations) (ServiceNow, Zabbix, Dell warranty) | [email](#email) | [audit](#audit) | [auth](#auth) (SAML placeholders) | [tools](#tools) (Secure Send) | [identifiers](#identifiers-dynamic) | [search](#search-dynamic) | [Custom fields](#custom-fields) --- ## Part 1: Environment variables Copy `.env.example` to `.env` and fill in values. In `production` (`FLASK_ENV=production`), `ProductionConfig.validate()` refuses to boot if `SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, or `CORS_ORIGINS` are missing or set to the dev defaults. ### Flask core | Variable | Required | Default | Notes | |----------|----------|---------|-------| | `FLASK_APP` | No | `wsgi.py` | Entry point for the `flask` CLI. | | `FLASK_ENV` | Yes | `development` | `production` for live sites (triggers `validate()`). Other values: `development`, `testing`. | | `SECRET_KEY` | Yes (prod) | dev default | Flask session/signing key. Generate: `python -c "import secrets; print(secrets.token_urlsafe(64))"`. | | `JWT_SECRET_KEY` | Yes (prod) | dev default | JWT signing key. Different value from `SECRET_KEY`. | | `JWT_ACCESS_TOKEN_EXPIRES` | No | `3600` | Access-token TTL in seconds. | | `JWT_REFRESH_TOKEN_EXPIRES` | No | `2592000` | Refresh-token TTL in seconds (30 days). | | `CORS_ORIGINS` | Yes (prod) | `http://localhost:5173` | Comma-separated explicit origins. Wildcard `*` is rejected in production. | | `LOG_LEVEL` | No | `INFO` | Logging verbosity. | ### Database | Variable | Required | Default | Notes | |----------|----------|---------|-------| | `DATABASE_URL` | Yes (prod) | dev localhost URL | `mysql+pymysql://:@:/?charset=utf8mb4`. Keep `?charset=utf8mb4`. | ### Authentication rate limiting IP-based fixed-window limit on the login endpoint, defense-in-depth atop the per-account lockout. Uses the cache extension, which is per-process, so the limit is approximate across multiple gunicorn workers. | Variable | Required | Default | Notes | |----------|----------|---------|-------| | `AUTH_RATELIMIT_ENABLED` | No | `True` | Set `False` to disable (TestingConfig disables it). | | `AUTH_RATELIMIT_MAX` | No | `30` | Max login attempts per source IP per window before 429. | | `AUTH_RATELIMIT_WINDOW_SECONDS` | No | `300` | Window length in seconds. | ### Collector ingest (ADR-006) | Variable | Required | Default | Notes | |----------|----------|---------|-------| | `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset and no managed token is presented. Sent as the `X-API-Key` header. | | `COLLECTOR_API_KEY_` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. | These env keys are the bootstrap/legacy fallback. A managed API token scoped to `collector.ingest` is preferred and is minted, rotated and revoked from Settings > API Tokens; see [COLLECTOR-INTEGRATION.md](COLLECTOR-INTEGRATION.md) (Managed collector tokens). ### Zabbix (printer supply monitoring) | Variable | Required | Default | Notes | |----------|----------|---------|-------| | `ZABBIX_ENABLED` | No | `false` | Enable the Zabbix integration. | | `ZABBIX_URL` | No | (empty) | Zabbix API URL. | | `ZABBIX_TOKEN` | No | (empty) | Zabbix API bearer token. | These are the boot-time wiring; the Settings entries (`zabbix_enabled`, `zabbix_url`, `zabbix_token`) are preferred for runtime changes. ### Employee directory database (optional, read-only) Separate HR lookup DB, read-only. This is where staff names come from: global search resolves the employee domain through it, and the audit log uses it to show a name instead of a bare SSO. Nothing here is required - leave it unset and both surfaces fall back to SSOs. There is no safe default for the password; an unset one fails loud rather than trying a guessed credential. | Variable | Required | Default | Notes | |----------|----------|---------|-------| | `EMPLOYEE_DB_HOST` | No | `localhost` | HR DB host. | | `EMPLOYEE_DB_USER` | No | (empty) | HR DB user. | | `EMPLOYEE_DB_PASSWORD` | No | (empty) | HR DB password. No safe default. | | `EMPLOYEE_DB_NAME` | No | `wjf_employees` | HR DB name. | ### docker-compose only Read by `docker-compose.yml`, not by the Flask app directly. | Variable | Required | Default | Notes | |----------|----------|---------|-------| | `MYSQL_ROOT_PASSWORD` | Yes | (none) | Root password for the bundled MySQL container. | | `MYSQL_PASSWORD` | Yes | (none) | App-user password; must match the `DATABASE_URL` password. | | `MYSQL_PORT` | No | `3306` | Host port for MySQL. Bound to `127.0.0.1` only. | | `API_PORT` | No | `5001` | Host port for the API container. | The opt-in backup overlay adds `RESTIC_REPOSITORY`, `RESTIC_PASSWORD`, `SHOPDB_SITE` and optional cloud credentials - see [BACKUP-RESTORE.md](BACKUP-RESTORE.md). --- ## Part 2: Settings table keys Seeded by `flask seed settings` (idempotent; re-running adds anything missing). Values are stored as strings and typed by `valuetype`. Secrets in this table (anything whose key contains `password`, `token`, or `secret`) are masked when read back through the API. ### site | Key | Default | Notes | |-----|---------|-------| | `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. | | `employee_directory_mode` | `selfhosted` | `external` reads a separate HR database (see `EMPLOYEE_DB_*`). No plugin on this line keeps a self-hosted directory, so `selfhosted` resolves no names - set `external`, or accept bare SSOs. | | `audit_retentiondays` | `0` | Delete audit entries older than this many days. 0 keeps everything forever. Saving a period deletes nothing by itself: purge from this page or run `flask audit purge --apply`. | | `site_base_url` | (empty) | Public base URL for QR codes and absolute links. Scheme + host, optionally including the subpath a site is served under (`https://host/shopdb`). Blank = use the browsing origin. | | `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. | | `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. | | `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. | | `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. | | `contact_email_domain` | `geaerospace.com` | Email domain appended to a support contact's SSO to build email (`sso@domain`) and Teams-chat links. Blank hides the contact action buttons. | | `dualpath_single_machine` | `true` | Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in the machines list, dashboard/report counts, and the floor map (the secondary bay is hidden). The data model always keeps both bay records; detail pages stay per-bay with a sibling banner. `false` lists and counts both bays separately. | | `site_timezone` | `America/New_York` | IANA timezone for the site. Notification times are stored UTC and converted for display by `frontend/src/utils/datetime.js`, so they are entered and shown in THIS zone, not the viewer's browser zone; daily-reset notification expiry (`expirymode=dailytime`) is computed here too. Public-readable so kiosks can resolve it. | ### branding Blank values fall back to the shipped GE default asset, so an un-reconfigured install still renders. Upload replacements at Settings > Branding, which saves them under `instance/branding/`. | Key | Default | Notes | |-----|---------|-------| | `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. | | `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. | | `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the machine badge print page. | | `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. | | `brand_primary_color` | (empty) | Primary brand color as a CSS color value (maps to `--primary`). Blank = built-in theme color. | | `brand_primary_dark_color` | (empty) | Primary hover/active color (maps to `--primary-dark`). Blank = auto-derived by darkening the primary color ~15%. | | `brand_accent_color` | (empty) | Accent color for secondary buttons and badges (maps to `--secondary`). Blank = built-in theme color. | | `brand_sidebar_color` | (empty) | Sidebar background color (maps to `--sidebar-bg`). Blank = built-in theme color. | ### printing | Key | Default | Notes | |-----|---------|-------| | `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. | | `qr_target_machine` | (empty) | Custom URL template for machine labels. Blank = link to the machine page. Placeholders: `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{name}`, `{pluginid}`. | | `qr_target_computer` | (empty) | Custom URL template for computer labels. Blank = link to the computer page. Same placeholders as machine. | | `qr_target_network_device` | (empty) | Custom URL template for network-device labels. Blank = link to the device page. Same placeholders as machine. | | `label_default_style` | `card` | Default asset-label layout when a label first opens: `card` (badge with image and identity) or `plain` (just the code and a caption). | | `label_default_codetype` | `qr` | Default asset-label code type when a label first opens: `qr` or `barcode` (CODE128). | | `label_default_encodes_machine` | `assetnumber` | What a machine label encodes by default. | | `label_default_encodes_computer` | `assetpage` | What a computer label encodes by default. | | `label_default_encodes_printer` | `assetpage` | What a printer label encodes by default. | | `label_default_encodes_network_device` | `assetpage` | What a network-device label encodes by default. | Values for those four `label_default_encodes_*` keys: `assetpage`, `assetnumber`, `serialnumber`, or `custom` (the matching `qr_target_` template). All of it is overridable on the label page itself. There is no `measuring_tool` label type on this branch. Metrology instruments are machines (ADR-018), so use the machine label keys, and the measuring-tools plugin is not bundled here - `shopdb/core/api/settings.py` declares four `qr_target_*` and four `label_default_encodes_*` keys to match. | Route | What it does | |---|---| | `/print/asset-label//` | One label. Public, like the other `/print/*` pages. `` is `machine`, `computer`, `printer` or `network_device`; `` is the asset's plugin id. | | `/print/asset-label-batch/` | A multi-selection of one type onto ULINE sheets: 6-up 3 in x 3 in, or a dense 72-up mini-label, with a start-cell offset for reusing partial sheets. Reached from the "Print Labels" button on each asset list page. | ### map **SUPERSEDED as of 0.11.0. Editing these four keys changes nothing on the map.** A blueprint and its pixel dimensions are properties of a LEVEL now (ADR-017). They are retained only so a downgrade finds the blueprint it had; the levels migration copied their values onto the first level. Manage plans in **Settings > Buildings and levels** - see [FLOOR-MAP.md](FLOOR-MAP.md). | Key | Default | Superseded by | |-----|---------|-------| | `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | `maplevels.blueprintlight` | | `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | `maplevels.blueprintdark` | | `map_width` | `3300` | `maplevels.mapwidth` | | `map_height` | `2550` | `maplevels.mapheight` | ### integrations | Key | Default | Notes | |-----|---------|-------| | `servicenow_enabled` | `true` | Enable ServiceNow ticket recognition and links. Disabled = tickets render as plain text. | | `servicenow_search_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. | | `servicenow_ticket_prefixes` | `GEINC,GECHG,GERIT,GESCT` | Comma-separated prefixes recognized as ServiceNow tickets. | | `servicenow_incident_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct incident URL if your instance has one. | | `servicenow_change_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct change URL if your instance has one. | | `zabbix_enabled` | `false` | Enable Zabbix for printer supply monitoring. | | `zabbix_url` | (empty) | Zabbix API URL. | | `zabbix_token` | (empty) | Zabbix API token (masked). | | `warranty_dell_enabled` | `false` | Enable Dell warranty (service-tag) lookups. | | `warranty_dell_clientid` | (empty) | Dell TechDirect API client id. | | `warranty_dell_clientsecret` | (empty) | Dell TechDirect API client secret (masked). | | `warranty_dell_tokenurl` | (empty) | Dell OAuth token URL. Blank = Dell default. | | `warranty_dell_apiurl` | (empty) | Dell warranty API URL. Blank = Dell default. | ### email | Key | Default | Notes | |-----|---------|-------| | `smtp_enabled` | `false` | Enable email notifications and alerts. | | `smtp_host` | (empty) | SMTP server hostname. | | `smtp_port` | `587` | SMTP port (587 TLS, 465 SSL, 25 plain). | | `smtp_username` | (empty) | SMTP auth username. | | `smtp_password` | (empty) | SMTP auth password (masked). | | `smtp_use_tls` | `true` | Use TLS for the SMTP connection. | | `smtp_from_address` | (empty) | From address for outgoing email. | | `smtp_from_name` | `ShopDB` | From name for outgoing email. | | `alert_recipients` | (empty) | Default alert/report recipients (comma-separated). | The mailer (`shopdb/utils/mailer.py`) reads these settings-first, falling back to matching `SMTP_*` environment variables only when at least one of them is set. When `smtp_enabled` is false or `smtp_host` is blank, every send is a graceful no-op that logs a warning, so an unconfigured site never crashes. The password is never logged. Three flows send mail, and **there is no scheduler in this app** - sending is always on-demand: - **Welcome email**, on `POST /api/users`. Best-effort: the account is created even if the send fails (the response carries a `warning`). The new account is flagged `mustchangepassword`, so the frontend forces `/change-password` before the app; changing it clears the flag and resets lockout counters. Opt out with `sendwelcome: false` in the create body. - **Test email**, `POST /api/settings/test-email` (`settings.edit`), behind the "Send Test Email" button. A real SMTP error is surfaced with the password scrubbed out. - **Alerts and report delivery**, `POST /api/reports/email` (`reports.export`), taking `{subject, columns, rows, intro?, to?}` and mailing the rows as an HTML table. Recipients default to `alert_recipients`. To automate a recurring send, point an external cron job at this endpoint with a PAT scoped to `reports.export`. ### audit Retention is `audit_retentiondays`, in the [site](#site) section. There is only one such key; anything writing `audit_retention_days` is writing to nothing. ### auth **SAML is not implemented yet.** These keys exist so a site can be configured ahead of the feature, and the settings page says so on screen. Nothing reads them: there is no SAML login route and no assertion is accepted, so sign-in is local accounts only whatever is set here. In particular `saml_allow_local_login` does NOT disable password login - the toggle is locked on until there is another way in, because a control that appears to close the local door while it stays open is worse than no control. | Key | Default | Notes | |-----|---------|-------| | `saml_enabled` | `false` | Reserved. Enable SAML SSO once it ships. | | `saml_idp_metadata_url` | (empty) | Reserved. SAML IdP metadata URL. | | `saml_entity_id` | (empty) | Reserved. SAML SP entity id. | | `saml_acs_url` | (empty) | Reserved. SAML Assertion Consumer Service URL. | | `saml_allow_local_login` | `true` | Reserved, and locked on. Local login cannot be turned off while it is the only way in. | | `saml_auto_create_users` | `true` | Reserved. Auto-create users on first SAML login. | | `saml_admin_group` | (empty) | Reserved. SAML group name that grants the admin role. | **Personal API tokens (PATs) have no configuration here**, and `JWT_ACCESS_TOKEN_EXPIRES` does not apply to them. They are created and revoked from Settings > API Tokens; the token model is documented once, in [IMPORT-API.md](IMPORT-API.md#1-authentication). ### tools | Key | Default | Notes | |-----|---------|-------| | `securesend_allow_guests` | `false` | Let people with no account create Secure Send links. Public (readable before login) so the create page can show a guest the form instead of a sign-in wall. Off by default: a guest secret has no creator, so the audit trail can say when and from where but never who. Receiving a secret never needs an account either way. | [SECURE-SEND.md](SECURE-SEND.md) is authoritative for the rest of it - guest rate limiting, why the read fails closed, and why the size limit is not a tuning knob. ### identifiers (dynamic) One boolean key per asset identifier per asset type, keyed `identifier___enabled` (default `true`). Admins choose which optional identifiers show on which asset types. See ADR-001. The exact set is generated from `IDENTIFIER_LABELS` x `IDENTIFIER_ASSETTYPES` in `shopdb/core/api/settings.py`. ### search (dynamic) One boolean key per search domain, keyed `search__enabled` (default `true`). Toggles whether a domain appears in global search results. The set is generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`. Search terms are matched word-wise: a multi-word query returns rows containing EVERY word, each word anywhere in the searched fields, in any order ("CSF Roles" matches a row with "CSF" and "Roles" in different columns). Quoting does not force a contiguous phrase. ## Custom fields Site-defined extra attributes per asset type (Settings > Custom Fields, table `customfields`). Each field has a `searchable` flag (default off). When on, the field's stored values are matched by global search and a hit routes to the owning asset's detail page. The asset's `search__enabled` domain toggle still applies, so a custom-field hit on a computer only shows when the computer search domain is enabled. Inactive or non-searchable fields are never matched. --- ## See also - [DEPLOY.md](DEPLOY.md) - per-site deployment runbook - [UPGRADE.md](UPGRADE.md) - upgrading an existing site - [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore - `GET /api/docs` - what endpoints exist and their auth, generated from the code