Show the kiosk label prefix, and let a plugin declare the settings it owns

Three defects, all found on printedparts_label_prefix, all one root cause:
nothing in the framework knew that setting existed.

The parts kiosk runs logged out. An unauthenticated read of a setting is
limited to an allowlist, the key was not on it, so the kiosk got a 404 and
fell back to no prefix. An admin previewing the same page while logged in saw
the prefix, which is why it looked like it worked.

The same setting also looked like it would not save. The row did not exist on
a site that installed the plugin before the setting was added, so the first
save created it - under the placeholder category the settings API uses for
keys it does not recognise, where the plugin's settings page, which lists by
category, could no longer see it. The value was in the database the whole
time.

And the row was missing in the first place because seeding ran from
on_install / on_enable, which fire only on a state transition. Neither runs
again on an upgrade, so a setting added in a later plugin version never
reached a site that installed an earlier one. The comment claiming enable ran
every upgrade cycle was simply wrong.

A plugin now declares the settings it owns in get_settings_defaults(): key,
default, type, category, description, and whether a logged-out page may read
it. The framework seeds declared keys at install, at enable, and on every
flask plugin upgrade-all; files a first-time write under the declared
category; re-homes any row left in the placeholder category, value untouched;
and answers an anonymous read for keys marked public. Core carries no list of
any plugin's keys.

Contract 0.16.0 (additive optional hook). printedparts and printers move to
the hook and floor their core_version at 0.16.0. The dev database had two rows
in the misfiled state (printedparts_alert_email, employee_db_host); the first
repairs itself on the next upgrade pass.
This commit is contained in:
cproudlock
2026-08-06 18:17:49 -04:00
parent 1aeb3bd1d4
commit 593dd46525
15 changed files with 538 additions and 85 deletions

View File

@@ -76,7 +76,7 @@ keying) are covered there as well.
Everything else is the core UI API: the endpoints the Vue frontend calls. As a
rule these are JWT-authenticated (a login token or a managed Personal Access
Token) and versioned by the plugin contract (`__contract_version__`, currently
0.15.0). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**;
0.16.0). Behavior and stability guarantees are in **CONTRACT-STABILITY.md**;
sister sites should pin tight `core_version` ranges until the contract reaches
1.0.
@@ -105,6 +105,7 @@ gated by `require_role` or `require_permission`; none are public.
| `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. |
| `GET /api/settings/map-blueprint/<filename>` | Serve the floor-map blueprint image. |
| `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). |
| `GET /api/settings` and `GET /api/settings/<key>` | Read-only, and only the public allowlist: the `branding` and `map` categories, a few named site keys, plus any key a plugin declares `public` in `get_settings_defaults` (e.g. `printedparts_label_prefix`, which the logged-out parts kiosk renders). Every other key answers 404 to an anonymous caller. |
| `GET /api/models/image/<filename>` | Serve a model image. |
| `GET /api/dashboard/navigation` | Public navigation tree. |
| `GET /api/dashboard/health` | Liveness / health probe. |

View File

@@ -8,7 +8,7 @@ the live code, not aspiration. The authoritative hook reference is
## Current version
The plugin contract is at **0.13.0**, declared in `shopdb/__init__.py` as
The plugin contract is at **0.16.0**, declared in `shopdb/__init__.py` as
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
bump is allowed to break the contract, and this project has used that latitude.
@@ -31,10 +31,12 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.11.0 | Added `service_token_authorized(scope)` to `shopdb.api` so a plugin's unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped managed service token without importing core token internals | additive surface (minor) |
| 0.12.0 | Added the mailer helpers (`send_email`, `send_alert`) to `shopdb.api` | additive surface (minor) |
| 0.13.0 | Added the `User` model to the `shopdb.api` surface | additive surface (minor) |
| 0.14.0 | Added `send_webhook` to the `shopdb.api` surface | additive surface (minor) |
| 0.15.0 | Added `authorized_service_token` / the `SupportTeam` model to the `shopdb.api` surface | additive surface (minor) |
| 0.16.0 | Added the `get_settings_defaults` hook so a plugin declares the Setting rows it owns; the framework seeds them at install, at enable, and on `flask plugin upgrade-all`, files a first-time write under the declared category, and honours `public: True` for pages that render before login | additive optional hook (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, and 0.11.0 (its
last entry); the current `__contract_version__` 0.13.0 is ahead of the last documented comment
entry. Earlier points
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0, 0.11.0, and
0.16.0; 0.12.0 through 0.15.0 are recorded in this table only. Earlier points
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
0.2 baseline.

View File

@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
__contract_version__ = '0.15.0'
__contract_version__ = '0.16.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
@@ -415,6 +415,46 @@ class PrintersPlugin(BasePlugin):
]
```
### `get_settings_defaults() -> List[Dict]` (0.16.0)
Declares the `Setting` rows this plugin owns. Return `[]` (the default) if it
owns none. Each entry is a dict:
| Key | Meaning |
|-----|---------|
| `key` | the Setting key |
| `value` | default value in string form |
| `valuetype` | `'string'` / `'boolean'` / `'integer'` / `'json'` |
| `category` | grouping the plugin's settings page filters on |
| `description` | what the setting does |
| `public` | `True` if an unauthenticated caller may read it; default `False` |
```python
class PrintedpartsPlugin(BasePlugin):
def get_settings_defaults(self):
return [
{'key': 'printedparts_label_prefix', 'value': '', 'valuetype': 'string',
'category': 'printedparts', 'public': True,
'description': 'Leading text on the physical labels, shown at the kiosk'},
]
```
The framework seeds declared keys at install, at enable, and on every
`flask plugin upgrade-all`, so a key added in a later plugin version reaches a
site that installed an earlier one. Existing values are never overwritten.
Declaring a key is also what tells the settings API which category and type to
use when an admin's save creates the row for the first time. Do not seed
settings by hand in `on_install` / `on_enable`: those hooks fire only on a state
transition, so a hand-seeded key added later never reaches an existing site, and
the row the first save creates lands in the placeholder `plugin` category where
the plugin's own settings page (which filters by category) cannot see it.
`public: True` puts the key on the unauthenticated read allowlist of
`GET /api/settings/<key>` and `GET /api/settings`. Use it only for cosmetic
values that a page rendering before login needs (a kiosk, a print page). Never
mark a credential, a hostname, or an integration URL public.
### `get_collector_schema() -> Optional[Dict]`
Declares the JSON Schema for an external collector pushing to `/api/collector/<pluginname>`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract.