Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.
Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.
Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
own framework: it listed as installed and would have been imported as a
unit. Both now key off the plugins dir's top-level dependencies, with an
emptied dependency list meaning 'nothing installed' rather than falling
back to the naming convention
- the store must not pass new flags to the runner: the scripting package
ships separately and an older one reads an unknown flag's value as a
package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
as Ok with an empty body — handled as an error it made every refresh
after the first verify a signature over zero bytes and sit stale
Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.
- store/index.rs signed index parse + ed25519 verify (ring), validate-and-drop
per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs single-flight install/uninstall: registry-integrity preflight
against the pin, spawn the runner CLI with live log capture,
post-install version check with rollback, provenance record,
runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs 12 routes under /api/v1/store, denied to the plugin token
(a plugin that can install plugins is an escalation primitive)
Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Manual publish (no sdk-v tag — the version was published directly so the
plugin-kit and rewritten rom-manager can consume pluginStateDir/pluginIngestDir
from the registry; the previously published 0.1.1 predates the security-tiers
merge and lacks those exports).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LocalService runner can't traverse the interactive user's profile the way
the old SYSTEM runner could — so a plugin can no longer read a file an app
running as *you* produced (the acute case: the Playnite exporter's library
JSON under %APPDATA%). Confirmed on-glass: as LocalService the glob finds
nothing and the profile is un-traversable.
Add the inverse of plugin-state: <config_dir>\ingest, granted BUILTIN\Users
Modify by plugins enable (disable reverts to inherited Users:RX). An
interactive-user app drops ingest\<plugin>\… and the de-privileged runner
reads it there — the one Users-writable carve-out in the otherwise
Users-read-only tree. SDK exports pluginIngestDir(name) to resolve it; on
Linux the systemd --user runner owns the config dir so same-user producers
write there with no grant.
Accepted tradeoff: the inbox is writable by any local user (trusted-single-
user model; it feeds only a LocalService runner). Consumers must treat ingest
data as lower trust than their own state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LocalService runner cannot write anywhere under %ProgramData%\punktfunk
(the config dir is Users-read-only), so a state-writing plugin's saveCache /
config-edit / first-run mkdir all fail EPERM — proven on-glass (rom-manager
only looked fine because its state dir was pre-created by an admin run and a
0-title reconcile skipped the write).
Add the one writable grant the model was missing, keeping the split crisp —
code dirs RX+WA, secrets R, and now a dedicated state root RW:
- plugins enable / build-scripting.ps1: create %ProgramData%\punktfunk plugin-state and grant LocalService (OI)(CI)(M); disable revokes. Users stay
read-only, so another non-admin still can't tamper with a plugin's launch
templates.
- SDK: export pluginStateDir(name) -> <config_dir>/plugin-state/<name>. Same
path on Linux (the systemd --user runner owns the config dir, writable with
no grant), so plugins use one branch-free helper.
Plugins must persist under pluginStateDir(), not straight under the config dir.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- plugins: the Windows runner task runs as LocalService now, and
public-registry names need --allow-public-registry
- automation + SDK README: connect()'s zero-config credential is the
scoped plugin token; pairing administration and hook registration
need an explicit PUNKTFUNK_MGMT_TOKEN opt-in
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supply chain: resolvePackage() used to pass any punktfunk-plugin-* or
foreign-scope name straight to bun add — a typo or a squatted look-alike
on npmjs.org would install operator-privileged code. Only the @punktfunk
scope (pinned to the Gitea registry by the bunfig scope map) resolves by
default now; anything else throws with an explanation unless
--allow-public-registry is passed, and even then installs print a loud
warning. Removal never gates — uninstalling stays safe regardless of
origin.
systemd (user unit): free hardening for well-behaved plugins —
NoNewPrivileges, PrivateTmp, ProtectSystem=strict with ReadWritePaths=%h
(plugin state and download dirs under $HOME keep working), and
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6. Plugins writing
outside $HOME, and distros that restrict unprivileged user namespaces
for user units, are handled via a documented systemctl --user edit
drop-in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fileIsSafe() returned true unconditionally on win32 ('ACL check is a
follow-up'), leaving the best-effort directory DACL as the only guard on
what the runner imports and executes. The follow-up: before importing a
unit, read its SDDL (Get-Acl via the full-System32-path powershell) and
refuse loudly — exactly like the Unix mode-bits path — unless the owner
is SYSTEM/Administrators/TrustedInstaller (or the account the runner
itself runs as, mirroring Unix's 'your own file is fine') and no other
principal holds a write-capable allow ACE (write/append data, EA/attrs,
DELETE, WRITE_DAC, WRITE_OWNER, generic write/all).
The SDDL verdict is a pure exported function with platform-independent
tests; unknown ACE shapes and unreadable ACLs fail closed. Inherit-only
ACEs (templates for children) and deny ACEs don't trip it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PunktfunkScripting scheduled task ran operator-installed plugin code
as SYSTEM with -RunLevel Highest — any plugin defect was a full
compromise of the box. The principal is now NT AUTHORITY\LocalService
(minimal privileges, no password to manage, exists at boot, loopback
networking works), registered without RunLevel:
- installer: New-ScheduledTaskPrincipal -UserId 'LocalService'
- plugins enable: converges the principal idempotently (migrating tasks
an older installer or a dev box registered as SYSTEM) BEFORE starting,
then grants LocalService read — via icacls, full-System32-path — on
exactly the two SYSTEM/Admins-DACL'd files the runner's connect()
needs: the scoped plugin-token and the TLS-pin cert.pem. Never
mgmt-token. plugins disable revokes the grants; plugins status now
prints the task principal so the migration is verifiable.
- build-scripting.ps1 mirrors the convergence + grants on dev deploys.
The usage text also mentions the new --allow-public-registry gate that
lands with the supply-chain commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scripting runner used to hold the console's full-admin mgmt-token — a
plugin defect could rewrite hooks.json (arbitrary command execution) or
administer pairing. The host now mints a second persisted secret,
plugin-token (PUNKTFUNK_PLUGIN_TOKEN), and require_auth grows a third lane
for it: loopback-confined like the admin token, but plugin_may_access
carves out /hooks (read AND write), everything under /pair, /native/pair,
/native/pending, client unpair DELETEs, and other plugins' ui-credential.
Everything a plugin legitimately does (status/library/events/sessions,
its own UI lease) is untouched.
The SDK's zero-config connect() now prefers PUNKTFUNK_PLUGIN_TOKEN /
plugin-token over mgmt-token, so plugins pick the scoped credential up
automatically; a script that genuinely needs the admin surface sets
PUNKTFUNK_MGMT_TOKEN explicitly. Old hosts without a plugin-token fall
back to mgmt-token unchanged. No OpenAPI change: the lane is auth-layer
only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One-liner plugin management replacing the manual scripting-dir + bunfig +
bun-add ritual: package ops forward to the bun runner (new sdk plugins
module + runner-cli subcommands, 11 tests green), enable/disable/status
drive the systemd unit on Linux and the PunktfunkScripting scheduled task
on Windows (installer support in the ISS). Docs page rewritten as .mdx
with per-platform Tabs (registered in mdx.tsx).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runner only found unscoped `punktfunk-plugin-*` packages. An unscoped
package can't be split across registries, which forced plugins to bundle their
own copy of @punktfunk/host + effect (the plugin's Gitea registry has no
`effect`; `--registry <gitea>` 404s it). Discovering the scoped
`@punktfunk/plugin-*` convention lets a plugin resolve cleanly from one scope
map and depend on @punktfunk/host + effect as SHARED (hoisted) deps instead of
bundling — no per-plugin duplication. Additive: unscoped names still work.
Bumps @punktfunk/host to 0.1.1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regenerated api/openapi.json (host built on Linux) with the plugins tag —
registerPlugin/listPlugins/deregisterPlugin/getPluginUiCredential + the
PluginRegistration/PluginSummary/PluginUi/PluginUiPublic/UiCredential schemas —
and the SDK's generated client. Drift test + 44 mgmt/events host tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- package.json: drop private; point main/types/exports/bin at a tsc-built dist/;
add publishConfig (unom/npm registry), files, repo metadata, and the
MIT OR Apache-2.0 license; effect becomes a peerDependency (shared instance).
- tsconfig.build.json: emit dist/ JS + .d.ts (bun shebang preserved on the bin).
- .npmrc: map the @punktfunk scope to the registry (no token committed).
- sdk-publish.yml: publish on sdk-v* tags or manual dispatch, reusing the
REGISTRY_TOKEN secret; typecheck/test/build/tag-matches-version gate.
- README: Install section for consumers.
Verified: build green, frozen lockfile stable, bun publish --dry-run packs
@punktfunk/host@0.1.0 (dist + README only) to the unom registry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add sdk/examples/virtualhere-dualsense.ts — bind a real USB DualSense (shared
from the couch via VirtualHere USB-over-IP) to the host for the length of each
connection and release it after, for full gyro/touchpad/adaptive-trigger/USB-
rumble passthrough instead of the emulated pad. Brackets on client.connected/
disconnected and releases the pad on SIGTERM for a clean runner stop.
Document it in the Events & hooks page with a zero-code hooks.json variant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop Orval for the first-party @effect/openapi-generator (OpenAPI 3.1 ->
Effect Schema + a typed HttpClient client) and bump effect 3.19 ->
4.0.0-beta.98. Port the hand-written surfaces to the v4 API (Result over
Either, Context.Service, Codec, Literals/Union arrays, Stream/Schedule/
Effect renames). Transport (CA-pinning fetch) and the reconnecting SSE
source are kept intact.
Make the SDK approachable for non-Effect users:
- Add pf.api.* on the Promise facade: the generated client surfaced as
typed, Promise-native methods (await pf.api.listPairedClients()), so REST
calls are autocompleted and checked instead of stringly-typed
pf.request(method, path, body) + `as` casts. Zero-drift veneer over
make(httpClient), backed by the same pinning fetch. pf.request stays as
the untyped escape hatch.
- Re-tier examples into a 1-4 complexity ladder, rewritten onto pf.api.*
(the typed payloads caught a wrong `launch` shape in provider-sync);
the Effect example is labelled advanced. Add examples/ to tsconfig so
they are typechecked (stops rot).
typecheck + 19 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The optional supervision layer (RFC §8): one service runs everything in
<config_dir>/scripts/ plus installed punktfunk-plugin-* packages
(<config_dir>/plugins/node_modules/), as Effect fibers.
- Plugins (a definePlugin default export, either main shape) are
SUPERVISED: a failure restarts them with capped exponential backoff
(jittered, 1s→60s); a clean return completes them. The Effect shape
runs under the PunktfunkHost layer; the async-fn shape gets a facade
client whose close is scope-guaranteed.
- Bare scripts are one-shot: importing them is the run, no restart
(export a plugin to be supervised).
- Shutdown is STRUCTURAL: SIGINT/SIGTERM interrupt the whole fiber tree,
so Effect plugins' scoped finalizers run and clients close before
exit — the systemctl-stop story, and the reason the Effect plugin
shape exists at all.
- The sshd rule applies to unit files (world-writable → refused loudly);
cache-busted imports make restarts real; --list for inventory.
6 new bun tests (17 total green): discovery + refusal, both plugin
shapes against a mock host, crash→restart with backoff, one-shot
semantics, and finalizer-on-interrupt. Live-verified against a real
host: a supervised watcher plugin received library.changed through the
pinned tunnel, and SIGTERM shut the tree down structurally (exit 0).
Deferred to the packaging follow-up (release.yml is in flight in a
parallel session): the vendored-Bun deb/rpm/iss packages and the
host-log-ring tee (needs a host ingest endpoint); console page rides
the other console surfaces.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
External game-library providers become first-class (RFC §8): a plugin
computes its desired title list and PUTs it — the host owns the diff.
- CustomEntry gains `provider` + `external_id` (API-set only; never on
manual entries). GameEntry surfaces `provider` for console attribution
and the new `GET /library?provider=` filter.
- PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed
on the provider's `external_id` — host ids stay stable across syncs,
orphans drop, manual entries and other providers are never touched,
an empty array clears the set. Validated: provider id [a-z0-9._-]
(`manual` reserved), unique non-empty external_ids.
- DELETE /api/v1/library/provider/{p}: clean uninstall, returns the
removed count.
- Ownership is unambiguous both ways: manual CRUD now returns 409 for a
provider-owned entry (MutateOutcome::ProviderOwned) instead of letting
an edit be silently clobbered at the next sync.
- library.changed now carries the mutating source (`manual` or the
provider id) — hooks and the SDK filter on it.
- Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the
provider-plugin skeleton.
347 host tests green (pure reconcile: stable ids, orphan drop,
idempotence, bystanders untouched; name/payload validation; route 400s)
+ 11 SDK tests. Live-verified end to end THROUGH the SDK against a real
host: sync → filtered list → manual-delete 409 → re-sync with stable id
+ orphan drop → uninstall (removed=2), with three
library.changed(source=romm) events observed on the live stream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New top-level sdk/ package (RFC §7): a typed management-API client plus
the lifecycle event stream, built on Effect, two surfaces over one core:
- @punktfunk/host — the Promise facade front door: connect() resolves
URL/token/TLS pin from the host's own files (zero config on the box),
fails fast on bad credentials, pf.events.on() with typed callbacks
(exact kinds, domain.* prefixes, "*", "dropped", "unknown"),
pf.request() for the REST surface. Effect never required.
- @punktfunk/host/effect — the PunktfunkHost service + PunktfunkHostLive
layer, Stream-based events()/eventsRaw(), typed errors
(AuthError | ApiError | TransportError | VersionSkew — a 2xx that
fails its schema is a typed skew, not undefined later), and every
wire shape as an effect/Schema: REST generated via orval
client:'effect' from api/openapi.json (S3 spike: works well; the
text/event-stream payload is out of its reach), events hand-mirrored
from the host's snapshot-tested wire format as a kind-discriminated
union.
One reconnecting SSE core under both surfaces: spec-shaped parser,
exponential+jittered backoff (capped, resets after a healthy
connection), Last-Event-ID resume, 401 terminal. Default is LIVE tail
only — a fresh notify script must not re-fire on the host's replayed
ring (since: 0 opts into full replay).
TLS: the pin trusts exactly the host's self-signed identity cert
(chain-verified; hostname check waived — the cert is deliberately
CN-only for fingerprint pinning). Bun via fetch tls, Node via an undici
dispatcher (optionalDependency).
definePlugin() accepts both main shapes (async fn | Effect requiring
PunktfunkHost). Examples in both styles; README carries the compat
contract + systemd/Task Scheduler templates.
11 bun tests green (wire decode against the Rust snapshot strings,
SSE parser/reconnect/Last-Event-ID/401, both surfaces vs a mock host).
Live-verified against a real host on Bun AND Node through the pinned
loopback hop: connect → REST mutate → live event received → resume
cursor advanced; a wrong CA is rejected. npm publish + CI wiring
deferred (npm org = RFC open question 1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>