Commit Graph
2037 Commits
Author SHA1 Message Date
Juan Denis 2d95e186aa fix: align backend locale order with manifest 2026-08-20 23:42:15 -04:00
Juan Denis 2a25ff839d fix: restore appearance settings rendering 2026-08-20 23:01:37 -04:00
Juan Denis 240ce23765 feat: add next localization batch 2026-08-20 22:50:06 -04:00
Juan Denis 5481057f95 feat: add five localized language bundles 2026-08-20 22:34:34 -04:00
github-actions[bot] cffa4cdcb8 chore: bump version to 1.7.121 [skip ci] 2026-08-21 02:01:00 +00:00
Juan Denis cc7178f994 fix(i18n): address Copilot review — render keyed labels, un-split sentences
- QuickCreate menu items carried labelKey but rendered item.label
- DataTable and the ColumnsMenu/GroupMenu/SortMenu/SortChipBar helpers
  rendered column.header while 66 files declare headerKey; all now
  resolve through translateLabel when the header is a string
- ExtensionBoundary failure card glued 'The extension' + slug + 'could
  not be loaded' from two fragments; now one Trans key with a component
  slot so translators can reorder
- LogFileList/IconReferenceTab empty states split 'No matches for "'
  around the query; now single keys with a {{query}} placeholder

es.json synced: orphans pruned, changed keys retranslated.
2026-08-20 22:00:55 -04:00
github-actions[bot] 4f77884129 chore: bump version to 1.7.120 [skip ci] 2026-08-21 01:42:32 +00:00
Juan Denis be130be5c4 chore(i18n): ratchet literal ceiling from 8034 to 0
All user-visible literals are now keyed; the ratchet prevents regressions.
2026-08-20 20:23:42 -04:00
Juan Denis 6424fec1d2 feat(i18n): complete Spanish locale — 7,969 keys
Full informal-tú Latin American Spanish translation of every en.json
key, merged over the existing 183-key locale (existing entries kept).
Mechanically verified: exact key-set parity, {{placeholder}} and <N>
tag parity, no empty values. Adversarial 3-reviewer QA pass applied
(28 fixes: glossary consistency, SDK gate/severity/rate-limit terms,
{{action}} quoting, register and punctuation).

check-i18n: no orphans, no placeholder drift, 0 untranslated.
2026-08-20 20:23:41 -04:00
Juan Denis 549a02f73b feat(i18n): regenerate en.json with 7,969 extracted keys
Generated by scripts/extract-i18n.mjs from the inline t() defaults —
was 183 keys before the mass conversion.
2026-08-20 20:23:41 -04:00
Juan Denis cc9c3fb70d feat(i18n): convert all remaining user-visible literals to translation keys
Mass conversion of ~8,000 English literals across 320+ files to
t('app.<file>.<key>', 'English default') calls, plus hand-conversion of
the 43 literals the codemod refused (concatenated toasts, module-level
helpers, class components) using bare t() from i18n/t or restructured
{{placeholder}} keys. Marketplace unsigned-release dialog now translates
the Install/Update verb inside the placeholder.

