- LocaleContext: reset ready on each language change so non-default
locale bundles trigger a rerender once active
- auth PUT /me: raise typed ValidationError (auth.unsupported_language)
instead of a hand-shaped error body; error-shape ceiling 1145 -> 1144
- i18n.py docstring: point at the real parity test
(test_user_language_preference.py)
`--locales <dir>` points the four assertions at another tree's bundles, so an
extension repo verifies its own translations with the panel's check instead of
copying it -- copying is how the vendor shims drifted. The languages.json
cross-check is skipped there, since an extension ships bundles rather than a
language list.
It also now reports keys present in en.json but absent from a translation.
Those render English rather than failing, so nothing was catching a bundle that
was quietly half-finished -- which is exactly the state a translation pass
starts in and needs to be able to see.
Used to verify all 13 extension Spanish bundles (2,073 keys) at full parity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Built to convert the WordPress extension (1,099 strings across 44 files) and
kept because the same job remains for 12 more extension repos and the panel's
own 8,034-literal tail.
i18n-codemod.mjs keys the same four shapes the census counts, using the same
isCopy rule, so what it converts is exactly what the ratchet measures. It
REFUSES rather than guesses: a hit outside a component needs `t` threaded
through a helper's arguments, which is a design decision, not a rewrite.
Five bugs it had, all caught by building the result rather than by reading it:
- multi-line JSX text became a string literal containing raw newlines. JSX
collapses that whitespace at render, so the default must too.
- a literal already containing \n (an SSH key placeholder) was emitted raw and
produced an unterminated string.
- `confirm({ title: 'x' })` matched BOTH the data-object rule and the copy-call
rule; applying both corrupted the file. Innermost edit wins -- for a call,
t() is right because nothing resolves a `titleKey` there.
- copy hides in `err.message || 'Failed…'` and `x ? 'A' : 'B'` as often as it
sits directly in a call, so argument recursion matches the census.
- that recursion then had to stop at nested JSX, or it "translated" a
`rel="noopener noreferrer"`.
Two census corrections, both of which were inflating the numbers:
- check-i18n-literals recursed into nested JSX inside a copy prop and counted
the nested element's own attributes. `rel` is not copy; the ratchet was
unreachable on correct code.
- check-intl-door walked dist/, so an extension repo's bundle double-counted
every call in it. WordPress reported 56 where the source had 28.
Both censuses and the extractor now take --root (and --out) so an extension
repo runs the panel's own checks instead of copying them -- copying is how the
vendor shims drifted. --update refuses a foreign root: those ceilings are the
panel's.
The format door takes `parts`, passing Intl.DateTimeFormat component options
through for what a named style cannot express -- chart ticks, month labels.
That was the actual reason callers reached for toLocaleDateString([], {...}),
which silently follows the browser; now they keep the format and the locale.
Ceilings re-seeded to the corrected numbers: literals 8034, intl door 123.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up audit of the extension surface, across all of them rather than one.
THE COMPATIBILITY HOLE. Two places documented an install-time `sdk_version`
check as existing -- app/utils/sdk.py's docstring ("checked at install (manifest
lint) and at load") and the runtime loader's comment, which named
`_assert_manifest_sdk_compatible` -- but `sdk_version_satisfies` had ZERO
production callers; only its own unit test. Consequences split by extension
kind: a runtime ESM extension installed cleanly and failed later at the load
gate, while a builtin/in-repo extension never passes that gate at all, so its
range went unchecked everywhere. The function now exists and runs at install,
beside the min/max_panel_version check, and refuses with what the operator
needs to act: what the extension wants, what this panel offers. Fail-open on a
missing range, matching the load gate's 'grace'. Proved both directions.
This matters now because SDK 1.3.0 added the localization surface and made
i18next/react-i18next shared singletons: an extension that externalizes them
needs a panel at 1.3.0+. Pinning `^1.3.0` is what turns a white screen on an
older panel into a readable message. The bump is additive, so anything pinned
to ^1.2.0 keeps installing and loading. Documented as a version table.
THE AUDIT. Measured, not assumed -- both censuses now take `--root` so they can
run against any extension repo (and refuse `--update` there, since the ceilings
belong to the panel):
2,105 untranslated literals across 13 extension repos (WordPress alone 1,099
in 44 files), plus 78 direct toLocale*/Intl calls that follow the BROWSER's
locale (WordPress 56). None of it was visible to any check before, because
every census stopped at frontend/src.
serverkit-gui -- the one builtin with real UI -- is converted as the worked
example, importing useTranslation/useFormat from 'serverkit-sdk' exactly as a
third-party extension would. That also closes its own toLocaleTimeString.
Ceiling 8055 -> 8034, intl door 124 -> 123.
The probe now asserts the claim the SDK surface rests on: an extension
resolving i18next through the shared vendor gets the HOST's INITIALISED
instance, and an extension-style t() returns Spanish. A second instance would
be uninitialised and every t() in that extension would render English forever
with nothing reporting it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SDK 1.2.0 -> 1.3.0 (additive).
Auditing the extension surface for plan 79 found the localization gap I
expected and two pre-existing bugs I did not.
The gap: extensions had no way to translate anything. The SDK now re-exports
the HOST's translation surface -- useTranslation, Trans, t, useLocale,
useLabel, useFormat and the intl door -- and i18next/react-i18next join the
shared vendor singletons. That sharing is the load-bearing part: an extension
bundling its own i18next would get a separate, uninitialised instance, so
every t() in it would render its English default forever and nothing would
report it. Exactly the "two React copies" hazard vendorShare already documents,
and exactly the failure mode this plan hit twice in core. They also join
dedupe/optimizeDeps for the same reason React is there.
The bugs, both caused by the same duplication:
- The import map listed `react` and `react-router-dom`, but no shim existed
for either. A runtime extension importing bare `react` 404'd at load.
- The serverkit-sdk shim re-exported 26 of the SDK's 71 exports. An extension
importing Button, Modal, EmptyState, formatBytes or timeAgo from the SDK got
`undefined`. The shim's own comment claimed parity was "asserted by the
frontend build + backend test_sdk_contract"; nothing asserted it.
Three files independently restated the same module list -- the import map, the
namespace capture, and one hand-written shim per module -- so they drifted.
Now vendorManifest.js is the list, generate-vendor-shims.mjs derives every
shim's exports (from the real package's namespace, or from the SDK entry's own
export statements -- never typed by hand), the import map is built from the
same manifest, and `--check` fails the build on drift. 26 -> 85 exports on the
SDK shim, 6 -> 8 shims, all present.
Manifest contributions gain an optional `labelKey` beside `label` for nav, tabs
and command-palette entries, resolved at render by the same translateLabel core
uses -- data cannot call t(), and a label resolved at import would translate
once and never follow a switch. Omitting it renders `label` verbatim, which is
what every existing extension does, so nothing breaks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §H + ratchet 7.
<html dir> already follows the language manifest and the locale probe asserts
it. What was missing is a stylesheet that could follow it: 468
direction-anchored declarations (margin/padding/border-left|right, bare
left:/right:, text-align and float) against 5 logical properties. An RTL
locale today would render a mirrored language inside an unmirrored layout, so
the plan ships plumbing and a ratchet, not a locale. The count is 468 rather
than the plan's grepped 412 because float and text-align are equally anchored.
Also splits `src/i18n/t.js` out of the i18n barrel. The API client's error
mapper imports the bare `t`, and the client is reachable from a node test --
which then loaded i18n/index.js and its `import.meta.glob`, a Vite
compile-time transform that throws under plain node. The obvious fix was the
runtime guard that had just been removed for silently discarding every locale,
so the module is split instead: t.js touches only the i18next singleton, and
index.js keeps initialisation and the glob for browser-only callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 B3 + acceptance 2/3/7.
The selector lives in Settings -> Appearance beside the theme picker, because
it is the same shape of choice, and is registered in the settings search index
so it is findable. Built on ui/select rather than a hand-rolled dropdown.
Proof is a Playwright probe (.claude/skills/screenshots/probe-locale.mjs --
that tree is git-ignored, like docs/plans). A screenshot in one language
proves nothing: the copy could be hardcoded English that happens to look
right. The probe loads the shell under two stored locales in a browser whose
own language is French, so following the browser cannot pass it, then drives
the REAL selector and asserts what the plan committed to:
1. the shell renders per-locale and <html lang>/<dir> follow the panel;
2. selecting Spanish re-renders the sidebar with no navigation, and caches
the choice for the next pre-auth paint;
3. with every API call aborted, the pre-auth screen still renders in
Spanish -- which is the whole reason locales are bundled, not fetched.
It immediately earned itself by catching two bugs that lint, build and reading
the diff all missed:
- `typeof import.meta.glob === 'function' ? import.meta.glob(...) : {}` -- a
guard added so the module could be imported by node tooling. Vite replaces
the CALL at build time; there is no such function at runtime, so the guard
was always false and discarded every locale bundle. Every language silently
fell back to English and nothing failed.
- Sidebar category headings stayed English in every locale: the keys lived in
CATEGORY_LABEL_KEYS and the English in CATEGORY_LABELS, two parallel maps.
Not sibling properties, so the extractor could not pair them and the keys
never reached en.json. One object per category now holds both.
Both are the failure this plan exists to prevent: copy that looks translated
and is not, with nothing to say otherwise.
es.json covers all 164 keys.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §E wave 2 + F1.
Login, Register and Setup go to zero untranslated literals. These screens run
before anyone is signed in, so they are what proves the precedence chain: with
no user row to read, the locale comes from localStorage, then the panel
default from /auth/setup-status, then the browser.
F1 turned out smaller than the plan assumed, and in a good way. The plan
proposed inventing an error-code contract; the repo already has one -- plan 76
milestone B's typed-error door emits { error, status, code, request_id }. So
the work was to USE it: fourteen auth returns become typed raises carrying
stable codes, and the API client maps the code to a translation key with the
server's own English as the default. The 257 `toast.error(err.message)` sites
keep working untouched and gain translations as codes land. Error-shape
ratchet 1159 -> 1145.
The first cut mapped `t('errors.' + code)`, which is wrong in a way worth
recording: a computed key is invisible to the extractor, so those strings
would never reach en.json and a translator would have no way to learn they
exist. The registry is an explicit literal table instead -- it doubles as the
reviewable list of which server errors are actually translated, and anything
absent falls through to English, which is today's behaviour.
Only auth carries codes so far. Plan 76 §C measured 23 sites that choose an
HTTP status by grepping their own error prose; translating that prose would
turn those 403s into 400s, so the rest waits on milestone C rather than
shipping a silent status regression.
test_login_links asserted the bare {'error': ...} body by equality. Updated to
assert the typed contract including `code` -- asserted rather than tolerated,
since dropping the code would silently make the message untranslatable again.
Ceiling 8087 -> 8055.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 G1/G2.
Modal gains `onSubmit`, which wraps body AND footer in one <form>. This is the
unblock plan 76 identified for form adoption: a submit button must live inside
the form it submits, so 28 files had to bypass the `footer` prop and hand-roll
a footer inside the body. Form mode also brings Enter-to-submit, which those
hand-rolled footers did not have. The form element has to BE the flex column
or the body's `flex: 1` resolves against it and the footer stops pinning.
The four Git repo pickers -- GitHub, GitLab, Gitea, Bitbucket, 693 lines --
were near-identical copies differing in which API they called, one URL shape,
and the provider name in six strings. They collapse to one RepoPicker plus a
provider table: four bindings of 11 lines each. The duplication that mattered
was the copy: the same search box, "Loading repositories...", empty state and
branch selector existed four times, so translating them meant translating each
four times, and drift between them was invisible without reading all four
files side by side.
The contract asks providers for `isReady(status)` rather than assuming a
connection object, which is what lets Gitea -- local, no OAuth,
installed+running instead of configured+connected, a two-argument branch API --
use the same component instead of being the fourth copy.
Converting it surfaced the same anti-pattern this plan rejected in
ConfirmDialog: the provider hints had become prefix + <Link> + suffix, three
fragments per sentence. A link lands in a different place in other languages
and split keys cannot be reordered by a translator, so they are single <Trans>
keys with the link as a component.
Ceiling 8132 -> 8087.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §E wave 1. The shell is first because it is on every screen: a
translated page inside an English frame reads as broken, while a translated
frame around an English page reads as in-progress.
Converted: Sidebar (31 literals -> 1, the brand name, which must not be
translated), sidebarItems, CommandPalette, PageTopbar, MobileTopBar,
NotificationBell, EmptyState, ErrorState, ErrorBoundary, ConfirmDialog,
AppLoader. Ceiling 8225 -> 8132, and es.json now covers all 98 keys, so the
shell is provably bilingual rather than merely keyed.
Three shapes needed real tooling rather than a t() call, and each was found by
converting a file rather than by planning:
- Labels in data files (sidebarItems, palette docs entries) cannot call t():
a table resolved at module load translates once, at import, and never
follows a locale switch. They now declare `labelKey` beside `label`; the
extractor collects the pair at its declaration site and useLabel resolves it
at render. The ratchet understands the pairing, so converting a data file
visibly moves the number (sidebarItems 27 -> 0).
- Sentences with an element inside them (ConfirmDialog's "Type <x> to
confirm") go through <Trans>, which the extractor now reads. Splitting them
into prefix/suffix keys would have been untranslatable: word order around
the value differs by language and a split sentence cannot be reordered.
- Class components and module scope (ErrorBoundary, AppLoader) import a bare
`t` from the i18n module. Reaching for `i18next.t` at the call site would
have left that copy out of en.json, since the extractor matches bare calls.
The declarative-pair heuristic initially swept up `archKey: 'amd64'` next to
`arch: 'x64 (amd64)'` -- "Key" already means "identifier" in places -- so a
pair now only counts when the value looks like a dotted translation key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 D1/D2.
extract-i18n.mjs walks the AST and regenerates en.json from the inline English
defaults, so en.json is output rather than a file anyone edits. That is what
makes a migration this size survivable: an unextracted key still renders
correct English, so a half-migrated page is never broken and never shows a raw
key path to a user. --check fails the build when it drifts.
check-i18n.mjs asserts what only a user in that language would otherwise
discover: no orphan keys, no dropped or invented {{placeholders}}, same leaf-
vs-namespace shape, and manifest/bundle agreement -- a language offered in the
selector with no bundle silently renders English, and the user cannot tell
that apart from "not translated yet".
The extractor immediately caught a real problem in its own author's code: the
format door wrapped i18next behind a helper called `translate()`, which made
its nine literal keys invisible to extraction -- copy that would have quietly
stopped being translatable. The wrapper is now named `t`, and the extractor
matches bare `t(...)` only, since `i18next.t(key)` re-dispatches a variable by
construction.
es.json is seeded now rather than at the end of the plan. A locale you never
switch to is a locale that does not work, and seeding it here means every
subsequent wave is provably bilingual as it lands instead of being validated
once at the finish.
All four guards mutation-checked: dropped placeholder, orphan key, missing
inline default, and stale en.json each fail as intended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §C + ratchet 5.
utils/intl.js owns dates, times, relative time, durations, numbers, percents
and lists. Three things the scattered calls could not do:
- A module-level locale published by LocaleContext, not read from a hook, so
the API error mapper, log formatter and CSV export format in the panel's
language too. useFormat() is a subscription over the same module, not a
second door.
- Formatter instances cached per (locale, kind, options). The
toLocaleDateString() calls this replaces built a fresh formatter per cell
per render, and Intl construction is the expensive half.
- Relative time via Intl.RelativeTimeFormat rather than translation keys --
the browser already carries every language's plural wording, and keying
"{{count}} days ago" would mean maintaining that table by hand.
This fixes a locale bug that predates any translation work: 124 call sites
use toLocale*() with no argument, which follows the BROWSER's locale, so a
panel set to Spanish already rendered English chrome around Spanish dates.
formatNumber.js was worse -- it built Intl.NumberFormat('en') at module load,
hardcoding English AND making it impossible to follow a switch.
The three human-facing utils now delegate instead of reimplementing, so their
existing callers become locale-aware without being touched. formatBytes gains
the locale decimal separator ("1,5 KB" in es) by replacing a toFixed + regex
trim with maximumFractionDigits, which trims identically in en -- parity
proved case by case against the pre-migration examples.
utils/timeAgo.js is deleted rather than left as a shim; its 6 importers move
to utils/time. A door nobody is forced through is a door nobody uses.
One bug found by the tests: formatNumber(null) returned "0", because
Number(null) is 0 -- a missing metric would have rendered as a real zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 A1/A2/A4 + B1/B2.
Delivery deliberately avoids the usual http-backend fetch. `en` is bundled;
other locales are dynamic imports Vite emits as chunks from the app's own
static origin. The sign-in and setup screens render before any API call
succeeds, so a fetched locale either flashes English or -- when the backend
is the thing that is broken -- never arrives, on the one screen whose job is
to say so. It also keeps air-gapped and subpath installs working with no
second path to 404.
Precedence (user row -> localStorage -> panel default -> navigator -> en)
mirrors ThemeContext's, including the split provider: LocaleProvider sits
outermost so a toast raised during theme init is already translatable, and
LocaleSync bridges auth state down, exactly as ThemeSync does.
users.language is nullable on purpose -- "follow the panel default" is a
different state from an explicit choice of English, so existing rows are not
backfilled. The backend restates the supported list to validate writes; a
parity test fails the build if it drifts from the frontend manifest, which
stays canonical.
Two bugs the tests caught, both real rather than test artifacts:
- normalize_language('ES') returned None. The shape gate was case-sensitive,
so two of the three forms browsers actually send were rejected.
- PUT /me is throttled 3/minute for credential edits, and language rides the
same route. Switching language three times returned 429 -- and because the
client applies the change locally first, the preference silently failed to
persist and reverted on the next sign-in elsewhere, defeating the column.
Cosmetic-only payloads are now exempt from that limit; credential edits
stay throttled (proved both ways) and the app-wide 100/minute still holds.
eslint moves to ecmaVersion 'latest' because import attributes are ES2025 and
Node's test runner requires them for the manifest imports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 D3. Plan 76 rule 1: every row gets a ratchet before its migration
starts, or it regrows.
Counts user-visible string literals not addressed by a translation key, in
the four shapes copy actually escapes through: JSX text nodes, JSX copy
attributes, toast/confirm arguments, and copy keys in data objects. The
fourth shape is the one an extraction pass usually misses -- a label in a
data file is still copy, and if it resolves at module load it also never
re-renders on a locale switch.
Seeded at 8225 across 347 files. The plan estimated ~4088 from greps; the
AST census finds twice that, because greps miss multi-line JSX text,
template-literal segments, and data-object labels (77 in routeManifest.js
alone). Plan corrected to the measured number.
Uses espree (eslint's own parser) for JSX -- added explicitly rather than
leaned on transitively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The workspace-authz round converted 28 routes off bare @jwt_required()
onto role decorators and ratcheted JWT_ONLY_CEILING 605 -> 577, but the
generated inventory was never regenerated, so CI's freshness test failed.
Regenerated from the same censuses that enforce the ratchets:
bare @jwt_required() 605 -> 577 (ceiling 605 -> 577)
hand-shaped error bodies 1159 -> 1158 (ceiling stays 1159, 1 of 25 slack)
controller-boundary 510 -> 509 (baseline json already at 509)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4431 was measured on a dev box that has four git-ignored extension copies
installed under backend/app/plugins/ (cloud-provision, ftp, remote-access,
status). test_builtin_extension_drift.py parametrizes over the on-disk
intersection of builtin-extensions/<ext>/backend and app/plugins/<ext>, so
those four contribute 3+3+5+3 = 14 cases that no clean checkout can collect
— CI collects 4417, and the ratchet went red for a suite that had lost
nothing.
This is the failure mode check_test_count.py's own docstring warns about (a
floor --update'd above what a clean checkout can reach). 4417 is still a
raise of 108 over main's 4309, matching the 107 tests this branch adds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating a workspace became admin-only server-side, so the topbar button, the
empty-state action and the `?focus=create` deep link were all offers of a
guaranteed 403. Non-admins get an empty state that says who to ask instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both are now enforced by tests that fail the build, so they belong where the
"adding a feature" checklist is rather than in a commit message nobody
re-reads. The one-boundary rule is the trap this round fell into: stacking
`@jwt_required()` outside a policy decorator looks like belt-and-braces and
is actually a regression, because it rejects API-key callers before the
policy can authorize them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two nets, deliberately complementary, because neither alone would have caught
the advisory:
- `test_route_authz_sweep.py` fires every mutating route as a viewer and
fails on any 2xx outside a reviewed `VIEWER_WRITABLE` list. It proves
*enforcement*, but a route that 400s or 404s on a dummy request before its
gate runs is invisible to it — `POST /workspaces/` 400s on an empty body,
so the net would never have seen the bug it exists for. Hence the targeted
Part A: the fixed routes must answer a viewer and a developer with 403, and
must not block an admin.
- `test_route_authz_static.py` proves *coverage* from the source instead: every
mutating route in `url_map` either carries a `_sk_authz` decorator, reaches
an authorization primitive through its module's call graph, or sits in
SELF_SCOPED / PUBLIC_ALT_AUTH / STATELESS with a written justification.
Staleness checks keep an allowlist slot from outliving the route that
earned it, and a SELF_SCOPED entry must still resolve the caller's identity
— calling `get_current_user()` is necessary but not sufficient, which is
exactly how `create_workspace` looked self-scoped while creating a global
row.
Both nets are blind below the API layer and to public routes, so
`test_authz_round3_guards.py` covers the three fixes whose shape they cannot
express (API-key scope widening, demo-deploy job creation, nginx diff
containment), the webhook cases land beside the rest of the gateway surface,
and the shared-resource tag gate lands with its own blueprint's tests. Each
comes with its non-vacuity case: the same call succeeds for a caller the gate
admits.
`migrations/history` gets tests because it had none — which is why the
NameError in it shipped.
Ratchets: JWT-only census 605 -> 577 (the routes that moved to policy
decorators), the controller-boundary baseline loses the one persistence
crossing `complete_onboarding` no longer makes, and the collected-test floor
rises to 4431.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The inbound gateway receiver is public by design — the HMAC signature IS the
credential — but the reject branch read `if not signature_valid and
signature_header`, so it only fired for a request that bothered to sign. A
delivery with no `X-Hub-Signature-256` at all skipped the branch entirely and
was logged, filtered and forwarded to the endpoint's `forward_url`. Every
endpoint row carries a non-null secret, so there is no legitimate unsigned
caller to preserve; the check is now unconditional and reports which of the
two cases it refused.
`preview_diff` joins a request-body `domain` straight onto SITES_AVAILABLE
and returns the current file's contents inside the diff, which makes a
traversing value an arbitrary file read. The guard lives in the service, not
only in the route, so every future caller inherits it, and the route now
surfaces the refusal as a 400 instead of handing the error dict back as a
200.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GHSA-r4q7-x795-vr4j was one forgotten check, but the audit behind it found
the same shape across the surface: `@jwt_required()` authenticates, it does
not authorize, and ServerKit has no default-deny to catch the difference.
This closes the routes that audit left open — fleet-monitor alert
acknowledge/resolve, recycle-bin restore, nginx test/diff, registrar
sync/test, server-template drift checks, uptime tracking on/off, server ping
and remote compose ps/logs, refresh-capabilities, template
test-db-connection, deploy branches, buildpack detect — plus three inline
gaps: a viewer could widen their own API key's scopes, the demo-deploy flag
was doing double duty as an authorization one, and tagging a shared resource
had no write check at all while attaching one did.
`_ensure_group_mutable` only ever blocked *system* queue groups, so every
queue_bus mutation now also calls `_ensure_group_accessible` (owner-or-admin)
— renaming, draining or deleting another user's group was open to any
authenticated caller.
The rbac policy decorators authenticate through `auth_required()`, so they
replace `@jwt_required()` rather than stack under it; stacking rejects
API-key callers before the policy can authorize them
(tests/test_auth_decorator_boundaries.py enforces this). Where a route then
needed the caller it reads `get_current_user()` — `get_jwt_identity()` raises
for an API-key request. The decorators also carry a `_sk_authz` marker so a
static test can see the decision from the source.
`migrations.get_migration_history` moves to `require_admin_user()` for the
same reason its siblings did: the inline `User.ROLE_ADMIN` check it kept was
already reading two names this module no longer imports, so the route raised
NameError for every caller, admin included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
POST /api/v1/workspaces/ was gated only by @jwt_required(), so any
authenticated user - including the read-only viewer role - could create
a workspace, automatically become its owner, then add arbitrary members
and mint workspace-scoped API keys (GHSA-r4q7-x795-vr4j, CWE-862).
Add @admin_required, matching the owner/admin checks every other
mutating workspace route already enforces, plus a regression test
asserting viewer and developer JWTs get 403 while an admin still
creates.
Co-authored-by: Alex <134992700+campguyuz@users.noreply.github.com>
The amd64 tarball job on run 32270325886 hung inside apt-get update
(unreachable azure apt mirror on the runner) and sat for GitHub's
6-hour default job timeout. Normal runs finish in ~13 minutes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_docker_probe_exception_is_unknown_not_absent monkeypatched
ips.subprocess.run — reaching through the service's namespace to the global
subprocess module, which only worked while install_profile_service still
carried its orphaned 'import subprocess'. The probe execs through
run_checked() since the door migration; the test now fails that door
directly, exercising the same except-Exception -> None (unknown) path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The table drifted across the convergence run: 3,874 tests (floor is now
4,309 and CI-enforced, written as 4,300+ so it stays true as tests are
added), 107 blueprints (registry test pins 109), 106 templates (bundled
index carries 118), 1,519 endpoints (a clean checkout's /api/v1 url_map
measures ~1,357 once locally-installed extension routes are excluded,
written as 1,350+), 65 screens (62 page modules today, written as 60+).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hook has zero importers yet was counted in the LEGACY_POLLERS baseline,
holding a phantom slot in the ratchet (a place a new raw setInterval could
hide). Poller debt shrinks 11 sites/10 files -> 10 sites/9 files; the
migration inventory is regenerated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last hand-rolled truthiness read in the app factory: it accepted only
the literal '1', invisible to the env-door ratchet's pattern. env_bool()
keeps '1' working (the updater's contract) and adds the door's documented
true/yes/on spellings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five env_bool-parsed flags had no line in either .env.example. The two
ALLOW_* switches are security-relevant opt-ins (pip runs with backend
privileges; private download URLs) and must be discoverable; the demo and
staging flags are documented alongside them so the gap stops being unnamed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Twenty-one imports whose last caller left during the plan-76/77 sweeps:
mostly 'import subprocess' in services now routed through the shared
runners, plus time/requests in the CachedRemoteIndex adopters, threading
in agent_gateway, os in api/system, User in api/bandwidth, run_privileged
in nginx_advanced_service, and the dead SENSITIVE_SETTING_PARTS alias in
audit_service (zero importers). Each removal AST-verified unused before
deletion; all modules compile and the app factory boots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guard ran a BARE pytest --collect-only from backend/, so --update on a
dev box swept git-ignored dev-data/ into the count and wrote a floor (4591)
that no clean checkout can collect — CI collects 4309, and the ratchet job
has been permanently red since 2026-08-18, which is a guard nobody can act
on. The floor's history shows the inflation entered at e6e605d1 (+314 with
only +46 test defs in the range it spanned).
Collection is now scoped to tests/ (matching what the CI shards run), a
non-zero collection exit refuses to report a partial count instead of
silently deflating it, and the floor is LOWERED 4591 -> 4309 — the value a
clean checkout actually collects; a dev checkout with sibling-repo
extension tests collects 4324, still above the floor. This is a deliberate,
documented decrease: no tests were removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three stub-kit dispatch tests used ['ufw', ...] as an arbitrary example
argv and one popen-guard test expected the real Popen to fail on it. Both
assumed ufw is absent on the running machine — true on every dev box, false
on CI's runners, where ufw exists sbin-only: the sbin guard fired inside
the stub path (SbinPathError instead of the scripted result) and the real
exec succeeded instead of raising FileNotFoundError. Red on every CI run
since the stub kit landed; invisible in local runs.
The dispatch tests now pin the guard's view of ufw to 'absent' (they test
dispatch mechanics, not the guard), and the guard test execs a name that
exists on no machine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw-subprocess census and the thread-ownership guard walked the
filesystem under backend/app, which on a dev box also holds *installed*
extension copies under app/plugins/ that git does not track. The census
therefore measured 28 on dev machines but 24 on CI's clean checkout — four
sites of invisible ceiling slack, a stale thread classification for the
locally-installed analytics extension, and a red migration-inventory
freshness test on every CI run since the range landed.
Both instruments now intersect their walk with git ls-files (falling back
to walking everything when git cannot answer), the analytics thread entry
is removed (extension repos own their own threads), the ceiling ratchets
down 28 -> 24, and the inventory is regenerated. Proving test:
test_untracked_files_are_not_counted plants an untracked file with a raw
call inside app/ and asserts the census does not see it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan-76 completion record cited a75f435f (clipboard) and 3ac566cb (first
poller wave) — pre-rebase hashes unreachable from HEAD; the landed commits
are e1a5a5e1 and 1503c493. The plan-77 record's '98 query sites' for the
Server SoftDelete tail measures 99 today, so it is recorded as approximate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four passages still described the pre-convergence world: bare @jwt_required()
as the route guard (now the frozen legacy population behind auth_required()
and the rbac identity door), plain JSON errors (now typed envelopes with
request ids), sockets.py as a pile of handlers (now the channel registry,
room grammar, and run envelope), and deploy_log/deploy_status as the deploy
channel (now a compatibility dual-emit under run_log/run_status). Also names
the model mixin suite and canonical status vocabulary in the Models row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The supported-versions table still said 1.6.x while 1.7.112 ships; the
SECURITY_AUDIT.md link pointed at a file that has never existed in git
history. Adds a short API Authentication section describing the JWT-only
default with per-route API-key opt-in through the rbac policy decorator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the user-facing work the last 107 commits shipped: managed-database
Recycle Bin, the typed error envelope with request ids and no-swallowed-
crashes guarantee, visibility-aware polling plus the unified run stream,
tunnel reconciliation on the long-poll transport, and surfaced credential
encryption failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>