Files
ServerKit/backend/app/utils/sdk.py
T
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

107 lines
4.2 KiB
Python

"""Frontend SDK compatibility contract (backend mirror).
The canonical value lives in ``frontend/src/plugins/sdk/index.js`` as the exported
``SDK_VERSION`` constant. The backend can't import JS at runtime, so this module
mirrors it — the two are kept in lock-step and asserted equal by
``backend/tests/test_sdk_contract.py``.
Purpose:
- ``GET /api/v1/plugins/contributions`` reports ``sdk_version`` so the panel UI
and tooling know what SDK surface the running panel offers.
- An extension's ``plugin.json`` may declare ``sdk_version`` — a semver RANGE the
extension is built against. It's checked at install
(``plugin_service._assert_manifest_sdk_compatible``) and, for runtime ESM
bundles, again at load (the loader refuses an incompatible bundle before
fetching any bytes and explains why). A manifest that pins no range installs
and loads anywhere — fail-open, deliberately.
The range grammar accepted by ``sdk_version_satisfies`` is intentionally small
(the subset extension authors actually write):
- empty / ``*`` / ``x`` → matches anything
- ``1.2.3`` → exact
- ``^1.2.3`` → >=1.2.3 and <2.0.0 (caret; 0.x pins the minor)
- ``~1.2.3`` → >=1.2.3 and <1.3.0 (tilde)
- ``>=1.2.0``, ``>1.0``, ``<=2``, ``<2.0.0``, ``=1.2.3``
- a comma- or space-separated AND of the comparators above
"""
import re
from app.utils.version import compare_versions
# Keep in lock-step with frontend/src/plugins/sdk/index.js `SDK_VERSION`.
#
# 1.3.0 — plan 79: localization. useTranslation/Trans/t, useLocale, useLabel,
# and the locale-aware format door (useFormat + formatDate/Number/Relative/…).
# i18next and react-i18next also became SHARED vendor singletons, so an
# extension that externalizes them needs a panel at this SDK or newer:
# pin `"sdk_version": "^1.3.0"` if you use any of that surface.
# Additive — an extension pinned to ^1.2.0 keeps loading.
# 1.2.0 — plan 52 Phases 4-5: layout/feedback primitives, ui/* form kit,
# common hooks, format utils, and the cross-feature embeds (git repo-connect,
# backups ProtectionPanel) the WordPress runtime-ESM extraction needed.
SDK_VERSION = '1.3.0'
def _version_tuple(v):
parts = []
for chunk in str(v).split('-')[0].split('+')[0].split('.'):
num = ''.join(ch for ch in chunk if ch.isdigit())
parts.append(int(num) if num else 0)
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
def _satisfies_one(current, comparator):
"""Evaluate ``current`` against a single comparator token (already trimmed)."""
comparator = comparator.strip()
if not comparator or comparator in ('*', 'x', 'X'):
return True
m = re.match(r'^(\^|~|>=|<=|>|<|=)?\s*v?(.+)$', comparator)
if not m:
return True # fail open on anything we don't understand
op, ver = m.group(1) or '=', m.group(2).strip()
if op == '^':
lo = _version_tuple(ver)
if lo[0] > 0:
hi = (lo[0] + 1, 0, 0)
elif lo[1] > 0:
hi = (0, lo[1] + 1, 0)
else:
hi = (0, 0, lo[2] + 1)
cur = _version_tuple(current)
return lo <= cur < hi
if op == '~':
lo = _version_tuple(ver)
hi = (lo[0], lo[1] + 1, 0)
cur = _version_tuple(current)
return lo <= cur < hi
cmp = compare_versions(current, ver)
if op == '>=':
return cmp >= 0
if op == '<=':
return cmp <= 0
if op == '>':
return cmp > 0
if op == '<':
return cmp < 0
return cmp == 0 # '=' or bare
def sdk_version_satisfies(range_str, current=None):
"""True iff ``current`` (defaults to this panel's SDK_VERSION) satisfies the
semver ``range_str``. Empty/None range matches anything (an extension that
doesn't pin a range installs everywhere). Malformed comparators fail open —
a bad range must never hard-block for the wrong reason."""
if current is None:
current = SDK_VERSION
if not range_str or not str(range_str).strip():
return True
# AND of comma- or whitespace-separated comparators.
tokens = [t for t in re.split(r'[\s,]+', str(range_str).strip()) if t]
return all(_satisfies_one(current, t) for t in tokens)