Commit Graph
5 Commits
Author SHA1 Message Date
CoffeeKnyte e5bf0155ad fix(playback): make revocation state converge and cutoffs credential-accurate
Five defects in revocation state and credential semantics. Lands after the
tracker-lifecycle batch on purpose: raising the over-cap TTL is only safe once
the count feeding it is trustworthy.

#13 -- a longer old revocation suppressed a newer cutoff. applyLocal kept or
replaced the WHOLE record by expiry, so when the existing revocation expired
later the new one was dropped entirely, including its newer RevokedAt. The
durable upsert did the same, with a comment documenting it as intentional.
RevokedAt is the user-kill CUTOFF, so this left a credential issued between the
two cutoffs valid -- a second admin kill after a user re-authenticates silently
failed to cut them. The two fields now merge independently: ExpiresAt stays
monotonic, RevokedAt advances to the later value, and reason follows the newer
cutoff. Both superseded comments are replaced rather than left contradicting the
code. Session-kind revocation still ignores RevokedAt, so the enforcer's
re-revoke cannot weaken a session kill.

Also fixed while here: Redis received the merged record but pub/sub published the
raw input one, so under pub/sub-only delivery (Redis down) an edge got the newer
short record without the older long expiry and lost monotonicity. Both now carry
the merged record.

#7 + M1 -- Postgres could indefinitely block the urgent Redis kill.
RevokeWithWarnings held the global opMu across all propagation, stripped the
caller's deadline with WithoutCancel, and did the durable Postgres upsert BEFORE
Redis, on a pool with no statement timeout. The local kill still applied, so
playback on that process was fine -- but edge propagation, pub/sub, the admin
response and every later revoke/unrevoke stalled behind the lock. Redis and
pub/sub now go first, and the detached context is bounded. WithoutCancel is kept
deliberately: propagation must outlive an aborted admin request.

opMu scope is deliberately NOT narrowed. mirrorToRedis is an unconditional SET
with no atomic merge, so same-process serialization is what stops an older value
overwriting a newer one; narrowing the lock would also let an unrevoke interleave
with a revoke's propagation. Bounding the context caps how long the lock can be
held, which is the actual reported harm. The remaining cross-replica race -- two
central replicas racing the same SET -- is documented, not half-fixed; it needs
A6's shared picture.

A2 / #6 -- a missed unrevoke got resurrected. In-memory tombstones already
existed, but being process-local they did not survive a restart or reach a
replica that missed the pub/sub event, so maintain's durable self-heal
re-Upserted the surviving entry and the ban returned. Tombstones are now durable,
via two nullable columns on stream_revocations rather than a second table: a
tombstone is a state of the same key, and it needs its own expiry horizon
separate from the revocation's. The upsert rejects a stale replica's write while
a tombstone is live but lets a genuinely newer revocation clear it, and warm
paths apply tombstones BEFORE revocations so an un-banned key cannot be restored
as a live kill. Tombstones are pruned on the same sweep, so the table cannot grow
without bound.

A1 / #3 -- over-cap kills reopened after 5 minutes while the token stayed
reconstructable for 24h. The TTL now derives from playback.MaxTokenTTL rather
than duplicating 24h, behind a validated setting.

Critically, the enforcer uses a revoke-if-absent path rather than re-revoking.
Expiry is monotonic and the enforcer re-evaluates every 30s, so a plain long TTL
would slide expiry forward by another full lifetime on every pass -- making a
wrong kill effectively permanent for as long as any stale record persisted, with
only an explicit unrevoke to recover it. Admin Revoke keeps its monotonic
behaviour; only the enforcer's own repeat kill is non-extending. The setting is
documented as affecting future revocations only, since monotonic expiry means it
cannot shorten one already issued.

A3 / #5 -- the user cutoff compared against a fresh time.Now() taken at request
entry, so a request from a pre-cutoff login could look post-cutoff and escape the
kill. The credential time is now the access token's iat.

Two deliberate choices worth stating. API-key credentials carry no issue time, so
they pass the zero time and, per IsRevoked's documented contract, are never
matched by a user cutoff: a user kill provably cannot cut an API-key-owned pour.
That is an accepted, logged, documented hole -- and strictly better than
time.Now(), which actively defeats the cutoff. And jellycompat uses the compat
session's CreatedAt rather than the bridged Silo token's iat, because that token
refreshes without a new Jellyfin login, so its iat would advance on refresh and
let a refreshed credential slip past a cutoff.

Stream tokens are now bound to their route: a token whose SessionID does not match
the URL's session_id is rejected with 403 instead of being silently ignored,
matching the reconstruction helper that already refused a different session.

Per-login logout cuts remain out of scope -- they need per-login identity in the
stream credential. S5 (the (sessionID, userID, startedAt) clump) is rejected as
ceremony now that A3 is the iat option rather than the generation model.

