web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181
functions in one module -- 9% of the core by line count and three times the
next largest file. It becomes a package of nine route modules grouped by path
segment, plus __init__.py for the shared imports, constants, Blueprint and
helpers.
Every route module decorates the SAME api_v3 Blueprint object, so endpoint
names stay api_v3.<function>, the URL map is unchanged and app.py is untouched.
Verified: 111 routes before, 111 after, byte-identical rules, endpoints and
methods, and every endpoint still on the one blueprint.
plugins 3,867 config 1,178 starlark 692 system 619
fonts 452 misc 398 wifi 361 display 326 backup 212
__init__ 1,787 (imports, constants, Blueprint, 56 helpers)
Two things the URL-map check could not catch, both found by running the suite:
1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one
directory deeper made that resolve to web_interface/ instead of the project
root. Nothing failed at import; it surfaced as ~110 tests failing with 404s
and "installation script not found", because every path built from it was
one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts
PROJECT_ROOT/run.py exists so the next move cannot repeat it.
2. Module-attribute patching. Tests do
monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route
module that binds such a name by value never sees the patch. The shared code
therefore stays in __init__.py rather than moving to a _common submodule --
it has to live on the module the tests patch -- and the eleven names tests
patch are read back through the package (_pkg.X) instead of bound by value.
Those eleven were found by AST-scanning every setattr in the test tree, not
by guessing; "time" is among them, used to drive a fake clock through the
second-resolution credential-backup filenames.
Test changes are confined to what genuinely moved: patch targets that now name
the owning route module, imports of helpers, and six tests that scan the api_v3
source as a file and now read the package directory.
Full suite: 4,278 passed, 68 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
Three review findings from #485 that I missed when addressing that PR;
it has since merged, so they land here.
1. Array-item secrets destroyed by any unrelated save (data loss).
remove_empty_secrets recursed into dicts but let a list fall through to
the scalar branch and kept it verbatim. Lists merge by *replacement*, so
the blanks the masked form posts back went straight over the stored
array:
stored [{"name":"a","token":"REAL-A"}, {"name":"b","token":"REAL-B"}]
posted [{"name":"a","token":""}, {"name":"b","token":""}]
merged [{"name":"a","token":""}, {"name":"b","token":""}]
-> both credentials gone
Same failure as the scalar api_key case fixed earlier, one container
deeper. Lists now prune element-wise, and a list with nothing real in it
is dropped so the stored one is left alone. Where one entry does change,
the new merge_secrets merges by index instead of replacing.
Two details the first attempt got wrong, both caught by existing tests:
- An emptied dict item must stay {}, not None. ConfigManager's
_strip_secrets_recursive treats a secrets list as *parallel* to the
regular one ({} = "item i has no secrets"); a None makes it stop
looking parallel, and it then drops the whole key from the main config
-- silently deleting the items' non-secret fields too.
- The incoming list's length wins. The regular config's list is
authoritative about how many items exist, so preserving surplus stored
entries would let the two fall out of step and make deleting an entry
impossible.
2. Submitted credentials written to the journal (security).
save_plugin_config logged `Full config: {plugin_config}` at INFO and
`Config that failed: {plugin_config}` at ERROR. Both run before
separate_secrets, so plugin_config still held the values just typed into
the form. Now keys only. Swept the rest of web_interface/ and src/ for
the same shape -- these were the only two.
3. Restart banner kept stale wording.
showRestartPending() cleared the stored custom text but left the DOM
element alone, so a config save could show the previous update's
message. The default is read back from the server-rendered copy rather
than duplicated in JS, so the template stays the one owner of the string.
Verified: 556 passed, 1 skipped across the web suite. Mutation-checked --
reverting api_v3 fails the logging guard and the array-merge test;
reverting either half of the secret_helpers change fails the unit tests.
New end-to-end coverage drives the real endpoint, not just the helpers.
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>