ESLint: 0 errors. Literal checker: 0 remaining.
2026-08-20 20:23:29 -04:00
Juan Denis f7d421797a fix(settings): use path-based settings routes in AI drawer and docker gate
DrawerHeader navigated to /settings?tab=ai and RequiresDocker linked to
/settings?tab=system, but settings routes are path-based — the query-tab
URLs landed on the wrong screen. Now /settings/ai and /settings/system.
Both files also converted to i18n keys as part of the mass conversion.
2026-08-20 20:23:19 -04:00
github-actions[bot] 4510143386 chore: bump version to 1.7.119 [skip ci] 2026-08-20 23:04:37 +00:00
Juan Denis 2825c536a8 fix(i18n): address review — reset locale readiness, typed error for bad language, correct parity test reference
- 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)
2026-08-20 19:04:09 -04:00
github-actions[bot] 7af31c3331 chore: bump version to 1.7.118 [skip ci] 2026-08-20 22:50:28 +00:00
Juan Denis ae95ac4478 docs: regenerate MIGRATION_INVENTORY to match measured state 2026-08-20 18:41:14 -04:00
github-actions[bot] a38cdc07b4 chore: bump version to 1.7.117 [skip ci] 2026-08-20 21:31:34 +00:00
Juan DenisandClaude Fable 5 1cb09b9987 fix(i18n): verified es fixes — 2FA term, cron nav, scheduling ambiguity, display probe, RAE comma
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:02:36 -04:00
Juan DenisandClaude Opus 5 821eccdb3b feat(i18n): run the locale check against any extension repo
`--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>
2026-08-20 10:47:13 -04:00
Juan DenisandClaude Opus 5 7de4d10eda feat(i18n): codemod for the long tail; census fixes; Intl component options
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>
2026-08-20 08:50:13 -04:00
Juan DenisandClaude Opus 5 9c0ae9e957 feat(extensions): enforce the SDK gate at install; localize the GUI builtin
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>
2026-08-20 00:31:42 -04:00
Juan DenisandClaude Opus 5 cff35cc651 feat(sdk): share i18next with extensions; generate the vendor shims
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>
2026-08-20 00:16:04 -04:00
Juan DenisandClaude Opus 5 33410950e8 chore(i18n): RTL plumbing ratchet; keep the bare t off the Vite-only path
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>
2026-08-20 00:04:29 -04:00
Juan DenisandClaude Opus 5 fe3c0d77bc feat(i18n): language selector, and fix two silent locale failures
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>
2026-08-19 23:59:25 -04:00
Juan DenisandClaude Opus 5 965ea5c27f feat(i18n): translate onboarding and auth; map server error codes to keys
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>
2026-08-19 23:49:32 -04:00
Juan DenisandClaude Opus 5 df8f8eaafd refactor(ui): one repo-picker contract; give Modal a form mode
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>
2026-08-19 23:39:25 -04:00
Juan DenisandClaude Opus 5 2103bb87bd feat(i18n): translate the shared shell; seed the common vocabulary
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>
2026-08-19 23:34:56 -04:00
Juan DenisandClaude Opus 5 600a4e9456 feat(i18n): generate en.json from source; check locale files for coherence
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>
2026-08-19 23:25:31 -04:00
Juan DenisandClaude Opus 5 7050b24137 feat(i18n): one locale-aware format door; delete the timeAgo shim
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>
2026-08-19 23:22:28 -04:00
Juan DenisandClaude Opus 5 b851f48ef0 feat(i18n): locale runtime, precedence, and a per-user language column
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>
2026-08-19 23:16:09 -04:00
Juan DenisandClaude Opus 5 cb5043d2d7 chore(i18n): seed the untranslated-literal ratchet before any migration
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>
2026-08-19 23:06:39 -04:00
github-actions[bot] 9e0f19c378 chore: bump version to 1.7.116 [skip ci] 2026-08-20 02:33:07 +00:00
Juan Denis 61bed36023 Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-08-19 22:31:29 -04:00
Juan DenisandClaude Opus 5 7311abe61f docs(inventory): regenerate the migration inventory after the authz sweep
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>
2026-08-19 22:29:52 -04:00
github-actions[bot] 1c28afca44 chore: bump version to 1.7.115 [skip ci] 2026-08-20 02:16:48 +00:00
Juan DenisandClaude Opus 5 0d42baf678 fix(tests): set the test-count floor to the clean-checkout number
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>
2026-08-19 22:09:59 -04:00
github-actions[bot] 343b4e3b14 chore: bump version to 1.7.114 [skip ci] 2026-08-20 01:54:09 +00:00
Juan DenisandClaude Opus 5 c8afc7ccd2 fix(workspaces): hide the creation entry points from non-admins
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>
2026-08-19 21:44:56 -04:00
Juan DenisandClaude Opus 5 9e18e35494 docs(authz): write down the default-deny and one-boundary rules
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>
2026-08-19 21:44:56 -04:00
Juan DenisandClaude Opus 5 7aa3286932 test(authz): default-deny backstops plus proving tests for the gates
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>
2026-08-19 21:44:45 -04:00
Juan DenisandClaude Opus 5 47ec555915 fix(security): a webhook with no signature is as unauthenticated as a wrong one
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>
2026-08-19 21:44:28 -04:00
Juan DenisandClaude Opus 5 031142111d fix(authz): give every mutating route an explicit authorization decision
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>
2026-08-19 21:44:17 -04:00
Juan DenisandAlex a92d40a042 fix(workspaces): require admin role to create workspaces
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>
2026-08-19 19:16:42 -04:00
Juan Denis ec78947157 Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-08-19 19:01:54 -04:00
Juan DenisandClaude Fable 5 3d3395d3ad ci(release): cap the tarball build at 30 minutes
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>
2026-08-19 17:34:32 -04:00
github-actions[bot] 3e0a5618e9 chore: bump version to 1.7.113 [skip ci] 2026-08-19 14:45:28 +00:00
Juan DenisandClaude Fable 5 de454527f5 fix(tests): docker-probe test patches the door, not the removed subprocess import
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>
2026-08-19 10:35:23 -04:00
Juan DenisandClaude Fable 5 a8ba79348e docs(readme): correct the stats table to measured, gate-backed numbers
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>
2026-08-19 10:16:19 -04:00
Juan DenisandClaude Fable 5 4fcd0db4d5 chore(frontend): delete the dead useContainerStatus hook and its legacy-poller entry
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>
2026-08-19 10:14:36 -04:00
Juan DenisandClaude Fable 5 5448694c8d refactor(app): SERVERKIT_SKIP_BACKGROUND reads through the env door
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>
2026-08-19 10:13:44 -04:00