Ctrl K
ComunidadGuías…

docs/ARCHITECTURE_DECISIONS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Architecture Decision Records

Status: Accepted — 2026-09-15

## ADR-001: Plugin Runtime Trust Model

**Decision**: Reviewed-only trust model for the community marketplace.

The plugin-worker executes third-party code in a dedicated child process
within a hardened container (read-only fs, mem/pids caps, cap_drop ALL,
no-new-privileges, internal-only network, process-group SIGKILL on
timeout, strict IPC validation). This is adequate for **admin-reviewed,
curated plugins** where the review process is the primary trust gate.

**NOT adequate for arbitrary hostile JavaScript**. The child and parent
share the same UID and container; `/proc/<ppid>/environ` readability
depends on host kernel policy, and a sufficiently motivated plugin
could attempt same-UID signal attacks. Per-plugin containers with
separate UIDs, PID namespaces and cgroups are the path to hostile-code
sandboxing — deferred until the marketplace scales beyond curated.

**Marketplace policy**: submissions require human review before
`approved` status; artifact hash pinning ensures reviewed bytes ==
installed bytes; dynamic plugin execution remains opt-in
(`LOBBYFORGE_DYNAMIC_PLUGINS_ENABLED=true` + worker URL).

## ADR-002: Plugin Client UI Architecture

**Decision**: Server-only for beta; sandboxed iframe as the target.

Dynamic marketplace plugins currently have `renderClient: () => null` —
they can manage server-side state, handle actions and use scoped
storage, but cannot render their own UI components in the LobbyForge
client. This is a **known functional gap**, not a bug.

**Target architecture** (post-beta):
- Each plugin UI runs in a sandboxed `<iframe>` with
  `sandbox="allow-scripts"` and a plugin-specific origin
- Communication via `postMessage` with a versioned capability protocol
  (state read/write, action dispatch, storage access)
- No direct access to the parent React context, DOM or LobbyForge
  session tokens
- Plugin bundles serve their client entry from the plugin-worker's
  static file path

This keeps untrusted client JS out of the main application context.

## ADR-003: Docker Image Supply Chain

**Decision**: Digest-pinned (IMPLEMENTED).

All six production third-party images AND the Dockerfile base image
are pinned to exact @sha256 digests. CI scans byte-identical to what
production deploys. Dependabot docker-compose is configured to
auto-PR digest updates weekly.

## ADR-004: GitHub Governance Level

**Decision**: Graduated enforcement; full lockdown before release.

Current: branch protection with required CI/security checks,
`enforcement_level: non_admins` (admin can bypass), unsigned commits.

**Pre-release action**: Enable admin enforcement, require PRs for all
changes (including admin), set up commit signing (GPG or SSH key),
create repo rulesets for branch protection.

## ADR-005: Desktop Distribution Security

**Decision**: Defer code signing to pre-distribution phase.

The Tauri desktop shell builds and runs correctly without signing.
Windows SmartScreen and macOS Gatekeeper will show warnings on
unsigned binaries, but this is acceptable for beta/closed testing.

**Pre-distribution action**: Obtain Windows Authenticode certificate,
set up macOS Developer ID + notarization, add SHA-256 checksums and
GitHub artifact attestations to the release workflow, pin release
actions to commit SHAs.

**Amended 2026-10-09: signing is wired, off until secrets exist.** The
choice of certificate follows
[DESKTOP_SIGNING_INSTALLER_RESEARCH_2026-10.md](DESKTOP_SIGNING_INSTALLER_RESEARCH_2026-10.md):
no free route removes the SmartScreen warning today; Certum Open Source
(SimplySign cloud, from €49) is the cheapest route open to an individual in
Turkey; SignPath Foundation is free but asks for reputation the project does
not have yet. Azure Artifact Signing is not available in Turkey.

As implemented (owner guide: [DESKTOP_SIGNING.md](DESKTOP_SIGNING.md)):
- **One pipeline.** `desktop-release.yml` is the only place installers are
  built; `release.yml` calls it for `v*` tags (version stamped in), and it
  can be run by hand for test builds plus a Windows install smoke test.
  The tauri-action build on `desktop-v*` tags is retired: two copies of the
  signing steps would drift, and desktop versions already follow the
  unified release.