#12 (closing an RSS feed does not cut its current pour) is deferred: it needs a
namespaced revocation id that cannot collide with real session ids, that id
threaded onto public feed requests, and protection against a new feed inheriting
an old tombstone.

Part of #305.
2026-07-30 12:27:05 +00:00
e56a1b3e03 fix(api): throttle api_keys last_used_at writes in auth middleware (#381)
* fix(api): throttle api_keys last_used_at writes in auth middleware

Every API key request spawned a goroutine that ran an UPDATE on
api_keys, so a key driving HLS segments or a polling integration hit the
table with one write per request, and a stalled database could pile
those goroutines up without bound. The jellycompat authenticator already
guards this same write with a once-per-minute throttle per key; the main
middleware was missing it.

Bring the two in line. Track the last write per key ID and only launch
the update once a minute has passed, with a timeout on the background
write. The map is keyed by key ID so it stays bounded.

* fix(auth): bound API key last-used throttling

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-20 11:20:50 -04:00
a0f7810481 fix(web): show admin chrome only on the admin account's primary profile (#131)
* fix(web): show admin chrome only on the admin account's primary profile

The top-right ServerActivity indicator and the sidebar Admin section were
gated on the account-level role alone, so every profile on an admin
account — including child profiles — saw admin system notifications and
the indicator polled four admin endpoints on their behalf. Gate both on
the active profile being the household primary, matching the existing
is_primary idiom in SettingsLayout and the server-side quota exemption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): resolve active profile via useCurrentProfile in admin route gates

RequireAdmin/RequirePrimaryOrAdmin read the profile from useAuth(), but the
admin chrome (AppSidebar, Layout) gates on useCurrentProfile(), which resolves
the selected profile. Use the same source in the route gates so the redirect
and the visible admin UI can never disagree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web,api): centralize acting-admin policy and enforce it server-side

Address code-review findings on the primary-profile admin gate:

- Add isActingAdmin to web/src/lib/permissions.ts as the single
  client-side definition of the policy (admin role + primary or no
  profile), with a useIsActingAdmin hook on top. Route gates, sidebar,
  Layout, and realtime channel gating all use it now, so the gate and
  the chrome can no longer disagree on null-profile handling.
- Convert the admin-gated surfaces the original change missed
  (MediaItemMenu, EditMetadataDialog images tab, AddToCollectionDialog,
  MarkerEditor, theme CatalogBrowser, PersonDetail, SettingsLayout,
  ItemDetail content pages) so an admin on a non-primary profile is a
  regular viewer everywhere, not just in the sidebar.
- Make the role-derived permission bypass (metadata curation, marker
  edit) follow the same policy on both client and server.
- Enforce the policy server-side: RequireActingAdmin middleware refuses
  admin routes when the request declares a non-primary profile via
  X-Profile-Id, and the metadata-curation middleware holds admins on
  non-primary profiles to explicitly assigned permissions.
- Stop spreading the profiles query result from useCurrentProfile so
  route gates only re-render when the resolved profile changes, and
  make it safe outside AuthProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web,api): fail closed on unresolved profiles in acting-admin policy

Address review feedback on the acting-admin gate:

- Server: actingAdminAllowed now denies when the declared profile cannot
  be resolved to one of the caller's profiles, so a bogus X-Profile-Id
  can no longer restore admin powers to a non-primary session.
- Client: useIsActingAdmin returns false while a selected profile id has
  not yet resolved (e.g. hard refresh before the profiles query
  returns), instead of briefly treating it as "no profile selected".
  useCurrentProfile exposes hasSelectedProfile to make that state
  distinguishable.
- hasPermission/canCurateMetadata/canEditMarkers now require the profile
  argument (resolved profile or explicit null), so a missed call site
  fails the typecheck instead of silently restoring the admin bypass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-11 10:41:52 -04:00
QuickandClaude Fable 5 ed4cebf3ba feat(ai): per-user transcription quota for subtitle ASR jobs
Cap how many Whisper transcription jobs each user account can start per
rolling window (day/week/month), configurable from admin settings. The
player modal shows remaining usage and the server returns 429 with
details when the limit is hit.

Enforcement is atomic with the job insert (per-user advisory lock, same
pattern as media-request quotas), so concurrent requests cannot race
past the limit. Failed/cancelled jobs that never produced transcription
work are refunded. Exemption applies to the admin account's primary
profile only; other profiles on an admin account stay subject to the
quota. A partial index covers the quota count, a malformed quota
setting row degrades to "no quota" instead of blocking startup, and the
period vocabulary and admin-role predicate are each defined once
(ai.ValidQuotaPeriod, apimw.IsAdmin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:02:42 -04:00
Silo Server Migration c085b12fd1 Initial Silo migration 2026-05-22 23:26:56 -04:00