Commit Graph
22 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 a8469e0642 fix(jellyfin): authenticate once, so the token survives to the password change
configure-auth.sh pins DeviceId="infinito-deploy" in CLIENT_HDR, and realign_admin_password authenticated twice with it: once for the access token, once for the user id. Jellyfin replaces the session bound to a device id when a new one is created, so the first token was already revoked when POST /Users/{id}/Password presented it. Run 33828309745 job 100890535194 shows the result as fifteen times "curl: (22) The requested URL returned error: 401", with the deploy otherwise complete (ok=2830 failed=1). get_user_id arrived with 1bf28bb2bf and carried the second call in from the start.

One AuthenticateByName response now yields both fields, read through token_of and user_id_of so neither expression is spelled twice.

Redeeming the reset pin clears the stored password: in that job forty authentications with the deployed password were denied, and the next one, with the pin string as the password, succeeded. Every exit after the redemption therefore states that the administrator is currently without one, and the two silent returns around ForgotPassword now log why they gave up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:54:31 +02:00
kevinveenbirkenbachandClaude Opus 5 791eee7c9f refactor(mcp)!: prove the endpoint contract with a CLI test, not a deploy probe
The contract check ran as an Ansible task in the middle of every provider deploy, and the two client checks ran the same way. A test that is part of the deploy cannot be run without deploying, cannot be re-run on its own, and reports its verdict as a deploy failure. Twenty-seven task files lose 491 lines to this.

It is now what it always was. Each provider ships files/test/test.sh, templates/test.env.j2 and meta/tests.yml, and test-e2e-cli stages one shared harness beside it: run.sh drives contract.sh in a throwaway container on the provider's own network, then isolation.sh for adapter-fronted providers. The read probe each provider offers moved from the calling task into meta/mcp.yml next to the allowlist it must satisfy, and the schema now rejects a read probe naming a tool outside that allowlist.

The bearer the test presents is the one a client resolves through mcp.credential, via the new mcp_client_bearer lookup over resolve_credential, which moved to utils.roles.applications.mcp so it is the same function mcp_servers renders every client connection with. The probe presented whatever the calling role passed, which for the fifteen adapter-fronted providers is a different secret than any client holds.

Sidecar isolation is read from the engine rather than measured inside the container, so it no longer needs a delegation to the node holding the task: attachment is compared by network name, which drops the gwbridge special case, and a swarm worker says it cannot measure instead of skipping silently.

Retrying is bounded by wall clock against MCP_DEADLINE_SECONDS, which scales through the same lookup('timeout') as the harness budget it has to stay under. A flag that is neither true nor false aborts rather than reading as off.

mcp-provider-probe becomes mcp-provider-cli-test: an enabled provider must ship the test rather than include the task. mcp-probe-bearer becomes mcp-adapter-bearer, tying ADAPTER_BEARER to the declared credential key now that no probe include carries it. A new integration test renders the shared env template for every provider through Ansible's own templar, so a later change to it breaks visibly instead of poisoning twenty deploys quietly.

Hermes and OpenClaw keep their client-side proof as CLI tests of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:46:26 +02:00
kevinveenbirkenbachandClaude Opus 5 c94a0a0b5d test(mcp): ask the ten enabled surfaces the hostile question
Nine roles enabled an MCP surface without a browser-side spec, so nothing
ever asked what the endpoint answers a caller with no credential at all.
Lift gitea's assertion into two shared registrars and register them per
role, and give each role the MCP_SERVICE_ENABLED flag they gate on.

mcpEndpointUrl now takes the base URL rather than reading APP_BASE_URL
unconditionally: jellyfin and zammad export their own base variable, and
declaring APP_BASE_URL for them would un-skip the shared guest persona,
which collapses itself on that variable being absent.