- **Off until configured.** `apps/desktop/scripts/release-signing.mjs`
  reads which secrets are set (environment `desktop-signing`). No secrets:
  unsigned, green. Half a provider, or two Windows providers: the build
  fails rather than silently shipping unsigned. Signing settings never
  live in the committed `tauri.conf.json`; CI passes them with `--config`.
- **Windows, Certum:** `CERTUM_EMAIL` + `CERTUM_OTP` (the TOTP seed) →
  `bundle.windows.signCommand` runs `ssign` (HTTPS client for SimplySign,
  downloaded and hash-pinned), so Tauri signs the app, the NSIS plugins,
  the uninstaller and the installer. MSI is skipped in these builds
  (ssign cannot sign MSI). Fallback without storing the seed:
  `pnpm --filter @lobbyforge/desktop sign:local` on the owner's machine
  with a one-time code, or a certificate thumbprint from the Windows
  store.
- **Windows, SignPath:** `SIGNPATH_API_TOKEN` + `SIGNPATH_ORGANIZATION_ID`
  → the app exe is signed through SignPath's action before bundling
  (pre-patched with the bundle-type marker so the bundler leaves the
  signature intact) and the installers after; release tags only, unless a
  test policy is configured. The NSIS-generated uninstaller stays unsigned
  on this route.
- **macOS:** `APPLE_CERTIFICATE` + `APPLE_CERTIFICATE_PASSWORD` sign;
  `APPLE_ID`/`APPLE_PASSWORD`/`APPLE_TEAM_ID` or an App Store Connect API
  key notarize. Empty variables are unset before Tauri runs.
- **Least exposure.** The compile step sees no secret; only the bundling
  step gets the chosen provider's secrets; values are never printed;
  signatures are verified after bundling, and a platform that fails
  uploads nothing instead of an unsigned installer.
