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>