The env-gate scanner follows require("./x") into the shared harness so a
gate declared there counts as consumed, and strips block comments first
so service-gating.js's own doc examples do not credit EMAIL, SSO and OICD
to every role that imports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:06:19 +02:00
kevinveenbirkenbachandClaude Opus 5 f07b01025d fix(jellyfin): read the reset pin where Jellyfin writes it
1bf28bb2bf added realign_admin_password for exactly the case CI produces -- an
existing server whose credential rotated between the two passes -- and then looked
for the pin file one directory too deep. The path had never run, so nothing caught
it.

Jellyfin#0 of run 33445831564 is that path's first execution. The sync deploy at
04:33 drove the wizard green; the async deploy at 06:03 met the same persisted
/config with a new password. /Startup/* is closed on a provisioned server, so the
wizard attempts produced exactly 40 x `curl: (22) ... error: 401`, and the realign
took over.

Its first step worked. The stderr block holds exactly 40 401s and no 41st, so
POST /Users/ForgotPassword returned 200; and the client IP 192.168.215.1 sits inside
the server's own `Used LAN subnets`, so `isInNetwork` held and no
"initiated from outside the local network" warning was logged -- two other [WRN]
lines in the same log prove warnings are captured. The file was written.

It was written to ProgramDataPath. DefaultPasswordResetProvider.cs:38-39 anchors
`passwordreset` at `ApplicationPaths.ProgramDataPath`, and the failing container's
own startup line reads `Main: Program data path: /config`. /config/data is the
DataPath, the SQLite directory the same log reports as a separate `Storage path`.
The glob never matched, `cat` fell into 2>/dev/null, pin came back empty, and the
function bailed with its own "wrote no pin file" -- an accurate message pointing at
the wrong culprit.

1bf28bb2bf's commit message names the right anchor
("DefaultPasswordResetProvider:100 ProgramDataPath/passwordreset<md5>.json"). Only
the shell line disagreed with it.

Line 47 was the only /config/data in the file; :102, :104, :110, :168 and
meta/volumes.yml:6 all use /config. The filename is MD5 of the uppercased username,
so exactly one file matches and head -n1 stays harmless.

Nothing is masked: both error branches stay, and no retry count or timeout is
touched. The steps after it are already correct against v10.11.11 --
DefaultPasswordResetProvider.cs:77 `ChangePassword(resetUser.Id, pin)` makes the pin
the password, which is what get_token and the follow-up password change assume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 09:59:01 +02:00
kevinveenbirkenbachandClaude Opus 5 1bf28bb2bf fix(jellyfin): realign the administrator password the wizard can no longer set
seed_admin_and_get_token only ever drove the startup wizard, and that wizard works
exactly once: on a server that is not yet provisioned. After the first deploy the
/Startup/* endpoints are closed and the administrator carries whatever password the
inventory held at install time, so a rotated credential leaves the account
unreachable. The CI rotates every credential between the sync and the async pass,
which is why job 99275699298 spent its 40 attempts on

    curl: (22) The requested URL returned error: 401

and then failed with the script's own diagnosis: "the administrator cannot
authenticate after wizard retries; the deployed password does not match the running
server". The script named the cause correctly and had no way back.

realign_admin_password runs only after the wizard loop is exhausted and uses
Jellyfin's documented local recovery. Read from the server's own source rather than
assumed:

  UserController.ForgotPassword          writes the pin file only when the request
                                         is local (IsLocal || IsInLocalNetwork)
  DefaultPasswordResetProvider:100       ProgramDataPath/passwordreset<md5>.json,
                                         serialized without options, so key "Pin"
  DefaultPasswordResetProvider:77        ChangePassword(resetUser.Id, pin)

That last line is the one that matters: redeeming the pin sets the password TO the
pin, it does not clear it. The pin is therefore both the credential to authenticate
with afterwards and the CurrentPw of the change that follows. A first draft logged
in with an empty password and would have failed on every run while passing every
lint.

Two shell corrections came out of the same pass. get_token now takes the password as
an argument, because reading it from the environment invited
`JELLYFIN_ADMIN_PASSWORD="" get_token`, where an assignment prefixed to a function
call can outlive the call. And the three captured values now guard with
`x="$(cmd)" || x=""` instead of one of them carrying `|| true` and the others none:
under set -euo pipefail the unguarded assignments aborted the script before the
emptiness check below them could report anything.

Worst case is the status quo: nothing here runs unless the deploy was already lost.

Same shape as 82452789c9 did for the homeassistant owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:07:06 +02:00
kevinveenbirkenbachandClaude Opus 5 dfe7a7fdd4 refactor(roles): sort role scripts into per-language folders
The merged branch moved every role-local script out of files/ into a folder
named for its language (files/python/, files/php/, files/shell/), but the
moves lost their staging and never landed. HEAD still carried the old paths
while the worktree had the new ones.

Move the 24 remaining scripts and update the six places that resolve them:
the mcp-adapter instance/isolation/probe tasks, the litellm key task, the
adapter README and the endpoint-path lint. Every changed line in those
files is a path.

Also drop roles/sys-svc-compose/files/swarm/stack_ready.sh, a stale
duplicate of the copy already tracked under files/shell/swarm/.

The added comments are the branch's own `nocheck: mirrored-unit-test`
directives, which state why a script whose measurement only exists inside
its container cannot have a mirrored unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:00:59 +02:00
kevinveenbirkenbachandClaude Opus 5 7162b7bf4c Merge fork/feature/svc-net-tor into feature/ai-agent-employees
834 commits from the Tor onion-service branch against 299 of ours, from merge
base 7b646bdece: 127 conflicted files, 3506 files changed.

The two sides are largely orthogonal. Theirs adds svc-net-tor and a `tor` service
across the role tree, plus two sweeping refactors: shipped scripts sorted by
language (files/ruby/, files/python/, files/shell/, scripts/lint/*.sh) and the
unit suite moved under tests/unit/python/. Ours is requirement 036, the native AI
gateway, plus the MCP role work. Most conflicts were both sides appending to the
same service map, resolved as unions.

Resolutions that were not unions:

Credentials moved namespace. Theirs renamed meta/schema.yml to meta/secrets.yml
and re-addressed credentials as secrets.credentials.<name>. Git carried our
litellm_api_key blocks through the rename, but every consumer we added still read
'credentials.<name>', which no longer resolves. All 45 call sites are migrated.

plugins/filter/container_volumes.py is deleted by theirs and relocated to
plugins/lookup/container_volumes.py, where it carries the same tmpfs defect our
f85996b43 had just fixed: a hard-coded {"size": ...} that silently drops a per
mount `mode`. The fix is re-applied in the relocated file, as that commit's merge
note anticipated.

compose.yml keeps only INFINITO_PLAYWRIGHT_REPORTS_BASE_DIR. Theirs also forwards
INFINITO_PLAYWRIGHT_STAGE_BASE_DIR; that line is deliberately dropped, per the
analysis recorded in 45804e9ad -- STAGE has a drift-proof role-vars fallback,
while forwarding it promotes a setdefault-pinnable .env value above that SPOT.

tasks/stages/01_constructor.yml takes their include, ./tasks/utils/setup/group.yml,
because their refactor removed tasks/groups/*-roles.yml, leaving our include
target nonexistent. Our task name is kept, since svc-virt is in the loop.

Makefile swarm-zombie is a union: their validate plus teardown, then our
lab_subnet cleanup, which their side dropped while leaving lab_subnet.sh as the
only orphaned reference in the file.

01_bootstrap.sh keeps our deterministic --ip pinning, which distinguishes an
already-attached node from a real failure, over their `|| true`.

Their language-sorted layout orphaned references our side still held: zammad's
two scripts/apply/*.rb, jenkins' java-with-ca-trust.sh, and the notify topics
their handlers renamed to hyphenated form. All corrected.

roles/sys-svc-compose/files/shell/swarm/stack_ready.sh gains our latched-update
block: paused, rollback_paused, rollback_started and rollback_completed exit 2
rather than polling a state that cannot resolve without another deploy.

config_from_run is unioned to (title, logged, jobs); the production caller in
trigger/__main__.py now passes jobs, without which our suite suppression would
have been inert.

READMEs are regenerated rather than hand-merged: 15 LiteLLM rows and 91 Tor rows
now derive from the merged metadata, which is itself evidence the meta merge is
coherent.

Verified: 5161 unit tests pass. The single failure, test_query.py TestSortSpec,
also fails on fork/feature/svc-net-tor at b51d2f9919, measured in a baseline
worktree -- so this merge introduces no new unit-test failure. Two modules are
excluded from that run because `baudolo`, a dependency their branch introduces,
is not installed on this host.
Not verified: no deploy has run against the merged tree, and make quality has not
been run end to end here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:32:30 +02:00
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00
kevinveenbirkenbachandClaude Opus 5 80ee933260 fix(web-app-jellyfin): converge the MCP password instead of trusting a token
The provisioner probed a stored token first, so a token that outlived a
password change kept the run green while the deployed password no longer
authenticated. Authenticate with the inventory password first and consult
the stored token only once that succeeds; when it fails, converge the
password onto the existing user.

The write targets /Users/Password?userId=<id>: omitting the query
parameter retargets the caller's own account, and an administrator acting
on another user is why no current password is required. Accept 200 as
well as 204, matching the sibling calls; demanding 204 alone would have
failed a successful convergence.

The repair branch is only reached when authentication failed and always
writes, so it now reports CHANGED. It previously printed UNCHANGED
whenever the user already existed, which told Ansible nothing had changed
while a credential had just been rotated.

configure-auth.sh no longer continues past an administrator it cannot
authenticate. Both call sites logged a warning and carried on, finishing
green with SSO and LDAP silently unconfigured.

Adds the role's first unit tests, covering token reuse, token staleness,
the convergence write and its rejection path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:26:35 +02:00
kevinveenbirkenbach 1c03f638be feat(mcp): serve MCP from a locked-down sidecar for ten more applications
Ten applications with no MCP server of their own get one without a line of code
inside them: qdrant, checkmk, fider, jellyfin, listmonk, pretix, prometheus,
snipe-it, zammad and libretranslate.

The sidecar is confined rather than trusted. It runs read-only, drops every
capability, forbids privilege escalation, publishes no port and joins only its
own provider's network, carrying neither that provider's alias nor the consumer
networks the provider itself joined. Its tool surface is a JSON contract, not
code: which tools exist, which upstream path each maps to and what shape the
arguments take. Adding a tool is a line of JSON, not a new image.

The image is built locally from the audited adapter source with the base image
as a build argument, so all ten share one reviewed build instead of ten upstream
dependencies. Each role provisions its own read-only identity in the language
its application speaks, and every adapter include is guarded by the role's MCP
flag, so switching it off leaves no container behind.
2026-08-06 20:44:16 +02:00
kevinveenbirkenbachandClaude Fable 5 d88f272f9f Merge branch 'feature/swarm-nfs' into feature/svc-net-tor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:31:08 +02:00
kevinveenbirkenbachandClaude Fable 5 c505ce7466 test(lint): forbid unmarked '|| true' in shell scripts; grandfather the corpus
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:39:16 +02:00
kevinveenbirkenbachandClaude Opus 4.8 07c6ad8e7c test(e2e): scale every nav click + timeout literal for onion targets
Over Tor the redirect chain behind a login/logout/OIDC click routinely exceeds Playwright's fixed 30s action timeout, so the click throws before the flow's scaled waits run. Sweep all role Playwright suites and the shared persona helpers: give every navigation/auth-transition click an onion-scaled timeout (.click({ timeout: resolveTimeout(30_000) })) and route the remaining raw millisecond literals (timeout options, waitForTimeout, sleep-promise setTimeout, positional wait args) through resolveTimeout. Local UI clicks (menus, toggles, modal dismissals, DOM clicks inside evaluate) stay untouched.

Two new lints keep it that way: test_no_raw_timeout_literals forbids raw numeric timeout literals in Playwright specs (opt-out: nocheck: raw-timeout), test_persona_clicks_scaled requires every click in the shared persona auth flows to carry a resolveTimeout timeout (opt-out: nocheck: unscaled-click).

Verified: node --check on all 133 changed JS files, all 13 playwright lints green, make autoformat clean, make test green (2745+196+163+4 tests OK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:35:53 +02:00
kevinveenbirkenbachandClaude Opus 4.8 4c91df5de7 test(e2e): forbid raw page.goto, migrate all specs to Tor-resilient gotoOnion
New lint test_no_raw_goto.py forbids raw `page.goto(...)` in role playwright specs + persona flows (the gotoOnion wrapper in personas/utils is exempt) so navigation retries transient Tor-transport errors over onion. Migrates every raw call across ~75 roles (343 sites) to `gotoOnion(page, ...)` and teaches gotoOnion to detect relative onion URLs (`isOnionCanonical`). gotoOnion is a behaviour-identical drop-in on clearnet (single attempt, same return). Verified: no-raw-goto lint green, eslint 0 errors, make test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:59:38 +02:00
kevinveenbirkenbachandClaude Opus 4.8 35dee215c8 test(playwright): route all timeouts through resolveTimeout for onion-compat
Add the global TIMEOUT_FACTOR and a shared timeouts.js helper: resolveTimeout(base) scales by TIMEOUT_FACTOR and, over a .onion target, an extra multiplier, so Tor's per-request latency no longer trips hard-coded timeouts. Migrate every raw timeout in the specs (timeout:/timeout=/setTimeout/waitForTimeout) to resolveTimeout, stage the helper in all three staging paths, and add test_no_raw_timeouts to forbid raw numeric timeouts. Clearnet behaviour is unchanged (factor 1: resolveTimeout(N) === N).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:38:06 +02:00
kevinveenbirkenbachandClaude Fable 5 15f95f0725 fix(curl): timeout-hardened curl SPOT filter, lint, and fleet-wide rollout
A curl against an accepted-but-silent peer hangs forever: task retries
never fire because the first attempt never returns, and the CI job dies
at the 6h runner cut (proven by the mailu admin-API hang that took the
suitecrm/peertube swarm legs down). The new `curl` filter renders the
hardened base command ("{{ 30 | curl }}" -> curl -s --connect-timeout 5
--max-time 30); scaling composes with lookup('timeout').

test_curl_via_filter enforces it: bare curl invocations in role tasks,
templates and shipped scripts fail the lint with a pointer to the
filter. Plain .sh files, which cannot render Jinja, pass by pinning
--max-time inline; Docker healthchecks are exempt (their timeout field
caps them); `# nocheck: curl-timeout` opts out (catalogued in
suppression.md).

Convert all 34 existing invocations: mailu (replacing the short-lived
MAILU_CURL var), elasticsearch, checkmk, yourls (300s for the
schema-creating installer POST), the telegram alert script, and the
plain scripts of jellyfin, svc-runner, apt_repo, moodle (600s tarball)
and the mattermost provisioner. Mailu's user/token tasks additionally
carry task-level timeouts so a wedged container exec cannot stall the
play.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:37:28 +02:00
kevinveenbirkenbachandClaude Fable 5 b7c2f7d372 fix(web-app-jellyfin): provision auth on the resolved node in swarm
configure-auth.sh ran `container exec/restart jellyfin` with the bare compose
name, which does not exist under docker stack deploy. Split the script into
install and configure phases (JELLYFIN_PHASE), target the runtime-resolved CID
(JELLYFIN_CT), and drive restarts from the task (container restart in compose,
container service update --force in swarm). The phase flow moves into
01a_auth.yml so the parent block stays within the size limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:18:08 +02:00
kevinveenbirkenbach 7957eabadc autoformat 2026-07-04 20:29:36 +02:00
kevinveenbirkenbach 9ee4cd0fb9 Merge branch 'main' of github.com:infinito-nexus/core into feature/swarm-nfs
# Conflicts:
#	docs/agents/action/iteration/compose.md
#	roles/svc-db-openldap/vars/main.yml
#	roles/svc-runner/defaults/main.yml
#	roles/svc-runner/tasks/02_cleanup_instance.yml
#	roles/web-app-discourse/meta/server.yml
#	roles/web-app-mailu/tasks/01_core.yml
#	roles/web-app-odoo/tasks/main.yml
2026-07-04 18:16:12 +02:00
kevinveenbirkenbachandClaude Opus 4.8 2f88f0a8b7 refactor(web-app-jellyfin): model auth plugins as meta/addons with per-addon specs
Declare the LDAP and SSO auth plugins as proper meta/addons entries
(mechanism/source/version, github-releases update monitoring, url/tag/
md5/guid under config:) and resolve the install parameters in vars from
the addon config instead of hardcoding them.

Move biber's login journeys into the mandated per-addon Playwright specs:
ldap-authentication tests the LDAP manual login, sso-authentication the
OIDC round-trip. Because a Jellyfin user binds to exactly one auth
provider, the LDAP spec skips while SSO is active — the ldap-only matrix
variant owns that path — so a single test user suffices and the specs
stay parallel-safe. playwright.spec.js keeps guest + native admin;
biber's generic persona is opted out via PERSONA_BIBER_BLOCKED.

Also fill the optional meta/info.yml fields (homepage, video, display).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:18:43 +02:00
kevinveenbirkenbachandClaude Opus 4.8 5b5c15f5c5 feat(web-app-jellyfin): Jellyfin media server with LDAP + OIDC auth
Bring the full matrix (full-auth / no-auth / ldap-only) green:

- healthcheck: use curl (the image ships no wget)
- install the LDAP plugin from its GitHub release (repo.jellyfin.org is
  unreachable from the deploy network); version/url/md5 live in
  meta/addons/ldap-authentication.yml and reach the script via env
- configure the SSO/OIDC provider through the plugin config API as JSON
  (order-agnostic) instead of hand-written XML, which silently produced
  an empty provider map; set DisablePushedAuthorization + https
  SchemeOverride so Keycloak accepts the redirect_uri
- inject the SSO login button via the branding login-disclaimer + CustomCss
- seed the break-glass admin unconditionally and retry the first-run
  wizard until the admin token resolves (fixes the no-auth variant race)
- Playwright: role-based authenticated-chrome selector for JF 10.11,
  localStorage-clearing logout, and LDAP-before-OIDC ordering (a Jellyfin
  user binds to exactly one auth provider)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:15:12 +02:00
kevinveenbirkenbachandClaude Opus 4.8 b57ce6c8f6 feat(web-app-jellyfin): add Jellyfin role with LDAP + OIDC SSO plugins
Implements requirement 030: web-app-jellyfin deploying jellyfin/jellyfin
10.11.11 behind sys-svc-proxy. No external DB (internal SQLite); /config,
/cache, /media named volumes; CPU transcoding. Jellyfin has no native SSO, so
auth is plugin-based (operator chose BOTH): jellyfin-plugin-ldapauth (central
OpenLDAP, all clients incl. native apps) + 9p4/jellyfin-plugin-sso (Keycloak
OIDC, web one-click). files/configure-auth.sh completes the first-run /Startup
wizard (seeds break-glass admin), installs both plugins via Jellyfin's own
/Packages installer, writes the plugin configs, and restarts.

Schemas source-verified against the pinned upstream: Jellyfin v10.11.11, LDAP
plugin v23 (GUID 958aad66-..., flat XmlSerializer config), SSO plugin v4.0.0.3
(OidConfig fields). Honest caveats: the SSO OidConfigs is a SerializableDictionary
(XML shape best-effort), plus plugin ABI compat + the SPA Playwright selectors
are live-deploy-validation items (env-blocked). Each config file is XML-well-
formedness checked before reload.

Validation: make autoformat clean; make test green (lint, test-lint, test-unit,
test-integration 190, test-external).

Refs: docs/requirements/030-web-app-jellyfin.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 03:23:10 +02:00