- **Installer.** A custom NSIS template (Tauri's, four fenced changes)
  makes the default install one-click, per user (`%LOCALAPPDATA%`, no UAC),
  with a compact branded progress window that opens the app when done;
  `/WIZARD` keeps Tauri's pages. English and Turkish follow the system
  language. Publisher is "LobbyForge contributors" (it was the product
  name, which the Microsoft Store rejects).

Still open: buying the certificate or SignPath's acceptance; macOS
Developer ID when Mac users arrive; Microsoft Store (MSIX) and winget
channels; the Tauri updater.

## ADR-006: No Central Authentication — the Hub Is Unauthenticated

**Decision**: The Official Hub (lobbyforge.org) has NO login/register
for end users. Identity is INSTANCE-LOCAL; self-hosting never depends
on a central LobbyForge account. (Accepted — 2026-09-17.)

Rationale: a hub-level login creates the user expectation "one
LobbyForge account works on every LobbyForge server" — the opposite of
the instance-local account model the platform is built on, and a
central dependency for every self-host.

Consequences:
- Hub surfaces (landing, discover, connect, download, docs) are public
  and read-only for visitors.
- "Sign in" flows START at an instance: discover → community → the
  instance's own `/login`.
- The future **LobbyForge ID** (if built) is an OPTIONAL identity
  provider for hub conveniences (starred communities, marketplace
  developer profile, plugin publishing, synced desktop instance list)
  and may be offered to instances as an OAuth provider — it is NEVER
  required to self-host or to run an instance.

**Amended 2026-09-28 — the optional hub account exists.** The official
hub now has sign-up and sign-in (`/login`, `/register`, hub home at
`/home`) — the "LobbyForge ID" foreseen above, in its first form. What
did NOT change:
- It is optional: every hub page a visitor used before stays public.
- It is not an identity for other instances. A self-hosted community
  still signs people in on its own `/login`; the hub account does not
  log you in anywhere else and no instance depends on it.
- It carries hub conveniences only: the communities you belong to on the
  official instance, the desktop app, the marketplace and (later)
  plugin publishing and a synced instance list.

Open: email verification and abuse protection beyond the per-IP rate
limit on sign-up; "Sign in with LobbyForge" as an OAuth provider for
instances remains future work.

## ADR-007: Marketplace Plugins Run Sandboxed (supersedes the trust limits of ADR-001 and the target of ADR-002)

**Status**: accepted 2026-10-03; implemented (PR #49, then the plugin unlock phases 1–3a in PRs #100, #103, #104 and #105, and the 2026-10-13 security fixes in #112).

**Decision**: a marketplace plugin can be installed without trusting its
author. Its code never runs with Node APIs and its UI never runs in the
app's origin.

### Server side — QuickJS in the plugin worker
- Marketplace plugin server code (`server.js` in the bundle) runs inside a
  **QuickJS WebAssembly** runtime (`quickjs-emscripten`) created in the
  plugin worker, one fresh runtime per call. Inside: plain ECMAScript
  only — no `require`/`import`, no `process`, `fs`, network, timers,
  `eval` of host objects, or shared memory. The host passes JSON in and
  reads JSON out; nothing else crosses the boundary.
- Limits per call: memory (32 MB), stack, and a CPU interrupt deadline
  (the existing call budget); exceeding any of them fails the call, never
  the worker. Output size stays capped (4 MiB).
- Contract (`sdk: "sandbox-v1"` in the bundle manifest): `server.js`
  assigns `globalThis.plugin = { createInitialState(ctx),
  handleAction(ctx, state, action), validateAction?(action),
  projectState?(state, viewerId, ctx), migrateState?(raw) }`, and the
  manifest declares `actionPolicies` (the same `GamePluginActionPolicy`
  shape as official plugins: roles, `actorFields`, `joinsRoster`,
  `audit`). `ctx` carries `players`, `now` and `random()` values injected
  by the host (`random` draws from a CSPRNG-seeded list the host passes
  in), `locale`, and the session/server ids. No storage in v1 (state is
  the plugin's only memory); a host-mediated storage effect can follow
  (it did: phase 3, below).
- `projectState` gives marketplace plugins the same per-viewer hidden
  state official plugins have: every read path (REST, gateway, action
  response) calls it for the viewer; without it the plugin's state is
  public, and the UI warns the installer.
- The worker keeps its container hardening (read-only fs, caps dropped,
  no-new-privileges, memory/pids limits) as defence in depth, sits on a
  network only the web app joins, and makes no outbound calls.
- Legacy bundles (Node `index.js`, ADR-001 model) stop loading; the
  installer refuses bundles without `sdk: "sandbox-v1"`.

### Server side — as implemented (2026-10-03)
Where the implementation refines the points above:
- **Package**: `quickjs-emscripten-core` + `@jitl/quickjs-wasmfile-release-sync`
  0.32.0 (bellard/quickjs 2025-09-13), pinned exactly — the sync release
  build only, about 1.4 MB instead of the umbrella package's four variants.
- **The interrupt deadline is not a hard wall.** QuickJS checks it between
  bytecodes, so one long native operation runs past it (measured:
  `sort()` on 200 000 numbers in a loop finished 298 s after a 300 ms
  deadline). Run in the worker's main thread that would freeze every
  plugin and the health check. So calls run in a small pool of executor
  threads (`PLUGIN_SANDBOX_THREADS`, default 2); a thread that has not
  answered 250 ms after the budget is terminated (V8 stops running
  WebAssembly within milliseconds) and replaced. The thread is liveness,
  not isolation: the WebAssembly VM is the boundary. The per-call Node
  child process of ADR-001 is gone — plugin code no longer runs as Node.
- **Fresh per call** means a fresh WebAssembly instance (new linear
  memory), not just a fresh QuickJS runtime: nothing an earlier call left
  in memory — another plugin's state included — is reachable even through
  a QuickJS bug. Cost: about 1.5 ms per call.
- **Limits**: 32 MB VM memory, 256 KiB VM stack (caught by QuickJS before
  the thread's native stack), budget `PLUGIN_CALL_BUDGET_MS` lowered from
  10 s to a 2 s default (a sandboxed reducer needs milliseconds, and a
  stuck call holds a thread for the whole budget), 4 MiB result, 2 MiB
  `server.js`, 1024 CSPRNG values per reducer call (`PLUGIN_RANDOM_VALUES`;
  `Math.random` draws from the same list; none at load time).
- **No host functions at all.** The prelude that runs before `server.js`
  hands the host a call function plugin code cannot reach, and removes
  `SharedArrayBuffer` (the build has no Atomics). Plugin code tampering
  with built-ins only changes its own output, which the host validates
  (JSON, object-shaped states, size cap).
- **`projectState` ctx** is `{ sessionId, serverId, hostId, now }` — no
  players and no locale, because the gateway knows neither and a
  projection must not differ between REST and WebSocket.
- **Policies are the web app's reading of the manifest**, validated by
  the installer and on every load from the web app's own copy of the
  files; the worker's `describe` must report the same policies or the
  plugin does not load. A VM escape therefore cannot widen who may send
  which action.
- **Translations** (added 2026-10-12): a bundle may ship
  `locales/<code>.json`, flat string tables validated with the manifest
  (`parseSandboxBundle`, same caps in the installer and the worker). The
  web app registers them with the plugin SDK's process-wide locale
  registry under the plugin's id, so the host's own screens show its
  `catalog.name` / `catalog.summary` in each viewer's language; never
  for the id of a compiled-in plugin. The iframe does not receive them.
- **The gateway** (which cannot reach the worker) projects only the
  official plugins itself (`isCoreProjectedPlugin`) and asks the web app
  for every other id through `POST /api/internal/activity-projection`,
  signed with a key derived from `LOBBYFORGE_SESSION_SECRET` (which the
  gateway already holds); failure means an event without state.
- `/api/internal/plugin-storage` and `LOBBYFORGE_PLUGIN_STORAGE_TOKEN` on
  `web` remain but are unused: storage became a host effect (below) and
  the worker still makes no outbound calls.
- **Host effects** (2026-10, phase 3a of docs/PLUGIN_UNLOCK_PLAN.md):
  `createInitialState` / `handleAction` may return `{ __lf: 1, state,
  effects }` — `timer.set` / `timer.clear`, `storage.set` /
  `storage.delete`, `chat.post`. They are still pure data crossing the
  boundary: the VM returns them beside the state, and the web app
  validates each one against the plugin's EFFECTIVE permissions (manifest
  `permissions` asked for AND approved at install) and applies all or none
  (`effect_refused`). Timers fire lazily under the session lock as
  `role: "system"` actions no client can send; `ctx.storage` is a
  read-only JSON snapshot passed in with the call, written back after the
  commit; a chat post becomes an author-less app message in the text
  channel the community chose. Official plugins use the same effects with
  their manifest's permissions. Details: docs/PLUGIN_SDK.md "Effects".

### Client side — sandboxed iframe
- A marketplace plugin may ship `ui/index.html` (+ assets). The lobby
  renders it in `<iframe sandbox="allow-scripts">` (no
  `allow-same-origin`, forms, popups, top navigation or downloads) from
  `/api/plugin-ui/{pluginId}/{version}/…`, so the frame has an **opaque
  origin**: it cannot read the app's cookies, storage or DOM, and the
  app's SameSite=Lax session cookie is not sent with its requests.
- The asset route answers with its own CSP: `default-src 'none';
  script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline';
  img-src 'self' data:; font-src 'self'; media-src <own> data: blob:;
  connect-src <own>; frame-ancestors 'self'; base-uri 'none'; form-action
  'none'; webrtc 'block'` plus `X-Content-Type-Options: nosniff`, where
  `<own>` is the plugin version's own folder on the configured public
  origin (`https://<instance>/api/plugin-ui/<id>/<version>/`; `'self'` when
  no origin is configured). `fetch()`, XHR, WebSocket, image and media
  requests reach the plugin's own files and nothing else. WebAssembly
  compiles (`'wasm-unsafe-eval'`) but reaches nothing a script cannot. The
  app's own CSP gains `frame-src 'self'`. (2026-10: phase 1 of the plugin
  unlock — media, WebAssembly and the plugin's own files; the first version
  had `connect-src 'none'`.)
- **What that CSP does not promise** (2026-10 security review): it is not
  "no network". WebRTC is outside `connect-src`, so a frame can open an
  `RTCPeerConnection` whose STUN/TURN traffic goes wherever the plugin
  says; `webrtc 'block'` refuses that only in browsers that implement the
  directive (the others ignore it). And where the frame navigates *itself*
  is decided by the embedding page's `frame-src`, not by the frame's own
  policy, so it can load any origin the lobby page may frame. What the
  frame is shown (the viewer's projected state, names, language) must be
  treated as readable by the plugin's author — which is why per-viewer
  projection, not the frame, is what keeps hidden state hidden.
- **Approved outside origins** (2026-10, phase 2 of the plugin unlock,
  docs/PLUGIN_UNLOCK_PLAN.md): a manifest may ask for `network` origins
  (`connect`, `img`, `media`, `frame`), which take effect only once the
  instance owner approves them at install (`approved` on the active
  record). The HTML document's policy then adds the plugin's EFFECTIVE
  origins — asked for AND approved — to `connect-src`, `img-src`,
  `media-src` and `frame-src` (no `frame-src` otherwise). A nested frame
  inherits the sandbox (opaque origin). Because the approval can change for
  one version, the HTML is `Cache-Control: no-cache`; every other file
  keeps the base policy (a subresource's CSP does not govern the frame) and
  stays `immutable`.
- **The author's own site as the UI** (phase 2): `ui: { url }`, once
  approved, is framed directly with `sandbox="allow-scripts
  allow-same-origin allow-forms allow-pointer-lock"` and
  `referrerpolicy="no-referrer"` — never `allow-top-navigation`,
  `allow-popups` or `allow-modals`. `allow-same-origin` (the site keeps its
  own origin, cookies and storage) is safe only because that origin is not
  the app's, so a URL on the app's host — any port or scheme, since
  cookies are scoped by host — or on a subdomain either way is refused at
  install, in the frame info route and in the lobby. The app's pages add
  the approved sites to `frame-src` (a 30 s per-process cache read from the
  install root, reset on install; nothing added on error). The lobby shows
  the site's host above the frame so its content is visibly not the app.
- **postMessage protocol v1** (all messages carry `lf: 1`): parent →
  frame `init { viewer, players, locale, theme (CSS variables), state }`
  and `state { state, revision }` (the PROJECTED state for that viewer);
  frame → parent `ready`, `action { action }`, `resize { height }`.
  The parent accepts messages only from that iframe's `contentWindow`
  AND the expected origin (`"null"` for the plugin's files, the approved
  site's origin for an external UI — the window survives a navigation, the
  origin does not), validates shape and size (≤ 64 KiB, ≤ 10 actions/s),
  and dispatches actions through the normal actions route with the
  viewer's session — the frame can do nothing the viewer could not do by
  sending actions. It posts to an external site's origin only; to the
  opaque files frame it must post to `'*'`.
- `@lobbyforge/plugin-sdk/frame` gives plugin authors a tiny client for
  this protocol plus the theme variables. On an external site (a real
  origin of its own, framed by any instance that installed the plugin) it
  sends only `ready` until the parent's first `init` names the parent's
  origin, then talks to that origin only.

### Consequences
- Review stays (catalog approval, digest pinning), but it is no longer
  the only thing between a plugin and the instance.
- Official plugins stay compiled in (they are trusted code and keep
  React panels in the app origin).
- Remaining risk: a plugin can still waste its own call budget or show
  misleading UI inside its frame; it cannot read other state, sessions or
  the network beyond the origins the owner approved (which then see what
  players see; an approved site can change what it serves at any time).
  Sibling subdomains of the app's registrable domain cannot be told apart
  from other sites without the Public Suffix List, so the owner should not
  approve an external UI on their own domain. Server side, a busy plugin can occupy the executor threads
  for up to one budget per call and so slow other marketplace plugins
  (never official ones, which do not use the worker); a QuickJS or V8
  WebAssembly escape would land in the worker container, which holds only
  the worker's RPC token and can reach only the web app.