Compare commits

...
Author SHA1 Message Date
Quick 5fecb07c4f fix(jellycompat): expose subtitle management 2026-06-26 12:11:02 -04:00
QuickandGitHub b21c3eea37 Merge pull request #221 from Silo-Server/codex/meilisearch-short-title-search
fix(search): tighten short meilisearch title queries
2026-06-26 10:53:11 -04:00
Quick f57c95e813 fix(search): update default semantic ratio for meilisearch settings 2026-06-26 10:47:02 -04:00
Quick a181586139 fix(search): keep meilisearch active during pending sync 2026-06-26 10:46:42 -04:00
Quick dd2784c0ee fix(search): tighten short meilisearch title queries 2026-06-26 10:13:15 -04:00
QuickandGitHub 4473f8c60b Merge pull request #220 from Silo-Server/codex/search-provider-interface
feat(search): add provider interface with initial Meilisearch support
2026-06-26 09:32:53 -04:00
Quick 125e94b429 fix(search): allow zero meilisearch task uid 2026-06-26 09:29:40 -04:00
QuickandClaude Opus 4.8 2a943550d4 fix(search): fold semantic toggle into catalog index schema version
catalogSearchMeilisearchSchemaVersion hashed the embedder, canonical dimensions, and index types but not SemanticEnabled. Because attachDocumentVectors omits _vectors when semantic is off, toggling semantic on without a full rebuild left previously indexed (and incrementally synced) documents without vectors while the Postgres coverage gate still reported the type "ready" — silently degrading hybrid ranking until a manual rebuild.

Folding SemanticEnabled into the schema version makes a toggle diverge from the stored version, so the provider falls back to keyword, sync skips, the admin status surfaces the schema mismatch, and a rebuild is required to restore hybrid — consistent with how embedder and index-type changes already behave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:43:09 -04:00
QuickandClaude Opus 4.8 6b45cfd855 fix(scanner): track podcast show audio paths during folder reconcile
Extract listPodcastShowAudioFiles and record each show's audio paths in
seenPaths during ScanPodcastFolder so reconciliation does not treat
still-present episodes as removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:34:18 -04:00
QuickandClaude Opus 4.8 ed6c084c68 feat(search): gate index events by active provider and harden rebuild reconcile
Completes the search-provider-interface wiring that the catalog hardening
commits already call into:

- Skip the transactional search-index-event write path when Meilisearch is
  not the active provider (ItemRepository.WithActiveSearchProvider /
  SearchIndexEventRepository.disabledByActiveProvider).
- Dead-letter catalog_search_index_events after 10 attempts instead of
  retrying forever.
- Track the rebuild high-water mark (MaxEventID / MarkProcessedThrough) and
  persist last_processed_event_id in UpdateStateAfterRebuild so a rebuild
  reconciles events enqueued during the rebuild.
- Validate (read-only) the embedding lock when embedding a search query
  instead of establishing/mutating it.
- Surface total_exact on the legacy /items browse response.
- Wire the active catalog search provider into the scanner and item repo at
  startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:34:04 -04:00
QuickandClaude Opus 4.8 91b5105f3c docs(search): add hybrid semantic search hardening plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:24:14 -04:00
QuickandClaude Opus 4.8 7080491f89 feat(web): surface semantic coverage and embedder capability in search admin
Render the new `semantic` block from the admin catalog-search status
endpoint in the Search Settings page: semantic readiness (with
disabled reason), overall vector coverage %, coverage-updated
timestamp, embedder capability, and a compact per-type coverage list.
Reuses the existing StatusRow/FieldGroup/Badge patterns and adds a
formatPercent helper; keeps the existing Vectorized Documents row.

The TS `semantic?` field is optional and the whole block is guarded so
a new frontend talking to an older backend during rollout degrades
gracefully.

Part of the hybrid-semantic-search hardening work (Task 8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:10:33 -04:00
QuickandClaude Opus 4.8 13ac4b753c perf(recommendations): split embedding backfill into cheap and text-stale passes
EmbedAll previously ran the full text-staleness CTE on every page, paying
five item_people LATERAL joins per eligible row just to detect whether an
item's canonical text had drifted - even when the real work was embedding
brand-new (missing) items during active backfill.

Restructure EmbedAll into two passes:

- Pass 1 (cheap): drain missing/model-stale items via the repurposed
  ItemsNeedingEmbedding query (single LEFT JOIN, no LATERAL), paged by a
  content_id cursor so a failed/skipped item is retried next run instead of
  stalling the page.
- Pass 2 (expensive): only once Pass 1 fully drains, run one bounded
  ListEmbeddingTextCandidates scan (LIMIT embeddingTextStaleQuotaPerRun=200)
  to re-embed text-drifted items. Re-embedding refreshes canonical_text, so
  handled rows drop out next run - no Pass 2 cursor needed.

Coverage-first tradeoff (documented in EmbedAll): under steady state Pass 1
drains every run so text-stale items stay fresh; only under pathological
continuous heavy ingest does Pass 2 get skipped, deliberately prioritizing
covering new items over re-embedding changed ones.

Supporting changes:
- ItemsNeedingEmbedding gains an afterID cursor + ORDER BY; SQL extracted to
  buildItemsNeedingEmbeddingSQL for a cheap-shape unit test (no item_people /
  LATERAL).
- Add an `embedder` interface seam (Engine.embClient) so EmbedAll is testable
  with a fake; *embeddings.Client still satisfies it.
- Extract embedBatch to DRY both passes, preserving quota/billing early-return,
  single-item fallback, ensureEmbeddingLock-before-upsert, and skip-on-store-error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:02:20 -04:00
QuickandClaude Opus 4.8 a5ef464d95 feat(search): validate embedder capability and expose semantic status
Add a rate-limited Meilisearch embedder capability check and surface a
semantic-coverage block in the admin search status.

Capability validation confirms the active index declares the configured
embedder with source="userProvided" and dimensions matching the canonical
embedding dimension, then runs a unit-vector hybrid probe to confirm the
index accepts the request shape. The whole path (GetSettings + evaluate +
probe) is cached for 5m and NEVER trips the circuit, marks a fallback, or
modifies unhealthyUntil, so a misconfigured embedder cannot take keyword
search down. The pure evaluator (evaluateEmbedderSettings) is unit-tested
across missing/wrong-source/wrong-dimensions/ok with distinct reasons.

The admin status gains a Semantic block read entirely from the in-memory
coverage snapshot (Snapshot + CoverageReady) with no new DB query in
Status(); document_count/vector_document_count are unchanged (additive
only). buildSemanticStatus is pure and unit-tested (nil snapshot and a
populated, deterministically-sorted snapshot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:43:21 -04:00
QuickandClaude Opus 4.8 dadd6f4769 feat(search): surface search diagnostics on catalog responses
Add per-query search observability to /api/v1/catalog. The internal
CatalogSearchResult now carries Mode + SemanticUsed alongside the existing
Provider + FallbackReason; all four are plumbed through CatalogResult (which
previously dropped them in resolveDirectSearchSource) and exposed as an
additive, omitempty search_diagnostics object on the catalog response.

Two correctness points:
- For Meilisearch, Mode/SemanticUsed are derived from the POST-downgrade
  request (baseSearchReq.Hybrid != nil) at the end of searchMeilisearch, so a
  hybrid->keyword self-downgrade reports mode="keyword", semantic_used=false.
- search_diagnostics is emitted iff a provider search actually ran
  (result.Provider != ""), which naturally omits browse, preview,
  non-relevance-sort q=, and group=work paths (none set Provider).

API additive-only: existing response fields keep declaration order and stay
byte-stable; only the new omitempty field is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:32:30 -04:00
QuickandClaude Opus 4.8 4cc92c1579 feat(search): gate hybrid on vector coverage readiness
Wire the semantic-coverage building blocks together so the Meilisearch
provider only goes hybrid once the active embedding model covers enough of
the requested item types.

- Add Coverage SemanticCoverageGate to MeilisearchProviderConfig and consult
  it in buildMeilisearchSearchRequest after the wordcount check; when
  not-ready, record "semantic_not_ready: <reason>" as a diagnostic fallback
  and stay keyword-only (shouldUseSemanticSearch's bool contract unchanged).
- Build and own a semanticCoverageTracker in
  NewCatalogSearchServiceFromSettings, but only when semantic is enabled and
  a real pool is present. Derive the model provider via a panic-safe comma-ok
  helper (semanticModelProvider) so a nil or non-implementing vectorizer
  degrades to a not-ready gate instead of asserting a nil interface.
- Add StartCoverageRefresh and call it from NewRouter on deps.AppContext so
  the refresher runs for the process lifetime.

Coverage == nil preserves today's behavior exactly; existing hybrid and
SkipTotal tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:17:47 -04:00
QuickandClaude Opus 4.8 f40611409d feat(search): semantic coverage tracker with hysteresis gate
Add the in-memory coverage gate the search hot path will consult to
decide whether semantic results may be served for a set of item types.

The tracker reads an atomic.Pointer snapshot with no DB and no lock on
the read path, refreshes single-flight under a mutex on a 2-minute
ticker, and applies per-type hysteresis (enable 0.90 / disable 0.80,
holding the previous latch inside the band) to avoid flapping. It
collapses to not-ready when the active embedding model changes so a
stale latch cannot leak across models, and fails safe: a not-yet-
computed snapshot reports not-ready (never panics), and a model-lookup
or count-query error retains the last-good snapshot instead of
publishing zeros.

Refresh counts through the existing catalogSemanticCoverageByType via an
injectable fetch seam, so the behavior is unit-testable with canned
counts and a fake model provider (no pgx.Rows faking). Reuses the
Task-1 coverageQuerier and extends catalogTypeCoverage with Ratio/Ready,
filled by the pure computeCoverageSnapshot helper. Adds the
SemanticCoverageGate interface beside the other provider interfaces;
Task 4 wires the gate and starts the refresher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 23:06:51 -04:00
QuickandClaude Opus 4.8 e21c35cb7f feat(search): expose active embedding model from lock
Add recommendations.Engine.ActiveEmbeddingModel, which reads the
embedding lock from server_settings and returns the locked model (or ""
when no lock is established). Define the catalog
CatalogSemanticModelProvider interface so a later task can scope vector
coverage checks to the active model; the recommendations Engine already
satisfies it. No wiring yet (Task 4 injects recEngine as this provider).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:51:36 -04:00
QuickandClaude Opus 4.8 927d7764a4 feat(search): model-filtered embed-eligible coverage counts
Establish how semantic vector "coverage" is counted: current-model
embeddings over embed-eligible items, per media type, from Postgres.

- Move the embed-eligibility predicate to a single source of truth in
  embeddingvectors.ItemEligibilityWhereClause; recommendations now
  delegates to it (output byte-identical).
- Add catalogSemanticCoverageByType (per-type eligible/vectorized) and
  define the coverageQuerier interface in catalog. Both numerator and
  denominator apply the eligibility predicate, so vectorized never
  exceeds eligible (C1 guarantee) even with a stale embedding on a
  now-unmatched item.
- countCatalogSearchVectorDocuments now takes a coverageQuerier and a
  model, applies the eligibility + model filter, and is the per-type
  numerator summed across types. Update all four callers (pass "").
- Add idx_media_item_embeddings_model (CONCURRENTLY, self-healing
  guard) so the model filter does not scan the embeddings table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:43:23 -04:00
Quick 20f36fdc31 fix(search): address provider review comments 2026-06-25 21:31:20 -04:00
Quick 5089e17ee6 fix(scanner): reconcile podcast rescans 2026-06-25 20:57:28 -04:00
Quick 07f2dd5a8f fix(admin): bound autoscan activity backlog 2026-06-25 20:50:45 -04:00
Quick 40329f616d perf(search): speed up catalog query results 2026-06-25 20:46:08 -04:00
Quick fb3a21c332 fix(search): batch orphan delete index events 2026-06-25 17:26:19 -04:00
Quick 86bc69c0a2 fix(search): defer meilisearch key check to runtime 2026-06-25 17:24:08 -04:00
Quick 6df388b25b fix(search): harden search index review paths 2026-06-25 17:00:45 -04:00
Quick 6ca427096b Add catalog search provider support 2026-06-25 16:20:14 -04:00
QuickandGitHub 89c435f046 Merge pull request #219 from Silo-Server/codex/fix-image-resolver-build
fix(plugins): restore image resolver startup build
2026-06-25 15:09:18 -04:00
Quick af52ea7bcc fix(plugins): use scoped installation store for image resolver 2026-06-25 15:08:55 -04:00
QuickandGitHub 13c0dde538 Merge pull request #218 from Silo-Server/codex/image-resolver-capability
feat(plugins): add image resolver registry
2026-06-25 15:01:07 -04:00
Quick 2045b7a0b2 feat(plugins): add image resolver registry 2026-06-25 14:47:48 -04:00
Quick 5c0027b54b fix(metadata): dedupe availability during re-id 2026-06-25 13:29:30 -04:00
87159b0a38 feat(collections): add profile-scoped display filters (#191)
* feat(collections): add profile-scoped display filters

* refactor(collections): dedup display-filter helpers per review

Address code-review feedback on the profile-scoped display filters
without changing behavior:

- Widen CompletedHistoryItemMap to accept ProgressCompletionStore and
  drop the duplicate completedHistoryItemMapForProgress copy.
- Extract the duplicated MDBList candidate retry loop into a generic
  collectionutil.FetchMDBListWithFallback helper, used by both the user
  and library collection syncers, and cover it with unit tests.
- Reuse validateOptionalLibraryIDs in HandleUpdateCollection instead of
  an inline positive-ID loop.
- Import the shared COLLECTION_{WATCH,MEDIA}_FILTER_OPTIONS in the
  template config form rather than redefining them locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(collections): sanitize query_definition library_ids fallback

readSourceConfigLibraryIDs validated source_config.library_ids (finite,
positive, truncated, deduplicated) but returned the query_definition
fallback raw, so legacy rows could surface zero/negative/duplicate IDs
that the backend now rejects on save. Extract a shared sanitizer and
apply it to both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(docs): This makes the agents annoying to work with

* Improve playback session handling

* Support collection source order in catalog filters

* fix(collections): address display filter review feedback

* refactor(catalog): remove duplicate collection query params

* Hide episode media scope for collection overlays

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:38 -04:00
Quick 227986094b Propagate playback client metadata through session sync 2026-06-23 14:26:14 -04:00
Quick 9819f0ff12 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-06-23 14:17:21 -04:00
QuickandGitHub dc44e13bfa Filter restricted library sections and share collection queries (#190)
* fix(collections): prevent collection query cache collision

* Filter restricted library sections and share collection queries
2026-06-23 14:16:31 -04:00
Quick 3ad77095c4 fix(collections): prevent collection query cache collision 2026-06-22 18:46:13 -04:00
QuickandClaude Opus 4.8 0726d9430e docs: add Silo trademark & brand policy
Add TRADEMARK.md (canonical brand policy), a brand-asset NOTICE under
web/public, and a License & Trademarks section in the README. Documentation
only; no application code changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:24:49 -04:00
544a262924 fix(jellycompat): accept Jellyfin current ApiKey query param for stream auth (#186)
Silo's ExtractToken only honored the legacy 'api_key' query parameter and
the X-Emby-Token/X-Mediabrowser-Token headers. Real Jellyfin's
AuthorizationContext treats 'ApiKey' (PascalCase) as the current,
always-enabled query token and 'api_key' as legacy (gated behind
EnableLegacyAuthorization). Native Jellyfin clients that build their own
direct-play /Videos/{id}/stream URLs (incl. Jellyfin Android TV) send
'ApiKey', which Silo rejected — the request arrived with auth_kind=none and
the route returned 401, surfacing as a client 'playbackerror'.

Match both 'ApiKey' and 'api_key' case-insensitively in ExtractToken and in
the authKind log classifier. Strictly additive: existing header and
api_key paths are unchanged.

Verified against the live server: /Videos/{id}/stream?...&ApiKey=<tok>
returned 401 before and is accepted after; &api_key=<tok> and the
X-Emby-Token header continue to return 206.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:20:04 -04:00
RXWatcherandGitHub 187f29e163 Merge pull request #183 from Silo-Server/fix/episodes-season-id-index-migration
Fix episodes season index migration
2026-06-19 06:23:21 +02:00
rxwatcher 729468be30 fix: wrap episodes season index migration block 2026-06-19 06:13:26 +02:00
QuickandGitHub de931f784a Fix ebook match dialog to support generic provider IDs (#182)
* fix(metadata): match non-video items with content-aware providers

* fix(web): guard invalid match year parsing
2026-06-18 20:19:47 -04:00
QuickandGitHub b84c040d27 Reinforce first profile empty state layout (#181) 2026-06-18 19:49:06 -04:00
QuickandGitHub cac435c4b9 Fix watch-state unwatch sync across user data and Jellyfin mappings (#179)
* Refine playback session handling and API responses

* fix(watchstate): harden completed-history visibility
2026-06-18 19:17:22 -04:00
dependabot[bot]andGitHub 08997264d2 build(deps): bump react-router from 7.15.0 to 7.15.1 in /web 2026-06-18 16:05:08 -04:00
dependabot[bot]andGitHub 42fd55db08 build(deps-dev): bump vite from 6.4.2 to 6.4.3 in /web 2026-06-18 16:04:45 -04:00
QuickandGitHub 7e532ea982 [codex] fix season page loading (#178)
* fix(catalog): speed up season page loads

* fix: address season loading review feedback
2026-06-18 14:18:35 -04:00
QuickandGitHub b3198276f7 [codex] fix(subtitles): stream live transcribe_translate cues (#177)
* fix(subtitles): stream live transcribe_translate cues

* fix(subtitles): harden live AI transcription
2026-06-18 12:16:27 -04:00
CoffeeKnyteandGitHub 6e1f79e8a9 feat(jellycompat): expose library collections as an auto-shown Collections view (#175)
* feat(jellycompat): expose library collections as a Collections library view

Surface server library collections as a top-level Jellyfin "Collections"
library (CollectionType "boxsets") so compat clients see them as the first
library in /UserViews and can browse them by ParentId. The BoxSet machinery
(list/detail/children) already existed; this adds the library wrapper.

- catalog: add LibraryCollectionRepository.AnyVisibleInLibraries, an
  index-only EXISTS probe (no item join/aggregation) used to gate the view so
  an empty Collections tab never shows. Mirrors collectionVisible semantics
  (multi-library scope rows or the legacy single library_id fallback).
- jellycompat: add the synthetic Collections CollectionFolder (fixed Jellyfin
  sentinel ID, stable across servers), prepend it to the user's views when a
  visible collection exists, and route ParentId/Items-by-ID for that sentinel
  to the existing BoxSet listing. ChildCount is left omitted (no per-call
  count, no unwatched badge).

AI-use disclosure: implemented with assistance from Claude.

* fix(jellycompat): display collection posters and a generated Collections tile

Library collections surfaced as Jellyfin BoxSets showed blank cards: their
poster_url is frequently a bundled frontend template path
(/images/collection-templates/x.jpg), which the compat image route passed
through unchanged and then rejected in parseRemoteImageURL (no scheme/host),
returning BadGateway. Clients probed Images/Primary and got nothing.

- images: serve app-relative artwork (bundled template posters) straight from
  the embedded frontend FS (new ImagesHandler.frontendFS), with content-type
  and cache headers. Wired through jellycompat.Dependencies.FrontendFS.
- poster_gen: on-the-fly gradient poster generator (per-title hue, centered
  white caption with black outline, gobold/opentype), memoized in a bounded
  cache. Used for the synthetic Collections library tile and as a fallback for
  collections without usable artwork, so cards are never blank.
- consolidate collection/view image routing in HandleItemImage, authorized by
  the signed tag or an authenticated, visibility-checked session.

AI-use disclosure: implemented with assistance from Claude.

* fix(jellycompat): declare 2:3 PrimaryImageAspectRatio on BoxSets and Collections tile

Clients defaulted collection cards to a square and crop the 2:3 poster to fit.
Set PrimaryImageAspectRatio (portrait 2/3) on the BoxSet DTO and the synthetic
Collections library tile so the full poster is shown, matching Jellyfin.

AI-use disclosure: implemented with assistance from Claude.
2026-06-18 10:20:48 -04:00
14ffc91dfb [codex] Expand provider image cache queue (#176)
* feat(metadata): expand provider image cache queue

* fix(metadata): harden provider image cache queue

Addresses bug-review feedback from Codex/CodeRabbit on the metadata image
cache pipeline. All findings validated against the code before fixing;
false positives (rows/connection deadlock, PhotoSourcePath merge coupling)
were confirmed non-issues and left unchanged.

- Honor metadata.cache_images for the background processor. The
  cache_metadata_images task was registered whenever S3 was configured,
  so merely enabling object storage downloaded the entire provider-artwork
  catalog even with caching disabled. Add ImageCacheProcessor.SetEnabled,
  gate RunOnce/RunUntilIdle on it, and wire it (with hot reload) from
  cfg.Metadata.CacheImages in main.go.
- Guard terminal job updates with lease ownership. EnqueueBatch can
  repurpose a running row with a new source; MarkSucceeded/MarkFailed
  keyed on id alone let a stale worker finalize the replacement job and
  drop the new artwork. Thread locked_by through and add
  status='running' AND locked_by=$n guards.
- Avoid uploading stale jobs onto the live artwork key. Verify the
  target still references the job's source (CurrentTargetSourcePath)
  before CacheImage, so a job whose source an admin/refresh already
  replaced cannot overwrite the deterministic storage object.
- COALESCE nullable external IDs in EnqueueExistingProviderArtwork. A
  NULL tmdb_id/tvdb_id/imdb_id on any candidate failed the scan and
  aborted the whole cache run; matches the existing item_repo pattern.
- Stop re-downloading the catalog every 30 days. Discovery now skips
  targets whose *_path is already a cached relative path, making the
  cached row the durable dedup marker instead of the prunable job row.
- Decouple catalog sweeps from queue draining. RunOnce no longer runs
  discovery per batch; RunUntilIdle sweeps only when the queue drains and
  throttles full sweeps to every 15m, so idle installs stop full-scanning
  every entity table each minute.
- Requeue claimed-but-unstarted jobs on cancellation. Acquire the
  semaphore before spawning workers and RequeueClaimed any jobs not yet
  started, instead of leaving them locked until the 15m lease expires.
- Skip the backoff sleep after the final upload attempt in
  putObjectWithRetry (saves ~1.5s on permanent failures).
- Add the s3/file/local/upload/generated exclusion to the seasons and
  episodes backfill in migration 20260617184537 for consistency with the
  later migration (the bad backfill was inert downstream, but the
  asymmetry is removed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:58 -04:00
Quick fbe54ffec5 Merge branch 'codex/notifications-request-burst' 2026-06-17 13:49:18 -04:00
Quick 562ae635d7 feat: improve audiobook groups and notification refresh 2026-06-17 13:48:57 -04:00
Quick cf3e68e02a fix(jellycompat): honor codec profile playback limits 2026-06-17 13:12:18 -04:00
Quick 20e0dd7fe2 Fix Jellyfin parent browsing and release dedupe
- List seasons or episodes correctly for Jellyfin parent item queries
- Deduplicate episode availability by logical episode identity
- Improve audiobook title fallbacks and existing item updates
2026-06-17 13:12:18 -04:00
e99079abf8 Server-side Kindle→EPUB conversion (mobi/azw/azw3) for in-app reading (#171)
* Kindle->EPUB conversion: design + proven wasm build pipeline

Server-side MOBI/AZW/AZW3 -> EPUB conversion so the Android in-app reader
can render Kindle-family ebooks. Conversion runs in-process via libmobi's
mobitool compiled to wasm32-wasi, executed by wazero (pure Go) -- no cgo,
no external binary, arch-independent, sandboxed untrusted input.

This commit lands the design + the validated build artifact (spike done):
- docs/.../2026-06-17-kindle-epub-conversion-design.md (Codex-reviewed;
  9 review fixes folded in: failure contract, strong cache key + negative
  cache, wazero command-module specifics, FS-sandbox tightening,
  double-gated capability, serve headers, .wasm guardrails).
- tools/mobitool-wasm/{Dockerfile,README.md}: reproducible build of
  mobitool.wasm (wasi-sdk 25, libmobi 9062742, zlib 1.3.1->wasm), with a
  smoke-conversion gate. Build proven on native amd64.
- internal/ebookconvert/mobitool.wasm (+ .sha256): canonical artifact,
  built on amd64. go:embed target for the converter package (next).

Spike proven on amd64: -e EPUB path works with --with-libxml2=no (internal
xmlwriter); converts MOBI6/KF8/HUFF-CDIC/unicode -> well-formed EPUB;
verified end-to-end under wazero (WASI preopen + argv + _start). Build
gotcha: link libmobi against real (wasm) zlib, not --with-zlib=no, to avoid
miniz duplicate-symbol clash with mobitool's zip miniz. DRM gotcha:
mobitool prints "Document is encrypted" to stdout but exits 0 -> detect via
stdout + output validation, not exit code.

Not yet implemented: internal/ebookconvert Go package (wazero harness +
cache + singleflight), read-handler wiring, admin flag, client capability.
v1-scope proposal required before PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ebookconvert: converter core + cache (Codex-reviewed)

internal/ebookconvert: in-process MOBI/AZW/AZW3 -> EPUB via the embedded
mobitool.wasm on wazero. Converter compiles the module once and instantiates
per conversion (isolated). Cache adds on-disk, singleflighted, size-bounded,
negative-cached conversion keyed by file identity + module fingerprint.

18 tests pass (DRM-free->valid EPUB, DRM->ErrDRMProtected + no output,
oversize/corrupt/missing/timeout/cancel/after-close, 6/8-way concurrent,
EPUB structural validation incl. stored-mimetype + container rootfile,
cache miss/hit/key-change/singleflight/eviction/negative-cache).

Codex review fixes folded in:
- timeout/cancel classified before generic nonzero exit (WithCloseOnContextDone
  surfaces sys.ExitError special codes); no more bogus "exit <huge>".
- DRM detection scoped to known mobitool diagnostic LINES (Document is
  encrypted / DRM key not found / Invalid DRM pid / DRM expired / DRM support
  not included) -> no false-positive on book text; Print Replica -> clear fail.
- WithMemoryLimitPages cap; capped stdout/stderr writers; MaxOutputBytes.
- read-only fs.FS input mount + dedicated writable out dir; documented that
  FS isolation ultimately relies on running as a non-root user (memory-safety
  is the WASM boundary). validateEpub now requires STORED mimetype + verifies
  the container.xml OPF rootfile exists. Atomic moveFile. Closed-guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ebookconvert: wire Kindle->EPUB into the read handler + capability endpoint

Server now transparently serves Kindle-family ebooks as EPUB when the admin
flag ebook.kindle_conversion_enabled is on and the WASM converter initialized.

- handlers.EbookConversion (converter + per-request flag predicate) on the read
  handler; HandleReadFile -> h.serveEbook. Kindle + enabled -> cached EPUB with
  X-Silo-Ebook-Conversion: converted, epub MIME, ETag = exact conversion cache
  key, must-revalidate. Failure (DRM/corrupt/oversize/unservable) -> raw
  original + X-Silo-Ebook-Conversion: failed + no-store, so the client opens
  externally. Context cancel propagates (not a conversion verdict).
- GET /api/v1/ebooks/capability advertises {enabled, source_formats,
  served_format, header contract}; enabled only when flag on AND converter
  wired (double gate) so the Android client can decide whether to flip
  mobi/azw/azw3 to in-app.
- router: buildEbookConversion compiles the module once at startup (feature off
  if it fails), cache dir is a sibling of TranscodeDir, flag read per request.

Codex review fixes folded in: ETag derived from the exact SourceKey cache key
(id+size+mtime+oshash+module version), not a weaker hash; no-store on the raw
fallback; open/stat failure of a produced EPUB falls back to raw per the
contract instead of 500. 10 handler tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ebookconvert): harden conversion cache, HEAD path, and artifact verification

Addresses adversarial review + CodeRabbit findings on the Kindle->EPUB feature.

Correctness:
- Stop poisoning the negative cache on transient timeouts. Introduce
  ErrConversionTimedOut (distinct, non-wrapping ErrConversionFailed); classify
  the per-call timeout as transient and propagate a caller's cancel/deadline
  verbatim instead of reclassifying it as a conversion failure. remember() now
  only caches deterministic verdicts (DRM / failed), so a one-off timeout under
  load no longer wedges a convertible book onto raw-fallback for 6h.
- Detach the singleflight conversion from any single caller's context (DoChan +
  context.WithoutCancel), so one caller cancelling no longer aborts the shared
  work for the others; the cache is still populated for the next reader.
- enforceBudget never evicts the entry it is about to return, and skips other
  conversions' in-flight "converting-*" temp files.
- Cache hits refresh mtime so the mtime-ordered budget eviction is a real LRU,
  not FIFO.

Read path:
- HEAD is now cache-only via Cache.Lookup: a hit serves real converted headers,
  a negatively-cached source serves the failed contract, a miss advertises the
  converted representation cheaply without triggering a (minute-long, ~1 GiB)
  conversion. The GET still delivers the body + authoritative verdict.
- The admin flag is read through a short-TTL predicate so the read path and the
  capability endpoint no longer hit the DB per request.

Artifact / build:
- Add an in-code provenance test (embedded mobitool.wasm matches its recorded
  sha256) and a self-hosted CI job that runs the ebookconvert smoke conversions
  + provenance check, so the committed wasm can't silently rot.
- Pin + checksum-verify wasmtime in the build Dockerfile (drop curl|bash).

Docs: correct the design doc cache-key + setting-name descriptions, document the
HEAD/timeout/LRU semantics and resource limits, note DRM-marker brittleness, and
fix the README markdown table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci: remove ebookconvert workflow

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-17 13:09:55 -04:00
9b111649f3 perf+fix(audiobooks): detail page, browse, sessions & scanner (#169)
* perf(catalog): fix audiobook detail N+1 + slow people facets

Audiobook detail pages were slow in proportion to track count (up to 433
files/book). Root causes, found by EXPLAIN ANALYZE on the live DB:

- effectiveAudioSelection ran 3-4 user-store queries (profile, audio pref,
  library pref) per file inside buildPlaybackInfo's loop, though the results
  are invariant across a request. Introduce a request-scoped audioPrefResolver
  that memoizes the store lookups (library prefs keyed by folder); a 400-file
  audiobook now issues each query once instead of per file. Selection logic is
  unchanged (audioPreference returns a copy so the original-language sentinel
  is still resolved per file).
- buildAudiobookExtension ran its four independent related-content queries
  serially; run them concurrently so latency is the slowest, not the sum.
- author/narrator browse facets did a full people-table scan; add a
  (kind, content_id, person_id) index so the facet resolves from an index-only
  scan of just that kind's credits (~112ms -> ~49ms on the live library).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(catalog): cache audiobook author/narrator group browse

The Authors/Narrators audiobook pages were slow on cold load and slow again
after a hard refresh (fast only while the React Query client cache was warm).

Root cause (EXPLAIN ANALYZE on live, 31K-audiobook library): the grouped
browse query is ~234ms/page, there are ~13K distinct authors, and the client
pages through the entire list on every load (sequential 500-row requests). With
no server-side cache each of the ~20 pages re-ran the full aggregation
(COUNT(*) OVER() forces it), so a cold load was ~20x234ms. The client's 60s
staleTime was the only thing making a warm revisit fast; a refresh wiped it.

Fix: AudiobookGroupsCache caches the full sorted group list per (library,
group_by, sort, viewer) for 60s (matching the client staleTime, so no extra
staleness) and serves every page as an in-memory slice — one aggregation per
window instead of one per page, and a refresh is a cache hit. Also raise the
client page size 500->2000 so fewer sequential round-trips are needed now that
a larger page is a cheap slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(settings): throttle per-request device last_seen upserts

Device-setting reads (HandleGetDeviceSetting, HandleGetEffectiveSettings,
HandleGetEffectiveSubtitleAppearance) each registered the request's device — an
INSERT ... ON CONFLICT upsert of last_seen_at on a single per-device row. A page
that fetches many settings fired hundreds of these concurrently; they serialized
on that row's lock (observed 100-237ms each, ~250 per page load in the slow
query log), taxing every settings fetch.

Throttle device registration to one upsert per (profile, device) per 5 minutes
via an in-process TTL cache, marking the device seen before the upsert so a
concurrent burst collapses to a single write. last_seen_at stays fresh to within
the window. Reads no longer issue a contended write on the hot path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf+fix(audiobooks): probe-repair, resume position, cache storm, groups reveal, hot-row + stats index

From the full audiobook code review (EXPLAIN + slow-query trace on live):

- #1 (P0, detail-page killer): NeedsCriticalProbeRepair required video codec/
  resolution/tracks, which audio-only files never have, so PlaybackProbeEnsurer
  re-ran ffprobe per file on every detail/watch load (up to N serial spawns for
  an N-track book) and never converged. Gate video-field checks on the file
  actually having a video stream. TDD.
- #3 (P0): abs session-sync rewound the resume cursor — UpdateProgressPosition
  did an unconditional SET with no monotonic guard, ignored its error, and
  no-op'd when no row existed (first-listen resume lost). Now a finish-preserving
  GREATEST upsert; caller logs failures.
- #4 (P0 perf): progress reports fired every ~10s invalidated all of
  catalogKeys.all → refetched every active browse/detail query incl the 13k
  audiobook group lists. Scope invalidation to the reported item's detail.
- #6 (P1 perf): Authors/Narrators page rendered all ~13k groups + cover images
  at once (main-thread freeze). Incremental reveal: render a capped window, grow
  on scroll via IntersectionObserver.
- #10: throttle abs TouchToken last_seen upsert (one per token per 5min) — same
  hot-row contention class as the device fix.
- #8: index abs_playback_sessions (user_id, profile_id, started_at) for the
  listening-stats aggregations.

Deferred (need contract/validation): listening-time idempotency (client delta-vs-
cumulative), scanner deleted-file reconcile, abs session retention job, abs list-
handler batch fetch, scanner-output P2s (need re-backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(audiobooks): batch-fetch abs list/shelf handlers (kill N+1)

handleSimilarItems, handleItemsInProgress, and handleGetMyProgress called
MediaStore.GetAudiobookByID once per row — up to ~500 single fetches (each a
few queries) on app open. Add GetAudiobooksByIDs (one access-scoped fetch +
people/series hydrated once for the whole set) and look results up from the
returned map, preserving order. Underlying primitives (GetByIDsWithAccess,
hydratePeople/Series) were already batch-capable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audiobooks): reconcile deleted files on scan + prune session history

#5: ScanAudiobookFolder only ever upserted — deleted/renamed books leaked
media_items/media_files/memberships forever. Mirror the ebook reconcile: collect
seenPaths during the walk, MarkMissing files no longer on disk, then
reconcileLibraryMemberships. Safety mirrors ebooks/video: an inaccessible root
(unmounted source) is skipped entirely, and a walk that saw zero files while the
DB has rows only reconciles after operator cleanup confirmation
(ebookEmptyCleanupAllowed) — so a flapping mount can't wipe the catalog. Soft
mark only; the existing grace-period purge hard-deletes later. Reconcile runs
only on a fully-completed (non-cancelled) scan. (#9 coarse case already handled:
audiobookFolderShouldSkip skips unchanged folders; per-file reuse deferred.)

#8-retention: abs_playback_sessions grew unbounded (one row per play-start, never
deleted) and fed every listening-stats scan. Add an hourly sweep in
SessionCleaner: close abandoned open sessions (no /close, stopped syncing >24h)
and delete closed sessions older than 90 days. Mirrors the recommendation_cache /
missing-files prune pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address max-effort code-review findings

From /code-review max on the pre-PR diff:
- DATA RACE (P0): SessionCleaner.lastABSSessionPrune is read+written by both the
  15s ticker goroutine and the shutdown-path CleanStale call (main.go defers
  Stop() to after that call). Guard the prune-due gate with a mutex. (CleanStale
  was stateless before this branch, so concurrent calls were previously safe.)
- ScanAudiobookFolder hardcoded fullScan=true into the empty-walk cleanup guard,
  but it's also called from ScanSubtree (incremental scans). An empty subtree
  scan would wrongly consume the operator's one-shot empty-cleanup allowance and
  warn. Thread a real fullScan flag (true from ScanFolder, false from the two
  subtree call sites), mirroring the ebook path.
- Revert UpdateProgressPosition to UPDATE-only (drop the INSERT-on-missing):
  keep the monotonic GREATEST + finish guard that fixes the resume rewind, but
  restore the no-op-on-missing contract so a stray sync tick can't resurrect
  just-cleared progress or create a zero-duration continue-listening row.
- Clamp the audiobook-groups handler limit (paging moved into the cache, leaving
  the old 500/page bound stranded); also gofmt the Scanner struct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address review feedback for scanner and stats

* fix(audiobooks): address review feedback

* fix(audiobooks): retry failed session prune

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-17 09:51:43 -04:00
f207b87ff7 fix(catalog): return user_data for audiobooks on /watch/{id} (#144)
HandleGetWatchDetail populated user_data (resume position) only for
movie/episode/ebook, so an audiobook fetched via /watch returned a null
position_seconds even though progress is recorded the same way. The
/catalog/items/{id} path already includes audiobooks; this brings the watch
endpoint in line so both expose the saved position.

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:32:48 -04:00
c4cbcddeae feat(manga): manga library type — series grouping, reading loop, AniList/MangaDex metadata + status badge (#138)
* docs: design spec for manga library type (host sub-project)

Forks the ebooks library type into a 'manga' type: series detected from the
folder tree as a first-class type='manga' item, .cbz/.cbr chapters stay
readable ebook items linked via a new manga_chapters table, browse shows series
cards, enrichment targets the series item at content level 'manga'. Hands off to
a follow-on plugin spec for the manga metadata source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for manga library type (host)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): manga filename index/volume parser

* feat(scanner): manga series-name-from-folder detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): align manga DB/scanner tasks to scanner pure-planner pattern (no test-DB)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(scanner): manga parser corpus regression

Add TestParseMangaIndexCorpus — 36 real-world scanlation filenames
covering bare chapter, decimal chapter, v/vol-prefix volume, and
c/ch-prefix chapter patterns; asserts <5% miss rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(db): manga_chapters link table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): manga_chapters repository + pure chapter-write mapping

Adds mangaChapterWrite (pure, unit-tested), upsertMangaChapter, and
listMangaChapters following the ebook/audiobook thin-SQL pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): recognize manga library type

Add isMangaLibraryType helper (unexported, matching the style of
isEbookLibraryType / isAudiobookLibraryType) with a corresponding
TestIsMangaLibraryType unit test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): manga library content level

Map library type "manga" to content level ["manga"] in
metadataContentLevelsForLibraryType so that seedDefaultChain seeds a
manga-level metadata provider chain when a manga library is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): route manga libraries to a manga scan path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): group manga chapters under a manga series item

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): give manga series item a library membership so it browses

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): browse manga libraries as series

Accept "manga" as a valid media_scope so a manga library browses only its
type='manga' series items; the per-chapter type='ebook' items are naturally
excluded because MediaScopeItemTypes("manga") expands to {"manga"}. Add the
manga default library sections (scoped to media_scope='manga') so the library
feed shows series cards. Refresh the two media_scope validation error messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): manga series detail lists chapters

For a type='manga' item, attach its chapters to the detail response via a new
MangaDetailExtension. fetchMangaChapters joins manga_chapters to media_items on
the chapter content ID, scopes to the series, and orders by chapter_index
(NULLS LAST) then sort_title — matching the scanner's chapter ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga detail types + library browse scoping

Add MangaChapter/MangaDetailExtension TS types mirroring the host
catalog structs, wire manga? onto ItemDetail, and admit "manga" as a
QueryDefinition.media_scope. Scope manga libraries to media_scope=manga
in browse (host expands it to type=manga series items) while reusing the
ebook sort universe via getLibrarySortRelevanceScope. Add isMangaLibraryType.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga series detail with volume-grouped chapter list

Add MangaContent detail view: a DetailHero series header plus a chapter
list grouped by volume. groupMangaChapters (pure, unit-tested) buckets
chapters by their volume token, orders chapters within a group by
chapter_index (nulls last) and orders groups by their minimum index;
loose (volume-less) chapters collapse into a trailing "Chapters" group.
Each chapter links to the existing ebook reader by content_id alone
(file_id is optional — the reader resolves the file server-side), reusing
buildMediaPlayHref. Admit "manga" into ItemDetail.type and wire the
detail switch. Continue-reading is deferred (needs per-chapter progress
fan-out / a last-read timestamp not in the current payload).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): handle manga in playable-type + collection filter-scope unions

Adding "manga" to the shared ItemDetail["type"] and
QueryDefinition["media_scope"] unions leaked into consumers with narrower
local types, breaking the production tsc build. Fixes:

- mediaNavigation: admit "manga" into PlayableMediaType. Manga series are
  not directly playable (you open the detail page and read a chapter,
  itself an ebook item), so buildMediaPlayHref falls through to the item
  href for them, like series/season.
- FilterRuleEditor: add "manga" to FilterRuleMediaScope and relabel
  "watched" -> "Read" for manga as well as ebook (manga is read).
- CollectionGuidedRulesEditor: add "manga" to GuidedFormState.mediaScope,
  a "Manga" media-type option, ebook-like "Read Status" labels, and map
  manga -> ebook sort-relevance scope (manga has no dedicated sort scope).
- CatalogFilterBar (cascading leak surfaced after the above): add a
  "Manga" scope option and map manga -> ebook sort-relevance scope in both
  scope handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): offer manga as a library type in the create dialog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): strip scene-release junk from manga series names

Add cleanMangaSeriesName which repeatedly strips trailing parenthetical
groups (year, year-range, Digital, release-group tags) then trims any
dangling dash, so folder names like "404 Demons (Digital) (Oak)" resolve
to "404 Demons". Wire it into mangaSeriesFromPath so both the series
title and the mangaSeriesGroupKey identity key use the cleaned value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): flat volume/chapter manga list; nest only multi-chapter volumes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): parse manga index after stripping series-name prefix

Numbers inside a series title (e.g. "404 Demons", "365 Days to the
Wedding") were wrongly grabbed as the chapter number because
parseMangaIndex matched the first bare number in the full filename.
mangaIndexForFile now strips the series-name prefix before delegating
to parseMangaIndex, so only the number that follows the title is used.
reconcileMangaFile in manga_scan.go is updated to call mangaIndexForFile
instead of parseMangaIndex directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): stop missing-file reconcile from deleting manga series items

Manga series items are file-less virtual parents; the shared
ReconcileFolderMembership swept them every scan because they have no
media_file. Exclude type='manga' from file-presence membership reconciliation,
and add a manga-scan step that deletes only series with zero remaining chapters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ebooks): exclude manga chapters from individual ebook enrichment

Manga chapters are type='ebook' parts of a series; the ebook enrichment sweep
was searching each one against book sources (Gutenberg/Anna's/etc.) and failing
in a pointless storm. Exclude items with a manga_chapters link; series-level
enrichment is handled separately.

* docs: design spec for manga metadata plugin + series enrichment (sub-project 2)

New silo-plugin-manga-metadata (AniList, high-confidence matching) + a host
MangaEnricher for type='manga' series; default-enabled metadata source for manga
libraries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for manga metadata plugin + series enrichment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db): manga_enrichment_state table

Mirrors ebook_enrichment_state: dedicated failure counter for the manga
enrichment sweep so it does not contend with media_items.refresh_failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(manga): series enricher (claims type='manga', resolves manga chain)

* feat(manga): sync_manga_metadata task + enricher wiring

* feat(catalog): expose manga chapter/volume counts in browse

Add manga_chapter_count and manga_volume_count to browse cards so the
frontend can render a Vols N / Ch N chip on manga series. The counts come
from two index-backed correlated subqueries over manga_chapters in the
browse SELECT (mangaCountColumns), scanned positionally before added_at and
nilled out for non-manga rows. Threaded through models.MediaItem and exposed
on the itemListResponse JSON card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sections): scope manga home recent sections to type=manga series

A manga library mixes type='manga' series with type='ebook' chapters, so
the auto-generated home 'Recently Added/Released in <Library>' rows surfaced
the junk chapter filenames. Add GeneratedHomeLibraryRecentConfigScoped which
emits the modern QueryDefinition shape (library_ids + media_scope) so a manga
library's generated home rows filter to type='manga' only. Other library
types are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): exclude manga chapters from browse/section/search surfaces

Manga CHAPTER items (type='ebook' rows linked into a type='manga' series
via manga_chapters) were leaking into catalog browse, section resolution,
and search as standalone items showing junk filenames. They are internal
sub-units of the series and only the series should appear.

There is no single shared item-listing chokepoint: browse, the query/preview
executor, and search each build their own WHERE. Add a shared, index-backed
anti-join predicate (manga_chapters.chapter_content_id is the PK) via
mangaChapterExclusionWhere and wire it into all three builders. By-id fetch
paths that legitimately resolve chapters (ebook reader, continue-reading,
series detail chapter list) use separate queries and are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): use #NN as the manga volume for Vol.YYYY #NN releases

mangaVolYearIssue early-return was returning the year token (e.g. "Vol.2003")
as the volume label, which the frontend couldn't prettify to "Volume N".
Now returns "v<issue>" (e.g. "v04") so the existing frontend regex ^v?(\d+)$
renders it as "Volume 4" correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): manga count chip on posters

Add an optional manga_chapter_count / manga_volume_count to the browse
item type and render a top-right "Vols N" / "Ch N" chip on ItemCard,
strictly gated on type==='manga'. The label prefers "Vols" when the
volume count dominates, "Ch" otherwise; the chip is hidden when the
chapter count is missing or non-positive. No other card type renders it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): manga reader back returns to series (no loop)

The ebook reader's back action defaulted to the chapter's own item
detail (/item/<chapter>), whose back returned to the reader — an
infinite loop for manga chapters. The reader now honors an explicit
backTo search param when present, navigating there instead. Absent for
normal ebooks, so their back behavior is unchanged. Only manga chapter
rows pass backTo, keeping the fix manga-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga chapter row actions (read/mark-read/download)

Each manga chapter/volume row now offers Read (the existing reader link,
now carrying a backTo to the series), Mark-read (the shared watched-state
mutation per chapter content_id), and Download (lazily fetches the
chapter's file versions on demand and opens the shared
DownloadVersionPicker, gated on user.download_allowed). The
volume-unit / loose-chapter / section structure from buildMangaList is
unchanged. Scoped to MangaContent only; EbookContent is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): validate reader backTo param is a safe in-app relative path

Prevents open-redirect / javascript:-URI XSS from a crafted ?backTo= URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): include per-chapter read state in manga detail

Manga chapters are ebook items, so a chapter is "read" when the viewer's
ebook_reader_progress row crosses the finished threshold. fetchMangaChapters
now LEFT JOINs that table scoped to the AccessFilter's user_id/profile_id and
exposes a per-chapter Read bool on MangaChapter, threaded through
buildMangaExtension. The detail payload previously carried no read state, so
the row toggle always started unread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga rows reflect read state on load

MangaChapter now carries an optional read flag from the detail payload, and
MangaRow seeds its mark-read toggle from chapter.read instead of always
starting unread. The optimistic toggle + shared watched mutation are
unchanged; only the initial value is seeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sections): exclude manga chapters from recently-added/released/random + other library-listing sections

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sections): manga recently-added/released cards show the latest volume's cover

* fix(manga): keep enrichment honest about no-match vs enriched, batch 50->200

- sweep stats now separate enriched / no_match / failed: a stamped no-match
  was counted (and logged) as an enrichment, which masked a collapse of the
  real match rate during the backfill
- batch size 50 -> 200 (SILO_MANGA_ENRICH_BATCH overrides): with the plugin
  serving GetMetadata from its search cache an item costs one rate-limited
  AniList request, so a sweep still fits the 5-minute task interval

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

* fix(manga): size enrich batch to the 5-minute interval at AniList's real budget

140 items x ~2.1s/request fits the interval; an overlong sweep makes the task
manager drop the next trigger and the effective rate falls below the AniList
budget.

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

* fix(catalog): manga count chip data missing from library browse

manga_chapter_count/manga_volume_count were only added to BrowseRepository,
but /library/{id}?tab=library flows through previewQuerySource ->
QueryExecutor.PreviewPage, which selects qualifiedListItemColumns and scans
with scanItems - so manga cards never carried the counts and the Vols/Ch
poster chip stayed hidden.

Append mangaCountColumns to the preview-page SELECT and scan them via a new
scanItemsWithMangaCounts (nil for non-manga rows, mirroring scanBrowseItems).
Extract listItemScanDests so the three scan variants share one destination
list instead of duplicating the 48-column scan.

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

* feat(web): manga chip reads 'X Volumes · X Chapters', menu verbs say Read

- chip: show distinct-volume and loose-chapter counts side by side instead
  of the single 'Vols N'/'Ch N' heuristic; mangaCountColumns now counts
  DISTINCT volume tokens (rows sharing a volume are one volume) and only
  un-volumed rows as chapters
- watched-state labels: type='manga' fell through to the video default, so
  the card dot menu and detail page said 'Mark Watched' - manga now uses
  the ebook reading verbs (Mark Read / Mark Unread, 'Marked as read' toast)
- format MangaContent.test.tsx (pre-existing prettier miss)

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

* feat(manga): backdrop enrichment - banner hero art + backdrop-only backfill

- cache remote backdrops like posters (cacheRemoteImages generalizes the
  poster-only path; failures keep the provider URL, which still renders)
- claim arm for enriched items missing a backdrop: fetched by stored
  provider ID (search skipped - no rate spend, no re-match risk) and only
  the backdrop is written; stamping after the attempt keeps banner-less
  series from being re-claimed every sweep
- backfill = one-time SQL clearing last_refreshed for poster-set/
  backdrop-empty manga

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

* feat(manga): reading-loop UX - continue CTA, next chapter, series-aware cards, file details

Fixes the four high-priority findings from the manga UX review plus a
file-inspector request:

- H1: series hero gets a Continue / Start Reading / Read Again CTA
  targeting the first unread chapter (firstUnreadChapter over the ordered
  list), plus an overflow menu (View Details, admin Refresh Metadata)
- H2: the reader resolves its owning manga series (chapter detail now
  carries series_id/series_title) and offers next-chapter navigation: a
  header next button and an end-of-book floating CTA at >=99.5% progress;
  back defaults to the series even without a backTo param
- H3: chapter rows show a persistent read check + muted title, and the
  mark-read mutation carries series_id so the series detail cache
  invalidates (read states no longer revert on revisit)
- H4: continue-reading cards for manga chapters present the series:
  sections payload resolves chapter->series linkage, the card heading/image
  link to the series, and meta lines launch the reader
- View Details: manga series menus (card dot menu + detail overflow) open
  a file inspector showing folder paths and per-chapter file names/sizes
  via GET /catalog/items/{id}/manga-files; paths are stripped for viewers
  without file-path visibility (item-versions policy)

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

* feat(manga): UX mediums - richer detail page, smarter list, manga sort scope

Second batch from the manga UX review (M1-M7):

- M1: multi-chapter volume sections are collapsible (fully read sections
  start collapsed) with sticky headers, and long series get a 'Jump to
  <next unread>' anchor above the list
- M2: the series hero shows the author line (HeroCrewLine learns Author
  credits with person links; DetailHero now renders crewLine and genre
  chips independently) and Volumes/Chapters badges
- M3: browse-card count chip abbreviates to '12 Vol - 3 Ch' so it fits
  narrow cards without occluding covers
- M4: manga gets its own sort scope: Duration/Bitrate (meaningless for
  file-less series rows) disappear, reading labels (Date Read / Reads)
  apply, Author stays
- M5: global search labels manga results 'Manga' instead of the raw type
- M6: chapters carry the viewer's reading fraction; part-read rows show an
  inline progress bar + percent
- M7: chapter rows show the extracted cover thumbnail (presigned
  poster_url on the chapters payload) instead of a generic icon

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

* fix(manga): UX lows - volume token dedupe, comic reader chrome, empty-state hint

- buildMangaList buckets volumes by canonical numeric token so mixed
  release naming (v01 + 1) yields one Volume 1 instead of duplicates
- cbz/cbr readers start with the side panel closed and hide prose-only
  chrome (reading ruler, TTS, typography/font controls, hyphenation,
  writing mode) while keeping comic-relevant settings (theme, brightness,
  margin, right-to-left, spread, flow)
- manga empty state mentions chapters appear after the library scan

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

* feat(manga): publication status badge via new SDK status field

- vendor the unpublished plugin SDK (adds MetadataItem.status) under
  internal/compat/ with a relative go.mod replace, following the
  zishang520-webtransport-go convention; swap to the published module
  before the upstream PR
- map plugin status into MetadataResult.ShowStatus, persist it during
  manga enrichment, and show it as the hero status badge (show_status was
  already on the detail payload and MetadataBadges)

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

* feat(manga): generalize backdrop pass to secondary fields (backdrop + status)

The backdrop-only claim arm becomes a secondary-fields pass: enriched items
missing a backdrop and/or publication status are claimed, fetched by stored
provider ID, and only the missing secondary fields are written. Lets the
new status field backfill across the already-enriched library instead of
applying only to future enrichments.

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

* fix(metadata): merge ShowStatus through MergeMetadata/MergeGlobalMetadata

The new MetadataResult.ShowStatus never reached the accumulated result the
manga enricher persists from - the field-by-field merges didn't know it, so
the status backfill pass obtained nothing. Regression-tested on both paths.

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

* fix(manga): keep scanner identity IDs out of the metadata flow

filterMangaProviderIDs passed the scanner's manga_series identity row
through, so the search-skip-when-already-matched guard saw provider IDs on
every item and never searched: unmatched items went straight to a by-ID
fetch with no usable ID and were stamped as terminal no-match without a
single provider request (and the MangaDex fallback was never consulted).

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

* chore: gitignore docker-compose.override.yml (local deployment override)

The override unpublishes the bundled redis/postgres host ports
(ports: !override []). It is a per-deployment, local-only file: ignoring it
keeps a rebase from main and git clean -fd from disturbing it, and keeps it
out of any PR. Its accidental absence once exposed Redis to the internet.

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

* fix(manga): code-review fixes — no-match guard, sort comparator, volume-count consistency

- enrichWithProviders: set accumulator.HasMetadata after a provider result
  merges (MergeMetadata doesn't propagate it). Without this, a confident
  match carrying only genres/authors/status/year but no cover and no overview
  failed the no-match check and was discarded + terminally stamped.
- byChapterIndex: both un-indexed chapters yield POSITIVE_INFINITY, so the
  subtraction was Infinity-Infinity=NaN (Array.sort treats NaN as 0, leaving
  order undefined). Compare explicitly for a stable order.
- MangaContent volume/chapter badges: derive counts from the rendered
  buildMangaList entries (which canonicalize v01 ≡ 1) instead of raw distinct
  volume tokens, so the badge can no longer say '2 Volumes' over one row.

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

* docs(manga): clarify the enrichment claim's secondary arm is admin-reset-only

The secondary arm (poster present, backdrop/status missing) requires
last_refreshed IS NULL, so it is only reachable when an operator resets
last_refreshed to backfill a newly-added field — not an automatic periodic
re-check (which would re-fetch banner-less series every sweep). Documents the
intent so it does not read as dead code.

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

* fix(manga): collapse continue-reading chapters per series; batch provider-id lookup

- Continue Reading now collapses multiple in-progress chapters of the same
  manga into one card (most recently read kept), mirroring the episode→series
  collapse. The reading section resolves chapter→series linkage into itemMeta
  (applyMangaChapterSeriesMeta) and runs the shared
  collapseContinueWatchingSeriesCandidates, which the reading path previously
  skipped.
- claimBatch resolves provider IDs for the whole batch in one query via the
  new ProviderIDRepository.GetByContentIDs (content_id = ANY), replacing the
  per-item GetByContentID N+1.

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

* feat(web): manga publication-status chip on browse cards + more legible chips

- Color-coded publication status pill (Ongoing/Completed/Hiatus/Cancelled/
  Upcoming) in the manga card's top-left corner, mirroring the vol/chapter
  count chip top-right. Strictly manga-gated; show_status was already on the
  browse payload.
- New .glass-chip (78% surface vs glass-subtle's 40%) for the manga count +
  status pills so the labels stay legible over busy cover art.

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

* build(manga): depend on published silo-plugin-sdk v0.7.0

Replace the vendored internal/compat/silo-plugin-sdk copy with a normal
dependency on the published SDK module at v0.7.0, which adds
MetadataItem.status (publication/airing status) consumed by the manga
status badge at internal/metadata/plugin_provider.go.

- go.mod: pin v0.7.0, drop the local-path replace directive
- remove the vendored internal/compat/silo-plugin-sdk tree
- Dockerfile: drop the vendored-SDK COPY
- strip the manga design docs/plans from docs/superpowers (internal)

Requires Silo-Server/silo-plugin-sdk#4 merged and tagged v0.7.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manga): exclude chapters from the matcher's unmatched-item lister

Manga chapters are type='ebook' items that stay status='pending' by
design - provider metadata lives on the type='manga' series item. The
scan-final RetryUnmatchedItemsByFolderAndPathPrefix listed all of them
and ran a rate-limited ebook-plugin search per chapter: 31,564 chapters
x ~1s = 8h46m appended to a 2-minute manga library scan (observed
live), every one a guaranteed no-match. Earlier runs never survived to
completion, so the library's last_scanned_at stayed NULL forever.

Add the same manga_chapters NOT EXISTS guard the ebook enricher's
claim query already uses. Verified live: the same library now scans in
27s with retried_items=0.

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

* fix(scanner): never probe-repair ebook/comic files (ebook+manga detail-page killer)

NeedsCriticalProbeRepair was always true for BaseType 'ebook' files (epub, pdf,
cbz, cbr — incl. manga chapters): buildEbookMediaFile leaves ProbeUpdatedAt nil
and they have no audio/video, so probeEnsurer.Ensure spawned ffprobe per file on
every detail/watch load and never converged (ffprobe errors on zip/rar, result
never persisted). Short-circuit probe-repair for ebook base type — they're read
directly and never use the transcode/playback probe pipeline.

SHARED fix: benefits both the ebooks and manga library types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf+fix(ebooks): parallelize detail extension + preserve finished read-state

- buildEbookExtension ran its 3 related-content queries (series, also-by-author,
  similar) sequentially; run them concurrently like buildAudiobookExtension so
  ebook detail latency is the slowest query, not their sum.
- PGEbookReaderProgressStore.Upsert did an unconditional SET progress=EXCLUDED;
  a routine autosave (e.g. reopening a finished book) could drop it below the
  0.9 finished threshold and silently un-mark it read (and clear the manga
  chapter checkmark, which rides on the same row). Guard: once finished,
  progress only moves on an explicit unread (row delete); below threshold it
  tracks freely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(manga): batch chapter presign, index volume counts, quiet scan log

- fetchMangaChapters presigned each chapter poster individually; a long-running
  series has hundreds of chapters. Batch them in one PresignImageURLs call, and
  add the missing rows.Err() check (was silently returning partial lists).
- The browse manga count chip's count(DISTINCT volume) subquery wasn't covered
  by manga_chapters_series (series_content_id, chapter_index); add
  idx_manga_chapters_series_volume (series_content_id, volume) so both count
  subqueries are index-only.
- Downgrade the per-chapter "manga scan: indexed" log from Info to Debug (one
  line per .cbz; the 500-file progress log already covers operator visibility).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manga): address PR #138 code-review findings

Folds PR #142 into the manga branch (already done via fast-forward) and
remediates the issues surfaced in the #138 code review.

Correctness:
- Preserve the scanner's manga_series identity anchor through enrichment.
  ReplaceByContentID's DELETE was unconditional, so the first successful
  enrichment wiped the manga_series provider-id row the scanner relies on
  for idempotency, causing duplicate series + metadata loss on the next
  scan. excludedProviderIDs now also means "not deleted", and the DELETE
  preserves those rows. (internal/catalog/provider_id_repo.go)
- Fall back to the series cover when the latest chapter has no poster.
  Poster columns default to '' (not NULL), so the manga series-card poster
  override blanked cards via a plain COALESCE; wrap operands in NULLIF.
  (internal/sections/fetcher.go)
- Keep backTo a real query param on reader links when libraryId is absent.
  It was string-concatenated with '&', producing a malformed URL on
  deep-links; route it through the query helper instead.
  (web/src/lib/mediaNavigation.ts, EbookReader.tsx, MangaContent.tsx)

Quality:
- Hide manga chapters from favorites/watchlist browse, matching the
  exclusion enforced on every other listing surface.
  (internal/catalog/favorites_browse.go)
- Centralize the manga chapter exclusion predicate into a single exported
  catalog.MangaChapterExclusionWhere, removing four duplicated copies.
  (catalog, sections, ebooks)
- Skip the two manga count subqueries on browse scopes that cannot contain
  manga (non-manga type filters), substituting NULL placeholders.
  (internal/catalog/browse.go)
- Normalize provider publication status (AniList/MangaDex/SDK variants)
  into a stable label set so show_status carries one manga value-domain.
  (internal/manga/enrichment.go)

Adds unit tests for the poster NULLIF contract, browse gating, and status
normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate go.sum after rebase onto main

Drops stale silo-plugin-sdk v0.6.0 and other leftover hashes from the
intermediate rebased states; go.mod is now on the published v0.7.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scanner): adapt manga scan to ebookFileShouldSkip 3-value signature

main changed ebookFileShouldSkip to also return the existing content ID;
the manga scan path only needs the unchanged flag, so discard the new
return. Resolves a silent semantic conflict from the rebase onto main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:13:10 -04:00
63d608f6fc fix(progress): filter continue-watching by viewer access scope (#167)
* fix(progress): filter continue-watching by viewer access scope

The continue-watching list (GET /progress) only filtered by library when an
explicit library_id query param was passed. The global call passes none, so a
restricted profile received progress rows for items outside its scope (e.g. an
XXX title, or a title above the profile's content-rating cap). The web client
then fans out a per-item GET /catalog/items/{id} detail fetch for each row, and
the inaccessible ones return 404 — surfacing as a dead Continue Watching tile
and stray 404s.

Always apply the viewer's access scope to the progress list. Adds
LibraryItemRepository.FilterAccessibleContentIDs, a batched, episode-aware
mirror of the detail endpoint's access predicate (library membership +
content-rating ceiling), and wires it into HandleListProgress for restricted
profiles only (unrestricted viewers are unaffected). ExcludedMediaTypes is
omitted intentionally: the viewer access.Scope does not carry it and the
request path never sets it.

* fix(progress): gate continue-watching episode access on the parent series

FilterAccessibleContentIDs keyed episode access off episode_libraries and
required a media_item_libraries membership even for rating-only viewers, both
of which diverge from the detail/watch path that masks inaccessible items
(DetailService.GetItemDetail → EnsureAccessible(episode.SeriesID)). For shows
whose episodes span multiple library folders this reintroduced the dead tile /
out-of-scope leak this filter exists to prevent, and rating-only profiles
could lose membership-less items the detail endpoint still serves.

Rewrite the predicate to mirror EnsureAccessible exactly: base the lookup on
media_items, join media_item_libraries only when the viewer is
library-restricted, and resolve episodes through their parent series. Extract a
pure buildFilterAccessibleContentIDsSQL so the query shape (placeholder
numbering, the parent-series join, the optional rating predicate) is
unit-tested without a database, and de-duplicate the two progress filter
helpers via progressContentIDs/keepAccessibleEntries.

AI-use disclosure: implemented with AI assistance (Claude).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:32:52 -04:00
d3v1l1989andGitHub 9d00ce2089 fix(web): start autoplay countdown when entering post-roll early (#162)
When a series episode reaches 30s before its end, the player enters
post-roll and pauses the underlying video to stop HLS from buffering the
tail segment. A paused video never fires the `ended` event, but the
PlayingNextScreen countdown was gated on `postRollVideoEnded`, which only
became true on `ended`. The result: the "Up Next" overlay appears, the
video is paused, and the countdown never starts — it only resumes if the
user manually plays the video (letting it reach the end and fire `ended`).

Arm the countdown gate at the moment we enter post-roll early, since that
is the point at which we've decided playback is over, rather than waiting
for an `ended` event that an intentionally-paused video will never emit.
2026-06-16 18:30:54 -04:00
d3v1l1989andGitHub 4502c7a850 fix(jellycompat): return seasons/episodes when browsing a series via /Items?ParentId= (#160)
* fix(jellycompat): return seasons/episodes when browsing a series via /Items?ParentId=

Clients that browse a show through the generic /Items?ParentId= endpoint with no
IncludeItemTypes (e.g. the VoidTV Android-TV client's getSeasons/getEpisodes) got
the top-level library views instead of the series' seasons or the season's
episodes. HandleItems only honored an item/season ParentId when
IncludeItemTypes=Season was present and otherwise fell through to the
library-views fallback (or a global type browse), so ParentId was effectively
ignored.

Decode a season ParentId into its own parentSeasonID and route item parents:
- season ParentId -> that season's episodes
- series ParentId -> the series' seasons (or its episodes for
  IncludeItemTypes=Episode; an unsatisfiable type filter returns an empty page)

Extract the shared writeSeriesEpisodesResponse used by /Shows/{id}/Episodes and
the new browse paths. The generic /Items browse paths honor StartIndex/Limit
(matching the /Items contract and the sibling seasons listing) while
/Shows/{id}/Episodes keeps its existing whole-season response.

Adds parent_browse_test.go covering series->seasons, season->episodes, the
unsatisfiable-type-filter empty case, paging, and the parser's season/item
ParentId distinction.

* fix(jellycompat): empty result for non-episode type filter on a season ParentId

handleSeasonEpisodeChildren ignored IncludeItemTypes and always returned the
season's episodes. A season's only children are episodes, so a type filter that
excludes Episode (e.g. IncludeItemTypes=Movie) now returns an empty page,
mirroring the series-parent path's handling of unsatisfiable type filters.
Addresses a CodeRabbit review comment.
2026-06-16 18:30:25 -04:00
d3v1l1989andGitHub 623348111c fix(jellycompat): resolve smart-collection BoxSet children at read time (#161)
Browsing into a smart (live-query) collection via /Items?ParentId={boxsetId}
returned 0 children while the BoxSet's ChildCount showed a non-zero count:
handleBoxSetChildren only read materialized library_collection_items via
ListItems, which is empty for smart collections whose membership is a query
evaluated at read time. Confirmed live — smart "Directed by ..." collections
returned TotalRecordCount 0 while materialized collections returned their items.

Branch handleBoxSetChildren on catalog.IsLiveQueryType: smart collections
resolve their members through the query executor (mirroring the web API's
loadLiveCollectionItems — Normalize/Validate/ApplySmartCollectionItemLimit,
library-scope intersection, access-filtered Preview), then feed the resulting
content IDs into the same hydration path as stored collections. A malformed or
invalid query definition degrades to an empty page (never a 500).

Hoist the duplicated intersectCollectionLibraryIDs helper into
catalog.IntersectCollectionLibraryIDs so the web and compat resolvers share one
implementation.

Adds smart_boxset_test.go (smart children resolve in query order, library-scope
intersection reaches the executor, malformed-query and no-executor degrade to
empty).
2026-06-16 18:30:16 -04:00
e084cdd1d6 Add unified literary works for ebooks and audiobooks (#107)
* docs: add literary works design and plan

* feat(literary): add work link schema

* feat(literary): add work domain primitives

* feat(literary): persist work links

* feat(literary): score work matches

* feat(catalog): include literary work summary on item detail

* feat(literary): expose work detail API

* feat(literary): assemble work detail

* feat(literary): add admin work linking primitives

* feat(catalog): group literary items by work

* feat(literary): auto-link works during book scans

* fix(literary): narrow work match candidates

* fix(literary): address work merge blockers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 18:17:08 -04:00
CoffeeKnyteandGitHub 9cd00a877b fix(metadata): cap un-completable episode refresh debt as terminal (#168)
Episodes a provider genuinely has no data for stay actionable forever
and, via syncRefreshDebtForItem, pin the whole series item at
RefreshDebtReasonEpisodeIncomplete -> priority 300 (the top band). Those
debt rows never drain and always cut the queue ahead of legitimately-due
content, every backoff interval, indefinitely.

Classify episode-incomplete debt as terminal once its persistent
attempt_count reaches a small cap (3, ~5 days under the existing stepped
backoff): demote it off the priority-300 band (to a still-fixable
reason's band if one is present, else a terminal floor) and re-check it
rarely (90d) instead of every 30d. The signal is purely attempt_count on
the existing row -- no schema change. Demote rather than delete: deleting
would let the next series pass recreate the row at attempt_count 0 and
restart the cycle.

Giving up fast is safe: the library "refresh incomplete" and per-item
refresh endpoints re-fetch on demand and bypass the backoff, and
media_items.episode_metadata_incomplete is left untouched so they still
find these items. Logs the terminal transition (no silent demotion).

AI-use disclosure: implemented with AI assistance (Claude Code).
2026-06-16 18:16:33 -04:00
bf54f040f9 fix(audiobooks): extract scan covers and harden dedupe (#103)
* fix(audiobooks): extract scan covers and harden dedupe

* fix(audiobooks): address scan cover review

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 17:58:01 -04:00
d3v1l1989andGitHub fe7eb70483 fix(jellycompat): reuse the play session for Static direct play instead of leaking one per request (#165)
Clients that direct-play via /Videos/{id}/stream?Static=true never call
PlaybackInfo, so they send their own client-generated PlaySessionId and repeat
it on every range request. resolvePlaybackRoute looked that id up, missed (the
server never minted it), and returned ErrSessionNotFound — so HandleVideoStream
fell to createStaticPlaySession and started a *new* upstream session for every
request. Each counts toward the per-user max_streams cap and only ages out after
the 45s activity grace, so a single direct play's range requests pile up orphaned
sessions and quickly trip the cap -> 429 TooManyStreams, locking the user out of
their own playback.

When the provided PlaySessionId is unknown (or owned by another caller), fall
through to the existing CompatToken-scoped FindByRoute reuse instead of erroring,
so all of a direct play's requests share one session. Reuse stays scoped to the
caller's own token, so a guessed/foreign id cannot bind another user's session.
Adds a test asserting StartSession runs once across repeated Static requests.
2026-06-16 17:51:42 -04:00
d3v1l1989andGitHub c81de3459e feat(jellycompat): add Filters2, LocalTrailers, UserImage, ClientLog and Sessions endpoints (#164)
* feat(jellycompat): add Filters2, LocalTrailers, UserImage and ClientLog endpoints

Four endpoints that real Jellyfin clients call were unregistered and fell
through to chi's default 404 (or, for Filters2, were swallowed by /Items/{id}).
All are additive and contract-faithful to the Jellyfin C# server:

- GET /Items/Filters2 -> 200 QueryFilters v2 shape (Genres NameGuidPair[],
  Tags, Audio/SubtitleLanguages), empty arrays. Fladder's filter UI 404'd before.
- GET /Items/{id}/LocalTrailers (+ /Users/{userId}/... alias) -> 200 bare
  BaseItemDto[] ([]); Silo indexes no local trailers. Infuse/Moonfin hit this
  on every item-detail load.
- GET|HEAD /UserImage?userId= -> the same anonymous palette avatar as the
  legacy /Users/{id}/Images/Primary route; HandleUserImage now reads the id
  from the query param when the path segment is absent (modern Jellyfin route).
- POST /ClientLog/Document -> 200 {FileName} after draining/discarding the
  body (Silo has no client-log store); 413 over 1 MiB, matching MaxDocumentSize.

Stops recurring 404 noise and lets clients that depend on these (filter sheets,
avatars, crash-log upload) work. Adds handler unit tests for each.

* feat(jellycompat): add GET /Sessions returning a contract-shaped session list

Wholphin and other jellyfin-sdk clients poll GET /Sessions (optionally
?deviceId=) every few seconds during playback; the route was unregistered, so
each poll hit a chi 404 the SDK could not deserialize — a ~289-per-4h 404 storm
in production. Register it under the same [Authorize] group Jellyfin uses and
return a correctly-typed SessionInfoDto[] (currently empty, consistent with the
existing compat stub handlers). This stops the storm and lets clients degrade
cleanly; populating live session/now-playing state from the playback store is a
follow-up.

* refactor(jellycompat): match Jellyfin client-log size limit exactly

Use 1,000,000 bytes (Jellyfin's ClientLogController.MaxDocumentSize, decimal)
instead of 1<<20, and fix the comment that wrongly called it 1 MiB. Behavior is
functionally identical (the body is discarded); this is contract-fidelity only.
Review follow-up.

* test(jellycompat): add router-level coverage for the new endpoints

The per-handler tests call handlers directly and never exercise NewRouter, so
route registration, chi static-vs-{id} ordering, and auth-group placement were
untested — the one thing this change is actually about. Add a full
NewRouter/ServeHTTP test asserting the session-auth-group routes (Filters2,
LocalTrailers x2, Sessions, ClientLog/Document) return 401 unauthenticated
(registered + behind auth, not 404 or accidentally anonymous), /UserImage serves
its anonymous palette avatar, and an authenticated Filters2 reaches the v2
filters handler (not shadowed by /Items/{id}) with /Sessions returning [].
2026-06-16 17:51:00 -04:00
d3v1l1989andGitHub 3f3cf55002 fix(jellycompat): return 404 for HLS segments of a failed transcode (#163)
HandleHLSSegment mapped every non-ErrSegmentNotFound error from the
segment-retrieval/recovery path to a generic 500 "Failed to load segment".
When a transcode process starts and then exits non-zero, WaitForSegment
returns a wrapped playback.ErrTranscodeFailed, which fell through to that
500 — observed in production as repeated 500s on seg_00000.ts that drove an
8x client retry storm and crash-log uploads.

The segment will never materialize once its transcode has died, so this is a
not-found condition: Jellyfin's DynamicHls handler falls through to a
PhysicalFileResult for the absent file, which ASP.NET serves as 404, never
500. Map ErrTranscodeFailed to 404 alongside ErrSegmentNotFound via a small
extracted hlsSegmentErrorResponse helper, reserving 500 for genuinely
unexpected errors. Adds a unit test pinning the mapping.
2026-06-16 11:14:14 -04:00
d3v1l1989andGitHub 190223e030 fix(jellycompat): serve item and user images anonymously to match Jellyfin (#158) 2026-06-15 18:18:10 -04:00
ea6d1d1f00 feat(jellycompat): support DELETE /Videos/ActiveEncodings transcode teardown (#159)
* feat(jellycompat): support DELETE /Videos/ActiveEncodings transcode teardown

* fix(jellycompat): guard ActiveEncodings teardown against not-yet-started sessions

HandleDeleteActiveEncodings omitted the UpstreamSessionID == "" guard that
the sibling Stopped-report path uses. A PlaybackSession is created by
PlaybackInfo with an empty UpstreamSessionID; it is only populated once the
first manifest request reaches ensureUpstreamPlayback. A DELETE
/Videos/ActiveEncodings arriving in that window (with a matching token) passed
the ownership guard and ran teardownPlaySession, which deletes the compat play
session from the store. A subsequent HandleMasterManifest then 404s, and the
teardown was not the "no-op-safe" operation its doc comment claims.

Mirror the Stopped report path by treating an unknown, not-owned, OR
not-yet-started PlaySessionId as a uniform idempotent 204 no-op. Add a
regression test that fails if the guard is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:17:48 -04:00
Quick ae65678ba4 fix(jellycompat): populate episode season parent metadata 2026-06-15 18:10:40 -04:00
d3v1l1989andGitHub 2e422b66df fix(jellycompat): match the Static stream query parameter case-insensitively (#157) 2026-06-15 18:03:58 -04:00
Quick 10a0635331 Increase default migration timeout
- Raise the default migration timeout from 5m to 20m
- Document SILO_MIGRATE_TIMEOUT in the example environment
2026-06-15 16:20:50 -04:00
13c5e0ba2f feat(catalog): deterministic cross-server content_id (#155)
* feat(catalog): deterministic cross-server content_id

Replace per-server Sonyflake content_id with a structured natural key
derived from provider IDs (movie:tmdb:…, series:tvdb:…, episode:…,
local:… fallback), so two servers holding the same title share one
anchor for artwork, watch history, progress, favorites and ratings.

- internal/contentid: derivation core, SeriesIDFromContentID transform,
  frozen precedence, SchemeVersion=1, embedded-series-anchor invariant.
- internal/metadata/service.go: deterministic id at every mint site.
- internal/catalog/history_source.go: resolve show via string transform
  for anchored episode ids; skip the episodes_pkey probe.
- migrations/sql/20260612130000: collision-safe value remap across the
  65-column reference graph + COLLATE "C", FK/trigger handling, audit
  map, working down.

Benchmarked against an exact-cardinality copy of cprod-postgres
(1.93M episodes, 775k history rows): 2.57x faster history page, 1.7x
throughput at 100 concurrent users, 2.7x cheaper per content_id probe.

* feat(catalog): re-ID untagged items to deterministic content_id at first match

Untagged libraries get a path-derived local: content_id at scan time and
only learn their provider IDs later, when the match worker confirms a
result. Previously that id was never folded back in, so untagged-then-matched
items kept a per-server local: placeholder forever and never converged across
servers (re-ID was deferred to a migration rerun).

mergeAndPersist now promotes a local: skeleton to its deterministic
provider-anchored id at the moment of first confirmed match, via a single
new gate (canonicalizeLocalContentID):

  - target id already taken  -> merge onto it (existing rebind machinery)
  - target id free           -> rename in place

The rename is a single SQL function (silo_rename_content_id); FK children
follow via ON UPDATE CASCADE added to the content_id family, so a fresh
skeleton moves a handful of rows rather than the full-table remap the bulk
migration does. The guard is one IsLocal prefix check, so tagged content and
all refreshes pay nothing, and the move is self-healing under retry.

Verified: gofmt/vet/build clean; migrate-validate passes; migration applies
on the real schema (up/down/up), FKs gain ON UPDATE CASCADE while keeping
ON DELETE; functional test confirms series PK move + series_id cascade +
provider-id sweep, and movie rename.

Follow-ups (noted in docs): recomposeSeriesChildIDs for a series that
accumulated episodes before matching; a lockstep test for the soft-ref list.

* fix(catalog): harden content_id parsing and merge per review

Address review feedback on the deterministic content_id work:

- history_source.go: gate the anchored-episode display-id transform on the
  full five-part episode shape (split_part parts 2-5 non-empty), not just the
  'episode:' prefix, so a malformed id can't transform to 'series:broken:' and
  vanish at the media_items join. Shared anchoredEpisodePredicate drives both
  the null-poisoned join key and the series-recovery expression.
- contentid.go: unexport the provider-precedence slices so no package can
  mutate the frozen SchemeVersion ordering at runtime.
- contentid.go: add parseAnchored to validate the exact per-kind arity and
  numeric season/episode suffixes; SeriesIDFromContentID and IsProviderAnchored
  now fail closed on truncated/malformed ids (e.g. "episode:tvdb:296762").
- canonicalize.go: distinguish catalog.ErrItemNotFound from transient lookup
  errors (a real error no longer masquerades as "target free"), and allow a
  matched local source to be consolidated onto the canonical row instead of
  orphaning a duplicate.

* refactor(contentid): URL-safe "-" separator in content_id

Use "-" instead of ":" to join content_id components
(movie-tmdb-228064, episode-tvdb-296762-1-5, local-<hex>). "-" is an RFC 3986
unreserved character, so a content_id is URL-safe verbatim: encodeURIComponent
is a no-op and the id is its own tidy path segment (/item/series-tvdb-296762)
with no %3A escaping. The stored value equals the URL value, so there is no
encode/decode boundary and an operator can grep the id straight out of a URL or
log. Every component is [a-z0-9]+ (or "tt"+digits), so "-" is unambiguous.

Pre-release format finalization: this branch is unmerged, so no deployed data
carries ":" ids — the migration mints the "-" form fresh and no re-migration is
needed. Still SchemeVersion 1.

- contentid.go: single `sep` constant drives construction and parsing so the two
  can never drift; all constructors/parsers and doc examples updated.
- history_source.go: split_part transform and the anchored-episode predicate use
  '-'; kept in lockstep with the package via a code comment.
- 20260612130000_deterministic_content_id.sql: derivation and season/episode
  composition emit '-'; LIKE filters match 'series-%'.
- docs/architecture/deterministic-content-id.md: format spec + rationale for the
  separator choice; this is the design doc the change is derived from.

Client-side: the web frontend treats content_id as an opaque string (no
splitting/regex), so no client changes are required; existing
encodeURIComponent call sites simply stop emitting %3A.

* docs(contentid): show why hash/bigint rejected in probe-cost table

Add Cross-server deterministic / Zero-join show transform / Human-readable
columns to the index-probe-cost comparison so the trade-off is legible at a
glance: the 128-bit hash and bigint surrogate are faster but each give up a
load-bearing property, and the structured key is the only all-checkmark row.

* docs(contentid): order probe-cost table to end on the structured key

* docs(contentid): label fenced blocks and drop stray EOF tags

Per CodeRabbit review: add 'text' language to three fenced code blocks
(MD040) and remove accidental </content></invoke> artifacts at EOF.

* fix(catalog): remap array-valued content_id soft references in deterministic id migration

The value-remap migration (20260612130000) enumerates the reference graph by FK
plus a scalar name+type sweep (text/varchar/bpchar). That misses
trending_discover_snapshots.content_ids: it is text[] (excluded by the type
filter), named content_ids not content_id (excluded by the name list), and
cannot carry an FK — so the bulk remap left those arrays holding stale Sonyflake
ids that resolve to nothing until the snapshot regenerates. A counterexample to
the migration's "self-protecting, cannot orphan" invariant.

Remap the array element-wise in both directions (Up old->new, Down new->old),
preserving order and leaving collision/unmatched elements untouched; a WHERE
EXISTS guard skips empty/unaffected arrays so array_agg never collapses the NOT
NULL column to NULL. Mirror the gap in silo_rename_content_id (20260614120000)
with array_replace for the single-value runtime rename so the two stay in
lockstep.

Verified on PG18: mixed/collision/empty arrays remap correctly and round-trip
clean; runtime array_replace preserves order.

Surfaced reviewing #155. The jellycompat restart-decode regression and the
atomicity-wording nit are posted as review comments, not addressed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(jellycompat): pack content_id into compat UUID reversibly so item ids survive restarts

Addresses the restart-decode regression raised in review of #155. With
content_id now a structured string instead of a numeric Sonyflake,
EncodeStringID sent every item/season id down the one-way SHA1 path, making
decode depend on an in-memory reverse map. That map is cold after a process
restart (the codec is a process-lifetime singleton), so a client presenting a
previously-issued item UUID — resume-from-home, deep link, detail page, image,
userdata — got "unknown compat id" until the item was re-listed.

Make the encoding reversible instead of stateful:

- internal/contentid: add Pack/Unpack, a bit-packed, fixed-budget (<=15 byte)
  binary form of a structured or local content_id. digitCount preserves
  provider-id leading zeros (e.g. imdb tt0944947); structured forms are
  self-delimiting; the local form fills the budget exactly. Provider ids that
  overflow uint64 return ok=false.
- Shrink ForLocal to a 112-bit (sha256(path)[:14]) hash so a local id packs
  losslessly into the 15-byte UUID payload. 112 bits is far beyond any single
  server's local-item count. No other code assumed the old width.
- internal/jellycompat: EncodeStringID packs item/season content_ids into the
  UUID (byte 0 = kind, bytes 1..15 = packed, non-zero tag distinguishes it from
  the numeric encoding); DecodeStringID unpacks first and re-packs to confirm,
  so an opaque id whose bytes merely parse is rejected and falls through to the
  map. Numeric ids and arbitrary names (genres, studios) are unchanged.

Net: item/season ids decode by pure computation — stable across restarts and
across instances — with no lookup table. Only the rare unpackable content_id and
non-content names still use the in-memory map.

TDD: round-trip property tests in contentid (all kinds, leading zeros, reject
cases) and a cross-instance decode test in jellycompat that fails on the old
hash+map path. Full contentid + jellycompat suites green; production code
golangci-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migrate): make the migration run timeout configurable (SILO_MIGRATE_TIMEOUT)

The boot-path migration runner hardcoded a 5-minute context timeout. The
deterministic-content-id value-remap (20260612130000) does a full-table COLLATE
rewrite + 65-column remap that needs ~20 min on a real dataset (615k items /
2M episodes), so it was cancelled at 5 min. Worse, Postgres keeps the orphaned
backend running (holding AccessExclusive locks) until it notices the dead client
at a statement boundary, while the goose session advisory lock releases on
disconnect — so each 5-min boot retry piled a new attempt behind the previous
one's locks. The migration never applied; the server boot-looped.

Make the timeout configurable via SILO_MIGRATE_TIMEOUT (a Go duration like
"60m"); 0 or negative disables the deadline for a one-off heavy migration. Default
stays 5m. All three entry points (migrate-status, --migrate-only, boot) honor it.

Required for the deterministic-content-id migration to apply on any real-sized
database, not just dev — the 5m cap made the PR undeployable at scale.

Follow-up (not here): on cancellation the runner should actively terminate its
backend so a future timeout cannot orphan a lock-holding statement.

TDD: MigrationTimeout parsing (default/override/zero/invalid) + MigrationContext
deadline behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(contentid): require exact length for local ids in Unpack

Tighten the tagLocal branch of Unpack from `len(body) < localHashLen` to an
equality check. The local form fills the compat-UUID payload exactly (no
padding), so a body of any other length is non-canonical; matching it exactly
keeps Unpack a strict fail-closed inverse of Pack for the fixed-length branch,
which decodes client-supplied UUIDs.

Not applied to the structured branch (a review suggestion proposed the same
change there): structured ids are self-delimiting and the compat layer pads them
with trailing zeros to fill the 15-byte UUID payload, so ignoring trailing bytes
is intentional and documented. Rejecting them would make every structured id
fail to decode — the jellycompat cross-instance test guards against that.

Not a live bug today (the only caller passes u[1:] from a 16-byte UUID, so body
is always exactly localHashLen, and idcodec re-packs to verify), but it is the
correct contract and zero-risk. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:34:13 -04:00
88aa769fe6 feat(collections): surface server collections on the user Collections tab (#156)
* feat(collections): surface server collections on the user Collections tab

The user-facing Collections tab only showed personal collections, which are
usually empty — leaving most users with a confusingly blank page. Server
(admin-curated) collections were reachable only inside each individual
library's tab.

Add a new GET /collections/server endpoint that aggregates visible library
collections across every accessible library (honoring access scope, capped per
library with a total_count for a See all link), and restructure Collections.tsx
into two titled sections: Your collections (personal) and Server collections
(horizontal teaser rows per library, linking into each library's Collections
tab). Extract the shared CollectionPosterCard so the per-library grid and the
new rows share one implementation.

* fix(collections): match server-collections loading skeleton to row layout

The Server collections section renders as one horizontal teaser row per
library, but the loading skeleton showed a poster grid — so data arriving
visibly reflowed the page from a grid into rows. Mirror the final layout
(section header + per-library rows of poster cards) in the skeleton, and
drop the now-unused COLLECTION_POSTER_GRID_CLASSES import.

Addresses CodeRabbit review comment on PR #156.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Align server collections with shared carousel behavior

- Add opt-out edge padding to reusable media carousels
- Render server collection rows with shared carousel controls and spacing

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:51:12 -04:00
5afe56cfc0 feat(jellycompat): add runtime-managed Jellyfin Web compatibility (#77)
* feat(jellycompat): install web assets at runtime

* fix(jellycompat): recover stale web operation locks

* fix(jellycompat): harden web component management

* feat(admin): refine compat settings and restart status

* chore(dev): add hot-reload docker compose stack

* fix(dev): include npm in hot-reload backend

* feat(admin): refine Jellyfin compatibility settings

* feat(settings): improve jellyfin proxy summary

* feat(settings): improve jellyfin web controls

* fix(settings): update jellyfin web removal status

* fix(settings): enable jellyfin web after install

* feat(jellycompat): auto-select web ui version

* test(api): update rate limit handler setup

* feat(jellycompat): refine web ui install onboarding

* fix(jellycompat): address web ui install review issues

* fix(onboarding): mirror jellyfin api runtime status

* fix(admin): remove global restart banner

* fix(settings): gate restart required tracking

* fix(jellyfin): ignore live settings for restart status

* fix(jellyfin): avoid restart for live compat settings

* fix(subtitles): normalize AI language codes

* fix(catalog): support partial title search tokens

* feat(branding): add white-label customization

* Add push relay engineering plan

- Document relay API contracts, APNs/FCM behavior, auth, storage, and ops
- Capture implementation plan, provider references, decisions, and README

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-15 09:34:08 -04:00
3e77f39d24 ci(v1): auto-label [v1] proposals so fork/CLI filings reach the board (#146)
The issue form only applies labels for web-form submissions; contributors
filing via API/CLI or from a fork lack the triage/write needed to set
labels, so their proposals landed unlabeled and never auto-added to the
Silo v1 project. Add an issues:opened workflow that stamps v1-proposed on
any [v1]-titled issue via the repo GITHUB_TOKEN, regardless of filer
permission.

Also drop epic from the proposal template's auto-labels: a fresh proposal
is a candidate, not yet a tracked epic. epic-ness (better: the Epic issue
type) is applied at acceptance/lock alongside v1 + milestone.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:26:19 -04:00
6f9427d613 docs(process): v1 scope-lock process — proposal template, CODEOWNERS gate, agent instructions (#145)
Implements the process layer of the v1 feature-lock planner: capability
proposals arrive uniform via issue form; the lock artifact
(docs/architecture/v1-scope.md) is CODEOWNERS-gated; the shared
CLAUDE.md/AGENTS.md guidelines gain the scope gate, additive-only API
rules, and pre-push checklist for agent-driven contributions.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:43:23 -04:00
bcf0253c09 feat(notifications): notify requesters of request status changes (#143)
Requests previously only notified the community server channels for
submitted/approved/declined and the requester personally for fulfilled.
This closes the gap and makes request posts addressable:

- New request.approved / request.declined delivery types ride the
  operational dispatch path to the requesting profile: inbox, websocket
  toast, email, Discord DM, personal webhooks (gated by the existing
  notify_requests flag), and web push. Submitted stays broadcast-only
  (the requester performed the action themselves). Title/year/decline
  reason travel in reason_flags since no catalog item exists yet.
- Request status notices are transactional: digest-mode recipients get
  an off-schedule early send (watermark-durable, last_digest_at left
  alone) instead of waiting for the digest hour. Per-episode recipients
  were already immediate via the dispatch nudge.
- At-most-once per (profile, request, type) via a partial unique index
  (migration 20260612100000), mirroring the fulfilled dedupe.
- Server-channel Discord request posts can @mention the requester via
  their OAuth-linked identity (notifications.server_channels.
  mention_requesters, default off). Resolved lazily in the sweep worker
  only when a Discord destination is about to receive the event; the
  ping uses content-level mention with pinned allowed_mentions, and the
  Discord identity never leaks into generic webhook payloads.

Android/Apple clients render the new inbox types with their generic
fallback until they add them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:20:55 -04:00
91c7eb364d fix(partman): self-heal default-partition conflicts instead of crash-looping (#139)
* fix(partman): self-heal default-partition conflicts instead of crash-looping

EnsureFuturePartitions died with log.Fatalf when CREATE ... PARTITION OF
failed with SQLSTATE 23514 (check_violation) — Postgres rejects creating a
daily/weekly partition while the default partition still holds a row that
belongs in the new partition's range. A single stray row therefore turned
every startup into a crash loop with no in-app recovery (operator had to
TRUNCATE the default partition by hand).

On 23514, drain exactly the conflicting rows out of the default partition and
attach a fresh partition for the range, in a single transaction: create a
standalone table (without copying identity so original ids re-insert),
DELETE ... RETURNING the in-range rows into it, then ATTACH. If any step
fails the transaction rolls back atomically — rows return to default
untouched, no partition is created, and the next cleanup tick retries. No row
is ever destroyed.

Both startup call sites (operational_logs, activity_log) downgraded from
log.Fatalf to a warning so a partition hiccup degrades to writing into the
default partition rather than taking the server down.

* fix(partman): lock default partition during default-conflict heal

Without the lock, live writers keep routing rows for the missing (current)
period into the default partition while the heal runs; a row committed
between the drain and the ATTACH re-triggers the same 23514 check violation
and rolls the whole heal back, so the retry tick can spin under steady write
load. Locking only the default leaf (not the parent) blocks those inserts
for the heal's short duration while the rest of the table stays readable
and writable.

Addresses CodeRabbit review on #139.

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

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:04:31 -04:00
QuickandGitHub f4594e902d Merge pull request #140 from Silo-Server/codex/admin-settings-search
[codex] add settings search command palette
2026-06-12 14:50:47 -04:00
Quick 9508449859 feat(web): add settings search command palette 2026-06-12 12:47:55 -04:00
QuickandGitHub 4e521ed2e8 feat(notifications): notification system v1 (#136)
Merge feat/notifications-v1: a durable, profile-scoped notification
system spanning availability detection, fanout, multi-channel
delivery, and the web/admin UI. 19 commits, ~22k lines, 11 migrations.

Core pipeline
- Availability seeding with per-library seed markers so back-catalog
  imports never flood; release_events -> profile interest fanout with
  settling delay and per-series burst caps; FOR UPDATE SKIP LOCKED
  claims for multi-node safety; durable per-profile inbox with
  cross-library dedupe; interest tracked via a userstore decorator so
  every mutation path feeds one chokepoint.
- Request-fulfilled notifications delivered across all channels.
- Feature flags default ON as kill switches; seeding and backfill run
  automatically at startup.

Channels
- In-app inbox with realtime websocket delivery authenticated by
  single-use handshake tickets, unread badge, per-reason preferences.
- Web Push: self-provisioned VAPID keys (private half encrypted at
  rest), RFC 8291 end-to-end encrypted payloads, service worker and
  subscribe flow.
- Outbound webhooks (gated behind admin opt-in): generic JSON with
  HMAC signing and Discord webhooks with rich embeds (posters,
  provider links, ratings); HTTPS-only private-destination guard at
  registration and connect time; URLs/secrets encrypted at rest;
  durable outbox with backoff and consecutive-failure auto-disable.
- Email: shared SMTP core (internal/mail) with branded HTML
  templates; per-profile verified addresses with per-episode or
  digest delivery and unsubscribe links.
- Discord bot DMs with account linking flow.
- Admin server channels broadcasting new content and request
  activity to Discord.

Web/admin UI
- Notifications inbox page, settings with delivery health, sidebar
  badge; admin settings redesigned around pipeline and channel cards
  with email config and synchronous test sends.

Specs in docs/superpowers/plans/notifications/ (APNs/FCM deferred to
v2; remaining follow-ups tracked in the v1.5 roadmap).
2026-06-12 10:38:53 -04:00
QuickandClaude Fable 5 1e3780d4fb fix(notifications): address code review findings
- pin the four new sensitive setting keys (SMTP password, Discord
  secret/bot token, VAPID keypair) in the encryption audit test so a
  future drop from SensitiveSettingKeys fails CI
- bound account-channel digest drains strictly before the stamped
  digest time so consecutive digest windows partition rows exactly,
  instead of recapping rows created at or after the previous stamp
- keep the events websocket open when an event-frame snapshot fails,
  matching the writeSnapshotFrame degrade-gracefully contract
- rename the seed task to Seed Content Availability to match its
  episode+movie seeding behavior
- carry poster_source_path into realtime dispatch rows per the
  DeliveryRow contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:32:21 -04:00
QuickandClaude Fable 5 91e987bcda fix(web): add padding and hierarchy to notification preferences popover
The shared PopoverContent primitive ships with p-0, but the preferences
popover never added its own padding, leaving toggle rows flush against
the border. Add p-4, separate the master switch from category toggles
with a divider, and dim dependent rows while the master switch is off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:54:56 -04:00
QuickandClaude Fable 5 22af261786 feat(web): redesign admin notifications settings around pipeline and channel cards
Replace the flat wall of setting groups with three zones that mirror how
notifications actually flow: a pipeline card framing the master switches
as Record events -> Fan out -> Deliver (with paused-stage explanations),
expandable per-channel cards with collapsed status chips (digest hour,
Discord credential state, failing server channels, SSRF warning), and an
Advanced zone for fanout tuning and retention. Discord's setup wall
becomes a collapsible guide; settings stay editable while a channel is
off so admins can configure before enabling. Same setting keys and form
plumbing throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:47:13 -04:00
QuickandClaude Fable 5 799754dde7 feat(notifications): rich Discord embeds with posters, provider links, and ratings
Upgrade all outbound Discord surfaces (personal webhooks, bot DMs, server
channels, request events) from bare title/description embeds to rich ones:
poster thumbnail, overview teaser, TMDB/IMDb/TVDB links, rating and genre
fields, content-rating footer, and a clickable title URL.

Artwork respects the v1 privacy contract via a new admin poster mode
(notifications.discord.poster_mode): "provider" (default) only emits public
provider-CDN URLs, "server" additionally presigns locally cached posters
from this server's image storage, "off" drops images entirely. Builders
never derive artwork URLs themselves; the sender layer resolves PosterURL
through System.discordPosterURL.

To keep provider-CDN URLs derivable after image caching rewrites
poster_path to a local storage key, media_items gains poster_source_path,
captured during cacheItemImages, preserved across refreshes that keep the
cached poster, and cleared on explicit poster overrides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:32:53 -04:00
QuickandClaude Fable 5 d2ccbcb1a6 fix(web): type server channel event field tables explicitly
tsc -b (the production build) collapses flatMap over the as-const union of
two differently shaped readonly field tuples to unknown, failing the Docker
frontend stage; a bare tsc --noEmit accepted it. Give the field entries one
explicit shared type so flatMap and channel[field.key] resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:10:36 -04:00
QuickandClaude Fable 5 3d2368aed7 feat(notifications): admin server channels broadcasting new content and request activity
Add admin-owned broadcast destinations ("community channels"): Discord or
generic webhooks fed straight from release_events by a per-channel watermark
sweep, announcing newly added movies/episodes as grouped digest posts plus
configurable media request lifecycle events (submitted/approved/declined/
fulfilled).

- Extend release_events with a kind discriminator and add a movie
  availability spine (movie_availability + kind-keyed
  notification_content_seed_state; first full scan seeds silently so
  upgrades never flood the movie back catalog)
- Sweep worker reads events by (created_at, id) cursor with batch-window
  grouping, per-channel backoff, and auto-disable; request events post
  best-effort via new requests.LifecycleNotifier hooks
- Reuse the webhook stack throughout: URL encryption (new AAD namespace),
  SSRF guard, embed limits, HMAC signing; shared type/name validation
  extracted for both services
- Admin CRUD API under /admin/notifications/server-channels and a Server
  Channels section in the notifications admin settings UI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:04:35 -04:00
QuickandClaude Fable 5 beb6b880fc fix(api): move account-level Discord routes out of RequireProfile
The Discord DM channel's prefs, link-init, and unlink endpoints are
account-level — the handlers only read the user ID — but were mounted
inside the /notifications subrouter, whose RequireProfile middleware
400s any request without an X-Profile-Id header. Register them as
static paths on the auth-only group instead, the same coexistence
pattern the public email-link routes already use (static paths win over
the mounted subrouter's wildcards; verified empirically, no middleware
leak onto profile-scoped routes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:06:09 -04:00
QuickandClaude Fable 5 b83df36636 fix(notifications): drain digest window fully and re-check kill switch under claim
Digest sends read one channelFetchLimit page and then stamped
last_digest_at, permanently dropping overflow rows from combined-mode
recaps and slipping digest-only overflow by a day per page. Digest legs
now page listSince until the window is empty before stamping; renderers
already cap displayed items, so large drains stay deliverable.
Per-episode sends keep single-page reads — their watermark-advance
semantics were already correct.

Also re-check the channel's enabled() under the claim lock so flipping
the admin kill switch stops an in-flight pass immediately instead of
after it completes; the existing errChannelUnavailable path aborts the
pass without penalizing the recipient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:05:57 -04:00
QuickandClaude Fable 5 88ddd2a406 feat(notifications): branded HTML email templates
Replace the bare-bones inline HTML in notification, verification, and
admin test emails with a shared branded layout in internal/mail,
matching the web UI's Midnight Cinema theme (dark card shell, wordmark,
mono episode-code badges, white primary CTA). The shell is built for
email clients: tables + inline styles, explicit dark color-scheme,
Outlook-safe button, and a width:100%/max-width pattern so the card
shrinks correctly on phones.

Plain-text bodies, subjects, and the link-free-when-unconfigured
guarantee are unchanged; the admin test email gains an HTML body so the
SMTP test doubles as a design preview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:34:19 -04:00
QuickandClaude Fable 5 ebf3352bda feat(notifications): per-profile email channel with verified addresses
Re-key the email notification channel from login accounts to profiles.
Each profile owns its mode, dispatch watermark, and destination address;
there is deliberately no fallback to the account email, so the account
holder no longer receives mail for every household profile. A profile
receives nothing until its own address is verified.

- Genericize the watermark-sweep engine over a recipient key
  (accountChannel[K]): email keys by profile_id, Discord stays on
  user_id. Delivery reads move into the channel adapters.
- Custom addresses verify via single-use SHA-256-hashed token links
  served by a public endpoint; enabling the channel requires a verified
  address, and clearing the address switches the channel off.
- Addresses are globally unique (case-insensitive): rejected when
  verified for another profile or matching another account's email or
  username. Checked at request time, re-checked at verify time
  (first-to-verify wins), backstopped by a partial unique index.
- Every email carries an RFC 8058 one-click unsubscribe link backed by
  a per-profile capability token, minted lazily under the claim tx.
- Child profiles cannot set addresses (and so receive no email in v1).
- Verification sends are rate limited (1/min, 10/day per profile);
  mail.Message gains custom header support for List-Unsubscribe.
- Migration drops the account-level prefs table without carrying
  opt-ins over, so nobody gets surprise emails post-upgrade.

Android/Apple notification settings need follow-up for the new
profile-scoped response shape and address-management endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:55:30 -04:00
QuickandClaude Fable 5 5f05374a1d feat(notifications): Discord bot DM channel with account linking
Adds Discord direct messages as a notification channel. Users link their
Discord account via OAuth2 (identify scope only, one-time server-side
state rows); a bot delivers their inbox notifications as DMs.

- Extract the email channel's watermark sweep into a generic
  account-channel engine; email and Discord are now thin adapters, so
  the SKIP LOCKED claim / watermark-after-send durability logic exists
  once.
- New internal/discord REST client (token exchange, identity, open DM,
  send message) — no Gateway connection, no new dependencies.
- Opt-in master switch (notifications.discord_enabled, default off)
  gates delivery, linking, capability, and the admin settings reveal.
- Admin UI: credentials (secret + bot token encrypted at rest), dev
  portal setup checklist, bot invite link buttons, and a test button
  that bypasses the settings read cache and is disabled while
  credential edits are unsaved.
- DM failures from missing shared guild (Discord 50007) surface as link
  health in user settings and self-heal via capped backoff.
- New combined mode (per_episode_and_digest) for email and Discord:
  instant sends all day plus a daily digest recapping the whole window
  since the previous digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:51:03 -04:00
QuickandClaude Fable 5 27b9006ef5 feat(notifications): gate user webhooks behind admin opt-in
Letting users point server-originated HTTP at arbitrary destinations is
an admin decision, so notifications.webhooks_enabled now defaults to
off instead of acting as a default-on kill switch. The flag is also
enforced at webhook creation and test sends (delivery was already gated
at enqueue and dispatch); existing webhooks stay manageable while
disabled so rows are never stranded. The admin toggle moves into the
Webhook Guards group with an off default, and the user settings page
hides the Webhooks section entirely when the capability is unavailable,
matching the other channel sections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:10:16 -04:00
QuickandClaude Fable 5 df95e3cb95 feat(notifications): email notification channel
Adds email as a notification channel built on the shared SMTP core
(mail.Sender). Email mode is a per-account preference (off, daily
digest, or per-episode) stored in notification_email_prefs; delivery is
an account-watermark sweep over notification_deliveries that dedupes
cross-profile duplicates, advancing the watermark only after a
successful send. Admin controls cover the channel kill switch, the
per-episode allowance (off coerces those accounts to the digest),
digest hour, and an external URL for deep links inside emails.
Availability is advertised through /notifications/capability and the
user settings page gains an Email section for opt-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:09:37 -04:00
QuickandClaude Fable 5 e5b210589d fix(notifications): address PR #136 review findings
Codex + CodeRabbit review fixes, all verified against current behavior:

- Web Push: single-writer VAPID provisioning via a new conditional
  SetIfAbsent settings write (no split-brain identity across nodes), and
  read/decode failures now surface instead of silently rotating the
  keypair; the eager-provisioning goroutine joins the shutdown WaitGroup
- Web Push: endpoint reassignment purges the previous owner's pending
  attempts inside the upsert transaction, with an ownership re-check at
  send time
- Webhooks: per-profile cap enforced atomically (advisory-locked
  count+insert), typed pgconn unique-violation mapping, create-time
  type/URL mismatch rejection, send-time HTTPS re-check, and Retry-After
  HTTP-date support (shared, clamped parser also used by web push)
- Delivery workers: transient delivery-row lookup errors leave the claim
  to lease expiry instead of permanently failing the attempt
- Interest: history-only imports now feed the index (userstore history
  hooks + completed-history folding in recompute/rebuild), rebuild also
  recomputes existing interest rows so removed sources get cleaned up,
  and failed flush mutations requeue (bounded) instead of dropping
- Retention: read notifications age from read_at, not created_at
- Startup: scan queue workers start only after the availability detector
  is wired, so resumed scans cannot skip availability recording
- mail: settings-store read failures propagate instead of reading as
  "not configured"
- DB: new migration adds episode ordinal/key CHECK constraints
- Web: service worker restricts notification clicks to same-origin URLs,
  preferences popover gets an error+retry state, and the realtime
  profile-rebind backoff grows to 5 minutes to keep shared channels
  stable through notifications-only outages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:16:27 -04:00
QuickandClaude Fable 5 4274220266 feat(web): show web push delivery health in notification settings
Surface per-subscription delivery health on the Notifications settings
page: this browser and each other device now show last-delivered /
last-failed status (failures newer than the last success render in
amber, mirroring webhook health), plus a device count and an explicit
disabled-after-failures state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:05:12 -04:00
QuickandClaude Fable 5 d9e27da59e feat(notifications): request-fulfilled notifications across all channels
Notify the requesting profile once its media request is actually present
in the catalog (roadmap 06, item 2). Completion transitions stay
notification-agnostic; a presence-gated pass at the end of each
reconcile run fires the notice, so it means "watchable in Silo", not
"download finished".

- New System.DispatchOperational: delivery insert + webhook/web-push
  outbox enqueue in one transaction, post-commit multi-dispatch. The
  webhook auto-disable notice now rides the same path (replacing its
  hand-rolled hub publish and the now-removed InsertOperational), which
  also delivers auto-disable notices over web push.
- At-most-once delivery: partial unique index on
  (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at
  marker on media_requests, backfilled for pre-existing completed
  requests so deploys never flood.
- Per-webhook notify_requests toggle (default on) through repo, service,
  API, and settings UI; gated independently of the episode reason flags.
- request.fulfilled rendering in web inbox, realtime toast, web push
  payload, and Discord/generic webhook payloads, deep-linking to the
  matched catalog item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:04:22 -04:00
QuickandClaude Fable 5 4fd21c7aee feat(web): admin settings page for notification controls
Expose every live notifications.* server setting on a new admin
Notifications tab (next to Email): kill switches, fanout tuning,
webhook guards, and retention. Unset kill switches render as enabled
to match the backend defaults, numeric fields surface their effective
defaults, and enabling private webhook destinations shows an SSRF
warning. v1.5 roadmap item 1; no backend changes — all keys are
live-read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:54 -04:00
QuickandClaude Fable 5 b091f0c6c1 feat(notifications): in-app inbox, realtime, webhooks, web push + shared SMTP core
Implements the notification system foundation and all v1 delivery channels
that need no external infrastructure (specs 00/01/04/05 in
docs/superpowers/plans/notifications/):

Foundation (spec 01):
- episode_availability seeding + per-library seed markers: "newly available"
  means newly released to this server, so back-catalog imports and first
  scans never flood (verified on dev: 1.13M episodes seeded silently)
- release_events -> profile_series_interest fanout worker with settling
  delay, per-series burst caps, FOR UPDATE SKIP LOCKED multi-node claims,
  and a guarded last-notified cursor
- interest index maintained via a userstore provider decorator so every
  favorites/watchlist/progress mutation path (REST, jellycompat, imports,
  playback) feeds it; progress writes only recompute on state transitions
- durable per-profile inbox + read state, forward-sync cursor API,
  websocket channel with short-lived single-use handshake tickets
- web UI: sidebar badge, inbox page, toasts, per-profile preferences
- startup/daily tasks: availability seeding, interest rebuild, retention

Outbound webhooks (spec 04):
- Discord embeds (text-only per the v1 privacy contract) and generic
  JSON signed Stripe-style with per-webhook secrets
- HTTPS-only + private-destination guard enforced at registration and at
  connect time (DNS-rebinding mitigation); URLs/secrets encrypted at rest
- durable per-target outbox enqueued in the fanout transaction, lease-based
  claims, 24h exponential retry, 3x-consecutive-4xx auto-disable with an
  in-app notice (loop-guarded)

Web push (spec 05):
- VAPID keypair self-provisioned at startup (single atomic JSON setting,
  private half encrypted at rest) — no third-party accounts needed
- payloads E2E-encrypted (RFC 8291); 404/410 treated as unsubscribe
- service worker + subscribe flow in Settings -> Notifications

Shared SMTP core (internal/mail):
- feature-agnostic mail.Sender over live email.* settings, STARTTLS or
  implicit TLS, encrypted password, admin Email settings page with
  synchronous test send; no consumer yet by design (digest is v1.5)

APNs/FCM (specs 02/03) are deferred to v2; the capability endpoint reports
them unavailable so clients render truthfully.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -04:00
QuickandClaude Fable 5 a24279e3e3 docs(notifications): import notification design docs, add web push + v1.5 specs
Imports the notification system design folder (architecture overview,
release-events/inbox foundation, APNs/FCM relay specs, outbound webhooks)
and adds the Web Push spec (05, implemented in this branch), the shared
outbound-email architecture note, and the v1.5 roadmap (06) covering the
remaining work after APNs/FCM were deferred to v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:46 -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 adeda3c87c docs(notifications): add notification system design plans
Imported the Continuum-era notification specs (durable inbox + websocket
foundation, APNs relay, FCM relay, outbound webhooks) and amended them for
Silo. Amendments from the 2026-06-11 review: wire contracts normalized to
Silo naming, back-catalog seeding and per-series burst suppression, durable
dispatch outbox, cross-library episode dedupe, forward-sync wake API,
ticket-based websocket handshake, relay threat-model additions (egress IP,
keyed collapse IDs), and webhook 4xx/SSRF hardening. Includes a
self-contained design-decisions.html visual overview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:38:29 -04:00
2982 changed files with 84013 additions and 6085 deletions
+5
View File
@@ -42,6 +42,7 @@ POSTGRES_SHM_SIZE=8gb
# Optional host port overrides for docker-compose.yml.
# PORT=8090
# Used only after Jellyfin-compatible app support is enabled by an admin.
# JF_PORT=8096
# PROXY_PORT=8083
# TRANSCODE_PORT=8082
@@ -59,6 +60,9 @@ POSTGRES_SHM_SIZE=8gb
# docs/architecture/secret-encryption.md.
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
# Optional public URL for Silo. If not set, the server will use the IP address of the container.
# SILO_PUBLIC_URL=https://silo.example.com
# Run from source / advanced overrides
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
@@ -72,3 +76,4 @@ POSTGRES_SHM_SIZE=8gb
# Optional server mode override for source runs or standalone worker nodes.
# MODE=integrated
#SILO_MIGRATE_TIMEOUT=60m
+2
View File
@@ -0,0 +1,2 @@
# v1 scope lock — changes to the lock artifact require maintainer review
/docs/architecture/v1-scope.md @Quick104
@@ -0,0 +1,56 @@
name: v1 capability proposal
description: Propose a user-facing capability for the Silo v1 scope lock
title: "[v1] <capability name>"
labels: ["v1-proposed"]
body:
- type: markdown
attributes:
value: |
One proposal per **user-facing capability** (e.g. "Manga library type", "Transcoded playback"),
not per PR or per endpoint. Locked capabilities are what the client teams plan against —
the **API surface** section is mandatory for a capability to be lockable.
Scope state and lock rules: `docs/architecture/v1-scope.md`.
- type: textarea
id: summary
attributes:
label: Summary
description: One paragraph, user-facing language. What can a user do when this ships?
validations:
required: true
- type: textarea
id: value
attributes:
label: User value / why v1
description: Why does v1 need this rather than v1.1?
validations:
required: true
- type: textarea
id: api-surface
attributes:
label: API surface
description: >
Endpoints/payloads clients will call (method + path + notable request/response fields).
Plain list now; OpenAPI pointers once the #135 tooling lands. A capability cannot be
Locked without this section filled in.
placeholder: |
GET /api/v1/manga/{seriesId}/chapters — list chapters (id, index, volume, read_state)
POST /api/v1/watch-progress — records reading progress (existing endpoint, new item type)
validations:
required: true
- type: textarea
id: client-impact
attributes:
label: Client impact (android / apple / web)
description: What must each client build to surface this capability? "None" is a valid answer per client.
validations:
required: true
- type: dropdown
id: size
attributes:
label: Rough size (server-side)
options:
- S
- M
- L
validations:
required: true
+26
View File
@@ -0,0 +1,26 @@
name: v1 proposal labeler
# The issue form applies `v1-proposed` automatically, but only for submissions
# through the web form. Contributors filing via the API/CLI (or from a fork)
# cannot apply labels without triage/write access, so their proposals would land
# unlabeled and never auto-add to the Silo v1 project. This stamps `v1-proposed`
# on any issue whose title starts with `[v1]`, regardless of who filed it.
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
label:
if: startsWith(github.event.issue.title, '[v1]')
runs-on: ubuntu-latest
steps:
- name: Apply v1-proposed
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
run: gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label v1-proposed
+7
View File
@@ -20,6 +20,7 @@ coverage.html
# Temp
/tmp/
*.tmp
.local/
# Worktrees
.worktrees/
@@ -40,6 +41,7 @@ go.work.sum
# Frontend
web/node_modules/
.pnpm-store/
web/dist/
plugins/metadb/dist/
web/*.tsbuildinfo
@@ -58,5 +60,10 @@ logs/
.playwright-mcp/
demo/
docker-compose.dev.yml
# Local-only deployment override (unpublishes bundled redis/postgres host
# ports via `ports: !override []`). Kept out of git so a rebase from main
# never disturbs it and it never lands in a PR; its absence once exposed
# Redis to the internet, so it must persist on the box.
docker-compose.override.yml
.playwright-cli/
output/
+12
View File
@@ -88,3 +88,15 @@ Recent history follows Conventional Commit-style subjects such as `feat(playback
- Silo separates login accounts (`users`) from household profiles; multiple profiles on one account share the same `user_id`.
- Profile `is_primary` marks the household parent for that account; it is not the same as server-wide `admin` role on the user account.
- Point the Vite dev frontend at a remote backend with `VITE_API_PROXY_TARGET` in `web/.env.local` (or inline) when running `make dev-frontend`; the frontend calls relative `/api` URLs proxied by Vite.
## Silo v1 Process (scope lock & API rules)
**API rules — additive-only within `/api/v1`.**
- Never rename or remove a response field, change a field's type, or repurpose a status code on an existing endpoint.
- New functionality adds new fields/endpoints; removals go through the existing Deprecation/Sunset header flow only.
- New features expose capability endpoints (feature detection) rather than relying on version sniffing.
- Contract strategy and tooling: issue #135.
**PR requirements.**
- Link the capability epic or sub-issue the PR serves (`Part of #NNN`). PRs with no linked scope item will be questioned at review.
- One concern per PR; Conventional Commit subject; AI-use disclosure in the PR body.
+6 -3
View File
@@ -50,12 +50,15 @@ RUN apt-get update && \
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list && \
apt-get update && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 git libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get purge -y gnupg && apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /tmp/silo-transcode
RUN mkdir -p /tmp/silo-transcode /var/lib/silo/compat/jellyfin-web
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
COPY --from=build /silo /usr/local/bin/silo
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
EXPOSE 8080 8096 13378
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
+7 -4
View File
@@ -55,13 +55,16 @@ RUN apt-get update && \
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list && \
apt-get update && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 git libvips42 fonts-noto-core fonts-noto-cjk && \
apt-get purge -y gnupg && apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /tmp/silo-transcode
RUN mkdir -p /tmp/silo-transcode /var/lib/silo/compat/jellyfin-web
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
COPY --from=frontend /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
COPY --from=build /silo /usr/local/bin/silo
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
EXPOSE 8080 8080 8096
EXPOSE 8080 8096 13378
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
ENTRYPOINT ["silo"]
+6 -5
View File
@@ -1,4 +1,4 @@
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
@@ -15,7 +15,8 @@ else ifneq ($(wildcard $(SHARED_PLUGIN_SDK_DIR)),)
DEV_PLUGIN_SDK_DIR ?= $(SHARED_PLUGIN_SDK_DIR)
endif
JELLYFIN_WEB_OUTPUT_DIR ?= third_party/jellyfin-web
JELLYFIN_WEB_INSTALL_DIR ?= .local/compat/jellyfin-web
JELLYFIN_WEB_VERSION ?= 10.11.6
# Build version stamping: inject the git revision so the admin Build panel shows a
# version even when Go's VCS metadata isn't embedded (mirrors the Dockerfile ldflags).
@@ -82,9 +83,9 @@ install-hooks:
fi
git config core.hooksPath .githooks
# Fetch and build the pinned Jellyfin Web bundle
jellyfin-web-bundle:
JELLYFIN_WEB_OUTPUT_DIR=$(JELLYFIN_WEB_OUTPUT_DIR) scripts/fetch-jellyfin-web.sh
# Fetch and build the pinned Jellyfin Web component into a gitignored local cache.
jellyfin-web:
go run ./cmd/silo/ compat-web install --dir "$(JELLYFIN_WEB_INSTALL_DIR)" --version "$(JELLYFIN_WEB_VERSION)"
# Read-only preflight for Continuum Docker installs moving to Silo.
migrate-continuum-check:
+12 -4
View File
@@ -1,6 +1,6 @@
# Silo
A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Compatible with Jellyfin/Emby clients (VidHub, Findroid) via a built-in compatibility layer.
A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Includes optional Jellyfin/Emby-compatible app support for clients such as VidHub and Findroid.
Join the community on [Discord](https://discord.gg/4RxuUQAEnW).
@@ -30,7 +30,7 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
docker compose up -d
```
This starts PostgreSQL, Redis, and the integrated Silo server. The app is available at `http://localhost:8090` and the Jellyfin-compatible endpoint at `http://localhost:8096`.
This starts PostgreSQL, Redis, and the integrated Silo server. The app is available at `http://localhost:8090`. Jellyfin-compatible app support is disabled until an administrator enables it in onboarding or admin settings.
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
@@ -252,9 +252,17 @@ cd web && bun run lint
cd web && bun run format:check
```
## License
## License & Trademarks
Silo is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE).
Silo's source code is licensed under the **GNU Affero General Public License
v3.0 or later** (`AGPL-3.0-or-later`) — see [LICENSE](LICENSE).
The **Silo name, logo, and wordmark are trademarks of Silo Media L.L.C.** and
are **not** covered by the AGPL. You're free to fork and redistribute the code,
but forks and redistributions must not use the Silo brand as their identity and
must remove or replace the brand assets. Publishing a Silo-branded app to an app
store requires written permission. See [TRADEMARK.md](TRADEMARK.md) for what's
permitted — including referential use like "compatible with Silo."
## Configuration
+5 -6
View File
@@ -5,11 +5,11 @@ material needed for Silo server builds.
## Jellyfin Web
`third_party/jellyfin-web/10.11.6/` contains a vendored Jellyfin Web build used
for Jellyfin compatibility surfaces. Upstream package notices generated by that
build are preserved next to the corresponding bundles as `*.LICENSE.txt` files.
Keep those notices with the vendored files when updating or redistributing this
directory.
Silo does not bundle Jellyfin Web in this repository or in the default runtime
image. Administrators can explicitly install Jellyfin Web as a separate
compatibility component with `silo compat-web install` or the admin settings UI.
The installer records the upstream source URL, tag, commit SHA, checksum,
license, and source/provenance metadata beside the installed assets.
## Collection Template Posters
@@ -17,4 +17,3 @@ directory.
poster artwork. The artwork is intended to use generic original scenes and
avoid copyrighted movie/show posters, recognizable actors, franchise
characters, provider logos, and readable in-image third-party branding.
+85
View File
@@ -0,0 +1,85 @@
# Silo Trademark & Brand Policy
Silo's source code is free and open source under the **GNU Affero General Public
License v3.0 or later** (`AGPL-3.0-or-later`). You are welcome to use, study,
modify, and redistribute it under those terms.
**That freedom applies to the code — not to the Silo name and brand.** The Silo
trademarks identify the official project and its official builds. This policy
explains how they may and may not be used. It exists for a simple reason:
anyone should be free to fork the software, while users can still trust that
something called "Silo" actually comes from us.
## Marks covered
The following are trademarks of **Silo Media L.L.C.** ("Silo Media"), whether or
not registered:
- the names **Silo** and **Silo Server**
- the **Silo logo** and **wordmark**
- Silo **product and component names**, including **SiloRemote**, **SiloMac**,
and other "Silo…" names
- confusingly similar variations of the above
The AGPL license over the source code **does not grant any license to these
marks.** This reservation is expressly permitted by AGPL-3.0 §7(e).
## Uses that are always fine — no permission needed
- **Referring to Silo truthfully** — "compatible with Silo," "a plugin for
Silo," "built on Silo," "a fork of Silo." Use the word to refer to *our*
project, not to brand *yours*.
- **Redistributing official, unmodified builds** under the Silo name, with all
notices kept intact.
- **Running, modifying, and self-hosting** Silo for yourself or your
organization.
- **Editorial and educational** use — articles, tutorials, talks, reviews.
## Uses that require our written permission
- Distributing a **modified version under the Silo name** (see *Forks* below).
- **Publishing an app to any app store** — Apple App Store, Google Play,
F-Droid, or similar — under the Silo name, logo, or a confusingly similar
identity.
- Using the marks in a **company, product, domain, or app name**, or in any way
that implies Silo Media operates or endorses your offering.
- **Merchandise** or other commercial use of the logo or wordmark.
## Forks must rebrand
You may fork Silo — that is your right under the AGPL. But a **distributed** fork
must not use the Silo name or logo as its identity. Pick your own name and logo,
remove the Silo brand assets (below), and you may state factually that your
project "is based on Silo" or "is a fork of Silo." This is the same model used by
Mozilla, Rust, and many other open-source projects: **open code, protected
identity.**
## Brand assets are not under the AGPL
The Silo logo, wordmark, and icon files are **© 2026 Silo Media L.L.C., all
rights reserved**, and are **not** licensed under the AGPL or any other
open-source license. Any redistribution or fork must **remove or replace** them.
See the `NOTICE` file in the brand-asset directories.
## Official sources
The only official Silo software comes from:
- the **Silo-Server** organization on GitHub — <https://github.com/Silo-Server>
- the official website — <https://siloserver.org>
- official app-store listings published by **Silo Media L.L.C.**
Any app-store listing, download, or "Silo" app that does not originate from the
above is **not** official and is **not** authorized to use the Silo marks.
## Requesting permission / reporting misuse
Email **trademark@siloserver.org** with the details. We're generally glad to
grant reasonable requests — the goal of this policy is only to prevent user
confusion and unauthorized "official-looking" releases.
---
*Silo and the Silo logo are trademarks of Silo Media L.L.C. The **™** symbol is
used with the Silo wordmark until federal registration issues, after which **®**
applies.*
+387 -34
View File
@@ -4,8 +4,10 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"io/fs"
"log"
"log/slog"
@@ -40,6 +42,7 @@ import (
"github.com/Silo-Server/silo-server/internal/audiobooks/podcastfeed"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/autoscan"
"github.com/Silo-Server/silo-server/internal/branding"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/catalogseed"
@@ -54,8 +57,11 @@ import (
"github.com/Silo-Server/silo-server/internal/intromarkers"
"github.com/Silo-Server/silo-server/internal/jellycompat"
"github.com/Silo-Server/silo-server/internal/libraryingest"
"github.com/Silo-Server/silo-server/internal/literaryworks"
"github.com/Silo-Server/silo-server/internal/logfilter"
"github.com/Silo-Server/silo-server/internal/logstream"
"github.com/Silo-Server/silo-server/internal/mail"
"github.com/Silo-Server/silo-server/internal/manga"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/mdblist"
"github.com/Silo-Server/silo-server/internal/metadata"
@@ -165,7 +171,10 @@ func configureOperationalLogging(
}
opsPM := partman.NewManager(pool, "operational_logs", partman.Daily, 3)
if err := opsPM.EnsureFuturePartitions(ctx); err != nil {
log.Fatalf("ensure operational log partitions: %v", err)
// Non-fatal: a partition hiccup must not crash-loop the server (see the
// operational_logs partition incident). Writes fall back to the default
// partition and the periodic cleanup retries EnsureFuturePartitions.
slog.Warn("ensure operational log partitions; continuing in degraded mode", "error", err)
}
var operationalWriter opslog.Writer
@@ -295,7 +304,56 @@ func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *sec
}
}
func runCompatWebCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: silo compat-web {status|install|update|remove}")
}
command := args[0]
flags := flag.NewFlagSet("compat-web "+command, flag.ContinueOnError)
flags.SetOutput(io.Discard)
root := flags.String("dir", config.DefaultJellyfinWebInstallDir, "Jellyfin Web component install root")
version := flags.String("version", config.DefaultJellyfinWebVersion, "Jellyfin Web version without leading v")
source := flags.String("source", jellycompat.DefaultWebSourceURL, "upstream jellyfin-web git repository")
if err := flags.Parse(args[1:]); err != nil {
return err
}
switch command {
case "status":
status := jellycompat.WebComponentStatusForConfig(&config.Config{
JellyfinCompat: config.JellyfinCompatConfig{
Enabled: false,
WebVersion: *version,
WebInstallDir: *root,
WebDir: filepath.Join(*root, "current"),
},
}, map[string]string{
"jellyfin_compat.web_source_url": *source,
})
return json.NewEncoder(os.Stdout).Encode(status)
case "install", "update":
status, err := jellycompat.InstallWebComponent(ctx, jellycompat.WebComponentInstallOptions{
InstallRoot: *root,
SourceURL: *source,
Version: *version,
})
_ = json.NewEncoder(os.Stdout).Encode(status)
return err
case "remove":
return jellycompat.RemoveWebComponent(*root)
default:
return fmt.Errorf("unknown compat-web command %q", command)
}
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "compat-web" {
if err := runCompatWebCommand(context.Background(), os.Args[2:]); err != nil {
log.Fatalf("compat-web: %v", err)
}
return
}
envFile := flag.String("env", ".env", "path to .env bootstrap file")
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
migrateStatus := flag.Bool("migrate-status", false, "show database migration status and exit")
@@ -328,7 +386,7 @@ func main() {
slog.Info("connected to PostgreSQL")
if *migrateStatus {
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
migCtx, migCancel := database.MigrationContext(ctx)
statuses, statusErr := database.MigrationStatuses(migCtx, pool, migrations.FS, "sql")
migCancel()
if statusErr != nil {
@@ -353,7 +411,7 @@ func main() {
}
if *migrateOnly {
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
migCtx, migCancel := database.MigrationContext(ctx)
migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql")
migCancel()
if migErr != nil {
@@ -371,7 +429,7 @@ func main() {
// ciphertext; secondary nodes read whatever the primary encrypted.
isPrimaryNode := bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == ""
if isPrimaryNode {
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
migCtx, migCancel := database.MigrationContext(ctx)
if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql"); migErr != nil {
migCancel()
log.Fatalf("failed to run migrations: %v", migErr)
@@ -462,6 +520,12 @@ func main() {
// still encrypts/decrypts — no raw settings repo may escape into later wiring.
settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
nodeID := resolveNodeIdentity()
catalogSearchStartupSettings, err := catalog.CatalogSearchSettingsFromMap(settings)
if err != nil {
slog.Warn("catalog search: failed to load settings for startup wiring; using postgres", "err", err)
catalogSearchStartupSettings = catalog.DefaultCatalogSearchSettings()
}
activeCatalogSearchProvider := catalog.ActiveCatalogSearchProvider(catalogSearchStartupSettings)
// Step 9: Validate
if err := cfg.Validate(); err != nil {
@@ -595,6 +659,16 @@ func main() {
bootstrapSensitiveValues["redis.url"] = bc.RedisURL
}
// Shared Redis client for components needing raw Redis beyond the event
// bus (websocket handshake tickets, session listing). Nil on Redis-less
// deployments; consumers fall back to in-process implementations.
apiRedisClient, apiRedisErr := cache.NewRedisClient(cfg.Redis)
if apiRedisErr != nil {
slog.Warn("redis client init failed; multi-node websocket tickets disabled", "error", apiRedisErr)
} else if apiRedisClient != nil {
defer func() { _ = apiRedisClient.Close() }()
}
deps := api.Dependencies{
Config: cfg,
LiveConfig: configWatcher.Config,
@@ -605,6 +679,7 @@ func main() {
DB: pool,
SecretCipher: dataCipher,
EventBus: eventBus,
RedisClient: apiRedisClient,
LogStreamHub: logStreamHub,
RealtimeHub: realtimeHub,
EventsHub: eventsHub,
@@ -735,6 +810,11 @@ func main() {
configureS3Clients(cfg, &deps)
}
var literaryWorkService *literaryworks.Service
if deps.DB != nil {
literaryWorkService = literaryworks.NewService(literaryworks.NewRepository(deps.DB))
}
// Step 4: Create scanner (if needed).
if needsScanner && deps.DB != nil {
folderRepo := catalog.NewFolderRepository(deps.DB)
@@ -744,9 +824,11 @@ func main() {
ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)
s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan)
s.SetSearchIndexProvider(activeCatalogSearchProvider)
configWatcher.OnChange(func(_, updated *config.Config) {
s.SetWorkers(updated.Scanner.Workers)
})
s.SetLiteraryWorkLinker(literaryWorkService)
deps.Scanner = s
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
slog.Info("scanner initialized")
@@ -967,6 +1049,7 @@ func main() {
// Step 4b: Create metadata service and match worker (if needed).
var metadataService *metadata.MetadataService
var metadataImageCacheProcessor *metadata.ImageCacheProcessor
var personRefreshService *metadata.PersonRefreshService
var matchWorker *metadata.MatchWorker
var libraryIngestExecutor *libraryingest.Executor
@@ -984,10 +1067,11 @@ func main() {
var episodeRepo *catalog.EpisodeRepository
var audiobookEnricher *audiobooks.Enricher
var ebookEnricher *ebooks.Enricher
var mangaEnricher *manga.Enricher
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
chainRepo := metadata.NewChainRepository(deps.DB)
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB).WithActiveSearchProvider(activeCatalogSearchProvider)
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
seasonRepo = catalog.NewSeasonRepository(deps.DB)
personRepo := catalog.NewPersonRepository(deps.DB)
@@ -997,33 +1081,14 @@ func main() {
<-pluginAutoUpdateDone
imageResolver := metadata.NewPluginImageResolver()
if pluginService != nil {
// Register image resolver sources for all enabled plugin metadata providers.
rows, err := deps.DB.Query(appCtx,
`SELECT pc.plugin_installation_id, pc.capability_id
FROM plugin_capabilities pc
JOIN plugin_installations pi ON pi.id = pc.plugin_installation_id
WHERE pc.capability_type = 'metadata_provider.v1' AND pi.enabled = true`)
if err != nil {
slog.Warn("failed to list capabilities for image resolver registration", "error", err)
} else {
defer rows.Close()
for rows.Next() {
var instID int
var capID string
if err := rows.Scan(&instID, &capID); err != nil {
slog.Warn("failed to scan capability for image resolver", "error", err)
continue
}
source := metadata.NewPluginClientSource(instID, capID, func(
ctx context.Context, installationID int, capabilityID string,
) (metadata.PluginMetadataClient, error) {
return pluginService.MetadataProviderClient(ctx, installationID, capabilityID)
})
imageResolver.RegisterSource(capID, source)
slog.Info("registered plugin image resolver", "capability_id", capID, "installation_id", instID)
if pluginService != nil && pluginInstallationStore != nil {
reloadImageResolvers := func(ctx context.Context) {
if err := reloadPluginImageResolvers(ctx, pluginInstallationStore, imageResolver, pluginService); err != nil {
slog.Warn("failed to reload plugin image resolvers", "error", err)
}
}
pluginService.AddLifecycleHook(reloadImageResolvers)
reloadImageResolvers(appCtx)
}
if deps.S3Public != nil {
presignTTL := cfg.S3.MetadataPresignExpiry
@@ -1073,6 +1138,16 @@ func main() {
personRepo,
providerIDRepo,
)
audiobookEnricher.SetLiteraryWorkLinker(literaryWorkService)
ebookEnricher.SetLiteraryWorkLinker(literaryWorkService)
mangaEnricher = manga.NewEnricher(
deps.DB,
chainRepo,
pluginResolver,
itemRepo,
personRepo,
providerIDRepo,
)
// Always wire the image resolver so plugin-prefixed URLs (e.g.
// metadb://) can be resolved to presigned HTTP URLs in API responses.
@@ -1083,23 +1158,47 @@ func main() {
if deps.S3Public != nil {
imageCacher := imagecache.New(deps.S3Public)
metadataService.SetImageCacher(imageCacher)
imageCacheJobs := metadata.NewImageCacheJobRepository(deps.DB)
metadataService.SetImageCacheJobEnqueuer(imageCacheJobs)
metadataImageCacheProcessor = metadata.NewImageCacheProcessorWithTargets(
imageCacheJobs,
imageCacher,
imageResolver,
metadata.ImageCacheProcessorTargets{
Items: itemRepo,
Seasons: seasonRepo,
Episodes: episodeRepo,
ItemLocalizations: catalog.NewMediaItemLocalizationRepository(deps.DB),
SeasonLocalizations: catalog.NewSeasonLocalizationRepository(deps.DB),
People: personRepo,
},
)
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
metadataImageCacheProcessor.SetEnabled(cfg.Metadata.CacheImages)
configWatcher.OnChange(func(_, updated *config.Config) {
metadataService.SetAutoCacheImages(updated.Metadata.CacheImages)
metadataImageCacheProcessor.SetEnabled(updated.Metadata.CacheImages)
})
if deps.Scanner != nil {
deps.Scanner.SetImageCacher(imageCacher)
}
if cfg.Metadata.CacheImages {
personRefreshService.SetImageCacher(imageCacher)
personRefreshService.SetImageCacheJobEnqueuer(imageCacheJobs)
slog.Info("metadata image caching enabled")
}
if audiobookEnricher != nil {
audiobookEnricher.SetImageCacher(imageCacher)
audiobookEnricher.SetImageCacheJobEnqueuer(imageCacheJobs)
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
}
if ebookEnricher != nil {
ebookEnricher.SetImageCacher(imageCacher)
ebookEnricher.SetImageCacheJobEnqueuer(imageCacheJobs)
}
if mangaEnricher != nil {
mangaEnricher.SetImageCacher(imageCacher)
mangaEnricher.SetImageCacheJobEnqueuer(imageCacheJobs)
}
}
@@ -1208,8 +1307,10 @@ func main() {
cfg.Scanner.MaxConcurrentLibraries,
cfg.Scanner.MaxConcurrentScoped,
)
libraryScanQueue.Start()
defer libraryScanQueue.Stop()
// Started below, after the notification system has attached its
// availability detector to the executor: a scan resumed by the
// workers before that wiring would complete without recording
// episode availability, silently losing release notifications.
deps.LibraryScanQueue = libraryScanQueue
}
if deps.DB != nil && deps.FileRepo != nil && metadataService != nil {
@@ -1271,6 +1372,48 @@ func main() {
}
defer userStoreProvider.Close()
}
// User-facing release notifications. The system reads user state through
// the raw store provider; the provider handed to everything downstream is
// wrapped so every favorites/watchlist/progress mutation (REST handlers,
// jellycompat, imports, playback) feeds the interest index.
var notificationSystem *notifications.System
if deps.DB != nil && userStoreProvider != nil {
notificationScopes := access.NewResolver(
auth.NewUserRepository(deps.DB),
userStoreProvider,
access.NewProfileTokenService(cfg.Auth.JWTSecret, 0),
)
notificationSystem = notifications.NewSystem(
deps.DB,
settingsRepo,
userStoreProvider,
notificationScopes,
auth.NewUserRepository(deps.DB),
deps.EventsHub,
deps.RedisClient,
deps.SecretCipher,
mail.NewSMTPSender(settingsRepo),
)
userStoreProvider = notifications.WrapUserStoreProvider(userStoreProvider, notificationSystem)
deps.Notifications = notificationSystem
if libraryIngestExecutor != nil {
libraryIngestExecutor.SetAvailabilityDetector(notificationSystem.Detector)
}
if needsWorkers {
notificationSystem.Start(appCtx)
defer notificationSystem.Wait()
}
}
// Start the scan queue only now that the availability detector (when
// notifications are enabled) is attached to the ingest executor, so scans
// resumed at startup cannot complete before the detector exists.
if libraryScanQueue != nil {
libraryScanQueue.Start()
defer libraryScanQueue.Stop()
}
if userStoreProvider != nil && pluginService != nil {
deps.PluginUserConfig = plugins.NewUserConfigStore(userStoreProvider, pluginService)
}
@@ -1369,6 +1512,7 @@ func main() {
cfg.Recommendations,
)
deps.Recommender = recEngine
deps.CatalogSearchVectorizer = recEngine
var err error
recWorker, err = recommendations.NewWorker(
@@ -1473,7 +1617,9 @@ func main() {
}
activityPM := partman.NewManager(pool, "activity_log", partman.Weekly, 2)
if err := activityPM.EnsureFuturePartitions(appCtx); err != nil {
log.Fatalf("ensure activity log partitions: %v", err)
// Non-fatal: see the operational_logs partition incident. Writes fall
// back to the default partition and periodic cleanup retries.
slog.Warn("ensure activity log partitions; continuing in degraded mode", "error", err)
}
var activityWriter activitylog.Writer
activityConsumer := activitylog.NewConsumer(pool, nil, logStreamHub)
@@ -1562,6 +1708,9 @@ func main() {
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
}
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
catalogSearchIndexer := catalog.NewCatalogSearchIndexer(deps.DB, settingsRepo)
taskMgr.Register(tasks.NewSyncCatalogSearchIndexTask(catalogSearchIndexer))
taskMgr.Register(tasks.NewRebuildCatalogSearchIndexTask(catalogSearchIndexer))
if deps.IntroAnalyzer != nil {
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
}
@@ -1575,12 +1724,20 @@ func main() {
}
taskMgr.Register(tasks.NewActivityLogCleanupTask(deps.DB, settingsRepo, activityPM))
taskMgr.Register(tasks.NewOperationalLogCleanupTask(deps.DB, settingsRepo, opsPM))
if notificationSystem != nil {
taskMgr.Register(tasks.NewSeedContentAvailabilityTask(notificationSystem))
taskMgr.Register(tasks.NewRebuildReleaseInterestTask(notificationSystem))
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationSystem))
}
if matchWorker != nil {
taskMgr.Register(tasks.NewMatchMediaTask(matchWorker))
}
if refreshWorker != nil && metadataService != nil {
taskMgr.Register(tasks.NewRefreshMetadataTask(refreshWorker, metadataService))
}
if metadataImageCacheProcessor != nil {
taskMgr.Register(tasks.NewCacheMetadataImagesTask(metadataImageCacheProcessor))
}
if pluginAutoUpdater != nil {
taskMgr.Register(tasks.NewCheckPluginUpdatesTask(pluginAutoUpdater))
}
@@ -1614,6 +1771,9 @@ func main() {
)
requestReconcileSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(reconcileResolver))
}
if notificationSystem != nil {
requestReconcileSvc.SetFulfillmentNotifier(notifications.NewRequestFulfillmentNotifier(notificationSystem))
}
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
@@ -1654,6 +1814,9 @@ func main() {
if ebookEnricher != nil {
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
}
if mangaEnricher != nil {
taskMgr.Register(tasks.NewSyncMangaMetadataTask(mangaEnricher))
}
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
if err != nil {
@@ -1839,6 +2002,21 @@ func main() {
}
deps.FrontendFS = distFS
server.WebDistFS = distFS
// White-label branding: one service shared by the API (public read + admin
// upload) and the frontend handler (index.html title, favicon, manifest).
// S3 is optional — pass a nil AssetStore (not the typed-nil *s3client.Client)
// when it isn't configured so text branding still works without it.
if settingsRepo != nil {
var brandingStore branding.AssetStore
if deps.S3Public != nil {
brandingStore = deps.S3Public
}
brandingSvc := branding.NewService(settingsRepo, brandingStore)
deps.BrandingService = brandingSvc
server.Branding = brandingSvc
}
router := api.NewRouter(deps)
// Step 8: Expose Prometheus metrics endpoint (not behind auth).
@@ -1946,7 +2124,7 @@ func main() {
}
var compatSrv *http.Server
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" {
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Enabled && cfg.JellyfinCompat.Listen != "" {
compatDeps := jellycompat.Dependencies{
Config: cfg,
LiveConfig: configWatcher.Config,
@@ -1956,6 +2134,7 @@ func main() {
NodePlanner: deps.NodePlanner,
JWTSecret: cfg.Auth.JWTSecret,
RecWorker: recWorker,
FrontendFS: deps.FrontendFS,
}
// Wire direct dependencies when DB is available.
@@ -2297,6 +2476,180 @@ func configureS3Clients(cfg *config.Config, deps *api.Dependencies) {
}
}
type pluginImageResolverCapabilityStore interface {
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
}
func reloadPluginImageResolvers(
ctx context.Context,
store pluginImageResolverCapabilityStore,
resolver *metadata.PluginImageResolver,
service *plugins.Service,
) error {
if resolver == nil {
return nil
}
if store == nil || service == nil {
resolver.ReplaceSources(nil)
return nil
}
installations, err := store.ListEnabled(ctx)
if err != nil {
return fmt.Errorf("list enabled plugin installations: %w", err)
}
sort.Slice(installations, func(i, j int) bool {
if installations[i] == nil {
return false
}
if installations[j] == nil {
return true
}
return installations[i].ID < installations[j].ID
})
var registrations []metadata.PluginImageResolverSourceRegistration
for _, installation := range installations {
if installation == nil {
continue
}
capabilities, err := store.ListCapabilities(ctx, installation.ID)
if err != nil {
return fmt.Errorf("list image resolver capabilities for installation %d: %w", installation.ID, err)
}
sort.Slice(capabilities, func(i, j int) bool {
if capabilities[i] == nil {
return false
}
if capabilities[j] == nil {
return true
}
if capabilities[i].Type != capabilities[j].Type {
return capabilities[i].Type < capabilities[j].Type
}
return capabilities[i].ID < capabilities[j].ID
})
for _, capability := range capabilities {
if capability == nil {
continue
}
switch capability.Type {
case sdkcapability.ImageResolver:
schemes, priority := imageResolverCapabilityConfig(capability)
if len(schemes) == 0 {
slog.Warn("plugin image resolver capability has no valid schemes",
"installation_id", installation.ID,
"capability_id", capability.ID)
continue
}
for _, scheme := range schemes {
source := metadata.NewPluginClientSource(installation.ID, capability.ID, func(
ctx context.Context, installationID int, capabilityID string,
) (metadata.PluginMetadataClient, error) {
return service.ImageResolverClient(ctx, installationID, capabilityID)
})
registrations = append(registrations, metadata.PluginImageResolverSourceRegistration{
Scheme: scheme,
Source: source,
Kind: metadata.PluginImageResolverSourceExplicit,
Priority: priority,
InstallationID: installation.ID,
CapabilityID: capability.ID,
})
}
case sdkcapability.MetadataProvider:
scheme := strings.TrimSpace(capability.ID)
if !metadata.ValidImageResolverScheme(scheme) {
slog.Warn("skipping legacy metadata image resolver with invalid scheme",
"installation_id", installation.ID,
"capability_id", capability.ID)
continue
}
source := metadata.NewPluginClientSource(installation.ID, capability.ID, func(
ctx context.Context, installationID int, capabilityID string,
) (metadata.PluginMetadataClient, error) {
return service.MetadataProviderClient(ctx, installationID, capabilityID)
})
registrations = append(registrations, metadata.PluginImageResolverSourceRegistration{
Scheme: scheme,
Source: source,
Kind: metadata.PluginImageResolverSourceLegacy,
InstallationID: installation.ID,
CapabilityID: capability.ID,
})
}
}
}
resolver.ReplaceSources(registrations)
slog.Info("reloaded plugin image resolvers", "sources", len(registrations))
return nil
}
func imageResolverCapabilityConfig(capability *plugins.Capability) ([]string, int) {
if capability == nil {
return nil, 0
}
meta := capabilityMetadataFields(capability.Metadata)
return metadataStringList(meta["schemes"]), metadataInt(meta["priority"])
}
func capabilityMetadataFields(raw map[string]any) map[string]any {
if raw == nil {
return nil
}
if nested, ok := raw["metadata"]; ok {
switch typed := nested.(type) {
case map[string]any:
return typed
}
}
return raw
}
func metadataStringList(value any) []string {
var out []string
switch typed := value.(type) {
case []string:
for _, item := range typed {
if scheme := strings.TrimSpace(item); metadata.ValidImageResolverScheme(scheme) {
out = append(out, scheme)
}
}
case []any:
for _, item := range typed {
text, ok := item.(string)
if !ok {
continue
}
if scheme := strings.TrimSpace(text); metadata.ValidImageResolverScheme(scheme) {
out = append(out, scheme)
}
}
}
return out
}
func metadataInt(value any) int {
switch typed := value.(type) {
case int:
return typed
case int32:
return int(typed)
case int64:
return int(typed)
case float64:
return int(typed)
case json.Number:
n, _ := typed.Int64()
return int(n)
default:
return 0
}
}
type markerPluginCapabilityStore interface {
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
+3
View File
@@ -23,6 +23,9 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
PlayMethod: string(s.PlayMethod),
ReportingNode: reportingNode,
ClientIP: s.ClientIP,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
ClientUserAgent: s.ClientUserAgent,
AudioTrackIndex: s.AudioTrackIndex,
TranscodeAudio: s.TranscodeAudio,
StreamBitrateKbps: s.StreamBitrateKbps,
+181
View File
@@ -0,0 +1,181 @@
# Development stack:
# docker compose -f docker-compose.dev.yml up --build
#
# Vite serves the UI at http://localhost:${PORT:-8090} and proxies API
# requests to the Air-reloaded Go backend on the compose network.
name: silo-dev
services:
postgres:
image: pgvector/pgvector:pg18
restart: unless-stopped
shm_size: ${POSTGRES_SHM_SIZE:-2gb}
environment:
POSTGRES_USER: ${POSTGRES_USER:-silo}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo}
POSTGRES_DB: ${POSTGRES_DB:-silo}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- silo-dev-postgres:/var/lib/postgresql
command: ["postgres", "-c", "listen_addresses=*"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-silo}"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:alpine
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- silo-dev-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
silo:
build:
context: .
dockerfile_inline: |
FROM node:22-slim AS node_runtime
FROM golang:1.26
ENV CGO_ENABLED=1
ENV GOPROXY=direct
ENV GOPRIVATE=github.com/Silo-Server/*
ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
ffmpeg \
git \
libvips-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY --from=node_runtime /usr/local/bin/node /usr/local/bin/node
COPY --from=node_runtime /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
RUN go install github.com/air-verse/air@latest
WORKDIR /app
restart: unless-stopped
working_dir: /app
command:
- sh
- -c
- |
mkdir -p tmp web/dist
if [ ! -f web/dist/index.html ]; then
printf '%s\n' '<!doctype html><html><body><div id="root"></div></body></html>' > web/dist/index.html
fi
cat >/tmp/air.docker.toml <<'AIR'
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/silo ./cmd/silo/"
bin = "./tmp/silo"
args_bin = []
include_ext = ["go", "sql", "json"]
include_dir = ["cmd", "internal", "migrations"]
exclude_dir = ["tmp", "vendor", "third_party", "docs", "scripts", "web"]
exclude_regex = ["_test\\.go$"]
delay = 500
stop_on_error = true
kill_delay = 500
send_interrupt = true
[log]
time = true
[misc]
clean_on_exit = true
AIR
air -c /tmp/air.docker.toml
environment:
MODE: integrated
PORT: "8080"
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
REDIS_URL: redis://redis:6379
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
POSTGRES_TUNE: "off"
ports:
- "${BACKEND_PORT:-18080}:8080"
- "${JF_PORT:-8096}:8096"
- "${ABS_PORT:-13378}:13378"
volumes:
- .:/app
- silo-dev-air-tmp:/app/tmp
- silo-dev-go-build:/root/.cache/go-build
- silo-dev-go-mod:/go/pkg/mod
- silo-dev-plugins:/var/lib/silo/plugins
- silo-dev-compat:/var/lib/silo/compat
- silo-dev-transcode:/tmp/silo-transcode
- silo-dev-audiobook-covers:/var/lib/silo/audiobook-covers
- ${MEDIA_ROOT:-/tmp/silo-dev-media}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/api/v1/health"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
env_file:
- path: .env
required: false
web:
build:
context: .
dockerfile_inline: |
FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
restart: unless-stopped
working_dir: /app/web
command: sh -c "pnpm install --frozen-lockfile --store-dir /pnpm/store && pnpm run dev -- --host 0.0.0.0"
environment:
VITE_API_PROXY_TARGET: http://silo:8080
VITE_HMR_CLIENT_PORT: "${PORT:-8090}"
PNPM_STORE_DIR: /pnpm/store
CI: "true"
ports:
- "${PORT:-8090}:5173"
volumes:
- .:/app
- silo-dev-web-node-modules:/app/web/node_modules
- silo-dev-pnpm-store:/pnpm/store
depends_on:
silo:
condition: service_healthy
volumes:
silo-dev-air-tmp:
silo-dev-audiobook-covers:
silo-dev-compat:
silo-dev-go-build:
silo-dev-go-mod:
silo-dev-plugins:
silo-dev-pnpm-store:
silo-dev-postgres:
silo-dev-redis:
silo-dev-transcode:
silo-dev-web-node-modules:
+21
View File
@@ -31,6 +31,26 @@ services:
timeout: 3s
retries: 5
meilisearch:
image: ${MEILISEARCH_IMAGE:-getmeili/meilisearch:latest}
restart: unless-stopped
profiles: [search]
entrypoint:
- /bin/sh
- -euc
- |
if [ -z "$${MEILI_MASTER_KEY:-}" ]; then
echo "MEILI_MASTER_KEY is required when enabling the search profile" >&2
exit 1
fi
exec /bin/meilisearch
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-}
ports:
- "${MEILISEARCH_HOST:-127.0.0.1}:${MEILISEARCH_PORT:-7700}:7700"
volumes:
- ${SILO_DATA_ROOT:-/opt/silo}/meilisearch:/meili_data
silo:
image: ${SILO_IMAGE:-ghcr.io/silo-server/silo-server:latest}
restart: unless-stopped
@@ -53,6 +73,7 @@ services:
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
- ${SILO_DATA_ROOT:-/opt/silo}/compat:/var/lib/silo/compat
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
@@ -0,0 +1,376 @@
# Deterministic, Cross-Server `content_id`
**Status:** Implemented (branch `feat/deterministic-content-id`)
**Scope:** catalog / scanner / metadata
**Key code:** `internal/contentid`, `internal/metadata/service.go`,
`internal/catalog/history_source.go`,
`migrations/sql/20260612130000_deterministic_content_id.sql`,
`migrations/sql/20260614120000_content_id_online_reid.sql`
This is the dev-facing reference for *why* `content_id` is what it is and *how*
it works. It merges the original design spec, the implementation notes, and the
PR write-up into one place.
---
## 1. The problem
`content_id` is the anchor almost everything hangs off: artwork, metadata, watch
history, progress, favorites, ratings, collections, credits. Historically it was
a **Sonyflake** — a locally generated, time-ordered random number minted at scan
time.
That means the *same* movie matched on two servers (or re-imported into a fresh
instance) gets a **different** id each time. Consequence: two servers holding the
identical title share *none* of that state. Run more than one Silo, or try to
sync a profile's history between servers, and the same film shows up as a
stranger on the other side — no resume point, no watched flags, no ratings. There
was no stable identity to build any cross-server feature on.
We want: **the same logical title produces the same `content_id` on every server,
every time**, derived from a globally stable anchor (the provider IDs already in
the library), and **stable across file renames/re-encodes** so linked state
survives.
---
## 2. The decision
Make `content_id` a **deterministic, provider-derived, structured natural key**,
stored as **`text COLLATE "C"`**:
| Item | `content_id` |
| ---- | ------------ |
| Movie | `movie-tmdb-228064` (fallback `movie-imdb-tt2413338`) |
| Series | `series-tvdb-296762` |
| Season | `season-tvdb-296762-1` |
| Episode | `episode-tvdb-296762-1-5` |
| Unmatched / local | `local-<128-bit path hash, hex>` |
Why this shape:
- **Collision-free with no hashing.** Provider IDs are unique by construction, so
this is a natural key, not a surrogate — there is no "how many bits" question
and no birthday bound.
- **No column-type migration.** It stays `text`, so the wide soft-reference graph
keeps working; only the *values* are remapped, not the type.
- **Debuggable.** A row's identity is legible in logs and queries, and the
scanner's match step becomes a direct PK lookup ("do I already have
`movie-tmdb-228064`?") instead of a join through `media_item_provider_ids`.
- **Stable** across renames/re-encodes — a movie keeps its tmdb id.
### Why `content_id`, not a file-level id
Two distinct identities at different granularities, not to be conflated:
- **`content_id`** — the *logical* item (movie/series/season/episode). One id,
*many* files (1080p + 4K + cuts; a series owns thousands of episode files).
- **`media_files.id`** — one *physical* file; a per-server serial that only
answers "which file on disk."
`content_id` is the high-value target (35 FKs + ~25 unconstrained soft references
point at it). Its stability requirement is the *opposite* of a file id's: a file
id *should* change on rename; a `content_id` must *not*, or every server would
drop a title's watch history on a routine rename. That's why it's anchored on
rename-invariant provider IDs, never a path or file hash. File-level dedup is a
separate, deferred concern.
---
## 3. How the id is derived
### Format (SchemeVersion 1)
```text
movie-<provider>-<id> movie-tmdb-228064
series-<provider>-<id> series-tvdb-296762
season-<provider>-<seriesId>-<n> season-tvdb-296762-1
episode-<provider>-<seriesId>-<s>-<e> episode-tvdb-296762-1-5
local-<hex112> local-<sha256(path)[:14]>
```
The component separator is `-`, an [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-2.3)
*unreserved* character. Every component is `[a-z0-9]+` (or `tt` + digits for
IMDb), so `-` is an unambiguous delimiter, and because it never needs
percent-encoding the id doubles as its own tidy URL path segment
(`/item/series-tvdb-296762`) — identical to what is stored in the database, so
there is no encode/decode boundary and an operator can grep the value straight
out of a URL or log. (`:` would be legal in a path too, but `encodeURIComponent`
escapes it to `%3A`; an unreserved separator sidesteps that entirely.)
- The leading entity-type token domain-separates namespaces, so a movie and an
episode can never alias on a coincidental number.
- IDs are normalized: lowercase scheme tokens, whitespace trimmed, IMDb keeps its
`tt` prefix verbatim.
### Frozen provider precedence
Each item anchors on **one** canonical provider, chosen by fixed precedence so
two servers that see the same tags pick the same anchor:
- **Movies:** `tmdb → imdb → tvdb`
- **Series / Season / Episode:** `tvdb → tmdb → imdb` (TVDB is episode-canonical)
Precedence **and** exact string format are frozen behind a code constant
(`contentid.SchemeVersion = 1`), *not* a DB column — any scheme change forces a
full remap that normalizes every row at once, so there is never a mixed-version
population. Staleness is recoverable by recomputing the expected id from the
provider columns and comparing.
### The load-bearing invariant: the show falls out of the episode id
Season/episode ids compose from the **series anchor + season/episode numbers**
(both present in filenames and universally available), *not* their own provider
episode IDs (often missing). Because `episode-<provider>-<seriesId>-<s>-<e>`
embeds the series anchor, the series id is a pure **string transform** of the
episode id — no catalog lookup:
```text
episode-tvdb-296762-1-5 → series-tvdb-296762
movie-tmdb-228064 → movie-tmdb-228064 (movies/series: unchanged)
```
This is relied on by the hot watch-history query (§5). The format must never
break it. (`contentid.SeriesIDFromContentID` is the canonical transform.)
### Where the anchor comes from
For tagged libraries the IDs are in the folder/file name
(`{imdb-tt2413338} {tmdb-228064}`), so both servers derive the same id **from the
path string at scan time, with no API call**. The denormalized
`media_items.{tmdb,imdb,tvdb}_id` columns and the `media_item_provider_ids` link
table are the other sources. `content_id` is *derived from* the link table, not a
replacement — keep the full provider-ID set indexed.
---
## 4. What was built
| Area | File(s) | Summary |
| ---- | ------- | ------- |
| Derivation core | `internal/contentid/contentid.go` | `ForMovie`/`ForSeries`/`ForSeason`/`ForEpisode`/`ForLocal`, `SeriesIDFromContentID`, `IsProviderAnchored`/`IsLocal`, `SchemeVersion`, frozen precedence, normalization. |
| Generation wiring | `internal/metadata/service.go` | `deriveLogicalContentID`/`deriveSeasonContentID`/`deriveEpisodeContentID` replace `idgen.NextID()` at every mint site (skeleton, provider-match, explicit/implicit seasons, episodes, scanner fallback). |
| Re-ID on match | `internal/metadata/canonicalize.go` | Promotes a `local-` placeholder to its deterministic id at first confirmed match. |
| Hot query | `internal/catalog/history_source.go` | Watch-history `display_id` resolves the show via the string transform for anchored episodes; skips the `episodes_pkey` probe. |
| Value-remap migration | `migrations/sql/20260612130000_deterministic_content_id.sql` | Collision-safe Sonyflake → structured remap across the whole reference graph + `COLLATE "C"`, with rollback. |
| Online re-ID migration | `migrations/sql/20260614120000_content_id_online_reid.sql` | Adds `silo_rename_content_id` + `ON UPDATE CASCADE` to the content-id FK family. |
### Generation: determinism at scan time
The scanner already parses provider tags from names, so `createOrFindSkeleton`
derives `movie-tmdb-…`/`series-tvdb-…` immediately — two servers seeing the same
tags mint the same id with no provider API call. This is the primary target
(Radarr/Sonarr/Plex-tagged libraries). Untagged new items get a path-derived
`local-` id keyed on the **file path** (not the folder), so distinct pre-merge
skeletons in a shared folder stay separate while being stable across rescans.
Same-server convergence is preserved by the existing dedup
(`findExistingByProviderIDs`) plus `UpsertTx`'s
`ON CONFLICT (content_id) DO UPDATE` backstop.
Non-movie/series items (audiobook/ebook/podcast) and anchorless items **stay on
Sonyflake** — there's no stable cross-server anchor to derive from, so changing
them gains nothing.
### Re-ID on match: untagged items converge later
A tagged file is born with the right id; an *untagged* file gets a `local-` id
and only learns its provider IDs later when the match worker confirms a result. A
single gate in `mergeAndPersist` (`canonicalizeLocalContentID`) promotes the
placeholder at first confirmed match:
```text
SCAN a new file ← unchanged, still no network call
▼
createOrFindSkeleton ──► tag? YES ► movie-tmdb-123 NO ► local-<hash>
▼
(later) match worker → mergeAndPersist → canonicalizeLocalContentID
│ Is the id still local- AND do we now have provider IDs?
│ no → do nothing
│ yes → Does a row already sit at movie-tmdb-123?
│ yes → MERGE this row into it (rebindItemToExistingItem)
│ no → RENAME local-<hash> → movie-tmdb-123
▼ └─ DB auto-moves child rows (ON UPDATE CASCADE)
rest of mergeAndPersist runs with the corrected id
```
The guard is a single `IsLocal` prefix check, so tagged content and all refreshes
pay nothing; the promotion is self-healing (a partial run re-runs on the next
match). The rename primitive `silo_rename_content_id` reuses the migration's
catalog-driven column enumeration so the two stay in lockstep.
`ON UPDATE CASCADE` only fires when a `content_id` actually changes (essentially
never outside this promotion), so there is no steady-state cost.
### The migration
The column **type does not change** (`text` → `text COLLATE "C"`); the **values**
are remapped via add-then-swap, not in-place PK mutation. It dynamically
enumerates the reference graph (the three PKs, every FK child from
`pg_constraint`, and soft-reference columns by name — **65 columns** on the real
schema), drops/recreates FKs and triggers, and retains a `content_id_migration_map`
audit table for rollback.
**Collisions are never merged.** If two items derive to the same key, or a key is
already taken, those rows keep their Sonyflake id and are flagged `collision` in
the map — the migration can never violate a PK or silently drop a row. Collision
status **cascades** from a series to its seasons/episodes (otherwise a uniquely
numbered episode under a colliding series would get an `episode-…` key whose
embedded series anchor points at a non-existent row).
`COLLATE "C"` is applied to every referencing column in the same migration so
join keys match on both type and collation.
---
## 5. Why `COLLATE "C"` is mandatory
Provider-derived IDs are pure ASCII, but `content_id` inherits the DB default
`en_US.utf8` collation. The design **regresses without `COLLATE "C"`**:
- For **equality / hash joins** (the watch-history join), `en_US.utf8` is
deterministic so PostgreSQL already compares raw bytes — collation is
irrelevant.
- For **ordering** comparisons (B-tree descent on PK probes, `ORDER BY`, range
scans), `en_US.utf8` invokes libc `strcoll` while `C` is a plain `memcmp`.
- Structured keys share long prefixes (`episode-tvdb-296762-…`), which makes
`strcoll` *worse* than on 18-digit Sonyflake strings — so a structured key
under `en_US.utf8` is *slower* than the status quo. Under `C` it's ~3–5×
faster.
So `COLLATE "C"` is load-bearing, not a free upgrade.
### The hot query
The watch-history page query (`history_source.go`) resolves each history row's
episode key to its series, then inner-joins `media_items`, deduped over the
profile's *entire* history. Two facts:
- `DISTINCT ON` can't short-circuit on `LIMIT` — cost is **O(history size)** with
PK probes per row, not O(page).
- The structured id lets us **delete the `LEFT JOIN episodes`** that existed only
to fetch `series_id`: for anchored episode ids the show is a string transform.
The join is null-poisoned for anchored ids (`= NULL` is an unsatisfiable b-tree
scan key) so the planner never descends `episodes_pkey` for them. Legacy/`local-`
ids still fall through the join.
The remaining `JOIN media_items` stays — it's for access/library filtering, not
the show id. Reducing full-history scans to O(page) for the heaviest profiles is
follow-up work (a per-`(profile, display_id)` summary table), not part of this
change.
---
## 6. Performance — measured at production scale
Measured on an isolated, prod-tuned PostgreSQL 18 instance (`shared_buffers=4GB`)
loaded with an **exact-cardinality copy of production**: 184k items, 1.93M
episodes, 775k watch-history rows / 1,622 profiles. "Before" reproduces
production today (Sonyflake, `en_US.utf8`); "after" applies the structured-key
remap + `COLLATE "C"`. **Every metric improves.**
**`content_id` index-probe cost** — 300k forced point lookups over 1.9M keys:
The last three columns are *why* the faster schemes were rejected: speed alone
isn't the goal — the scheme has to carry the properties the whole feature exists
for. ✓ = has the property, ✗ = lacks it.
| Key scheme | Index size | µs / probe | vs today | Cross-server deterministic | Zero-join show transform | Human-readable |
| ---------- | ---------- | ---------- | -------- | :------------------------: | :----------------------: | :------------: |
| Sonyflake text, `en_US.utf8` *(today)* | 74 MB | 5.05 | 1.00× | ✗ | ✗ | ✗ |
| 128-bit hash `bytea(16)` | 74 MB | 1.80 | 2.81× | ✓ | ✗ | ✗ |
| `bigint` surrogate | 41 MB | 1.23 | 4.11× | ✗ | ✗ | ✗ |
| **Structured + `COLLATE "C"` (this PR)** | 84 MB | **1.86** | **2.71×** | ✓ | ✓ | ✓ |
The structured key lands within 3% of a 128-bit hash and is the **only all-✓
row**. The two faster schemes each give up something load-bearing:
- **128-bit hash** is cross-server deterministic (it hashes the same natural
key), but an opaque digest can't be string-transformed, so episode→series
needs a catalog join (no zero-join hot path), and it isn't legible in logs,
URLs, or `psql`.
- **`bigint` surrogate** is just the status quo's narrow integer: fast because
it's 8 bytes, but per-server (not reproducible elsewhere), no embedded anchor
to transform, and not meaningful to a human.
Index grows ~14% (84 vs 74 MB), immaterial.
**Real history-page query** — heaviest profile (25,384 rows):
| State | Latency | `episodes_pkey` probes | Speedup |
| ----- | ------- | ---------------------- | ------- |
| Before | 385 ms | 25,348 | 1.00× |
| **This PR** | **150 ms** | **2,775** | **2.57×** |
**Concurrency** (`pgbench`, 150 real profiles): at 100 concurrent users,
**311 ms / 321 tps → 181 ms / 551 tps** — 1.7× throughput, 42% lower tail
latency. Comparison cost is pure CPU, so cheaper `COLLATE "C"` comparisons raise
the throughput ceiling under load.
*Caveat: warmed (dataset fits in `shared_buffers`); the before/after **ratios**
are the robust result, not absolute tps.*
---
## 7. Known limitations / follow-ups
- **Untagged series children.** A series that accumulated season/episode rows
*before* it matched keeps those children on Sonyflake until a follow-up
`recomposeSeriesChildIDs` pass re-derives them. Rare at first match (children
usually don't exist yet); not yet wired.
- **Cross-provider reconciliation.** A series tagged tmdb-only on one server and
tvdb-only on another still diverges — both paths derive from the denormalized
provider columns. Proper fix: consult `media_item_provider_ids` before walking
the precedence list.
- **O(history) query shape.** The watch-history `DISTINCT ON` over full history is
the real liability at concurrency; the string transform removes a probe but not
the full scan. The O(page) summary table is the fix, deferred.
- **Migration at scale.** Runs in one transaction holding `AccessExclusive` locks
for the COLLATE rewrite + remap — run off-peak; very large installs (10–50M
rows) should use the batched procedure noted in the migration header.
- **`local-` uses the absolute path,** so a same-server remount re-IDs local
items. Acceptable for the best-effort local namespace. The hash is 112 bits
(`sha256(path)[:14]`) so a `local-` id packs losslessly into a compat UUID
(see below).
- **Client-visible id churn.** `jellycompat` encodes `content_id` into the
client-facing UUID, so item ids seen by Android/Apple/Jellyfin clients change at
migration (caches/resume keyed on item id reset once). **silo-android** and
**silo-apple** should be made aware; server-side state is fully remapped. The
encoding is *reversible* — a structured or local `content_id` is bit-packed
into the UUID (`contentid.Pack`/`Unpack`), not hashed into a lookup table — so
a compat item id decodes by pure computation and is stable across server
restarts and across instances. Only arbitrary names (genres, studios) and the
rare `content_id` too large to pack still use the in-memory hash map.
---
## 8. Prior art
- **Plex:** logical identity is a provider-namespaced GUID (`plex://movie/…`,
`com.plexapp.agents.imdb://tt…`) — exactly this shape. (Its per-server integer
`ratingKey` is the non-portable counterexample we avoid.)
- **Jellyfin / Emby:** store namespaced provider IDs and match on them; item GUIDs
are deterministic. Same principle — anchor identity on the provider, not a
per-server sequence.
---
## Appendix — alternatives rejected
Retained so the decision isn't re-litigated:
- **Keep Sonyflake.** Non-deterministic per server — the entire problem.
- **Hash provider tuple → `uuid` (128-bit).** Collision resistance is unnecessary
(input is already unique), the id becomes opaque, and it forces a `text → uuid`
migration across 35 FKs + ~25 soft refs whose failure mode is a seq-scan cliff
(miss one column → `uuid = text` per-row cast → index disabled). Pays a large
migration cost for an ~8-byte width win that only matters at a 50M-row ceiling.
- **Hash provider tuple → `text` hex.** Opaque like the uuid, large like the
natural key — no reason to pick it over the legible structured key.
- **Path or content hash.** A title spans many files at many paths (no single path
to hash), and the hash changes on rename — the opposite of the stability
requirement. Only appropriate for *file-level* identity.
- **Pack provider + numeric id into `bigint`.** Variable-width provider numbers
and S/E packing make the encoding brittle; not worth it over a legible key.
- **File-level deterministic id (`media_files`).** Deferred, not rejected — lower
value (no metadata/watch state hangs off it), with open problems (server-local
mount anchors, same-file-in-two-libraries). A complement, out of scope here.
+49
View File
@@ -0,0 +1,49 @@
# Outbound Email (`internal/mail`)
**Status:** Implemented 2026-06-11
Silo's shared outbound email facility. It is deliberately feature-agnostic: any
feature that sends mail (notification emails, account flows, invites) composes
a `mail.Message` and hands it to the shared `mail.Sender`, so SMTP
configuration, security policy, and diagnostics live in exactly one place.
## Abstraction
```go
type Sender interface {
Enabled(ctx context.Context) bool
Send(ctx context.Context, msg Message) error
}
```
`Message` carries recipients, subject, and text and/or HTML bodies (both set →
multipart/alternative). `Send` returns `mail.ErrNotConfigured` when email is
disabled or incomplete, so features treat email as an optional transport and
degrade gracefully. The SMTP implementation (`mail.NewSMTPSender`) is backed by
`github.com/wneessen/go-mail`.
## Configuration
Live server settings (no restart required; read on every send — volume is
low):
| Key | Default | Notes |
|---|---|---|
| `email.enabled` | `false` | master switch |
| `email.smtp_host` | — | required |
| `email.smtp_port` | `587` | |
| `email.smtp_security` | `starttls` | `starttls` \| `tls` (implicit, port 465) \| `none` |
| `email.smtp_username` | — | empty = no auth |
| `email.smtp_password` | — | encrypted at rest (`SensitiveSettingKeys`) |
| `email.from_address` | — | required |
| `email.from_name` | `Silo` | |
Admin UI: Admin Settings → Connections → Email, including a synchronous test
send (`POST /api/v1/admin/email/test`).
## Adding a consumer
Construct messages in the feature package and send through a `mail.Sender`
dependency. Do not read `email.*` settings from feature code, and check
`Enabled` (or branch on `ErrNotConfigured`) rather than treating a missing
SMTP configuration as an error.
+19
View File
@@ -0,0 +1,19 @@
# Silo v1 Scope
**Status: NOT LOCKED — proposal window open.**
Propose capabilities with the **v1 capability proposal** issue template; triage happens on the
[Silo v1 project](https://github.com/orgs/Silo-Server/projects/5).
When the scope locks, this file becomes the source of truth and will contain:
1. **Locked capabilities** — a table of capability epics (issue links) with one-line scope statements.
2. **API policy** — additive-only within `/api/v1` (no field renames/removals, no type changes,
no status-code repurposing; removals only via the Deprecation/Sunset header flow; capability
endpoints for feature detection). Contract tooling: #135.
3. **Amendment rules** — after lock, this file changes only via PR with code-owner review.
An amendment PR is the exception process: it must say what changes, why it cannot wait
for v1.1, and what it displaces.
Until lock: treat any capability not tracked as `Proposed`/`Locked` on the project as out of scope
for feature PRs (see the scope gate in `CLAUDE.md`).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
# Plan: Personal + Server sections on the user Collections tab
Status: Draft
Branch: `feat/collections-server-section`
Owner: TBD
Commands assume the repository root is the cwd.
## Problem
The user-facing **Collections** tab on the home screen (`web/src/pages/Collections.tsx`)
only shows the signed-in profile's *personal* collections. For most users this list is
empty or nearly empty, so the tab looks broken or pointless.
Meanwhile, server-curated collections (admin-created "library collections") only appear
*inside each individual library's* Collections tab (`web/src/pages/LibraryCollections.tsx`,
served by `GET /library/{id}/collections`). There is no place in the app where a user can
see the collections curated across the whole server — they are effectively hidden behind
per-library navigation.
We want the top-level Collections tab to become the single home for collections:
1. **Your collections** — the user's personal/shared collections, with a large section
title, shown at the top (current behavior, kept intact: grouping, drag-reorder, create,
edit, sync, delete).
2. **Server collections** — below that, a clearly titled section aggregating the visible
server (library) collections across every library the user can access, so they are no
longer buried per-library.
## Current state (what exists today)
### Frontend
- `web/src/pages/Collections.tsx` — the top-level tab. Renders the page header + a
`GroupedCollectionsBoard` of personal collections. Data via `useCollections()` /
`useCollectionGroups()` (`web/src/hooks/queries/collections.ts`), both backed by
`GET /collections` → `CollectionsListResponse { collections, groups }`.
- `web/src/pages/LibraryCollections.tsx` — per-library tab. Renders a poster grid
(`CollectionPosterCard`) grouped by admin groups + ungrouped. Data via
`useLibraryCollections(libraryId)` → `GET /library/{id}/collections` →
`LibraryTabResponse { groups, ungrouped }`. **This is the visual we want to reuse for the
server section** (poster cards, item-count badge, navigate to catalog href).
- `web/src/api/types.ts` — `Collection`, `CollectionGroup`, `CollectionsListResponse`,
`LibraryTabResponse`, `LibraryTabGroup`, `LibraryTabCollection`, `LibraryTabUngrouped`.
- Catalog hrefs: `buildLibraryCollectionCatalogHref(id, title)` and
`buildUserCollectionCatalogHref(id, title)` in `web/src/pages/catalogSearchParams.ts`.
### Backend
- `internal/api/handlers/collections.go` — personal collection handlers under `/collections`
(registered in `internal/api/router.go` ~lines 1700-1726, behind `RequireProfile`).
- `internal/api/handlers/library_collections.go`:
- `HandleListLibraryCollections` (line 813) — builds the per-library `LibraryTabResponse`.
- `requestCanAccessLibrary` (line 3311) — checks `access.GetScope(ctx).AllowedLibraryIDs`
(nil ⇒ access to all libraries).
- `LibraryCollectionRepository.ListAll(ctx, libraryID *int, opts)`
(`internal/catalog/library_collection_repo.go:368`) — **with `libraryID == nil` it
already returns server collections across all libraries** (uses
`libraryCollectionScopeFallbackJoin`), filtering `visibility = 'visible'` unless
`IncludeHidden`. This is the aggregation primitive we need.
- `presignGPURL` / `toLibraryCollectionResponses` — turn stored poster paths into signed
URLs for the response.
## Design decision: aggregate on the server, new endpoint
We add a new **user-facing aggregate endpoint** rather than fanning out N per-library calls
from the client (avoids N round-trips, keeps access/visibility filtering server-side, and
matches the "Performance first / Reliability first" priorities in `CLAUDE.md`).
### New endpoint
`GET /collections/server` (registered next to the other `/collections` routes in
`internal/api/router.go`, behind `RequireProfile`).
Response shape (new types, mirrors the poster-card needs without per-library group noise):
```jsonc
{
"libraries": [
{
"library_id": 3,
"library_name": "Movies",
"total_count": 80, // total visible server collections in this library
"collections": [ // CAPPED teaser slice (see "Scale & pagination")
{
"id": "...",
"title": "...",
"poster_url": "https://signed...",
"poster_thumbhash": "...",
"item_count": 42,
"featured": false
}
]
}
]
}
```
Grouping by library (with `library_name`) is the recommended default: it lets the frontend
render "Server collections" as one **horizontal scrollable row per library**, which keeps a
large catalog legible. (Alternative: a single flat list — simpler UI, but loses provenance
and the per-library "See all" affordance. Recommend per-library grouping.)
### Scale & pagination (many collections per library)
The server section is a **discovery** surface aggregating across *all* accessible libraries
at once, so a full wrapping grid per library does not scale (e.g. 5 libraries × 80
collections = 400 stacked tiles). Decisions:
- **Render each library as a horizontal carousel row**, not a wrapping grid. Heading = the
library name + a **"See all →"** link.
- **Cap the response** at ~20 collections per library and include `total_count`. Ordering of
the capped slice follows the repo's existing `featured DESC, sort_order ASC, title ASC`
("best first"), so the teaser row surfaces featured/curated collections.
- **"See all" links to the existing per-library Collections tab**
(`/library/{id}` Collections tab, served by the unchanged
`GET /library/{id}/collections`), which already renders the full wrapping grid with admin
grouping. We do **not** build a new full-aggregate view — reuse the canonical
"show everything for this library" surface.
- This caps payload size and the per-library query cost, and keeps the home tab fast
("Performance first").
### API contract impact
- `GET /collections` (personal) — **unchanged**.
- `GET /library/{id}/collections` (per-library tab, "See all" target) — **unchanged**;
reused as-is.
- `GET /collections/server` — **new, additive**. No existing response changes; no
Android/Apple contract break.
### Why a separate endpoint, not extending `GET /collections`
It is tempting to make `GET /collections` return both personal and server collections in one
call. We deliberately do **not**, for three reasons:
- **Client safety / semantics.** `GET /collections`'s `collections[]` array is the user's
*editable, personal* shelves — web and the Android/Apple clients render reorder/edit/
delete/sync affordances on it. Mixing read-only server collections into that same array is
a *silent semantic break*: the JSON still parses, so nothing errors, but clients would
offer "delete"/"reorder" on collections the user can't mutate (→ 403/404). A new sibling
field (`{ collections, groups, server }`) would be additive and parse-safe, but still
couples discovery into the personal-management contract.
- **Cache / refetch lifecycle.** `GET /collections` is the heavily-mutated query —
invalidated on every create, edit, reorder, group change, and sync (via
`invalidateUserCollectionQueries`). Server collections don't change on a personal reorder,
so folding them in would refetch the whole server-wide catalog on every personal mutation
("Performance first" violation). Separate endpoints ⇒ independent cache keys + lifecycles.
- **Zero downstream risk.** A brand-new `/collections/server` endpoint cannot break existing
clients, because nothing calls it yet. Consolidating into one call later is a *deliberate
follow-up requiring coordinated Android/Apple work* — and must use a distinct field, never
overload `collections[]`.
### Handler logic (`HandleListServerCollections`)
1. Resolve accessible library IDs: read `access.GetScope(ctx)`. If `AllowedLibraryIDs ==
nil`, the user can see all libraries → list all of them; else restrict to that set.
2. List visible server collections:
- Simplest correct approach: call `repo.ListAll(ctx, nil, ListLibraryCollectionsOptions{})`
once (server-wide, visible-only), then **filter each collection to libraries the user
can access** and bucket by library. This requires knowing each collection's
library membership (`library_collection_libraries`). If `ListAll` does not already
return membership, either (a) add a per-library loop calling
`repo.ListByLibrary(ctx, libID, opts)` for each accessible library (bounded by library
count, each query already does the grouping/sort), or (b) extend the repo to return
library membership. **Recommended: per-accessible-library loop** — reuses the existing,
tested `ListByLibrary` path and naturally yields the per-library buckets and respects
scope. Deduplicate collections that span multiple libraries if we choose flat output;
for per-library output, a collection legitimately appears under each of its libraries.
3. For each library, set `total_count` to the full visible count, then **cap
`collections` to ~20** (keep the repo's `featured DESC, sort_order ASC, title ASC`
ordering so featured/curated collections lead). Define the cap as a single named
constant.
4. Resolve library display names (catalog/library store lookup) for `library_name`.
5. Presign poster URLs via the existing `presignGPURL` helper — only for the capped slice,
so we never presign 100 posters we won't send.
6. Skip empty libraries. Return `200` with `{ libraries: [] }` when nothing is visible.
Reuse existing helpers (`presignGPURL`, sort helpers like `applyCollectionSort`) rather than
duplicating logic. Add the new response structs next to the existing
`libraryTabResponse` types in `library_collections.go`, or in `collections.go` if cleaner —
keep them in the package that owns the behavior.
## Frontend changes
### Types (`web/src/api/types.ts`)
Add:
```ts
export interface ServerCollectionsLibrary {
library_id: number;
library_name: string;
total_count: number; // total visible; collections[] is a capped teaser slice
collections: LibraryTabCollection[]; // reuse existing shape
}
export interface ServerCollectionsResponse {
libraries: ServerCollectionsLibrary[];
}
```
### Query hook (`web/src/hooks/queries/collections.ts` + `keys.ts`)
- Add `collectionKeys.server()` to `web/src/hooks/queries/keys.ts`.
- Add `useServerCollections()` → `GET /collections/server` →
`ServerCollectionsResponse`. Independent query key from `/collections`, so the personal
section keeps its single round-trip and the server section loads in parallel.
### Page (`web/src/pages/Collections.tsx`)
Restructure into two titled sections inside the existing `page-shell`:
1. **"Your collections"** section
- Add a large section title (e.g. `<h2 class="page-title ...">Your collections</h2>`)
above the existing `GroupedCollectionsBoard` / empty-state block. Keep all current
personal-collection behavior unchanged (create, group, reorder, sync, delete).
- The page-level `<h1>Collections</h1>` header + subtitle + action buttons stay at the
very top.
2. **"Server collections"** section (new)
- Title `<h2>Server collections</h2>` with a short subtitle ("Curated across every
library on this server").
- Use `useServerCollections()`. Render **one horizontal scrollable row per library**
(carousel), not a wrapping grid. Each poster card reuses the **same visual as
`LibraryCollections.tsx`** — extract `CollectionPosterCard` into a shared component so
both pages use it (avoids duplicate logic per `CLAUDE.md` maintainability rule).
Proposed location: `web/src/components/collections/CollectionPosterCard.tsx`.
- For each `library` bucket: a sub-heading row with `library_name` and a **"See all →"**
link routing to that library's existing Collections tab (the unchanged
`GET /library/{id}/collections` surface). Show "See all" only when
`total_count > collections.length`; optionally include the count, e.g. "See all (80)".
- Cards navigate via `buildLibraryCollectionCatalogHref(id, title)` and show the
item-count badge. These are admin/library collections (`kind="regular"`); the
sidebar-pin affordance can remain — pass `library.library_id` as `libraryId`.
- Use the project's existing horizontal-row/carousel pattern if one exists (check
`web/src/components` for a shelf/row component used on the home screen) rather than
hand-rolling overflow scrolling; reuse it for consistent snap/scroll behavior.
- Loading: skeleton row(s) (reuse the existing skeleton block pattern).
- Empty: if `libraries` is empty, render nothing (or a muted "No server collections yet"
line) — do not show a scary empty state, since the personal section above may have
content.
### Refactor note
Extracting `CollectionPosterCard` is the only shared-logic extraction required. Keep the
extraction behavior-identical for `LibraryCollections.tsx` (props: `collection`, `kind`,
`libraryId`). The per-library tab keeps its wrapping grid; only the new server section uses
horizontal rows.
## Out of scope / non-goals
- No changes to how admin/library collections are created or to per-library tabs.
- No reordering/editing of server collections from the user Collections tab (read-only).
- No new visibility model — we honor existing `visibility = 'visible'` + access scope.
## Risks / follow-ups
- **Access scope correctness**: must not leak collections from libraries outside
`AllowedLibraryIDs`. Mitigate by driving the query from accessible library IDs, and add a
test for a restricted-scope user.
- **Collections spanning multiple libraries** appear once per library in per-library output;
confirm this is the desired UX (vs. dedup). Decide before implementation.
- **Performance**: per-accessible-library loop is N queries. Acceptable for typical library
counts; if N grows large, revisit with a single membership-aware query. Add an index check
on `library_collection_libraries(library_id)` (likely already present).
- **Client parity**: this is a new server endpoint and a web-only UI change. Android/Apple
clients have their own collections surfaces — file follow-up issues if they should mirror
the personal+server split. No API contract break (purely additive endpoint).
- **Empty-state UX** for users with no personal AND no server collections — ensure the page
still reads sensibly.
## Verification
- Backend: `go build ./...`, `go vet ./...`, and a handler/unit test for
`HandleListServerCollections` covering (a) full access, (b) restricted scope, (c) empty.
- Frontend: `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`.
- Manual: log in as a normal profile with empty personal collections; confirm the
"Your collections" section shows the empty/create state and the "Server collections"
section lists library collections grouped by library with correct posters, counts, and
working navigation into each collection's catalog view.
## Implementation order
1. Backend: response types + `HandleListServerCollections` + route registration.
2. Backend test for access-scope filtering.
3. Frontend: types + `useServerCollections` hook + `collectionKeys.server()`.
4. Frontend: extract shared `CollectionPosterCard`; refactor `LibraryCollections.tsx`.
5. Frontend: restructure `Collections.tsx` into the two titled sections.
6. Lint/format/build/manual verification.
@@ -0,0 +1,311 @@
# Plan: interleave `transcribe_translate` so live AI subtitles follow the playhead
Issue: https://github.com/Silo-Server/silo-server/issues/154
Area: `internal/subtitles/ai` (+ `internal/playback` extraction, `internal/config`)
Type: bug fix to an existing (shipped) capability — makes the chained AI-subtitle
path deliver its designed live behavior. No new client-facing contract.
Commands assume the repository root is the cwd.
## Problem (confirmed)
`Service.runTranscribe` (`internal/subtitles/ai/service.go`) runs the chained
`transcribe_translate` kind as two **global, sequential** stages:
1. Transcribe the **entire** file (progress 5%→70%). Transcript cues are not
streamed for the chained kind —
`streamTranscript := streaming && job.Kind == JobKindTranscribe`
(`service.go:417`), so it is `false` for `transcribe_translate`.
2. Only then translate **all** cues (70%→95%), streaming each translated batch
as it lands (`service.go:482-493`).
Because translation cannot start until step 1 finishes for the whole file, the
first translated cue streams minutes into the job, long past the playhead.
### Two distinct bottlenecks (both must be addressed)
- **Transcription stage is global.** Translation waits for the whole-file ASR
pass. This is the dominant term for a feature film (~20 min ASR) and the
primary cause. Fixed by interleaving translation per chunk (Phase 1).
- **Extraction is global and precedes any callback.** `Transcribe` calls
`t.extract(...)` (`transcriber.go:124`) before the `onChunk` loop, and
`ExtractAudioChunks` blocks on a single whole-track ffmpeg pass
(`internal/playback/audio_extract.go:34,65`). So even with per-chunk
interleave and small chunks, the first cue still waits for full-file
extraction (~1–2 min for a 2h film). Fixed by playhead-first incremental
extraction (Phase 2).
The transcriber already exposes the per-chunk seam: `Transcribe` invokes
`onChunk(cues, done, total)` per chunk, playhead-first (`transcriber.go:111-174`),
and plain `transcribe` already streams through it (`service.go:426-429`).
Secondary defect found while confirming: `WhisperTranscriber.SetExtraction`
clamps any `chunkSeconds` outside `[60, 600]` to the **default 600**
(`transcriber.go:100-102`), so a small live chunk value silently becomes 10 min
instead of clamping to the floor.
## Goal & honest latency targets
Translated cues stream playhead-first for a session-attached
`transcribe_translate` job, instead of waiting for whole-file transcription.
Time-to-first-translated-cue, by phase:
- **Today:** `extract(full) + transcribe(full) + translate(first batch)` — many
minutes (≈ full ASR pass).
- **After Phase 1 (interleave, existing full extraction):**
`extract(full) + transcribe(first chunk) + translate(first chunk)` — removes
the whole-file ASR pass from the critical path; ≈ extraction time (~1–2 min for
a 2h film), then cues follow the playhead. Already usable for live after a
one-time wait.
- **After Phase 2 (playhead-first incremental extraction):**
`extract(one chunk near playhead) + transcribe + translate` — single-digit
seconds. This is what the issue means by "within seconds."
Phase 1 delivers most of the value with low risk; Phase 2 closes the remaining
extraction latency. Ship Phase 1 first; do not claim "within seconds" until
Phase 2 lands.
### Non-goals (this plan)
- Pacing/throttling to the playhead (rolling window). Optional in the issue;
follow-up below.
- Cross-chunk translation-context continuity beyond batch context neighbours.
## Decision: interleave only when streaming
**Session-attached (live) jobs** use the interleaved per-chunk path.
**Background (non-streaming) `transcribe_translate` jobs keep the current
whole-file transcribe-then-translate-at-end path unchanged.** Rationale: the
issue is live-only; this preserves background output byte-for-byte (full
cross-file translation context, no incremental-extraction cost) and keeps the
two behaviors cleanly separated by the existing `streaming` boolean. The
shared transcript-storage step is factored so it is not duplicated.
---
## Phase 1 — interleave translation per chunk (streaming jobs)
### 1a. `internal/subtitles/ai/service.go` — `runTranscribe`
For the chained kind **when `streaming`**, replace the kind-specific `onChunk`
body (`service.go:423-430`) with per-chunk translate-and-stream:
- Capture a translate error in a closure: `var translateErr error` and an
accumulator `var translatedAccum []SubtitleCue`.
- In `onChunk(chunk, done, total)`:
- If `translateErr != nil` or `len(chunk) == 0`, return (skip silence; stop
translating after a prior failure). Do **not** abort transcription.
- Else call `s.translator.Translate` on **that chunk's** cues. On error: set
`translateErr`, emit `TranslationFailed` once, and return (let transcription
continue). On success: stream translated cues via `TranslationCues` and
append to `translatedAccum`.
- Progress: single 5%→95% band keyed on chunk `done/total`
(message "Transcribing & translating").
- Keep `onChunk` signature unchanged (no error return). Aborting on translate
failure via the callback is intentionally avoided — see 1b.
After `Transcribe` returns:
- **Always store the transcript track** (unchanged, `service.go:447-466`,
including the transcript `SubtitleReady` broadcast). This must run regardless
of `translateErr`.
- If `translateErr != nil`: `finishWithError(ctx, job, translateErr)`. The
transcript is already persisted (cache preserved), and the live session
already received `TranslationFailed`.
- Else: assemble the translated track from `translatedAccum` (`sortCuesByStart`
+ `StoreSubtitle`, `service.go:499-514`), `CompleteJob`, and
`TranslationCompleted` (`service.go:515-522`).
- Remove the whole-file translate-at-end (`service.go:479-497`) for the
streaming path only.
Non-streaming chained jobs fall through to the existing whole-file path,
untouched.
### 1b. Transcript-on-translation-failure (resolves review P1)
Today the full transcript is stored before translation starts
(`service.go:447` then `:482`), so a translation failure still leaves a
transcript/cache row and an already-broadcast `SubtitleReady`. The interleaved
path preserves this exactly because:
- a per-chunk translate failure sets `translateErr` but lets the ASR pass run to
completion (it does **not** return an error from `onChunk`), so `Transcribe`
returns the full cue set normally;
- the transcript is then stored on the same post-transcribe path as today,
before the job is failed for the translated track.
An ASR failure or context cancellation still returns an error from `Transcribe`
and yields no transcript — identical to today.
### 1c. Live progress semantics (resolves review P2)
`SubtitleTranslationCuesPayload.Done/Total` is documented as overall progress
(`internal/playback/realtime.go:172-181`). For live `transcribe_translate`:
- `TranslationCues` carries **`done = chunks completed, total = total chunk
count`** — the same chunk-granular convention plain `transcribe` already emits
(`service.go:427-428`). Monotonic and stable across chunks.
- `TranslationStarted.TotalCues = 0` (indeterminate), consistent with the
transcribe path today (`service.go:412`), since the cue total is unknown before
ASR completes.
- Cues carry absolute Start/End, so the client places them regardless of
arrival order; unpause is driven by cue arrival near the playhead, not by
`Done/Total`. Confirm with client teams that `Done/Total` is treated as a
fraction, not absolute cue indices (clients already receive chunk counts from
the plain `transcribe` path, so this should hold).
### 1d. Small live chunks + clamp fix
- Add `TranscribeJobRequest.ChunkSeconds int` (0 = configured default). In
`Transcribe`, use `req.ChunkSeconds` when `> 0`, else the atomic default
(`transcriber.go:121-122`); validate/clamp identically to `SetExtraction`.
- Fix the clamp in `SetExtraction` (`transcriber.go:99-105`): clamp
out-of-range to the nearest bound, not to the 600 default.
- Lower `minASRChunkSeconds` from 60 to 15 (`transcriber.go:26`); update the
doc comment about request count / boundary word-clip tradeoff.
- Config: add `subtitle_ai.live_asr_chunk_seconds` (default 30):
- `internal/config/config.go`: new field beside `ASRChunkSeconds`
(`config.go:274-277`).
- `internal/config/db_loader.go`: load it next to `asr_chunk_seconds`
(`db_loader.go:472-476`).
- `internal/subtitles/ai/config.go`: add `LiveASRChunkSeconds int` to the
service `Config` so the service can set `req.ChunkSeconds` for streaming jobs.
- `internal/api/router.go`: plumb through `effectiveSubtitleAIConfig` and the
`OnConfigChange` `UpdateConfig` (`router.go:1024-1055`).
### 1e. Phase 1 tests
- `service_test.go`: fake `Transcriber` emitting several chunks playhead-first
via `onChunk`; fake `Translator` recording calls. For a streaming
`transcribe_translate` job assert: one `Translate` per non-empty chunk; first
`TranslationCues` streamed before the last chunk is transcribed; empty chunks
skipped; final stored translated track = all translated cues sorted by start;
`Done/Total` is chunk-granular and monotonic.
- Failure case: a translate error on chunk N sets the failure but transcription
completes, the transcript track **is** stored, `TranslationFailed` is emitted,
and the job ends via `finishWithError`.
- Non-streaming `transcribe_translate`: unchanged whole-file path; correct final
track; no `TranslationCues`.
- `transcriber_test.go`: `SetExtraction` clamp fix and `ChunkSeconds` override
(incl. the new 15s floor).
---
## Phase 2 — playhead-first incremental extraction (true seconds latency)
Phase 1 still waits on full-file extraction before the first cue. Phase 2
removes that by extracting and consuming chunks incrementally, seeked to the
playhead.
### 2a. New incremental extractor in `internal/playback`
Add a streaming, seek-based extractor alongside `ExtractAudioChunks`:
```
ExtractAudioChunksFrom(ctx, filePath, audioTrackIndex, dir, ffmpegPath,
startSec float64, chunkSeconds int,
onSegment func(AudioChunk) error) error
```
- Runs one ffmpeg pass with `-ss <startSec> -i <file> ... -f segment
-segment_list segments.csv` covering `startSec`→end.
- Consumes segments **as they complete**: poll the segment-list CSV (ffmpeg
appends `filename,start,end` when it closes each segment) on a short interval;
for each new row, invoke `onSegment(AudioChunk{Path, Start: startSec + csvStart})`.
Reconcile any trailing rows after `cmd.Wait()` so the final segment is not
missed.
- Honors `ctx` (CommandContext kills ffmpeg on cancel) and `onSegment` errors
(stop the pass). Caller owns `dir`; segments are deleted by the consumer after
ASR to cap disk at one extraction.
- Preserves exact per-segment starts from the CSV, so timing does not accumulate
drift within a pass (only the initial `-ss` seek is packet-accurate, sub-second,
and is further corrected by the existing `ProbeAudioStartOffset`).
Rejected alternative — **per-chunk seek extraction** (`-ss s -t d` per chunk):
simpler control flow but O(n) ffmpeg spawns and packet-aligned per-chunk seeks
that can clip words at every seam. The single-pass streaming consumer keeps the
existing segment-muxer boundary quality and far fewer process spawns.
### 2b. Transcriber incremental mode
- Add `TranscribeJobRequest.Incremental bool`, set by the service for streaming
jobs.
- When `Incremental`, `Transcribe` drives playhead-first in two streaming passes
(reproducing today's `chunkOrderForPosition` order): pass A `startSec =
playhead → end`, then pass B `0 → playhead`. Each completed segment is fed
straight into the existing per-chunk ASR loop body (transcribe → `onChunk`),
which Phase 1 already wires to translate+stream. The full cue set is still
accumulated and returned for transcript storage.
- When not `Incremental` (background, and plain transcribe unless we opt it in),
keep the existing whole-file `ExtractAudioChunks` + `chunkOrderForPosition`
path.
Note: timing composition (`-ss` reset PTS + segment CSV start + probe offset)
must be verified with a fixture; see tests.
### 2c. Phase 2 tests
- `audio_extract` test (or transcriber test with an injected extractor) covering
incremental emission order, exact-start mapping with a `startSec` offset, and
ctx-cancel mid-pass.
- Transcriber test asserting `Incremental` yields playhead-first chunk order
across the two passes and returns the complete cue set for transcript storage.
- Confirm first `onChunk` fires after ~one chunk of audio, not after full-file
extraction (timing/ordering assertion via a fake extractor, not wall-clock).
---
## Multi-repo / client impact
None expected to the contract. Reuses the existing notifier protocol
(`TranslationStarted` / `TranslationCues` / `TranslationCompleted` /
`TranslationFailed`, `notifier.go`) under the same translated-track `trackKey`.
Translated cues simply begin arriving far earlier, and `Done/Total` keeps the
chunk-granular meaning plain `transcribe` already uses (1c). Worth a smoke test
on one `silo-android` / `silo-apple` client to confirm the live track renders
cues arriving before the transcript track completes, and that `Done/Total` is
treated as a fraction.
## Risks & tradeoffs
- **Translation context at chunk boundaries (live only).** Per-chunk translation
resets the batch context-neighbour window at each ~30s boundary — accepted
quality tradeoff for live; background jobs are unchanged.
- **More/smaller ASR requests for live jobs.** 30s chunks mean more requests;
per-chunk timeout (`chunkSeconds * asrChunkTimeoutFactor`) and per-spawn
overhead scale; the 15s floor bounds the worst case.
- **Incremental extraction reliability (Phase 2).** Segment-list polling + the
`-ss` seek path need fixtures for boundary timing, the final-segment race, and
cancel/cleanup. Single ffmpeg pass per direction keeps cost low.
- **Boundary word-clips.** Smaller live chunks add more fixed-length boundaries
where a straddling word can clip — already a documented v1 limitation; the
silence-aligned follow-up would address it.
## Out of scope / follow-ups
- **Pace to playback (issue item 3):** rolling window ahead of the playhead to
bound work; separate change once interleave + incremental extraction land.
- **Cross-chunk translation context:** feed the previous chunk's tail source
cues as untranslated context into each chunk's translate call.
- **Silence-aligned chunk boundaries:** reduce boundary word-clips that smaller
live chunks make more frequent.
## v1 scope note
`docs/architecture/v1-scope.md` is **NOT LOCKED**; the issue carries
`v1-proposed`. This is a **bug fix** to an already-shipped capability (no new
user-facing capability, endpoint, or response field — only an internal config
key and corrected streaming behavior), so it proceeds under the "bug fixes
proceed normally" clause. Link the PR to the AI-subtitle capability item per the
v1 PR requirements.
## Pre-push checklist
- `make lint`
- `cd web && pnpm run lint && pnpm run format:check` (run only if web touched —
none expected)
- `make verify-local-paths`
- Go tests for `internal/subtitles/ai` and `internal/playback` in a
libvips-capable container (a bare-host `go test ./...` silently skips CGO
packages).
@@ -0,0 +1,188 @@
# Personal Collection Catalog Filter Standardization - Implementation Plan
- **Date:** 2026-06-23
- **Spec:** `docs/superpowers/specs/2026-06-23-personal-collection-catalog-filter-standardization-design.md` (rev. 3, post-adversarial-review). The spec owns the *why* and the decisions; this plan owns the *how*, with concrete files and ordering.
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
## Approach & PR Boundaries
Three concerns, landed in order. PR 1 is independently valuable and must merge first because PR 2 depends on its hardened predicate for correctness.
| PR | Concern | Depends on |
|----|---------|-----------|
| **PR 1** | Harden the catalog `watched` predicate (series/season rollup + row-type branching) and pin equivalence with item user-data. | — |
| **PR 2** | Replace the `watch_filter` / `media_filter` enum vocabulary with a `display_query_definition` fragment end to end (migration → storage → API → execution → web). | PR 1 |
| **PR 3** (deferred) | Wire display filters for smart/live collections via an executor-level `(source) AND (display)` conjunction. | PR 2 |
**Why PR 3 is deferred:** display-filter controls are currently exposed only for manual/exact and imported collections (`CollectionBuilder.tsx` renders them only when `collection_type === "manual"`; imported uses `ImportedCollectionEditor.tsx`). Smart collections never reach `FilterUserCollectionDisplayItems` today (`catalog_resolver.go` branches smart collections to `resolveQuerySource` before the filter call). So the OR-leak the adversarial review found is a guardrail for *future* wiring, not a v1 path. Build PR 2 so the same conjunction helper is reusable, but do not expose smart-collection display filters until PR 3.
---
## PR 1 — Catalog Watched Predicate Hardening
**Goal:** make the catalog `watched` rule produce the same played/read verdict as item user-data (`internal/api/handlers/user_state.go`) for every item kind, under both scoped and unscoped queries. This is the single-semantic foundation; everything else routes through it.
### Current state (verified)
- `internal/catalog/query_builder.go`:
- `buildWatchedClause` (≈900–912) wraps `userStateCompletionClause` and negates for `value:false`.
- `userStateCompletionClause` (≈1119–1171) checks completion against **the row's own `content_id`** (`uwp.media_item_id = mi.content_id` / `uwh.media_item_id = mi.content_id`). No episode rollup → a series row is effectively never watched.
- `ebookUserStateCompletionClause` (≈1173–1193) is selected **only when `qb.mediaScope == "ebook"`** → an ebook under an unscoped query is misclassified by the video clause.
- `internal/api/handlers/user_state.go` `allEpisodesCompleted` (≈171–185) defines the canonical rollup over the episode repository's set.
- Episode set (the parity target): `internal/catalog/episode_repo.go` `ListBySeriesIDs` / `ListBySeasonIDs` select from `episodes` filtered by `series_id` / `season_id` **AND** `episodeAvailabilityPredicate` = `EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = episodes.content_id)`. No specials/season-0 exclusion, no media-file requirement. Links: `episodes.series_id`, `episodes.season_id`.
- The base relation always exposes `mi.type` (`media_items mi`, or the episode derived table that hard-codes `'episode'::text AS type`), so a `CASE` on row type is feasible.
### Changes
1. **Extract a reusable completion predicate.** In `internal/catalog/query_builder.go`, refactor `userStateCompletionClause` into a helper that, given a row alias and the linking column, emits the leaf completion EXISTS pair (progress + history, with hidden-history guard). Keep `ebookUserStateCompletionClause` as the ebook leaf. This is the shared building block for the branches below.
2. **Add series/season rollup branches with repository-parity episode sets.** Emit, for a series row, "≥1 child episode exists AND no child episode is incomplete," expressed so the child-episode set is **identical** to `episodeAvailabilityPredicate`:
```sql
EXISTS (SELECT 1 FROM episodes e
WHERE e.series_id = mi.content_id
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id))
AND NOT EXISTS (
SELECT 1 FROM episodes e
WHERE e.series_id = mi.content_id
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id)
AND NOT (<leaf completion predicate for e.content_id>))
```
Season rollup is the same with `e.season_id = mi.content_id`. The leaf predicate inside the `NOT EXISTS` reuses the helper from step 1 keyed on `e.content_id`. **The `episode_libraries` EXISTS must match `episodeAvailabilityPredicate` verbatim** — if `episode_repo.go` ever changes that predicate, this SQL changes with it. Add a code comment cross-referencing `episodeAvailabilityPredicate` so the coupling is discoverable.
3. **Branch by row type for unscoped queries.** When `qb.mediaScope` is empty/`video` (rows can be mixed), select the completion source from `mi.type` rather than from `mediaScope`:
- `movie` / `episode` / video leaf → leaf predicate,
- `series` → series rollup, `season` → season rollup,
- `ebook` → ebook leaf,
- `manga` → deferred; treat as never-completed for now (documented), do not invent a collection-only interpretation.
When `mediaScope` is a single concrete kind (e.g. `series`, `ebook`), the optimizer-friendly path may emit just that branch. Implement as a `CASE mi.type WHEN ... END` or chained `(mi.type = 'x' AND <clause>) OR ...`; verify argIdx accounting (each leaf/ebook clause increments `qb.argIdx` by 2 — the rollups reuse the leaf, so count the placeholders carefully and add a builder test that the produced arg count matches bound args).
4. **`last_watched` stays separate.** Do not route `last_watched` through the new predicate; it is a `user_last_watched` CTE timestamp aggregate. Leave as-is; add a one-line comment noting the intentional divergence.
### Tests (PR 1)
- `internal/catalog/query_builder_test.go`: assert the `watched` clause contains the series/season rollup EXISTS only when the scope/row-type can include series/seasons; assert arg count == bound args for each branch; assert the `episode_libraries` predicate text is present in the rollup.
- **Equivalence test (highest value):** seed a Postgres fixture with a series whose episodes are variously completed/not, a season, a movie, and an ebook for one profile; assert the catalog `watched` rule's matched set equals exactly the items whose `user_state.go` `Played` flag is true. Because `user_state.go` lives in `internal/api/handlers` (CGO/libvips), run this in the libvips-capable container; the test can drive both the catalog executor and the item-user-state builder against the same pool and diff the verdicts. If a same-package home is awkward, place the comparison test in `internal/api/handlers` where both sides are reachable.
### Release note (PR 1)
Catalog `watched` filtering for series/seasons changes from "never matches" to "matches when all available episodes are completed." Call this out in the PR body and release notes per the spec's semantics-change risk.
---
## PR 2 — Replace the Enum Vocabulary with `display_query_definition`
One concern: swap the persisted representation and every surface that reads/writes it. Clean replacement — no aliases, no backfill (the enums are branch-only and unreleased; see spec Current Branch State). Land the steps below together so no layer reads a column another layer has dropped.
### Step 2.1 — Migration reshape
Edit `migrations/sql/20260623184858_user_collection_display_filters.sql` in place so the canonical schema has no legacy columns:
- **Up:** `ALTER TABLE public.user_personal_collections ADD COLUMN IF NOT EXISTS display_query_definition jsonb;` (nullable; `NULL`/absent = no display filter). Remove the `watch_filter` / `media_filter` `ADD COLUMN` and their CHECK constraints.
- **Down:** `ALTER TABLE ... DROP COLUMN IF EXISTS display_query_definition;`
**Operational gotcha:** Goose keys applied migrations by version, so editing an already-applied migration will **not** re-run it. Any dev DB that already applied the original `20260623184858` (which added `watch_filter`/`media_filter`) must be rolled back to before it and re-applied: `make migrate-down` to the prior version, then `make migrate-up`. If a shared dev DB makes in-place editing risky, the alternative is a *new* timestamped migration (`make migrate-create NAME=replace_collection_display_filters`) that drops the two columns and adds `display_query_definition`; the spec prefers in-place reshape since nothing is merged, but call the choice out in the PR.
### Step 2.2 — Backend storage & types
- `internal/userstore/types.go`: in `Collection` (≈185–213), `CreateCollectionInput` (≈238–255), `UpdateCollectionInput` (≈257–278), remove `WatchFilter` / `MediaFilter`; add `DisplayQueryDefinition string` (and `*string` on the update input), mirroring the existing `QueryDefinition` fields.
- `internal/userstore/pgstore/collections.go`:
- `collectionSelectColumns` (≈15–18): drop `watch_filter, media_filter`; add `display_query_definition`.
- `scanCollection` (≈20–42) and the `ListCollections` scan (≈192–196): drop the two scan targets; add `&c.DisplayQueryDefinition`.
- `CreateCollection` INSERT (≈79–94): drop the two columns/args; add `display_query_definition` + arg.
- `UpdateCollection` (≈270–283): replace the two `add("watch_filter"…)` / `add("media_filter"…)` blocks with a single `display_query_definition` block driven by `input.DisplayQueryDefinition != nil`.
- The normalization at create-time (≈59–68) moves from enum normalization to **fragment normalization** (Step 2.5).
- `internal/userstore/collection_filters.go`: repurpose into the fragment normalizer's home, or delete the enum constants/`Normalize*` funcs if nothing else uses them after the swap. Keep the file's tests meaningful — convert `collection_filters_test.go` to cover the new normalizer (Step 2.5) rather than the deleted enums.
### Step 2.3 — API surface
- `internal/api/handlers/collections.go`:
- `createCollectionRequest` (≈39–50), `updateCollectionRequest` (≈52–67), `collectionResponse` (≈73–101): drop `watch_filter` / `media_filter`; add `display_query_definition json.RawMessage` following the existing `query_definition` handling (response assembly mirrors `defaultJSON([]byte(c.QueryDefinition))` at ≈843).
- `HandleCreateCollection` (≈270–279) / `HandleUpdateCollection` (≈341–356): replace enum validation with fragment normalization/validation (Step 2.5); reject invalid fragments with `400`.
- `collectionCapabilitiesResponse` (≈108–111) + `HandleCapabilities` (≈224–229): replace `watch_filters`/`media_filters` value lists with capability metadata describing the supported display-query fields (`type`, `watched`) and the UI presets (`all/watched/unwatched`, `all/movies/series`). Keep it additive and feature-detection-oriented per the v1 API rules.
- `internal/api/handlers/user_collection_imports.go`: `userImportSharedFields` (≈67–77) and `createImportedCollection` validation (≈214–223) — same swap to `display_query_definition`.
### Step 2.4 — Exact-collection execution through the catalog executor
Replace the custom Go filter pass and the metadata-only in-Go matcher with one SQL path.
- Delete `internal/catalog/user_collection_display_filters.go` (`FilterUserCollectionDisplayItems` and helpers) and its test, plus the duplicated `userCollectionAllEpisodesCompleted`.
- Call sites to rewrite:
- `internal/catalog/catalog_resolver.go` `resolveUserCollectionSource` (≈479–526, filter call at ≈517).
- `internal/sections/fetcher.go` `fetchUserCollection` (filter call at ≈1371).
- New behavior for exact collections (per spec Execution Model):
1. Load member IDs in source order (`user_personal_collection_items`, `ORDER BY position`) — unchanged.
2. Parse `collection.DisplayQueryDefinition` into a `QueryDefinition`, **force `Limit = nil`** and drop any fragment `sort`/`library_ids` defensively before execution.
3. Run the executor with `AccessFilter.AllowedContentIDs = memberIDs` (the pattern `resolveCandidateItemsWithQuery` already uses at `catalog_resolver.go` ≈606–607). The executor's `conditions` slice AND-joins `AllowedContentIDs` with the display fragment, so membership ∧ filter is automatic — no OR-leak risk here.
4. For source-ordered requests: request the **full** match set (no limit), collect matching IDs into a set, reapply original order, then apply request offset/limit. Pagination happens after reordering, never inside the executor.
5. Stop using `filterCatalogItems` (`catalog_resolver.go` ≈1772) for these collections — replace the `UseSourceOrder` branch in `resolveExactOrderedMediaItems` (≈571–586) with the match-set-then-reorder path. **Do not delete `filterCatalogItems`:** it has a second caller at `catalog_resolver.go:244` (the query-source path), so it stays; only the exact-collection call at ≈575 changes.
- Empty display fragment ⇒ skip the executor filter entirely and return members in source order (preserves today's "no filter" performance for unfiltered collections).
### Step 2.5 — Fragment normalization (shared backend helper)
A `display_query_definition` is a **filter-only** `QueryDefinition` fragment. Add one normalizer (e.g. in `internal/catalog` next to `QueryDefinition`, or `internal/userstore`) used by every write path (create/update/import):
- Accept only `match` + `groups`, with rules limited to the allowed display vocabulary (v1: `type`, `watched`).
- Reject (preferred, on write) or strip `limit`, `sort`, `library_ids`, `media_scope`.
- Reject unknown fields/ops so clients learn the contract.
- Return a canonical JSON string for storage. Used by `pgstore` create/update and the API handlers.
### Step 2.6 — Web
The frontend already has the machinery: `QueryDefinition` types, `createEmptyQueryDefinition` / `normalizeQueryDefinition` (`web/src/api/types.ts` ≈1318–1361), and `CollectionGuidedRulesEditor.tsx` which already converts `watched` rules ↔ a `watchStatus` field (`queryDefinitionToGuidedState` ≈93–254, `guidedStateToQueryDefinition` ≈257–374). Reuse it; do not add a parallel state model.
- `web/src/lib/collectionDisplayFilters.ts`: replace the enum option lists / `normalize*` helpers with converters **preset ⇄ `QueryDefinition` fragment**:
- `watched`/`unwatched`/`all` ⇄ a `{field:"watched",op:"is",value:true|false}` rule (or none),
- `movie`/`series`/`all` ⇄ a `{field:"type",op:"is",value:"movie"|"series"}` rule (or none),
- a builder that assembles the two presets into a single filter-only fragment, and a reader that derives the two preset values back from a fragment (for editing).
- `web/src/api/types.ts`: drop `UserCollectionWatchFilter` / `UserCollectionMediaFilter` and the `watch_filter` / `media_filter` fields on `Collection` (≈1270–1271), `CreateCollectionRequest` (≈1403–1404), `UpdateCollectionRequest` (≈1420–1421); add `display_query_definition?: QueryDefinition`.
- Editors — swap the two `<Select>` controls' wiring to read/write through the helper, keeping the same visible labels:
- `web/src/components/collections/CollectionBuilder.tsx` (value shape ≈45–57, UI ≈259–313, submit ≈88–101),
- `web/src/pages/ImportedCollectionEditor.tsx` (state ≈118–125, UI ≈277–354, save ≈184–214),
- `web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx` (state ≈99–100, submit ≈119–167).
- `web/src/hooks/queries/collections.ts` (`useCreateCollection` ≈85–101, `useUpdateCollection` ≈103–127): payloads now carry `display_query_definition` instead of the enums (the generic `buildUserCollectionPayload` needs no change beyond the type swap).
### Tests (PR 2)
- `internal/api/handlers/collections_test.go`: create/update/get round-trips `display_query_definition`; `watch_filter`/`media_filter` are absent from request and response; an invalid fragment (e.g. containing `limit` or an unknown field) returns `400`.
- Resolver/executor tests:
- exact collection routes through the catalog `watched` rule and preserves source order across pages;
- an exact collection larger than a page returns the full match set in source order regardless of fragment contents (guards the limit-truncation finding);
- a mixed-media exact collection (movie + ebook) with `watched`/`unwatched` classifies the ebook via `ebook_reader_progress`, not video history (relies on PR 1 row-type branching).
- Fragment-normalizer unit tests (replacing `collection_filters_test.go`): strips/rejects `limit`/`sort`/`library_ids`/`media_scope`; round-trips `type` + `watched` rules.
- Frontend: helper test for preset ⇄ fragment conversion (both directions); one editor test asserting the submit payload carries the canonical `display_query_definition`.
---
## PR 3 — Smart/Live Display Filters (deferred)
Only when smart/live collections expose display-filter controls. Implements the spec's executor-level conjunction so `(source) AND (display)` holds regardless of the source's top-level `match`.
- Seam: `internal/catalog/query_executor.go`, after the `AllowedContentIDs` handling (≈322) and before `ApplySectionAccessFilter`. Build the display fragment with a second `NewQueryBuilder("mi").WithArgIdx(argIdx)`, `rebindSQLPlaceholders` its output to the running `argIdx`, append the rebound clause to `conditions`, and advance `argIdx`/`args`. Because `conditions` is AND-joined (≈353), this yields `(sourceWhere) AND (displayWhere)` with no group-list manipulation and no OR-leak.
- Do **not** append the display group into `def.Groups` — that is the OR-leak the adversarial review found (`QueryBuilder.Build` joins top-level groups with the def's single `match`, ≈84–111).
- Tests: a `match: any` source plus a display filter never returns rows the source alone excludes; a contradictory `watched` overlay on a `match: any` source returns zero rows.
---
## Verification (every PR, pre-push)
Per `CLAUDE.md`:
- `make lint`
- `cd web && pnpm run lint && pnpm run format:check`
- `make verify-local-paths`
- Go tests in a **libvips-capable container** (a bare-host `go test ./...` silently skips CGO packages including `internal/api/handlers`, where the equivalence and DTO tests live). Prefix `GOWORK=off` for builds/tests in this repo.
- Targeted: `go test ./internal/catalog/... ./internal/api/handlers/... ./internal/userstore/...` and the relevant `web` helper/editor tests.
## Sequencing summary
1. PR 1 (predicate + equivalence test) → merge.
2. PR 2 (migration → types/storage → API/imports → fragment normalizer → exact execution → web), landed together → merge.
3. PR 3 only if/when smart-collection display filters are wired.
Link each PR to the capability sub-issue (`Part of #NNN`) per the v1 process; keep one concern per PR.
## Open items to confirm before/while implementing
- Confirm no `silo-android` / `silo-apple` build already consumes the branch's `watch_filter` / `media_filter` fields (spec assumes unreleased; if pinned, fall back to the contingency backfill path in the spec's Migration Strategy).
- Confirm `episodeAvailabilityPredicate` is the intended "child episode" definition to match (Open Question #4 in the spec) — it currently includes specials/season-0 if they have library links. Match it exactly; change item user-data and the catalog rule together if it should differ.
- Decide the fragment normalizer's package home (`internal/catalog` vs `internal/userstore`) based on import direction — it needs the `QueryDefinition` type without creating an import cycle.
@@ -0,0 +1,327 @@
# Hybrid Semantic Search Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
**Goal:** Make Meilisearch hybrid semantic search safe to enable broadly by gating it on *trustworthy* per-type vector coverage, validating embedder capability, and exposing per-query/admin diagnostics — without regressing the hot search path.
**Architecture:** Semantic search stays gated behind the existing `SemanticEnabled && wordCount >= 3` rule, plus a new **coverage gate** that reads an in-memory, periodically-refreshed per-type coverage snapshot (never a per-request DB query). Coverage = `current-model embeddings ÷ embed-eligible items`, computed entirely from Postgres and latched with hysteresis. Existing provider/fallback fields are plumbed through the resolver into an additive `search_diagnostics` response object. A rate-limited capability probe validates the Meili embedder without downing keyword search.
**Tech Stack:** Go (pgx/pgxpool), Meilisearch hybrid search (`userProvided` embedder), React/TypeScript admin UI, Goose migrations.
## Global Constraints
- **API additive-only within `/api/v1`** — never rename/remove a response field, change a type, or repurpose a status code. New fields are `omitempty` additions. (CLAUDE.md)
- **Performance first, reliability first, predictable under load** — the search request path must not gain a synchronous DB count, lock wait, or remote probe. Fail **safe** (keyword-only), never panic. (CLAUDE.md Core Priorities)
- **One additive index migration** is in scope (revised — see Assumptions): a btree on `media_item_embeddings(model)` to support the periodic coverage query. No schema/column changes.
- **Canonical embedding dimensions** = `embeddingvectors.CanonicalDimensions` (= 3072; re-exported as `recommendations.CanonicalEmbeddingDimensions`). Never hardcode the number.
- **Single source of truth for embed-eligibility** — the predicate `(status='matched' OR type IN ('audiobook','ebook'))` must not be duplicated. It moves to the neutral `internal/embeddingvectors` package (imported by both `catalog` and `recommendations`; `recommendations` already imports `catalog`, so `catalog` cannot import `recommendations`).
- **Single phase** — measurement *and* enforcement ship together. The diagnostics shipped here are what let us retune thresholds later without code changes-in-anger.
- Build/test in worktrees with `GOWORK=off` and a stubbed `web/dist` (see worktree-build-quirks memory).
---
## Corrected Baseline (what actually exists today — verified)
Work from this, not from intuition. Each point was confirmed against the tree.
1. **The only semantic skip rule is `SemanticEnabled && len(Fields(normalizedQuery)) >= 3`** (`internal/catalog/search_meilisearch_provider.go:329`). There is **no** `SkipTotal`/preview skip, and `TestMeilisearchSearchRequestBuildsHybridForApproximateInteractiveSearch` (`search_provider_test.go:213`) asserts hybrid **IS** used with `SkipTotal: true`. → **Do not add a `SkipTotal` skip.** Layer the coverage gate onto the existing rule; remove nothing.
2. **`CatalogSearchResult` already carries `Provider` and `FallbackReason`** (`search_provider.go:64-71`). Diagnostics work is *plumbing these through* `CatalogResult` (which drops them, `catalog_resolver.go:24-30,291-296`) — not inventing provider fields.
3. **The provider already self-downgrades hybrid→keyword on a Meili error** (`search_meilisearch_provider.go:221-228`). Diagnostics must report the *post-downgrade* reality.
4. **`countCatalogSearchVectorDocuments` ignores `model`** (`search_indexer.go:757`), counts the *indexed* population (no eligibility filter), and its companion denominator (`document_count`) comes from **Meili index stats** (`search_service.go:129`), not Postgres. It has **four callers** — `search_service.go:139`, `search_indexer.go:133`, `:203`, `:310` — all must be updated when its signature changes.
5. **Indexed population ≠ embed-eligible population.** `LoadDocumentsAfter` (`search_indexer.go:534`) indexes every type-matched, non-manga row regardless of `status`; embeddings are only written for `(status='matched' OR audiobook OR ebook)` (`recommendations/repo.go:154-160`, `recommendationItemEligibilityWhereClause`). A denominator of "indexable items" is therefore structurally < 1.0 on any library with unmatched video → **the gate would never open.** Coverage must use the **embed-eligible** population for both numerator and denominator.
6. **Status fields that already exist:** `document_count`, `vector_document_count` (`search_provider.go:347-348`). The earlier draft's `index_document_count` does **not** exist — keep real names; add new fields alongside.
7. **`UpsertEmbedding` already enqueues a Meili index event after every write** (`recommendations/repo.go:186`) — verified correct, **no change**.
8. **Catalog search settings are restart-bound** (`config/restart_keys.go:75-85`, *"Catalog search provider construction is intentionally startup-bound in v1"*). The service is built **once** in `NewRouter` and never live-rebuilt. → the coverage refresher is a single process-lifetime goroutine; no live-rebuild leak exists in v1.
9. **`catalogSearchService` is a local var in `NewRouter`** (`router.go:437`), wired into handlers but **never returned** to `main.go`. The long-lived `deps.AppContext` (`router.go:88`) is the refresher's context.
10. **Legacy `/search` plain-`q` re-route is descoped** — see [Descoped](#descoped--not-in-this-pass).
---
## Design Decisions
### D1 — Coverage = current-model embeddings ÷ embed-eligible items (single Postgres source, per type)
Let `ELIG(alias)` = `embeddingvectors.ItemEligibilityWhereClause(alias)` → `(alias.status='matched' OR alias.type IN ('audiobook','ebook'))` (moved here from `recommendations`; `recommendations.recommendationItemEligibilityWhereClause` is refactored to call it — no behavior change, no duplication).
- **Denominator** (embed-eligible items, per type):
```sql
SELECT mi.type, COUNT(*) AS eligible
FROM media_items mi
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
AND ($1::text[] IS NULL OR mi.type = ANY($1))
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
GROUP BY mi.type
```
- **Numerator** (eligible items with a *current-model* embedding, per type):
```sql
SELECT mi.type, COUNT(*) AS vectorized
FROM media_item_embeddings e
JOIN media_items mi ON mi.content_id = e.media_item_id
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
AND ($1::text[] IS NULL OR mi.type = ANY($1))
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
AND ($2 = '' OR e.model = $2)
GROUP BY mi.type
```
Applying `ELIG` to **both** guarantees numerator ⊆ denominator → ratio ∈ `[0,1]` (an embedding left behind on an item that became unmatched can't push a type over 1.0). Unmatched items are excluded from both, so they never drag coverage. As backfill completes, ratio → 1.0.
**Active model (`$2`)** is read from the embedding lock (`recommendations.EmbeddingLock.Model`, `embedding_lock.go:16`). No lock ⇒ `$2 = ""` and the snapshot is marked not-ready (never gate semantic on for an unestablished embedding space).
> **Why model-filtering is load-bearing:** right after a model change, old vectors persist but are mismatched to the new query embedder. A model-blind count reads ~100% at the most dangerous moment. Filtering by the locked model collapses coverage to ~0 until re-embedding catches up.
**Index (migration, in scope):** `media_item_embeddings` has only an HNSW index on `embedding` (`migrations/sql/001_schema.sql:1857`, `014_...:25`) — nothing on `model`. Add `CREATE INDEX CONCURRENTLY idx_media_item_embeddings_model ON public.media_item_embeddings (model)` so the numerator filter is index-supported. (Goose: timestamped file via `make migrate-create`, with `-- +goose NO TRANSACTION` because `CONCURRENTLY` cannot run in a txn.)
### D2 — Coverage lives in an in-memory snapshot, refreshed off the request path
A `semanticCoverageTracker` (new, `internal/catalog/semantic_coverage.go`) holds an `atomic.Pointer[semanticCoverageSnapshot]`.
- **Refresh cadence:** a single background goroutine every `semanticCoverageRefreshInterval` (**2 minutes** — long enough that two `GROUP BY` aggregates are negligible steady-state load even at 1M rows; short enough to bound the post-model-change danger window, which also fails safe via the model-collapse rule below). The interval is a named const; **there is no after-sync trigger** (the indexer and service share no handle — see Descoped rationale; the interval alone is sufficient and avoids a cross-component wiring + race).
- **Single-flight & immutability:** `Refresh` holds a `sync.Mutex` across read-prev → query → compute → publish (background path; contention irrelevant). Each refresh builds a **fresh** `PerType` map and publishes a new immutable snapshot pointer; the published snapshot is **never mutated** after publish (avoids a `map` data race against concurrent `CoverageReady` reads). A `-race` test fires refreshes concurrently with reads.
- **Fail-safe reads:** `CoverageReady` reads only the atomic pointer — **no DB, no lock, no remote call.** If the snapshot is `nil` (boot, before first refresh) it returns `(false, "coverage not yet computed")` — **never** dereferences a nil map (that would be a hot-path panic).
- **Error retention:** on a refresh query error, retain the last-good snapshot (transient DB blips must not flip semantic off); log; do not publish a zeroed snapshot. Admin status flags `UpdatedAt` staleness.
- **Model-change collapse:** the snapshot records the `Model` it was computed for. When `Refresh` observes the active model differs from the published snapshot's model, it **immediately publishes an all-not-ready snapshot** (safe) and recomputes real coverage under the new model on the same pass. This bounds the mismatched-vector window to ≤ one interval and fails safe within it.
### D3 — Per-type readiness with hysteresis; scope = AND of its types; empty scope = all types
Each type's `ready` flag is **latched**: flips **true** at `ratio >= semanticCoverageEnableRatio` (0.90), flips **false** only at `ratio < semanticCoverageDisableRatio` (0.80). The snapshot carries prior latched state so each refresh applies hysteresis — preventing flap (identical queries returning differently-ranked results).
`CoverageReady(itemTypes)`:
- **Non-empty scope:** ready iff **every** requested type is latched-ready (`min` semantics — a weak subtype blocks the mixed scope). The reason names the first failing type.
- **Empty/nil scope** (the common "search everything" case — `MediaScopeItemTypes("") → nil`, so `ItemTypes == nil` reaches the gate): ready iff **every type in the snapshot** is latched-ready. This deliberately mirrors the empty-config semantics and refuses to let an unscoped search bypass a weak type. Reason names the first failing type, or `"coverage not yet computed"` if the snapshot is nil/empty.
### D4 — Diagnostics: existing fields, plumbed, reported post-downgrade, scoped to the provider path
- Add `Mode string` (`"keyword"`/`"hybrid"`) and `SemanticUsed bool` to `CatalogSearchResult`; the Meili provider sets them to reflect the **final** request after the internal hybrid→keyword downgrade.
- Add `Provider`, `Mode`, `SemanticUsed`, `FallbackReason` to `CatalogResult`; copy them in `resolveDirectSearchSource`.
- Add an `omitempty` `search_diagnostics` object to `catalogResponse`.
- **Scope:** diagnostics are produced **only** on the direct provider-backed search path (`useDirectSearchPath` = relevance sort, desc, no advanced rules — `catalog_resolver.go:1416-1427`). They are **omitted** for: browse, non-relevance-sorted `q=` searches (which never hit the provider), and `group=work` responses (the grouped wrapper builds a fresh `CatalogResult` at `catalog.go:287-293` and is already approximate — `TotalExact=false`). Each exclusion gets an explicit handler test.
### D5 — Capability validation: narrow, rate-limited, non-fatal, admin-path only
Embedder settings are written by Silo itself, so validation guards only **external drift** (Meili volume reset, manual edits). Add `GetSettings` to the Meili client; verify the configured embedder exists, `source == "userProvided"`, and `dimensions == embeddingvectors.CanonicalDimensions`. The hybrid probe:
- runs **only on the admin status path** (`HandleGetCatalogSearchStatus`), **not** on `CheckConnection`/health (which only does `GET /health`), and the admin query has no `refetchInterval` (`settings.ts`, `staleTime: 15_000`);
- runs **only when `state.ActiveIndexUID != ""`**, and is **cached for `semanticCapabilityProbeTTL` (5m)** → ≤ 1 probe / 5m regardless of refresh rate;
- uses a **non-degenerate unit sample vector** (`v[0]=1.0`, rest 0, length `CanonicalDimensions`) — an all-zero vector has zero norm and can spuriously fail cosine hybrid; targets `ActiveIndexUID` with `Hybrid.Embedder = config.Embedder`, `limit:1`.
Failures surface in admin status and force keyword-only; they **never** flip provider `Healthy` or trip the circuit.
### D6 — Backfill split: cheap discovery vs. bounded text-staleness, with fairness
The expensive part of `ListEmbeddingTextCandidates` is **building `current_text` for every eligible row** via 5 `LATERAL` joins to `item_people` (`recommendations/repo.go:544-589`) — *not* the `IS DISTINCT FROM` comparison.
- **Cheap discovery** (`ListMissingOrModelStaleEmbeddingIDs`): `LEFT JOIN media_item_embeddings`, filter `ELIG("mi") AND (e.media_item_id IS NULL OR e.model = '' OR e.model <> $currentModel)`, `ORDER BY mi.content_id`, `LIMIT`. **No `LATERAL`, no `current_text`.** The SQL is built by a package-private `buildMissingOrModelStaleEmbeddingIDsSQL() string` helper so a unit test can assert it contains neither `item_people` nor `LATERAL` (mirrors the existing `embeddingEligibilityWhereClause()` string-helper test pattern). Returns IDs; `current_text` is then built **only for that bounded batch** via `BuildEmbeddingTextForIDs(ids)`.
- **Text-staleness** (existing `ListEmbeddingTextCandidates`): keep, but run as a **bounded, unconditional quota per cycle** (`embeddingTextStaleQuotaPerRun`) so canonical-text-stale items progress even when the cheap backlog never empties (fairness against starvation).
- **Cursors:** the cheap pass advances `afterID` from the **cheap query's** last id (it is the ordered cursor), independent of `BuildEmbeddingTextForIDs` row order (`ANY($1)` does not preserve slice order, and a dropped id must not desync the cursor). The text-stale pass keeps its **own** `afterID`. Both monotonic.
---
## File Structure
| File | Responsibility | Change |
| --- | --- | --- |
| `migrations/sql/<ts>_media_item_embeddings_model_index.sql` | `(model)` btree, `CONCURRENTLY` / `NO TRANSACTION` | Create |
| `internal/embeddingvectors/eligibility.go` | `ItemEligibilityWhereClause(alias)` — single source of truth | Create |
| `internal/catalog/semantic_coverage.go` | tracker, snapshot, querier seam, per-type SQL, hysteresis, single-flight refresher | Create |
| `internal/catalog/search_provider.go` | gate + model-provider interfaces; `Mode`/`SemanticUsed`; semantic status structs | Modify |
| `internal/catalog/search_meilisearch_provider.go` | consult gate; set `Mode`/`SemanticUsed` (incl. downgrade); capability probe (unit vector) | Modify |
| `internal/catalog/search_meilisearch_client.go` | `GetSettings` + settings DTOs | Modify |
| `internal/catalog/search_service.go` | build/own tracker; `StartCoverageRefresh`; comma-ok model-provider; `Status()` reads snapshot | Modify |
| `internal/catalog/search_indexer.go` | `ELIG`+`model` numerator & eligible denominator; update **all 4** `countCatalogSearchVectorDocuments` callers | Modify |
| `internal/catalog/catalog_resolver.go` | carry diagnostics through `CatalogResult` (direct path only) | Modify |
| `internal/recommendations/repo.go` | delegate eligibility to `embeddingvectors`; `ListMissingOrModelStaleEmbeddingIDs` (+SQL helper); `BuildEmbeddingTextForIDs` | Modify |
| `internal/recommendations/similar.go` | `EmbedAll`: cheap drain + unconditional text-stale quota; dual cursors | Modify |
| `internal/recommendations/engine.go` | `ActiveEmbeddingModel(ctx)` from the lock | Modify |
| `internal/api/handlers/catalog.go` | `searchDiagnostics` DTO; populate on direct path; omit for grouped | Modify |
| `internal/api/router.go` | start `service.StartCoverageRefresh(deps.AppContext)` inside `NewRouter` | Modify |
| `web/src/hooks/queries/admin/settings.ts`, `web/src/pages/admin-settings/SearchSettings.tsx` | surface readiness/ratio/per-type/capability | Modify |
---
## Tasks
### Task 1: Shared eligibility predicate, model+eligibility coverage counts, supporting index
**Files:** Create `internal/embeddingvectors/eligibility.go`, `migrations/sql/<ts>_media_item_embeddings_model_index.sql`; Modify `internal/recommendations/repo.go:150-160`, `internal/catalog/search_indexer.go:757-782` (+ callers `:133,:203,:310`, `search_service.go:139`); Test `internal/embeddingvectors/eligibility_test.go`, `internal/catalog/search_indexer_test.go`.
**Interfaces — Produces:**
- `embeddingvectors.ItemEligibilityWhereClause(alias string) string`
- `countCatalogSearchVectorDocuments(ctx, q coverageQuerier, itemTypes []string, model string) (int, error)` (adds `model`; applies `ELIG`)
- `catalogSemanticCoverageByType(ctx, q coverageQuerier, itemTypes []string, model string) ([]catalogTypeCoverage, error)` → `{Type string; Eligible, Vectorized int}`
- [ ] Migration: `make migrate-create NAME=media_item_embeddings_model_index`; body uses `-- +goose NO TRANSACTION` then a self-healing guard for a leftover invalid index from a crashed build (follow the `20260618164519_add_episodes_season_id_index.sql` precedent: a `DO $$ ... NOT i.indisvalid ... DROP INDEX $$` block), then `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_item_embeddings_model ON public.media_item_embeddings (model);` (down: `DROP INDEX CONCURRENTLY IF EXISTS`). The runner honors `NO TRANSACTION` (three shipping precedents). Run `make migrate-up`; verify with `make migrate-status`.
- [ ] Move the eligibility predicate to `embeddingvectors`; refactor `recommendationItemEligibilityWhereClause` to delegate (assert output string unchanged).
- [ ] Failing tests: (a) `model` filter excludes rows whose `e.model` differs; (b) an **unmatched, non-book** item inflates neither numerator nor denominator (the C1 regression guard); (c) denominator counts eligible items with no embedding row; (d) ratio ∈ [0,1] when a stale embedding exists on a now-unmatched item.
- [ ] Implement both queries with `ELIG`; merge per type in Go; update **all four** callers (pass active model where available, else `""`).
- [ ] `GOWORK=off go test ./internal/embeddingvectors ./internal/catalog -run 'Eligib|Coverage' -count=1` → PASS. Commit.
### Task 2: Active embedding model from the lock
**Files:** Modify recommendations `Engine` (`engine.go`), `internal/catalog/search_provider.go`; Test `internal/recommendations/*_test.go`.
**Produces:** `recommendations: func (e *Engine) ActiveEmbeddingModel(ctx) (string, error)` (→ `GetEmbeddingLock`; `lock.Model` or `""`); `catalog: type CatalogSemanticModelProvider interface { ActiveEmbeddingModel(ctx context.Context) (string, error) }`.
- [ ] Test: lock present → its `Model`; no lock → `("", nil)`.
- [ ] Implement; confirm `recEngine` satisfies both `CatalogSearchQueryVectorizer` (the real interface name, `search_provider.go:77`) and `CatalogSemanticModelProvider`.
- [ ] Run recommendations tests → PASS. Commit.
### Task 3: Coverage tracker — querier seam, snapshot, hysteresis, single-flight refresher
**Files:** Create `internal/catalog/semantic_coverage.go`, `semantic_coverage_test.go`; Modify `internal/catalog/search_provider.go` (consts + gate interface).
**Produces:**
```go
const (
semanticCoverageEnableRatio = 0.90
semanticCoverageDisableRatio = 0.80
semanticCoverageRefreshInterval = 2 * time.Minute
)
// coverageQuerier is the seam so Refresh is unit-testable without a real *pgxpool.Pool.
type coverageQuerier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
}
type catalogTypeCoverage struct { Type string; Eligible, Vectorized int; Ratio float64; Ready bool }
type semanticCoverageSnapshot struct { PerType map[string]catalogTypeCoverage; Overall float64; Model string; UpdatedAt time.Time }
type SemanticCoverageGate interface { CoverageReady(itemTypes []string) (ready bool, reason string) }
type semanticCoverageTracker struct { /* q coverageQuerier; indexTypes []string; models CatalogSemanticModelProvider; mu sync.Mutex; snap atomic.Pointer[semanticCoverageSnapshot]; clock func() time.Time */ }
func (t *semanticCoverageTracker) Refresh(ctx context.Context) error
func (t *semanticCoverageTracker) CoverageReady(itemTypes []string) (bool, string) // nil-snapshot ⇒ (false, "coverage not yet computed")
func (t *semanticCoverageTracker) Snapshot() *semanticCoverageSnapshot
func (t *semanticCoverageTracker) Run(ctx context.Context) // ticker; returns on ctx.Done()
```
- [ ] Test (no panic / fail-safe): `CoverageReady` on a freshly-constructed tracker (no `Refresh` yet) returns `(false, "coverage not yet computed")` and touches the querier **zero** times (querier stub fails the test if called).
- [ ] Test (hysteresis): 0.85→not ready; →0.92 ready; →0.85 **stays** ready; →0.79 not ready.
- [ ] Test (scope): `CoverageReady(["movie","series"])` false when `series` < threshold though `movie` ready; reason names `series`. Empty scope requires all snapshot types ready.
- [ ] Test (model collapse): snapshot computed for model A; active model becomes B → next `Refresh` publishes all-not-ready before recomputing.
- [ ] Test (`-race`): concurrent `Refresh` + `CoverageReady` loops; assert monotonic `UpdatedAt`, no race.
- [ ] Test (error retention): a `Refresh` whose query errors retains the prior snapshot.
- [ ] Implement single-flight mutex, fresh-map immutability, nil-safe reads, model-collapse, retain-last-good, ticker `Run`.
- [ ] `GOWORK=off go test ./internal/catalog -run Coverage -race -count=1` → PASS. Commit.
### Task 4: Wire gate + model provider; comma-ok degradation; start refresher in NewRouter
**Files:** Modify `internal/catalog/search_meilisearch_provider.go:27-66,329-334` (`MeilisearchProviderConfig` opens at `:27`), `internal/catalog/search_service.go:31-74`, `internal/api/router.go:437-552`.
**Consumes:** `SemanticCoverageGate`, `CatalogSemanticModelProvider`. **Produces:** `MeilisearchProviderConfig.Coverage SemanticCoverageGate`; `func (s *CatalogSearchService) StartCoverageRefresh(ctx context.Context)`.
- [ ] `shouldUseSemanticSearch` → `SemanticEnabled && wordCount>=3 && (p.config.Coverage == nil || ready)`; not-ready yields fallback reason `"semantic_not_ready: " + reason` (a **diagnostic, not an error**). Keep the `SkipTotal`-builds-hybrid test green.
- [ ] In `NewCatalogSearchServiceFromSettings`: only when `SemanticEnabled`, derive the model provider via **comma-ok** `mp, ok := queryVectorizer.(CatalogSemanticModelProvider)`. If `!ok` or `queryVectorizer == nil` (semantic enabled but recommendations disabled — a real config), build the tracker with a model provider that yields `""` ⇒ every type not-ready (reason `"embedding model provider unavailable"`); **never** assert without comma-ok (nil-interface assertion panics). Store the tracker on the service; pass it as `Coverage`.
- [ ] `StartCoverageRefresh` runs `tracker.Run(ctx)` once; no-op when there is no tracker (postgres/semantic-off). Call it from `NewRouter` after the service is built: `if catalogSearchService != nil { catalogSearchService.StartCoverageRefresh(deps.AppContext) }`. (The service is local to `NewRouter` and not returned; `deps.AppContext` is the process-lifetime ctx — Baseline #8/#9.)
- [ ] Test: stub gate not-ready ⇒ long query stays keyword-only; ready gate + vectorizer ⇒ hybrid emitted; nil vectorizer + semantic-enabled ⇒ not-ready, no panic.
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
### Task 5: Diagnostics plumbing (provider → resolver → handler), correctly scoped
**Files:** Modify `internal/catalog/search_provider.go:64-71`, `internal/catalog/search_meilisearch_provider.go:189-237`, `internal/catalog/catalog_resolver.go:24-30,269-297`, `internal/api/handlers/catalog.go:55-61,91-134`; Test `search_provider_test.go`, `catalog_test.go`.
**Produces:** `CatalogSearchResult{+Mode string; +SemanticUsed bool}`; `CatalogResult{+Provider,Mode string; +SemanticUsed bool; +FallbackReason string}`; handler `type searchDiagnostics struct { Provider, Mode string; SemanticUsed bool; FallbackReason string \`json:"fallback_reason,omitempty"\` }`; `catalogResponse{+SearchDiagnostics *searchDiagnostics \`json:"search_diagnostics,omitempty"\`}`.
- [ ] Provider: `Mode="hybrid", SemanticUsed=true` only when the hybrid request is issued **and survives**; on the downgrade (`:221-228`) set `Mode="keyword", SemanticUsed=false, FallbackReason=semanticFallback`.
- [ ] Resolver: copy the four fields in `resolveDirectSearchSource` (direct path only).
- [ ] Handler: populate `search_diagnostics` only on the non-grouped direct path; **omit** for `group=work` and for non-relevance-sort `q=` requests.
- [ ] Tests: (a) hybrid error ⇒ `semantic_used:false, mode:"keyword", fallback_reason` set (post-downgrade truth); (b) existing `total/total_exact/has_more/items/snapshot` byte-stable; (c) field **absent** for browse, for `group=work`, and for a `q=`+title-sort request.
- [ ] `GOWORK=off go test ./internal/catalog ./internal/api/handlers -count=1` → PASS. Commit.
### Task 6: Capability validation + rate-limited probe; admin status from snapshot
**Files:** Modify `internal/catalog/search_meilisearch_client.go:167+`, `internal/catalog/search_meilisearch_provider.go`, `internal/catalog/search_service.go:90-145`, `internal/catalog/search_provider.go` (status structs).
**Produces:**
```go
func (c *meilisearchClient) GetSettings(ctx, uid string) (meilisearchIndexSettings, error) // GET /indexes/{uid}/settings
type meilisearchIndexSettings struct { Embedders map[string]meilisearchEmbedderSettings `json:"embedders"` }
type meilisearchEmbedderSettings struct { Source string `json:"source"`; Dimensions int `json:"dimensions"` }
type CatalogSearchSemanticStatus struct {
Ready bool `json:"ready"`; DisabledReason string `json:"disabled_reason,omitempty"`
CoverageRatio float64 `json:"vector_coverage_ratio"`; CoverageUpdatedAt *time.Time `json:"coverage_updated_at,omitempty"`
PerType []CatalogSearchTypeCoverage `json:"per_type,omitempty"`; Capability CatalogSearchSemanticCapability `json:"capability"`
}
type CatalogSearchTypeCoverage struct { Type string `json:"type"`; Eligible int `json:"eligible"`; Vectorized int `json:"vectorized"`; CoverageRatio float64 `json:"vector_coverage_ratio"`; Ready bool `json:"ready"` }
type CatalogSearchSemanticCapability struct { OK bool `json:"ok"`; Reason string `json:"reason,omitempty"`; Embedder string `json:"embedder,omitempty"`; Dimensions int `json:"dimensions,omitempty"` }
const semanticCapabilityProbeTTL = 5 * time.Minute
```
- [ ] Add `Semantic CatalogSearchSemanticStatus` to `CatalogSearchRuntimeStatus`. **Keep** `document_count`/`vector_document_count` unchanged (additive only). The `Semantic` block (ratio, per-type, ready) is read from `tracker.Snapshot()` — **no fresh query** in `Status()`; if no tracker, `Ready=false, DisabledReason="semantic disabled"`.
- [ ] Capability: embedder present, `source=="userProvided"`, `dimensions==embeddingvectors.CanonicalDimensions`; each failure → distinct `Reason`. Probe (unit vector, `limit:1`, `Embedder=config.Embedder`) only when `ActiveIndexUID != ""`, cached `semanticCapabilityProbeTTL`.
- [ ] Tests: missing embedder / wrong source / wrong dimensions / probe failure each distinct; provider `Healthy` and circuit untouched in all four.
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
### Task 7: Backfill cheap/expensive split with fairness
**Files:** Modify `internal/recommendations/repo.go:472-621`, `internal/recommendations/similar.go:251-362`; Test `repo_test.go`, `similar_test.go`.
**Produces:** `buildMissingOrModelStaleEmbeddingIDsSQL() string`; `ListMissingOrModelStaleEmbeddingIDs(ctx, afterID, currentModel string, limit int) ([]string, error)`; `BuildEmbeddingTextForIDs(ctx, ids []string) ([]EmbeddingTextCandidate, error)`; `EmbedAll` two-pass loop with dual cursors and an unconditional `embeddingTextStaleQuotaPerRun`.
- [ ] Test (SQL shape): `buildMissingOrModelStaleEmbeddingIDsSQL()` contains neither `"item_people"` nor `"LATERAL"`.
- [ ] Test (behavior, no real DB needed for the shape test; behavior tests use existing recommendations test seams): an item that is *only* canonical-text-stale (people changed, model current) is **not** returned by the cheap query; a missing/model-stale item **is**.
- [ ] Test (parity): `BuildEmbeddingTextForIDs(ids)` reproduces the same `canonical_text` as the legacy full CTE for the same ids, regardless of `ids` order.
- [ ] Test (no starvation): with a permanently non-empty cheap backlog, a text-stale item is still embedded within a bounded number of `EmbedAll` runs (quota is unconditional); both cursors stay monotonic.
- [ ] Implement; cheap `afterID` advances from the cheap query's last id.
- [ ] `GOWORK=off go test ./internal/recommendations -count=1` → PASS. Commit.
### Task 8: Admin UI surfacing
**Files:** Modify `web/src/hooks/queries/admin/settings.ts:21-54`, `web/src/pages/admin-settings/SearchSettings.tsx:202-261`.
- [ ] Extend the `CatalogSearchStatus` TS type with the `semantic` block (ready, disabled_reason, vector_coverage_ratio, coverage_updated_at, per_type, capability).
- [ ] Render a "Semantic readiness" row (ready/disabled + reason), overall coverage %, a per-type coverage table, a capability badge, and a "coverage updated" timestamp. Keep "Vectorized Documents" (`vector_document_count`).
- [ ] `cd web && pnpm run lint && pnpm run format:check` → PASS. Commit.
---
## Test Plan
**Go unit (`internal/embeddingvectors`, `internal/catalog`):**
- Eligibility predicate single-sourced; unmatched non-book item excluded from numerator **and** denominator (C1 guard); ratio ∈ [0,1].
- Hybrid omitted when gate not ready; emitted when ready + capable + long query + vectorizer present.
- **Model-stale safety:** only-old-model embeddings ⇒ coverage ~0 ⇒ keyword-only; model-change collapse publishes not-ready within one interval.
- Hysteresis latch; mixed-scope AND; empty-scope = all-types; nil-snapshot returns not-ready with **no panic** and **no DB call**; `-race` refresh/read; error retention.
- Capability: missing embedder / wrong source / wrong dimensions / probe failure each distinct; keyword stays up.
**Go unit (`internal/recommendations`):**
- Cheap SQL excludes `item_people`/`LATERAL`; people-only-stale row excluded from cheap pass; per-ID text build matches legacy output order-independently; no text-stale starvation; dual cursors monotonic.
**Go unit (`internal/api/handlers`):**
- `search_diagnostics` present on direct path; **absent** for browse, `group=work`, and non-relevance-sort `q=`; `semantic_used` reflects post-downgrade reality; existing fields byte-stable.
**Admin status:** `semantic.ready`, `disabled_reason`, `vector_coverage_ratio`, `per_type`, `capability`, `coverage_updated_at` sourced from the snapshot (no fresh query).
**Focused command:**
```bash
GOWORK=off go test ./internal/embeddingvectors ./internal/catalog ./internal/api/handlers ./internal/recommendations -race -count=1
```
---
## Descoped — not in this pass
**Routing legacy `/search?q=` through the provider pipeline.** Sunset **Wed, 01 Jul 2026** (`internal/api/handlers/legacy_read_routes.go:10`, `legacyReadSunset`) — days away; hardening a path scheduled for deletion is negative ROI. The typed legacy path already routes through the resolver; only the bare-`q` fallback hits `itemRepo.Search`, which the sunset removes. Re-routing also risks `browseResponse`↔`catalogResponse` shape drift. Reopen as a one-task follow-up **only if the sunset slips**, with a byte-for-byte `browseResponse` assertion.
**After-sync coverage refresh trigger.** The indexer (`main.go:1730`) and the search service (`router.go:457`) share no handle, so an after-drain trigger would need new cross-component wiring and would race the interval refresh. The 2-minute interval plus model-change collapse is sufficient; the trigger is intentionally omitted.
---
## Assumptions
- **One additive index migration** (`media_item_embeddings(model)`, `CONCURRENTLY`). No schema/column changes. *(Revised from the original "no migration": the model-filtered numerator is only cheap with this index, and performance is priority #1.)*
- All runtime coverage state is **in-memory** (atomic per-type snapshot), rebuilt on boot and refreshed every 2 minutes; not persisted.
- Coverage is computed **entirely from Postgres** over the **embed-eligible** population (`status='matched' OR audiobook/ebook`), model-filtered, manga excluded, restricted to configured index types; it **never** uses the Meili `document_count`.
- Active embedding model comes from `recommendations.EmbeddingLock.Model`; **no lock / no model provider ⇒ semantic not ready.**
- A model change is reflected within ≤ one refresh interval and **fails safe** within that window via the model-collapse rule; the residual ≤2-minute mismatch window is acceptable for a rare, admin-gated reconfiguration.
- Thresholds (enable 0.90 / disable 0.80) and the 2-minute interval are named consts and starting heuristics; the diagnostics shipped here are the instrument for retuning them. Promoting them to settings is a possible follow-up, not this pass.
- `semantic_ratio` stays `0.30` (`DefaultMeilisearchSemanticRatio`).
- Scope is hybrid-search hardening only — not conversational search.
---
## Self-Review (writing-plans checklist)
- **Findings coverage:** C1 eligibility denominator (D1, Task 1) · C2 empty-scope semantics (D3, Task 3) · missing `model` index (D1 migration, Task 1) · 4-caller signature change (Baseline #4, Task 1) · refresher wiring in `NewRouter`/`AppContext` (Baseline #9, Task 4) · concurrent-refresh single-flight + immutability + `-race` (D2, Task 3) · nil-snapshot/error fail-safe (D2, Task 3) · model-change collapse (D2, Task 3) · comma-ok nil vectorizer (Task 4) · querier seam + SQL-string helper for testability (Task 3, Task 7) · grouped/non-relevance diagnostics scoping (D4, Task 5) · unit probe vector + admin-only/5m (D5, Task 6) · `Status()` reads snapshot (Task 6) · backfill cursors + fairness (D6, Task 7) · legacy & after-sync descoped (Descoped) — all mapped.
- **Type consistency:** `SemanticCoverageGate`/`CatalogSemanticModelProvider`/`coverageQuerier`/`semanticCoverageTracker`/`catalogTypeCoverage`/`CatalogSearchSemanticStatus` used identically across tasks; `Eligible` (not `Indexable`) is the denominator field name throughout.
- **No placeholders:** every new symbol has a signature; SQL is concrete; the migration body is specified.
@@ -0,0 +1,388 @@
# Silo Notifications — Architecture Overview
**Date:** 2026-04-28
**Status:** Draft
**Scope:** Cross-cutting design covering all delivery channels: durable in-app inbox, realtime websocket, Apple Push (APNs), Android Push (FCM), and outbound webhooks.
**Companion docs:**
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — Foundation
- [`02-apns-relay.md`](./02-apns-relay.md) — Apple push
- [`03-fcm-relay.md`](./03-fcm-relay.md) — Android push
- [`04-outbound-webhooks.md`](./04-outbound-webhooks.md) — Discord and generic webhooks
## Goal
Silo should notify a profile when a new episode of a series they care about becomes available in their library. "Care about" means at least one of: the series is favorited, watchlisted, in continue-watching, or has a profile-eligible next-up cursor.
The notification should reach the profile through whichever delivery channels the profile has configured: an in-app inbox with realtime push to connected clients, mobile push notifications to Apple and Android devices, and outbound webhooks to user-chosen destinations like a Discord channel.
## Why this is hard
Three constraints pull in different directions:
1. **Self-hosted privacy.** Silo is self-hosted media. Notification content (episode titles, posters, profile names, library identity) must never leave the user's server unless the user has explicitly opted into a destination that requires it.
2. **Mobile push gatekeeping.** Apple and Google require pushes to the official App Store / Play Store builds to be authenticated by the credentials of the developer account that signed and published the app. There is no fully self-hosted, zero-Silo-infrastructure path to push to the official mobile apps.
3. **Multi-profile fairness.** A single user account on a Silo server can have multiple profiles (kid, adult, parent, etc.). Notifications must be scoped to the active profile. Devices, push tokens, webhooks, and preferences are all profile-scoped.
The design resolves these by:
- Routing all notification work through a durable per-profile inbox that lives on the user's own server. Everything else is a transport on top of this inbox.
- Operating a privacy-preserving relay for APNs and FCM that holds the official-app credentials and accepts only opaque push requests. Self-hosted servers opt in. The relay never sees notification content.
- Profile-scoping every database row, every API surface, every websocket subscription, and every push registration.
## Mental model
```
┌─────────────────────────────────────────────────┐
│ │
ingest/scan ────► release_events (one row per newly-available │
│ episode in a library) │
│ │ │
│ ▼ │
│ fanout worker │
│ │ │
│ │ loads profile_series_interest │
│ │ applies eligibility + preferences │
│ ▼ │
│ notification_deliveries (one row per │
│ eligible profile per release) │
│ │ │
│ │ AFTER COMMIT │
│ ▼ │
│ channel dispatcher ◄── fans out to channels: │
│ │
│ ┌───────────────┬───────────────┬─────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ websocket apple push android webhooks│
│ (in-app) (APNs relay push (per │
│ or custom) (FCM) profile)│
│ │ │ │ │ │
└───┼───────────────┼───────────────┼─────────┼───┘
▼ ▼ ▼ ▼
connected iOS / tvOS / Android Discord /
browser tabs macOS apps apps user URL
and apps (wake then (wake) (content
fetch metadata) included)
```
Three invariants make this work:
- **The durable row is the source of truth.** Realtime websocket events, mobile pushes, and webhooks are all triggered *after* the `notification_deliveries` row commits. If any transport fails, the inbox row still exists and shows up on next reconnect or refresh.
- **The fanout worker decides recipients exactly once.** Recipients are derived from a compact `profile_series_interest` index keyed by `(library_id, series_id)`. Per-channel transports are purely about delivery — they never make recipient decisions.
- **Dispatch enqueue is durable (outbox).** The same fanout transaction that inserts `notification_deliveries` also inserts `pending` per-target attempt rows for the push and webhook channels (`push_delivery_attempts` / `webhook_delivery_attempts`). The post-commit dispatcher *claims* those rows for immediate delivery; recovery workers sweep stale `pending` rows. A crash between delivery commit and dispatch therefore delays pushes/webhooks instead of silently dropping them. The websocket channel deliberately has no outbox row — it is best-effort because the inbox snapshot covers reconnect. See `01-release-events-and-inbox.md` "Transaction and Concurrency Rules".
## Channels at a glance
| Channel | Where it lives | Trust model | Content visibility |
|---|---|---|---|
| **In-app inbox** | User's own server, accessed via authenticated REST API | Same auth as the rest of Silo | Full content |
| **Realtime websocket** | User's own server, `/events/ws` channel `notifications` | Same auth, profile-scoped | Full content |
| **Apple Push (APNs)** | Silo-operated relay (or admin-supplied custom APNs credentials) | Opt-in. Self-hosters trust the relay to forward opaque requests. | Opaque payload only — app fetches content from user server after wake |
| **Android Push (FCM)** | Silo-operated relay (or admin-supplied custom FCM credentials) | Same as APNs | Opaque data-only message — app fetches content from user server after wake |
| **Outbound webhooks** | User-chosen destination URL (e.g., Discord, Slack, custom service) | Profile chose the destination; trust is implicit | Full content included by default. Discord type renders native embeds; generic type sends signed JSON |
## Triggers (what generates a notification)
A notification is generated when a new episode becomes available in a library and at least one profile has interest in the series:
- **Favorite** — the series is favorited by the profile.
- **Watchlist** — the series is on the profile's watchlist.
- **Continue watching** — the profile has in-progress watch state for the series.
- **Next up** — the new episode is at or beyond the profile's `next_expected_episode_key` cursor and the profile has progression state for the series.
A profile may match multiple reasons for a single release event. The `notification_deliveries` row records all matching reasons in a `reason_flags` JSONB column; the fanout produces exactly one delivery per `(profile_id, release_event_id)` regardless of how many reasons matched. A second uniqueness rule spans libraries: because Silo media items are catalog-level (`media_item_libraries` is a junction), the same episode landing in two libraries (e.g., "TV" and "TV 4K") shares one `episode_id`, and at most one `episode.available` delivery is created per `(profile_id, episode_id)` — dual-quality library setups do not double-notify.
**Back-catalog protection:** "newly available" must mean *newly released to this server*, not *newly seen by the notifications feature*. The first scan of a newly created library and the one-time feature-enable backfill **seed** `episode_availability` without creating release events, and the fanout worker applies a per-series burst cap for bulk additions to existing libraries. Without this, importing a 200-episode back-catalog of a series with hundreds of interested profiles would generate tens of thousands of deliveries and pushes in a single scan. Full rules in `01-release-events-and-inbox.md` "Seeding and Burst Suppression".
**v1 explicitly does not support:**
- Movie additions. Silo has no movie-availability detector parallel to the per-episode one in this design; without it, there's nothing to fan out from for movies. Users *can* watchlist movies that don't yet exist in their library (via TMDB lookups), but the missing piece is the ingest-time signal that a watchlisted movie has now arrived. v2 can add a `movie_availability` table and analogous release events; the fanout/inbox/channel layers in this design are movie-ready by construction.
- Metadata-only updates (poster refreshes, title corrections).
- Generic library-changed notifications.
- Aggregated notifications like "3 new episodes available." Per-episode deliveries first; aggregation is a presentation concern that can stack on top.
## Mode terminology
The design uses three vocabularies that correspond to different layers; they are intentionally not the same word. This is the canonical mapping:
| Layer | Terms | Where it lives |
|---|---|---|
| **Profile-level push mode** (UI-facing) | `off`, `in_app_only`, `private_push` | `push_devices.push_mode` |
| **APNs wire mode** (relay request `mode` field) | `private_alert`, `background_wake` | Sent to APNs relay or used in custom APNs payload builder |
| **FCM wire mode** (relay request `mode` field) | `private_data`, `background_wake` | Sent to FCM relay or used in custom FCM payload builder |
Mapping:
- Profile `private_push` → APNs `private_alert` for visible wakes, FCM `private_data` for visible wakes.
- Profile `in_app_only` → no remote push at all (websocket / inbox only).
- Profile `off` → no notifications for this device whatsoever.
- The `background_wake` wire mode is reserved for low-noise sync (per-channel spec details). It is not exposed at profile level in v1.
## Preference model
Preferences are profile-scoped and flat. There is no per-channel × per-reason matrix.
```
notification_preferences (per profile)
├── enabled bool — global kill switch
├── notify_favorites bool
├── notify_watchlist bool
├── notify_continue_watching bool
└── notify_next_up bool
```
Per-webhook reason gating uses **the same four boolean columns** on `notification_webhooks` (`notify_favorites`, `notify_watchlist`, `notify_continue_watching`, `notify_next_up`) — not a single structured `reason_filters` JSON field.
**Precedence:** profile preferences are a **hard gate** applied during fanout (a delivery row is not created if all matching reasons are disabled at the profile level). Per-webhook flags and per-device push mode are **additional filters** applied during dispatch — they can narrow what fires for a specific destination but cannot re-enable a reason the profile has globally disabled.
Each push device has a `push_mode` (`off`, `in_app_only`, `private_push`) that gates whether mobile push fires for that specific device. Each webhook has its own `enabled` flag and per-webhook `notify_favorites` / `notify_watchlist` / `notify_continue_watching` / `notify_next_up` booleans for finer control.
The fanout worker uses `notification_preferences` to decide whether to *create* a `notification_delivery` at all. The channel dispatcher uses per-device and per-webhook flags to decide which transports fire after the row commits.
This means: if a profile turns off `notify_continue_watching`, a delivery is never created for a continue-watching-only match, and no transports fire. If a profile leaves the reason flags on but disables a specific Discord webhook, the inbox row + websocket + push still fire normally — the webhook just doesn't.
## Mobile push: why a relay is necessary
Apple and Google operate the only push infrastructure that can wake a closed app on iOS and Android. To send a push to the official Silo app on the App Store or Play Store, the request must be authenticated by the credentials of the developer account that signed and published that app build:
- **APNs:** Apple Developer Team ID + Auth Key ID + `.p8` private key. The auth key is account-scoped (not topic-scoped); it can sign JWTs that authorize push to any bundle topic the team owns. The `apns-topic` request header selects which topic the push targets, and Apple rejects requests whose topic is not owned by the signing team.
- **FCM:** Firebase service account JSON whose private key signs a JWT exchanged for an OAuth2 access token. The service account is scoped to a specific Firebase project, and the project owns one or more registered Android app package names. (Package + SHA-1 cert fingerprint registration matters for Firebase Authentication and App Check, but for plain FCM v1 push the service account + package name + matching FCM registration token are what's required.)
Distributing these credentials to thousands of self-hosted Silo installations is not viable:
1. It violates Apple's and Google's developer agreements, which prohibit sharing credentials outside the team.
2. Anyone with a copy could push to every Silo app worldwide.
3. Apple actively monitors and revokes leaked keys.
Therefore the design separates two paths:
### Hosted relay (`silo_relay`) — the default for official-app users
A small Silo-operated service holds the official APNs `.p8` key and FCM service account JSON. Self-hosted servers opt in by configuring a relay API key, then call the relay with opaque push requests. The relay signs and forwards to APNs / FCM. The relay never sees notification content.
The relay is **stateless on the request path**. Its database holds only:
- relay accounts (one per opt-in self-hosted server installation)
- relay API keys (with `last_used_at`, revocable)
- minimal per-account allowlists (which app topics this account may push to)
- redacted operational logs (no APNs/FCM tokens, no notification content)
It does **not** store user identities, profile data, device-to-user mappings, or notification content. Apple and Google do the actual addressing — when a push request goes out, the relay just forwards `{token, opaque_ids}` to APNs/FCM, and APNs/FCM deliver based on the device token.
### Custom credentials (`custom_apns` / `custom_fcm`) — escape hatch
Power users, white-label deployments, or anyone running a fork that ships its own resigned mobile app can configure their own Apple/Google developer credentials. The user's Silo server signs requests directly using those credentials and sends to APNs/FCM without going through the Silo relay.
Custom mode is **only useful if the admin has shipped a custom-signed app build under their own developer account** with their own bundle/package ID. It does not let a self-hoster push to the official App Store / Play Store app — that's not technically possible without the official developer credentials.
### Addressing flow walkthrough
The most common question on this design is "how does the relay know which user/device to send to with thousands of servers and thousands of users?" The answer is: it doesn't, because Apple and Google handle addressing.
```
1. User installs official Silo app on iPhone from App Store.
2. App calls iOS push registration; Apple returns an opaque APNs device token
(~100 bytes, unique per app install per device).
3. App POSTs that token to the user's OWN server:
POST https://my-silo.example.com/api/v1/devices/push/apple
{ device_id, apns_token, ... }
4. User's server stores in its push_devices table, scoped to the active profile.
5. New episode lands. Fanout worker creates a notification_delivery row.
6. Channel dispatcher loads the profile's enabled push_devices and POSTs
to the relay (or signs directly via custom_apns):
POST https://relay.silo.app/v1/apple/send
Authorization: Bearer rk_thisServersRelayApiKey
{
"token": "740f...", ← APNs device token from step 3
"topic": "com.continuum.app.ios",
"environment": "production",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...", ← lets app find right local server
"delivery_id": "01JDELIVERY..." ← lets app fetch notification meta
}
7. Relay signs JWT with Silo's .p8, forwards to APNs:
POST https://api.push.apple.com/3/device/740f...
apns-topic: com.continuum.app.ios
authorization: bearer <signed JWT>
{generic opaque payload with silo.* fields}
8. Apple delivers to the iPhone holding token 740f...
9. App wakes, reads server_device_id, finds matching local server account,
GETs full notification content from the user's own server.
```
The relay knows: a relay API key was used (so: which self-hosted server installation), the server's egress IP address (inherent to any hosted relay — the server connects to it directly), an APNs device token, a timestamp, and opaque IDs. The relay does not know: which user, which profile, what notification content, what server URL, what library, or what media item. Even a fully compromised relay leaks only timing + device tokens + egress IPs, not notification content or identity. The egress IP is the closest thing to an identity leak in this design — for home hosting it identifies the household's connection — and is listed here so the privacy claims stay exhaustive rather than over-strong.
## Threat model summary
Detailed per-channel threat models live in each channel's spec. The cross-cutting summary:
| Channel | Adversary | Worst case |
|---|---|---|
| In-app inbox + websocket | Network attacker | Existing Silo auth threat model. Notifications add no new surface. |
| APNs / FCM relay (operator) | Compromised relay or hostile relay operator | Can learn device tokens, push timing, and the server's egress IP for opted-in servers. Cannot learn user identity, notification content, server URL, or library data. Cannot fabricate meaningful pushes (only generic opaque payloads). |
| APNs / FCM (Apple / Google) | Apple / Google as platform operator | Can see app topic, device token, generic payload, timing. No user identity, no media metadata, no server URL. |
| Outbound webhooks | Webhook destination operator (e.g., Discord) | Sees full notification content (titles, posters, episode info) for the profile that configured the webhook. The profile chose this destination; this is the explicit trade. |
| Outbound webhooks | Network attacker who owns a URL submitted by the user | Can receive the profile's notifications until the webhook is removed. Mitigated by HMAC for generic webhooks, HTTPS-only, host blocklist (no localhost / RFC1918 by default), and a profile-visible "last seen" / failure status. |
## Channel dispatcher boundary
A single internal `Dispatcher` interface fans the durable row out to channels. Each channel implements the same minimal contract:
```go
type Dispatcher interface {
// Called once per notification_deliveries row, AFTER the row commits.
// Per-target dispatchers (push, webhooks) do not decide their own work:
// the fanout transaction already enqueued one `pending` attempt row per
// (delivery_id, target_id), where target_id is push_device.id or
// notification_webhook.id. Dispatch claims and sends those rows; a
// recovery worker sweeps `pending` rows whose claim never happened
// (crash between commit and dispatch). Idempotency is therefore
// by (delivery_id, target_id, attempt_number). The websocket dispatcher
// has no per-target row and is instead idempotent by delivery_id alone —
// re-publishing the same delivery_id is a no-op for connected clients.
Dispatch(ctx context.Context, delivery NotificationDelivery) error
}
```
Implementations:
- `WebsocketDispatcher` — publishes a `notification.created` event on the `notifications` channel, scoped to the delivery's `(user_id, profile_id)`. Best-effort; durable row is the source of truth.
- `ApplePushDispatcher` — selects enabled `push_devices` for the profile where `platform = 'apple'` and routes through the configured provider (`silo_relay` or `custom_apns`). Records each attempt in `push_delivery_attempts`.
- `AndroidPushDispatcher` — same but `platform = 'android'`, routes through `silo_relay` or `custom_fcm`.
- `WebhookDispatcher` — selects enabled `notification_webhooks` for the profile, applies per-webhook reason filters, and POSTs the channel-specific payload (Discord embed or generic JSON+HMAC). Records each attempt in `webhook_delivery_attempts`.
Channel failures are isolated: if APNs is down, webhooks and websocket still fire. If a webhook destination is unreachable, push still fires. The fanout worker does **not** wait for any dispatcher to succeed before marking the release event processed; the durable delivery rows plus the `pending` outbox attempt rows (enqueued in the same transaction) guarantee eventual delivery for push and webhooks even across a crash. The relay-bound dispatchers additionally pace their sends client-side (token bucket) so a burst never trips the relay's rate limits — see `02`/`03` "Relay pacing".
## Capability surface
The frontend and mobile clients need to know what's available without guessing. A single capability endpoint returns the truth:
```http
GET /api/v1/notifications/capability
```
```json
{
"in_app": { "enabled": true },
"apple_push": {
"available": true,
"provider": "silo_relay",
"supported_modes": ["private_push", "in_app_only"]
},
"android_push": {
"available": false,
"provider": "off",
"supported_modes": ["in_app_only"]
},
"webhooks": {
"available": true,
"max_per_profile": 10,
"supported_types": ["discord", "generic"]
}
}
```
Clients render setup UI from this response. They never have to introspect admin settings.
## Data model overview
The full schemas live in the per-channel specs. The shared shape:
| Table | Purpose | Spec |
|---|---|---|
| `episode_availability` | "Episode E became available in library L." Idempotent. Seeded silently for back-catalog. | 01 |
| `release_events` | One per `(library_id, episode_id)` newly-available event. | 01 |
| `notification_library_seed_state` | Per-library marker: availability has been seeded; release events may now be emitted. | 01 |
| `profile_series_interest` | Compact recipient index keyed by `(profile_id, library_id, series_id)`. | 01 |
| `notification_deliveries` | Per-profile durable inbox row. Cross-library dedupe via partial unique `(profile_id, episode_id)`. | 01 |
| `notification_preferences` | Per-profile reason toggles + master enable. | 01 |
| `push_devices` | Per-profile-per-device push registration. Platform-tagged (`apple` / `android`). | 02 / 03 |
| `push_delivery_attempts` | Per-attempt push log. No notification content. | 02 / 03 |
| `notification_webhooks` | Per-profile webhook destinations (Discord or generic). | 04 |
| `webhook_delivery_attempts` | Per-attempt webhook log. | 04 |
A single migration block lands the foundation tables. APNs/FCM/webhook tables can land in separate migrations as those channels ship — they don't block the v1 in-app inbox.
## API surface overview
Profile-scoped (require `X-Profile-Id` header):
- `GET /api/v1/notifications` — paginated inbox (newest-first, `before` cursor).
- `GET /api/v1/notifications/sync` — forward sync (`since` cursor); the endpoint mobile clients call after a push wake.
- `GET /api/v1/notifications/{id}` — single delivery by ID.
- `GET /api/v1/notifications/unread-count` — for the badge.
- `POST /api/v1/notifications/{id}/read` — mark single read.
- `POST /api/v1/notifications/read-all` — mark all read.
- `POST /api/v1/events/ws-ticket` — mint a short-lived single-use websocket ticket bound to `(user_id, profile_id)`.
- `GET /api/v1/notifications/preferences` — read profile prefs.
- `PUT /api/v1/notifications/preferences` — update profile prefs.
- `GET /api/v1/notifications/capability` — what channels are available.
- `POST /api/v1/devices/push/apple` — register an APNs device.
- `POST /api/v1/devices/push/fcm` — register an FCM device.
- `DELETE /api/v1/devices/push/{id}` — disable a push device.
- `GET /api/v1/notifications/webhooks` — list profile's webhooks.
- `POST /api/v1/notifications/webhooks` — create a webhook.
- `PUT /api/v1/notifications/webhooks/{id}` — update a webhook.
- `DELETE /api/v1/notifications/webhooks/{id}` — delete a webhook.
- `POST /api/v1/notifications/webhooks/{id}/test` — fire a test event.
Admin-scoped:
- Settings registry entries for APNs and FCM provider configuration (admin-only via existing `internal/api/handlers/settings.go` patterns).
## Realtime channel
A new event channel `notifications` joins the existing `catalog`, `jobs`, `sessions`, `tasks`, `scans`, `history_import`, `user_state`, `plugins` channels in `internal/events/types.go`. The websocket handler must validate active profile identity and must reject `notifications` events whose `profile_id` doesn't match the connection's bound profile.
Because browsers can't set custom headers on WebSocket connections, profile identity is carried by a **short-lived single-use ticket**: the client calls `POST /api/v1/events/ws-ticket` (normal auth + `X-Profile-Id`), receives an opaque ticket valid for ~30 seconds, and passes it as a `ticket` query parameter on the handshake. The server consumes the ticket and binds the connection to the `(user_id, profile_id)` it was minted for. A long-lived profile token must **not** be passed in the query string — self-hosted servers commonly sit behind reverse proxies whose access logs capture query strings, and a logged ticket that expired 30 seconds after minting is harmless where a logged profile token is not.
Event types on this channel:
- `notification.created` — a new delivery for the bound profile.
- `notification.read` — a delivery was marked read (allows multi-tab coherence).
Snapshot on subscribe: recent unread deliveries for the bound profile, so reconnecting clients hydrate without a separate REST call.
## Out of scope for v1
- Movie release notifications (no trigger surface; v2).
- Request-fulfilled notifications ("your requested item is now available"). Silo's requests system postdates this design; it is the most obvious next notification type and slots into the extensible `notification_deliveries.type` registry without schema changes. v2.
- Aggregated notifications ("3 new episodes available"; presentation layer, can stack on per-episode rows later). The per-series burst cap in `01` bounds bulk-addition volume until an aggregated delivery type exists.
- Web Push (browser PWA notifications via Push API). Possible v2 — would mirror APNs/FCM as a third push platform.
- Email or SMS delivery.
- Cross-profile / household-wide notification views.
- Admin-level system notifications ("scan complete", "library health alert"). The existing realtime events hub (`internal/events/`, with the operational publisher wrapper at `internal/notifications/hub.go`) already handles operational events; this folder's design is strictly user-facing media notifications.
- Notification templates / customization beyond Discord-vs-generic.
- Time-window quiet hours. Could be added to `notification_preferences` later; not v1.
- Locale negotiation for push payloads. APNs / FCM payloads use generic localization keys (`SILO_NOTIFICATION_GENERIC_BODY` etc.) so the OS picks language from device locale.
## Open questions
These are the few remaining items not yet decided. None block writing the per-channel specs.
1. **Service-level rate limits per relay account.** Per-second and per-day caps need real numbers. Suggest 10 req/sec burst, 50,000 req/day initial quota; tune from logs. The server-side dispatchers pace their relay calls client-side (default 5 req/sec token bucket, see `02`/`03`) so a hundreds-of-users server drains bursts smoothly instead of slamming into 429s; the per-series burst cap in `01` bounds worst-case burst size.
2. **Webhook destination domain blocklist edge cases.** RFC ranges are defined in `04-outbound-webhooks.md`; remaining open question is whether IPv6 v4-mapped addresses (`::ffff:0:0/96`) and CGNAT (`100.64.0.0/10`) need any admin escape hatch beyond the global `allow_private_destinations` flag.
3. **Discord image proxy timing.** Ship in v1 (build `media.discord-cdn-proxy.silo.app`) or defer to v1.5 with text-only embeds? Recommendation in `04`: defer to v1.5; v1 ships text-only embeds.
Resolved during review (no longer open):
- **Relay service code lives in a separate repo** (provisional `silo-push-relay`). This folder defines the contract.
- **Migration ordering.** Foundation schema lands as one migration; per-channel schemas can land separately as their channels ship.
- **`server_device_id` lifecycle.** Stable across token rotation; rotates only when a device is removed and re-registered.
## Phasing
Implementation phases gated by feature flags:
| Phase | Flag | Capability |
|---|---|---|
| 1 | `notifications.schema_enabled` | Tables exist; no writes. |
| 2 | `notifications.interest_updaters_enabled` | `profile_series_interest` updates land on favorites/watchlist/playback events. Backfill task runs. |
| 3 | `notifications.release_events_enabled` | Availability seeding backfill runs first, then ingest writes `release_events`. No fanout yet. |
| 4 | `notifications.fanout_enabled` | Fanout worker runs; `notification_deliveries` rows materialize. Websocket channel published. |
| 5 | `notifications.ui_enabled` | Inbox API + frontend badge + page exposed. |
| 6 | `notifications.webhooks_enabled` | Outbound webhooks + Discord/generic dispatchers. v1 ships **text-only** Discord embeds (no `image` or `avatar_url` fields) to avoid leaking the user's server origin to Discord. v1.5 adds the optional `media.discord-cdn-proxy.silo.app` proxy and re-enables embed images. |
| 7 | `notifications.apple_push_enabled` | APNs registration + dispatcher. Requires relay infra (or admin-supplied custom credentials). |
| 8 | `notifications.android_push_enabled` | FCM registration + dispatcher. Requires relay infra (or admin-supplied custom credentials). |
Phases 1-6 ship without any new external infrastructure. Phases 7-8 require Silo to provision Apple Developer + Firebase developer accounts and operate the relay service.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,891 @@
# Privacy-Preserving APNs Relay Spec
**Date:** 2026-04-27 (refined 2026-04-28)
**Status:** Draft (refined; open questions resolved)
**Scope:** Silo server push integration, Apple clients, opt-in central APNs relay, custom APNs provider configuration
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md) — read first for cross-cutting context
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — foundational durable inbox + fanout
**Sibling spec:**
- [`03-fcm-relay.md`](./03-fcm-relay.md) — Android push using the same threat model and architecture
## Summary
Silo should support remote Apple notifications through either an opt-in
central APNs relay or admin-configured APNs credentials, while keeping
notification metadata on the user's own Silo server.
The hosted relay should be a constrained transport service. It should receive
only the minimum fields needed to submit an APNs request:
- an APNs device token
- APNs environment and app topic
- a push mode
- an opaque server/device correlation key
- an opaque delivery identifier
- optional badge count
Neither provider path may send titles, item names, usernames, profile names,
library names, server URLs, artwork URLs, watched-state metadata, or
notification body text through APNs.
When the Apple device wakes, the app should fetch real notification metadata
directly from the user's Silo server using the existing authenticated
server connection.
## Problem
Reliable Apple remote notifications require APNs. A self-hosted Silo server
cannot directly wake an iPhone, iPad, or Apple TV app in the background without
going through Apple's push infrastructure.
That creates two product concerns:
1. Privacy: notification contents should not be exposed to Apple or a Silo
operated relay.
2. Operational friction: most server admins should not need to create and manage
their own Apple developer account, APNs key, bundle topic, and push service.
Silo should offer a practical default that preserves the self-hosted trust
model as much as the platform allows.
## Decision
Add configurable Apple push providers for the official Apple app:
- `off`: no remote Apple push
- `silo_relay`: Silo's hosted APNs relay
- `custom_apns`: admin-supplied APNs credentials used directly by the server
The relay submits APNs requests using Silo-controlled Apple credentials for
the official app bundle. A self-hosted server admin may opt in to use this relay.
The relay's API should intentionally prevent notification content from being
sent through the relay.
If `custom_apns` is selected, the user's Silo server submits directly to
APNs using admin-provided Apple developer credentials. This avoids the Silo
relay entirely, but it must use the same minimal payload and metadata-fetch
rules as the hosted relay path.
The default push mode should be **Private Alert**:
- APNs displays a generic Silo notification.
- The APNs payload contains only opaque identifiers.
- The app fetches the actual notification row from the user's server when it is
opened or granted background execution.
The implementation should also support **Background Wake** for low-noise sync,
with the explicit understanding that iOS background pushes are opportunistic and
not a reliable user-visible notification mechanism.
## Goals
- Make Apple push possible without every admin managing APNs credentials.
- Keep actual notification metadata on the user's Silo server.
- Make relay participation explicit and disabled by default.
- Make APNs credentials configurable for admins who want to avoid the hosted
relay.
- Keep the relay stateless for device subscriptions where possible.
- Keep APNs payloads opaque and generic.
- Keep the hosted relay and custom APNs paths behaviorally equivalent from the
app's point of view.
- Fit on top of the durable notification inbox design.
## Non-Goals
- Replacing APNs for Apple remote push.
- Shipping notification content through the central relay.
- Requiring Firebase or Google services for this Apple path.
- Solving Android push in this spec.
- Adding marketing, analytics, or delivery-tracking exports to the relay.
- Hiding APNs itself from Apple devices. Custom APNs still uses Apple APNs.
- Making silent background delivery as reliable as visible push.
## Threat Model
### What The User's Server Knows
The user's Silo server knows:
- the notification content
- the profile and user recipients
- the registered local devices
- APNs device tokens for those devices
- push delivery attempts and failures
This is acceptable because the server is already the authority for the user's
media, profiles, sessions, and notification inbox.
### What The Central Relay May Know
The relay may see:
- an opt-in relay account or install identifier
- the server's egress IP address (inherent to any hosted relay — the server
connects to it directly; for home hosting this identifies the household's
connection, making it the closest thing to an identity leak in this design)
- APNs device tokens submitted in send requests
- request timestamps
- coarse push mode, such as `private_alert` or `background_wake`
- opaque `collapse_id` values (per-server-keyed HMACs of series identity; the
relay can group one server's pushes into series-equivalence classes but
cannot recover which series, nor correlate across servers — see "Send Apple
Push" validation)
- APNs response status and APNs request IDs
- opaque delivery identifiers
The relay must not receive enough information to know what media item,
notification type, profile, user, library, server hostname, or server URL caused
the push.
### What Apple May Know
Apple/APNs may see:
- the official app topic
- the target device token
- APNs headers
- request timing
- the generic APNs payload
Apple must not receive media titles, usernames, library names, server URLs,
artwork URLs, or other notification metadata in the payload.
### Residual Metadata Leakage
This design cannot hide that a device received a Silo push at a particular
time, nor the egress IP of the server sending relay requests. It can only hide
the meaning and content of the push. Admins for whom IP exposure matters can
route relay traffic through their existing VPN/proxy egress; the relay path is
plain HTTPS and needs no special handling.
## User And Admin Controls
### Server Admin Control
The server admin must explicitly choose an Apple push provider.
Suggested setting:
```text
notifications.apple_push.provider = off | silo_relay | custom_apns
```
Default:
```text
off
```
If `silo_relay` is selected, the admin configures a relay API key and
accepts a clear privacy notice:
```text
Silo Relay can wake Apple devices through APNs, but notification details
stay on this server. The relay receives APNs device tokens, timestamps, opaque
delivery IDs, and generic push mode only.
```
If `custom_apns` is selected, the admin configures their own Apple developer
APNs credentials:
```text
notifications.apple_push.custom.team_id
notifications.apple_push.custom.key_id
notifications.apple_push.custom.private_key
notifications.apple_push.custom.default_environment = production | sandbox
notifications.apple_push.custom.allowed_topics = [...]
```
The server then sends directly to APNs and does not call the Silo relay.
The same private payload rules still apply.
### Device User Control
Each Apple device must opt in independently:
- the OS notification permission must be granted
- the app must be signed into a Silo server
- the active profile must enable push notifications for that device
- the server admin must have enabled an Apple push provider
Admins must not be able to silently enroll every profile/device into remote push
without the device having granted OS notification permission.
### Profile Control
Push preferences should sit on top of the durable notification preferences from
the inbox design.
Suggested profile/device modes:
- `off`: never send remote push to this device
- `in_app_only`: websocket and inbox only
- `private_push`: generic APNs wake/alert, fetch details from server
- `full_preview`: out of scope for v1; would require explicit user consent and
a separate privacy warning
V1 should ship only `off`, `in_app_only`, and `private_push`.
## System Architecture
```mermaid
sequenceDiagram
participant App as Apple App
participant Server as User Silo Server
participant Relay as Silo APNs Relay
participant APNs as Apple APNs
App->>App: Request OS notification permission
App->>App: Register for remote notifications
App->>Server: Register device token and opaque server_device_id
Server->>Server: Store encrypted token and device preferences
Server->>Server: Create durable notification_delivery
Server->>Relay: Send minimal APNs relay request
Relay->>APNs: Submit generic APNs payload
APNs->>App: Deliver private alert or background wake
App->>Server: Fetch notification metadata directly
Server->>App: Return inbox rows for active profile
```
## Ownership Boundaries
### This Repository
Silo owns:
- admin settings for selecting a push provider
- profile and device notification preferences
- APNs token registration from Apple clients
- durable storage of device registrations
- push fanout from `notification_deliveries`
- relay client integration
- retry, backoff, and APNs error handling
- inbox sync APIs used after wake
### Apple Client Repository
The Apple app owns:
- APNs registration
- notification permission UX
- device registration API calls
- local server/device correlation
- wake handling
- fetching metadata from the user's server
- rendering local/in-app notification details
### Central Relay Service
The central relay owns:
- APNs provider credentials for official Silo bundle topics
- relay API authentication
- request validation and rate limiting
- APNs request submission
- redacted operational logs
- minimal delivery status reporting
The relay should live outside this repo unless the project later decides to
co-locate the service code. This repo should define the contract and implement
the self-hosted server side.
### Custom APNs Mode
When `custom_apns` is selected, there is no central relay in the send path.
The user's Silo server owns:
- APNs provider authentication using the configured team ID, key ID, and private
key
- topic allowlisting for the official Silo app bundle IDs it intends to
support
- sandbox vs production environment selection
- APNs request construction
- APNs response handling
- APNs credential rotation and secret storage
Custom APNs mode must not unlock richer payloads. It exists to let admins avoid
the Silo relay, not to bypass the privacy-preserving notification contract.
## Data Model
This spec assumes `notification_deliveries` exists from the durable notification
inbox design.
### `push_devices`
Purpose: profile/device-scoped remote push registration. Shared across Apple and Android — see [`03-fcm-relay.md`](./03-fcm-relay.md) for the FCM-specific columns.
Columns:
- `id text primary key`
- `user_id integer not null` (matches `users.id integer`)
- `profile_id text not null`
- `device_id text not null`
- `platform text not null` — `'apple'` or `'android'`. Required; the row is invalid without it.
- `provider text not null` — for Apple rows: `'silo_relay'` or `'custom_apns'`. For Android rows: `'silo_relay'` or `'custom_fcm'`. (Effectively the active configured provider at registration time; updates rewrite the row.)
- `apns_environment text` — `'production'` / `'sandbox'`. Nullable; populated only for `platform = 'apple'`.
- `apns_topic text` — bundle topic. Nullable; populated only for `platform = 'apple'`.
- `apns_token_ciphertext bytea` — nullable; populated only for `platform = 'apple'`.
- `apns_token_hash text` — nullable; populated only for `platform = 'apple'`.
- `fcm_token_ciphertext bytea` — nullable; populated only for `platform = 'android'`. Defined here so the table is forward-compatible with [`03-fcm-relay.md`](./03-fcm-relay.md); the FCM spec adds the rest of the Android-specific columns.
- `fcm_token_hash text` — nullable; Android only.
- `fcm_project_id text` — nullable; Android only.
- `fcm_package_name text` — nullable; Android only.
- `server_device_id text not null` — random opaque value, generated server-side at registration. Stable across token rotation; rotates only when the device is removed and re-registered.
- `push_mode text not null default 'private_push'` — profile-level mode (see [`00-architecture-overview.md`](./00-architecture-overview.md) "Mode terminology").
- `enabled boolean not null default true`
- `last_seen_at timestamptz`
- `last_success_at timestamptz`
- `last_failure_at timestamptz`
- `last_failure_code text`
- `created_at timestamptz not null default now()`
- `updated_at timestamptz not null default now()`
Constraints:
- unique on `(profile_id, device_id, platform)` — a device can register exactly once per platform per profile, regardless of which provider the admin happens to have configured. This matters because admins may flip between `silo_relay` and `custom_apns` mid-rotation; a `provider`-scoped unique key would leave stale rows behind.
- unique on `(server_device_id)`
- `CHECK ((platform = 'apple' AND apns_token_hash IS NOT NULL AND fcm_token_hash IS NULL) OR (platform = 'android' AND fcm_token_hash IS NOT NULL AND apns_token_hash IS NULL))` — exactly the platform-appropriate token columns are populated.
Notes:
- `server_device_id` must be random and opaque. It is the value included in the
APNs payload so the app can map the push back to the correct local server
account.
- Store APNs tokens encrypted at rest when the local encryption facility exists.
- Store `apns_token_hash` for dedupe and diagnostics without logging raw tokens.
### `push_delivery_attempts`
Purpose: operational record of push attempts without notification content.
Columns:
- `id text primary key`
- `notification_delivery_id text not null`
- `push_device_id text not null`
- `provider text not null`
- `attempt_number integer not null` — 1-based counter incremented on each retry. Used as the `Idempotency-Key` suffix for relay calls (see Send Apple Push), and lets diagnostics distinguish "10 attempts of one delivery" from "10 deliveries each first-attempted".
- `relay_request_id text`
- `apns_id text` — Apple's APNs ID returned in the response. Nullable; null for FCM rows.
- `fcm_message_name text` — FCM v1 message name (see [`03-fcm-relay.md`](./03-fcm-relay.md)). Nullable; null for APNs rows.
- `status text not null` — `'pending'`, `'delivered'`, `'retrying'`, `'failed'`, `'device_disabled'`. `pending` rows are the dispatch outbox: the fanout transaction in [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) inserts attempt 1 as `pending` in the same transaction as the delivery; the push dispatcher claims and sends them post-commit.
- `failure_code text`
- `attempted_at timestamptz not null default now()`
- `next_retry_at timestamptz`
Constraints:
- unique on `(notification_delivery_id, push_device_id, attempt_number)` to make the relay's `Idempotency-Key` deterministic and prevent double-claims of an attempt.
- add an index on `(status, next_retry_at)` for retry workers. The same index serves outbox recovery: a sweeper claims `pending` rows older than ~60s (crash between delivery commit and dispatch) with `FOR UPDATE SKIP LOCKED`.
Retention:
- keep successful attempts for a short operational window, such as 14 days
- keep failed attempts longer, such as 30 to 90 days, to diagnose device issues
## Server API Surface
### Register Apple Push Device
```http
POST /api/v1/devices/push/apple
```
Profile-scoped. Requires normal auth plus active profile context.
Request:
```json
{
"device_id": "apple-device-local-id",
"apns_token": "hex-or-base64-token",
"apns_environment": "production",
"apns_topic": "com.continuum.app.ios",
"push_mode": "private_push"
}
```
Response:
```json
{
"id": "01J...",
"server_device_id": "01JOPAQUE...",
"enabled": true,
"push_mode": "private_push"
}
```
Rules:
- `apns_topic` must be allowlisted by app build/channel.
- `apns_environment` must be `sandbox` or `production`.
- registration is idempotent by `(profile_id, device_id, provider)`.
- token rotation updates the encrypted token and token hash.
- a disabled server push provider should still allow registration, but should
report that remote push is unavailable in a capability field if the frontend
needs it.
### Disable Push Device
```http
DELETE /api/v1/devices/push/{id}
```
Profile-scoped and idempotent.
The server may either delete the row or mark `enabled = false`. Marking disabled
is preferred for diagnostics and future re-enable flows.
### Push Capability
```http
GET /api/v1/notifications/capability
```
The full envelope is defined in [`00-architecture-overview.md`](./00-architecture-overview.md). The Apple-relevant slice is:
```json
{
"apple_push": {
"available": true,
"provider": "silo_relay",
"available_providers": ["silo_relay", "custom_apns"],
"supported_modes": ["private_push", "in_app_only"],
"privacy_mode": "metadata_fetch"
}
}
```
The full response also includes `in_app`, `android_push` (see [`03-fcm-relay.md`](./03-fcm-relay.md)), and `webhooks` (see [`04-outbound-webhooks.md`](./04-outbound-webhooks.md)).
This lets clients present truthful setup UI without guessing whether the admin
has enabled remote push.
## Relay API Contract
The relay API should be intentionally narrow. The user server should not send an
arbitrary APNs payload.
### Send Apple Push
```http
POST /v1/apple/send
Authorization: Bearer <relay_api_key>
Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>
```
Request:
```json
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
```
Response:
```json
{
"request_id": "01JRELAY...",
"apns_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "accepted"
}
```
Validation:
- `token` is required and must be plausible for APNs.
- `environment` must be `sandbox` or `production`. The relay forwards `production` requests to `https://api.push.apple.com` and `sandbox` requests to `https://api.development.push.apple.com` (Apple's current canonical hostnames; older docs/libraries also reference `api.sandbox.push.apple.com`, but `api.development.push.apple.com` is the current Apple-documented form).
- `topic` must be allowlisted for the relay account.
- `mode` must be `private_alert` or `background_wake`.
- `server_device_id` and `delivery_id` must be opaque values with length limits (recommend ≤128 chars, ULID-shaped).
- `collapse_id` must be opaque and ≤64 bytes — Apple enforces a 64-byte cap on `apns-collapse-id`. Reject longer values with HTTP 400.
- `collapse_id` derivation (server-side rule, not relay-enforced): the server computes `collapse_id = base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated to 26 chars, where `server_collapse_secret` is a random per-server secret generated at first use. Never send raw or plainly-hashed `series_id` — an unkeyed hash would let the relay (or Apple) dictionary-match popular series IDs. Residual: the relay can still group one server's pushes into per-series equivalence classes (that is what collapse is for); the per-server key prevents recovering the series or correlating across servers.
- `badge` is optional. Default should be omitted to avoid leaking unread counts through APNs unless the user/admin explicitly enables badge sync.
- no free-form notification title or body fields are accepted.
- no image URL, media ID, username, server hostname, or server URL field is accepted.
### Relay-Built APNs Payloads
For `private_alert`, the relay constructs:
```json
{
"aps": {
"alert": {
"title-loc-key": "SILO_NOTIFICATION_TITLE",
"loc-key": "SILO_NOTIFICATION_GENERIC_BODY"
},
"sound": "default"
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
For `background_wake`, the relay constructs:
```json
{
"aps": {
"content-available": 1
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
Headers:
- `apns-topic`: selected from the allowlisted `topic`
- `apns-push-type`: `alert` for `private_alert`, `background` for
`background_wake`
- `apns-priority`: `10` for `private_alert`, `5` for `background_wake`
- `apns-collapse-id`: optional opaque `collapse_id`
The relay should not allow callers to override this payload shape in v1.
## Custom APNs Contract
`custom_apns` should share the same internal send model as `silo_relay`,
but replace the relay HTTP call with a direct APNs provider request.
The server-side push sender should accept only a structured internal request:
```json
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
```
The sender builds the APNs payload locally using the exact same payload shapes
defined above for `private_alert` and `background_wake`.
Configuration:
- `team_id`: Apple developer team ID.
- `key_id`: APNs auth key ID.
- `private_key`: APNs `.p8` private key, stored as a secret.
- `default_environment`: `production` or `sandbox`.
- `allowed_topics`: explicit bundle topics this server is allowed to send for.
Rules:
- The admin may configure credentials, environment, and topics.
- The server must not expose a free-form APNs JSON payload setting.
- The server must not expose custom title/body templates for remote push in v1.
- Topic values must match the device registration topic and the configured
allowlist.
- Sandbox tokens must be sent to the sandbox endpoint and production tokens to
the production endpoint.
- APNs auth tokens should be cached briefly and regenerated before expiry.
- Credential validation should provide a test-send or dry-run diagnostic that
does not include notification content.
## Wake And Metadata Fetch
When the app receives a push:
1. Read `silo.v`, `server_device_id`, and `delivery_id`.
2. Find the local server account that owns `server_device_id`.
3. If background execution is available, call the server immediately.
4. If the app is opened from the notification, call the server before rendering
the target screen.
5. Fetch notification metadata from the user's server.
6. Render the real notification from server data.
Suggested fetch:
```http
GET /api/v1/notifications/sync?since=<last_cursor>
```
or:
```http
GET /api/v1/notifications/{delivery_id}
```
The sync endpoint is preferred because push delivery is not guaranteed and
multiple notification deliveries can be coalesced.
If the app cannot reach the server, it should keep the generic notification and
retry the inbox sync later.
## Fanout Rules
Push fanout should happen after durable inbox delivery commits.
Flow:
1. `notification_deliveries` row is created. **In the same transaction**, the
fanout worker enqueues one `pending` `push_delivery_attempts` row per
enabled `push_device` of the recipient profile (the dispatch outbox — see
[`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
"Transaction and Concurrency Rules").
2. The realtime websocket event is published for connected clients.
3. The push dispatcher claims the `pending` attempt rows post-commit
(`FOR UPDATE SKIP LOCKED`); a recovery sweeper claims rows older than ~60s
whose dispatch never ran.
4. Each claimed attempt sends through the configured provider:
- `silo_relay`: send the minimal relay request
- `custom_apns`: build the same minimal APNs payload locally and send it
directly to APNs
5. APNs errors update device state and attempt status.
Do not block notification delivery creation on APNs or relay availability.
### Relay pacing
The dispatcher paces relay calls client-side with a token bucket (default 5
req/sec, setting `notifications.push.relay_send_rate`), shared across the APNs
and FCM dispatchers since both consume the same relay account quota. On a
server with hundreds of users, a popular release can enqueue several hundred
push attempts at once; pacing drains the queue smoothly instead of slamming
into the relay's rate limit and burning retries on 429s. `TooManyRequests`
from the relay is still honored with backoff — pacing is the steady-state
mechanism, backoff is the safety net. The per-series burst cap in `01` bounds
worst-case queue depth. Custom APNs sends are paced separately and more
generously (Apple's own limits are far higher than any single-server relay
quota).
## APNs Error Handling
The server should handle relay/APNs failures without exposing notification
content.
Recommended handling:
- `BadDeviceToken` / `InvalidToken`: disable the token and require re-registration. (Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`; treat them equivalently.)
- `Unregistered`: disable the token.
- `DeviceTokenNotForTopic`: disable and log a build/topic mismatch.
- `TooManyRequests`: retry with backoff.
- `ExpiredProviderToken`: applies only to `custom_apns`. The cached APNs JWT has expired. Regenerate the JWT immediately and retry the request once before backing off. The relay path never surfaces this code because the relay manages its own JWTs.
- `TooManyProviderTokenUpdates`: applies only to `custom_apns`. Apple has rate-limited JWT regeneration. Stop regenerating; reuse the existing token; retry after backoff. Indicates a bug in the JWT cache (regenerating more often than every 20 minutes).
- relay unavailable, APNs unavailable, or network timeout: retry with backoff.
- malformed request: mark failed and surface admin diagnostics.
Errors should be visible to admins as operational status, not to normal users
unless their device needs reauthorization.
## Privacy Requirements
The implementation must satisfy these requirements before shipping:
- Relay requests contain no notification title or body.
- Relay requests contain no media identifiers.
- Relay requests contain no server hostname or base URL.
- Relay requests contain no profile, username, library, collection, or item names.
- Relay requests *do* carry `apns-topic` (e.g., `com.continuum.app.ios`) and `mode` (`private_alert` / `background_wake`). These are platform/build identifiers that Apple already sees; documenting them here so the privacy claim list is exhaustive rather than over-strong.
- Direct APNs payloads contain no notification title or body beyond generic
localizable keys.
- Direct APNs payloads contain no media identifiers, server hostname, server URL,
profile name, username, library name, collection name, or item name.
- Relay logs redact raw APNs tokens.
- Relay logs redact authorization headers.
- Relay logs do not persist full request payloads.
- Server logs redact APNs tokens and relay API keys.
- Badge count is disabled by default.
- Push registration and remote push provider use are opt-in.
- Device unregister disables further push attempts for that device.
## Security Requirements
- Relay API uses TLS only.
- Relay API keys are scoped to a relay account or install identifier.
- Relay API keys can be revoked without changing the user's Silo server
authentication.
- Relay requests are rate limited by API key and coarse token hash.
- Relay supports idempotency keys to prevent duplicate APNs sends during retry.
- Relay does not accept arbitrary APNs payload JSON in v1.
- Server stores relay API keys as secrets.
- Server stores custom APNs private keys as secrets.
- Server never logs custom APNs private keys, generated provider tokens, or APNs
auth headers.
- Server stores APNs tokens encrypted at rest where local secret storage exists.
- Admin diagnostics must not print raw APNs tokens.
## Settings And UX
Admin settings should explain the tradeoff plainly:
```text
Apple Push Provider
Off
No Apple remote push. Devices still receive in-app realtime updates while open.
Silo Relay
Uses Silo's APNs relay to wake Apple devices. Notification details stay on
this server. The relay receives APNs device tokens, timestamps, and opaque
delivery IDs.
Custom APNs
Advanced. Send directly to Apple APNs using your own Apple developer
credentials. Notification details still stay on this server.
```
Device settings should describe the user-visible mode:
```text
Private Push
Show a generic Silo notification, then fetch details from your server when
this device wakes.
```
Do not claim the central relay is fully self-hosted. The truthful claim is:
```text
Notification content stays on your server. Apple, and the Silo relay if
selected, may still process generic wake messages needed for Apple push
delivery.
```
## Implementation Plan
### Task 1: Add Push Provider Settings
Files likely involved:
- `internal/api/handlers/settings.go`
- `web/src/lib/settingsManifest.ts`
- settings UI files as needed
Add server/admin settings for:
- provider selection
- relay endpoint
- relay API key
- custom APNs team ID
- custom APNs key ID
- custom APNs private key
- custom APNs environment
- custom APNs topic allowlist
- badge sync enabled or disabled
- relay send rate (`notifications.push.relay_send_rate`, shared with FCM pacing)
Default provider must be `off`.
### Task 2: Add Push Device Registration
Files likely involved:
- new migration under `migrations/`
- new package or service under `internal/notifications`
- `internal/api/handlers/notifications.go`
- `internal/api/router.go`
Add profile-scoped Apple push device registration, token rotation, and disable
APIs.
### Task 3: Add Apple Push Provider Clients
Files likely involved:
- `internal/notifications/apple_relay.go`
- `internal/notifications/apple_apns.go`
- config/settings accessors
Implement the narrow `/v1/apple/send` client. The client should not accept a
free-form title/body/content payload.
Implement the direct APNs client for `custom_apns` using the same internal
request shape and payload builder.
### Task 4: Add Push Fanout Worker
Files likely involved:
- `internal/notifications/push_fanout.go`
- notification delivery creation path from the durable inbox implementation
Trigger push fanout after `notification_deliveries` commit. Use retries and
record `push_delivery_attempts`.
### Task 5: Add Apple Client Registration And Wake Handling
Files live in the Apple client repository, not this server repo.
Required client behavior:
- request notification permission
- register APNs token with the user's server
- store `server_device_id` locally
- handle token rotation
- fetch notification metadata after wake/open
- unregister or disable on sign-out/profile removal
### Task 6: Add Admin Diagnostics
Expose high-level status:
- provider enabled/disabled
- number of registered Apple devices
- last relay success
- last relay failure code
- custom APNs credential presence and topic/environment status
- last custom APNs success
- last custom APNs failure code
- token/topic mismatch warnings
Do not expose raw APNs tokens.
## Validation Plan
Use minimal verification while the work is still design-only. When implemented,
verify:
- relay-disabled servers never call the relay
- `custom_apns` servers never call the relay
- device registration is profile-scoped and idempotent
- APNs tokens are redacted in logs
- relay request bodies contain no titles, body text, item IDs, server URLs, or
profile names
- direct APNs payloads contain no titles, body text, item IDs, server URLs, or
profile names beyond the generic localizable notification keys
- custom APNs private keys and generated provider tokens are redacted in logs
- creating one `notification_delivery` sends at most one push per enabled device
- APNs token rotation updates the stored token without duplicating devices
- `Unregistered` and `BadDeviceToken` disable the affected device
- app wake fetches notification metadata from the user's server
- server offline after push leaves only the generic notification visible
## Resolved Questions (was: Open Questions)
These four open questions from the original draft were resolved during the multi-channel refinement:
- **Topics: per-platform.** Official Silo builds use separate APNs topics for iOS, tvOS, and macOS. Each is a different bundle ID with its own provisioning. The `apns_topic` allowlist on a relay account enumerates the per-platform topics the account may push to.
- **Badge counts: disabled in v1.** Badge count leaks unread volume through APNs. v1 ships without badge updates; an explicit per-profile opt-in to badge sync can land in v2 alongside a privacy notice.
- **Relay statefulness: stateless v1.** No stored token aliases. The relay accepts raw APNs tokens on every request. Token aliases add complexity for a benefit (reduced repeat-token exposure to relay logs) that is small compared to "redact tokens from logs." Add aliases later if abuse-pattern analysis or rate-limit pressure justifies it.
- **Relay deployment repo: out of scope for this spec.** The relay implementation lives in a separate repo (provisional name `silo-push-relay`). This document defines the contract the user-facing Silo server implements; the relay's internal architecture (deployment platform, dependency tree, etc.) is the relay repo's concern.
## Remaining Open Questions
- **Multi-bundle topic allowlist UX.** When an admin configures custom APNs, how do they declare allowlisted topics — free-text textarea, or a structured list? Recommendation: structured list with platform tags (iOS / tvOS / macOS) so the UI can show which platforms have working push.
- **APNs JWT cache lifetime.** APNs JWTs are valid for 60 minutes per Apple's docs but may be reused across requests. Should the custom APNs path cache and reuse, or sign per-request? Recommendation: cache with regeneration at 50 minutes to leave headroom, matching Apple's recommendation.
@@ -0,0 +1,748 @@
# Privacy-Preserving FCM Relay Spec
**Date:** 2026-04-28
**Status:** Draft
**Scope:** Silo server push integration for Android, opt-in central FCM relay, custom FCM provider configuration
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
- [`02-apns-relay.md`](./02-apns-relay.md) — sibling spec; structurally identical with FCM-specific differences
## Summary
Silo should support remote Android notifications through either an opt-in central FCM relay or admin-configured Firebase Cloud Messaging credentials, while keeping notification metadata on the user's own Silo server.
The hosted relay should be a constrained transport service. It should receive only the minimum fields needed to submit a Firebase Cloud Messaging v1 API request:
- an FCM registration token
- the target Firebase project ID and Android package name (allowlisted)
- a push mode
- an opaque server/device correlation key
- an opaque delivery identifier
- optional badge count (disabled by default)
Neither provider path may send titles, item names, usernames, profile names, library names, server URLs, artwork URLs, watched-state metadata, or notification body text through FCM.
When the Android device wakes, the app should fetch real notification metadata directly from the user's Silo server using the existing authenticated server connection.
## Why this spec mirrors APNs
The reasoning, threat model, and fanout flow are identical to the APNs case. Apple and Google both gatekeep mobile push at the app-bundle level: only the developer who signed and published the app can authenticate pushes to it. The same arguments for a hosted relay apply unchanged. The differences are entirely about FCM's protocol and credentials:
- **Auth:** FCM v1 API uses **OAuth2 with a Google service account**, not a JWT signed with a `.p8` key.
- **Endpoint:** Pushes go to `https://fcm.googleapis.com/v1/projects/{project_id}/messages:send`.
- **Project model:** A Firebase project owns the Android app's package name + SHA-1 cert fingerprint mapping. The relay holds one service account JSON per official Firebase project.
- **Topics vs tokens:** FCM supports both topic broadcasts and per-token sends. Silo uses **per-token sends only** — topic broadcasts would leak audience information.
- **Message types:** FCM has `notification` and `data` messages. The privacy design uses **data-only messages** so the OS can't render content the relay shouldn't have constructed.
## Decision
Add configurable Android push providers for the official Silo Android app:
- `off`: no remote Android push
- `silo_relay`: Silo's hosted FCM relay
- `custom_fcm`: admin-supplied FCM service account JSON used directly by the server
The relay submits FCM v1 requests using Silo-controlled Firebase credentials for the official app package. A self-hosted server admin may opt in to use this relay. The relay's API should intentionally prevent notification content from being sent through the relay.
If `custom_fcm` is selected, the user's Silo server calls FCM v1 directly using admin-provided service account JSON. This avoids the Silo relay entirely, but it must use the same minimal payload and metadata-fetch rules as the hosted relay path.
The default push mode should be **Private Data Wake**:
- FCM delivers a data-only message.
- The data payload contains only opaque identifiers.
- The Android app handles the message via `FirebaseMessagingService.onMessageReceived` and constructs a generic local notification while it fetches the actual notification row from the user's server.
The implementation should also support **Background Wake** for low-noise sync, with the explicit understanding that Android background data messages are subject to Doze, App Standby, and battery-optimization restrictions and are not a reliable user-visible notification mechanism.
## Goals
- Make Android push possible without every admin managing their own Firebase project.
- Keep actual notification metadata on the user's Silo server.
- Make relay participation explicit and disabled by default.
- Make FCM credentials configurable for admins who want to avoid the hosted relay.
- Keep the relay stateless for device subscriptions where possible.
- Keep FCM payloads opaque and generic (data-only).
- Keep the hosted relay and custom FCM paths behaviorally equivalent from the app's point of view.
- Fit on top of the durable notification inbox design.
## Non-Goals
- Replacing FCM for Android remote push.
- Shipping notification content through the central relay.
- Requiring Apple push services for this Android path.
- Supporting Huawei Mobile Services (HMS) push, Amazon Device Messaging, or other Android-adjacent push systems in v1. Could be added later as parallel providers.
- Adding marketing, analytics, or delivery-tracking exports to the relay.
- Hiding FCM itself from Android devices. Custom FCM still uses Google FCM.
- Making silent background delivery as reliable as visible notifications.
## Threat Model
### What The User's Server Knows
The user's Silo server knows:
- the notification content
- the profile and user recipients
- the registered local devices
- FCM registration tokens for those devices
- push delivery attempts and failures
This is acceptable because the server is already the authority for the user's media, profiles, sessions, and notification inbox.
### What The Central Relay May Know
The relay may see:
- an opt-in relay account or install identifier
- the server's egress IP address (inherent to any hosted relay — the server connects to it directly; see the identical note in [`02-apns-relay.md`](./02-apns-relay.md))
- FCM registration tokens submitted in send requests
- request timestamps
- coarse push mode, such as `private_data` or `background_wake`
- opaque `collapse_key` values (per-server-keyed HMACs of series identity; same derivation rule as APNs `collapse_id`)
- FCM response status and FCM message names
- opaque delivery identifiers
The relay must not receive enough information to know what media item, notification type, profile, user, library, server hostname, or server URL caused the push.
### What Google May Know
Google / FCM may see:
- the official app package name and Firebase project
- the target FCM registration token
- FCM v1 request headers
- request timing
- the generic FCM data payload (which contains only opaque identifiers)
Google must not receive media titles, usernames, library names, server URLs, artwork URLs, or other notification metadata in the payload. In particular, the design uses **data-only messages**, never `notification` messages, so Google's servers don't carry rendering content.
### Residual Metadata Leakage
This design cannot hide that a device received a Silo push at a particular time, nor the egress IP of the server sending relay requests. It can only hide the meaning and content of the push. Additionally, on Android, FCM delivery may be delayed by Doze / App Standby; an adversary observing wake patterns may infer rough activity but not content.
## User And Admin Controls
### Server Admin Control
The server admin must explicitly choose an Android push provider.
Suggested setting:
```text
notifications.android_push.provider = off | silo_relay | custom_fcm
```
Default:
```text
off
```
If `silo_relay` is selected, the admin configures a relay API key and accepts a clear privacy notice:
```text
Silo Relay can wake Android devices through Firebase Cloud Messaging,
but notification details stay on this server. The relay receives FCM
registration tokens, timestamps, opaque delivery IDs, and generic push mode
only.
```
If `custom_fcm` is selected, the admin configures their own Firebase service account JSON:
```text
notifications.android_push.custom.service_account_json
notifications.android_push.custom.project_id
notifications.android_push.custom.allowed_packages = [...]
```
The server then sends directly to FCM and does not call the Silo relay. The same private payload rules still apply.
### Device User Control
Each Android device must opt in independently:
- the OS notification permission must be granted (Android 13+ requires `POST_NOTIFICATIONS` runtime permission)
- the app must be signed into a Silo server
- the active profile must enable push notifications for that device
- the server admin must have enabled an Android push provider
Admins must not be able to silently enroll every profile/device into remote push without the device having granted OS notification permission.
### Profile Control
Push preferences sit on top of the durable notification preferences from the inbox design. The push-mode field on a `push_devices` row is shared across platforms (`apple` and `android`) so the same `push_mode` enum applies.
Suggested profile/device modes:
- `off`: never send remote push to this device
- `in_app_only`: websocket and inbox only
- `private_push`: generic FCM data wake, fetch details from server
- `full_preview`: out of scope for v1
V1 should ship only `off`, `in_app_only`, and `private_push`.
## System Architecture
```mermaid
sequenceDiagram
participant App as Android App
participant Server as User Silo Server
participant Relay as Silo FCM Relay
participant FCM as Google FCM
App->>App: Request POST_NOTIFICATIONS permission (API 33+)
App->>App: Get FCM registration token via FirebaseMessaging.getToken()
App->>Server: Register registration token and opaque server_device_id
Server->>Server: Store encrypted token and device preferences
Server->>Server: Create durable notification_delivery
Server->>Relay: Send minimal FCM relay request
Relay->>FCM: Submit data-only message via FCM v1 API
FCM->>App: Deliver data message; FirebaseMessagingService.onMessageReceived
App->>Server: Fetch notification metadata directly
Server->>App: Return inbox rows for active profile
```
## Ownership Boundaries
### This Repository
Silo owns:
- admin settings for selecting an Android push provider
- profile and device notification preferences
- FCM token registration from Android clients
- durable storage of device registrations
- push fanout from `notification_deliveries`
- relay client integration
- retry, backoff, and FCM error handling
- inbox sync APIs used after wake
### Android Client Repository
The Android app owns:
- FCM registration via Firebase SDK
- notification permission UX (`POST_NOTIFICATIONS` on API 33+)
- device registration API calls
- local server/device correlation
- `FirebaseMessagingService.onMessageReceived` handling
- fetching metadata from the user's server
- rendering local notifications via `NotificationManager` after the wake
### Central Relay Service
The central relay owns:
- FCM service account credentials for the official Silo Firebase project(s)
- relay API authentication
- request validation and rate limiting
- FCM v1 request submission with OAuth2 access token caching
- redacted operational logs
- minimal delivery status reporting
The relay should live outside this repo unless the project later decides to co-locate the service code. This repo defines the contract and implements the self-hosted server side. The relay implementation can share most code with the APNs relay; only the upstream provider client differs.
### Custom FCM Mode
When `custom_fcm` is selected, there is no central relay in the send path.
The user's Silo server owns:
- FCM v1 OAuth2 token generation from the configured service account JSON
- access token caching (Google's OAuth2 access tokens are short-lived; cache and refresh on expiry)
- package allowlisting for the official Silo app package(s) it intends to support
- FCM v1 request construction (data-only)
- FCM response handling
- FCM credential rotation and secret storage
Custom FCM mode must not unlock richer payloads. It exists to let admins avoid the Silo relay, not to bypass the privacy-preserving notification contract.
## Data Model
The `push_devices` and `push_delivery_attempts` schemas are owned by [`02-apns-relay.md`](./02-apns-relay.md) and are platform-tagged from the start (`platform = 'apple' | 'android'`) so this spec adds no new columns. The Android-relevant columns on `push_devices` are:
- `platform text not null` — must be `'android'` for FCM rows.
- `provider text not null` — `'silo_relay'` or `'custom_fcm'`.
- `fcm_token_ciphertext bytea` — required for Android rows.
- `fcm_token_hash text` — required for Android rows; used for dedupe/diagnostics without logging raw tokens.
- `fcm_project_id text` — the Firebase project the token was minted under.
- `fcm_package_name text` — the Android package name (e.g., `com.continuum.app.android`).
- All `apns_*` columns must be null for Android rows. The `CHECK` constraint defined in `02-apns-relay.md` enforces this exclusivity.
Unique constraints (defined in `02-apns-relay.md`): `(profile_id, device_id, platform)` and `(server_device_id)`. Both are intentionally platform-scoped, not provider-scoped — the same Android device cannot register twice even if the admin flips between `silo_relay` and `custom_fcm`.
`push_delivery_attempts` columns relevant to FCM (also owned by 02):
- `provider text not null` — for FCM rows: `'silo_relay'` or `'custom_fcm'`.
- `apns_id text` — null for FCM rows.
- `fcm_message_name text` — populated on success with FCM v1's response `name` (e.g., `"projects/continuum-prod-android/messages/0:..."`); null for APNs rows.
- `attempt_number integer not null` — 1-based; participates in the unique constraint and in the relay `Idempotency-Key`.
Retention is identical to APNs: ~14 days for successes, 30-90 for failures.
## Server API Surface
### Register Android Push Device
```http
POST /api/v1/devices/push/fcm
```
Profile-scoped. Requires normal auth plus active profile context.
Request:
```json
{
"device_id": "android-device-local-id",
"fcm_token": "long-fcm-registration-token",
"fcm_project_id": "continuum-prod-android",
"fcm_package_name": "com.continuum.app.android",
"push_mode": "private_push"
}
```
Response:
```json
{
"id": "01J...",
"server_device_id": "01JOPAQUE...",
"enabled": true,
"push_mode": "private_push"
}
```
Rules:
- `fcm_package_name` must be allowlisted for the configured provider's allowed_packages list.
- `fcm_project_id` must match the configured Firebase project for the active provider.
- registration is idempotent by `(profile_id, device_id, platform)` where platform is implied as `android`.
- token rotation updates the encrypted token and token hash. FCM tokens rotate periodically; the app should call this endpoint whenever `FirebaseMessagingService.onNewToken` fires.
- a disabled server push provider should still allow registration, but should report that remote push is unavailable in the capability response.
### Disable Push Device
Shared with APNs, see [`02-apns-relay.md`](./02-apns-relay.md):
```http
DELETE /api/v1/devices/push/{id}
```
### Push Capability
Extended from the APNs spec to include Android:
```http
GET /api/v1/notifications/capability
```
Response:
```json
{
"in_app": { "enabled": true },
"apple_push": {
"available": true,
"provider": "silo_relay",
"available_providers": ["silo_relay", "custom_apns"],
"supported_modes": ["private_push", "in_app_only"]
},
"android_push": {
"available": true,
"provider": "silo_relay",
"available_providers": ["silo_relay", "custom_fcm"],
"supported_modes": ["private_push", "in_app_only"]
},
"webhooks": {
"available": true,
"max_per_profile": 10,
"supported_types": ["discord", "generic"]
}
}
```
## Relay API Contract
The relay API should be intentionally narrow. The user server should not send an arbitrary FCM payload.
### Send Android Push
```http
POST /v1/fcm/send
Authorization: Bearer <relay_api_key>
Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>
```
Request:
```json
{
"token": "fcm-registration-token",
"project_id": "continuum-prod-android",
"package_name": "com.continuum.app.android",
"mode": "private_data",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_key": "01JOPAQUE_COLLAPSE"
}
```
Response:
```json
{
"request_id": "01JRELAY...",
"fcm_message_name": "projects/continuum-prod-android/messages/0:1234567890123456%abcdef",
"status": "accepted"
}
```
Validation:
- `token` is required and must be plausible for FCM (FCM tokens are typically 152-180+ chars, base64url-ish).
- `project_id` must match the configured Firebase project for this relay account's allowed packages.
- `package_name` must be allowlisted for the relay account.
- `mode` must be `private_data` or `background_wake`. Note: this is the **wire mode** (FCM-side); the profile-level mode is `private_push`. See [`00-architecture-overview.md`](./00-architecture-overview.md) "Mode terminology" for the mapping.
- `server_device_id`, `delivery_id`, and `collapse_key` must be opaque values with length limits.
- `collapse_key` derivation (server-side rule, not relay-enforced): identical to the APNs `collapse_id` rule in [`02-apns-relay.md`](./02-apns-relay.md) — `base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated, using the same per-server secret. Never send raw or plainly-hashed `series_id`.
- `badge` is optional. Default should be omitted to avoid leaking unread counts unless the user/admin explicitly enables badge sync.
- no free-form notification title or body fields are accepted.
- no image URL, media ID, username, server hostname, or server URL field is accepted.
### Relay-Built FCM Payloads
For `private_data`, the relay constructs:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "high",
"collapse_key": "01JOPAQUE_COLLAPSE"
}
}
}
```
For `background_wake`, the relay constructs:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "normal",
"collapse_key": "01JOPAQUE_COLLAPSE"
}
}
}
```
Notes:
- The relay sends **data-only messages** (no top-level `notification` field) so Google never carries rendering content. The Android app constructs the local notification in `onMessageReceived`.
- `priority: high` is used for `private_data` so the message wakes the app even under Doze. High-priority data messages are subject to FCM's anti-abuse quotas — if a server hits the quota, deliveries fall back to normal priority and may be deferred.
- `collapse_key` allows multiple pushes for the same series to coalesce when the device is offline, mirroring `apns-collapse-id`.
- The relay does not allow callers to override this payload shape in v1.
## Custom FCM Contract
`custom_fcm` should share the same internal send model as `silo_relay`, but replace the relay HTTP call with a direct FCM v1 API request.
The server-side push sender should accept only a structured internal request (same shape as the relay request body above):
```json
{
"token": "fcm-registration-token",
"project_id": "continuum-prod-android",
"package_name": "com.continuum.app.android",
"mode": "private_data",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_key": "01JOPAQUE_COLLAPSE"
}
```
The sender builds the FCM v1 payload locally using the exact same payload shapes defined above for `private_data` and `background_wake`.
Configuration:
- `service_account_json`: full Firebase service account JSON, stored as a secret.
- `project_id`: Firebase project ID.
- `allowed_packages`: explicit Android package names this server is allowed to send for.
Rules:
- The admin may configure credentials, project ID, and allowed packages.
- The server must not expose a free-form FCM JSON payload setting.
- The server must not expose custom title/body templates for remote push in v1.
- Package values must match the device registration package and the configured allowlist.
- OAuth2 access tokens should be cached and regenerated before expiry (Google access tokens are typically valid for 1 hour).
- Credential validation should provide a test-send or dry-run diagnostic that does not include notification content. FCM v1 supports `validate_only: true` requests for this purpose.
## Wake And Metadata Fetch
When the app receives an FCM data message:
1. `FirebaseMessagingService.onMessageReceived(remoteMessage)` fires.
2. Read `data.v`, `data.server_device_id`, and `data.delivery_id`.
3. Find the local server account that owns `server_device_id`.
4. If the app is in the foreground, update in-app inbox state directly.
5. If background:
- Construct a generic local notification using `NotificationManager` with a localized "New Silo notification" title.
- Schedule a `WorkManager` job to fetch the actual metadata from the user's server.
- When the fetch returns, update the visible notification with real content (or replace it).
6. If the app is opened from the notification, call the server before rendering the target screen.
Suggested fetch:
```http
GET /api/v1/notifications/sync?since=<last_cursor>
```
or:
```http
GET /api/v1/notifications/{delivery_id}
```
The sync endpoint is preferred because push delivery is not guaranteed and multiple notification deliveries can be coalesced.
If the app cannot reach the server, it should keep the generic notification and retry the inbox sync when connectivity returns.
## Fanout Rules
Push fanout should happen after durable inbox delivery commits. Same flow as APNs:
1. `notification_deliveries` row is created. In the same transaction, the fanout worker enqueues one `pending` `push_delivery_attempts` row per enabled Android `push_device` (the dispatch outbox — see [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)).
2. The realtime websocket event is published for connected clients.
3. The push dispatcher claims the `pending` attempt rows post-commit, partitioned by platform; a recovery sweeper claims rows older than ~60s whose dispatch never ran.
4. Each claimed attempt sends through the configured Android provider:
- `silo_relay`: send the minimal relay request.
- `custom_fcm`: build the same minimal FCM v1 payload locally and send directly.
5. FCM errors update device state and attempt status.
Do not block notification delivery creation on FCM or relay availability.
Relay pacing is shared with APNs: one token bucket (`notifications.push.relay_send_rate`, default 5 req/sec) covers both dispatchers because both consume the same relay account quota. See [`02-apns-relay.md`](./02-apns-relay.md) "Relay pacing".
## FCM Error Handling
The server should handle relay/FCM failures without exposing notification content.
FCM v1 error codes worth handling explicitly:
- `UNREGISTERED` (404): the token is no longer valid (app uninstalled or data cleared). Disable the device and require re-registration.
- `INVALID_ARGUMENT` (400): malformed request. Mark failed and surface admin diagnostics. Should be rare given the constrained payload.
- `SENDER_ID_MISMATCH` (403): the token was minted under a different Firebase project. Disable and log.
- `QUOTA_EXCEEDED` (429): retry with backoff, **honoring the `Retry-After` response header** if present (FCM uses standard HTTP semantics here). Common when high-priority data-message quotas are hit; FCM specifically throttles high-priority data messages that don't surface a user-visible notification on the device. Consider falling back to `priority: normal` after repeated 429s within a cooldown window.
- `UNAVAILABLE` (503): FCM is temporarily down. Retry with backoff.
- `INTERNAL` (500): retry with backoff.
- `THIRD_PARTY_AUTH_ERROR` (401): credential failure (relay or custom_fcm). Surface in admin diagnostics; do not retry until the admin fixes it.
- relay unavailable, FCM unavailable, or network timeout: retry with backoff.
Errors should be visible to admins as operational status, not to normal users unless their device needs reauthorization.
## Privacy Requirements
The implementation must satisfy these requirements before shipping:
- Relay requests contain no notification title or body.
- Relay requests contain no media identifiers.
- Relay requests contain no server hostname or base URL.
- Relay requests contain no profile, username, library, collection, or item names.
- Direct FCM payloads contain no notification title or body. Specifically: **no top-level `notification` object** in the FCM v1 message — data-only messages only.
- Direct FCM payloads contain no media identifiers, server hostname, server URL, profile name, username, library name, collection name, or item name.
- Relay logs redact raw FCM tokens.
- Relay logs redact authorization headers and OAuth2 access tokens.
- Relay logs do not persist full request payloads.
- Server logs redact FCM tokens, custom service account JSON contents, and generated OAuth2 access tokens.
- Badge count is disabled by default.
- Push registration and remote push provider use are opt-in.
- Device unregister disables further push attempts for that device.
## Security Requirements
- Relay API uses TLS only.
- Relay API keys are scoped to a relay account or install identifier.
- Relay API keys can be revoked without changing the user's Silo server authentication.
- Relay requests are rate limited by API key and coarse token hash.
- Relay supports idempotency keys to prevent duplicate FCM sends during retry.
- Relay does not accept arbitrary FCM payload JSON in v1.
- Server stores relay API keys as secrets.
- Server stores custom FCM service account JSON as a secret.
- Server never logs custom FCM service account JSON contents, generated OAuth2 access tokens, or FCM authorization headers.
- Server stores FCM registration tokens encrypted at rest where local secret storage exists.
- Admin diagnostics must not print raw FCM tokens or service account JSON.
- Custom FCM service account JSON must validate as a real Google service account JSON before being accepted (parse + sanity check `type: "service_account"` and required key fields).
## Settings And UX
Admin settings should explain the tradeoff plainly:
```text
Android Push Provider
Off
No Android remote push. Devices still receive in-app realtime updates while open.
Silo Relay
Uses Silo's FCM relay to wake Android devices. Notification details stay on
this server. The relay receives FCM tokens, timestamps, and opaque delivery IDs.
Custom FCM
Advanced. Send directly to Google FCM using your own Firebase service account.
Notification details still stay on this server. Only useful if you've published
your own signed Android app variant under your own Firebase project.
```
Device settings (shared with APNs) should describe the user-visible mode:
```text
Private Push
Show a generic Silo notification, then fetch details from your server when
this device wakes.
```
Do not claim the central relay is fully self-hosted. The truthful claim is:
```text
Notification content stays on your server. Google FCM, and the Silo relay
if selected, may still process generic wake messages needed for Android push
delivery.
```
## Implementation Plan
### Task 1: Add Android Push Provider Settings
Files likely involved:
- `internal/api/handlers/settings.go`
- `web/src/lib/settingsManifest.ts` (or admin-scope equivalent)
- settings UI files as needed
Add server/admin settings for:
- provider selection
- relay endpoint
- relay API key
- custom FCM service account JSON
- custom FCM project ID
- custom FCM allowed packages
- badge sync enabled or disabled (shared with Apple settings)
Default provider must be `off`.
### Task 2: Extend Push Device Registration
Files likely involved:
- migration extending `push_devices` from the APNs spec to add FCM-specific columns and a check constraint
- `internal/notifications` package
- `internal/api/handlers/notifications.go`
- `internal/api/router.go`
Add profile-scoped Android push device registration, token rotation, and disable APIs.
If the APNs spec lands first, this task is purely additive (new columns nullable, new endpoint, expanded `platform` enum).
### Task 3: Add Android Push Provider Clients
Files likely involved:
- `internal/notifications/fcm_relay.go`
- `internal/notifications/fcm_direct.go`
- config/settings accessors
Implement the narrow `/v1/fcm/send` relay client. The client should not accept a free-form notification payload.
Implement the direct FCM v1 client for `custom_fcm` using:
- `golang.org/x/oauth2/google` for service-account-based OAuth2 token generation, or an equivalent that doesn't add a heavy Google SDK dependency.
- Cached access token with refresh-before-expiry.
- Standard `net/http` for the FCM v1 endpoint.
Both clients use the same internal request shape and payload builder so the dispatcher code is uniform.
### Task 4: Extend Push Fanout Worker
Files likely involved:
- `internal/notifications/push_fanout.go` (shared with APNs)
Trigger Android push fanout for devices with `platform = 'android'`. Use retries and record `push_delivery_attempts` with `fcm_message_name` populated on success.
### Task 5: Add Android Client Registration And Wake Handling
Files live in the Android client repository, not this server repo.
Required client behavior:
- request `POST_NOTIFICATIONS` permission on Android 13+
- get FCM registration token via `FirebaseMessaging.getInstance().getToken()`
- register token with the user's server
- handle `FirebaseMessagingService.onNewToken` for token rotation
- store `server_device_id` locally, scoped to the active server account
- handle `onMessageReceived` for both foreground and background data messages
- fetch notification metadata after wake/open
- unregister or disable on sign-out / profile removal / app data clear
### Task 6: Add Admin Diagnostics
Expose high-level status (extending the APNs diagnostics surface):
- provider enabled/disabled
- number of registered Android devices
- last relay success
- last relay failure code
- custom FCM credential presence and project/package status
- last custom FCM success
- last custom FCM failure code
- token/package/project mismatch warnings
Do not expose raw FCM tokens or service account JSON.
## Validation Plan
Use minimal verification while the work is still design-only. When implemented, verify:
- relay-disabled servers never call the relay
- `custom_fcm` servers never call the relay
- device registration is profile-scoped and idempotent
- FCM tokens are redacted in logs
- relay request bodies contain no titles, body text, item IDs, server URLs, or profile names
- direct FCM payloads contain no `notification` object and no titles, body text, item IDs, server URLs, or profile names
- custom FCM service account JSON is redacted in logs
- generated OAuth2 access tokens are redacted in logs
- creating one `notification_delivery` sends at most one push per enabled device
- FCM token rotation updates the stored token without duplicating devices
- `UNREGISTERED` and `SENDER_ID_MISMATCH` disable the affected device
- `QUOTA_EXCEEDED` triggers backoff
- app wake fetches notification metadata from the user's server
- server offline after push leaves only the generic notification visible
## Open Questions
- Should official Silo Android builds use one Firebase project per build channel (debug / staging / prod), or share?
- Should badge counts (Android notification dots / numbers) be allowed in `private_push`, or deferred?
- Should the relay store hashed FCM token aliases to avoid sending raw tokens on every request? Defer to v2 unless rate-limit pressure justifies it.
- Should the server fall back to `priority: normal` after repeated `QUOTA_EXCEEDED` to keep deliveries flowing at the cost of latency? Recommendation: yes, with a cooldown timer.
- Should we also add a Huawei Mobile Services (HMS) push path for non-Google Android devices? Out of scope v1; could mirror this spec as `04-hms-relay.md` later.
@@ -0,0 +1,731 @@
# Outbound Webhooks Spec
**Date:** 2026-04-28
**Status:** Draft
**Scope:** Profile-scoped outbound webhook destinations for Silo notifications. Native Discord embed type and generic JSON+HMAC type.
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
## Summary
Silo should let each profile send their notifications to user-chosen webhook destinations: a Discord channel, a Slack incoming webhook, a personal automation service, or any HTTP endpoint that accepts JSON. Webhooks are configured per profile, support per-webhook reason filters, and include full notification content by default — the profile chose the destination, so trust is implicit.
Two webhook types ship in v1:
- **Discord** — native Discord embed payload. The user pastes a Discord channel webhook URL; Silo POSTs Discord-formatted embeds.
- **Generic** — canonical Silo JSON, HMAC-SHA256 signed via `X-Silo-Signature`. Suitable for Slack-incoming-webhook-style targets, custom user automations, or any HTTP endpoint.
## Why this is different from APNs / FCM
The privacy model inverts. APNs and FCM relay paths route through infrastructure operators (Apple, Google, Silo's relay) that the user did not directly choose. Therefore those paths carry no notification content — only opaque IDs.
Outbound webhooks go to a destination the **profile explicitly configured**. By choosing the URL, the profile is consenting to send notification content to that endpoint. There is no privacy benefit to gating content out of webhooks the profile set up themselves; doing so would just make the feature useless ("New episode! [check Silo]" is no better than what's already in the app).
The trust model is therefore:
- Profile chose the URL → notification content is included by default.
- Discord embeds get series title, episode title, season/episode numbers, and poster.
- Generic webhooks get the same data in canonical JSON, plus an HMAC signature derived from a per-webhook signing secret.
- Silo still enforces privacy guardrails the profile cannot opt out of: HTTPS only, no localhost / RFC1918 destinations by default, redacted logs, retry/disable on persistent failure.
## Decision
Add per-profile webhook destinations, configurable through the profile's notification preferences UI:
- Profile creates a webhook by pasting a URL and optionally a name and reason filters.
- Webhook type is detected from the URL or selected explicitly: `discord` (matches Discord webhook URL pattern) or `generic`.
- Silo POSTs a typed payload to the URL whenever a `notification_deliveries` row commits for that profile and the webhook's reason filters allow it.
- Failed deliveries are retried with exponential backoff and disabled after a configurable consecutive-failure threshold.
- Each webhook has a profile-visible status: enabled, last success, last failure code, consecutive failure count.
## Goals
- Let a profile send Silo notifications to Discord, Slack, or any user-chosen URL.
- Render natively in Discord without the user copy-pasting JSON templates.
- Provide a stable, signed, well-documented generic format for everything else.
- Keep the feature profile-scoped: a profile only sees and controls their own webhooks.
- Add no new infrastructure dependencies (no relay needed; webhooks are direct outbound HTTP from the user's Silo server).
- Make failure modes legible to the profile so they can fix a broken webhook themselves.
## Non-Goals
- Server-wide / admin-level webhooks for system events (scan complete, library health, etc.). Out of scope v1; Silo's existing realtime hub already handles operational events.
- Webhook destinations that require OAuth (Slack apps, Discord bots, Microsoft Teams Adaptive Cards). Webhook-style endpoints with an unauth'd POST URL are sufficient for v1.
- Per-event-type custom payload templates. The Discord embed shape and the generic JSON shape are fixed in v1; richer customization is v2.
- Webhook retry windows beyond 24 hours. After ~24h of consecutive failures, the webhook is auto-disabled and the profile is notified in-app.
- Multiple webhook destinations per webhook row (e.g., one row firing to two URLs). One URL per row.
- Two-way webhooks. Silo only POSTs out; it does not consume webhook responses for state.
## Trust Model
The profile chose the destination URL. By creating a webhook, the profile consents to send full notification content (titles, posters, episode metadata) to that URL.
What we still don't trust the profile about:
- **HTTPS:** Required. `http://` URLs are rejected.
- **Local / private destinations:** Rejected by default. Specifically, the URL host must resolve to an address outside all of the following ranges:
- **IPv4 private/special:** `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10` (CGNAT / RFC6598), `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.0.0.0/24` (IETF protocol assignments), `192.0.2.0/24` / `198.51.100.0/24` / `203.0.113.0/24` (TEST-NET-1/2/3), `192.88.99.0/24` (deprecated 6to4 anycast), `192.168.0.0/16`, `198.18.0.0/15` (benchmarking, RFC 2544), `224.0.0.0/4` (multicast), `240.0.0.0/4` (reserved future use)
- **IPv6 private/special:** `::/128` (unspecified), `::1/128` (loopback), `fc00::/7` (ULA), `fe80::/10` (link-local), `2001:db8::/32` (documentation), `64:ff9b::/96` (NAT64)
- **IPv4-mapped IPv6:** `::ffff:0:0/96` — the most likely real-world bypass. A literal `::ffff:127.0.0.1` resolves to loopback when the connection is established but bypasses naive IPv4-only checks. The validator must unwrap v4-mapped addresses and re-check against the IPv4 deny set.
- **DNS names that resolve to any of the above** at registration time *and* at delivery time (DNS rebinding mitigation; see below).
An admin-only setting `notifications.webhooks.allow_private_destinations = true` may be set for dev environments. It applies globally to the server and is intended only for development.
- **Server URL leakage:** Webhook payloads must **not** include the user's Silo server hostname, base URL, or absolute artwork URLs that include the server origin. Posters in Discord embeds use a profile-token-signed proxy URL (or, for v1, the embed omits images if the server URL would leak). Generic webhooks include only relative paths and let the receiver fetch from the server using their own knowledge of the server URL.
- **Relay leakage:** The webhook delivery path runs entirely on the user's own Silo server; no Silo-operated infrastructure is involved.
## User Flow
```
1. Profile opens Settings -> Notifications -> Webhooks.
2. Profile clicks "Add webhook".
3. Pastes a URL like https://discord.com/api/webhooks/123/abc.
4. Silo auto-detects Discord type from URL pattern.
5. Profile names the webhook ("Family Discord") and toggles
reason filters: favorites on, watchlist on, continue_watching off, next_up off.
6. Profile clicks "Test", Silo POSTs a sample notification.
7. Discord channel shows the test embed; profile clicks "Save".
8. From now on, matching new-episode notifications post to Discord.
9. If the webhook URL becomes invalid (Discord deletes it), Silo:
a. Marks each 404'd delivery failed immediately (non-retryable 4xx);
transient network failures instead retry with backoff for ~24h.
b. After 3 consecutive non-retryable-4xx deliveries (or the retry
threshold for persistent network failures), auto-disables the webhook.
c. Posts an in-app notification to the profile: "Your 'Family Discord'
webhook stopped working. Last error: 404. Edit settings to fix."
```
## Data Model
### `notification_webhooks`
Purpose: profile-scoped webhook destination.
Columns:
- `id text primary key`
- `user_id integer not null` (matches `users.id integer`)
- `profile_id text not null`
- `name varchar(64) not null` — user-friendly label, capped to bound row size
- `type text not null` — `'discord'` or `'generic'`
- `url_ciphertext bytea not null` — destination URL, encrypted at rest
- `url_host varchar(253) not null` — denormalized host (no path) for diagnostics and validation logs (253 = max DNS host length)
- `signing_secret_ciphertext bytea` — null for `discord` (Discord webhooks don't sign); required for `generic`
- `enabled boolean not null default true`
- `notify_favorites boolean not null default true`
- `notify_watchlist boolean not null default true`
- `notify_continue_watching boolean not null default true`
- `notify_next_up boolean not null default true`
- `consecutive_failures integer not null default 0`
- `disabled_reason varchar(256)` — populated when auto-disabled
- `last_success_at timestamptz`
- `last_failure_at timestamptz`
- `last_failure_status integer`
- `last_failure_message varchar(256)` — short, non-sensitive diagnostic, e.g., "404 Not Found"
- `created_at timestamptz not null default now()`
- `updated_at timestamptz not null default now()`
Constraints:
- `(profile_id, name)` is unique per profile to prevent duplicate-name confusion.
- `CHECK (type IN ('discord', 'generic'))`.
- `CHECK (type = 'discord' OR signing_secret_ciphertext IS NOT NULL)` — generic webhooks must have a signing secret.
Indexes:
- `(profile_id)` for the listing endpoint.
- `(profile_id, enabled)` partial index where `enabled = true` for the dispatcher hot path.
Notes:
- URLs and signing secrets are encrypted using the same local secret-storage facility as other sensitive columns (e.g., APNs tokens). If that facility doesn't exist yet, this spec lands the columns as plain `bytea` containing UTF-8 bytes for v1; an explicit follow-up adds the encryption layer.
- `disabled_reason` is set when the system auto-disables; cleared when the profile re-enables.
### `webhook_delivery_attempts`
Purpose: operational record of webhook attempts.
Columns:
- `id text primary key`
- `notification_delivery_id text not null references notification_deliveries(id) on delete cascade`
- `webhook_id text not null references notification_webhooks(id) on delete cascade`
- `attempt_number integer not null`
- `attempted_at timestamptz not null default now()`
- `next_retry_at timestamptz`
- `http_status integer`
- `outcome text not null` — `'pending'`, `'delivered'`, `'retrying'`, `'failed'`, `'auto_disabled'`
- `failure_message varchar(256)` — short diagnostic (e.g., HTTP status text, DNS error class). Must not include payload contents.
Constraints:
- `(webhook_id, notification_delivery_id, attempt_number)` is unique to prevent double-claims under retry concurrency.
- `CHECK (outcome IN ('pending', 'delivered', 'retrying', 'failed', 'auto_disabled'))`.
- index on `(webhook_id, attempted_at desc)` for per-webhook history listing.
- index on `(outcome, next_retry_at)` for the retry worker.
Retention:
- keep `delivered` rows for ~7 days
- keep `failed`/`auto_disabled` rows for ~30 days for profile-visible debugging
- keep `pending`/`retrying` rows until they resolve
## Server API Surface
All endpoints are profile-scoped and require `X-Profile-Id` middleware.
### List Webhooks
```http
GET /api/v1/notifications/webhooks
```
Response:
```json
{
"webhooks": [
{
"id": "01J...",
"name": "Family Discord",
"type": "discord",
"url_host": "discord.com",
"enabled": true,
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": false,
"notify_next_up": false,
"last_success_at": "2026-04-27T18:32:11Z",
"last_failure_at": null,
"last_failure_status": null,
"last_failure_message": null,
"consecutive_failures": 0,
"disabled_reason": null
}
]
}
```
Note the response **never includes the full URL or signing secret**. It returns only `url_host` so the profile can identify which Discord/Slack/etc. the webhook points at without leaking the secret URL token (which functions as the auth credential for Discord webhooks).
### Create Webhook
```http
POST /api/v1/notifications/webhooks
```
Request:
```json
{
"name": "Family Discord",
"url": "https://discord.com/api/webhooks/123456/abcdef-token",
"type": "discord",
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": false,
"notify_next_up": false
}
```
Rules:
- `type` is optional; if omitted, the server detects Discord URLs via pattern (`https://discord.com/api/webhooks/{id}/{token}` or `https://discordapp.com/...`). All other URLs default to `generic`.
- For `generic`, the server generates a random signing secret (32 bytes, base64) and returns it **once** in the response. The profile is responsible for storing it on the receiving service.
- URL must pass private-destination guards.
- URL must use `https`.
- `name` length capped at 64 chars; reject empty or whitespace-only.
- Per-profile cap: max 10 webhooks (configurable via admin setting; documented as `notifications.webhooks.max_per_profile`).
Response:
```json
{
"id": "01J...",
"name": "Family Discord",
"type": "discord",
"url_host": "discord.com",
"enabled": true,
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": false,
"notify_next_up": false,
"signing_secret": null
}
```
For `generic`:
```json
{
"id": "01J...",
"name": "My Slack Webhook",
"type": "generic",
"url_host": "hooks.slack.com",
"enabled": true,
"notify_favorites": true,
"notify_watchlist": true,
"notify_continue_watching": true,
"notify_next_up": true,
"signing_secret": "base64-encoded-secret-shown-once"
}
```
`signing_secret` is **only** returned at create time and is never re-fetchable. Rotation requires a separate endpoint.
### Update Webhook
```http
PUT /api/v1/notifications/webhooks/{id}
```
Request fields are all optional; included fields are updated:
```json
{
"name": "Renamed",
"enabled": true,
"notify_favorites": false,
"url": "https://discord.com/api/webhooks/.../new-token"
}
```
Updating the URL re-validates against the private-destination guard and resets `consecutive_failures` to 0.
### Delete Webhook
```http
DELETE /api/v1/notifications/webhooks/{id}
```
Idempotent. Cascades to `webhook_delivery_attempts`.
### Rotate Signing Secret (generic only)
```http
POST /api/v1/notifications/webhooks/{id}/rotate-secret
```
Generates and returns a new signing secret. The profile must update the receiving service to use the new secret.
### Test Webhook
```http
POST /api/v1/notifications/webhooks/{id}/test
```
Synchronously POSTs a sample payload to the destination and returns the result:
```json
{
"ok": true,
"http_status": 204,
"duration_ms": 187
}
```
or:
```json
{
"ok": false,
"http_status": 404,
"duration_ms": 234,
"message": "404 Not Found"
}
```
The test payload is identical in shape to a real notification but is clearly marked (e.g., embed footer text "Silo test notification" for Discord; `"test": true` field in generic). Test sends do not consume the retry/auto-disable counters.
## Payload Formats
### Discord
The Discord webhook API documents the `POST /api/webhooks/{id}/{token}` endpoint, which accepts a JSON body with optional `content` (plain text) and `embeds` (rich cards). Silo sends embeds only; no `content` field, so the message renders cleanly without a leading text line.
**v1 payload (text-only, no images):**
```json
{
"embeds": [
{
"title": "Severance — S2 E1: Hello, Ms. Cobel",
"description": "New episode available on Silo",
"color": 5814783,
"footer": {
"text": "Silo • Severance"
},
"timestamp": "2026-04-28T12:34:56Z",
"fields": [
{ "name": "Reason", "value": "Favorited & Continue Watching", "inline": true },
{ "name": "Season", "value": "2", "inline": true },
{ "name": "Episode", "value": "1", "inline": true }
]
}
],
"username": "Silo"
}
```
**v1.5 payload (with image proxy, additive):**
In v1.5, the builder adds `image` (poster) and `avatar_url` (Silo mark) fields, both pointing at `media.discord-cdn-proxy.silo.app` URLs. **In v1, these fields MUST be omitted entirely** — the builder must not fall back to absolute URLs that include the user's server origin. The privacy contract is broken if Discord sees the user's server URL in any embed field.
Notes on the Discord payload:
- **v1 builder rule:** the `image` field, the embed's `url` field, and the top-level `avatar_url` field must be omitted. Discord renders the embed without an image; the channel webhook's configured avatar is used. There is no fallback to the user's server URL.
- **v1.5 builder rule:** `image.url` and `avatar_url` use a CDN proxy hosted at `media.discord-cdn-proxy.silo.app`. The proxy is a small Silo-operated service that takes a short-lived signed URL token (issued by the user's server when constructing the webhook payload) and server-side streams the corresponding asset from the user's server. The leak vector being mitigated is *Discord's own infrastructure* fetching and caching the embed image — Discord's CDN (`media.discordapp.net`) fetches once from whatever origin the embed names; end-users only ever see the cached `media.discordapp.net` URL. The proxy ensures the origin Discord fetches from is the proxy, not the user's server.
- **Discord embed limits** (apply to both v1 and v1.5; payload builder must enforce):
- 6,000 char total across `title` + `description` + `field.name` + `field.value` + `footer.text` per embed
- `title`: 256 chars
- `description`: 4,096 chars
- `fields`: 25 max; `field.name` 256 chars, `field.value` 1,024 chars
- `footer.text`: 2,048 chars
- 10 embeds per message (Silo sends one)
- **Truncation policy:** prefer to truncate `description` first (with ellipsis), then field values. Never truncate `title`. If even truncated content exceeds the 6,000-char total, drop fields right-to-left until under cap.
- `color` is a Discord embed accent color encoded as decimal RGB. Silo picks per-reason: favorite=5814783 (Silo brand purple), watchlist=3066993 (green), continue_watching=15844367 (yellow), next_up=15158332 (red).
- `username` overrides the Discord webhook's default name to "Silo".
- The webhook URL itself is the auth — Discord's webhook tokens are bearer credentials in the URL path. This is why we never return the URL on read.
The Discord embed structure follows Discord's webhook API. See [Discord embed object docs](https://discord.com/developers/docs/resources/channel#embed-object) for the full schema.
### Generic
JSON body, signed via HMAC-SHA256 over the raw bytes Silo sends. Receivers verify against the literal bytes they received — no canonicalization required on either side.
Headers:
- `Content-Type: application/json`
- `User-Agent: Silo-Webhook/1.0`
- `X-Silo-Event: notification.created`
- `X-Silo-Webhook-Id: 01J...` — the webhook row ID
- `X-Silo-Delivery-Id: 01J...` — the underlying `notification_deliveries.id`
- `X-Silo-Timestamp: 1714299296` — Unix epoch seconds, integer
- `X-Silo-Signature: t=1714299296,v1=<hex-hmac-sha256>`
- HMAC-SHA256 of the byte string `{X-Silo-Timestamp}.{request body bytes}` using the per-webhook signing secret. The result is hex-encoded.
- Format follows Stripe's signing convention so receivers can use existing libraries (Stripe Go SDK, `stripe-signature` ports, etc.).
- **The timestamp value in the body's `timestamp` field is informational and may be RFC3339 for human readability; only the `X-Silo-Timestamp` header value (Unix epoch integer) participates in the HMAC computation.**
Body:
```json
{
"event": "notification.created",
"delivery_id": "01J...",
"webhook_id": "01J...",
"timestamp": "2026-04-28T12:34:56Z",
"version": 1,
"test": false,
"profile_id": "profile-1",
"library_id": 7,
"type": "episode.available",
"reason_flags": {
"favorite": true,
"watchlist": false,
"continue_watching": true,
"next_up": true
},
"series": {
"id": "series-123",
"title": "Severance"
},
"episode": {
"id": "episode-456",
"title": "Hello, Ms. Cobel",
"season_number": 2,
"episode_number": 1
}
}
```
Notes on the generic payload:
- The body is canonicalized (sorted keys, UTF-8 encoded) before HMAC computation so the receiver can verify deterministically.
- `profile_id` is included so the receiver can route by profile (one Slack channel per profile, etc.). It is the profile's own ID and is acceptable to share with the destination the profile chose.
- No server URL, no absolute poster URLs, no library name. The receiver cannot link this back to the user's server hostname unless the profile has separately told them.
- `version: 1` allows future schema changes without breaking existing receivers.
- `test: true` distinguishes test sends from real sends.
### Signature Verification (generic)
Receivers verify by:
```
parts = X-Silo-Signature.split(",")
ts = parts.find("t=").value // Unix epoch integer (string form)
v1 = parts.find("v1=").value // hex string
raw_body = literal request body bytes (do NOT re-parse and re-serialize JSON)
expected = hex(hmac_sha256(secret, ts + "." + raw_body))
if constant_time_compare(v1, expected) and abs(now_epoch - parse_int(ts)) < 300:
accept
else:
reject
```
Key rules:
- Sign and verify the **literal request body bytes**, not a re-canonicalized form. This eliminates JSON-canonicalization ambiguity (number formatting, escape rules, key ordering) and matches Stripe's pattern.
- `ts` and `now_epoch` are both Unix epoch seconds (integer). The timestamp window of 300 seconds (5 minutes) prevents replay; receivers may tighten if they wish.
- Use a constant-time comparator (`hmac.equal`, `crypto/subtle.ConstantTimeCompare`, etc.) to avoid timing attacks.
### Signing Secret Rotation
`POST /api/v1/notifications/webhooks/{id}/rotate-secret` generates a new signing secret and returns it once. After rotation:
- The new secret takes effect immediately for all subsequent deliveries.
- **Pending and retrying delivery attempts re-sign with the current secret on each retry**, not the secret that was active when the attempt was first enqueued. This means receivers who have updated to the new secret will accept the retried delivery; receivers still on the old secret will reject it. The expected operational pattern is: profile rotates the secret and updates their receiver atomically (or near-atomically); brief retry-window mismatches are acceptable.
- Silo does **not** keep the old secret around after rotation. There is no dual-acceptance window.
## Delivery Semantics
### Send path
1. The fanout transaction in [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) commits the `notification_deliveries` row **and**, in the same transaction, one `webhook_delivery_attempts` row with `outcome = 'pending'` per enabled, reason-matching webhook (the dispatch outbox). Two filters apply at enqueue time:
- **Type deny list.** `webhook.auto_disabled` deliveries never enqueue webhook attempts — a webhook auto-disable notice must never be re-dispatched as a webhook, otherwise a broken webhook would generate an auto-disable notification that fires another webhook attempt that fails again, looping. Other notification types may join this list as they're added.
- **Per-webhook reason filter** (`notify_favorites`, etc.). No matching reason, no attempt row.
2. `WebhookDispatcher.Dispatch(delivery)` runs post-commit and claims the delivery's `pending` attempt rows (`FOR UPDATE SKIP LOCKED`).
3. For each claimed attempt:
- Construct the payload (Discord embed or generic JSON).
- POST the payload with a 10-second total timeout.
- On success (HTTP 2xx): mark attempt `delivered`, update webhook `last_success_at`, reset `consecutive_failures = 0`.
- On failure: mark attempt `retrying` with `next_retry_at`, increment `consecutive_failures` on the webhook.
4. The retry worker reads `webhook_delivery_attempts WHERE (outcome = 'retrying' AND next_retry_at <= now()) OR (outcome = 'pending' AND attempted_at <= now() - interval '60 seconds')` — the second arm is outbox recovery for attempts whose post-commit dispatch never ran (process crash).
5. When `consecutive_failures` exceeds the threshold (default 10) over a span > 24h, the webhook is auto-disabled (`enabled = false`, `disabled_reason = '...'`), and an in-app notification is posted to the profile via the in-app inbox.
### Preference precedence (relationship to `notification_preferences`)
Profile-level `notification_preferences.notify_*` flags are a **hard gate** applied during fanout (see [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)). A delivery row is never created if all matching reasons are disabled at the profile level — so the dispatcher never sees those events.
Per-webhook `notify_*` flags are an **additional filter** applied during dispatch. They can narrow what fires for a specific webhook, but they cannot re-enable a reason the profile has globally disabled. The frontend must reflect this: if a profile turns off `notify_continue_watching` globally, the per-webhook continue-watching checkbox should appear disabled with explanatory text rather than implying the user can re-enable it for one destination.
### Retry schedule
Exponential backoff, capped at 24h total:
| Attempt | Delay since first attempt |
|---|---|
| 1 | 0 (immediate) |
| 2 | 30s |
| 3 | 2m |
| 4 | 10m |
| 5 | 30m |
| 6 | 2h |
| 7 | 6h |
| 8 | 12h |
| 9 | 18h |
| 10 | 24h, then auto-disable |
Each attempt has a 10s total timeout.
**4xx skip-retry:** 4xx HTTP responses (except `408 Request Timeout`, `425 Too Early`, `429 Too Many Requests`) are deterministic destination-side rejections — retrying *this delivery* won't help, so the attempt is marked `failed` immediately without walking the 10-attempt schedule. Auto-disable, however, requires **3 consecutive deliveries** to fail with a non-retryable 4xx (tracked via `consecutive_failures` plus the failure-status class) before the webhook is disabled. A single 4xx is not proof the webhook is dead: destination-side WAF/CDN blips (Discord behind Cloudflare) intermittently return 403/400 for valid webhooks, and instant disable on one such blip would force pointless manual re-enables. Three consecutive deterministic rejections is strong evidence the URL is actually gone. The profile is notified in-app per "Auto-Disable Notification" below.
`429 Too Many Requests` honors the `Retry-After` header if present (overriding the schedule above for that attempt).
### Concurrency
The dispatcher fans webhook deliveries in parallel within a small bounded pool (e.g., 16 workers per server process). One slow webhook destination cannot block other deliveries.
### Test delivery isolation
`POST /webhooks/{id}/test` sends synchronously, does not write to `webhook_delivery_attempts`, does not affect `consecutive_failures`, and returns the HTTP result inline.
## Privacy Requirements
- Webhook URLs are encrypted at rest where the local encryption facility supports it.
- Webhook URLs are never returned in API responses (only `url_host`).
- Generic signing secrets are returned only at create / rotate time; never readable after.
- Logs redact full URLs (host only) and full signing secrets.
- Logs do not persist webhook payload bodies. Failure logs may include HTTP status text.
- Webhook payloads must not include the Silo server URL or absolute artwork URLs that include the server origin.
- An admin must not be able to read another profile's webhook URLs or signing secrets.
## Security Requirements
- HTTPS-only destinations.
- Private-destination guard on URL submission and on each delivery (re-resolve to catch DNS rebinding to private IPs).
- HMAC-SHA256 with per-webhook 32-byte signing secrets for `generic`.
- Signing secret returned only at create / rotate; rotation immediately invalidates the previous secret.
- Per-profile cap on webhook count (default 10).
- Per-profile rate limit on webhook delivery (e.g., 60 deliveries / 60 seconds / profile across all webhooks). Notifications that exceed the limit are still durably stored in the inbox; webhooks just don't fire for them. Logged so admins can spot runaway scenarios.
- TLS verification is enforced and not user-overridable.
- HTTP redirects are followed (max 3 hops) but the final URL must still pass the private-destination guard at each hop.
## DNS Rebinding Mitigation
A naive implementation that resolves the URL once at registration time is vulnerable to DNS rebinding: the host could resolve to a public IP at registration and a private IP at delivery time. Mitigation:
- At delivery time, resolve the host yourself, validate the resolved IPs against the private deny set, then connect using the validated IP (or a controlled `net.Dialer` that re-validates the address it's about to connect to).
- Do not let the standard library transparently re-resolve on each request.
- The `internal/notifications/webhook_http.go` HTTP client should use a custom `Dialer.Control` callback that inspects the resolved address and refuses connections to private ranges.
## Settings And UX
Profile-level settings:
```text
Webhooks
Add webhook
Send Silo notifications to a webhook URL.
Discord URLs render as native Discord embeds.
Other URLs receive signed JSON.
[Family Discord] ✓ enabled
discord.com
✓ Favorites ✓ Watchlist ☐ Continue watching ☐ Next up
Last success: 2 hours ago
[ Test ] [ Edit ] [ Delete ]
[Slack: #media] ✓ enabled
hooks.slack.com
✓ all reasons
Last failure: 3 minutes ago — 404 Not Found
⚠ This webhook is failing. Check the destination URL.
[ Test ] [ Edit ] [ Delete ]
+ Add webhook
```
Admin-level settings (server-wide guards, default values):
- `notifications.webhooks.max_per_profile` (default 10)
- `notifications.webhooks.allow_private_destinations` (default false; for dev environments)
- `notifications.webhooks.deliveries_per_minute_per_profile` (default 60)
## Implementation Plan
### Task 1: Schema
Files:
- new migration under `migrations/` adding `notification_webhooks` and `webhook_delivery_attempts` tables.
### Task 2: Webhook Repository And Service
Files:
- `internal/notifications/webhook_repo.go`
- `internal/notifications/webhook_service.go` (CRUD, validation, signing-secret handling)
Includes:
- URL validation (https, private-destination guard)
- Discord URL detection
- signing secret generation
- per-profile count enforcement
### Task 3: HTTP Client With Address Guard
Files:
- `internal/notifications/webhook_http.go`
Custom HTTP client with:
- 10-second timeout
- TLS verification non-overridable
- `Dialer.Control` callback that re-validates the resolved IP at connect time
- bounded redirect handling
- structured error reporting (DNS error class, TCP, TLS, HTTP status) for `failure_message`
### Task 4: Payload Builders
Files:
- `internal/notifications/webhook_payload_discord.go`
- `internal/notifications/webhook_payload_generic.go`
Each pure function `func Build(delivery NotificationDelivery, hook Webhook) ([]byte, error)`. No side effects, easy to unit test.
### Task 5: Dispatcher Integration
Files:
- `internal/notifications/webhook_dispatcher.go`
Implements the channel `Dispatcher` interface from `00-architecture-overview.md`. Loads enabled webhooks for the delivery's profile, applies reason filters, posts payloads, records attempts.
### Task 6: Retry Worker
Files:
- `internal/notifications/webhook_retry_worker.go`
Polls `webhook_delivery_attempts WHERE outcome = 'retrying' AND next_retry_at <= now()` with `FOR UPDATE SKIP LOCKED`. Re-dispatches and updates state. Handles auto-disable.
### Task 7: API Handlers And Routes
Files:
- `internal/api/handlers/notifications_webhooks.go`
- `internal/api/router.go`
Routes:
- `GET /api/v1/notifications/webhooks`
- `POST /api/v1/notifications/webhooks`
- `PUT /api/v1/notifications/webhooks/{id}`
- `DELETE /api/v1/notifications/webhooks/{id}`
- `POST /api/v1/notifications/webhooks/{id}/test`
- `POST /api/v1/notifications/webhooks/{id}/rotate-secret`
All require `RequireProfile` middleware.
### Task 8: Frontend
Files:
- `web/src/pages/settings/NotificationWebhooksSettings.tsx`
- `web/src/hooks/queries/notification-webhooks.ts`
- form components, status pills, test-result display
Includes:
- "Show signing secret once" UX with explicit "I've saved it" confirmation.
- Show secret-rotation flow.
- Last-failure status with admin-level message ("404 Not Found"), not stack traces.
- Test button.
- Per-reason filter checkboxes.
### Task 9: Auto-Disable Notification
When auto-disable fires, create a `notification_deliveries` row with `type = "webhook.auto_disabled"`, including the webhook name and last failure code. The profile sees this in their inbox so a broken webhook doesn't fail silently.
### Task 10: Optional CDN Proxy For Discord Images
Files:
- separate repo (e.g., `silo-discord-cdn-proxy`)
- `internal/notifications/webhook_image_signer.go` for issuing short-lived signed URL tokens
Out of scope for v1 unless Discord image rendering is wanted in the first release. v1 ships embeds without images; v1.5 adds the proxy and image URLs.
## Validation Plan
- Unit tests for URL validators (HTTPS, private-IP guard, DNS resolution).
- Unit tests for payload builders (Discord shape, generic canonicalization, HMAC determinism).
- Integration test: round-trip a generic webhook against a stubbed receiver that verifies the signature.
- Integration test: Discord embed against `https://discord.com/api/webhooks/.../test-only` (or recorded fixture) — manual verification only since Discord doesn't offer a sandbox.
- Concurrency test: 100 deliveries in parallel; verify retry rows aren't double-counted.
- Auto-disable test: simulate 3 consecutive deliveries returning 404 and verify the webhook is disabled with the correct in-app notification; verify 1-2 isolated 4xx blips do **not** disable it; verify persistent timeouts exhaust the 10-attempt schedule before disabling.
- Outbox recovery test: commit deliveries with `pending` attempt rows, skip the inline dispatcher, and verify the retry worker sends them after the 60s recovery window.
- Manual verification:
- Create a Discord webhook URL.
- Add it to a profile.
- Trigger a release event.
- Confirm the embed renders correctly in Discord.
- Disable the Discord webhook on Discord's side.
- Trigger another event.
- Verify the webhook auto-disables after retries.
- Verify the profile receives an in-app "webhook auto-disabled" notification.
## Open Questions
- **Discord image proxy scope.** Build it in v1, or ship v1 without images and add v1.5? Recommendation: ship without images in v1 — text-only embeds are useful and require no new infra. Add the proxy in v1.5 if user feedback wants images.
- **Slack and Microsoft Teams native types.** Should we add `slack` and `teams` types alongside `discord` and `generic`? Both Slack incoming webhooks and Teams Adaptive Cards have their own JSON shapes. Recommendation: `generic` works for Slack (Slack incoming webhooks accept arbitrary JSON; users can build their own message format with a Workflow). Teams requires Adaptive Card JSON specifically. Defer both to v2 unless concrete user demand emerges.
- **Per-event-type templating.** Some users will want to customize the embed text. Recommendation: defer to v2; v1 fixed shapes ship faster and most users will accept them.
- **Webhook "channels" beyond episode.available.** Should webhook payloads cover other event types (release-aggregated, server announcements)? V1 only fires on `notification.created` for `episode.available`. Other types are forward-compatible via the `event` and `type` fields.
- **Outgoing IP.** Webhook deliveries originate from the user's Silo server IP. Do we need an option to route through a proxy? Recommendation: not v1; document that deliveries come from the server's egress IP.
- **Backpressure.** What happens when a profile's webhook is slow but reachable, blocking delivery for an hour at a time? The 10s timeout caps individual deliveries; bounded worker pool caps fanout concurrency. A single bad webhook shouldn't degrade other webhooks because each delivery runs independently.
@@ -0,0 +1,54 @@
# Web Push Spec
**Date:** 2026-06-11
**Status:** Implemented
**Scope:** Browser push notifications (Push API + VAPID) as a third push platform alongside the deferred APNs/FCM channels.
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
## Why Web Push ships before APNs/FCM
The architecture overview deferred mobile push because Apple and Google require pushes to official store builds to be signed by the publisher's credentials, forcing a Silo-operated relay. Web Push has neither problem:
- **No accounts, no relay.** The server self-provisions a VAPID keypair on first use. Any standards-compliant browser push service (Chrome, Firefox, Edge, Safari 16+) accepts VAPID-signed requests from any origin.
- **Content-safe by protocol.** Payloads are encrypted end-to-end (RFC 8291, `aes128gcm`) to keys held only by the subscribed browser. The vendor push service relays ciphertext. Unlike the APNs/FCM design, payloads can therefore carry full display content (titles, episode numbers, poster URLs) without violating the self-hosted privacy model — there is no opaque-wake/fetch dance.
The residual leak matches the relay threat model: the push service sees the user server's egress IP, delivery timing, and payload size. It never sees content or identity.
## Data model
- `web_push_subscriptions` — profile-scoped browser registrations: `endpoint` (unique; a resubscription from the same browser under a different profile reassigns the row), `p256dh`, `auth`, `device_name`, failure bookkeeping. No FK to profiles (per-user SQLite stores); profile deletion purges in code.
- `web_push_delivery_attempts` — the durable dispatch outbox, mirroring `webhook_delivery_attempts`: `pending` rows enqueued in the fanout transaction, claimed post-commit with a lease, swept by the retry loop after a crash.
## VAPID identity
Generated once and persisted in `server_settings`:
- `notifications.web_push.vapid_public_key` — served to clients via the capability endpoint.
- `notifications.web_push.vapid_private_key` — encrypted at rest (`SensitiveSettingKeys`).
The private key is persisted before the public key so a crash between writes regenerates the pair instead of stranding clients with an unusable public key. The pair must never be rotated casually: browsers bind subscriptions to it.
## API surface (profile-scoped)
- `GET /api/v1/notifications/capability` — `web_push: { available, public_key }`.
- `POST /api/v1/notifications/web-push/subscriptions` — body is `PushSubscription.toJSON()` plus `device_name`.
- `GET /api/v1/notifications/web-push/subscriptions` — for the settings UI device list.
- `DELETE /api/v1/notifications/web-push/subscriptions/{id}`
- `POST /api/v1/notifications/web-push/unsubscribe` — by endpoint (browsers don't know row IDs).
Subscription endpoints are attacker-controllable URLs the server will POST to, so they pass the same HTTPS + private-destination guard as webhooks, both at registration and at connect time (guarded dialer).
## Delivery semantics
- Fanout enqueues one `pending` attempt per enabled subscription of each recipient profile, in the same transaction as the delivery rows. No per-reason filters: profile preferences already gate delivery creation.
- Retry schedule is short (30s/2m/10m/30m, 5 attempts): vendor push services queue messages for offline devices themselves (TTL 12h), so server-side retries only ride out transient push-service errors.
- `404`/`410` from the push service is the protocol's unsubscribe signal: the subscription row is deleted, not retried.
- `notifications.web_push_enabled` is the kill switch (default on).
## Client
- `web/public/sw.js` — displays notifications and routes clicks (episode deep link, or the inbox).
- `web/src/lib/webPush.ts` — permission + subscribe/unsubscribe flows.
- Settings → Notifications → "Browser Notifications" — this-browser toggle plus a revocable list of the profile's other subscribed devices.
@@ -0,0 +1,169 @@
# Notifications v1.5 Roadmap
**Date:** 2026-06-11
**Status:** Draft (work not started)
**Scope:** The remaining notification work between the shipped v1 and the deferred v2 push channels. Each item is independent and sized to land as its own PR.
**Depends On:**
- [`00-architecture-overview.md`](./00-architecture-overview.md)
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — implemented
- [`04-outbound-webhooks.md`](./04-outbound-webhooks.md) — implemented
- [`05-web-push.md`](./05-web-push.md) — implemented
## Where v1 landed (context for this doc)
Implemented 2026-06-11: the full foundation (availability seeding, release events,
interest index, fanout worker with burst caps, websocket channel with ticket
handshake, inbox/sync/preferences/capability APIs, web inbox + badge + settings),
outbound webhooks (Discord + generic HMAC, SSRF guard, durable outbox, retry +
auto-disable), Web Push (VAPID self-provisioned, E2E-encrypted payloads, service
worker), and the shared SMTP core (`internal/mail`, see
`docs/architecture/email.md`) with an admin Email settings page — but no feature
consuming email yet.
**Deferred to v2 by explicit decision:** APNs (`02`) and FCM (`03`) — they
require Silo-operated relay infrastructure and developer accounts. Also v2 per
the original plans: movie availability, aggregated notifications ("3 new
episodes"), quiet hours, cross-profile views.
---
## 1. Admin settings UI for notification controls
**Why:** every `notifications.*` setting works today but is reachable only
through the raw admin settings API. Admins should not need `curl` to find the
kill switches.
**What:** an admin settings page ("Notifications", next to the Email page added
in v1) exposing:
| Group | Keys |
|---|---|
| Kill switches | `notifications.release_events_enabled`, `notifications.fanout_enabled`, `notifications.ui_enabled`, `notifications.webhooks_enabled`, `notifications.web_push_enabled` |
| Fanout tuning | `notifications.fanout.settle_seconds` (default 30), `notifications.fanout.max_series_burst` (default 3) |
| Webhook guards | `notifications.webhooks.max_per_profile` (10), `notifications.webhooks.allow_private_destinations` (false; dev only — label it loudly), `notifications.webhooks.deliveries_per_minute_per_profile` (60) |
| Retention | `notifications.retention.read_days` (90), `notifications.retention.unread_days` (180), `notifications.retention.event_days` (30) |
**Files:** add `web/src/pages/admin-settings/NotificationsAdminSettings.tsx`
(follow `EmailSettings.tsx` / `useSettingsForm`), register in
`web/src/pages/admin-settings/AdminSettingsLayout.tsx`. No backend work — all
keys are live-read.
**Effort:** small (one page, no migrations, no Go changes).
---
## 2. Request-fulfilled notifications (`request.fulfilled`)
**Why:** `00-architecture-overview.md` calls this "the most obvious next
notification type." Users who request media currently learn it arrived by
checking manually; every delivery channel they configured should tell them.
**Design:** the `notification_deliveries.type` registry is extensible by
construction — no schema change.
- New type `request.fulfilled`. `reason_flags` carries the operational shape
(like `webhook.auto_disabled` does), e.g.
`{"request_id": "...", "tmdb_id": 123, "media_type": "movie"}` — never the
four reason booleans.
- **Hook point:** the request reconciliation service (`internal/mediarequests`)
is where a request transitions to available/fulfilled. On that transition,
insert a delivery via `DeliveryRepository.InsertOperational` (the path the
webhook auto-disable notice already uses) and publish through the system's
dispatchers so websocket, web push, and webhooks all fire.
- **Recipient:** the requesting profile (requests are profile-attributed). No
`profile_series_interest` involvement — this is a direct, not fanned-out,
notification.
- **Webhook enqueue:** operational inserts bypass the fanout outbox, so either
(a) extend `InsertOperational` to optionally enqueue per-target attempt rows,
or (b) add a small shared "dispatch one delivery durably" helper used by both
this and the auto-disable notice. Prefer (b); the auto-disable notice
deliberately skips webhooks (loop guard) but request notices should not.
- **Per-reason preferences:** add nothing in v1.5. The profile master toggle
(`notification_preferences.enabled`) gates it; a dedicated
`notify_requests` flag can come later if users ask.
- **Clients:** the web inbox/toast/web-push renderers fall back to a generic
card for unknown types; add a `request.fulfilled` case with the media title,
poster, and a deep link to the item (or the request page until matched).
**Effort:** medium-small. The delivery/dispatch machinery all exists.
---
## 3. Email digest channel
**Why:** first real consumer of `internal/mail`; reaches users who don't keep a
browser open and have no webhook.
**Open design decisions (resolve before building):**
- **Account-level, not profile-level.** Email addresses live on `users`;
profiles have none. A digest therefore aggregates across the account's
profiles (group by profile inside the email body).
- **Digest, not per-episode.** Per-episode email is spam at hundreds-of-users
scale and duplicates the realtime channels. Recommend: opt-in daily digest of
unread deliveries, sent by a taskmanager task (reuse the checkpointed
iteration pattern from the interest backfill), with a per-user
enable + cadence setting.
- **Unsubscribe / preference surface:** account settings, not profile
notification preferences.
**Files (sketch):** `internal/notifications/email_digest.go` (compose from
`DeliveryRepository`, send via `mail.Sender`, branch on
`mail.ErrNotConfigured`), a `taskmanager` task, a small user-settings surface.
**Effort:** medium. Blocked on the design decisions above, not on plumbing.
---
## 4. Native client adoption (no push required)
**Why:** the Android and Apple apps gain a full notification experience today —
APNs/FCM only add closed-app wake-ups later.
Server surfaces ready for clients (`silo-android`, `silo-apple`):
- `GET /api/v1/notifications` + `unread-count` + read endpoints — inbox UI.
- `GET /api/v1/notifications/sync` — opaque forward cursor for
reconnect/foreground catch-up (this is also the wake-fetch endpoint the v2
push specs assume, so client work done now is reused).
- `POST /api/v1/events/ws-ticket` + `ticket` query param on `/api/v1/events/ws`,
`notifications` channel — realtime while the app is open. Snapshot on
subscribe hydrates recent unread.
- `GET /api/v1/notifications/capability` — drive setup UI from this, never from
admin settings.
- `GET/PUT /api/v1/notifications/preferences` — per-profile reason toggles.
**Effort:** client-repo work; the server side is done. Coordinate per the
multi-repo guidance in the repo root `CLAUDE.md`.
---
## 5. Hardening backlog (defer freely)
- **DB-backed integration tests** from the `01` verification plan: idempotent
availability/event inserts, cross-library delivery dedupe, per-series burst
cap, outbox recovery (pending rows with no dispatch → retry worker sends),
multi-node claim safety. The behaviors shipped and were exercised manually on
dev; they are not yet pinned by automated tests because the repo has no
Postgres test harness for this package.
- **Metrics:** `01` names Prometheus-style counters
(`release_events_suppressed_total`, etc.); v1 ships them as structured log
fields. Revisit when the repo grows a metrics registry — keep the names.
- **Discord embed images** via the `media.discord-cdn-proxy` service
(see `04`, "v1.5 payload"). Requires a new Silo-operated repo/service plus
`webhook_image_signer.go`; v1 deliberately ships text-only embeds so the
user's server origin never reaches Discord.
- **Webhook delivery history endpoint:** `webhook_delivery_attempts` already
has the listing index; a `GET /api/v1/notifications/webhooks/{id}/attempts`
endpoint + UI table would make failures self-diagnosable beyond the
last-failure summary.
---
## Suggested order
1. Admin settings UI (#1) — smallest, completes operability.
2. Request-fulfilled (#2) — highest product value per effort.
3. Webhook history endpoint (#5, last bullet) — pairs naturally with #1.
4. Email digest (#3) — after its design decisions are made.
5. Native clients (#4) — parallel track in the client repos.
@@ -0,0 +1,109 @@
# Notifications: Email Channel
**Date:** 2026-06-11
**Status:** Implemented (written post-implementation)
**Scope:** Item 3 of [`06-v1.5-roadmap.md`](./06-v1.5-roadmap.md) — the first real consumer of the shared SMTP core (`internal/mail`, `docs/architecture/email.md`).
**Depends On:** [`00-architecture-overview.md`](./00-architecture-overview.md), [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
## Decisions (resolving the roadmap's open questions)
- **Account-level, as planned.** Email addresses live on `users`; one mode
covers every profile on the account and one email aggregates across them.
- **Per-episode AND digest, not digest-only.** The roadmap recommended
digest-only; product direction chose to offer per-episode alerts too, gated
by an admin allowance (`notifications.email.allow_per_episode`). When the
admin disallows it, accounts set to per-episode are **coerced to the daily
digest** rather than silenced.
- **Interest-scoped by construction.** Email consumes existing
`notification_deliveries` rows, which the fanout only creates for profiles
with series interest (favorites, watchlist, continue-watching, next-up) and
for direct notices (`request.fulfilled`, `webhook.auto_disabled`). The
channel adds no targeting of its own — it is never "all new content".
- **Opt-in, default off.** Users enable it per account in Settings →
Notifications; enabling initializes the watermark to now so history never
floods a fresh opt-in.
## Architecture: watermark sweep, not a third outbox
Webhooks and web push use per-target outbox attempt rows. Email deliberately
does not:
- Deliveries already carry `user_id`, and an account whose profiles follow the
same series gets one row per profile — a per-row outbox would email the same
episode several times. The sweep collapses them (dedupe by `episode_id`, by
`request_id` for requests).
- A per-account watermark over `(created_at, id)` that advances **only after a
successful SMTP send** gives durability for free: a crash or SMTP outage
re-sends on the next pass instead of dropping.
- Both cadences are the same mechanism: per-episode sweeps every minute (and
is nudged by the dispatcher seconds after fanout commits); the digest is the
same sweep gated on "today's send hour passed and not yet stamped today".
State lives in `notification_email_prefs` (`migrations/sql/`,
`email_notification_channel`): mode, watermark, `last_digest_at`, and failure
backoff counters (`last_attempt_at`, `consecutive_failures`; 1m doubling,
capped at 6h). No FK to `users` per the notification-tables rule; deleted or
disabled accounts drop out of the recipient join. A supporting index
`notification_deliveries_user_created_idx (user_id, created_at, id)` serves
the sweep.
**Multi-node safety:** each account is processed inside one transaction that
claims the prefs row `FOR UPDATE SKIP LOCKED`, re-derives eligibility from the
locked row (mode flips and another node's digest stamp are both re-checked),
sends, then commits the watermark/stamp. Failed sends commit only the backoff
counters. `mail.ErrNotConfigured` aborts the whole pass; three consecutive
send failures end it early (SMTP trouble is global, not per-recipient).
**Flood bounds:** one email renders at most 30 lines (`…and N more in your
Silo inbox`), one pass fetches at most 200 rows per account, and upstream the
per-series burst cap already limits fanout volume. Digest emails include only
rows still unread at compose time; the watermark passes read rows silently.
## Files
| Piece | Location |
|---|---|
| Modes, prefs repo (`notification_email_prefs`) | `internal/notifications/email_prefs_repo.go` |
| Worker, dispatcher nudge, System service methods | `internal/notifications/email_digest.go` |
| Subject/text/HTML rendering | `internal/notifications/email_compose.go` |
| Account sweep query | `DeliveryRepository.ListForUserSince` (`internal/notifications/delivery_repo.go`) |
| Settings accessors | `internal/notifications/settings.go` |
| API handlers | `internal/api/handlers/notifications_email.go` (+ capability in `notifications.go`) |
| Web UI (user) | `EmailSection` in `web/src/pages/settings/NotificationsSettings.tsx` |
| Web UI (admin) | Email group in `web/src/pages/admin-settings/NotificationsAdminSettings.tsx` |
| Logic tests | `internal/notifications/email_logic_test.go` |
The worker is wired in `notifications.NewSystem` (new `mail.Sender` parameter,
passed from `cmd/silo/main.go`); its dispatcher joins the `MultiDispatcher`,
so operational deliveries (`request.fulfilled`, `webhook.auto_disabled`) nudge
it exactly like fanout rows do.
## Settings
| Key | Default | Meaning |
|---|---|---|
| `notifications.email_enabled` | `true` | Channel kill switch (availability still requires SMTP configured via `email.*`) |
| `notifications.email.allow_per_episode` | `true` | Admin allowance for the per-episode cadence |
| `notifications.email.digest_hour` | `8` | Hour (0–23, server-local) daily digests go out |
| `notifications.email.external_url` | empty | Public base URL for deep links in emails; empty sends link-free emails (the server origin is never leaked implicitly) |
## API
- `GET /api/v1/notifications/email-preferences` → `{"mode": "off" | "per_episode" | "daily_digest"}`
- `PUT /api/v1/notifications/email-preferences` `{"mode": ...}` — 400 codes:
`bad_request` (unknown mode), `not_allowed` (per-episode disallowed),
`no_email` (account has no address). Any profile on the account may set it.
- `GET /api/v1/notifications/capability` gained
`"email": {"available", "modes", "digest_hour"}`; clients gate setup UI on
it as usual. `available` requires the kill switch on **and**
`mail.Sender.Enabled()` — never read `email.*` settings directly.
## Deliberately not in v1
- Posters/images in emails (would require externally reachable presigned URLs).
- `List-Unsubscribe` headers / tokenized unsubscribe endpoint (self-hosted,
opt-in; revisit if servers grow beyond household scale).
- Per-user digest hour (admin-global for now).
- DB-backed integration tests for the sweep (same Postgres-harness gap as the
rest of `01`'s verification backlog; pure logic is covered by
`email_logic_test.go`).
@@ -0,0 +1,88 @@
# Silo Notifications — Design Index
> **Provenance:** Imported 2026-06-11 from `ContinuumApp/continuum` (`docs/superpowers/plans/notifications/` at `04aa9266`), written 2026-04-28 before the project was renamed to Silo. Reviewed and amended 2026-06-11: product naming and all wire contracts (payload keys, headers, settings enums, localization keys, provisional domains/repo names) are normalized to Silo. Bundle topics, Android package names, and Firebase project IDs intentionally keep their current official client-build values (`com.continuum.app.*`, `continuum-prod-android`) — they are allowlist *config*, not part of this design's contract, and the shipped client builds still use them. Relay/proxy hostnames (`relay.silo.app`, `media.discord-cdn-proxy.silo.app`) are provisional. See "2026-06-11 review amendments" below for the design-level changes made during review.
This folder collects the design work for Silo's notification system, covering durable in-app inbox + websocket realtime, Apple Push (APNs), Android Push (FCM), and outbound webhooks (Discord-native + generic).
A self-contained visual summary of the design decisions (who/what/where/why per decision, pipeline, trust model) lives at [`design-decisions.html`](./design-decisions.html) — open it in any browser; no build or network access required.
## Reading order
1. **[`00-architecture-overview.md`](./00-architecture-overview.md)** — Cross-cutting overview. Read this first. Explains the channel model, fanout pipeline, preference shape, threat model, and addressing model (why a relay is required for mobile push). Links into the per-channel specs.
2. **[`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)** — Foundation. Durable `release_events` -> per-profile `notification_deliveries` fanout, the in-app inbox API, the websocket channel, and per-profile preferences. Vendor-agnostic; nothing else works without this.
3. **[`02-apns-relay.md`](./02-apns-relay.md)** — Apple push. Privacy-preserving design with three provider modes (`off` / `silo_relay` / `custom_apns`).
4. **[`03-fcm-relay.md`](./03-fcm-relay.md)** — Android push. Mirrors the APNs spec with `off` / `silo_relay` / `custom_fcm` modes using FCM v1 (OAuth2 service-account JSON).
5. **[`04-outbound-webhooks.md`](./04-outbound-webhooks.md)** — Profile-scoped outbound webhooks. Native Discord embed type and generic JSON+HMAC type. Content included by default because the profile chose the destination.
## Status
| Doc | Status |
|---|---|
| 00-architecture-overview | Draft (amended 2026-06-11) |
| 01-release-events-and-inbox | Draft (refined, amended 2026-06-11) |
| 02-apns-relay | Draft (refined, amended 2026-06-11) |
| 03-fcm-relay | Draft (amended 2026-06-11) |
| 04-outbound-webhooks | Draft (amended 2026-06-11) |
Nothing in this folder has been implemented. The codebase has zero notification-related code beyond the existing realtime events hub (`internal/events/`, with the operational publisher wrapper at `internal/notifications/hub.go`), which publishes catalog/jobs events and is unrelated to the user-facing notification system designed here.
## 2026-06-11 review amendments
A scaling and privacy review (self-hosted servers with hundreds of users) resolved four design-level gaps. The detailed designs live in the per-doc sections; this is the index:
1. **Back-catalog seeding + burst suppression** (`01`). First scan of a new library and the feature-enable backfill seed `episode_availability` *without* creating release events; bulk additions to existing libraries are bounded by a per-series fanout burst cap. Without this, importing a 200-episode back-catalog of a popular series would generate tens of thousands of deliveries and pushes in one scan.
2. **Durable dispatch outbox** (`01`, referenced by `02`/`03`/`04`). The fanout transaction that inserts `notification_deliveries` also inserts `pending` per-target attempt rows for the push and webhook channels. A crash between delivery commit and dispatch no longer silently loses pushes/webhooks; recovery workers drain stale pending rows.
3. **Cross-library episode dedupe** (`01`). Media items in Silo are catalog-level (`media_item_libraries` junction), so the same episode landing in "TV" and "TV 4K" shares one `episode_id`. A partial unique index on `(profile_id, episode_id)` guarantees at most one `episode.available` delivery per profile per episode across libraries.
4. **Mobile wake-fetch endpoints defined in the foundation** (`01`). `GET /api/v1/notifications/sync` (forward cursor) and `GET /api/v1/notifications/{id}` — previously referenced by `02`/`03` but defined nowhere.
Smaller amendments: relay threat models now list the server's egress IP as residual leakage (`00`/`02`/`03`); `collapse_id`/`collapse_key` derivation is specified as per-server-keyed HMAC so the relay can't read series identity out of it (`02`/`03`); the websocket handshake uses a short-lived single-use ticket instead of a long-lived profile token in the query string (`00`/`01`); client-side relay pacing (`02`/`03`); webhook auto-disable requires 3 consecutive non-retryable 4xx instead of 1 (`04`); SSRF deny list gains `198.18.0.0/15` and `192.88.99.0/24` (`04`); interest recompute fires on watch-state transitions, not every progress tick (`01`); profile-deletion cascades and `release_events.dedupe_key` composition are specified (`01`); migration tasks now follow the repo's timestamped Goose convention (`01`).
## Scope summary
**In scope (v1):**
- Episode-availability notifications (a new episode lands in a library and is relevant to a profile via favorites / watchlist / continue-watching / next-up).
- Durable per-profile inbox with read/unread state.
- Realtime websocket delivery for connected clients.
- Apple Push via opt-in Silo-operated relay or admin-supplied custom APNs credentials.
- Android Push via opt-in Silo-operated relay or admin-supplied custom FCM credentials.
- Outbound webhooks per profile, with native Discord embed rendering and generic JSON+HMAC.
**Out of scope (v1):**
- Movie release notifications (the trigger surface — favoriting a movie that doesn't yet exist — isn't a flow Silo supports).
- Email, SMS, or other delivery channels.
- Browser Web Push (PWA) notifications.
- Cross-profile administrator dashboards.
- Marketing or promotional notifications.
- Third-party ingest events (Plex/Sonarr/Radarr) as notification sources.
## Cross-cutting decisions
These hold across all channels and were resolved during spec review:
- **Episode-only triggers in v1.** Confirmed: there's no UX path for a user to favorite/watchlist a movie before it exists in their library.
- **Per-profile webhooks include content by default.** The profile chose the destination; trust is implicit. Discord-native embeds show series and episode details; generic webhooks include the same.
- **Mobile push payloads stay opaque by default.** APNs and FCM relay paths never see notification content. The app fetches metadata from the user's own server after wake. No badge counts in v1 (deferred to user-opt-in in v2).
- **Preference shape is flat:** per-profile reason flags (favorites / watchlist / continue-watching / next-up) plus a master enable toggle on each push device and each webhook. Not a per-channel × per-reason matrix.
- **Per-platform APNs topics.** iOS, tvOS, and macOS each get their own bundle topic. Single-build collapse is a v2 concern.
- **Stateless relay v1.** No stored token aliases. Add only if abuse/rate-limit pressure justifies it.
- **Relay services live in their own repos.** This folder defines the contracts the user-server side implements; the actual relay deployment (APNs / FCM) is a separate operational repo.
- **Back-catalog never floods.** Initial library scans and the feature-enable backfill seed availability silently; per-series burst caps bound fanout for bulk additions (see `01`).
- **One delivery per episode per profile, across libraries.** Dual-quality library setups (e.g., "TV" + "TV 4K") do not double-notify.
- **Dispatch enqueue is durable (outbox).** Push and webhook sends survive a crash between delivery commit and dispatch; the websocket channel stays best-effort because the inbox snapshot covers reconnect.
## Phasing
Implementation should land in this order, gated by feature flags so partial deploys are safe:
1. Schema (Phase 1 of `01-release-events-and-inbox.md`).
2. `profile_series_interest` updaters and backfill task (no fanout yet).
3. Availability seeding backfill, then release event creation (still no fanout). Seeding **must** complete before release events are enabled, or the first scan after enablement emits an event per back-catalog episode.
4. In-app fanout worker, websocket channel, inbox APIs.
5. Frontend inbox + badge + preference UI.
6. Outbound webhooks (no new infra; can ship before mobile push).
7. APNs relay + custom APNs.
8. FCM relay + custom FCM.
The relay services (steps 7-8) require Silo to provision Apple Developer + Firebase developer accounts and host the relay services. Steps 1-6 require no new external infrastructure.
@@ -0,0 +1,281 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Silo notifications — design decisions</title>
<style>
:root {
color-scheme: light dark;
--background: #f4f4f6;
--card: #ffffff;
--border: #d8d8de;
--fg: #1a1a1e;
--fg-2: #3a3a42;
--muted: #63636b;
--chip: #e8e8ec;
--green: #22c55e;
--purple: #a855f7;
--amber: #f59e0b;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #141417;
--card: #1c1c20;
--border: #28282e;
--fg: #e8e8ec;
--fg-2: #d0d0d6;
--muted: #9696a0;
--chip: #232328;
--green: #81c995;
--purple: #c78dbd;
--amber: #e8a87c;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--background);
color: var(--fg);
font-family: "Outfit", ui-sans-serif, system-ui, "Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.6;
}
main { max-width: 880px; margin: 0 auto; padding: 52px 28px 48px; }
h1 { font-size: 24px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
.sub { color: var(--muted); font-size: 14px; margin: 0 0 8px; }
.sub code { font-size: 12.5px; }
h2 {
font-size: 17px; font-weight: 600; margin: 44px 0 14px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
p { color: var(--fg-2); margin: 0 0 12px; max-width: 72ch; }
code {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px; background: var(--chip); border-radius: 4px; padding: 1px 5px;
}
.legend { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 10px 0 0; font-size: 13px; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 7px; }
.sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
table { width: 100%; border-collapse: collapse; margin: 4px 0 0; }
th { text-align: left; font-size: 13px; font-weight: 600; color: var(--fg); padding: 8px 14px 8px 0; border-bottom: 1px solid var(--border); }
td { font-size: 13.5px; color: var(--fg-2); padding: 9px 14px 9px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
td:first-child, th:first-child { white-space: nowrap; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; }
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 16px 18px;
}
.card h3 { font-size: 14.5px; font-weight: 600; margin: 0 0 10px; }
.kv { display: grid; grid-template-columns: 54px 1fr; row-gap: 7px; column-gap: 10px; }
.kv dt { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0; }
.kv dd { font-size: 13.5px; color: var(--fg-2); margin: 0; line-height: 1.55; }
.svgwrap { border: 1px solid var(--border); border-radius: 12px; background: var(--card); padding: 18px 14px 10px; }
svg text { font-family: "Outfit", ui-sans-serif, system-ui, sans-serif; }
.bt { font-size: 13px; font-weight: 600; fill: var(--fg); }
.bm { font-size: 12.5px; font-weight: 600; fill: var(--fg); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
.bs { font-size: 11.5px; fill: var(--muted); }
.bx { fill: var(--card); stroke: var(--border); }
ul.docs { padding-left: 20px; margin: 0; color: var(--fg-2); }
ul.docs li { margin: 4px 0; }
footer { margin-top: 40px; font-size: 13px; color: var(--muted); }
</style>
</head>
<body>
<main>
<h1>Silo notifications — design decisions</h1>
<p class="sub">Episode-availability notifications for a self-hosted, privacy-focused media server, designed to hold up with hundreds of users on one install.</p>
<p class="sub">Companion to the specs in <code>docs/superpowers/plans/notifications/</code> · drafts amended 2026-06-11</p>
<h2>How a notification flows</h2>
<p>Ingest records availability, a worker fans out to interested profiles, and a durable inbox row is written before any channel fires. Every channel below the row is a transport, never a source of truth.</p>
<div class="svgwrap">
<svg width="100%" viewBox="0 0 760 452" role="img" aria-label="Pipeline from library scan to release events to the fanout worker, into a durable notification_deliveries row, which dispatches to websocket, Apple push, Android push, and webhook channels after commit. The push channels continue through the opt-in Silo push relay, which sees only a device token and opaque IDs, then through APNs or FCM, and the woken device fetches notification details from the user's own server. A dashed path shows custom credentials skipping the relay.">
<defs>
<marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M2 1L8 5L2 9" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</marker>
</defs>
<rect class="bx" x="20" y="24" width="190" height="52" rx="8"/>
<text class="bt" x="115" y="45" text-anchor="middle">Scan and ingest</text>
<text class="bs" x="115" y="63" text-anchor="middle">new episode lands</text>
<rect class="bx" x="280" y="24" width="190" height="52" rx="8"/>
<text class="bm" x="375" y="45" text-anchor="middle">release_events</text>
<text class="bs" x="375" y="63" text-anchor="middle">one row per episode</text>
<rect class="bx" x="540" y="24" width="190" height="52" rx="8"/>
<text class="bt" x="635" y="45" text-anchor="middle">Fanout worker</text>
<text class="bs" x="635" y="63" text-anchor="middle">burst caps + eligibility</text>
<line x1="214" y1="50" x2="274" y2="50" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="474" y1="50" x2="534" y2="50" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<path d="M635 76 L635 98 L380 98 L380 114" fill="none" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="250" y="120" width="260" height="52" rx="8"/>
<text class="bm" x="380" y="141" text-anchor="middle">notification_deliveries</text>
<text class="bs" x="380" y="159" text-anchor="middle">durable row, dispatched after commit</text>
<line x1="380" y1="174" x2="97" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="380" y1="174" x2="287" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="380" y1="174" x2="473" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="380" y1="174" x2="663" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="10" y="212" width="170" height="52" rx="8" stroke="var(--green)"/>
<text class="bt" x="95" y="233" text-anchor="middle">Websocket</text>
<text class="bs" x="95" y="251" text-anchor="middle">full content</text>
<rect class="bx" x="200" y="212" width="170" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="285" y="233" text-anchor="middle">Apple push</text>
<text class="bs" x="285" y="251" text-anchor="middle">opaque wake</text>
<rect class="bx" x="390" y="212" width="170" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="475" y="233" text-anchor="middle">Android push</text>
<text class="bs" x="475" y="251" text-anchor="middle">opaque wake</text>
<rect class="bx" x="580" y="212" width="170" height="52" rx="8" stroke="var(--amber)"/>
<text class="bt" x="665" y="233" text-anchor="middle">Webhooks</text>
<text class="bs" x="665" y="251" text-anchor="middle">your chosen URL</text>
<line x1="285" y1="268" x2="345" y2="292" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<line x1="475" y1="268" x2="415" y2="292" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="290" y="296" width="180" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="380" y="317" text-anchor="middle">Silo push relay</text>
<text class="bs" x="380" y="335" text-anchor="middle">sees token + opaque IDs</text>
<line x1="380" y1="352" x2="380" y2="380" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<path d="M230 268 L230 410 L284 410" fill="none" stroke="var(--muted)" stroke-width="1" stroke-dasharray="4 4" marker-end="url(#a)"/>
<text class="bs" x="222" y="352" text-anchor="end">custom credentials</text>
<text class="bs" x="222" y="368" text-anchor="end">skip the relay</text>
<rect class="bx" x="290" y="384" width="180" height="52" rx="8" stroke="var(--purple)"/>
<text class="bt" x="380" y="405" text-anchor="middle">APNs / FCM</text>
<text class="bs" x="380" y="423" text-anchor="middle">deliver the generic wake</text>
<line x1="474" y1="410" x2="536" y2="410" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
<rect class="bx" x="540" y="384" width="190" height="52" rx="8" stroke="var(--green)"/>
<text class="bt" x="635" y="405" text-anchor="middle">Device wakes</text>
<text class="bs" x="635" y="423" text-anchor="middle">fetches details from home</text>
</svg>
<div class="legend" style="padding: 8px 6px 8px;">
<span><i class="sw" style="background: var(--green);"></i>content stays in Silo</span>
<span><i class="sw" style="background: var(--purple);"></i>opaque wake only, device fetches from home</span>
<span><i class="sw" style="background: var(--amber);"></i>full content to a destination the profile chose</span>
</div>
</div>
<h2>Trust model</h2>
<table>
<thead><tr><th>Channel</th><th>Who sees content</th><th>Why that's acceptable</th></tr></thead>
<tbody>
<tr><td><i class="sw" style="background: var(--green); margin-right: 8px;"></i>Inbox + websocket</td><td>Only the user's own server and signed-in clients</td><td>Same auth boundary as the rest of Silo</td></tr>
<tr><td><i class="sw" style="background: var(--purple); margin-right: 8px;"></i>Apple / Android push</td><td>Nobody — relay and Apple/Google see a device token, timestamps, and opaque IDs</td><td>The device fetches real content from the user's server after waking</td></tr>
<tr><td><i class="sw" style="background: var(--amber); margin-right: 8px;"></i>Webhooks</td><td>The destination operator (e.g., Discord)</td><td>The profile pasted that URL; consent is explicit</td></tr>
</tbody>
</table>
<h2>Foundation</h2>
<div class="grid">
<div class="card">
<h3>Durable inbox is the source of truth</h3>
<dl class="kv">
<dt>Who</dt><dd>Every profile on the server</dd>
<dt>What</dt><dd>One delivery row per event; every channel is just a transport on top</dd>
<dt>Where</dt><dd>The server's own PostgreSQL</dd>
<dt>Why</dt><dd>Transports fail — the row survives, so reconnect or refresh always recovers</dd>
</dl>
</div>
<div class="card">
<h3>Preferences are flat, not a matrix</h3>
<dl class="kv">
<dt>Who</dt><dd>Profiles tuning what they hear about</dd>
<dt>What</dt><dd>Four reason toggles plus a master switch; per-device and per-webhook filters can only narrow further</dd>
<dt>Where</dt><dd>Hard gate at fanout, extra filters at dispatch</dd>
<dt>Why</dt><dd>A per-channel by per-reason grid is unexplainable in UI and impossible to debug</dd>
</dl>
</div>
</div>
<h2>Privacy boundaries</h2>
<div class="grid">
<div class="card">
<h3>Opaque push through a hosted relay</h3>
<dl class="kv">
<dt>Who</dt><dd>Admins who opt in; official store app builds</dd>
<dt>What</dt><dd>The relay holds the Apple and Google signing credentials and forwards a device token plus opaque IDs only</dd>
<dt>Where</dt><dd>Silo-operated service in its own repo, stateless on the request path</dd>
<dt>Why</dt><dd>Only the publisher's credentials can wake store builds; content never leaves the server — the device fetches details from home after waking</dd>
</dl>
</div>
<div class="card">
<h3>Custom credentials escape hatch</h3>
<dl class="kv">
<dt>Who</dt><dd>Power users and forks shipping their own signed app builds</dd>
<dt>What</dt><dd><code>custom_apns</code> / <code>custom_fcm</code> send straight to Apple or Google with the exact same minimal payload</dd>
<dt>Where</dt><dd>Entirely on the user's server</dd>
<dt>Why</dt><dd>Zero Silo infrastructure in the path, without loosening the privacy contract</dd>
</dl>
</div>
<div class="card">
<h3>Profiles, not accounts, are the unit</h3>
<dl class="kv">
<dt>Who</dt><dd>Households sharing one login across several profiles</dd>
<dt>What</dt><dd>Preferences, devices, webhooks, inbox rows, and websocket bindings are all profile-keyed</dd>
<dt>Where</dt><dd>Every table, API route, and event envelope</dd>
<dt>Why</dt><dd>A kid's profile and a parent's profile must not see each other's activity</dd>
</dl>
</div>
<div class="card">
<h3>Webhooks carry full content, by choice</h3>
<dl class="kv">
<dt>Who</dt><dd>Each profile, up to 10 destinations</dd>
<dt>What</dt><dd>Discord embeds or HMAC-signed JSON with series and episode details</dd>
<dt>Where</dt><dd>Direct HTTPS from the server to the URL the profile pasted</dd>
<dt>Why</dt><dd>Choosing the destination is the consent; HTTPS-only, the SSRF deny list, and no-server-origin rules stay mandatory</dd>
</dl>
</div>
</div>
<h2>Scale and reliability</h2>
<div class="grid">
<div class="card">
<h3>Fanout reads a compact interest index</h3>
<dl class="kv">
<dt>Who</dt><dd>Servers with hundreds of users, roughly 1,000 profiles</dd>
<dt>What</dt><dd><code>profile_series_interest</code> keyed by library and series, updated on favorite, watchlist, and watch transitions</dd>
<dt>Where</dt><dd>Written at interaction time, read once per release event</dd>
<dt>Why</dt><dd>Cost scales with profiles interested in one series, not with a million-episode catalog</dd>
</dl>
</div>
<div class="card">
<h3>Back-catalog imports never flood</h3>
<dl class="kv">
<dt>Who</dt><dd>Anyone adding a new library or a full season pack</dd>
<dt>What</dt><dd>First scans seed availability silently; bulk additions cap at 3 notifications per series per scan</dd>
<dt>Where</dt><dd>Scanner seeding plus the fanout worker burst cap</dd>
<dt>Why</dt><dd>One 200-episode import with 300 interested profiles would otherwise mean about 60,000 pushes in a single scan</dd>
</dl>
</div>
<div class="card">
<h3>Dispatch enqueue is durable (outbox)</h3>
<dl class="kv">
<dt>Who</dt><dd>Operators who care what a crash costs</dd>
<dt>What</dt><dd>Pending push and webhook attempt rows commit in the same transaction as the inbox row</dd>
<dt>Where</dt><dd>The fanout transaction; recovery workers sweep stale rows</dd>
<dt>Why</dt><dd>A crash between commit and dispatch delays sends instead of silently losing them</dd>
</dl>
</div>
<div class="card">
<h3>One delivery per episode, across libraries</h3>
<dl class="kv">
<dt>Who</dt><dd>Dual-quality setups like TV plus TV 4K</dd>
<dt>What</dt><dd>A partial unique index on profile and episode spans libraries; the first event processed wins</dd>
<dt>Where</dt><dd>The <code>notification_deliveries</code> schema</dd>
<dt>Why</dt><dd>Media items are catalog-level in Silo, so the same episode landing twice must not notify twice</dd>
</dl>
</div>
</div>
<h2>Source documents</h2>
<ul class="docs">
<li><code>00-architecture-overview.md</code> — channel model, fanout pipeline, threat model, relay addressing</li>
<li><code>01-release-events-and-inbox.md</code> — durable inbox, fanout worker, seeding and burst suppression, sync API</li>
<li><code>02-apns-relay.md</code> — Apple push: hosted relay and custom APNs</li>
<li><code>03-fcm-relay.md</code> — Android push: hosted relay and custom FCM</li>
<li><code>04-outbound-webhooks.md</code> — Discord and generic JSON+HMAC webhooks</li>
<li><code>README.md</code> — reading order, status, and the 2026-06-11 amendment index</li>
</ul>
<footer>Generated 2026-06-11 from the amended notification specs. Commands and paths assume the repository root is the cwd.</footer>
</main>
</body>
</html>
@@ -0,0 +1,967 @@
# Silo Push Relay — Engineering Spec
**Date:** 2026-06-13
**Status:** Draft (implementation-grade)
**Service:** `silo-push-relay` (provisional repo name; the relay code lives in a **separate repository**)
**Provisional hostname:** `relay.silo.app`
**Stack:** Go 1.25+, PostgreSQL, Redis
**Provenance / source contracts (read these first):**
- [`../00-architecture-overview.md`](../00-architecture-overview.md) — cross-cutting design; "Mobile push: why a relay is necessary", addressing-flow walkthrough, mode terminology, cross-channel threat model.
- [`../02-apns-relay.md`](../02-apns-relay.md) — **the external APNs relay contract** (`POST /v1/apple/send`): request/response/validation, relay-built payloads, headers, error handling. The self-hosted Silo server is already designed against this; the relay is its counterpart and must honor it exactly.
- [`../03-fcm-relay.md`](../03-fcm-relay.md) — **the external FCM relay contract** (`POST /v1/fcm/send`): request/response/validation, relay-built data-only payloads, Android config, error handling.
- [`./02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md) — the 2026-current Apple/Google/Go reference this service is built on. Where the older contract docs (authored 2026-04) glossed an upstream-provider mechanic, **this reference wins for the upstream side**; the **external relay contract from 02/03 is never changed** — reconciliations are called out explicitly in §5.2 (FCM upstream field casing), §6.2 (sandbox host), §6.4 (APNs error mapping), and §7.5.
> **Commands assume the repository root is the cwd.** No local absolute filesystem paths or transient worktree IDs appear in this document; all repo references are repository-relative.
---
## 1. Title, Provenance, Status, Scope
### 1.1 What this service is
`silo-push-relay` is a small, **stateless-on-the-request-path** HTTP service that:
1. Holds the official Silo Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials.
2. Accepts authenticated, **opaque, content-free** push requests from opted-in self-hosted Silo servers on two endpoints: `POST /v1/apple/send` and `POST /v1/fcm/send`.
3. Builds a fixed, generic APNs / FCM payload from those opaque fields and forwards it to APNs / FCM on the caller's behalf.
4. Maps the upstream provider response back to a narrow caller-facing result.
It is the **server counterpart** to the relay-client integration in `02-apns-relay.md` and `03-fcm-relay.md`. Those two documents define the wire contract from the self-hosted Silo server's perspective; this document specifies the service that answers it. **The external request/response contract in 02/03 is authoritative and is reproduced here byte-for-byte; this spec adds the server-internal design (upstream clients, storage, auth, rate limiting, idempotency, ops) that 02/03 deliberately left to "a separate repo".**
### 1.2 What this service is NOT
- It is **not** a Silo media server, and it shares no database, auth system, or deployment with `silo-server`.
- It is **not** a general-purpose push gateway: it accepts only the narrow JSON shape in §5; it never accepts a free-form APNs/FCM payload, title, body, or media identifier.
- It is **not** an addressing service. Apple and Google do the addressing from the device token. The relay never resolves a token to a user, profile, device-to-user mapping, or server.
- It does **not** store notification content, user/profile identity, server URLs, or token→user aliases (v1 is stateless on the request path; see `02-apns-relay.md` "Relay statefulness: stateless v1").
- It is **not** in the `custom_apns` / `custom_fcm` path. When a self-hosted admin configures their own credentials, the Silo server talks to APNs/FCM directly and never touches this relay (see `00-architecture-overview.md` "Custom credentials").
### 1.3 Relationship to the contract docs and the separate repo
| Concern | Owner | Document |
|---|---|---|
| External relay request/response/validation/payloads | `02`/`03` (authoritative) | `../02-apns-relay.md`, `../03-fcm-relay.md` |
| Self-hosted server's relay-client, dispatcher, pacing, retry | `silo-server` | `../02`, `../03`, `../00` |
| Relay service internals (this doc): upstream clients, DB, auth, rate limit, idempotency, deploy | `silo-push-relay` (separate repo) | **this file** |
| 2026-current upstream provider mechanics (JWT, OAuth2, endpoints, error tables) | reference | `./02-apns-fcm-2026-reference.md` |
These design docs are authored **here in `silo-server`** alongside the `02`/`03` contracts purely for co-location. The relay **code** lives elsewhere (`silo-push-relay`).
---
## 2. Goals and Non-Goals
### 2.1 Goals
- **G1 — Faithful contract.** Implement `POST /v1/apple/send` and `POST /v1/fcm/send` exactly as `02`/`03` specify: same request fields, same validation rules, same response shape, same relay-built payloads, same headers.
- **G2 — Content-free by construction.** The request schema admits **only** the opaque fields in §5. Any unexpected/free-form field is rejected with `400`. The relay cannot forward content even if a caller tries.
- **G3 — Credential custody.** Hold the official `.p8` and SA JSON in a secret manager, never on disk in the repo/image, never in env vars where avoidable (§11).
- **G4 — Stateless request path.** No per-request DB writes other than throttled `last_used_at` and redacted op-logs. Idempotency + rate-limit state live in Redis. The relay can be horizontally scaled and a single instance can be killed mid-flight without data loss (the server-side outbox in `../01-release-events-and-inbox.md` guarantees re-delivery).
- **G5 — Correct upstream mechanics (2026).** One cached ES256 JWT per APNs team (regenerated ~50 min, never < 20 min apart); one cached OAuth2 access token per FCM project (~1 h, refresh with margin); HTTP/2 connection reuse with PING health checks; correct reason→error mapping. Per `./02-apns-fcm-2026-reference.md`.
- **G6 — Multi-tenant isolation.** Every API key, rate-limit bucket, idempotency key, and log line is scoped by `account_id`. Per-account allowlists bound which APNs topics / FCM (project, package) an account may push to.
- **G7 — Minimal attack surface for admin ops.** No public admin HTTP API in v1. Account/key/allowlist administration is a CLI (`relayctl`) writing directly to the DB (§5.6).
- **G8 — Operable.** Structured redacted logs, Prometheus metrics, `/healthz` + `/readyz`, graceful shutdown.
### 2.2 Non-goals
- **NG1** — Storing token→user aliases, device subscriptions, or any user/profile identity (deferred; `02` "stateless v1").
- **NG2** — Accepting arbitrary APNs/FCM JSON, custom titles/bodies, badge by default, or topic broadcasts (FCM `topic`/`condition` are never used — per-token only, per `03`).
- **NG3** — Delivery receipts, analytics, marketing exports, or open-tracking.
- **NG4** — APNs broadcast / Live Activity channels (`/4/broadcasts/...`) — out of scope; see `./02-apns-fcm-2026-reference.md` §1.10. v1 uses only `/3/device/<token>`.
- **NG5** — Web Push, HMS, ADM, or any non-APNs/non-FCM transport.
- **NG6** — A public self-service signup / billing surface. Accounts are provisioned by Silo operators via `relayctl`.
- **NG7** — The `custom_apns` / `custom_fcm` direct paths (those run inside `silo-server`, not here).
---
## 3. Trust and Threat Model
### 3.1 What the relay sees (restated from `00`/`02`/`03`)
The relay, even fully compromised, sees only:
- **Which self-hosted installation** is calling — the relay API key (`rk_…`) maps 1:1 to a relay account.
- **The server's egress IP** — inherent to any hosted relay; the server connects directly. For home hosting this identifies the household connection. **This is the closest thing to an identity leak in the design** and is listed so the privacy claim stays exhaustive, not over-strong (`00` addressing-flow note).
- **A device token** — APNs device token (~100 bytes hex) or FCM registration token (~152–180+ chars).
- **Request timing.**
- **Coarse wire mode** — `private_alert`/`background_wake` (APNs) or `private_data`/`background_wake` (FCM).
- **Opaque `collapse_id` / `collapse_key`** — per-server-keyed HMACs (see §3.3).
- **Opaque correlation IDs** — `server_device_id`, `delivery_id`.
- **Upstream response status** — APNs `apns-id` / FCM message name, status code, reason string.
- **`apns-topic` / FCM (`project_id`, `package_name`)** — platform/build identifiers Apple/Google already see (`02` privacy note).
### 3.2 What the relay must NEVER see or store
Per `00`/`02`/`03`: no notification title or body, no media identifiers, no profile/user/username/library/collection/item names, no server hostname or base URL, no artwork URLs, no watched-state metadata, no device→user mapping, and **no token aliases** (stateless v1). The request schema (§5) has no field that could carry any of these, and an unknown field is a hard `400` (§12).
### 3.3 Collapse-ID equivalence classes (opaque to the relay)
The Silo server computes `collapse_id = base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated to 26 chars (APNs) / equivalent for FCM `collapse_key`, where `server_collapse_secret` is a random per-server secret (`02`/`03`). The relay treats this as **opaque** and never derives it. Residual leakage: the relay can group **one server's** pushes into per-series equivalence classes (that is what collapse is *for*), but the per-server key prevents recovering the series identity or correlating the same series across two different servers.
### 3.4 Residual leakage (exhaustive)
| Leak | To whom | Why it's irreducible | Mitigation |
|---|---|---|---|
| Egress IP | Relay operator | Server connects directly over HTTPS | Admin may route relay traffic through their own VPN/proxy egress (`02` residual-leakage note) |
| Push timing | Relay operator + Apple/Google | A push is an observable network event | None — only meaning/content is hidden |
| Device token | Relay operator + Apple/Google | Required to address the device | Tokens never logged raw; truncated/hashed (§11/§12) |
| Collapse equivalence class | Relay operator | Collapse must be a stable per-series key | Per-server HMAC key prevents series recovery & cross-server correlation (§3.3) |
| App topic / Firebase project + package | Relay operator + Apple/Google | Required by the upstream APIs | Platform/build identifiers Apple/Google already hold |
The relay **cannot** learn user identity, notification content, server URL, or library data even when fully compromised (`00` threat-model summary). It **cannot fabricate meaningful pushes** — only generic opaque wake payloads.
### 3.5 Adversaries
| Adversary | Capability | Worst case | Control |
|---|---|---|---|
| Hostile/compromised relay operator | Full DB + request stream | Learns device tokens, timing, egress IPs of opted-in servers; can send generic wakes | No content/identity; per-server collapse keying; opt-in & disabled by default |
| Stolen relay API key | Can send opaque pushes as that account | Generic wakes to tokens the thief already holds; bounded by per-account allowlist + rate limits | Revoke key (`relayctl key revoke`); rotate; per-account quota caps spend (OWASP API4:2023) |
| Cross-tenant attacker | Tries to push to another account's topic/token | Rejected: topic/project/package not on attacker's allowlist | Per-account allowlist enforcement (§8.4); namespaced Redis keys |
| Apple / Google | Platform operator | Sees topic, token, generic payload, timing | No identity/content (data-only FCM; loc-key-only APNs alert) |
---
## 4. Architecture
### 4.1 Request path (stateless)
```
1. TLS terminate (LB or in-process) → HTTP/2 inbound.
2. Auth middleware: parse "Authorization: Bearer rk_<prefix>_<secret>";
SELECT row by indexed prefix; constant-time-compare hash; reject revoked/expired.
3. Rate-limit middleware: per-account Redis token bucket (Lua: refill→check→consume).
Over limit → 429 + Retry-After.
4. Body decode with DisallowUnknownFields (any unexpected field → 400).
5. Validate fields (§5) incl. allowlist check (topic / project+package).
6. Idempotency: Idempotency-Key header → Redis SET NX lock; replay / 409 / 422.
7. Build the fixed relay payload (§5) + headers; forward to upstream APNs/FCM
client (cached JWT / OAuth2 token, reused HTTP/2 conn).
8. Map upstream response → caller-facing JSON (§5).
9. Store the {status, body} under the idempotency key (TTL ~24h);
throttled last_used_at update; emit redacted op-log + metrics.
```
The only synchronous DB touch on the hot path is the **API-key lookup** (indexed, O(1) by prefix) and, at most once per minute per key, a `last_used_at` write. Idempotency and rate-limit state are in Redis. No notification content, no token alias, ever persisted.
### 4.2 Component diagram
```mermaid
flowchart TB
subgraph Server["Self-hosted Silo server (caller)"]
DISP[Push dispatcher + client-side pacing\n5 req/s token bucket]
end
DISP -->|"POST /v1/apple/send Bearer rk_… Idempotency-Key"| LB[(TLS / Load balancer)]
DISP -->|"POST /v1/fcm/send"| LB
subgraph Relay["silo-push-relay (stateless replicas)"]
LB --> API[HTTP server\nnet/http, HTTP/2, timeouts]
API --> AUTH[Auth middleware\nprefix lookup + ConstantTimeCompare]
AUTH --> RL[Rate-limit middleware\nRedis Lua token bucket]
RL --> IDEM[Idempotency\nRedis SET NX / replay / 409 / 422]
IDEM --> VAL[Validate + allowlist\nDisallowUnknownFields]
VAL --> AC[APNs client\ncached ES256 JWT ~50m\nper-team HTTP/2 pool]
VAL --> FC[FCM client\ncached OAuth2 token ~1h\nHTTP/2]
API --> OPS[/healthz /readyz /metrics/]
AUTH -. redacted .-> LOG[slog JSON]
end
AC -->|"POST /3/device/<token>"| APNS[(api.push.apple.com\n/ api.sandbox.push.apple.com)]
FC -->|"POST /v1/projects/{id}/messages:send"| FCM[(fcm.googleapis.com)]
AUTH --> PG[(PostgreSQL\naccounts / api_keys /\nallowlists / op_logs)]
RL --> REDIS[(Redis\nidempotency + rate buckets)]
IDEM --> REDIS
CLI[relayctl CLI\naccount/key/allowlist admin] --> PG
SM[(Secret manager)] -.->|.p8 / SA JSON / pepper| AC
SM -.-> FC
```
### 4.3 Data stores
| Store | Holds | Why | Notes |
|---|---|---|---|
| **PostgreSQL** | `relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs` | Durable, low-write config + redacted audit | `jackc/pgx/v5` + `pgxpool` (`./02-apns-fcm-2026-reference.md` §3.4). Never stores tokens, content, or identity. |
| **Redis** | `idem:{account}:{key}` (idempotency lock/result), `rl:{account}` (token-bucket state) | Fast, TTL'd, shared across replicas | `redis/go-redis/v9`. Atomic Lua for rate-limit; `SET NX` for idempotency. Idle keys TTL out. |
| **Secret manager** | APNs `.p8`, FCM SA JSON, API-key HMAC pepper, DB/Redis creds | Credential custody | KMS/Vault/secret-manager preferred over mounted file over env var (OWASP, `./02-apns-fcm-2026-reference.md` §4.5/§5). |
### 4.4 Credential holding
- **APNs:** one (or a few, scoped) `.p8` ES256 keys + Team ID + Key ID per official team. Prefer team-scoped + topic-specific keys (Feb-2025 hardening) for least privilege (`./02-apns-fcm-2026-reference.md` §1.2). A connection pool is **per APNs team** (a connection cannot serve multiple teams — §1.1 of the reference).
- **FCM:** one service-account JSON per official Firebase project (`continuum-prod-android`). On GCP, prefer keyless Workload Identity / attached SA; off-GCP, the JSON lives in the secret manager (`./02-apns-fcm-2026-reference.md` §2.2/§4.5).
- Credentials are loaded **once at startup** from the secret manager into memory; never written to disk, never logged, never returned by any endpoint. Reload requires a process restart (v1) or SIGHUP-triggered re-read (optional).
---
## 5. Public API Contract
All endpoints are HTTPS-only over HTTP/2. Authentication is `Authorization: Bearer rk_<key>` (§8.2). `Content-Type: application/json` for send endpoints. Bodies are decoded with `DisallowUnknownFields` — **any unrecognized field is a `400`** (§12). Unless stated otherwise, error bodies use the shape in §5.5.
### 5.1 `POST /v1/apple/send`
Authoritative source: `02-apns-relay.md` "Send Apple Push". Reproduced exactly.
**Request**
```http
POST /v1/apple/send HTTP/2
Host: relay.silo.app
Authorization: Bearer rk_live_3Qw9...secret...
Idempotency-Key: 01JDELIVERY...:01JDEVICE...:1
Content-Type: application/json
```
```json
{
"token": "apns-token",
"environment": "production",
"topic": "com.continuum.app.ios",
"mode": "private_alert",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_id": "01JOPAQUE_COLLAPSE"
}
```
**Field table**
| Field | Type | Required | Rule |
|---|---|---|---|
| `token` | string | yes | Required; must be plausible for APNs — non-empty, hex-shaped (`^[0-9a-fA-F]+$`). Apple device tokens are commonly **64 hex chars (32 bytes)** but Apple does not guarantee a fixed length and has lengthened them historically, so the relay enforces a **generous bound (e.g. ≥ 64 and ≤ 256 hex chars)** rather than a tight guess that could reject valid future-length tokens. Reject non-hex / out-of-bound → `400 invalid_token`. |
| `environment` | string | yes | `production` or `sandbox`. Selects upstream host (§6.2). Any other value → `400 invalid_environment`. |
| `topic` | string | yes | Must be on the account's APNs topic allowlist (§8.4). Not allowlisted → `403 topic_not_allowed`. |
| `mode` | string | yes | `private_alert` or `background_wake`. Other → `400 invalid_mode`. |
| `server_device_id` | string | yes | Opaque; length-limited (≤128 chars, ULID-shaped recommended). Over limit → `400 invalid_field`. |
| `delivery_id` | string | yes | Opaque; length-limited (≤128). |
| `badge` | integer \| null | no | Default omitted. When present, must be a **bounded non-negative integer `0 ≤ badge ≤ 9999`** — out of range → `400 invalid_field`. This is the one numeric field copied verbatim into `aps.badge`, so it is bounded to keep it from becoming an unbounded caller-controlled channel visible to Apple. Badge is **disabled by default** to avoid leaking unread counts (`02` privacy); the relay forwards it only if provided. |
| `collapse_id` | string \| null | no | Opaque; **≤ 64 bytes** (Apple's `apns-collapse-id` cap). Over 64 bytes → `400 invalid_collapse_id`. |
No other fields are accepted: **no title, body, image URL, media ID, username, server hostname, or server URL** field exists (`02`). An unknown key → `400 unexpected_field`.
**`Idempotency-Key` header** (required for sends): format `<notification_delivery_id>:<push_device_id>:<attempt_number>` (`02`/`03`). See §10.
**Response — 200**
```json
{
"request_id": "01JRELAY...",
"apns_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "accepted"
}
```
- `request_id` — relay-generated ULID, also echoed as the `X-Request-Id` response header and used in logs.
- `apns_id` — Apple's `apns-id` response header (UUID).
- `status` — `accepted` on a 200 from APNs.
> **Sandbox `apns-unique-id` (additive observability).** On a **200 in the sandbox/DEVELOPMENT environment only**, APNs additionally returns an `apns-unique-id` response header used by the Push Notifications Console Delivery Log (reference §1.6). It is the single most useful field for diagnosing "accepted-but-not-delivered" on sandbox. The relay **captures `apns-unique-id` (sandbox only)** into the op-log and the `relayctl ping-upstream` output, and may echo it in the sandbox 200 body, to aid delivery-log correlation during client QA. It is **never** present or logged for production sends.
**Relay-built APNs payloads** (the relay constructs these; callers cannot override — `02`).
`private_alert`:
```json
{
"aps": {
"alert": {
"title-loc-key": "SILO_NOTIFICATION_TITLE",
"loc-key": "SILO_NOTIFICATION_GENERIC_BODY"
},
"sound": "default"
},
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
When `badge` is present and non-null, the relay adds `"badge": <n>` inside `aps`. Otherwise it is omitted entirely (no `badge` key).
`background_wake`:
```json
{
"aps": { "content-available": 1 },
"silo": {
"v": 1,
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
}
}
```
**Exact APNs request headers the relay sets** (`02` Headers + reconciled with `./02-apns-fcm-2026-reference.md` §1.4):
| Upstream header | `private_alert` | `background_wake` | Source |
|---|---|---|---|
| `:method` | `POST` | `POST` | fixed |
| `:path` | `/3/device/<token>` | `/3/device/<token>` | `token` field |
| `authorization` | `bearer <cached ES256 JWT>` | same | relay JWT (§6.1) |
| `apns-topic` | the allowlisted `topic` | same | `topic` field |
| `apns-push-type` | `alert` | `background` | `02` |
| `apns-priority` | `10` | `5` | `02` (and reference §1.4.1: background **must** be 5; 10 is an error) |
| `apns-collapse-id` | `collapse_id` if present (≤64B) | same | `02` |
| `apns-id` | omitted (Apple auto-generates; echoed back) | same | reference §1.4 |
| `apns-expiration` | **finite default** (e.g. `now + 4 h`) | **short TTL** (e.g. `now + 1 h`) | reference §1.4. **Configurable**; ships a sane non-30-day default — see below. |
> **`apns-expiration` policy (v1 ships a finite default, not the 30-day store-and-retry).** With `apns-expiration` omitted, Apple stores and retries an undelivered push for up to **30 days** (reference §1.4) — a wake fired days later is useless or confusing for content that is fetched live from the user's server on wake (the underlying notification may already be read/deleted server-side). The relay therefore sets a **finite, configurable `apns-expiration`**: a few hours for `private_alert` (default ~4 h) and a **short** TTL for `background_wake` (default ~1 h), so a device offline for days does not get a stale wake. Both defaults are config values (per-deployment); the relay never ships the 30-day default. The FCM path sets the parallel `android.ttl` (§5.2) for parity. (Resolved from former open question §15 #5; values are tunable from product input.)
Payload is **uncompressed JSON**, ≤ 4 KB (reference §1.5). The relay-built payloads are far under 4 KB; the relay still rejects with `400 payload_too_large` defensively if a (future) field pushes it over, and maps an upstream `413` per §6.4.
**Error mapping back to the caller** — see §6.4.
### 5.2 `POST /v1/fcm/send`
Authoritative source: `03-fcm-relay.md` "Send Android Push". Reproduced exactly.
**Request**
```http
POST /v1/fcm/send HTTP/2
Host: relay.silo.app
Authorization: Bearer rk_live_3Qw9...secret...
Idempotency-Key: 01JDELIVERY...:01JDEVICE...:1
Content-Type: application/json
```
```json
{
"token": "fcm-registration-token",
"project_id": "continuum-prod-android",
"package_name": "com.continuum.app.android",
"mode": "private_data",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY...",
"badge": null,
"collapse_key": "01JOPAQUE_COLLAPSE"
}
```
**Field table**
| Field | Type | Required | Rule |
|---|---|---|---|
| `token` | string | yes | Plausible FCM token: non-empty, base64url-ish, **length 100 ≤ len ≤ 4096** (FCM tokens are typically 152–180+ chars — `03`; the upper bound guards against oversized-token abuse without rejecting legitimate longer tokens). Implausible → `400 invalid_token`. |
| `project_id` | string | yes | Must match the configured Firebase project for this account's allowlist (§8.4). Mismatch → `403 project_not_allowed`. |
| `package_name` | string | yes | Must be on the account's FCM package allowlist, paired with `project_id` (§8.4). Not allowlisted → `403 package_not_allowed`. |
| `mode` | string | yes | `private_data` or `background_wake` (the **wire** mode; profile-level is `private_push` — `00`/`03`). Other → `400 invalid_mode`. |
| `server_device_id` | string | yes | Opaque; ≤128 chars. |
| `delivery_id` | string | yes | Opaque; ≤128 chars. |
| `badge` | integer \| null | no | Accepted for caller-contract parity with the APNs endpoint, but **FCM has no data-only home for it** (the relay never builds a `notification` block). v1 **ignores `badge` on the FCM path** — it is neither rendered nor smuggled into the `data` map (§5.2 construction rules, §12). When present it must still pass the same bound as APNs (`0 ≤ badge ≤ 9999`); otherwise → `400 invalid_field`. |
| `collapse_key` | string \| null | no | Opaque; **≤ 64 bytes**. This is the **inbound caller field name** (snake_case, per `03`); the relay maps it to the FCM REST field `android.collapseKey` (camelCase — see §5.2 construction rules). Over 64 bytes → `400 invalid_collapse_key`. |
No free-form `notification`, title, body, image URL, media ID, username, or server URL field exists (`03`). Unknown key → `400 unexpected_field`.
**Response — 200**
```json
{
"request_id": "01JRELAY...",
"fcm_message_name": "projects/continuum-prod-android/messages/0:1234567890123456%abcdef",
"status": "accepted"
}
```
- `fcm_message_name` — FCM v1 response `name`.
**Relay-built FCM payloads — data-only, no `notification` block** (`03`).
`private_data`:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "HIGH",
"collapseKey": "01JOPAQUE_COLLAPSE"
}
}
}
```
`background_wake`:
```json
{
"message": {
"token": "fcm-registration-token",
"data": {
"v": "1",
"wake": "notifications.changed",
"server_device_id": "01JOPAQUE...",
"delivery_id": "01JDELIVERY..."
},
"android": {
"priority": "NORMAL",
"collapseKey": "01JOPAQUE_COLLAPSE"
}
}
}
```
**Construction rules (reconciled with `./02-apns-fcm-2026-reference.md` §2.3/§2.4):**
- `message.data` is `map<string,string>` — **all values are strings** (`"v": "1"`, not `1`). It is a **fixed, closed set of keys** — exactly `{v, wake, server_device_id, delivery_id}` — with **no caller-controlled key or value** beyond the already-opaque IDs (§12). Reserved keys (`from`, `message_type`, `notification`, `google.*`, `gcm.*`) are never used, and `badge` is never injected into `data`.
- No top-level `message.notification` — **data-only** so Google carries no rendering content (`03`).
- `android.priority`: `"HIGH"` for `private_data` (wakes under Doze), `"NORMAL"` for `background_wake`. The relay emits the **canonical uppercase enum** values documented for FCM REST `AndroidConfig.priority` (reference §2.4); FCM also accepts lowercase, but the canonical form is used to avoid relying on a parenthetical allowance. **Reconciliation:** the `03` contract reproduces lowercase `high`/`normal`; the wire field accepts both and `priority` is relay-set (not a caller field), so this is an internal-upstream normalization with no external-contract impact.
- `android.collapseKey` (REST **camelCase** — reference §2.4): set to the caller's `collapse_key` when present, omitted otherwise. **Maps to FCM `android.collapseKey`** — the relay POSTs raw JSON to the FCM HTTP v1 REST endpoint, which rejects unknown fields (including a snake_case `collapse_key` inside `android`) with `INVALID_ARGUMENT`. **Reconciliation (parallel to the sandbox-host one in §6.2):** the inbound caller field stays `collapse_key` per `03`/§5.2; only the relay-built upstream FCM field is `collapseKey`.
- `android.ttl` (a duration string, e.g. `"14400s"`): set to a **finite, configurable** TTL for parity with the APNs `apns-expiration` policy (FCM otherwise defaults to **4 weeks** of storage). Default ~4 h for `private_data`, a shorter TTL for `background_wake`, so a device offline for days does not get a stale wake. Configurable per deployment.
- `validateOnly` (top-level, **camelCase** — reference §2.5) is the dry-run flag for the `relayctl` test path only (§7.3, §7.5); never set on production sends.
- The relay never sets FCM `topic`/`condition`; only `message.token` (per-token only — `03`).
- Payload ≤ 4096 bytes for token sends (reference §2.3); the fixed payload is far under.
> **Reconciliation note (priority vs. reference §2.8).** The reference warns that high-priority data messages that never produce a user-facing notification can be deprioritized to normal by FCM over a ~7-day window. The `03` contract nonetheless specifies `priority: high` for `private_data` (the app *does* surface a notification after wake), and the relay **honors the `03` contract** — it does not second-guess priority. Deprioritization is an upstream behavior, not a contract change; the `03` doc already documents the quota fallback (caller may switch to normal after repeated 429s — §7.4).
**Exact FCM request** the relay sends:
```http
POST /v1/projects/continuum-prod-android/messages:send HTTP/2
Host: fcm.googleapis.com
Authorization: Bearer <cached OAuth2 access token>
Content-Type: application/json
```
**Error mapping** — see §7.4.
### 5.3 `GET /healthz`
Liveness. Returns `200 {"status":"ok"}` if the process is up. No dependency checks. Used by the orchestrator's liveness probe. Unauthenticated.
### 5.4 `GET /readyz` and `GET /metrics`
- **`GET /readyz`** — readiness. Readiness gates on **core shared dependencies only**: Postgres reachable (`SELECT 1` via pool) and Redis reachable (`PING`). Per-provider health (APNs signer initialized; FCM token source initialized) is reported **separately, not as a binary gate**, so that a **single-provider** credential/init failure does **not** pull the whole replica from the LB and kill the healthy provider too (APNs and FCM are independent upstreams; the "channel failures are isolated" principle from `00` carried into the relay's own health model). The body is `200 {"status":"ready","providers":{"apns":"healthy|degraded","fcm":"healthy|degraded"}}` when core deps are up, or `503 {"status":"not_ready","checks":{...}}` only when **PG or Redis** is down. When a provider is `degraded`, the **send handler for that provider** returns `503 upstream_unavailable` for that provider only (the other provider keeps serving); a per-provider gauge and circuit-breaker state make `APNs degraded, FCM healthy` visible to operators rather than a binary not-ready. See §13.2 (`relay_provider_healthy`).
- **`GET /metrics`** — Prometheus exposition (`promhttp`). Bound to the internal network / scrape-only. See §13.2 for the catalog.
### 5.5 Standard error body
All 4xx/5xx (except health endpoints) return:
```json
{
"error": {
"code": "topic_not_allowed",
"message": "topic is not on this account's allowlist",
"request_id": "01JRELAY..."
}
}
```
`code` is a stable machine string (see §6.4 / §7.4 tables). `request_id` matches `X-Request-Id`. `Retry-After` is set on `429`/`503` (§9, §10).
### 5.6 Admin surface — `relayctl` CLI (no public admin HTTP API)
To minimize attack surface (G7), there is **no HTTP admin API** in v1. Administration is a Go CLI, `relayctl`, that connects **directly to the relay's PostgreSQL** (via a privileged DSN held by operators only) and performs the operations below. It runs from an operator workstation or a jump host — never exposed to the public internet.
| Command | Action | DB effect |
|---|---|---|
| `relayctl account create --name <label> [--note <text>]` | Provision a relay account (one per opt-in installation). Prints the new `account_id`. | INSERT `relay_accounts`. |
| `relayctl account list [--active]` | List accounts with status, key count, last activity. | SELECT. |
| `relayctl account disable --account <id>` | Suspend an account (all keys rejected, but rows retained). | UPDATE `relay_accounts.status='disabled'`. |
| `relayctl key issue --account <id> [--env live\|test] [--expires <dur>]` | Generate a new API key. **Prints the full `rk_…` token exactly once** (only the hash is stored). | INSERT `relay_api_keys`. |
| `relayctl key list --account <id>` | List keys (prefix, created, last_used, revoked) — never the secret. | SELECT. |
| `relayctl key revoke --key-prefix <prefix>` | Revoke a key immediately. | UPDATE `relay_api_keys.revoked_at=now()`. |
| `relayctl allowlist apns set --account <id> --topics com.continuum.app.ios,.tvos,.macos` | Replace the account's APNs topic allowlist. | UPSERT `relay_apns_allowlist`. |
| `relayctl allowlist fcm set --account <id> --project continuum-prod-android --packages com.continuum.app.android` | Replace the account's FCM (project, packages) allowlist. | UPSERT `relay_fcm_allowlist`. |
| `relayctl allowlist show --account <id>` | Print effective allowlists. | SELECT. |
`relayctl` operations are themselves audited into `relay_op_logs` (event `admin.*`) with the operator identity (OS user / `--actor`) but never the secret token. The CLI shares the relay's data-access package so schema and constraints stay in one place.
---
## 6. Upstream APNs Client
Built per `./02-apns-fcm-2026-reference.md` §1 and §3.1. Library choice: `github.com/sideshow/apns2 v0.25.0` **or** a hand-rolled `net/http` + `golang.org/x/net/http2` + `golang-jwt/jwt/v5` client (reference §3.1; bus-factor caveat in §15). Either way, the mechanics below are required.
### 6.1 Auth — JWT ES256 lifecycle
- **Algorithm:** ES256 (ECDSA P-256) **only** — APNs accepts no other (reference §1.1).
- **Header:** `{"alg":"ES256","kid":"<10-char Key ID>"}`. **Claims:** `{"iss":"<10-char Team ID>","iat":<epoch seconds>}`.
- **Signing key:** the official `.p8` private key from the secret manager.
- **Caching:** generate **one** JWT, hold it in memory behind a mutex, reuse across all senders/connections/topics for that team (reference §1.1/§4.4).
- **Refresh cadence:** regenerate on a **~50-minute timer** (well inside Apple's 1-hour `iat` window), **never more often than once per 20 minutes**, and eagerly on a `403 ExpiredProviderToken` (then retry the request once). Over-frequent refresh → `429 TooManyProviderTokenUpdates`; back off and slow refresh (reference §1.1 lifecycle table).
- **Single-flight regeneration (stampede guard).** Under a burst of concurrent sends sharing one cached JWT, an expiry or a `403 ExpiredProviderToken` is observed by **many in-flight goroutines at once**. JWT regeneration **must be single-flighted** — e.g. `golang.org/x/sync/singleflight` keyed by team, or a generation counter behind the mutex: a goroutine seeing `ExpiredProviderToken` checks whether the JWT was **already refreshed since its request started** (compare its captured generation/`iat` to the current one) and, if so, **reuses the new token** instead of triggering another refresh. Only **one** refresh happens per ~20-min window regardless of how many concurrent 403s arrive. A naive per-goroutine regenerate-on-403 would trip `429 TooManyProviderTokenUpdates`; the 20-minute floor is honored **by this concurrency mechanism**, not just asserted. (Mirrors risk R3.)
- **Per-team pools.** A connection cannot map to multiple teams; open a separate JWT + HTTP/2 connection pool per APNs team (reference §1.1). v1 has one official team, so one pool — but the code keys pools by team to stay correct.
- **Key selection — `(environment, topic) → .p8 key`.** With Apple's Feb-2025 team-scoped/topic-specific keys (max 2 team-scoped keys per environment), more than one `.p8` may be loaded (e.g. a topic-specific key for `com.continuum.app.ios` plus a team key; or per-environment scoped keys). A **sandbox send must use a sandbox-scoped key and a production send a production-scoped key** — using the wrong one yields `403 BadEnvironmentKeyIdInToken` / `UnrelatedKeyIdInToken`. The relay therefore holds an explicit **key-selection map `(environment, topic) → key id`** in config, falling back to an unscoped team key only where no scoped key exists. **Startup validation:** every allowlisted `(topic, environment)` must resolve to a usable key, or startup fails. A `BadEnvironmentKeyIdInToken`/`UnrelatedKeyIdInToken` at runtime maps to `upstream_auth_error` (§6.4) and is treated as a **key-selection misconfiguration** (ops alert).
### 6.2 Endpoint selection
| `environment` field | Upstream host | Port |
|---|---|---|
| `production` | `https://api.push.apple.com` | 443 (2197 fallback) |
| `sandbox` | `https://api.sandbox.push.apple.com` | 443 (2197 fallback) |
Both hosts are **hardcoded constants** selected only by the `environment` enum — never derived from any other request field (§11 egress/SSRF posture).
> **Reconciliation (host name).** `02-apns-relay.md` validation text names the sandbox host `https://api.development.push.apple.com`. **Both `api.sandbox.push.apple.com` and `api.development.push.apple.com` are valid, functionally-equivalent Apple sandbox aliases — both resolve.** The relay **standardizes on `api.sandbox.push.apple.com`** (reference §1.3; it also matches `sideshow/apns2 v0.25.0`'s `HostDevelopment` constant, which points at `api.sandbox.push.apple.com`). This is an *internal upstream* detail and does **not** change the external contract: callers still send `"environment": "sandbox"` exactly as `02` specifies. (See reference open question §5 item 4 / this spec §15 — now a note that the two hosts are interchangeable, not a discrepancy to resolve at build time.)
### 6.3 Connection management
- HTTP/2 + TLS 1.2+ (1.3 acceptable). **Reuse one client / connection pool per process per environment per team**; never create a client per push (TLS setup is slow — reference §3.1). One well-tuned connection can do thousands of pushes/sec.
- Set a non-zero `ReadIdleTimeout` (~15 s) + `PingTimeout` (~15 s) on the `http2.Transport` to reap half-open connections (reference §3.3 — "the single most important tuning for a long-lived relay").
- Do **not** hardcode `SETTINGS_MAX_CONCURRENT_STREAMS`; read it from the server (reference §1.3/§3.3).
- Handle `GOAWAY`: drop the connection from the pool and reconnect; finish in-flight streams ≤ `Last-Stream-ID` (RFC 9113 §6.8). Go's `net/http2` transport does most of this when one client is reused (reference §1.3). **A stream severed by `GOAWAY`/reset before a final APNs response is mapped to `503 upstream_unavailable` (retryable) and is NOT retried in-process** — see §6.4 "Connection-severed / GOAWAY" for the exact caller-facing rule and the at-most-once rationale.
- Ensure the TLS trust store includes **USERTrust RSA** (SHA-2 root) — already in standard OS/Go pools; only matters if the relay pins or ships a minimal trust store (reference §1.7).
### 6.4 Response / reason mapping → caller-facing error
The relay parses the APNs JSON `{"reason":"…"}` (and the `timestamp` on 410), not just the status code (reference §1.6). It maps to a caller-facing `error.code` and decides retryability. **The relay does not itself retry transient upstream errors across separate caller requests** — it returns a retryable status so the caller's outbox/backoff (in `../01-release-events-and-inbox.md`/`02`) drives retries; the one exception is the in-process JWT-expiry retry (§6.1).
| APNs status | APNs `reason` | Caller HTTP | Caller `error.code` | Retryable (caller)? |
|---|---|---|---|---|
| 200 | — | 200 | — | success |
| 400 | `BadDeviceToken`, `InvalidToken`, `DeviceTokenNotForTopic`, `MissingDeviceToken` | 422 | `bad_device_token` | **No — purge token** (caller disables device, per `02`). `02` treats `BadDeviceToken`/`InvalidToken` equivalently: "Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`; treat them equivalently." See the environment-mismatch caveat below. |
| 400 | `BadTopic`, `MissingTopic`, `TopicDisallowed` | 400 | `bad_topic` | No (config error) |
| 400 | `BadCollapseId` | 400 | `invalid_collapse_id` | No |
| 400 | `BadExpirationDate`, `BadPriority`, `BadMessageId`, `InvalidPushType`, `PayloadEmpty`, `DuplicateHeaders`, `IdleTimeout` | 400 | `bad_request` | No |
| 403 | `ExpiredProviderToken` | (internal) | — | **Relay regenerates JWT, retries once** (§6.1), then maps result |
| 403 | `InvalidProviderToken`, `MissingProviderToken`, `UnrelatedKeyIdInToken`, `BadEnvironmentKeyIdInToken`, `Forbidden`, `BadCertificate*` | 502 | `upstream_auth_error` | No (relay misconfig — **ops alert**, not caller-fixable) |
| 404 | `BadPath` | 502 | `upstream_error` | No |
| 405 | `MethodNotAllowed` | 502 | `upstream_error` | No |
| 410 | `Unregistered`, `ExpiredToken` | 410 | `unregistered` | **No — purge token** (caller disables device). `timestamp` (ms) passed through in `error.message` so caller can compare against its registration time. |
| 413 | `PayloadTooLarge` | 400 | `payload_too_large` | No |
| 429 | `TooManyProviderTokenUpdates` | 503 | `upstream_throttled` | Yes — relay slows JWT refresh; caller backs off |
| 429 | `TooManyRequests` | 429 | `rate_limited` | Yes — per-device backoff; `Retry-After` set |
| 500 | `InternalServerError` | 503 | `upstream_unavailable` | Yes (caller backoff ~15 min) |
| 503 | `ServiceUnavailable`, `Shutdown` | 503 | `upstream_unavailable` | Yes (caller backoff) |
**Default / fallback for unrecognized reasons.** Any **unmapped** `reason` string on a `400`/`403`/`410` (e.g. a future Apple reason the relay does not know) maps to `502 upstream_error`, **non-retryable, no token purge** — a safe default that surfaces the anomaly for investigation rather than silently dropping it or purging a possibly-live token. An unmapped `5xx` maps to `503 upstream_unavailable` (retryable). The exact unmapped reason is recorded in the op-log `upstream_reason` for triage.
**Connection-severed / GOAWAY (no final response).** A request whose HTTP/2 stream is terminated by a `GOAWAY`, connection reset, or any transport drop **before a final APNs response arrives** is mapped to `503 upstream_unavailable` (retryable) — the same as a network timeout. The relay does **NOT** transparently retry such a request in-process; the caller's outbox (`01-release-events-and-inbox.md`) re-dispatches it under the same idempotency key, preserving at-most-once semantics (a request that may have been delivered must not be silently re-sent by the relay). Apple emits `GOAWAY` routinely for maintenance/overload, so this is a normal, expected outcome, distinguished in the op-log/metrics with a `connection_severed` outcome label.
**Environment / sandbox-vs-production mismatch caveat.** A caller that sends `environment=sandbox` with a production token (or vice-versa) gets `BadDeviceToken`/`InvalidToken` from APNs for a token that may be perfectly valid in the other environment. The relay still returns `422 bad_device_token`, but **the caller should NOT treat the first such error from a newly-onboarded device as conclusive grounds to purge** when an environment mismatch is plausible. To let operators distinguish a systemic misconfiguration from genuinely dead tokens, the relay tags these op-logs/metrics so a burst of `bad_device_token` concentrated on **one account** (a likely env mixup) is visible separately from scattered dead tokens across many accounts. See §5.1 (token rules) and §8.4.
Mapping to the caller's own error handling in `02`: `bad_device_token`/`unregistered` → the caller disables the device (`02` "BadDeviceToken / Unregistered: disable the token"); `upstream_throttled`/`rate_limited`/`upstream_unavailable` → the caller retries with backoff. The relay never surfaces `ExpiredProviderToken`/`TooManyProviderTokenUpdates` as caller-facing codes (those are the relay's own JWT lifecycle — `02` notes they apply only to `custom_apns`, never the relay path).
Network timeout / connection failure to APNs → `503 upstream_unavailable` (retryable).
---
## 7. Upstream FCM Client
Built per `./02-apns-fcm-2026-reference.md` §2 and §3.2. Preferred path: `golang.org/x/oauth2/google` `TokenSource` (auto-caches + refreshes) + a custom HTTP/2 client posting the relay-built JSON (reference §3.2 — lighter than the Admin SDK and gives direct control of connection reuse and error classification). The Admin SDK (`firebase.google.com/go/v4/messaging`) is an acceptable alternative; pick exactly one.
### 7.1 OAuth2 service-account token source + caching
- Build a `TokenSource` from the SA JSON with `google.CredentialsFromJSONWithType(ctx, jsonKey, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` (least-privilege scope; `CredentialsFromJSON` is deprecated — reference §2.2/§3.2). The `TokenSource` caches and auto-refreshes.
- Access tokens are valid **1 hour**; never mint per request. The library re-mints before expiry; if hand-rolling the JWT-bearer grant, refresh with a ~5-minute safety margin (reference §2.2).
- **Credential resolution:** prefer keyless **Workload Identity / attached SA** when the relay runs on GCP; off-GCP, load the SA JSON from the secret manager (reference §2.2/§4.5; §11 here).
- One token source **per Firebase project**. v1 has one official project (`continuum-prod-android`).
### 7.2 Endpoint
```http
POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
Authorization: Bearer <oauth2_access_token>
Content-Type: application/json
```
Global host only — **no regional/data-residency send endpoints exist** as of June 2026 (reference §2.3). The host `fcm.googleapis.com` is a **hardcoded constant**, never derived from request input. `{PROJECT_ID}` is the validated `project_id` from the request: it is checked against a **strict charset (`^[a-z0-9-]+$`) before** allowlist lookup and before URL interpolation, so an allowlist row can never inject path-traversal or override the host (§11 egress/SSRF posture).
### 7.3 Data-only message construction
As in §5.2: `message.token` set to the caller's `token`; `message.data` is string-valued only (a fixed, closed key set — §5.2/§12); `message.android.priority` is the canonical `"HIGH"`/`"NORMAL"` enum by mode; the caller's `collapse_key` is mapped to the REST field `message.android.collapseKey` (camelCase) when present; **no `notification` block**. The relay can optionally use `validateOnly: true` (top-level, camelCase) for the `relayctl`-driven test path (reference §2.5) but **never** on production sends.
### 7.4 Error mapping → caller-facing error + Retry-After
The relay classifies on `error.status` / the `FcmError.errorCode` in `details[]`, not the bare HTTP code (reference §2.6).
| FCM HTTP | `error.status` | Caller HTTP | Caller `error.code` | Retryable (caller)? |
|---|---|---|---|---|
| 200 | — | 200 | — | success |
| 400 | `INVALID_ARGUMENT` | 422 | `invalid_argument` | No. Ambiguous (bad payload **or** bad token); since the relay payload is independently valid, the caller may treat it as a dead token (`03` / reference §2.6). |
| 401 | `THIRD_PARTY_AUTH_ERROR` | 502 | `upstream_error` | **Not expected on this pure-Android path** — see note below. No auto-purge; surface for investigation. Do **NOT** page as a relay-credential failure. |
| 403 | `SENDER_ID_MISMATCH` | 403 | `sender_id_mismatch` | **No — disable device** (token minted under a different project; `03`). |
| 404 | `UNREGISTERED` | 410 | `unregistered` | **No — purge token** (`03`). Caller-facing **410** is an **intentional cross-provider normalization** — see note below. |
| 429 | `QUOTA_EXCEEDED` (RESOURCE_EXHAUSTED) | 429 | `rate_limited` | Yes — **honor `Retry-After`** if present; else default **60 s** (reference §2.7). |
| 429 / 400 | per-device rate (`DeviceMessageRateExceeded` — see note) | 429 | `rate_limited` | Yes — **retryable; NOT a token purge**. Conservative default `Retry-After` (60 s) if none provided. |
| 401 / 403 | (HTTP **status**, not `FcmError` code) `UNAUTHENTICATED` / `PERMISSION_DENIED` on `messages:send`, **or** a non-200 from `oauth2.googleapis.com/token` at mint time | 502 | `upstream_auth_error` | No — **relay's own OAuth2 / service-account credential failure** (**ops alert**). See note below. |
| 500 | `INTERNAL` | 503 | `upstream_unavailable` | Yes (backoff; no documented `Retry-After`). |
| 503 | `UNAVAILABLE` | 503 | `upstream_unavailable` | Yes — **honor `Retry-After`** if present (reference §2.7). |
| — | `UNSPECIFIED_ERROR` / any unmapped `errorCode` | 502 | `upstream_error` | No auto-purge; surface for investigation (reference §2.6). |
**Note — `THIRD_PARTY_AUTH_ERROR` is not a relay-credential signal on this path.** Per Firebase's error-codes page, `THIRD_PARTY_AUTH_ERROR` means *"APNs certificate or web-push auth key was invalid or missing"* and is returned **only** for messages targeting **iOS-via-APNs or Web Push** registration tokens — i.e. when FCM itself must authenticate downstream to Apple/VAPID. The relay's `/v1/fcm/send` sends **only Android registration tokens** (data-only `message.token`, package `com.continuum.app.android`), so a legitimate Android send can never produce `THIRD_PARTY_AUTH_ERROR`. If it ever appears it indicates a **per-token/registration anomaly** (an Android token that is somehow APNs/web-bound — a client/registration bug), not a relay `.p8`/SA misconfiguration; treat it as `502 upstream_error`, surface for investigation, and do **not** page on-call as a credential failure.
**Note — the relay's OWN FCM credential failure surfaces differently.** A failure of the relay's OAuth2 service-account auth shows up as a non-200 from the Google token endpoint (`oauth2.googleapis.com/token`) at mint time, or as an HTTP **status** `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` on `messages:send` (the gRPC/REST status, **not** an `FcmError.errorCode`). **These** are the signals that map to `upstream_auth_error` and warrant an ops alert (the alerting condition in `01-implementation-plan.md` keys on this `upstream_reason`, not on `THIRD_PARTY_AUTH_ERROR`).
**Note — `UNREGISTERED` is deliberately re-mapped from FCM's native 404 to caller-facing 410.** FCM natively returns `UNREGISTERED` as HTTP **404**; the relay normalizes it to caller-facing **410** so it matches the same "dead token, purge" semantic the APNs path uses for `Unregistered` (where APNs natively uses 410). This is an **intentional cross-provider normalization** (not a bug) so the caller's device-disable logic is uniform across APNs and FCM. Similarly `SENDER_ID_MISMATCH` keeps FCM's native 403.
**Note — per-device rate exceedance.** FCM enforces per-device Android caps (~240 msg/min, ~5000 msg/hr) that surface as a `DeviceMessageRateExceeded`-class error, **distinct from project-level `QUOTA_EXCEEDED`** (reference §2.7). Because the relay fans out bursty per-device wakes, this is a plausible real outcome. The exact error code/string for per-device exceedance is **not firmly documented**, so the relay detects it **defensively** by classifying on `error.status`/`errorCode` and maps it to a **retryable `429 rate_limited`** with a conservative default `Retry-After` — **never a token purge** (the device is fine, just rate-limited). The relay's coarse per-`(account, token_hash)` limiter (§9.1) is the local guard, but an upstream per-device error must still map to `429`, not fall through to `502`.
**Retry-After honoring (reference §2.7):** on FCM `429`, the relay reads the upstream `Retry-After`; if absent it defaults to 60 s. It forwards this as the caller-facing `Retry-After` header on the `429 rate_limited` response (relative `delay-seconds`, not an absolute epoch — reference §4.2). On `503 UNAVAILABLE` it forwards any upstream `Retry-After`.
Mapping to `03` caller handling: `unregistered`/`sender_id_mismatch`/`invalid_argument` → caller disables the device; `rate_limited` → caller backs off and may fall back to `priority: NORMAL` after repeated 429s (`03` open question, resolved "yes with cooldown"); `upstream_auth_error` → caller surfaces admin diagnostics, no retry. Note that `upstream_auth_error` is now sourced from the **relay's own** OAuth2/SA credential signals (HTTP `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` on `messages:send`, or a token-mint failure), **not** from `THIRD_PARTY_AUTH_ERROR` — which on this Android-only path is a registration anomaly, not a relay-credential problem (see the notes above and §7.5).
### 7.5 Reconciliations with the older `03` contract
- **`Retry-After` on 429.** `03` says "honoring the `Retry-After` response header if present (FCM uses standard HTTP semantics here)". The reference (§2.6/§2.7) refines this: FCM's error page promises only `google.rpc.QuotaFailure` (not `RetryInfo`); treat `Retry-After`/`RetryInfo` as optional and fall back to 60 s. The relay implements the **reference's** refinement while still surfacing a `Retry-After` to the caller exactly as `03` expects.
- **No batch endpoint.** The reference (§2.1) confirms FCM's batch endpoint is discontinued; the relay sends **one POST per request** (it already does — one caller request = one device).
- **Upstream field casing — `collapseKey`, `priority`, `validateOnly`.** `03` writes the upstream Android fields in snake_case (`collapse_key`) and lowercase priority (`high`/`normal`), and writes the dry-run flag as `validate_only`. The correct **FCM HTTP v1 REST** field names are `android.collapseKey` (camelCase), the canonical `priority` enum `"HIGH"`/`"NORMAL"` (uppercase; lowercase also accepted), and top-level `validateOnly` (camelCase) (reference §2.4/§2.5). The relay uses the **REST-correct names on the upstream wire** while leaving the **inbound caller field `collapse_key` unchanged** per `03`. FCM v1 rejects unknown fields with `INVALID_ARGUMENT`, so this reconciliation is mandatory, not cosmetic.
- **FCM 4-distinct-collapse-key-per-device cap.** FCM stores at most **4 distinct collapse keys per device while offline**; a 5th distinct key evicts one nondeterministically (reference §2.7 — a delivery/storage behavior, **not** a per-message validation limit). Because `collapse_key` is a per-series HMAC and a device may follow **more than 4 series**, collapsible coalescing is **best-effort**: excess distinct keys evict nondeterministically and a collapsible wake may be dropped. This is **acceptable** because the durable inbox + sync (`01-release-events-and-inbox.md`) recovers any dropped wake; the relay treats `collapse_key` opaquely and takes no action. **Asymmetry note:** APNs has no equivalent documented per-device `collapse-id` cap, so the FCM path alone carries this best-effort caveat.
- **External contract unchanged.** None of the above alters the `03` request/response shapes; only the relay's internal upstream behavior is specified more precisely.
---
## 8. Account, Key and Allowlist Model
### 8.1 DB schema (PostgreSQL)
Goose-style migration. All IDs are ULIDs stored as `text` for consistency with the Silo convention; timestamps are `timestamptz`.
```sql
-- relay_accounts: one per opt-in self-hosted installation.
CREATE TABLE relay_accounts (
id text PRIMARY KEY, -- ULID
name text NOT NULL, -- operator-facing label
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','disabled')),
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- relay_api_keys: revocable bearer keys (rk_ prefix). Secret never stored.
CREATE TABLE relay_api_keys (
id text PRIMARY KEY, -- ULID
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
key_prefix text NOT NULL, -- non-secret, indexed lookup handle (e.g. rk_live_3Qw9XYZ)
key_hash bytea NOT NULL, -- HMAC-SHA256(pepper, secret), 32 bytes
env text NOT NULL DEFAULT 'live'
CHECK (env IN ('live','test')),
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz, -- throttled write (<= 1/min)
expires_at timestamptz, -- optional
revoked_at timestamptz -- non-null = revoked
);
CREATE UNIQUE INDEX relay_api_keys_prefix_uidx ON relay_api_keys (key_prefix);
CREATE INDEX relay_api_keys_account_idx ON relay_api_keys (account_id);
-- relay_apns_allowlist: which APNs topics an account may push to.
CREATE TABLE relay_apns_allowlist (
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
topic text NOT NULL, -- e.g. com.continuum.app.ios
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, topic)
);
-- relay_fcm_allowlist: which (project, package) pairs an account may push to.
CREATE TABLE relay_fcm_allowlist (
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
project_id text NOT NULL, -- e.g. continuum-prod-android
package_name text NOT NULL, -- e.g. com.continuum.app.android
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, project_id, package_name)
);
-- relay_op_logs: redacted operational/audit log. No tokens, no content.
CREATE TABLE relay_op_logs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(),
account_id text REFERENCES relay_accounts(id) ON DELETE SET NULL,
request_id text, -- ULID echoed to caller
event text NOT NULL, -- e.g. send.apns, send.fcm, auth.reject, admin.key_issue
provider text, -- 'apns' | 'fcm' | null
outcome text, -- 'accepted' | 'rejected' | 'upstream_error' | ...
status_code int, -- caller-facing HTTP status
error_code text, -- caller-facing error.code
upstream_reason text, -- APNs reason / FCM status (non-sensitive)
token_hash text, -- SHA-256(token) hex, NOT the raw token
egress_ip inet, -- caller egress IP
latency_ms int
);
CREATE INDEX relay_op_logs_account_time_idx ON relay_op_logs (account_id, occurred_at DESC);
CREATE INDEX relay_op_logs_time_idx ON relay_op_logs (occurred_at DESC);
```
Notes:
- `relay_op_logs` is the only table written on the hot path besides the throttled `last_used_at`. It carries **no raw token, no payload, no identity** — only a `token_hash` (SHA-256 hex) for correlating repeated failures to one device without storing the token (reference §4.6). Retention: see §13.
- No table stores APNs/FCM device tokens, notification content, user/profile IDs, or token aliases — by design (`00` "Its database holds only…").
### 8.2 API-key format, hashing, lookup, compare
- **Format:** `rk_<env>_<random>`. The **prefix** = `rk_<env>_<first 6–8 chars of the random>` is **non-secret**, stored in cleartext, and uniquely indexed (`key_prefix`). The full random part is 32 bytes from `crypto/rand`, base62/base64url-encoded. GitHub/Stripe-style design — lets a request `SELECT` by indexed prefix in O(1), then constant-time-compare (reference §4.1).
- **Hashing:** store `HMAC-SHA256(pepper, secret)` (32 bytes) where `pepper` is a server-side secret in the secret manager. A fast keyed hash is appropriate for **high-entropy 256-bit random keys** — OWASP's slow-KDF (argon2id) guidance is scoped to low-entropy passwords and would needlessly throttle per-request auth (reference §4.1). Never store plaintext.
- **Lookup + compare on each request (constant-time regardless of whether the prefix exists):**
1. Parse `Authorization: Bearer rk_<env>_<rest>`; reject malformed → `401 unauthorized`.
2. Derive `key_prefix` from the token; `SELECT … FROM relay_api_keys WHERE key_prefix = $1` (unique index).
3. If no row, or `revoked_at IS NOT NULL`, or `expires_at < now()`, or the account is `disabled` → still perform a **dummy `HMAC-SHA256` against a fixed decoy hash with `crypto/subtle.ConstantTimeCompare`**, then return the uniform `401 unauthorized` (do not distinguish reasons to the caller). **Do not short-circuit before the HMAC+compare** — an unknown prefix must take the same time as a known prefix with a wrong secret, or the timing difference is a key-enumeration oracle (a known/live prefix would otherwise reject measurably faster). The fine-grained `reason` (`unknown_prefix`/`hash_mismatch`/…) is kept **only in internal logs and the internal-only `/metrics`** (§13.2), never surfaced to a tenant or an untrusted scraper.
4. Compute `HMAC-SHA256(pepper, presented_secret)` and `crypto/subtle.ConstantTimeCompare` against `key_hash` (or the decoy). Mismatch → `401`. Never use `==`/`bytes.Equal` on secret material (reference §4.1).
5. Throttle-update `last_used_at` (at most once per minute per key). **The throttle clock lives in Redis** (`lastused:{key_prefix}` set `NX EX 60`) so the "≤1/min/key" bound holds **cross-replica** (a per-instance clock would allow N writes/min/key across N replicas); only when that `NX` succeeds does the relay issue the Postgres `UPDATE`. The Redis key carries no secret. This avoids a Postgres write per request (reference §4.1) without leaking a per-key validity side channel through DB write cadence.
- **Rotation:** multiple live keys per account are supported (issue a new key, deploy it on the server, then revoke the old) for zero-downtime rotation (reference §4.1).
- **Client-IP resolution (trusted-proxy requirement).** The relay runs **behind a TLS load balancer** (§4.2, §14.1), so Go's `http.Request.RemoteAddr` is the LB/proxy address, not the caller. The relay derives the client IP as follows: **use `RemoteAddr` unless the immediate peer is in the allowlisted trusted-LB CIDR set, in which case take exactly the right-most untrusted hop from the LB-set forwarding header (`X-Forwarded-For`)**. **Caller-supplied forwarding headers are ignored** — a caller cannot set `X-Forwarded-For`/`X-Real-IP` to forge an IP. The trusted-proxy CIDR list is a **config value**. Both the per-IP auth-failure limiter and the op-log `egress_ip` consume this single resolved IP. (Without this, a caller could either spread brute-force across unlimited fake IPs to defeat the per-IP throttle (OWASP API2:2023) and poison `egress_ip`, or — if `RemoteAddr` were trusted blindly behind the LB — collapse every tenant onto one IP and trip the throttle for all at once.)
- **Anti-brute-force — per-IP auth-failure limiter (specified).** Auth failures are rate-limited **per resolved client IP** (independent of the per-account bucket) to blunt key-guessing (OWASP API2:2023, reference §4.1). Concretely:
- **Store:** Redis, key `authfail:{client_ip}`, sliding/fixed window with `EXPIRE`.
- **Window / threshold:** e.g. **20 failures / 5 min** per IP (tunable).
- **Response on breach — *soft* lockout, not a hard block:** added latency / a `429` with `Retry-After` (e.g. 60 s backoff) rather than dropping the connection. This is deliberate because the doc anticipates **VPN/CGNAT shared egress** (§3.4): multiple self-hosted servers can share one egress IP, so a hard per-IP block would be collateral damage from a noisy/compromised neighbor. Optionally scope tracking by `(key_prefix, IP)` to further isolate neighbors.
- **Fail-open/closed stance:** this limiter **fails open** if Redis is unreachable (availability over a perfectly-enforced anti-guessing cap), logging `authfail_limiter.degraded`. The hard auth boundary (HMAC compare, allowlist) is always enforced from Postgres regardless.
- **Metric + runbook:** `relay_auth_failures_total{reason=…}` (internal only) plus a documented response runbook for the key-guessing alert (R8, §6.4 monitoring): inspect the offending IP/prefix, confirm whether a single account or a guessing pattern, revoke/rotate as needed.
### 8.3 The `rk_` token is shown exactly once
`relayctl key issue` prints the full `rk_…` token to stdout **once**; only the HMAC hash and prefix are persisted. There is no endpoint or command that can recover a secret. Lost keys are revoked and re-issued.
### 8.4 Allowlist enforcement
Enforced **after** auth, **before** building the upstream payload:
- **APNs (`/v1/apple/send`):** the request `topic` must exist in `relay_apns_allowlist` for the caller's `account_id`. Not present → `403 topic_not_allowed`. The official build config allowlist (allowlist config, **not** part of the contract) is the set `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos` (`02` resolved-questions: per-platform topics).
- **FCM (`/v1/fcm/send`):** the `(project_id, package_name)` pair must exist in `relay_fcm_allowlist` for the caller's `account_id`. Missing project → `403 project_not_allowed`; project present but package not → `403 package_not_allowed`. Official build config: project `continuum-prod-android`, package `com.continuum.app.android`.
- Allowlists are loaded per request from Postgres (cheap, small) or from a short-TTL in-memory cache keyed by `account_id` (invalidated on `relayctl allowlist … set`). The cache must be per-account-scoped so one account's allowlist can never authorize another's push.
These are **allowlist config values**, set by `relayctl`, not part of the external wire contract — a different deployment (e.g., a fork) would set different topics/packages.
> **Environment ⇄ token consistency (operational caveat).** The allowlist does **not** (and cannot) verify that the request `environment` matches the environment the device `token` was minted for. A `sandbox`-vs-`production` mixup presents downstream as APNs `BadDeviceToken`/`InvalidToken` → `422 bad_device_token` (§6.4), which would otherwise tell the caller to purge a perfectly good token. To let operators catch a **systemic** misconfiguration, the relay emits a distinct op-log/metric tag for these so a burst of `bad_device_token` concentrated on **one account** (likely an env mixup) is distinguishable from scattered genuinely-dead tokens across many accounts. The cross-field rule is documented in §5.1 and §6.4.
---
## 9. Rate Limiting and Quotas
### 9.1 Model
Per-account **token bucket** enforced in Redis with an atomic Lua script (refill → check → consume in one `EVAL`), state stored as a HASH `rl:{account_id} = {tokens, last_refill_ts}` with a TTL for idle-account eviction (reference §4.2). A Redis-backed limiter (not per-instance `x/time/rate`) is required because the relay runs multiple replicas behind a load balancer — per-instance limiters would not enforce a global cap.
A second, coarser limiter keys on `(account_id, token_hash)` to blunt hammering a single device (the `02`/`03` "rate limited by API key and coarse token hash" requirement).
**Redis operational requirements (eviction, HA, sizing).** Redis backs both idempotency (fail-closed) and rate limiting (fail-open), so its configuration is correctness-relevant, not just performance:
- **Non-evicting policy for idempotency.** The idempotency keyspace **must** run under `maxmemory-policy noeviction` (or live in a **dedicated Redis/db** separate from the rate-limit keyspace) so that memory pressure can **never silently evict an in-flight idempotency lock** — an evicted lock would silently violate at-most-once (a concurrent retry would get `NX`-success and double-send). Rate-limit keys may be evicted harmlessly; idempotency keys must not. This is called out in risks R4/R5.
- **HA topology.** Run Redis with an HA topology (Sentinel or Cluster) with prompt failover. A Redis failover/restart makes **every** send `503` while idempotency fails closed (§10) — a push-delaying, not push-losing, event (the caller outbox retries), **but only as long as idempotency stays fail-closed**. A Redis outage should **page ops**.
- **Keyspace sizing.** Size the idempotency keyspace from the quota math: ~`50k/day/account × N accounts`, each entry holding `{status, body, upstream_id, canonical_hash}` under a ~24 h TTL. Provision `maxmemory` with headroom above that working set so `noeviction` never starts rejecting writes.
### 9.2 Suggested limits (ties to `00` open question #1)
| Limit | Default | Source / rationale |
|---|---|---|
| Per-account burst | **~10 req/s** | `00` open question #1 ("Suggest 10 req/sec burst") |
| Per-account daily quota | **50,000 req/day** | `00` open question #1 ("50,000 req/day initial quota") |
| Bucket refill rate | 10 tokens/s, burst capacity 20 | derived from the 10 req/s target with a small burst allowance |
| Per-(account, token) coarse cap | e.g. 1 req/3 s sustained | abuse guard on a single device |
These are tunable per account (future column on `relay_accounts`); v1 ships a single global default and tunes from logs (`00`). The Silo server already paces its calls client-side (default 5 req/s shared token bucket — `02`/`03` "Relay pacing"), so a well-behaved caller stays comfortably under the 10 req/s burst; the relay limit is the backstop against a misbehaving or compromised caller (OWASP API4:2023 — cap per-tenant downstream spend).
**Daily-quota mechanics (store, reset, fail-open).** The per-second token bucket (§9.1) does **not** model the 50,000 req/day cap; the daily counter is a **separate Redis counter** keyed `rl:day:{account_id}:{yyyymmdd}` with an `EXPIRE` past the day boundary, incremented atomically per accepted send:
- **Reset semantics:** a **fixed UTC calendar day** (the `{yyyymmdd}` is UTC), not a rolling 24 h — simple, predictable for operators, and the key self-expires.
- **Breach:** when the counter reaches 50,000 the account's sends return `429 rate_limited` for the rest of the UTC day with a `Retry-After` = seconds until 00:00 UTC. Daily exhaustion blocks **all** of an account's pushes until reset — a notable availability event, so it is **alerted** (see below), not silent.
- **Fail-open during a Redis outage:** like the per-second bucket, the daily counter **fails open** if Redis is unreachable — but this is exactly the abuse-amplification window, so it is **bounded** by the in-process fallback limiter in §9.4 (a compromised key cannot burn unlimited upstream sends during the outage) and the outage pages ops. The daily quota **cannot be enforced cross-replica while Redis is down**; that limitation is explicit.
- **Alerting:** a metric/alert fires for accounts **approaching** (e.g. ≥80%) or **hitting** the daily cap, since a legitimate hundreds-of-users server can plausibly hit 50k/day on a big release burst — operators should see this and raise the cap rather than have the account silently throttle. Reconcile the 50k/day default against the `02`/`03` burst-cap math when tuning.
### 9.3 429 response
On limit exceeded the relay returns:
```http
HTTP/2 429
Retry-After: 3
Content-Type: application/json
X-Request-Id: 01JRELAY...
{ "error": { "code": "rate_limited",
"message": "account rate limit exceeded",
"request_id": "01JRELAY..." } }
```
`Retry-After` is a **relative `delay-seconds`** integer (never an absolute Unix epoch — reference §4.2), computed from the bucket's time-to-next-token. The daily-quota breach also returns `429 rate_limited` with a longer `Retry-After` (seconds until the daily window rolls).
### 9.4 Fail-open vs fail-closed (bounded, not unlimited)
If Redis is unreachable, the rate-limit middleware **fails open** for availability (a push is more valuable than a perfectly enforced cap), but **bounded** — it does **not** simply remove the cap. When Redis is down the relay falls back to a **conservative per-instance in-process limiter** (`golang.org/x/time/rate`, keyed per `account_id`) that caps each account to a small multiple of its normal rate. This matters because the per-account quota is the primary control bounding a **stolen-key** attacker's spend and per-tenant downstream APNs/FCM cost (OWASP API4:2023, §3.5 "Stolen relay API key", G6): an unbounded fail-open would let a compromised `rk_` key send without limit against the official Apple/Google credentials — including burning the shared FCM project quota (§9.5) common to all tenants — for the entire outage. The **allowlist (always enforced from Postgres)** remains the hard boundary. A `rate_limit.degraded` op-log + metric fires; the **daily quota cannot be enforced cross-replica** during the outage, and an outage **pages ops**. The idempotency layer (§10), by contrast, **fails closed** to avoid double-sends. (Reference §4.2 — explicitly a per-endpoint decision.)
### 9.5 Shared FCM project quota — global limiter / circuit breaker
The per-account limits above are **local to each account**, but the **FCM project quota (~600k messages/min, reference §2.7) is SHARED across ALL relay accounts** sending to `continuum-prod-android`. Without a relay-side guard, one noisy/compromised account can exhaust the shared project quota and every other account's sends start returning `429 QUOTA_EXCEEDED` — a **multi-tenant fairness** failure (one noisy tenant degrades all). The relay therefore adds a **global (per-project) outbound limiter / circuit breaker** at the aggregation point — the only place that *can* see aggregate FCM rate across accounts:
- **(a) Track aggregate FCM send rate** against the documented project quota (a Redis counter keyed per project, e.g. `fcmq:{project_id}:{yyyymmddhhmm}` with a per-minute window).
- **(b) Shed load before hammering FCM:** when near the quota, return `503 upstream_throttled` (retryable) **before** forwarding, rather than spending quota on calls that FCM will reject anyway (some errors still count against quota).
- **(c) Anti-spike smoothing (optional):** honor the reference §2.7 guidance to avoid concentrating traffic in the 2-minute windows around `:00/:15/:30/:45` — the relay, as the cross-account aggregation point, is the only component that can actually implement this ramp.
- **Observability:** a `relay_fcm_project_quota_headroom` gauge plus an alert when headroom is low. This is explicitly a **multi-tenant fairness** control (a single account must not exhaust the shared project quota for all).
---
## 10. Idempotency
### 10.1 Key format
`Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>` (`02`/`03`). This is **scoped per account** in storage (so two accounts cannot collide). The relay validates the header is present and non-empty on send endpoints; missing → `400 missing_idempotency_key`. The three-part shape is not strictly parsed (it is opaque to the relay), but it is length-limited (≤ 255 chars, Stripe-style — reference §4.3).
### 10.2 Store, TTL, locking, duplicate semantics
Redis, namespaced `idem:{account_id}:{key}` (reference §4.3):
1. `SET idem:{account}:{key} <in_flight_marker> NX EX <lock_ttl>` (`lock_ttl` = 30 s). The `<in_flight_marker>` carries a **unique per-attempt nonce** so the completing request can prove ownership (see invariant below).
2. **NX success** → this is the first request. Process it; on completion, **compare-and-set**: overwrite the key with the serialized `{status, body, upstream_id, canonical_hash}` and a **~24 h TTL** **only if the stored value is still this request's own in-flight marker** (i.e. the lock was not stolen by a concurrent request after a TTL expiry). Success *and* error are both recorded (reference §4.3 / Stripe model).
3. **NX fail + stored completed result** → **replay** it: return the original status code and body verbatim (so a retried send returns the same `apns_id`/`fcm_message_name`, never a second upstream send).
4. **NX fail + still the in-flight marker** → the original is mid-flight → **`409 conflict`** (`{"error":{"code":"idempotency_conflict",…}}`) so the caller retries later.
5. **Same key, different request payload** → **`422 unprocessable`** (`idempotency_key_reuse`). The relay stores a `canonical_hash` of the request alongside the result and compares (reference §4.3). **The canonical hash is computed over the validated fields with the device `token` replaced by its already-computed `token_hash` (SHA-256) — the raw token is NEVER part of the canonical form.** The stored idempotency value contains only `{status, body, upstream_id, canonical_hash}`; it holds **no raw token** (matching the op-log guarantee §12, NG1). This keeps the "no token aliases / stateless v1" privacy guarantee intact even though the value lives in Redis under `idem:{account}:{key}` for ~24 h.
> **Lock-TTL invariant (no double-send, no result-clobber).** The upstream APNs/FCM call **must run under a hard context deadline strictly shorter than `lock_ttl`** — e.g. **upstream timeout 10 s, `lock_ttl` 30 s** — and the handler **must refuse to send upstream if its own deadline would exceed the lock TTL**. Otherwise a slow first attempt (HTTP/2 setup + JWT/OAuth refresh + GOAWAY-reconnect can stack up) could outlive the 30 s lock; the lock would expire mid-flight, a concurrent retry of the same `Idempotency-Key` would get `NX`-success and fire a **second** upstream send (defeating at-most-once), and when the slow first attempt finally completed it would clobber the stored result with a different `apns_id`. The **compare-and-set on completion** (step 2) prevents the clobber — a lock that was stolen by a concurrent request cannot have its result overwritten by the original — and the **deadline-shorter-than-TTL** rule prevents the double-send in the first place. (Optionally, a lock heartbeat may renew the marker while genuinely in flight, but the deadline rule is the primary guarantee.) This invariant is the whole reason the idempotency store exists (R4).
This guarantees **at-most-once upstream send** under the caller's concurrent retries (the `../01-release-events-and-inbox.md` outbox can re-claim and re-dispatch the same `(delivery_id, device_id, attempt_number)`), which is exactly why the key includes `attempt_number`: attempt 2 is a *new* idempotency key (intended re-send after a failure), while two concurrent dispatches of attempt 1 collapse to one upstream call.
### 10.3 What a duplicate returns (summary)
| Situation | Response |
|---|---|
| First request | Process; record `{status, body, upstream_id, canonical_hash}` (no raw token — §10.2) for ~24 h. |
| Duplicate, original completed | Replay stored status + body (same `apns_id`/`fcm_message_name`). |
| Duplicate, original in-flight | `409 idempotency_conflict`. |
| Same key, different payload | `422 idempotency_key_reuse`. |
| Missing key | `400 missing_idempotency_key`. |
Idempotency **fails closed**: if Redis is unavailable, send endpoints return `503 upstream_unavailable` (retryable) rather than risk a double-send (contrast §9.4).
---
## 11. Security Requirements
- **TLS only.** Public endpoints are HTTPS over HTTP/2; no plaintext listener. TLS 1.2+ (prefer 1.3). HSTS on the public hostname. (`02`/`03` "Relay API uses TLS only".)
- **Secret management.** APNs `.p8`, FCM SA JSON, the API-key HMAC pepper, and DB/Redis credentials live in a **secret manager** (KMS/Vault/cloud secret-manager). Ranking: secret-manager > tightly-permissioned mounted file > env var (**discouraged**) > in-image/repo (**forbidden**) (reference §4.5). On GCP, prefer keyless Workload Identity for FCM and avoid a downloadable SA JSON entirely (reference §4.5). Never commit or bake credentials into the image.
- **Key storage.** API keys stored only as `HMAC-SHA256(pepper, secret)` + non-secret prefix; constant-time compare; revocable; optional expiry; multiple live keys per account for rotation (§8.2). The relay's own `rk_` secret is shown once and never recoverable.
- **No raw secrets in transit out.** No endpoint returns the `.p8`, SA JSON, pepper, or any `rk_` secret. `/metrics` and op-logs carry none.
- **Redaction (enforced in middleware so it cannot be bypassed).** Never log: `Authorization`/bearer header, raw API key (log only the non-secret prefix or a hash), raw APNs/FCM device tokens (log `SHA-256` truncated hash only), notification payload bodies, the relay JWT, or OAuth2 access tokens (reference §4.6; `02`/`03` privacy/security requirements). See §12/§13.
- **Isolation / multi-tenancy.** Every API key, rate-limit bucket, idempotency key, allowlist cache entry, and log line is scoped by `account_id`. Shared-store keys are namespaced (`idem:{account}:…`, `rl:{account}`) to prevent cross-tenant collision or replay (reference §4.7). For APNs, connection pools are per-team (§6.1).
- **Network posture.** `/metrics` and `/readyz` bound to the internal/scrape network; only `/v1/*` and `/healthz` are public. `relayctl` reaches Postgres over a private network with a privileged DSN held by operators only — there is no public admin API (§5.6).
- **Input hardening.** `DisallowUnknownFields`, body size cap (e.g. 16 KiB — far above the ~1 KB legitimate body), explicit server timeouts (`ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes`) since the zero value means no timeout (reference §3.3). Graceful shutdown via `srv.Shutdown(ctx)` on SIGTERM.
- **Egress / SSRF posture.** Both upstream hosts are **hardcoded constants** — `api.push.apple.com` / `api.sandbox.push.apple.com` (APNs) and `fcm.googleapis.com` (FCM) — **never derived from any request field**. The only request-derived value interpolated into an upstream URL is the FCM `project_id` (into `/v1/projects/{project_id}/messages:send`); it is **allowlist-checked** (§8.4) **and** validated against a strict charset **before** allowlist lookup and URL construction: `project_id` and `package_name` against `^[a-z0-9-]+$` / reverse-DNS, `topic` against the APNs reverse-DNS shape. This ensures an allowlist row can never introduce a path-traversal/host-override and the host can never be templated from caller input (SSRF/path-injection prevention).
- **Anti-brute-force** on auth failures per resolved client IP, with trusted-proxy IP resolution (§8.2).
---
## 12. Privacy Requirements
The relay's privacy guarantee is **structural**, not policy-based: the request schema has no field that can carry content, and unknown fields are rejected.
- **Content-free guarantee.** The send-endpoint structs contain only the fields in §5.1/§5.2. There is **no** title, body, image URL, media ID, username, server hostname, server URL, library/collection/item name, or watched-state field (`02`/`03` privacy requirements). The relay-built payloads use only generic localization keys (`SILO_NOTIFICATION_TITLE` / `SILO_NOTIFICATION_GENERIC_BODY`) and opaque IDs.
- **Reject any unexpected field.** JSON decoding uses `DisallowUnknownFields`; any field not in the schema → `400 unexpected_field`. This is the enforcement point for the content-free guarantee — a caller (buggy or hostile) cannot smuggle content through an extra key.
- **Badge disabled by default.** `badge` is omitted unless explicitly provided, so unread counts don't leak through APNs/FCM by default (`02`/`03`).
- **FCM is data-only with a closed key set.** No top-level `notification` block is ever constructed, so Google carries no rendering content (`03`). The `message.data` map is a **fixed, closed set of keys** — exactly `{v, wake, server_device_id, delivery_id}` — with **no caller-controlled key or value** beyond the already-opaque IDs (§5.2). `badge` is **never** injected into `data` (it has no data-only home and is ignored on the FCM path — §5.2), so the data-map allowlist cannot be widened by a provided badge or any extra key.
- **No raw token in any store.** Neither the op-log nor the idempotency store holds a raw device token: the op-log carries only `token_hash` (SHA-256), and the idempotency value's `canonical_hash` is computed with the token replaced by `token_hash` (§10.2). The stateless-v1 "no token aliases" guarantee (NG1) holds across both stores.
- **Redacted logging.** Op-logs and structured logs **never** contain: raw device tokens (only `SHA-256` hash), notification content (none exists), the `Authorization` header, the relay JWT, OAuth2 tokens, or the raw API key (only the prefix). Full request payloads are never persisted (`02`/`03` "Relay logs do not persist full request payloads"). What **is** logged for triage: `request_id`, `account_id`, status code, caller `error.code`, upstream reason, `token_hash`, egress IP, latency (reference §4.6).
- **No identity storage.** The relay stores no user/profile identity, no device→user mapping, no server URL, and no token aliases (`00`).
---
## 13. Observability
### 13.1 Structured logs (with redaction)
`log/slog` JSON handler (reference §3.5). Every request emits one structured line through a redaction middleware that strips the disallowed fields (§12). Example (a rejected, allowlist-failed APNs send):
```json
{
"time": "2026-06-13T10:00:00Z",
"level": "WARN",
"msg": "send.apns rejected",
"request_id": "01JRELAY...",
"account_id": "01JACCOUNT...",
"provider": "apns",
"event": "send.apns",
"outcome": "rejected",
"status_code": 403,
"error_code": "topic_not_allowed",
"token_hash": "9f86d081...c2b5",
"egress_ip": "203.0.113.7",
"latency_ms": 2
}
```
Redaction is implemented in middleware so no code path can log a raw token, the bearer header, the JWT, or an OAuth2 token. The redacted audit subset also lands in `relay_op_logs` (§8.1) for queryable history. **Op-log retention:** successful sends **>14 days** are deleted; failures/upstream errors **>90 days** are deleted (mirrors the `02`/`03` `push_delivery_attempts` retention guidance). At 50k/day/account this table grows unbounded without active GC, so retention is a **shipped deliverable, not a deferral** — enforced by a scheduled sweeper / `relayctl logs --gc` (specified in `01-implementation-plan.md` Phase 5), with a disk-usage / table-growth alert. Time-partitioning of `relay_op_logs` is the recommended store strategy; see §15 #8.
### 13.2 Metrics catalog (Prometheus, `/metrics`)
`prometheus/client_golang` (reference §3.5). Labels avoid high-cardinality fields (no token, no `request_id`).
| Metric | Type | Labels | Meaning |
|---|---|---|---|
| `relay_requests_total` | counter | `provider`, `endpoint`, `status_code`, `error_code` | All inbound send requests by outcome. |
| `relay_request_duration_seconds` | histogram | `provider`, `endpoint` | End-to-end relay latency. |
| `relay_upstream_requests_total` | counter | `provider`, `upstream_status`, `upstream_reason` | APNs/FCM calls by upstream result. |
| `relay_upstream_duration_seconds` | histogram | `provider` | Upstream call latency. |
| `relay_auth_failures_total` | counter | `reason` (`bad_format`,`unknown_prefix`,`hash_mismatch`,`revoked`,`expired`,`account_disabled`) | Auth rejections. **`/metrics` is internal/scrape-only** — the `reason` split (which distinguishes `unknown_prefix` from `hash_mismatch`) must **never** be exposed to a tenant or untrusted scraper, or it becomes a key-enumeration oracle paired with the constant-time auth path (§8.2). |
| `relay_rate_limited_total` | counter | `account_id`(low-card, hashed/bucketed if needed), `kind` (`account`,`token`) | 429s emitted. |
| `relay_rate_limit_degraded_total` | counter | — | Fail-open events when Redis is down (§9.4). |
| `relay_idempotency_total` | counter | `result` (`first`,`replay`,`conflict`,`reuse`) | Idempotency decisions. |
| `relay_apns_jwt_refresh_total` | counter | `result` (`scheduled`,`expired_retry`,`throttled`) | JWT lifecycle (§6.1). |
| `relay_fcm_token_refresh_total` | counter | `result` | OAuth2 token refreshes (§7.1). |
| `relay_redis_up` / `relay_pg_up` | gauge | — | Core dependency health (drives `/readyz` — §5.4). |
| `relay_provider_healthy` | gauge | `provider` | Per-provider init/circuit-breaker health (`1` healthy / `0` degraded) — does **not** gate `/readyz` (§5.4). |
| `relay_provider_credential_healthy` | gauge | `provider` | Synthetic credential-validation result (§13.4); `0` triggers the credential alert. |
| `relay_fcm_project_quota_headroom` | gauge | `project_id` | Remaining headroom against the shared FCM project quota (§9.5). |
| `relay_daily_quota_used_ratio` | gauge | `account_id`(low-card/bucketed) | Fraction of the 50k/day cap consumed; alerts ≥80% (§9.2). |
| `relay_connection_severed_total` | counter | `provider` | Sends mapped to `503` by GOAWAY/connection reset before a final response (§6.4). |
| `relay_authfail_limiter_degraded_total` | counter | — | Per-IP auth-failure limiter fail-open events (§8.2). |
### 13.3 Admin diagnostics
`relayctl` provides read paths for operators (no raw secrets/tokens): `account list`, `key list`, `allowlist show`, and a `relayctl logs --account <id> [--since …] [--outcome rejected]` that queries `relay_op_logs` (redacted by construction). A `relayctl ping-upstream --provider apns|fcm --env sandbox` performs a credential-only health check (JWT sign / token mint, optionally an FCM `validateOnly:true` send) without sending real content — useful after a credential rotation. The same check captures the sandbox `apns-unique-id` (§5.1) when APNs returns one.
### 13.4 Credential health, validation and rotation alerting
Credentials can be revoked or rotated **out from under the relay** at any time — Apple "actively monitors and revokes leaked `.p8` keys" (`00`), and FCM SA keys / Workload-Identity bindings can be disabled. Relying only on production sends starting to fail (upstream `403 InvalidProviderToken` / the relay's own `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED`) is too late. The relay therefore validates credentials **proactively**, distinguishing an **auth-layer** failure from a mere token-layer rejection:
- **Startup + periodic synthetic validation.** On startup and on a periodic timer, the relay exercises the auth layer end-to-end: an FCM `validateOnly:true` send, and an APNs send to a **known-bad token** that still forces APNs to authenticate the JWT (so an auth-layer `403 InvalidProviderToken`/`BadCertificate*` is distinguishable from a token-layer `400 BadDeviceToken` — the latter proves the credential is *accepted*). Note `/readyz` alone is **insufficient**: it only proves a JWT was **signed locally** / a token **minted**, not that APNs/FCM **accept** the credential.
- **`credential healthy` gauge.** Wire the synthetic-validation result into a per-provider `relay_provider_credential_healthy` gauge (§13.2) and surface it on the per-provider readiness signal (§5.4).
- **Continuous health via cron.** Schedule `relayctl ping-upstream` (e.g. a cron / scheduled run) for continuous credential health between restarts.
- **Alert on first auth failure.** Alert on the **first** `upstream_auth_error` (the relay's own credential signals — §6.4/§7.4), not after a sustained failure rate, since a revoked credential fails *all* sends immediately.
---
## 14. Deployment and Operations
### 14.1 Public endpoint & TLS
- One public hostname, `relay.silo.app`, terminating TLS (1.2+, prefer 1.3) at a load balancer or in-process. HTTP/2 end to end. Only `/v1/apple/send`, `/v1/fcm/send`, and `/healthz` are public; `/readyz` and `/metrics` are internal-only.
- Set explicit server timeouts and graceful shutdown (§11).
### 14.2 High availability — "relay-down delays, never loses"
The relay being down does **not** lose pushes: the self-hosted server's dispatch is an **outbox**. The fanout transaction in `../01-release-events-and-inbox.md` inserts `pending` `push_delivery_attempts` rows in the same transaction as the `notification_deliveries` row; the dispatcher claims them post-commit and a recovery sweeper re-claims stale `pending` rows (`00`/`02`/`03`). So if the relay returns `503`/times out, the caller marks the attempt `retrying` and retries with backoff — the durable inbox row and the outbox attempt row guarantee eventual delivery. **This means the relay can be operated with ordinary HA (multiple stateless replicas, rolling deploys) rather than five-nines durability.** A short relay outage only delays pushes.
- **Stateless replicas.** No in-memory request state that can't be reconstructed; idempotency and rate-limit state are in Redis, shared across replicas. Scale horizontally behind the LB.
- **Rolling deploys / kill-safety.** A replica can be drained (`srv.Shutdown`) or killed mid-request; the caller retries the same idempotency key, which either replays the recorded result or re-sends at-most-once (§10).
- **Drain ordering (no orphaned idempotency locks).** A clean drain must not turn into a 30 s stall per retry. On `SIGTERM`: (1) flip `/readyz` to `503` so the LB **stops routing new requests** to this replica; (2) keep serving **in-flight** handlers; (3) bound the `srv.Shutdown` grace period to **less than the idempotency `lock_ttl`** (§10.2) — and/or **release/downgrade the in-flight idempotency marker on handler context-cancellation** — so that a send killed mid-flight does **not** leave a stale 30 s `in_flight` marker in Redis. Otherwise the caller's immediate retry hits §10.2 case 4 and gets `409 idempotency_conflict` for up to `lock_ttl` seconds. The relationship between the shutdown grace period and `lock_ttl` is load-bearing and is asserted by a drain test (no orphaned in-flight markers after a rolling restart — see `01-implementation-plan.md`).
### 14.3 Secret provisioning
- Provision the APNs `.p8` (+ Team ID, Key ID) and FCM SA JSON (or Workload Identity binding) into the secret manager out-of-band. The HMAC pepper and DB/Redis creds likewise. Never in the image or repo (§11).
- **Credential rotation runbook (overlap window, dual-key).** Apple permits **multiple active `.p8` keys per team**, and the relay already keys connection pools by team (§6.1), so rotation is **make-before-break**: (1) provision the new credential into the secret manager alongside the old; (2) load both — the relay supports an **overlap window** where old and new keys are both valid and the key-selection map (§6.1) can resolve to either; (3) run `relayctl ping-upstream` / the synthetic validation (§13.4) to confirm the **new** credential authenticates against APNs/FCM; (4) cut sends over to the new key (rolling restart picks up the new secret); (5) retire/revoke the old credential only after the new one is confirmed healthy. The same overlap pattern applies to FCM SA keys / Workload-Identity bindings. This avoids the failure mode where a single-key rotation revokes the old credential before the new one is proven, taking all pushes down.
### 14.4 Sandbox vs production
- The relay supports both APNs environments via the request `environment` field, routing to `api.push.apple.com` (production) or `api.sandbox.push.apple.com` (sandbox) (§6.2). FCM has a single global host (no sandbox split — reference §2.3); test sends use `validateOnly:true` (reference §2.5).
- A **staging relay deployment** (separate hostname, separate accounts) is recommended for client QA so test traffic never touches production accounts/quotas.
### 14.5 Scaling & regions
- **Scaling.** One well-tuned APNs HTTP/2 connection handles thousands of pushes/sec (reference §1.3/§3.1); one FCM token source serves the project. The bottleneck at relay scale is inbound throughput and Redis, both horizontally scalable. Keep upstream clients **process-singletons** (one per env/team/project), not per-request.
- **Regions.** APNs and FCM are global; FCM has no data-residency send endpoints (reference §2.3). v1 runs a single region close to the operator's Postgres/Redis. Multi-region is possible later (the request path is stateless; Redis/Postgres would need regional strategy) but is out of scope for v1.
- **Quota awareness.** FCM's default project quota is ~600k messages/min (reference §2.7); the relay's per-account 50k/day default (§9.2) is far under that even summed across many accounts. The **shared** project quota is nonetheless guarded by the relay-side global limiter / circuit breaker (§9.5) so one noisy account cannot exhaust it for all tenants; operators monitor `relay_fcm_project_quota_headroom` (§13.2), the project quota in Cloud Console, and `relay_upstream_requests_total{upstream_reason="QUOTA_EXCEEDED"}`.
---
## 15. Open Questions
1. **Per-account rate limits — real numbers.** v1 ships the `00`-suggested ~10 req/s burst + 50k/day defaults (§9.2); tune from production logs. Should limits become per-account columns on `relay_accounts` with `relayctl quota set`? (Ties to `00` open question #1.)
2. **APNs sandbox hostname (resolved — note only).** `api.sandbox.push.apple.com` and `api.development.push.apple.com` are **interchangeable, both-resolving** Apple sandbox aliases; the relay standardizes on `api.sandbox.push.apple.com` (reference §1.3, reference open question §5 item 4). The external contract (`"environment":"sandbox"`) is unaffected either way. No build-time decision required.
3. **`apns2` vs hand-rolled APNs client.** `sideshow/apns2` has had no release since v0.25.0 (Oct 2024) — bus-factor risk. Decide before implementation: adopt `apns2` or hand-roll `net/http2` + `golang-jwt/jwt/v5` (reference §3.1, §5.2).
4. **FCM credentials: GCP-keyless vs off-GCP JSON.** If the relay runs on GCP, use Workload Identity / attached SA (no downloadable key); off-GCP, the SA JSON lives in the secret manager. Resolve in the deployment design (reference §4.5 / §2.2).
5. **`apns-expiration` / `android.ttl` default values (resolved; tune from product input).** v1 **ships finite defaults** — `apns-expiration` ~4 h for `private_alert` / ~1 h for `background_wake`, and the parallel FCM `android.ttl` (§5.1/§5.2) — rather than the 30-day APNs / 4-week FCM store-and-retry, so stale wakes don't fire days later. The exact durations are configurable and may be tuned with product input (reference §1.4).
6. **API-key hashing: HMAC pepper vs plain salted SHA-256.** This spec chooses HMAC-SHA-256 with a secret-manager pepper for defense-in-depth. Pepper rotation is operationally heavier (re-hash all keys or support dual peppers). Confirm the tradeoff (reference §4.1, open question §12).
7. **Idempotency-Key spec status.** The IETF draft expired 2026-04-18 without becoming an RFC; the header name + Stripe-style semantics are de-facto standard. Re-check for an RFC before relying on its exact status-code semantics (reference §4.3).
8. **Op-log volume & retention.** At 50k/day/account across many accounts, `relay_op_logs` grows unbounded without GC. v1 **ships** the retention sweep (delete success >14 d, failure >90 d — §13.1) as a scheduled `relayctl logs --gc` / built-in sweeper (`01-implementation-plan.md` Phase 5), with a table-growth alert. Open: whether to **time-partition** `relay_op_logs` (recommended) or offload to a log pipeline rather than Postgres at higher volume.
9. **Multi-region.** Out of scope for v1 (single region). If latency to distant self-hosters matters, design a regional Redis/Postgres strategy for the otherwise-stateless request path.
10. **FCM priority deprioritization.** The relay honors `03`'s `priority: high` for `private_data`. If FCM deprioritizes Silo's app-instance traffic over the 7-day window (reference §2.8), is any relay-side change warranted, or is this purely the caller's `priority: normal` fallback concern? Lean: caller's concern; revisit if metrics show systematic deprioritization.
@@ -0,0 +1,738 @@
# Silo Push Relay — Implementation Plan
**Date:** 2026-06-13
**Status:** Draft (implementation-grade, phased)
**Service:** `silo-push-relay` (provisional repo name; the relay code lives in a **separate repository**)
**Provisional hostname:** `relay.silo.app`
**Stack:** Go 1.25+, PostgreSQL, Redis
**Source documents (read before executing any phase):**
- [`./00-relay-spec.md`](./00-relay-spec.md) — the engineering spec this plan implements. Section numbers below (e.g. "spec §6.4") refer to it.
- [`./02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md) — 2026-current Apple/Google/Go reference (cited as "reference §N").
- [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md) — the **authoritative external wire contract** (the `02`/`03` request/response shapes). The relay must honor these byte-for-byte.
> **Commands assume the repository root is the cwd.** This document contains no local absolute filesystem paths or transient worktree IDs; all repo references are repository-relative.
> **Repo boundary.** This plan describes a **separate repository** (`silo-push-relay`). The design docs are authored here in `silo-server` purely for co-location with the `02`/`03` contracts. The relay **code** lives elsewhere. The one deliberate coupling is the shared `pushwire` payload-builder package, mirrored into both repos (see §3 and Phase 3/4).
---
## 1. Overview
### 1.1 What is being built
A small, **stateless-on-the-request-path** Go HTTP service that holds the official Silo Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials, accepts authenticated **content-free** push requests from opted-in self-hosted Silo servers on two endpoints, builds a fixed generic APNs/FCM payload, forwards it upstream, and maps the upstream result back to a narrow caller-facing response.
The two send endpoints and their contract are fixed by `02`/`03` and reproduced in spec §5:
| Endpoint | Purpose | Authoritative contract |
|---|---|---|
| `POST /v1/apple/send` | Forward one opaque push to APNs for one device token | `../02-apns-relay.md`, spec §5.1 |
| `POST /v1/fcm/send` | Forward one opaque data-only push to FCM for one device token | `../03-fcm-relay.md`, spec §5.2 |
| `GET /healthz` | Liveness (public) | spec §5.3 |
| `GET /readyz` | Readiness incl. PG/Redis core deps + per-provider credential health (internal) | spec §5.4 |
| `GET /metrics` | Prometheus exposition (internal) | endpoint spec §5.4; metric catalog §13.2 |
Administration is a CLI (`relayctl`) that writes directly to the relay's PostgreSQL — **there is no public admin HTTP API** in v1 (spec §5.6).
### 1.2 The v1 cut line
**In scope (v1):**
- `POST /v1/apple/send` and `POST /v1/fcm/send` exactly per `02`/`03`.
- Bearer auth (`rk_` keys), per-account allowlists, Redis token-bucket rate limiting, Redis idempotency.
- APNs token-based (ES256 `.p8`) client with cached JWT; FCM HTTP v1 client with cached OAuth2 token.
- PostgreSQL storage of accounts/keys/allowlists/redacted op-logs only; Goose-style migrations.
- `relayctl` admin CLI; structured redacted logs; Prometheus metrics; `/healthz` + `/readyz`; graceful shutdown.
- Single region, multiple stateless replicas behind a TLS load balancer.
**Explicitly out of scope (v1)** — from spec §2.2 / NG1–NG7:
- Token→user aliases, device subscriptions, any user/profile identity (stateless v1).
- Arbitrary APNs/FCM JSON, custom titles/bodies, topic/condition broadcasts, badge-by-default.
- Delivery receipts, analytics, open-tracking.
- APNs broadcast / Live Activity channels (`/4/broadcasts/...`).
- Web Push, HMS, ADM, or any non-APNs/non-FCM transport.
- A public self-service signup / billing surface.
- The `custom_apns` / `custom_fcm` direct paths (those run inside `silo-server`).
- Multi-region (the request path is stateless, but Redis/Postgres regional strategy is deferred).
### 1.3 The real long pole: provider account provisioning
**Apple/Firebase account provisioning is the critical-path dependency, not the code.** Obtaining the official Apple Developer team, generating a `.p8` APNs auth key (Key ID + Team ID), registering per-platform bundle topics, standing up the Firebase project(s), and minting a service-account JSON can take days-to-weeks of organizational/approval lead time and is **fully decoupled from coding**.
> **Action: start the §7 provisioning checklist on day 1, in parallel with Phase 0.** Phases 0–2 (scaffold, storage, auth/rate-limit/idempotency) need **no** live provider credentials and can complete entirely against fakes and contract tests. Only Phase 3 (APNs integration tests against sandbox) and Phase 4 (FCM `validateOnly`) are gated on credentials. If provisioning slips, the code still reaches "everything but live upstream" without blocking.
```mermaid
flowchart LR
subgraph Track A — Code
P0[Phase 0\nscaffold] --> P1[Phase 1\nstorage] --> P2[Phase 2\nauth/RL/idem]
P2 --> P3[Phase 3\nAPNs]
P2 --> P4[Phase 4\nFCM]
P3 --> P5[Phase 5\nobservability]
P4 --> P5
P5 --> P6[Phase 6\nharden+deploy]
end
subgraph Track B — Provisioning (parallel, long pole)
A1[Apple team + .p8 + topics]
G1[Firebase project + SA JSON + package]
end
A1 -. gates .-> P3
G1 -. gates .-> P4
A1 -. gates .-> P6
G1 -. gates .-> P6
```
---
## 2. Repository Bootstrap
### 2.1 Go module path
```
module github.com/silo-app/silo-push-relay
```
Target **Go 1.25+** (required by `jackc/pgx v5.10` and `firebase.google.com/go/v4 v4.20`; reference §3 version-targeting note). Pin in `go.mod` with `go 1.25`.
### 2.2 Directory layout
```
silo-push-relay/
├── cmd/
│ ├── relay/ # main HTTP service entrypoint (wires config → stores → clients → server)
│ │ └── main.go
│ └── relayctl/ # admin CLI (account/key/allowlist/logs/ping-upstream); writes directly to PG
│ └── main.go
├── internal/
│ ├── config/ # env + secret-manager config loading, validation, defaults
│ ├── httpapi/ # router, handlers, middleware (auth, rate limit, idempotency, redaction), error bodies
│ ├── apns/ # APNs upstream client: ES256 JWT lifecycle, HTTP/2 pool, reason→error mapping
│ ├── fcm/ # FCM upstream client: OAuth2 token source, HTTP/2 client, status→error mapping
│ ├── accounts/ # data-access for accounts/keys/allowlists (shared by httpapi + relayctl)
│ ├── ratelimit/ # Redis Lua token-bucket limiter (per-account + coarse per-token)
│ ├── idempotency/ # Redis SET NX lock / replay / 409 / 422 store
│ ├── pushwire/ # SHARED payload builders (APNs + FCM payload/header construction) — mirrored into silo-server
│ ├── oplog/ # redacted op-log writer (PG) + token hashing helper
│ ├── observability/ # slog JSON handler + redaction, Prometheus registry/metrics, /healthz /readyz /metrics
│ └── store/ # pgxpool + go-redis bootstrapping, health pings, migration runner hook
├── migrations/
│ └── sql/ # Goose-style timestamped SQL migrations (see §2.6)
├── deploy/
│ ├── Dockerfile
│ └── (k8s / compose manifests as applicable)
├── Makefile
├── go.mod
├── go.sum
├── .golangci.yml
├── .github/workflows/ci.yml # or equivalent CI config
└── README.md
```
Package ownership mirrors the Silo convention (CLAUDE.md "keep new code in the package that owns the behavior"): no catch-all `utils` package; shared payload logic lives in `pushwire`; shared data-access lives in `accounts`/`store` so `relayctl` and `httpapi` share schema and constraints (spec §5.6 "The CLI shares the relay's data-access package").
### 2.3 The shared `pushwire` package (maintainability win)
`internal/pushwire` is the single source of truth for **how an opaque request becomes an upstream payload + headers** — the APNs `aps`/`silo` dictionaries and `apns-*` headers (spec §5.1), and the FCM data-only `message` body + `android` config (spec §5.2). It is intentionally **dependency-light** (pure Go structs + JSON, no network) so it can be **mirrored into `silo-server`** under its `custom_apns` / `custom_fcm` paths, where the self-hosted server builds the *same* payloads when an admin uses their own credentials. One package, two repos: the relay and the direct-credential path can never drift on payload shape. (See §3 dependency table and Phase 3/4.)
> **Mirroring mechanism & sync runbook (drift/ownership hazard — make it explicit).** Keep `pushwire` import-cycle-free and free of relay-only types (no DB, no config). **Designate one repo as the canonical source of truth** for `pushwire` (the relay repo `silo-push-relay`, since it owns the upstream payload contract). The copy in `silo-server` is a **checked-in generated artifact**, not a hand-edited file: commit a **content checksum** of the canonical package alongside it, and have **both** repos' CI verify their local copy against that checksum (a copied package in repo A cannot fail repo B's CI on its own, so a passive golden-file test in one repo is insufficient). Bump a **versioned payload-schema constant** on any payload change so a mismatch is unambiguous. Add a golden-file + closed-allowlist-key contract test on both sides (Phase 3/4 / §5.2). Document the ownership/sync runbook (who regenerates, in what order, how the checksum is bumped) so a payload change cannot silently diverge until the other repo happens to update. The preferred end state is a small shared module both repos import rather than a manual copy.
### 2.4 Config strategy
Twelve-factor-ish, but credentials come from a **secret manager**, not env vars (spec §11, reference §4.5 ranking: secret-manager > mounted file > env var > in-image). `internal/config` resolves, in order of preference per secret:
| Config | Source (preferred → fallback) | Notes |
|---|---|---|
| APNs `.p8` private key, Key ID, Team ID | secret manager → mounted file | never env var, never in image |
| FCM SA JSON **or** Workload Identity binding | GCP attached SA / Workload Identity → secret manager JSON | off-GCP uses JSON; on-GCP keyless (reference §4.5) |
| API-key HMAC pepper | secret manager | rotation is heavier (spec open Q#6) |
| Postgres DSN, Redis URL | secret manager → env | pool-tuned |
| Non-secret tuning (timeouts, rate defaults, listen addrs, log level, environment label) | env vars / flags | safe to log |
`config.Load(ctx)` returns a validated, fully-populated struct or a hard error; the process refuses to start with a missing/invalid credential. Provide a `config.Validate()` that `cmd/relay` calls before opening any listener. Non-secret defaults (rate limits from spec §9.2, timeouts from spec §11) live as constants with env overrides.
### 2.5 Makefile (mirror Silo's Makefile-driven workflow)
`make` targets, modeled on `silo-server`'s Makefile (CLAUDE.md §Build):
```make
make build # go build ./cmd/relay ./cmd/relayctl
make run # run cmd/relay locally against docker-compose PG+Redis
make test # go test ./... (unit + contract; no live providers)
make test-integration # go test -tags=integration ./... (APNs sandbox + FCM validateOnly; needs creds)
make lint # golangci-lint run
make fmt # gofmt -w + goimports
make migrate-create NAME=add_thing # timestamped Goose migration scaffold
make migrate-up # apply migrations
make migrate-status # list migration state
make relayctl ARGS="account list" # build+run the admin CLI
make docker # build deploy/Dockerfile
make loadtest # run the k6/vegeta burst scenario (Phase 6)
```
`make migrate-create NAME=...` must produce a **timestamped** filename (CLAUDE.md: "New migrations must use timestamped filenames created with `make migrate-create`; do not run `goose fix` or create paired `.up.sql`/`.down.sql` files"). The relay uses single-file Goose SQL migrations with `-- +goose Up` / `-- +goose Down`.
### 2.6 Migrations (Goose-style)
`migrations/sql/<timestamp>_<name>.sql`, run by a Goose runner wired in `internal/store` and invokable via `make migrate-up` and on `cmd/relay` startup (behind a flag). The v1 schema is exactly spec §8.1 (`relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`). IDs are ULIDs stored as `text`; timestamps are `timestamptz` (Silo convention).
### 2.7 CI outline
CI (GitHub Actions or equivalent) runs on every push/PR:
```yaml
jobs:
build-test:
steps:
- setup-go 1.25
- make lint # golangci-lint
- make build
- make test # unit + contract tests (no provider creds)
- upload coverage
migrate-check:
services: [postgres, redis]
steps:
- make migrate-up # migrations apply cleanly on a fresh DB
- make migrate-status
integration: # gated: only when provider creds are available (Phase 3+)
if: secrets.APNS_P8 != '' && secrets.FCM_SA_JSON != ''
services: [postgres, redis]
steps:
- make test-integration # APNs sandbox + FCM validateOnly contract tests
```
The `integration` job is **conditional on secrets being present** so Phases 0–2 are green long before any credential exists.
---
## 3. Dependency Choices
All versions are from the reference §3.7 pinned list. Pin exact versions in `go.mod`; bump deliberately.
| Concern | Library | Version | One-line rationale |
|---|---|---|---|
| APNs client | `github.com/sideshow/apns2` **or** hand-rolled | `v0.25.0` / `x/net/http2 v0.56.0` + `golang-jwt/jwt/v5 v5.3.1` | De-facto Go APNs HTTP/2 client (auto JWT + conn reuse); **decide vs. hand-rolled before Phase 3** because `apns2` has had no release since Oct 2024 (bus-factor; reference §3.1, spec open Q#3). |
| FCM auth | `golang.org/x/oauth2/google` | `v0.36.0` | `CredentialsFromJSONWithType(..., google.ServiceAccount, firebase.messaging scope)` gives an auto-caching/-refreshing `TokenSource`; lighter than the Admin SDK and gives direct control of conn reuse + error classification (reference §3.2 — preferred for a fixed payload). |
| FCM (alt, batteries-included) | `firebase.google.com/go/v4/messaging` | `v4.20.0` | Acceptable alternative with `IsUnregistered`/`IsQuotaExceeded` helpers; **pick exactly one** FCM path, not both (reference §3.2). Plan defaults to the `x/oauth2` path. |
| JWT (ES256 sign for APNs; RS256 for hand-rolled FCM grant) | `github.com/golang-jwt/jwt/v5` | `v5.3.1` | Maintained JWT lib; ES256 for APNs, RS256 if hand-rolling the FCM token exchange (reference §3.1). |
| HTTP/2 transport tuning | `golang.org/x/net/http2` | `v0.56.0` | `ReadIdleTimeout`+`PingTimeout` to reap half-open upstream conns — "the single most important tuning for a long-lived relay" (reference §3.3). |
| Postgres | `github.com/jackc/pgx/v5` + `pgxpool` | `v5.10.0` | Recommended driver/pool; `pgxpool.New` is concurrency-safe + health-checked (reference §3.4). |
| Redis | `github.com/redis/go-redis/v9` | `v9.20.1` | Context-first client with pooling; atomic Lua for rate-limit, `SET NX` for idempotency (reference §3.4). |
| Router | stdlib `net/http` (Go 1.22+ `http.ServeMux` patterns) | stdlib | Two routes + health/metrics need no framework; stdlib `ServeMux` supports method+path patterns and gets HTTP/2 over TLS automatically. Avoids a dependency; middleware is plain `http.Handler` wrapping. |
| Structured logging | `log/slog` | stdlib (Go 1.21+) | `slog.NewJSONHandler` for production; redaction middleware so secrets can't leak (reference §3.5, spec §13.1). |
| Metrics | `github.com/prometheus/client_golang` | `v1.23.2` | Custom registry + `promhttp.HandlerFor` for `/metrics`; per-provider latency histograms + outcome counters (reference §3.5, spec §13.2). |
| Per-instance rate (outbound pacing helper) | `golang.org/x/time/rate` | current | `Reservation.Delay()` to compute relative `Retry-After`; used for in-process pacing, **not** the cross-replica cap (that is Redis — reference §4.2). |
| ULID generation | `github.com/oklog/ulid/v2` (or equivalent) | current | Generate `request_id` and entity IDs as ULIDs (Silo convention; spec §8.1). |
**Decision to make before Phase 3 (spec open Q#3):** adopt `sideshow/apns2` or hand-roll the APNs client. Recommendation: prototype both in Phase 3 behind the `internal/apns` interface; pick `apns2` if its recent commit/issue activity is acceptable, else hand-roll on `x/net/http2` + `golang-jwt/jwt/v5` mirroring the verified `apns2` internals (reference §3.1: `HostProduction`/`HostDevelopment`, `TokenTimeout=3000`s, `ReadIdleTimeout=15s`). Either way the rest of the service depends only on the `internal/apns` interface, not the concrete client.
**`pushwire` maintainability note (reference + spec §5).** The shared `pushwire` package builds the exact APNs/FCM payloads and headers. Because `silo-server`'s `custom_apns`/`custom_fcm` paths must produce identical payloads, `pushwire` is mirrored into `silo-server` with **one canonical source repo** (the relay), a **committed checksum verified in CI on both sides**, and a **closed-allowlist-key** test so an added `data`/payload key fails CI regardless of golden-file regeneration order (§2.3 sync runbook, §5.2). This is also a **supply-chain control for the content-free guarantee**: the payload-builder that enforces "data-only, no `notification` block, fixed key set" is enforced in two repos that must not drift. Payload shape is defined once.
---
## 4. Phased Tasks
Effort sizes: **S** ≈ 1 day, **M** ≈ 2–3 days, **L** ≈ 4–5 days (one engineer). Sizes are rough.
### Phase 0 — Scaffold, config, health, CI (size: M)
**Goal.** A buildable, lintable, testable repo that boots `cmd/relay`, serves `/healthz`, loads config, and is green in CI — with **no** provider dependency.
**Files to create/modify.**
- `go.mod`, `go.sum`, `.golangci.yml`, `Makefile`, `README.md`, `.github/workflows/ci.yml`, `deploy/Dockerfile`.
- `cmd/relay/main.go` — load config, build slog JSON logger, start an `http.Server` with explicit timeouts and graceful shutdown, mount `/healthz`.
- `internal/config/config.go` — `Load(ctx)`, `Validate()`, defaults; secret-manager interface stub (real wiring in Phase 6).
- `internal/observability/logger.go` — `slog.NewJSONHandler` setup.
- `internal/httpapi/server.go`, `internal/httpapi/router.go`, `internal/httpapi/health.go` — router, `/healthz`.
- `internal/httpapi/errors.go` — the standard error body (spec §5.5) and a `writeError(w, status, code, msg, requestID)` helper.
**Implementation notes.**
- Server timeouts are **mandatory** (zero = no timeout): `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes` (reference §3.3, spec §11). Body size cap ~16 KiB.
- Graceful shutdown: on SIGTERM/SIGINT call `srv.Shutdown(ctx)` with a bounded context (spec §14.2).
- `/healthz` returns `200 {"status":"ok"}`, unauthenticated, no dependency checks (spec §5.3).
- Error body shape is fixed (spec §5.5):
```json
{ "error": { "code": "string_code", "message": "human readable", "request_id": "01JRELAY..." } }
```
Every request gets a ULID `request_id`, echoed as the `X-Request-Id` response header.
**Tests.**
- Unit: `config.Validate` rejects missing required fields; `writeError` emits exact JSON + `X-Request-Id`.
- Unit: `/healthz` returns 200 with the exact body.
- CI: `make lint`, `make build`, `make test` green; `migrate-check` job present (no migrations yet → no-op pass).
**Acceptance criteria.**
- [ ] `make build` produces `relay` and `relayctl` binaries.
- [ ] `make lint` and `make test` pass in CI with zero provider credentials.
- [ ] `cmd/relay` boots, serves `/healthz` 200, and shuts down gracefully on SIGTERM.
- [ ] Every response carries `X-Request-Id`; errors use the §5.5 shape.
---
### Phase 1 — Storage, migrations, `relayctl` admin CLI (size: L)
**Goal.** The full PostgreSQL schema, a migration runner, the shared data-access package, and a working `relayctl` that can provision accounts/keys/allowlists and read op-logs — all without touching providers.
**Files to create/modify.**
- `migrations/sql/<ts>_init_relay_schema.sql` — exact schema from spec §8.1: `relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`, plus indexes (`relay_api_keys_prefix_uidx`, `relay_api_keys_account_idx`, `relay_op_logs_account_time_idx`, `relay_op_logs_time_idx`).
- `internal/store/postgres.go` — `pgxpool.New(ctx, dsn)`, `Ping`, migration runner hook.
- `internal/store/redis.go` — `go-redis` client + `PING` (used Phase 2).
- `internal/accounts/` — data-access: `CreateAccount`, `ListAccounts`, `DisableAccount`, `IssueKey`, `ListKeys`, `RevokeKey`, `LookupKeyByPrefix`, `SetApnsAllowlist`, `SetFcmAllowlist`, `GetAllowlists`. Used by both `httpapi` and `relayctl`.
- `internal/accounts/keys.go` — key generation + hashing (see notes).
- `internal/oplog/oplog.go` — `Write(ctx, entry)` to `relay_op_logs`; `TokenHash(token) string` (SHA-256 hex).
- `cmd/relayctl/main.go` + subcommands per spec §5.6 table.
**Implementation notes.**
- **API-key format & hashing** (spec §8.2, reference §4.1):
- Format `rk_<env>_<base62/base64url>`; env ∈ {`live`,`test`}. Random secret = 32 bytes from `crypto/rand`.
- **Prefix** = `rk_<env>_<first 6–8 chars of random>`, non-secret, stored cleartext, uniquely indexed.
- Store `key_hash = HMAC-SHA256(pepper, secret)` (32 bytes). Never store plaintext. Fast keyed hash is correct for 256-bit random keys (do **not** use argon2id here).
- `relayctl key issue` prints the full `rk_…` **exactly once** to stdout; only hash+prefix persist (spec §8.3).
- `relayctl` connects directly to Postgres with a privileged DSN held by operators (no public admin API; spec §5.6). Each mutating command writes an `admin.*` op-log with operator identity (OS user / `--actor`) and never the secret token (spec §5.6).
- Allowlist commands replace the set atomically (UPSERT/DELETE in one tx) for `relay_apns_allowlist` / `relay_fcm_allowlist`.
- Official allowlist config values to seed in examples/tests (allowlist config, **not** the contract — spec §8.4): APNs topics `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos`; FCM project `continuum-prod-android`, package `com.continuum.app.android`.
**`relayctl` command surface (spec §5.6 / §13.3).**
| Command | Effect |
|---|---|
| `relayctl account create --name <label> [--note <text>]` | INSERT `relay_accounts`; print `account_id` |
| `relayctl account list [--active]` | SELECT accounts + key count + last activity |
| `relayctl account disable --account <id>` | `status='disabled'` |
| `relayctl key issue --account <id> [--env live\|test] [--expires <dur>]` | INSERT key; print `rk_…` once |
| `relayctl key list --account <id>` | SELECT (prefix/created/last_used/revoked; never secret) |
| `relayctl key revoke --key-prefix <prefix>` | `revoked_at=now()` |
| `relayctl allowlist apns set --account <id> --topics a,b,c` | replace APNs allowlist |
| `relayctl allowlist fcm set --account <id> --project <p> --packages a,b` | replace FCM allowlist |
| `relayctl allowlist show --account <id>` | print effective allowlists |
| `relayctl logs --account <id> [--since …] [--outcome rejected]` | query `relay_op_logs` (redacted) |
| `relayctl logs --gc` | retention GC: delete success rows >14d, failure/reject rows >90d (Phase 5; cron-schedulable) |
| `relayctl ping-upstream --provider apns\|fcm --env sandbox` | credential-only health check; flips `relay_credential_healthy` (Phase 3/4/6) |
**Tests.**
- Integration (PG via docker-compose): `make migrate-up` applies cleanly on a fresh DB; `make migrate-status` lists state.
- Unit/integration: `IssueKey` → `LookupKeyByPrefix` round-trips; hash is HMAC-SHA256; plaintext never stored.
- Integration: full `relayctl` flow — create account → issue key → set allowlists → list/show; `admin.*` op-logs written without secrets.
- Unit: `oplog.TokenHash` is stable SHA-256 hex; op-log entry rejects/strips raw token.
**Acceptance criteria.**
- [ ] Migrations apply on a fresh DB and match spec §8.1 exactly (column names/types/constraints/indexes).
- [ ] `relayctl` can provision an account, issue a key (printed once), set both allowlists, and read redacted logs.
- [ ] No table stores tokens, content, or identity; `relay_op_logs` holds only `token_hash`, never raw tokens.
- [ ] Key hash is HMAC-SHA256(pepper, secret); constant-time compare verified in a unit test.
---
### Phase 2 — Auth middleware, rate limit, idempotency (size: L)
**Goal.** The full request-path middleware chain (auth → rate limit → idempotency → decode/validate/allowlist) wired in front of stub send handlers that return a deterministic fake upstream result. No real provider yet.
**Files to create/modify.**
- `internal/httpapi/middleware_auth.go` — bearer parse, prefix lookup, constant-time compare, revoked/expired/disabled rejection, throttled `last_used_at`, per-IP anti-brute-force.
- `internal/ratelimit/limiter.go` + `ratelimit/bucket.lua` — Redis Lua token bucket (per-account + coarse per-token).
- `internal/httpapi/middleware_ratelimit.go` — applies the limiter, emits `429` + `Retry-After`.
- `internal/idempotency/store.go` + `idempotency/idem.lua` (if needed) — `SET NX` lock, replay, 409, 422.
- `internal/httpapi/middleware_idempotency.go` — wraps send handlers.
- `internal/httpapi/decode.go` — `DisallowUnknownFields` decoder + field validation + allowlist enforcement.
- `internal/httpapi/apple.go`, `internal/httpapi/fcm.go` — handler skeletons returning a fake `accepted` result (real upstream in Phase 3/4).
**Implementation notes.**
- **Auth** (spec §8.2): parse `Authorization: Bearer rk_<env>_<rest>` (malformed → `401 unauthorized`); derive `key_prefix`; `SELECT … WHERE key_prefix=$1` (unique index); reject if no row / revoked / expired / account disabled (do not distinguish reasons to caller — all `401 unauthorized`); `subtle.ConstantTimeCompare(HMAC(pepper, presented), key_hash)`; throttle `last_used_at` to ≤1/min/key; rate-limit auth failures per resolved client IP (see client-IP resolution below).
- **Constant-time auth path (no enumeration oracle).** On a missing/revoked/expired row, **still** perform a dummy `HMAC-SHA256` against a fixed decoy hash with `subtle.ConstantTimeCompare` before returning the uniform `401`, so "unknown prefix" is not measurably faster than "known prefix, wrong secret." Keep the fine-grained failure reason (`unknown_prefix` vs `hash_mismatch`) in **internal** logs/metrics only — never in a response and never exposed to an untrusted `/metrics` scraper.
- **Client-IP resolution / trusted proxy (security-critical).** The relay sits behind a TLS load balancer, so Go's `http.Request.RemoteAddr` is the LB address, not the caller. Derive the client IP from `RemoteAddr` **unless** the immediate peer is in an **allowlisted trusted-proxy CIDR list** (a config value), in which case take the **right-most untrusted hop** from the LB-set forwarding header. **Caller-supplied `X-Forwarded-For`/`X-Real-IP` headers are ignored** — trusting them would let an attacker spread brute-force across unlimited fake IPs (defeating the per-IP throttle) and poison `egress_ip` in op-logs. Both the per-IP auth-failure limiter and the logged `egress_ip` consume this single resolved IP. Account for shared egress (CGNAT/VPN — multiple self-hosted servers behind one IP, anticipated in spec §3.4): make the brute-force lockout **soft** (added latency / `Retry-After`), not a hard block, so a noisy/compromised neighbor cannot lock out legitimate servers sharing the IP.
- **Bounded fail-open on Redis outage.** The per-account/daily rate limit is the primary cap on a stolen-key attacker's spend (and on shared downstream APNs/FCM quota). A naive fail-open removes that cap entirely during a Redis outage. Instead, when Redis is unreachable, fall back to a conservative **in-process** per-account limiter (`x/time/rate`) so each account is still capped to a small multiple of its normal rate; the allowlist (always enforced from Postgres) remains the hard boundary. A Redis outage pages ops; the cross-replica daily quota cannot be enforced during the outage (documented limitation).
- **Rate limit** (spec §9): Redis HASH `rl:{account_id} = {tokens, last_refill_ts}` with TTL, atomic Lua (refill→check→consume in one `EVAL`). Defaults (spec §9.2): per-account ~10 req/s burst, refill 10/s with burst capacity 20; per-account daily 50,000 req/day; coarse `(account_id, token_hash)` ~1 req/3s. Over limit → `429 rate_limited` with **relative** `Retry-After` (delay-seconds, never an absolute epoch — reference §4.2). On Redis down: **bounded fail-open** (in-process per-account fallback limiter, above — not unlimited), log `rate_limit.degraded`, increment `relay_rate_limit_degraded_total` (spec §9.4).
- **Daily quota mechanics.** Store the daily counter as a **separate Redis counter** `rl:day:{account_id}:{yyyymmdd}` with `INCR` + `EXPIRE` to the end of the window — it is **not** the per-second token bucket and **not** a Postgres column. Reset semantics: **fixed UTC calendar day** (`yyyymmdd` in UTC). During a Redis outage the daily counter, like the per-second bucket, falls back to the bounded in-process limiter and the precise cross-replica daily cap cannot be enforced (documented; pages ops). Emit `relay_rate_limited_total{kind="daily"}` and alert on accounts approaching/hitting the cap (a big-release burst can legitimately reach 50k/day; §6.4).
- **Project-level FCM circuit breaker (multi-tenant fairness).** The ~600k msgs/min FCM project quota (reference §2.7) is **shared across all relay accounts**, but the per-account limit is local to each account — one noisy account can exhaust the shared quota for everyone. Add a relay-side **global per-project outbound limiter / circuit breaker** that tracks aggregate FCM send rate against the project quota and **sheds load with `503 upstream_throttled` before hammering FCM** when near quota (the relay is the only aggregation point that can honor reference §2.7's anti-spike / smooth-traffic guidance — avoid sending within a 2-minute window of :00/:15/:30/:45). Export `relay_fcm_project_quota_headroom` and alert (§6.4).
- **Idempotency** (spec §10): require `Idempotency-Key` header on sends; missing → `400 missing_idempotency_key`. Length-limit ≤255 chars; the three-part `<delivery_id>:<device_id>:<attempt>` shape is **opaque** (not strictly parsed). Namespaced `idem:{account_id}:{key}`. `SET … NX EX <lock_ttl>` (`lock_ttl` ≈ 30s) in-flight lock:
- NX success → process; on completion overwrite with serialized `{status, body, upstream_id, canonical_hash}` + ~24h TTL (success **and** error recorded).
- NX fail + stored completed result → **replay** verbatim (same `apns_id`/`fcm_message_name`).
- NX fail + still in-flight → `409 idempotency_conflict`.
- Same key + different request payload (compare stored `canonical_hash`) → `422 idempotency_key_reuse`.
- **Fails closed**: Redis unavailable → `503 upstream_unavailable` (never risk a double-send; spec §10.2).
- **Lock-TTL invariant (no double-send / no clobber).** The upstream APNs/FCM call (plus HTTP/2 setup, JWT/OAuth refresh, GOAWAY reconnect) can otherwise outlast `lock_ttl`, letting a concurrent retry's NX succeed and fire a **second** upstream send. Pin the upstream call with a hard context deadline **strictly shorter than `lock_ttl`** (≈10s upstream vs 30s lock) and refuse to send upstream if the handler's own deadline has already passed the lock TTL. On completion, use a **compare-and-set** that only overwrites the stored value if it is still *this* request's in-flight marker, so a lock stolen by a concurrent request cannot have its result clobbered.
- **Privacy invariant (no stored token).** The `canonical_hash` is computed over the validated fields with the device token **replaced by its already-computed `token_hash` (SHA-256)**; the stored idempotency value is exactly `{status, body, upstream_id, canonical_hash}` and holds **no raw token** — matching the op-log no-raw-token guarantee (NG1, spec §3.2/§12).
- **Drain interaction.** On handler context-cancellation (e.g. a drain/shutdown that severs an in-flight send), **release or downgrade** the in-flight marker so an immediate caller retry does not get spurious `409 idempotency_conflict` for the full `lock_ttl` (see Phase 6 drain ordering and §5.7 drain test).
- **Decode/validate/allowlist** (spec §5.1/§5.2/§8.4): `DisallowUnknownFields` (unknown key → `400 unexpected_field`). Validate every field per the spec field tables (token shape, environment, mode, ID length caps, collapse-id ≤64 bytes for APNs). Allowlist check **after** auth, **before** payload build: APNs `topic` ∈ account's `relay_apns_allowlist` else `403 topic_not_allowed`; FCM `(project_id, package_name)` ∈ `relay_fcm_allowlist` else `403 project_not_allowed` / `403 package_not_allowed`. Allowlist may be a short-TTL per-account-scoped in-memory cache invalidated on `relayctl allowlist … set`.
**Middleware order (spec §4.1).**
```
TLS → auth → rate-limit → decode(DisallowUnknownFields) → validate+allowlist → idempotency → [Phase 3/4 upstream] → map → store idem result → op-log + metrics
```
**Tests.**
- Unit: bearer parse edge cases; constant-time compare; revoked/expired/disabled all map to `401`; `last_used_at` throttle.
- Unit: the auth path is **constant-time for unknown-prefix vs wrong-secret** (dummy HMAC + `ConstantTimeCompare` on a missing/revoked row) — no enumeration timing oracle; the fine-grained reason stays internal-only.
- Unit: **trusted-proxy client-IP resolution** — a forged caller `X-Forwarded-For`/`X-Real-IP` is ignored; only the LB-set header from an allowlisted trusted-proxy peer is honored; `RemoteAddr` is used otherwise.
- Integration (Redis): token bucket refill/consume math; `429` + correct relative `Retry-After`; **daily counter** (`rl:day:{account}:{yyyymmdd}`, fixed-UTC reset, `EXPIRE`) trips at 50k; **bounded** fail-open on Redis down (in-process per-account cap engaged, not unlimited).
- Integration (Redis): idempotency first/replay/409/422/missing-key matrix (spec §10.3); fail-closed `503` on Redis down; the stored value carries **no raw token** (canonical hash uses `token_hash`).
- Integration (Redis): **Redis dropped mid-request** (not just down at start) — rate-limit layer degrades to bounded fail-open; idempotency layer fails closed (`503`); a lock whose TTL expires mid-send cannot be stolen into a double-send (upstream deadline < `lock_ttl`).
- Contract: `DisallowUnknownFields` rejects every disallowed field (`title`, `body`, `image_url`, `media_id`, `username`, `server_url`, …) with `400 unexpected_field`.
- Contract: allowlist rejections produce exact codes `topic_not_allowed` / `project_not_allowed` / `package_not_allowed`.
**Acceptance criteria.**
- [ ] A request with a valid key, allowlisted topic, and `Idempotency-Key` reaches the (stub) handler and returns `accepted`.
- [ ] Duplicate idempotency key replays the identical body; in-flight → `409`; payload mismatch → `422`; missing → `400`.
- [ ] Over-limit → `429` with relative `Retry-After`; Redis-down rate-limit fails open, idempotency fails closed.
- [ ] Every unknown field and every non-allowlisted topic/project/package is rejected with the exact §5/§8 error code.
---
### Phase 3 — APNs client + `POST /v1/apple/send` (size: L) · gated on Apple provisioning
**Goal.** A production APNs client (cached ES256 JWT, reused HTTP/2 pool, correct reason→error mapping) and the live `/v1/apple/send` endpoint building the §5.1 payloads via `pushwire`, validated against the APNs **sandbox**.
**Files to create/modify.**
- `internal/apns/client.go` — the `APNsClient` interface + concrete impl (apns2 **or** hand-rolled per §3 decision).
- `internal/apns/jwt.go` — ES256 JWT lifecycle (cache, ~50min refresh, never <20min, eager refresh on `ExpiredProviderToken`).
- `internal/apns/transport.go` — per-team HTTP/2 pool with `ReadIdleTimeout`/`PingTimeout`; env→host selection.
- `internal/apns/errors.go` — APNs status+`reason` → caller `error.code` + retryability (spec §6.4 table).
- `internal/pushwire/apns.go` — build `private_alert` / `background_wake` `aps`+`silo` payloads and `apns-*` headers (spec §5.1).
- `internal/httpapi/apple.go` — replace the Phase 2 stub with the real send.
**Implementation notes.**
- **JWT** (spec §6.1, reference §1.1): ES256 only; header `{"alg":"ES256","kid":<10-char Key ID>}`, claims `{"iss":<10-char Team ID>,"iat":<epoch s>}`. Cache one JWT behind a mutex; regenerate on a ~50-min timer, never <20-min apart; eager regenerate + **single in-process retry** on `403 ExpiredProviderToken`; back off / slow refresh on `429 TooManyProviderTokenUpdates`. **Per-team pool** keyed by team even though v1 has one team.
- **Single-flighted JWT regeneration (stampede guard).** Under a burst of concurrent sends sharing one cached JWT, an expiry (or a `403 ExpiredProviderToken`) is observed by many in-flight goroutines at once; a naive per-goroutine regenerate-on-403 would trip `429 TooManyProviderTokenUpdates`. Regeneration must therefore be **single-flighted** (e.g. `golang.org/x/sync/singleflight` or a generation/epoch counter under the mutex): a goroutine seeing `ExpiredProviderToken` first checks whether the JWT was already refreshed since *its* request started and reuses the new token rather than triggering another refresh. Exactly **one** refresh happens per ~20-min window regardless of how many concurrent 403s arrive.
- **Endpoint** (spec §6.2): `production` → `https://api.push.apple.com`; `sandbox` → `https://api.sandbox.push.apple.com`. Both `api.sandbox.push.apple.com` and the older alias `api.development.push.apple.com` are **valid, equivalent sandbox hosts that still resolve** — we standardize on `api.sandbox.push.apple.com` (the current canonical name and `apns2` v0.25.0's `HostDevelopment`); `02`'s use of `api.development.*` is not a discrepancy to resolve, just the older alias. The external contract `"environment":"sandbox"` is unchanged. The upstream hosts are **hardcoded constants never derived from request input**. Port 443, 2197 fallback.
- **Connection** (spec §6.3, reference §3.3): one client/pool per process per env per team; never per push. Non-zero `ReadIdleTimeout`(~15s) + `PingTimeout`(~15s). Do not hardcode `SETTINGS_MAX_CONCURRENT_STREAMS`. Handle `GOAWAY` (drop+reconnect). Ensure system trust store includes USERTrust RSA (default in OS/Go pools).
- **Headers** (spec §5.1 table): `apns-topic` = allowlisted topic; `apns-push-type` = `alert` (private_alert) / `background` (background_wake); `apns-priority` = `10` / `5` (background **must** be 5; 10 is an error — reference §1.4.1); `apns-collapse-id` = `collapse_id` if present (≤64 bytes); `apns-id` omitted (Apple generates, echoed back). **`apns-expiration` ships with a finite default** (configurable) rather than 0/omitted: a few hours for `private_alert`, a short TTL for `background_wake`. Omitting it makes Apple store-and-retry an undelivered push for **up to 30 days** (reference §1.4) — for a notification whose content is fetched live on wake, a wake fired days later is useless or confusing. Mirror this on FCM with a finite `android.ttl` (default is otherwise ~4 weeks) for cross-provider parity. (Resolved open Q#5; the exact default values want product input but v1 ships a sane non-30-day default, not an omission.)
- **Payloads** (`pushwire`, spec §5.1): `private_alert` → `aps.alert` with `title-loc-key=SILO_NOTIFICATION_TITLE`, `loc-key=SILO_NOTIFICATION_GENERIC_BODY`, `sound=default`, plus `silo` dict (`v:1`, `wake:"notifications.changed"`, `server_device_id`, `delivery_id`); add `aps.badge` only if `badge` is present, non-null, and within a **bounded range (0–9999)** — out-of-range → `400 invalid_field` (it is the one numeric field that escapes the opaque-ID discipline, so it is bounded rather than copied verbatim). `background_wake` → `aps.content-available:1` + `silo` dict. Uncompressed JSON, far under 4 KB; defensive `400 payload_too_large` if it ever exceeds.
- **Response → caller** (spec §5.1): `200` → `{ "request_id", "apns_id" (Apple's apns-id header), "status":"accepted" }`. Error mapping per spec §6.4 (e.g. `BadDeviceToken`/**`InvalidToken`**/`DeviceTokenNotForTopic`/`MissingDeviceToken` → `422 bad_device_token`; `Unregistered`/`ExpiredToken` → `410 unregistered` with `timestamp` passed through in `error.message`; `403 InvalidProviderToken`/etc → `502 upstream_auth_error` + ops alert; `429 TooManyRequests` → `429 rate_limited` + `Retry-After`; 5xx → `503 upstream_unavailable`). **`InvalidToken` is treated equivalently to `BadDeviceToken` (token-purge, non-retryable)** — Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`, and the `02` contract requires treating them equivalently; omitting it would let a dead device fall through to the catch-all and never be disabled. Add a **default/fallback rule**: any unrecognized 400/403/410 `reason` maps to a safe non-retryable `502 upstream_error` rather than being dropped. **Connection-severed sends** — a request whose HTTP/2 stream is terminated by `GOAWAY` or any connection reset **before** a final APNs response arrives → `503 upstream_unavailable` (retryable), the same as a network timeout; the relay does **not** transparently retry it (the caller's outbox does), preserving at-most-once semantics under the idempotency key. Tag the op-log outcome / metric label `connection_severed` so these are distinguishable. **Environment/token mismatch caveat:** a `sandbox`/`production` mismatch (e.g. a prod token sent with `environment=sandbox`) presents upstream as `BadDeviceToken` → `bad_device_token`, which instructs the caller to purge a perfectly good token; emit a distinct op-log/metric so a systemic misconfig (many `bad_device_token` from one account) is distinguishable from genuinely dead tokens. Network/timeout to APNs → `503 upstream_unavailable`. The relay does **not** retry transient upstream errors across caller requests (the caller's outbox does); the only in-process retry is the JWT-expiry one (single-flighted — see JWT note below).
**Tests.**
- Unit: JWT signs valid ES256 with correct header/claims; refresh-timer logic (≥20min, ≤60min, eager-on-expired); single retry on `ExpiredProviderToken`.
- Concurrency: force JWT expiry under a burst of concurrent sends; assert **exactly one** regeneration (single-flight) and **zero** `429 TooManyProviderTokenUpdates`.
- Unit (`pushwire`): golden-file payloads + headers for `private_alert` and `background_wake`, with and without `badge`/`collapse_id` (byte-exact vs spec §5.1). **Same golden files mirrored into `silo-server`.**
- Unit: every row of the spec §6.4 reason→error table maps correctly (table-driven), **including `InvalidToken` → `bad_device_token`** (equivalent to `BadDeviceToken`) and the **default/fallback** rule (unrecognized reason → `502 upstream_error`, non-retryable).
- Fake APNs upstream injecting a **`GOAWAY` / mid-stream reset** before a final response → caller gets `503 upstream_unavailable`, the op-log/metric is tagged `connection_severed`, and there is **no lost or double send** (the relay does not transparently retry).
- Integration (**Apple sandbox**, behind `-tags=integration`, requires `.p8`): real send to `api.sandbox.push.apple.com` returns 200 + `apns_id`; an intentionally bad token returns the expected `bad_device_token`/`unregistered` mapping.
- Integration: `relayctl ping-upstream --provider apns --env sandbox` signs+connects without sending content; optionally captures the sandbox-only `apns-unique-id` response header (reference §1.6) into the ping output for Push-Notifications-Console delivery-log correlation during client QA.
**Acceptance criteria.**
- [ ] `/v1/apple/send` builds the exact §5.1 payloads/headers and returns `{request_id, apns_id, status:"accepted"}` on success against sandbox.
- [ ] Background pushes always send `apns-priority: 5` and `apns-push-type: background`.
- [ ] JWT is cached, reused, and refreshed on the ~50-min cadence; `ExpiredProviderToken` triggers one regenerate+retry; concurrent 403s yield exactly one single-flighted regeneration.
- [ ] Every spec §6.4 mapping is covered by a passing table-driven test, **including `InvalidToken` and the unrecognized-reason fallback**; a `GOAWAY`/connection-severed send maps to `503 upstream_unavailable` with no double-send; the `pushwire` golden files match across both repos.
---
### Phase 4 — FCM client + `POST /v1/fcm/send` (size: L) · gated on Firebase provisioning
**Goal.** A production FCM HTTP v1 client (cached OAuth2 token, tuned HTTP/2, correct status→error mapping) and the live `/v1/fcm/send` endpoint building the §5.2 **data-only** payloads via `pushwire`, validated with FCM `validateOnly:true`.
**Files to create/modify.**
- `internal/fcm/client.go` — the `FCMClient` interface + concrete impl (`x/oauth2/google` path per §3).
- `internal/fcm/oauth.go` — `CredentialsFromJSONWithType(ctx, json, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` → cached/auto-refreshing `TokenSource`, one per project.
- `internal/fcm/transport.go` — tuned HTTP/2 client; endpoint `POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send`.
- `internal/fcm/errors.go` — FCM `error.status` → caller `error.code` + retryability + Retry-After honoring (spec §7.4).
- `internal/pushwire/fcm.go` — build data-only `message` + `android` config (spec §5.2).
- `internal/httpapi/fcm.go` — replace the Phase 2 stub with the real send.
**Implementation notes.**
- **OAuth2** (spec §7.1, reference §2.2): least-privilege scope `firebase.messaging`; `TokenSource` caches + auto-refreshes (~1h tokens; never mint per request). On GCP prefer keyless Workload Identity / attached SA; off-GCP load SA JSON from the secret manager (open Q#4). One token source per Firebase project.
- **Endpoint** (spec §7.2): global host only — no regional endpoints (reference §2.3). The host `fcm.googleapis.com` is a **hardcoded constant never derived from request input**. `{PROJECT_ID}` is the allowlist-checked `project_id`, and `project_id`/`package_name` are validated against a **strict charset** (e.g. `^[a-z0-9-]+$` / reverse-DNS) **before** allowlist lookup and URL interpolation, so an allowlist row can never introduce a path-traversal or host-override (no SSRF / path injection).
- **Payload — data-only** (`pushwire`, spec §5.2): `message.token` = caller token; `message.data` is a **fixed, closed set of string-valued keys only** (`"v":"1"`, `"wake":"notifications.changed"`, `"server_device_id"`, `"delivery_id"`) — no caller-controlled key or value beyond the opaque IDs; **no `notification` block**; `message.android.priority` = `"HIGH"` (private_data) / `"NORMAL"` (background_wake); `message.android.collapseKey` set only if the caller sent `collapse_key`. **Field-casing reconciliation (reference §2.4):** the relay POSTs raw JSON to the real FCM HTTP v1 REST endpoint, whose AndroidConfig field is camelCase **`collapseKey`** (FCM v1 rejects unknown fields with `INVALID_ARGUMENT`, so a literal `collapse_key` inside `android` would fail every collapsible send). The **inbound caller request body field stays `collapse_key`** per `03`; only the relay-built upstream JSON uses `collapseKey`. Likewise `android.priority` is sent as canonical uppercase `"HIGH"`/`"NORMAL"` (reference §2.4 lists these as the enum values, with lowercase also accepted; the relay sends the canonical uppercase to be robust against future enum tightening). Honor the `03` contract's `priority: high`-equivalent (`"HIGH"`) for `private_data` (do not second-guess — spec §5.2 reconciliation note). Set a finite `android.ttl` (default ~4 weeks otherwise) for parity with the APNs `apns-expiration` default (above). Never set `topic`/`condition`. ≤4096 bytes. Use `validateOnly:true` (camelCase REST field — reference §2.5) only for the `relayctl` test path, never production.
- **`badge` has no data-only FCM home.** The relay-built FCM payload is strictly data-only (no `notification` block), so a caller-provided `badge` has nowhere legitimate to go. **Drop `badge` from the FCM accepted schema** (it is disabled-by-default and would otherwise have to be smuggled into the closed `data` map, weakening the allowlist). On the APNs side, bound `badge` to a sane range (e.g. 0–9999) and reject otherwise with `400 invalid_field` — it is the one numeric field that escapes the opaque-ID discipline.
- **Collapse-key coalescing is best-effort (reference §2.7).** FCM stores at most **4 distinct `collapseKey`s per device** while offline; a 5th evicts one nondeterministically. Since `collapse_key` is a per-series HMAC and a device may follow >4 series, collapsible coalescing is best-effort and excess distinct keys evict nondeterministically — acceptable because the durable inbox + sync (the caller's outbox) covers any dropped collapsible wake. APNs has no equivalent documented per-device collapse-id cap (note the asymmetry).
- **Response → caller** (spec §5.2): `200` → `{ "request_id", "fcm_message_name" (FCM v1 `name`), "status":"accepted" }`. Error mapping per spec §7.4 (e.g. `INVALID_ARGUMENT` → `422 invalid_argument`; `SENDER_ID_MISMATCH` → `403 sender_id_mismatch`; `UNREGISTERED` → `410 unregistered`; `QUOTA_EXCEEDED` → `429 rate_limited` honoring upstream `Retry-After` else default **60s**; `INTERNAL`/`UNAVAILABLE` → `503 upstream_unavailable` honoring `Retry-After` on 503; `UNSPECIFIED_ERROR` → `502 upstream_error`, no auto-purge). Classify on `error.status`/`FcmError.errorCode`, not the bare HTTP code. **Retry-After** forwarded to the caller as relative delay-seconds (reference §4.2). Add a **default/fallback rule** so any unrecognized FCM `error.status`/`errorCode` maps to a safe non-retryable `502 upstream_error` (never an auto-purge), rather than being dropped.
- **`THIRD_PARTY_AUTH_ERROR` (401) — re-classified (do NOT page as a relay-credential failure).** Per Firebase's error-codes page this means an invalid/missing **APNs cert/key or Web Push auth credential** and is returned only for iOS-via-APNs or Web Push registration tokens. `/v1/fcm/send` sends **only** Android registration tokens (`message.token`, package `com.continuum.app.android`, data-only), so a legitimate Android send can never produce `THIRD_PARTY_AUTH_ERROR`. If it ever appears, treat it as a **per-token/registration anomaly** → `502 upstream_error`, surface for investigation, and do **not** page on-call as a relay `.p8`/SA misconfig. The relay's **actual** OAuth2/service-account credential failures surface as a non-200 from `oauth2.googleapis.com/token` at mint time, or HTTP `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` (the `status`, not an `FcmError` code) on `messages:send` — **those** map to `502 upstream_auth_error` + ops alert.
- **Per-device rate exceedance (`DeviceMessageRateExceeded`, reference §2.7).** The Android per-device caps (240/min, 5,000/hr) are **distinct** from project-level `QUOTA_EXCEEDED` and surface defensively as a quota/`INVALID_ARGUMENT`-adjacent signal whose exact code/string is not firmly documented; detect on `error.status`/`errorCode` and map to a retryable `429 rate_limited` with a conservative default `Retry-After`, **not** a token purge.
- **`UNREGISTERED` caller-facing 410 is an intentional cross-provider normalization.** FCM natively returns `UNREGISTERED` as HTTP **404**; the relay remaps it to caller `410 unregistered` so the caller's device-disable ("purge dead token") logic is uniform with the APNs path (which natively uses 410). This is a deliberate divergence from FCM's native status, not a bug.
**Tests.**
- Unit: token source caches; no per-request mint; refresh margin.
- Unit (`pushwire`): golden-file data-only payloads for `private_data` and `background_wake`, with/without `collapse_key` (byte-exact vs spec §5.2; **all data values strings**; **no `notification` block**). Mirrored into `silo-server`.
- Unit: every row of the spec §7.4 status→error table (table-driven), including `Retry-After` honoring + 60s default, the unrecognized-`error.status` fallback (`502 upstream_error`), the per-device-rate (`DeviceMessageRateExceeded`) → `429 rate_limited` (no purge), and `THIRD_PARTY_AUTH_ERROR` → `502 upstream_error` (per-token anomaly, **not** paged as relay-credential).
- Unit/contract: the FCM upstream JSON uses camelCase `collapseKey`, uppercase `priority` (`"HIGH"`/`"NORMAL"`), and `validateOnly`; `badge` is rejected from the FCM schema; the `data` map is the closed key set (no extra keys).
- Integration (**FCM `validateOnly:true`**, `-tags=integration`, requires SA JSON): a valid request validates OK; a malformed token surfaces `invalid_argument`/`unregistered` mapping.
- Integration: `relayctl ping-upstream --provider fcm --env sandbox` mints a token + does a `validateOnly` send.
**Acceptance criteria.**
- [ ] `/v1/fcm/send` builds the exact §5.2 data-only payload (string values, no `notification`) and returns `{request_id, fcm_message_name, status:"accepted"}`.
- [ ] OAuth2 token is cached/refreshed, never minted per request; one source per project.
- [ ] Every spec §7.4 mapping is covered (incl. the unrecognized-status fallback, per-device-rate, and `THIRD_PARTY_AUTH_ERROR` as a non-paging per-token anomaly); `QUOTA_EXCEEDED`/`UNAVAILABLE` forward `Retry-After` (default 60s on 429).
- [ ] The upstream FCM JSON uses `collapseKey` (camelCase), `"HIGH"`/`"NORMAL"` priority, `validateOnly` (camelCase), a finite `android.ttl`, and the closed `data` key set; `badge` is not accepted on the FCM path.
- [ ] `validateOnly` test path works; production sends never set `validateOnly`.
---
### Phase 5 — Observability, redaction, metrics (size: M)
**Goal.** Production-grade redacted logging, op-log persistence, the full Prometheus metric catalog, and `/readyz`/`/metrics` wired and network-scoped.
**Files to create/modify.**
- `internal/observability/redact.go` — redaction middleware (strips `Authorization`/bearer, raw key, raw token, JWT, OAuth2 token, payload bodies).
- `internal/observability/metrics.go` — Prometheus registry + the spec §13.2 catalog.
- `internal/httpapi/health.go` — `/readyz` (PG `SELECT 1`, Redis `PING`; APNs and FCM reported **per-provider**, not as a single combined gate) and `/metrics` (`promhttp.HandlerFor`), both bound to the internal network.
- `internal/oplog/gc.go` + `cmd/relayctl` `logs --gc` subcommand (or a background sweeper in `cmd/relay`) — the retention GC deliverable; plus the §8.1 partitioning DDL (or a documented partition/offload decision).
- Wire `oplog.Write` into every send path with the redacted subset.
**Implementation notes.**
- **Redaction enforced in middleware** so no code path can leak (spec §11/§12/§13.1). Never log: bearer header, raw API key (log prefix or hash only), raw device tokens (log `SHA-256` truncated hash only), payload bodies, relay JWT, OAuth2 tokens. Do log: `request_id`, `account_id`, `status_code`, `error_code`, upstream reason, `token_hash`, `egress_ip`, `latency_ms`.
- **Op-log retention is a concrete Phase 5 deliverable** (spec §8.1/§13.1): every send writes one `relay_op_logs` row (redacted by construction). Ship a real GC — **add `relayctl logs --gc`** (to the §5.6 command table and Phase 1/5 file lists) **or** a built-in background sweeper in `cmd/relay` — with a definite retention query (`DELETE` success rows >14d, failure/reject rows >90d) on a schedule with an owner. Either add **time-partitioning DDL** to the §8.1 schema or document an explicit partition/offload decision here (do **not** defer it to an open question). Add a disk-usage / `relay_op_logs` table-growth alert (at 50k/day/account the table grows unbounded without GC — a classic "works for months then the disk fills" surprise).
- **Metrics catalog** (spec §13.2): `relay_requests_total`, `relay_request_duration_seconds`, `relay_upstream_requests_total`, `relay_upstream_duration_seconds`, `relay_auth_failures_total`, `relay_rate_limited_total` (incl. `kind="daily"`), `relay_rate_limit_degraded_total`, `relay_idempotency_total`, `relay_apns_jwt_refresh_total`, `relay_fcm_token_refresh_total`, `relay_redis_up`, `relay_pg_up`, plus `relay_provider_healthy{provider=...}`, `relay_credential_healthy{provider=...}`, and `relay_fcm_project_quota_headroom`. **Keep labels low-cardinality** (no token, no `request_id`).
- **`/readyz` — per-provider, not all-or-nothing.** Return `200` only when the **core deps** (PG + Redis) are reachable, and report **per-provider** APNs/FCM health separately in `checks` (a `relay_provider_healthy{provider}` gauge). A single provider's credential/init failure must **not** down the whole replica (that would pull a healthy APNs replica from the LB just because FCM creds are temporarily unresolvable, killing Apple push too); instead the send handler for an un-initialized/degraded provider returns `503 upstream_unavailable` for **that provider only**. Surface per-provider circuit-breaker state so operators see "APNs degraded, FCM healthy" rather than a binary not-ready. Body shape: `503 {"status":"not_ready","checks":{...}}` only when a core dep is down (spec §5.4).
- `/readyz` and `/metrics` bound to the internal/scrape network; only `/v1/*` and `/healthz` are public (spec §11/§14.1).
**Tests.**
- Unit: redaction middleware drops every disallowed field even when handlers attempt to log them (assert no token/bearer/JWT/OAuth2 string appears in captured log output).
- Unit: a success and a failure path each produce exactly one op-log row with the redacted subset and a `token_hash` (never raw token).
- Unit: metric counters increment with correct labels for first/replay/conflict idempotency, auth-failure reasons, rate-limit kinds, JWT/token refresh results.
- Integration: `/readyz` flips to `503` when PG or Redis is down; `/metrics` exposes the full catalog.
**Acceptance criteria.**
- [ ] No log line or op-log row can contain a raw token, bearer header, JWT, OAuth2 token, or payload body (asserted by tests).
- [ ] All spec §13.2 metrics are exported with low-cardinality labels.
- [ ] `/readyz` reflects real dependency + credential health; `/metrics` and `/readyz` are not publicly routable.
---
### Phase 6 — Hardening, load test, deploy (size: L) · gated on provisioning + secret manager
**Goal.** Production hardening (real secret-manager wiring, TLS, anti-abuse), a hundreds-of-users burst load/pacing test, idempotency-under-concurrency proof, container build, and rollout.
**Files to create/modify.**
- `internal/config/secrets_*.go` — real secret-manager integration (KMS/Vault/cloud secret manager); load `.p8`, SA JSON, pepper, DSNs at startup into memory; optional SIGHUP re-read.
- `deploy/Dockerfile` (distroless/minimal), deploy manifests, TLS/HSTS config.
- `test/load/` — k6/vegeta burst scenario + concurrency idempotency test harness.
- Alerting rules (Prometheus → alertmanager / SLO doc).
**Implementation notes.**
- **Secrets** (spec §11, reference §4.5): credentials loaded once at startup from the secret manager into memory; never on disk in the image/repo, never logged, never returned by any endpoint. On GCP prefer keyless FCM (Workload Identity). Rolling restart picks up rotated secrets.
- **TLS** (spec §11/§14.1): HTTPS/HTTP-2 only, TLS 1.2+ (prefer 1.3), HSTS on `relay.silo.app`. Terminate at LB or in-process.
- **Anti-abuse** (spec §8.2/§11): per-IP auth-failure rate limiting keyed on the **trusted-proxy-resolved client IP** (caller-supplied `X-Forwarded-For`/`X-Real-IP` ignored — see Phase 2 client-IP resolution; the trusted-proxy CIDR list is config); **soft** lockout (latency / `Retry-After`) to avoid CGNAT/VPN collateral; body size cap; explicit timeouts; graceful shutdown. Document a response runbook for the key-guessing alert.
- **Credential-health validation & rotation runbook** (spec §4.4/§14.3). Add a **startup + periodic synthetic credential check** that proves the loaded credential actually authenticates upstream — not just that a JWT signs / a token mints locally (which `/readyz` already checks). For FCM, a `validateOnly:true` send; for APNs, an auth-exercising send to a known-bad token that still distinguishes an auth-layer `403` (`InvalidProviderToken`) from a token-layer `400` (`BadDeviceToken`). Wire the result into a `relay_credential_healthy{provider=...}` gauge and into `/readyz`'s per-provider health (below). Schedule `relayctl ping-upstream` as a cron for continuous health, and **alert on the first `upstream_auth_error`** (§6.4) — Apple actively revokes leaked `.p8` keys and SA keys/bindings can be rotated out from under the relay. Document the **rotation runbook** (overlap window, dual-key support — APNs allows multiple keys and the relay pools by team).
- **Graceful-shutdown drain ordering** (spec §14.2). On `SIGTERM`: (1) flip `/readyz` to `503` so the LB stops routing **new** requests; (2) keep serving in-flight handlers; (3) bound `srv.Shutdown(ctx)` to **less than `lock_ttl`** so any marker left by a killed-mid-flight handler expires quickly — **and** release/downgrade the in-flight idempotency marker on handler context-cancellation (Phase 2), so a drained-but-incomplete send frees its lock immediately rather than forcing the caller's retry to eat a 30s `409 idempotency_conflict`. A clean drain must leave **no orphaned in-flight idempotency markers**; the relationship between the shutdown grace period and `lock_ttl` is explicit.
- **Load/pacing test** (spec §13 / testing strategy §5 below): simulate a hundreds-of-users burst (e.g. 300–500 concurrent device sends), assert the relay stays under the per-account cap, returns correct `429` + relative `Retry-After`, and one well-tuned upstream connection sustains the throughput (reference §1.3 — thousands/sec). Verify the FCM smooth-traffic guidance does not trip quota (reference §2.7).
- **Idempotency under concurrency**: fire N concurrent requests with the **same** `Idempotency-Key`; assert **at most one** upstream send (count via a fake upstream), the rest replay or `409`.
- **FCM quota-storm shedding**: when the shared project quota is exhausted, the relay-side project circuit breaker sheds with `503 upstream_throttled` before forwarding (Phase 2 / §6.4), rather than hammering FCM into a 429 stampede.
- **HA** (spec §14.2): multiple stateless replicas behind the LB; rolling deploys; kill-safety (a drained/killed replica is covered by the caller's outbox retry). Relay-down delays, never loses.
**Tests.**
- Load: hundreds-of-users burst (latency p50/p95/p99, error rate, `429` correctness).
- Concurrency: same-key idempotency → at-most-once upstream (assert against a counting fake).
- Security: secret-manager load path; no secret in image (`docker history`/scan); TLS config (min version, HSTS).
- Security: forged caller `X-Forwarded-For`/`X-Real-IP` does **not** change the resolved client IP / `egress_ip` (only the trusted-proxy header from an allowlisted peer is honored); per-IP throttle cannot be bypassed.
- Drain: rolling-restart drains in-flight requests via `srv.Shutdown` without dropping a completed send, `/readyz` flips to `503` before draining, and **no orphaned in-flight idempotency markers** remain afterward (an immediate caller retry does not get a spurious `409`).
- Resilience: FCM quota-exhaustion → relay sheds with `503 upstream_throttled` (project circuit breaker) instead of forwarding into a 429 storm.
- Credential health: the synthetic startup/periodic check flips `relay_credential_healthy` and `/readyz` when the loaded credential is rejected upstream (auth-layer `403`), distinct from a token-layer `400`.
**Acceptance criteria.**
- [ ] Secrets load exclusively from the secret manager; image/repo scans show no embedded `.p8`/SA JSON/pepper.
- [ ] A hundreds-of-users burst is handled within the per-account caps with correct `429`/`Retry-After`; one upstream connection sustains the load.
- [ ] Concurrent identical-key sends produce **at most one** upstream call.
- [ ] A rolling restart drains in-flight requests, flips `/readyz` to `503` first, and leaves **no orphaned in-flight idempotency markers**.
- [ ] The synthetic credential check gates `/readyz`/`relay_credential_healthy` on real upstream acceptance; the first `upstream_auth_error` pages ops; the rotation runbook is documented.
- [ ] Forged caller forwarding headers cannot poison `egress_ip` or bypass the per-IP throttle.
- [ ] TLS 1.2+ enforced, HSTS set; `/metrics`+`/readyz` internal-only; staging deployment validated before production rollout.
---
## 5. Testing Strategy
### 5.1 Layers
| Layer | What it covers | Provider creds? |
|---|---|---|
| **Unit** | Field validation, JWT/token lifecycle math, key hashing/compare, error-table mappings, redaction, metric labels | No |
| **Contract** | Exact `02`/`03` request/response shapes: `DisallowUnknownFields` rejects every disallowed field; success/error bodies are byte-exact; `pushwire` golden files | No |
| **Integration** | Real upstream behavior: APNs **sandbox** sends; FCM **`validateOnly:true`** validation; PG migrations; Redis rate-limit/idempotency | APNs `.p8` / FCM SA (sandbox) |
| **Redaction assertions** | No raw token/bearer/JWT/OAuth2/payload ever reaches a log or op-log | No |
| **Load / pacing** | Hundreds-of-users burst; per-account cap + `429`/`Retry-After`; upstream connection reuse sustains throughput | Optional (fake upstream) |
| **Idempotency-under-concurrency** | Same key, many concurrent requests → at-most-once upstream send | No (fake upstream) |
| **Failure-mode** | APNs `GOAWAY`/mid-stream reset mapping (no lost/double send); JWT-expiry single-flight (one regen, zero `TooManyProviderTokenUpdates`); Redis dropped mid-request (idempotency fail-closed, rate-limit bounded fail-open); graceful-drain leaves no orphaned idempotency lock; FCM quota-exhaustion shedding | No (fakes) |
### 5.2 Contract tests against the 02/03 shapes
For each endpoint, a golden corpus of requests/responses derived from spec §5.1/§5.2:
- **Valid** `private_alert` / `background_wake` (APNs) and `private_data` / `background_wake` (FCM) requests → assert the built upstream payload + headers are **byte-exact** vs the spec (the `pushwire` golden files).
- **Every disallowed field** (`title`, `body`, `image_url`, `media_id`, `username`, `server_url`, `notification`, …) → `400 unexpected_field`.
- **Every validation failure** → its exact `error.code` (`invalid_token`, `invalid_environment`, `invalid_mode`, `invalid_collapse_id`, `topic_not_allowed`, `project_not_allowed`, `package_not_allowed`, `missing_idempotency_key`, …).
- The same `pushwire` golden corpus is shared with `silo-server`'s `custom_apns`/`custom_fcm` tests. Because a copied package in one repo cannot fail the other repo's CI on its own, the mirror is a **checked-in generated artifact with a committed checksum** that **both** repos verify in CI against the canonical hash (see §3 sync runbook) — a divergence fails CI on both sides regardless of which repo changed first.
- **Closed-allowlist assertion (privacy guarantee):** a dedicated test asserts the FCM `data` map keys and the APNs payload key set are **exactly** the closed allowlist, so adding any key (e.g. smuggling content into `data`) fails CI even if golden files are regenerated. The content-free guarantee is enforced structurally, not only by golden equality.
### 5.3 Integration against sandbox / validateOnly
- **APNs sandbox** (`api.sandbox.push.apple.com`): a real send returns 200 + `apns_id`; a deliberately invalid token returns the mapped `bad_device_token` / `unregistered`. Run behind `-tags=integration`, only when `.p8` secrets are present (CI `integration` job).
- **FCM `validateOnly:true`** (reference §2.5): a valid request validates without delivering; a malformed token surfaces `invalid_argument`/`unregistered`. **Never** `validateOnly` on production sends.
### 5.4 Redaction assertions
Capture log + op-log output across success and every error path; assert via regex that none of: the bearer value, the raw `rk_` secret, a raw device token, the relay JWT, or an OAuth2 access token ever appears, and that `token_hash` is present where a token was involved (spec §12/§13.1).
### 5.5 Load / pacing test (hundreds-of-users burst)
Simulate a burst of several hundred concurrent device sends (one account, mixed APNs/FCM) against a **fake upstream** that records call counts and timing:
- Assert per-account rate limiting holds (10 req/s burst + 50k/day; spec §9.2) and over-limit responses are `429` with a **relative** `Retry-After`.
- Assert one upstream HTTP/2 connection is reused (no connection-per-push) and sustains the burst (reference §1.3/§3.1).
- Assert latency percentiles and zero double-sends.
### 5.6 Idempotency-under-concurrency test
Fire N (e.g. 50) concurrent requests sharing one `Idempotency-Key` at a fake upstream that increments a counter per call. Assert: **exactly one** upstream call; the other N−1 either replay the stored result or receive `409 idempotency_conflict`; a follow-up with a **different** payload but the same key returns `422 idempotency_key_reuse`. This proves at-most-once upstream send under the caller's concurrent outbox retries (spec §10.2).
### 5.7 Failure-mode tests (the exact modes on-call hits)
Beyond the happy path and the error-table mappings, the plan tests every failure mode that actually pages an engineer (all use fakes — no provider creds):
- **APNs `GOAWAY` / mid-stream reset.** A fake APNs upstream injects `GOAWAY` (or resets the stream) before a final response mid-burst → the caller-facing result is `503 upstream_unavailable`, the op-log/metric is tagged `connection_severed`, and a counting fake confirms **no lost or double send** (no transparent retry).
- **JWT expiry under concurrency.** Force the cached APNs JWT to expire under a concurrent burst → assert **exactly one** single-flighted regeneration and **zero** `429 TooManyProviderTokenUpdates`.
- **Redis dropped mid-request** (not just down at start) → idempotency fails **closed** (`503`); rate-limit degrades to **bounded** fail-open; a lock whose TTL expires mid-send cannot be stolen (upstream deadline `<` `lock_ttl`).
- **Graceful-drain idempotency.** A rolling restart during in-flight sends leaves **no orphaned in-flight idempotency markers** (an immediate caller retry does not eat a spurious `409`); `/readyz` flips to `503` before draining.
- **FCM quota-exhaustion shedding.** With the shared project quota exhausted, the relay-side project circuit breaker sheds with `503 upstream_throttled` instead of forwarding into a 429 storm.
---
## 6. Deployment
### 6.1 Container build
- `deploy/Dockerfile`: multi-stage; static `relay` (and `relayctl`) binaries on a distroless/minimal base. **No credentials baked in** (spec §11). Non-root user; read-only filesystem where possible.
- Image carries only the binary + CA roots (ensure USERTrust RSA present — default in `ca-certificates`; spec §6.3).
### 6.2 Secret provisioning
| Secret | Source | Notes |
|---|---|---|
| APNs `.p8` + Key ID + Team ID | secret manager (or mounted file) | per official team; never env/image (spec §14.3) |
| FCM SA JSON **or** Workload Identity | GCP keyless (on-GCP) / secret manager JSON (off-GCP) | open Q#4 (spec §15) |
| API-key HMAC pepper | secret manager | rotation is heavier (open Q#6) |
| Postgres DSN, Redis URL | secret manager | privileged `relayctl` DSN held only by operators |
Rotation: issue a new APNs key / FCM credential, deploy, retire the old; the relay loads secrets at startup so a rolling restart picks them up (spec §14.3). Optional SIGHUP re-read.
### 6.3 TLS, health, metrics wiring
- TLS 1.2+ (prefer 1.3), HTTP/2, HSTS on `relay.silo.app`; terminate at LB or in-process (spec §14.1).
- **Public:** `/v1/apple/send`, `/v1/fcm/send`, `/healthz`. **Internal-only:** `/readyz`, `/metrics` (spec §11/§14.1).
- Liveness probe → `/healthz`; readiness probe → `/readyz`; Prometheus scrapes `/metrics` on the internal network.
### 6.4 Monitoring / alerting
Alert on (from the §13.2 catalog):
- **First `upstream_auth_error` → page ops.** Alert on the caller-facing `upstream_auth_error` reason (the relay's genuine `.p8`/SA/OAuth2 credential failure — a non-200 from `oauth2.googleapis.com/token`, or `401 UNAUTHENTICATED`/`403 PERMISSION_DENIED`/`InvalidProviderToken` on send) > 0 → **credential misconfig, page ops**: never caller-fixable. **Do NOT** key this alert on `THIRD_PARTY_AUTH_ERROR`, which on this pure-Android path indicates a per-token/registration anomaly, not a relay-credential problem (see Phase 4 reclassification) — surface it for investigation at most, never page.
- **Proactive credential-health alert.** Alert if the periodic synthetic credential check (Phase 6: a scheduled `relayctl ping-upstream` / `validateOnly` FCM send + APNs auth-exercising probe, plus the `relay_credential_healthy` gauge wired into `/readyz`) reports the loaded `.p8`/SA/Workload-Identity credential is no longer accepted upstream — Apple actively revokes leaked `.p8` keys and SA keys/bindings can be rotated out from under the relay, so this must fire **before** production sends start failing.
- Sustained `relay_rate_limit_degraded_total` increase → Redis degraded (fail-open active; in-process per-account fallback limiter engaged).
- `relay_redis_up == 0` / `relay_pg_up == 0`; any provider showing `relay_provider_healthy{provider=...} == 0` → that upstream is degraded (per-provider, not a whole-replica outage).
- Elevated `relay_requests_total{status_code="5xx"}` rate; `relay_request_duration_seconds` p99 regression.
- `relay_upstream_requests_total{upstream_reason="QUOTA_EXCEEDED"}` and **low `relay_fcm_project_quota_headroom`** → shared FCM project quota pressure: the relay-side project limiter is shedding with `503 upstream_throttled` before hammering FCM (watch Cloud Console; spec §14.5). Treat as a multi-tenant fairness event — one noisy account must not exhaust the ~600k/min project quota shared across all accounts.
- Accounts approaching/hitting the **per-account daily cap** (`relay_rate_limited_total{kind="daily"}`) → a legitimate big-release burst can hit 50k/day; surface for an operator to consider a temporary cap bump.
- Anomalous `relay_auth_failures_total` rate from an egress IP → possible key-guessing (see §6.4 anti-brute-force; the per-IP limiter consumes the resolved client IP, not a caller-supplied header). Note: the fine-grained `reason` (unknown-prefix vs hash-mismatch) is internal-only and must not be exposed to tenants.
### 6.5 Rollout
- **Staging first** (spec §14.4): separate hostname + separate accounts so client QA never touches production quotas. Validate APNs sandbox + FCM `validateOnly` end-to-end.
- **HA / rolling** (spec §14.2): multiple stateless replicas behind the LB; rolling deploy with drain (`srv.Shutdown`); a killed replica is covered by the caller's outbox retry — **relay-down delays, never loses**.
- Single region in v1, close to PG/Redis (spec §14.5).
---
## 7. Provisioning Checklist (the long pole — start day 1)
> These gate the phases that touch live providers (Phase 3 APNs, Phase 4 FCM, Phase 6 production). Run them **in parallel** with Phases 0–2, which need no credentials.
### 7.1 Apple (gates Phase 3 + Phase 6)
- [ ] Apple Developer **Program account / team** with push entitlement (capture the 10-char **Team ID**).
- [ ] Generate an **APNs Auth Key (`.p8`)** in the developer portal; capture the **Key ID** (10 chars) and download the `.p8` **once** (Apple shows it once). Store in the secret manager (spec §14.3).
- [ ] Consider Feb-2025 **scoped keys** (team-scoped or topic-specific) for least privilege (reference §1.2).
- [ ] Register per-platform **bundle topics** for the official build config and seed the account allowlist (spec §8.4): `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos`.
- [ ] Standardize on the **sandbox host** `api.sandbox.push.apple.com` (reference §1.3; `api.development.push.apple.com` is an equivalent still-resolving alias — no build-time resolution needed) and confirm the APNs server TLS root (**USERTrust RSA**, present in standard pools; spec §6.3 / reference §1.7).
- [ ] Decide `apns2` vs hand-rolled client (open Q#3) before Phase 3.
### 7.2 Firebase / Google (gates Phase 4 + Phase 6)
- [ ] Firebase **project(s)** for the official build config: `continuum-prod-android` (and a staging project if desired).
- [ ] Register the Android **package** `com.continuum.app.android`; seed the FCM allowlist `(project_id, package_name)` (spec §8.4).
- [ ] Provision a **service account** with the least-privilege `firebase.messaging` scope (reference §2.2). On GCP, prefer keyless **Workload Identity / attached SA**; off-GCP, download the **service-account JSON** and store it in the secret manager — never in repo/image (reference §2.2/§4.5; open Q#4).
- [ ] Confirm the global send host `fcm.googleapis.com` (no regional endpoints; reference §2.3) and the default ~600k msgs/min project quota (reference §2.7 / spec §14.5).
### 7.3 Cross-cutting
- [ ] Secret manager chosen and wired (KMS/Vault/cloud secret manager) for `.p8`, SA JSON, pepper, DSNs (spec §11 / Phase 6).
- [ ] DNS + TLS cert for `relay.silo.app` (and the staging hostname).
---
## 8. Risks, Mitigations, and Milestone Sequencing
### 8.1 Risks and mitigations
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | **Provider provisioning slips** (Apple/Firebase approvals) — the real long pole | Med | High (blocks Phase 3/4/6) | Start §7 on day 1 in parallel; Phases 0–2 + all unit/contract tests need no creds; fakes for upstream |
| R2 | `sideshow/apns2` is unmaintained (no release since Oct 2024) | Med | Med | Hide behind `internal/apns` interface; prototype hand-rolled `x/net/http2`+`golang-jwt/jwt/v5` in Phase 3; decide per open Q#3 |
| R3 | **JWT/token mishandling** (over-frequent refresh → APNs `429 TooManyProviderTokenUpdates`; per-request mint → throttling; concurrent-403 stampede) | Med | High | Cache + mutex; ~50-min APNs refresh (never <20min), eager-on-expired; **single-flighted regeneration** so a concurrent burst of `ExpiredProviderToken` yields exactly one refresh per ~20-min window; OAuth2 `TokenSource` auto-refresh; metrics `relay_apns_jwt_refresh_total`/`relay_fcm_token_refresh_total` |
| R4 | **Double-send** under concurrent caller retries (incl. lock-TTL expiry mid-send, and stale in-flight markers after a killed/drained replica) | Med | High | Redis `SET NX` idempotency, **fail-closed**; pin the upstream call deadline **strictly shorter than `lock_ttl`** (≈10 s upstream vs 30 s lock) and refuse to send if the deadline already passed the lock TTL; release/downgrade the in-flight marker on handler context-cancellation; at-most-once test (§5.6); drain test asserting no orphaned in-flight markers (§5.7); key includes `attempt_number` |
| R5 | **Redis outage / eviction** | Low | Med | Rate-limit **fails open but bounded** (in-process per-account fallback limiter, logged + metric); idempotency **fails closed** (`503`, retryable via caller outbox); idempotency keyspace requires a **non-evicting `maxmemory-policy` (`noeviction`)** or a dedicated Redis/db so a rate-limit eviction can never silently drop an in-flight idempotency lock (which would violate at-most-once) |
| R6 | **Content/identity leak** despite design | Low | Critical | Structural: `DisallowUnknownFields`, content-free schema, data-only FCM, badge-off-by-default, redaction in middleware, op-logs hold only `token_hash` |
| R7 | **Cross-tenant push** (one account targeting another's topic/token) | Low | High | Per-account allowlist enforced after auth; namespaced Redis keys; per-account-scoped allowlist cache |
| R8 | **Stolen relay API key** (incl. abuse-amplification during a Redis outage, and per-IP-throttle bypass via forged forwarding headers) | Low | Med | Revocable keys (`relayctl key revoke`), per-account rate caps + allowlist bound spend; **bounded** fail-open (in-process per-account cap when Redis is down — never unlimited); per-IP auth-failure throttling keyed on a **trusted-proxy-resolved client IP** (caller-supplied `X-Forwarded-For`/`X-Real-IP` ignored) |
| R9 | **`pushwire` drift** between relay and `silo-server` | Med | Med | One **canonical source repo**; mirror is a checked-in generated artifact with a committed **checksum** verified in CI on **both** sides; golden-file + closed-allowlist-key tests fail CI on divergence regardless of regeneration order |
| R10 | **APNs TLS root rotation** (USERTrust) | Low | Low | Use `api.sandbox.push.apple.com` (the `api.development.*` alias is equivalent and still resolves — not a risk); rely on the standard trust store (already includes USERTrust RSA) |
| R11 | **FCM high-priority deprioritization** over 7-day window (reference §2.8) | Low | Low | Honor `03`'s `priority:high` for `private_data` (app surfaces a notification); caller-side `priority:normal` fallback is the lever (open Q#10) |
| R12 | **Op-log volume** at 50k/day/account across many accounts (unbounded table → disk fills) | Med | Low | **Shipped** retention GC (`relayctl logs --gc` / background sweeper: 14d success / 90d failure) on a schedule with an owner; time-partitioning DDL (or a documented partition/offload decision); table-growth/disk alert |
### 8.2 Milestone sequencing & rough effort
| Milestone | Phases | Effort | Provider creds needed | Parallelizable |
|---|---|---|---|---|
| **M0 — Skeleton green** | Phase 0 | ~M (1 wk) | No | — |
| **M1 — Storage + admin** | Phase 1 | ~L (1 wk) | No | with §7 provisioning |
| **M2 — Request path (no upstream)** | Phase 2 | ~L (1 wk) | No | with §7 provisioning |
| **M3 — APNs live (sandbox)** | Phase 3 | ~L (1 wk) | **Apple `.p8`** | Phase 3 ∥ Phase 4 |
| **M4 — FCM live (validateOnly)** | Phase 4 | ~L (1 wk) | **Firebase SA** | Phase 4 ∥ Phase 3 |
| **M5 — Observability** | Phase 5 | ~M (3 d) | No | — |
| **M6 — Hardened + deployed** | Phase 6 | ~L (1 wk) | All + secret manager | — |
**Rough total:** ~6–7 engineer-weeks of code, with **§7 provisioning running in parallel from day 1** as the gating long pole for M3/M4/M6. Phases 3 and 4 can be split across two engineers once Phase 2 lands. The critical path is: M0 → M1 → M2 → (M3 ∥ M4) → M5 → M6, intersected with provisioning availability.
---
## 9. Open Questions (carried from spec §15)
These do not block starting; resolve at the noted phase.
1. **Per-account rate limits — real numbers** (resolve from logs post-M6; spec §9.2). v1 ships ~10 req/s burst + 50k/day.
2. **APNs sandbox hostname** (resolved — note only). Use `api.sandbox.push.apple.com`; `api.development.push.apple.com` is an equivalent, still-resolving sandbox alias. The two are interchangeable, so no build-time resolution is required — this is a standardization note, not an open question.
3. **`apns2` vs hand-rolled APNs client** — decide before Phase 3 (§3, reference §3.1).
4. **FCM credentials: GCP-keyless vs off-GCP JSON** — resolve in deployment design (Phase 6).
5. **`apns-expiration` / `android.ttl` defaults** (largely resolved). v1 ships a **finite** default (not omitted/30-day): a few hours for `private_alert`, short TTL for `background_wake`, with a matching finite FCM `android.ttl`; both configurable. Only the exact numeric values want product input.
6. **API-key hashing: HMAC pepper vs plain salted SHA-256** — plan chooses HMAC pepper; confirm rotation tradeoff (Phase 1).
7. **Idempotency-Key spec status** — header name + Stripe-style semantics are de-facto; re-check for an RFC.
8. **Op-log volume & retention** (resolved into a Phase 5 deliverable). Ship `relayctl logs --gc` (or a background sweeper): 14d success / 90d failure, on a schedule with an owner, plus time-partitioning DDL (or a documented partition/offload decision) and a table-growth alert — not deferred.
9. **Multi-region** — out of scope for v1; design later if latency to distant self-hosters matters.
10. **FCM priority deprioritization** — honor `03`'s `priority:high`; revisit only if metrics show systematic deprioritization.
@@ -0,0 +1,554 @@
# APNs & FCM 2026 Reference — Privacy-Preserving Push Relay
> **Provenance.** This document reflects **2026-current** Apple (APNs) and Google (FCM HTTP v1) developer documentation, plus the current Go ecosystem and OWASP guidance. Every non-obvious factual claim carries an inline citation in the form `(source: URL, date)`. Where a primary source disagreed with secondary/blog material, the primary source wins and the secondary claim is flagged as inaccurate. Where a fact remains uncertain, it is called out explicitly rather than overstated.
>
> **Compile date:** _<!-- PLACEHOLDER: set at compile time -->_
>
> **Commands assume the repository root is the cwd.** No local absolute filesystem paths appear in this document; all repo references are repository-relative.
## Scope
This is the factual foundation for a self-hosted, privacy-preserving push **relay** — a server that accepts notification requests from authenticated tenants and forwards them to Apple Push Notification service (APNs) and Firebase Cloud Messaging (FCM) on their behalf, without the providers (or other tenants) learning more than necessary. It captures the wire-level contracts (auth, endpoints, headers, payloads, errors, throttling), the recommended Go libraries and tuning, and the security/ops practices the spec and implementation plan are written against. It does **not** cover client-side SDK integration except where client behavior (token rotation, notification-permission gating) constrains relay design.
---
## 1. APNs (2026)
### 1.1 Provider authentication — token-based (JWT, ES256)
APNs supports **both** token-based and certificate-based provider authentication over HTTP/2 + TLS in 2026; Apple does **not** deprecate certificate-based auth. Both have dedicated docs and no deprecation notice (source: https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server, 2026-06-13). The relay should use **token-based** (`.p8`) auth: the signing key does not expire (it can be revoked), one key covers sandbox + production and all/subset of a team's apps, and there is no yearly certificate renewal.
> **Correction vs. common blog claims:** Third-party posts claiming Apple is "deprecating / shutting down certificate-based auth in 2025" are **inaccurate**. The only confirmed 2025 changes are (a) the APNs **server** TLS root-CA rotation (§1.7) and (b) the optional token-key hardening features (§1.2). Neither deprecates provider auth (source: https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server, 2026-06-13).
**JWT structure.** APNs supports **only the ES256** algorithm (ECDSA over P-256); no other algorithm is accepted (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13).
```jsonc
// JWT header
{
"alg": "ES256", // ES256 ONLY
"kid": "ABC123DEFG" // 10-char Key ID from the developer account
}
// JWT claims
{
"iss": "DEF123GHIJ", // 10-char Team ID
"iat": 1437179036 // issued-at, seconds since Epoch (UTC)
}
```
The JWT is signed with the `.p8` ECDSA P-256 private key using ES256. It is sent on **every** notification request as an HTTP/2 header in Base64URL-encoded JWT form (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13):
```http
authorization: bearer eyAia2lkIjog...MEYCIQDz...
```
**Lifecycle & regeneration cadence (critical):**
| Rule | Value | Source |
|---|---|---|
| Max token age (`iat`) | ≤ 1 hour, else `ExpiredProviderToken` (403) | (source: …/establishing-a-token-based-connection-to-apns, 2026-06-13) |
| Refresh **no more** often than | once every **20 minutes** (per connection) | (same) |
| Refresh **no less** often than | once every **60 minutes** | (same) |
| Over-frequent refresh → | `TooManyProviderTokenUpdates` (429) | (source: …/handling-notification-responses-from-apns, 2026-06-13) |
**Relay pattern:** Generate **one** ES256 JWT, cache it in memory behind a mutex, and reuse it across **all** senders/connections/topics. Regenerate on a ~50-minute timer (well inside the 1-hour window, never < 20 min apart), or eagerly on a `403 ExpiredProviderToken`. Never sign a fresh JWT per request.
**One key, many servers.** A single key/token can be reused across multiple provider servers, multiple connections, and all (or a subset) of a team's apps/topics — confirmed verbatim by Apple: "You can use the same token from multiple provider servers" and "You can use one token to distribute notifications for all or a subset of your company's apps" (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13). **Caveat:** a connection cannot map to multiple teams — "If you manage APNs for multiple developer accounts, open separate connection pools for each of them" (same source). For a multi-tenant relay, this means **per-team connection pools**.
### 1.2 Token-key hardening (Feb 2025, optional)
On **2025-02-17** Apple added optional scoped token keys; the classic unrestricted team key still works, and "you don't have to update your keys unless you want to take advantage of the new capabilities" (source: https://developer.apple.com/news/?id=wy4tb0uo, 2025-02-17). Two new key types:
| Key type | Scope | Limit |
|---|---|---|
| **Team-scoped key** | Sandbox **or** Production only; all topics in the team | max **2 keys per environment** |
| **Topic-specific (topic-based) key** | Bound to specific bundle IDs (least privilege) | max **200 keys for Sandbox and 200 for Production, with up to 400 topics per topic-based key** |
(source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13 — verbatim scoped-key limit confirmed). For a multi-tenant relay these are a meaningful isolation lever: issue separately scoped `.p8` keys per environment/app rather than one all-powerful key.
### 1.3 Endpoints & HTTP/2 connection management
| Environment | Host | Ports |
|---|---|---|
| Production | `api.push.apple.com` | 443 or 2197 |
| Development / sandbox | `api.sandbox.push.apple.com` | 443 or 2197 |
(source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13). Port 2197 is functionally equivalent to 443 and exists so firewalls can allow APNs while blocking other 443 traffic — keep it as a fallback. `api.push.apple.com` (production) and `api.sandbox.push.apple.com` (sandbox) are the current canonical hosts named in Apple's docs. The older `api.development.push.apple.com` is a **still-valid equivalent alias for sandbox**: as of 2026-06-13 both names resolve to the same Akamai CNAME (`api-sand-vs.push-apple.com.akadns.net`) and the same IP set (verified by DNS, e.g. `17.188.168.x`), so they hit the identical sandbox backend. We **standardize on `api.sandbox.push.apple.com`** (it matches the canonical docs and `sideshow/apns2` v0.25.0's `HostDevelopment` constant), but the alias is not dead — see §5.
- **Transport:** HTTP/2 with **TLS 1.2 or later** (TLS 1.3 acceptable). `:method POST`, `:path /3/device/<device_token>` where the token is the hex bytes of the device token (source: …/sending-notification-requests-to-apns, 2026-06-13).
- **Connection reuse:** "Reuse a connection as long as possible. In most cases, you can reuse a connection for many hours to days. If your connection is mostly idle, you may send a HTTP2 PING frame after an hour of inactivity." Reuse reduces bandwidth/CPU; rapid connect/disconnect is treated as DoS-like behavior (source: …/sending-notification-requests-to-apns, 2026-06-13).
- **Stream multiplexing:** "APNs allows multiple concurrent streams for each connection, but don't assume a specific number of streams." Read the server's `SETTINGS_MAX_CONCURRENT_STREAMS`; do not hardcode. The exact number varies by server load and auth type (same source). A single well-tuned connection handles thousands of pushes/sec.
- **GOAWAY handling:** When APNs terminates a connection it sends an HTTP/2 `GOAWAY` frame whose payload includes JSON with a `reason` key. Apply RFC 9113 §6.8: stop opening new streams, finish in-flight streams with ID ≤ `Last-Stream-ID`, open a new connection for the rest; `NO_ERROR` = graceful shutdown, any other code = connection error (source: https://www.rfc-editor.org/rfc/rfc9113#section-6.8, 2022-06). The relay should drop a GOAWAY'd connection from the pool and transparently reconnect. Go's `net/http2` transport does most of this automatically when one `http.Client` is reused.
```mermaid
flowchart LR
R[Relay sender goroutines] -->|stream 1..N over 1 conn| C{HTTP/2 conn pool}
C -->|POST /3/device/token| A[api.push.apple.com:443]
A -. GOAWAY reason JSON .-> C
C -->|drop + reconnect| C2[new conn]
C2 -->|in-flight <= Last-Stream-ID finish on old conn| A
```
### 1.4 Request headers
`:method = POST`, `:path = /3/device/<device_token>`. Payload is **uncompressed** JSON.
| Header | Required | Allowed values / format | Notes |
|---|---|---|---|
| `authorization` | yes (token auth) | `bearer <ES256 JWT>` | Ignored on cert connections |
| `apns-topic` | yes | bundle ID, optionally with a type suffix (see §1.5) | `MissingTopic`/`BadTopic` (400) on error |
| `apns-push-type` | required on **watchOS 6+**, strongly recommended elsewhere | `alert`, `background`, `controls`, `fileprovider`, `liveactivity`, `location`, `mdm`, `pushtotalk`, `voip`, `widgets`, `complication` | Must match payload; invalid → `InvalidPushType` (400) |
| `apns-priority` | no (default 10) | `10`, `5`, `1` | See §1.4.1; invalid → `BadPriority` (400) |
| `apns-expiration` | no | UNIX epoch seconds (UTC) | `0` = deliver once, no store; nonzero = store & retry up to **30 days**; invalid → `BadExpirationDate` (400) |
| `apns-collapse-id` | no | ≤ **64 bytes** | Same value merges notifications on-device (latest wins); >64B → `BadCollapseId` (400) |
| `apns-id` | no | canonical UUID (8-4-4-4-12, lowercase hex) | Echoed in response; auto-generated if omitted; invalid → `BadMessageId` (400) |
(source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13). `apns-push-type` required on watchOS 6+ confirmed (source: https://developer.apple.com/documentation/usernotifications, 2026-06-13).
**`apns-topic` suffixes by push type** (source: …/sending-notification-requests-to-apns, 2026-06-13):
| Push type | `apns-topic` suffix |
|---|---|
| `complication` | `.h.complication` |
| `controls` | `.push-type.controls` |
| `fileprovider` | `.pushkit.fileprovider` |
| `liveactivity` | `.push-type.liveactivity` |
| `location` | `.location-query` |
| `pushtotalk` | `.voip-ptt` |
| `voip` | `.voip` |
| `widgets` | `.push-type.widgets` |
| `alert` / `background` | (none — bare bundle ID) |
#### 1.4.1 `apns-priority` semantics (corrected)
`apns-priority` is optional; if omitted, APNs sets priority to 10. Three values, with these exact semantics (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13):
| Value | Meaning |
|---|---|
| `10` | Send the notification **immediately** (use for alert/sound/badge). |
| `5` | Send based on **power considerations** on the user's device. |
| `1` | Prioritize power over all factors and **do not wake the device** (lowest-power). |
- For `apns-push-type=background`, Apple's guidance is explicit and **narrower** than some sources state: "**Always use priority 5. Using priority 10 is an error.**" Apple does **not** list priority `1` as an accepted alternative for background pushes (source: same, 2026-06-13).
- The claim that priority `1` is a "new tier for Live-Activity-style delivery" is **not supported** by Apple's docs. Live Activity updates use `apns-push-type=liveactivity` (typically priority 10 for immediate or 5 for throttled), not a priority-1 mechanism. Priority `1` is simply the lowest-power option (source: same, 2026-06-13).
### 1.5 Payload shapes & size limits
**Size limits** (source: https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification, 2026-06-13):
| Type | Max payload |
|---|---|
| Regular (alert/background/etc.) | **4096 bytes (4 KB)** |
| VoIP (PushKit) | **5120 bytes (5 KB)** |
Oversize → `413 PayloadTooLarge`. Payload must **not** be compressed. Live Activity payloads use the standard 4 KB limit.
**Payload root** = reserved `aps` dictionary + optional custom keys. `aps` keys (source: same, 2026-06-13): `alert`, `badge`, `sound`, `thread-id`, `category`, `content-available`, `mutable-content`, `target-content-id`, `interruption-level`, `relevance-score`, `filter-criteria`.
**Alert (user-facing) example:**
```json
{
"aps": {
"alert": { "title": "Title", "subtitle": "Sub", "body": "Body text" },
"badge": 1,
"sound": "default",
"thread-id": "thread-42",
"interruption-level": "active",
"relevance-score": 0.8
},
"custom-key": "opaque-relay-payload"
}
```
The `alert` sub-dictionary supports `title`/`subtitle`/`body` plus localization keys (`loc-key`/`loc-args`, `title-loc-key`/`title-loc-args`, `subtitle-loc-key`/`subtitle-loc-args`), `launch-image`, and the legacy `action-loc-key` (source: same, 2026-06-13).
`interruption-level` accepts exactly: `passive`, `active` (default), `time-sensitive` (needs Time Sensitive Notifications entitlement), `critical` (needs Critical Alerts entitlement, Apple-approved). Critical sound uses a **dictionary**: `{ "critical": 1, "name": "...", "volume": 0.0–1.0 }` (source: same, 2026-06-13).
**Background (silent) example** — must set `apns-push-type=background`, `apns-priority=5`, and an `aps` containing **only** `content-available: 1` (no `alert`/`badge`/`sound`):
```json
{ "aps": { "content-available": 1 } }
```
(source: https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app, 2026-06).
**Live Activity** remote pushes use `apns-push-type=liveactivity`, `apns-topic=<bundleID>.push-type.liveactivity`, **token-based auth only**. The `aps` adds: `event` (`start`|`update`|`end`), `content-state` (decodes into the app's `ActivityAttributes.ContentState`), `timestamp`, `stale-date`, `dismissal-date` (UNIX seconds), optional `relevance-score` and `alert`. `attributes-type` + `attributes` are included **only** for `event: start` (push-to-start). For Live Activities, `relevance-score` is an **unconstrained Double** (Smart Stack ordering), not the 0–1 range used by `UNNotificationContent.relevanceScore` (source: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications, 2026-06).
### 1.6 Status codes & reason strings
**HTTP status codes** (source: https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns, 2026-06-13): 200, 400, 403, 404, 405, 410, 413, 429, 500, 503.
For unsuccessful requests the body is a JSON object `{"reason":"<String>"}`; for **410** it additionally includes a `timestamp` (Number, **milliseconds** since Epoch) indicating when the token became invalid for the topic (source: same).
| Status | Reason strings | Meaning / handling |
|---|---|---|
| 200 | — | Success. Echoed `apns-id` header; in **dev**, `apns-unique-id` header (response-only) for Push Notifications Console Delivery Log. |
| 400 | `BadCollapseId`, `BadDeviceToken`, `BadExpirationDate`, `BadMessageId`, `BadPriority`, `BadTopic`, `DeviceTokenNotForTopic`, `DuplicateHeaders`, `IdleTimeout`, `InvalidPushType`, `MissingDeviceToken`, `MissingTopic`, `PayloadEmpty`, `TopicDisallowed` | Bad request. **Do not retry.** `BadDeviceToken`/`DeviceTokenNotForTopic` ⇒ purge token. |
| 403 | `BadCertificate`, `BadCertificateEnvironment`, `ExpiredProviderToken`, `Forbidden`, `InvalidProviderToken`, `MissingProviderToken`, `UnrelatedKeyIdInToken`, `BadEnvironmentKeyIdInToken` | Auth/cert error. `ExpiredProviderToken` ⇒ regenerate JWT & retry once. `Invalid/MissingProviderToken` ⇒ config error (don't blind-retry). `UnrelatedKeyIdInToken` ⇒ open a new connection. |
| 404 | `BadPath` | Invalid `:path`. Don't retry. |
| 405 | `MethodNotAllowed` | Only `POST` allowed. Don't retry. |
| 410 | `Unregistered`, `ExpiredToken` | Token no longer active for topic. **Permanently stop sending** to that token (until app re-registers). Use the `timestamp` (ms): only purge if your registration is older than it. Not a connection error / no disconnection. |
| 413 | `PayloadTooLarge` | Payload exceeds 4 KB (5 KB VoIP). Don't retry. |
| 429 | `TooManyProviderTokenUpdates`, `TooManyRequests` | Back off. `TooManyProviderTokenUpdates` ⇒ slow JWT refresh (≤ once/20 min). `TooManyRequests` ⇒ per-device-token backoff. |
| 500 | `InternalServerError` | Transient. Retry after ~15 min with backoff. |
| 503 | `ServiceUnavailable`, `Shutdown` | Transient. Retry after ~15 min with backoff. |
(source: https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns, 2026-06-13).
**Retry policy summary.** Never retry permanent errors (`BadDeviceToken`, `Unregistered`, `ExpiredToken`, `PayloadTooLarge`, `DeviceTokenNotForTopic`, `Forbidden`); back off on 429; retry 5xx after ~15 min with backoff. 410 is **not** treated as a connection error (source: same, 2026-06-13). Always parse the JSON `reason`, not just the status code.
### 1.7 APNs server TLS root rotation (2025) — trust-store only
APNs migrated its **server** TLS certificate to a new SHA-2 root, **USERTrust RSA Certification Authority** (a Sectigo root): sandbox **2025-01-20**, production **2025-02-24** (source: https://developer.apple.com/news/?id=09za8wzy, 2024-10-17; effective per https://developer.apple.com/news/upcoming-requirements/?id=02242025a, 2025-02-24).
- This affects only how the relay **validates the APNs server TLS chain** — it does **not** require updating the APNs SSL provider certificates Apple issued you, nor app/account certs.
- Standard OS / Go system root pools (macOS/iOS, Linux `ca-certificates`, Go `crypto/x509` system pool) **already trust** USERTrust RSA, so no action is needed unless the relay **pins** or ships a **custom/minimal** trust store — in which case it must add this root or TLS handshakes fail after the cut-over dates.
- No newer APNs cert/infrastructure requirement is published on Apple's Upcoming Requirements page as of June 2026 (source: https://developer.apple.com/news/upcoming-requirements/?id=02242025a, 2025-02-24).
### 1.8 Background-push throttling
Background (`content-available`) pushes are best-effort and throttled. Apple's verbatim guidance: "The number of background notifications allowed by the system depends on current conditions, but don't try to send more than **two or three per hour**." The system may delay, coalesce, or drop them based on battery/network/usage (source: https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app, 2026-06). The older "throttle disabled when a debugger is attached" behavior is Apple-Forums developer-experience guidance, **not** in the current spec — do not rely on it.
### 1.9 Legacy binary protocol — dead
The legacy binary (TCP, ports 2195/2196) APNs protocol is fully retired; Apple set the migration deadline to the HTTP/2 provider API at **March 31, 2021** (source: https://developer.apple.com/news/?id=c88acm2b, 2020-10-09; earlier announced https://developer.apple.com/news/?id=11042019a, 2019-11-04). **Do not implement it.** There is no binary fallback in 2026.
### 1.10 Broadcast push (Live Activity channels) — situational
Broadcast push for Live Activities (channels) is current (iOS/iPadOS 18+). You `POST /4/broadcasts/apps/<bundleID>` over HTTP/2 + TLS 1.2+. Required headers: `apns-channel-id` (base64), `apns-push-type=liveactivity`, `apns-priority` (10/5/1), `apns-expiration`; optional `apns-request-id` (UUID, echoed back). Channels are managed via a **separate** channel-management API (`/1/apps/<bundleId>/channels`, `/1/apps/<bundleId>/all-channels`); limit 10,000 channels per app per environment (source: https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns, 2026-06-13).
> **Hostname nuance (correction):** Apple's broadcast **send** code samples use broadcast-specific hosts `api-broadcast.push.apple.com` (prod) / `api-broadcast.sandbox.push.apple.com` (sandbox) — **not** the plain `api.push.apple.com` used for `/3/device`. Channel **management** uses a third host pair `api-manage-broadcast(.sandbox).push.apple.com` on ports 2195/2196. Apple's own connection-section text confusingly lists the plain hosts (a documented inconsistency) (source: same, 2026-06-13). If broadcast is in scope, verify the exact host against Apple's code samples at build time.
---
## 2. FCM HTTP v1 (2026)
### 2.1 Legacy-API shutdown status
Sending with the legacy FCM HTTP and XMPP APIs was **deprecated 2023-06-20**, and **shutdown began 2024-07-22** — verbatim: "Sending messages (including upstream messages) with the FCM legacy APIs for HTTP and XMPP was deprecated on June 20, 2023, and shutdown begins on July 22, 2024" (source: https://firebase.google.com/docs/cloud-messaging/migrate-v1, 2025-05-29). The legacy HTTP endpoint (`fcm.googleapis.com/fcm/send`), XMPP (`fcm-xmpp.googleapis.com`), and legacy "server key" string auth are **gone**. Only **HTTP v1** remains.
The **batch send** endpoint (`fcm.googleapis.com/batch`) was also discontinued, shutdown date **2024-06-20** (source: same, 2025-05-29). For a raw-protocol relay there is **no batch endpoint** — send one `POST` per registration token and fan out via HTTP/2 multiplexing or a bounded concurrency pool.
### 2.2 OAuth2 service-account authentication
Auth is **OAuth2 service-account bearer tokens**. Recommended (least-privilege) scope: `https://www.googleapis.com/auth/firebase.messaging` (the broader `cloud-platform` works but is a superset) (source: https://firebase.google.com/docs/cloud-messaging/auth-server, 2025-05-29). Send the token as:
```http
Authorization: Bearer <oauth2_access_token>
```
**Token minting (RFC 7523 JWT-bearer grant):** build a JWT signed **RS256** with the service-account private key, then exchange it (source: https://developers.google.com/identity/protocols/oauth2/service-account, 2025):
```jsonc
// JWT claims for the token exchange
{
"iss": "<service-account-email>",
"scope": "https://www.googleapis.com/auth/firebase.messaging",
"aud": "https://oauth2.googleapis.com/token",
"iat": 1700000000,
"exp": 1700003600 // iat + up to 3600s (max 1 hour)
}
```
```http
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<signed_JWT>
```
Response: `{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600 }`. **Access tokens are valid 3600s (1 hour).** Cache and re-mint before expiry (≈5-minute safety margin); never mint per request (source: same, 2025). Prefer a maintained Google auth library over hand-rolling.
**Credential resolution (ADC):** `GOOGLE_APPLICATION_CREDENTIALS` env var (path to SA JSON) → platform attached/default SA → error. Firebase: the JSON "should be kept private. Do not add it to public repos!" (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06). On GCP, prefer keyless auth (§4.5).
### 2.3 Endpoint & message JSON
```http
POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
Authorization: Bearer <oauth2_access_token>
Content-Type: application/json
```
The path accepts the project ID **or** project number. Host is global `fcm.googleapis.com`; **no** regional/data-residency send endpoints exist as of June 2026, and no new required top-level fields were introduced through 2025–2026 (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06-12).
**Request body** = `{ "validateOnly"?: bool, "message": Message }`. `message` is **required** and must carry **exactly one** target: `token`, `topic`, or `condition` (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current). For a relay sending to one device, use `message.token`.
```jsonc
{
"message": {
"token": "<registration_token>",
"data": { "k1": "v1", "k2": "v2" }, // map<string,string> ONLY
"android": { "priority": "HIGH", "ttl": "0s", "collapseKey": "sync" },
"apns": { "headers": { "apns-priority": "10" }, "payload": { "aps": { /* ... */ } } }
}
}
```
**Data-only emphasis (relay-critical):** A message with a `notification` block is **auto-displayed** by the FCM SDK when the app is backgrounded/killed, and the app code does **not** run until the user taps it. A **data-only** message (no `notification` block) is **always** delivered to the app's handler (`onMessageReceived` / background handler) and never auto-displayed — full app control in foreground and background. **A relay that needs its own logic/custom display on every message should send DATA-ONLY messages** (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/set-message-type, 2025-05-29).
**`message.data` is `map<string,string>` — all values MUST be strings** (no nested objects/numbers/booleans; JSON-stringify anything structured). Reserved/forbidden keys: `from`, `message_type`, `notification`, and any key prefixed `google.`/`gcm.` (or reserved words) → `INVALID_ARGUMENT`. `android.data` overrides top-level `data` for Android (source: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages, 2025-05-29; https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12).
**Payload size:** max **4096 bytes** for token/device sends, **2048 bytes** for topic sends; over → `INVALID_ARGUMENT` (source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12).
### 2.4 AndroidConfig
`message.android` (REST camelCase) (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current):
| Field | Type / values | Notes |
|---|---|---|
| `priority` | `"NORMAL"` \| `"HIGH"` (lowercase also accepted) | Default: NORMAL for data messages, HIGH for notification messages. HIGH attempts immediate delivery and can wake a dozing device. |
| `collapseKey` | string | Group id; only the last queued message delivered on reconnect. **Not** a request-validation limit: FCM *stores* at most **4** distinct collapse keys per device while the device is offline; a 5th evicts one nondeterministically (a delivery/storage behavior — see §2.7). The API does **not** reject a request for using a 5th distinct key. |
| `ttl` | Duration string in seconds with `s` suffix, e.g. `"3s"`, `"3.000000001s"` | `"0s"` = deliver-now-or-drop. Range **0 – 2,419,200s (28 days)**; out-of-range → `INVALID_ARGUMENT`. |
| `restrictedPackageName` | string | Deliver only if the token's app package matches. |
| `directBootOk` | bool | Allow delivery during Direct Boot. |
| `data` | map<string,string> | Overrides `message.data` for Android. |
Newer **optional** fields exist (`bandwidthConstrainedOk`, `restrictedSatelliteOk`); admin-SDK support landed ~March 2026; both are documented Optional and do not change the required contract (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06-12).
### 2.5 `validateOnly` (dry-run)
Set top-level `validateOnly: true` to fully validate the request (token format, payload size, field correctness) and return a message-id-shaped response **without delivering**. Default `false` (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current). Use in CI/tests.
### 2.6 Error model
Error body shape (source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12):
```json
{ "error": { "code": 404, "message": "...", "status": "UNREGISTERED",
"details": [ { "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED" } ] } }
```
Classify on `error.status` / the FcmError `errorCode`, not just the HTTP code.
| HTTP | `error.status` | Meaning | Handling |
|---|---|---|---|
| 400 | `INVALID_ARGUMENT` | Malformed payload **or** bad token (bad format, mismatched package, oversize, reserved data key, bad TTL). `details` = `google.rpc.BadRequest`. | **Do not retry.** Delete token **only** if payload is independently known-valid; otherwise fix payload. |
| 401 | `THIRD_PARTY_AUTH_ERROR` | Invalid/missing APNs cert/key or Web Push auth credential. | Fix credentials (ops alert). **Do not retry.** |
| 403 | `SENDER_ID_MISMATCH` | Authenticated sender ID ≠ sender that registered the token. | Send from the correct project; permanent. **Do not retry.** |
| 404 | `UNREGISTERED` | App unregistered (uninstall, APNs token invalid, 270-day Android inactivity, restore). | **Permanently delete the token.** Never retry. |
| 429 | `QUOTA_EXCEEDED` (RESOURCE_EXHAUSTED) | Project / device / topic rate quota exceeded. `details` = `google.rpc.QuotaFailure` naming which quota. | Retry with exponential backoff; min initial delay **1 minute** for project- and topic-rate cases. |
| 500 | `INTERNAL` | Unknown internal error. | Retry per "Handling retries"; **no** documented `Retry-After`. |
| 503 | `UNAVAILABLE` | Server overloaded / temporarily unavailable. | Retry with backoff + jitter; **honor `Retry-After`** if present. |
| (none) | `UNSPECIFIED_ERROR` | Catch-all; "No more information is available about this error." | Conservative: **do not** auto-delete token; surface for investigation. |
(source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12 / 2026-06-13).
> **Corrections / precision notes:**
> - `QUOTA_EXCEEDED` does **not** carry a `google.rpc.RetryInfo` per FCM's own page — only `google.rpc.QuotaFailure` is promised. General Google API guidance allows `RetryInfo`; treat its presence as optional and fall back to `Retry-After`/60 s (source: same, 2026-06-13).
> - `INVALID_ARGUMENT` (400) is **ambiguous**: it can mean malformed payload **or** bad token. Only treat it as a dead token when the payload is independently validated (source: same, 2026-06-12).
> - Strictly, `UNSPECIFIED_ERROR` is the `FcmError.errorCode` inside `details[]`, not the top-level google.rpc `status` — operationally equivalent (source: same, 2026-06-13).
### 2.7 Retry-After, backoff, quotas, throttling
**Retry classification at scale** (source: https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12):
- **Never retry** 400 / 401 / 403 / 404 — "abort, and do not retry."
- **429:** "retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to **60 seconds**."
- **500 / 503:** "retry with exponential back-off" (+ jitter; honor `Retry-After` on 503 if present).
- "Wait at least **10 seconds** before retrying a failed request"; set ≥10 s send timeout; cap retries; a request still failing after **60 minutes** is an outage/miscategorization, not something to retry forever.
**Exponential backoff must use jitter** ("varying the retry delays through a random process"). **Smooth traffic:** ramp 0→max RPS over ≥60 s, and "avoid sending messages within a 2 minute window of each of the :00, :15, :30, and :45 minute marks" to dodge synchronized spikes (source: same, 2026-06-12).
**Project quota.** Default per-project downstream quota is **600,000 messages per minute** ("covers over 99% of FCM developers"), enforced via a **600K-token 1-minute token bucket that refills to full at the end of each (non-clock-aligned) minute**. Over quota → `429 RESOURCE_EXHAUSTED` (`QUOTA_EXCEEDED`) until refill. **The quota measures messages, not requests.** Client errors (HTTP 400–499) count against quota **except 429s** (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13; corroborated https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12).
> **Correction:** The research claim that "different message types cost different token amounts" is **not** documented and is treated as inaccurate — quota is counted **per message** regardless of type; topics are merely more efficient because one request fans out to many subscribers (source: https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12).
**Per-device Android limits:** up to **240 messages/minute** and **5,000 messages/hour** to a single device (headroom for bursts, e.g. rapid chat). On iOS, FCM returns an error when APNs per-device limits are exceeded (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
> **Correction:** Exceeding the **per-device** Android caps is **not** reported as `QUOTA_EXCEEDED` (429). `429 / QUOTA_EXCEEDED` is the **project-level** per-minute quota. Firebase's throttling/quotas page does **not** specify a particular error code or status string for the per-device caps — it only says "For iOS, we return an error when the rate exceeds APNs limits." Secondary sources name the per-device signal `DeviceMessageRateExceeded`, but this string is **not** firmly documented on a primary Google page, so **classify it defensively** rather than matching an exact literal (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
>
> **Relay handling (per-device rate).** Because the exact code/string is uncertain, the relay should treat any per-device-rate signal — whether surfaced as an `INVALID_ARGUMENT`-adjacent error, a quota-style `RESOURCE_EXHAUSTED` that names a per-device (not project) quota in `details`, or a `DeviceMessageRateExceeded`-shaped string — as a **transient, retryable rate condition**: map it to a caller-facing **429 with a conservative default `Retry-After`** and **never** as a token purge (the token is healthy; only the rate is too high). Detect on `error.status` / the `FcmError.errorCode` and the `QuotaFailure` scope, not on a hardcoded string. The relay's whole purpose is fanning bursty per-device wakes, so this is a plausible real outcome that must have a defined mapping rather than falling through to `UNSPECIFIED_ERROR`/502.
**Collapsible-message throttling:** burst of **20 per app per device**, refill **1 every 3 minutes** (source: same, 2026-06-13).
> **Correction:** Exceeding the collapsible burst is a **silent delay** (battery protection), **not** a `QUOTA_EXCEEDED` error: "we delay messages to reduce the impact on a user's battery" (source: same, 2026-06-13).
**Distinct collapse keys:** FCM stores at most **4** different collapsible messages per device (one per distinct `collapse_key`); a 5th evicts a key nondeterministically. Default `collapse_key` = app package name (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/collapsible-message-types, 2026-06-12).
> **Design implication (collapse-key cap vs. per-series keys).** If the relay derives `collapse_key` per notification series (e.g. a per-series HMAC), a single device that follows **more than 4 active series** will exceed FCM's 4-distinct-key store, and excess distinct keys evict each other nondeterministically — so **collapsible coalescing is best-effort**, not guaranteed, once a device crosses 4 series. This is acceptable only if a dropped collapsible wake is recoverable (e.g. the caller's durable inbox + sync covers it); otherwise the caller should cap distinct `collapse_key`s per device. **Asymmetry to note:** APNs has **no** documented per-device cap on the number of distinct `apns-collapse-id` values, so this 4-key eviction behavior is FCM-only — the two providers are not symmetric here, and the cross-provider design docs should reconcile this rather than assume parity.
**Non-collapsible offline storage (Android):** up to **100** messages stored while offline; beyond that, **all** stored messages are discarded and the device gets a single "contact your server" signal on reconnect. Do not treat FCM as a durable queue; the app must re-sync (source: same, 2026-06-12).
**Quota increases:** self-serve up to **+25%** in Google Cloud Console (gated on ≥80% usage for ≥5 consecutive minutes/day, <5% client-error ratio at peak, scale best practices). Larger/temporary bumps via Firebase Support: ≥15 days lead (≥30 days for >18M msgs/min), ≤2 events/year, ≤30-day total duration (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
### 2.8 High-priority data-message behavior & power management
- **Normal priority:** delivered immediately when awake; in Doze, batched to maintenance windows. **High priority:** FCM attempts immediate delivery, can wake a sleeping device, grants temporary network + a partial wakelock (source: https://developer.android.com/training/monitoring-device-state/doze-standby, 2024-07-28).
- **Deprioritization:** A high-priority message that does **not** produce a user-facing notification gets **deprioritized to normal** (and may be throttled): "If FCM detects a pattern in which messages don't result in user-facing notifications, your messages may be deprioritized to normal priority." FCM evaluates ~7 days of per-app-instance behavior (source: https://firebase.blog/posts/2025/04/fcm-on-android/, 2025-04-17). **Do not use high priority for silent data-only syncs; use normal priority and reserve high priority for messages that produce a notification.**
- **App Standby Buckets:** high-priority delivery is still subject to the device's standby-bucket quota; once exhausted, further high-priority messages are effectively treated as normal. Exact per-bucket numeric caps are **not published** by Google and are dynamic/OEM-dependent (source: https://firebase.blog/posts/2019/02/life-of-a-message/, 2019-02-26).
- **Background-restricted apps:** receive **no** FCM messages (any priority, any state) until launched to the foreground (source: https://developer.android.com/training/monitoring-device-state/doze-standby, 2024-07-28).
- **Android 13+ (`POST_NOTIFICATIONS`):** notifications require the runtime `POST_NOTIFICATIONS` permission; without it, delivered notification messages are silently dropped. If the app's first notification channel is created while in the background (which the FCM SDK does on first receipt), Android won't display or prompt until the next app open — so early notifications are **lost**. **Data-message delivery to `onMessageReceived` is unaffected** (source: https://developer.android.com/develop/ui/views/notifications/notification-permission, 2024-08-08).
### 2.9 Registration-token lifecycle
- **Rotation hook:** `onNewToken()` fires on initial generation and every refresh (including FCM-initiated refresh when "the security of the previous token had been compromised"). Tokens also change on reinstall, data-clear, device restore. Persist the token to your server inside `onNewToken` (source: https://firebase.google.com/docs/cloud-messaging/manage-tokens, 2026-06-13).
- **Android inactivity expiry:** "When stale tokens reach **270 days** of inactivity, FCM considers them expired." iOS/web rely on APNs/Web Push (no inactivity-based expiry), so cleanup there is driven by send-time error responses (source: same, 2026-06-13).
- **Freshness strategy:** store each token **with a server timestamp** (FCM SDKs don't provide one), refresh ~**monthly** (no benefit more often than weekly), skip tokens outside your staleness window before sending, and delete on `UNREGISTERED` (404) or on `INVALID_ARGUMENT` (400) **when the payload is verified valid** (source: same, 2026-06-13).
---
## 3. Go ecosystem (2026)
> **Version targeting:** `jackc/pgx v5.10` and `firebase.google.com/go/v4 v4.20` both require **Go 1.25+**, so target **Go 1.25+** for the whole relay (source: https://pkg.go.dev/github.com/jackc/pgx/v5, 2026-06-03; https://pkg.go.dev/firebase.google.com/go/v4/messaging, 2026-05-14).
### 3.1 APNs client
| Choice | Version | Rationale |
|---|---|---|
| `github.com/sideshow/apns2` | **v0.25.0** (tagged 2024-10-25 UTC; pkg.go.dev shows "Oct 24, 2024" due to TZ rounding) | De-facto Go APNs HTTP/2 client. Auto-manages ES256 JWT generation/refresh and HTTP/2 connection reuse. Host constants match Apple. **Hold ONE `*apns2.Client` per process/environment; never recreate per push** (TLS setup is slow). One client "can potentially do 4,000+ pushes per second." (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2024-10-25) |
| Hand-rolled (alternative) | `net/http` + `golang.org/x/net/http2` (**v0.56.0**, 2026-06-09) + `golang-jwt/jwt/v5` (**v5.3.1**) | Full control of the HTTP/2 transport; mirror apns2: parse `.p8` EC key, sign one ES256 JWT, cache ~50 min, reuse one `*http2.Transport` with `ReadIdleTimeout`/`PingTimeout` set. (source: https://pkg.go.dev/github.com/golang-jwt/jwt/v5, 2026-01-01; https://pkg.go.dev/golang.org/x/net/http2, 2026-06-09) |
`apns2` internals (verified against v0.25.0 source): `HostProduction = "https://api.push.apple.com"`, `HostDevelopment = "https://api.sandbox.push.apple.com"`; `NewTokenClient(*token.Token{AuthKey, KeyID, TeamID})` / `NewClient(cert)`; chain `.Production()`/`.Development()`. `token.TokenTimeout = 3000` (50 min, deliberately under Apple's 1-hour window); `GenerateIfExpired()` regenerates under a mutex. Timeouts: `HTTPClientTimeout=60s`, `ReadIdleTimeout=15s`, `TCPKeepAlive=15s`, `TLSDialTimeout=20s`. `ClientManager` (MaxSize 64, MaxAge 10m) pools clients across multiple certs/keys (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2024-10-25; token/token.go, same).
> **Maintenance caveat:** `apns2` has had no release since v0.25.0 (Oct 2024); bus-factor risk. Verify recent commit/issue activity before committing vs. the hand-rolled path (see §5, item 2).
### 3.2 FCM / OAuth2
Pick **exactly one** of these two FCM approaches, not both:
| Choice | Version | Rationale |
|---|---|---|
| `golang.org/x/oauth2/google` (lighter) | **v0.36.0** (2026-02-11) | Best for a **fixed/known payload**: `CredentialsFromJSONWithType(ctx, jsonKey, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` → `creds.TokenSource` already caches+auto-refreshes; POST your own JSON to the v1 endpoint with a tuned HTTP/2 client. Fewer deps; full control of connection reuse; **you implement retries/error-classification**. Note: `CredentialsFromJSON` is deprecated in favor of `CredentialsFromJSONWithType`. (source: https://pkg.go.dev/golang.org/x/oauth2/google, 2026-02-11) |
| `firebase.google.com/go/v4/messaging` (batteries-included) | **v4.20.0** (2026-05-14, Go 1.25+) | Auto-mints/caches OAuth token, auto-retries transient errors, rich error helpers: `IsUnregistered` (drop token), `IsQuotaExceeded`, `IsUnavailable`, `IsInvalidArgument`, `IsThirdPartyAuthError`, `IsSenderIDMismatch`. Use `Send` / `SendEach` / `SendEachForMulticast` (each = one HTTP call per message, ≤500). **`SendAll`/`SendMulticast` are deprecated (and removed in newer SDK majors)** — do not use. (source: https://pkg.go.dev/firebase.google.com/go/v4/messaging, 2026-05-14) |
For a privacy-preserving relay forwarding opaque tenant payloads, the **`x/oauth2/google` + custom HTTP/2 client** path is usually preferable: it avoids the heavy Admin SDK and gives direct control of connection reuse and error handling.
### 3.3 HTTP/2 tuning (both transports)
`golang.org/x/net/http2.Transport` (**v0.56.0**, 2026-06-09) knobs (source: https://pkg.go.dev/golang.org/x/net/http2, 2026-06-09):
- `ReadIdleTimeout` (~10–15 s): if no frame received for this duration, send a PING health check (0 = disabled).
- `PingTimeout` (~15 s, default): close the conn if no PING response arrives.
- `WriteByteTimeout`: close if no bytes can be written within the timeout.
- `StrictMaxConcurrentStreams` (bool): if `false` (default) new TCP conns open to stay under each conn's `SETTINGS_MAX_CONCURRENT_STREAMS`; if `true` the server limit is global and callers block.
**Setting a non-zero `ReadIdleTimeout` + `PingTimeout` is the single most important tuning for a long-lived relay** — it reaps half-open APNs/FCM connections. Do not hardcode max-concurrent-streams; let the transport read the server SETTINGS (the exact APNs/FCM advertised value is not a published primary-source constant — measure at runtime; see §5, item 3).
**Inbound server:** `net/http` gets HTTP/2 automatically over TLS and supports graceful shutdown. **Set explicit timeouts** — the zero value means no timeout: `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes` (default 1 MiB). On SIGTERM call `srv.Shutdown(ctx)` with a bounded context (source: https://pkg.go.dev/net/http, 2026-06-01).
### 3.4 Storage
| Choice | Version | Rationale |
|---|---|---|
| `github.com/jackc/pgx/v5` + `pgxpool` | **v5.10.0** (2026-06-03, Go 1.25+) | Recommended Postgres driver/pool. Use `pgxpool.New(ctx, dsn)` (not `pgx.Connect`) for a server — concurrency-safe, health-checked. Token store, API-key store, idempotency. (source: https://pkg.go.dev/github.com/jackc/pgx/v5, 2026-06-03) |
| `github.com/redis/go-redis/v9` | **v9.20.1** (2026-06-11) | Context-first Redis client with built-in pooling. Distributed rate-limiting, idempotency in-flight locks, dedup/queue state. (source: https://pkg.go.dev/github.com/redis/go-redis/v9, 2026-06-11) |
### 3.5 Observability
| Choice | Version | Rationale |
|---|---|---|
| `log/slog` (stdlib) | Go 1.21+ | Structured logging, no third-party dep. `slog.NewJSONHandler` for production aggregation; redaction middleware (§4.6). (source: https://pkg.go.dev/log/slog, 2026-06-01) |
| `github.com/prometheus/client_golang` | **v1.23.2** (2025-09-05) | Prometheus instrumentation; custom registry + `promhttp.HandlerFor` for `/metrics`. Instrument per-provider send latency (histogram) and outcome counters labeled by provider + status/reason. (source: https://pkg.go.dev/github.com/prometheus/client_golang/prometheus, 2025-09-05) |
### 3.6 Reference pattern (not a dependency)
`github.com/appleboy/gorush` is a maintained reference push **server** (built on `sideshow/apns2`, supports FCM) with a notification queue, worker pool, and pluggable stats storage — useful as a **pattern reference** for worker pools and retry/queue design, but it is a full server, not a library to embed (source: https://github.com/appleboy/gorush, 2025-11-20).
### 3.7 Pinned versions (summary)
```
github.com/sideshow/apns2 v0.25.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/net v0.56.0 // http2
golang.org/x/oauth2 v0.36.0
firebase.google.com/go/v4 v4.20.0 // only if using the Admin SDK path
github.com/prometheus/client_golang v1.23.2
github.com/jackc/pgx/v5 v5.10.0
github.com/redis/go-redis/v9 v9.20.1
golang.org/x/time (rate — see §4.2)
// Go 1.25+ required
```
---
## 4. Security & ops (2026)
### 4.1 API-key storage & comparison
- **Generate** 32 bytes from `crypto/rand`; format as `<prefix>_<base62/base64url>` where the **prefix** (8–12 chars, optionally tenant/env-tagged) is **non-secret**, stored in cleartext, and **indexed**. The remaining 32 bytes are the secret. This GitHub/Stripe-style design lets you `SELECT` the row by indexed prefix in O(1), then constant-time-compare the hash — no full-table scan, no SQL index over the secret (engineering consensus aligned with OWASP API2:2023, source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2023).
- **Hashing:** Store **SHA-256** (or **HMAC-SHA-256** with a server-side pepper kept in the secret manager) of the secret — never plaintext. For **high-entropy random keys** (≥128, ideally 256 bits), a fast hash is acceptable; OWASP's slow-KDF warning is scoped to **low-entropy human passwords**, where "Fast hashing algorithms such as SHA-256 are not suitable" — that warning does **not** apply to 256-bit random API keys, and `argon2id` per-request would needlessly throttle the relay (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06). _Note: this passwords-vs-keys distinction is engineering consensus consistent with OWASP, not a single verbatim OWASP line — see §5, item 12._
- **If** you ever store a low-entropy password (not API keys), OWASP's current argon2id minimum is **m=19456 KiB (19 MiB), t=2, p=1** with a unique per-secret salt (source: same, 2026-06). Do **not** apply argon2id to per-request API-key verification.
- **Comparison:** use `crypto/subtle.ConstantTimeCompare(x, y []byte)` on the fixed-length (32-byte) hashes. Never use `==` or `bytes.Equal` on raw secret material (source: https://pkg.go.dev/crypto/subtle, current).
- **OWASP API2:2023:** "API keys should not be used for user authentication. They should only be used for API clients authentication." Never put keys in URLs/query strings; implement anti-brute-force on auth endpoints (source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2023).
- **Metadata per key:** `account_id`, `key_hash`, `prefix` (indexed), `created_at`, `last_used_at` (written throttled, e.g. ≤ once/min, to avoid a DB write per request), `expires_at` (optional), `revoked_at`. Support **multiple live keys per account** for zero-downtime rotation; reject revoked/expired keys on every request (aligned with OWASP API2:2023, source: same, 2023).
### 4.2 Rate limiting
- **Single instance:** `golang.org/x/time/rate` token bucket — `NewLimiter(r Limit, b int)`. Use `Allow()` to drop/reject (return 429) on inbound; `Wait(ctx)`/`Reserve()` to pace outbound APNs/FCM calls. `Reservation.Delay()` gives the wait → use it to compute `Retry-After`. Per-account: `map[accountID]*Limiter` with eviction (per-instance only) (source: https://pkg.go.dev/golang.org/x/time/rate, current).
- **Multi-instance (behind LB):** per-instance limiters no longer enforce a global limit — use a **Redis-backed distributed limiter** with an **atomic Lua** script (refill → check → consume in one `EVAL`), state as a Redis HASH `{tokens, last_refill_ts}` + TTL for idle-account eviction. Alternatives: `redis-cell` (GCRA, `CL.THROTTLE` returns remaining quota + retry-after) or `redis-gcra` Lua scripts. Decide **fail-open vs fail-closed** when Redis is down (fail-open for availability; fail-closed for abuse-sensitive endpoints) (source: https://github.com/Losant/redis-gcra, current).
- **Response:** on limit exceeded, return **`429 Too Many Requests`** (RFC 6585 §4, 2012-04) with a **`Retry-After`** header. ABNF: `Retry-After = HTTP-date / delay-seconds`, `delay-seconds = 1*DIGIT` (RFC 9110 §10.2.3, 2022-06). **Common bug to avoid:** do **not** emit an absolute Unix epoch integer — `delay-seconds` is a **relative** count of seconds; an HTTP-date must use the full date format. Prefer an integer relative-seconds value from `Reservation.Delay()` (source: https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3, 2022-06).
- **OWASP API4:2023** (renamed "Unrestricted Resource Consumption"): enforce rate limits, "Define and enforce a maximum size of data on all incoming parameters and payloads," cap per-account push volume, and "Configure spending limits for all service providers/API integrations" — directly relevant to capping per-tenant APNs/FCM spend (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2023).
### 4.3 Idempotency
Accept an **`Idempotency-Key`** header (UUIDv4, **scoped per account**). _Status note:_ this is an IETF **draft** (`draft-ietf-httpapi-idempotency-key-header-07`, 2025-10-15), **not** a ratified RFC, and that version **expired 2026-04-18** — treat the header **name** and pattern as the de-facto standard (Stripe uses the same header), but do not cite it as an RFC (source: https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07, 2025-10-15).
Status-code lifecycle from the draft:
| Situation | Response |
|---|---|
| First request | Process; record the response. |
| Duplicate, original **completed** | Replay the previously completed result (success or error). |
| Duplicate, original **in-flight** | **409 Conflict** (RFC 7807 body, type `.../idempotency`). |
| Same key, **different payload** | **422 Unprocessable Content**. |
| Missing key where required | **400 Bad Request**. |
(source: same, 2025-10-15).
**Stripe-style production model** (de-facto reference): header `Idempotency-Key`, value up to 255 chars, recommend UUIDv4; saved result (status + body) replayed for **≥24 hours** for both success and failure; same key + different params → error; concurrent in-flight → not saved, client may retry; applies to POST (GET/DELETE ignore it) (source: https://docs.stripe.com/api/idempotent_requests, 2026-06).
**Relay implementation:** atomic `SET idem:{account}:{key}` with **NX + TTL** as an in-flight lock. NX success → process, then overwrite the marker with the serialized `{status, body}` and a ~24 h TTL. NX fail + stored response → replay it with the original status. NX fail + still in-flight → return **409**. Same key + different payload → **422**. This prevents double-sends to APNs/FCM under concurrent retries.
### 4.4 Provider auth recap (security view)
- **APNs:** cache one ES256 JWT, regenerate on a ~50-min timer (never < 20 min apart) under a mutex, reuse across senders; refresh on `403 ExpiredProviderToken`; back off on `429 TooManyProviderTokenUpdates`. Prefer team-scoped + topic-specific `.p8` keys (Feb 2025) for least privilege. Ensure the TLS trust store includes USERTrust RSA / SHA-2 root (§1.7). (sources as cited in §1.)
- **FCM:** cache the OAuth2 access token (~1 h) rather than minting per message. On GCP, prefer keyless auth (§4.5). (sources as cited in §2.)
### 4.5 Secret management
OWASP Secrets Management ranking — **KMS/Vault/secret-manager (best) > tightly-permissioned mounted secret file (acceptable) > plain env var (discouraged) > in image/repo (forbidden)**. Verbatim: "environment variables are generally accessible to all processes and may be included in logs or system dumps. Using environment variables is therefore not recommended unless the other methods are not possible." Encrypt at rest (AES-256-GCM or ChaCha20-Poly1305); "You should not store keys next to the secrets they encrypt" (envelope encryption / KMS); least privilege; prefer dynamic/short-lived secrets (source: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html, 2026-06). Store the APNs `.p8` and FCM SA JSON in a secret manager; never commit or bake into the image.
**Google keyless direction (2025):** prefer Workload Identity / service-account impersonation / attached SA over downloadable JSON keys; org policy `constraints/iam.disableServiceAccountKeyCreation` blocks new keys. If the relay runs **on GCP**, use an attached SA / Workload Identity. If **off-GCP**, a JSON key is sometimes unavoidable — then it **must** live in a secret manager, not a repo or plain env var (source: https://docs.cloud.google.com/iam/docs/keys-disable-enable, 2026-06).
### 4.6 Log redaction
OWASP Logging exclude-list (verbatim items): "Access tokens"; "Authentication passwords"; "Session identification values"; "Database connection strings"; "Encryption keys and other primary secrets"; payment/PII. Redact by remove/mask/sanitize/hash/encrypt (source: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html, 2026-06).
For the relay specifically — **never log**: the `Authorization`/bearer header, the raw API key (log only the non-secret **prefix** or a hash), full APNs/FCM **device tokens** (truncate/hash), or **notification payload bodies**. **Do log**: request ID, `account_id`/tenant ID, status code, error `reason`/`status`, and Retry-After/idempotency decisions for triage. Implement redaction in logging middleware so it can't be bypassed.
### 4.7 Multi-tenant isolation
Combine **noisy-neighbor resource control** (per-tenant rate/volume quotas at gateway/service/data tiers) with **per-tenant scoping** of every stored API key, rate-limit bucket, idempotency key, cached provider token, and **log line**. "Attach a tenant ID to every request, log entry, metric, and trace." Namespace all shared-store keys by `account_id` to prevent cross-tenant collision/replay. Cap downstream APNs/FCM spend per tenant (OWASP API4:2023). For APNs, recall the per-team connection-pool constraint (§1.1) (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2023).
```mermaid
flowchart TB
T[Tenant request + API key + Idempotency-Key] --> AUTH[Auth middleware: prefix lookup + ConstantTimeCompare]
AUTH --> RL[Per-tenant rate limit: Redis Lua token bucket]
RL -->|429 + Retry-After| T
RL --> IDEM[Idempotency: Redis SET NX lock / replay / 409 / 422]
IDEM --> ROUTE{Provider}
ROUTE -->|APNs| APNS[ES256 JWT cached ~50m, per-team HTTP/2 pool]
ROUTE -->|FCM| FCM[OAuth2 token cached ~1h, v1 messages:send]
APNS --> STORE[(pgx: tokens / keys / idempotency)]
FCM --> STORE
AUTH -. redacted .-> LOG[slog JSON: tenant, req-id, status, reason]
```
---
## 5. Open questions / re-verify before implementation
1. **APNs token page exact wording.** `establishing-a-token-based-connection-to-apns` is a JS-rendered SPA; content was confirmed via Apple's JSON data endpoint and corroborating sources. Re-read the live page to confirm exact 20/60-minute refresh wording and the scoped-key limits ("200 keys per env, up to 400 topics per topic-based key") near build time.
2. **`apns2` maintenance / bus factor.** No release since v0.25.0 (Oct 2024). Check recent commit/issue activity before committing to it vs. a hand-rolled `net/http2` + `golang-jwt/jwt/v5` client.
3. **APNs `SETTINGS_MAX_CONCURRENT_STREAMS`.** Intentionally variable and not documented by Apple. Measure at runtime; do not hardcode connection sizing.
4. **APNs sandbox hostname alias (resolved — interchangeable).** `api.sandbox.push.apple.com` and `api.development.push.apple.com` are interchangeable sandbox aliases: as of 2026-06-13 both resolve to the same Akamai CNAME and IP set (DNS-verified, §1.3), so either reaches the same sandbox backend. We standardize on `api.sandbox.push.apple.com` (canonical in Apple's docs and `apns2`'s `HostDevelopment`). No build-time blocker — this is a naming preference, not a discrepancy.
5. **APNs broadcast hosts.** If Live Activity broadcast/channels are in scope, confirm the broadcast-specific hosts (`api-broadcast.*`, `api-manage-broadcast.*` on 2195/2196) against Apple's code samples; the connection-section text in Apple's docs is internally inconsistent.
6. **Future APNs requirements.** None published after Feb 2025 as of June 2026 — re-check Apple's Upcoming Requirements page (e.g. a future TLS-1.3 minimum or another CA rotation) close to deployment.
7. **FCM `RetryInfo` on 429.** FCM's error-codes page promises only `google.rpc.QuotaFailure`, not `RetryInfo`. Treat `RetryInfo` as optional; fall back to `Retry-After` / 60 s default.
8. **FCM data-residency endpoints.** None for FCM as of June 2026 (global `fcm.googleapis.com` only). Re-check release notes if EU/other data-residency requirements emerge.
9. **FCM project quota tiers above 600K/min.** Confirm a project's real ceiling via Google Cloud Console quotas rather than assuming a secondary-source tier.
10. **App Standby Bucket caps.** Per-bucket high-priority wake-up caps are unpublished and OEM/version-dependent; confirm via on-device testing (`adb shell am set-standby-bucket` / `get-standby-bucket`) if precise behavior matters.
11. **Android 270-day token expiry.** Currently documented (manage-tokens, 2026-06-13) but a platform constant that can change — re-verify at build time.
12. **API-key "fast hash OK" citation.** Engineering consensus consistent with OWASP's passwords-vs-keys threat model, but not a single verbatim OWASP line for API keys; if a hard citation is required, point to that distinction, and decide plain salted SHA-256 vs HMAC-SHA-256 pepper (pepper adds defense-in-depth but complicates rotation).
13. **Idempotency-Key draft status.** Draft 07 expired 2026-04-18 without becoming an RFC; re-check for an 08+/RFC before relying on its exact status-code semantics as "standard."
14. **GCP vs off-GCP for FCM credentials.** Whether the relay runs on GCP (keyless Workload Identity) or off-GCP (JSON key in a secret manager) changes the recommended FCM credential approach — resolve in the deployment design.
@@ -0,0 +1,268 @@
# Silo Push Relay — Decision Record
> **Provenance.** This is the decision record for the Silo Push Relay. It resolves the implementation decisions that the engineering spec ([`00-relay-spec.md`](./00-relay-spec.md)) left open in **§15 ("Open questions needing human / product input")**, building on the phased build plan ([`01-implementation-plan.md`](./01-implementation-plan.md)) and the cited 2026 factual foundation ([`02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md)). Each decision below is **firm** and **implementer-followable**: pick it, build it. The external wire contract in [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md) is authoritative and unchanged — no decision here adds, renames, or repurposes a caller-visible field. Commands assume the repository root is the cwd.
These decisions reflect **2026-current** APNs and FCM behavior and a deliberately narrow v1 cut line: a single-region, self-hosted-ecosystem relay whose two priorities are **reliability** and **custody of the official Apple/Google push credentials**. Where the spec already leaned a direction, this record ratifies and operationalizes it; where it left a real fork, this record picks one decisively and says exactly what that commits the team to.
---
## Summary
| Decision | Recommendation (one line) | Confidence | Gates |
|---|---|---|---|
| APNs Go client (§15 #3) | Hand-roll on `net/http` + `x/net/http2` + `golang-jwt/jwt/v5` behind `internal/apns`; do **not** take a production dep on `sideshow/apns2`. | High | Phase 3 |
| FCM credentials (§15 #4) | Default **keyless** via ADC (attached SA on GCP, WIF off-GCP); SA-JSON-in-secret-manager is the explicit last-resort fallback. | High | Phase 4 / Phase 6 |
| `apns-expiration` policy (§15 #5) | Relay-set **finite** TTL: 4h `private_alert`, 1h `background_wake`, parallel FCM `android.ttl`; relay-owned config, **not** a caller field. | High | Phase 3 (APNs) / Phase 4 (FCM) |
| Per-account rate limits (§15 #1) | Ship **hard-coded global defaults** (config constants + env overrides); add per-account columns + `relayctl quota` only when logs prove need. | High | Phase 2 |
| API-key hashing (§15 #6) | `HMAC-SHA256(pepper, secret)` — fast keyed hash, pepper in secret manager; **not** argon2id, **not** salted SHA-256. | High | Phase 1 / Phase 2 |
| Op-log retention (§15 #8) | Native PostgreSQL **declarative RANGE partitioning** by day, retention by **partition DROP** via `pg_partman`; not bulk `DELETE`, not external offload. | High | Phase 1 / Phase 5 |
---
## Decision 1 — APNs Go client: hand-roll, do not depend on `sideshow/apns2`
**Decision.** Should the relay's APNs client adopt `sideshow/apns2`, or hand-roll the client on `net/http` + `golang.org/x/net/http2` + `golang-jwt/jwt/v5`? (Spec §15 #3; plan §3 "Decision to make before Phase 3".)
**Resolution.** **Hand-roll the APNs client** on `net/http` with `x/net/http2` and `golang-jwt/jwt/v5`, behind the existing `internal/apns` interface. Do **not** take a production dependency on `sideshow/apns2`. Mirror its verified internals — production host `api.push.apple.com`, sandbox host `api.sandbox.push.apple.com`, token TTL 3000 s with a 20-minute refresh floor, an HTTP/2 transport with `ReadIdleTimeout`/`PingTimeout` near 15 s, one client per process per (env, team) — and add the behavior `apns2` lacks: single-flighted JWT regeneration keyed by team, an (env, topic) → `.p8` key-selection map for Apple's Feb 2025 scoped keys with startup validation, a finite `apns-expiration` policy (Decision 3), and a connection-severed → `503` mapping with no transparent retry. Optionally keep `apns2 v0.25.0` as a throwaway Phase 3 oracle, then drop it before Phase 6.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Hand-roll (chosen)** | No dependency on a stalled library; relay owns its whole upstream transport. Surface is small and fully specified. `jwt/v5` and `x/net/http2` are maintained and already needed (FCM, HTTP/2 tuning). Enables single-flight JWT regen, scoped-key selection, finite expiration, and connection-severed mapping that `apns2` lacks. | More code/tests up front for the JWT lifecycle, HTTP/2 pool, GOAWAY handling, and reason mapping. Must re-earn `apns2`'s production hardening through tests. |
| Adopt `sideshow/apns2 v0.25.0` | Fastest path to a working client; de-facto library with verified internals, battle-tested at throughput, MIT, small, vendorable. | Maintenance drift: no release since Oct 2024, one untagged commit in twelve months, none in 2026; 25 open issues / 8 PRs with no maintainer response in 2025–2026, including an unaddressed connection-leak issue (#238). Lacks single-flight regen, scoped-key selection, expiration policy, connection-severed semantics — so it would be wrapped anyway. Bus-factor risk. |
| Adopt a fork (e.g. `ad-mos/apns2`) | In principle routes around the inactive upstream; adds Live Activity. | Less current than upstream (untagged pseudo-version from Jul 2024, no stable tags) — bus factor is worse. Live Activity is out of scope. |
**Rationale.** Three points decide it for a reliability-first, multi-year, self-hosted relay. First, **maintenance health is poor**: `v0.25.0` (Oct 2024) is still the newest tag (source: https://github.com/sideshow/apns2/releases, 2026-06-13), the only commit to master in twelve months is one untagged dep-bump PR #239 dated 2025-07-22 (source: https://github.com/sideshow/apns2/commits/master, 2026-06-13), and an open issue (#238, 2024-11-11) reports exactly the failure class that matters here — an HTTP/2 connection/goroutine leak in a process holding long-lived connections for days — with no maintainer response in 2025 or 2026 (source: https://github.com/sideshow/apns2/issues, 2026-06-13). Second, **relay requirements already exceed `apns2`**: single-flight JWT regen (to avoid `429 TooManyProviderTokenUpdates`), the (env, topic) → `.p8` scoped-key map with startup validation for Apple's Feb 2025 scoped keys, a finite `apns-expiration`, connection-severed → `503` with no transparent retry, and sandbox-only `apns-unique-id` capture — `apns2` has none of these, so it would be wrapped regardless, leaving only small JWT/HTTP-2 glue. Third, **that glue is low risk to own**: the 2026 reference verified every constant, `jwt/v5` and `x/net/http2` are maintained and already needed, and Go's `net/http2` handles GOAWAY and reconnect transparently on a reused client. Compatibility is a non-issue — `apns2` master pins `x/net v0.33.0` / go 1.18 but module MVS resolves up to the relay's `x/net v0.56.0` and `jwt v5.3.1` and builds under Go 1.25/1.26 (source: https://github.com/sideshow/apns2/blob/master/go.mod, 2026-06-13) — so the disqualifier is maintenance and missing behavior, not versions.
**Consequences (what this commits).**
- **Deps.** `go.mod` takes `golang-jwt/jwt/v5 v5.3.1` and `golang.org/x/net v0.56.0` as production deps (both already planned in plan §3). `apns2` is **not** a production dep — at most a Phase 3 prototype dropped before Phase 6.
- **Code (`internal/apns`).**
- `jwt.go` — ES256 `.p8` sign; in-memory cache; ~50-minute regen timer; 20-minute floor; eager regen on `403 ExpiredProviderToken` with a single retry; **single-flight regen keyed by team**.
- `transport.go` — per-(env, team) HTTP/2 transport with non-zero `ReadIdleTimeout`/`PingTimeout`, hardcoded host constants by environment enum, GOAWAY reconnect, USERTrust RSA via the system trust store.
- `client.go` — the `internal/apns` interface implementation.
- `errors.go` — the spec §6.4 reason table, including `InvalidToken` → `BadDeviceToken`, unrecognized reason → `502`, connection-severed → `503`.
- Key-selection map and startup validation live in `internal/config`.
- Everything else depends only on the `APNsClient` interface, so the choice is reversible.
- **Ops.** A CVE or HTTP/2 bug is patched by bumping `x/net` rather than waiting on an inactive repo. Test against a fake APNs upstream for GOAWAY and mid-stream reset, a JWT-expiry burst that must yield **exactly one** regen, plus the Apple sandbox integration test (Phase 3).
- **Schema.** None — upstream-client-only; no Postgres/Redis or external-contract change.
**Revisit if.** Reverse toward `apns2` only if, **before Phase 3 ships**, `sideshow/apns2` cuts a fresh tagged release newer than `v0.25.0` and shows sustained maintainer responsiveness — clearing the backlog and addressing the #238 leak class — over two to three months, resolving the bus-factor concern. Conversely, **re-validate the hand-rolled JWT/HTTP-2 internals** if Apple changes APNs provider auth (a new required header, a non-ES256 algorithm, a host change, or deprecating token auth), since the hand-rolled client now owns those mechanics.
**Sources.**
- `apns2` latest release/tag is `v0.25.0` (Oct 24–25 2024); no newer tag exists (source: https://github.com/sideshow/apns2/releases, 2026-06-13).
- Only commit to master in twelve months is untagged dep-bump PR #239 (2025-07-22); no 2026 commits (source: https://github.com/sideshow/apns2/commits/master, 2026-06-13).
- 25 open issues / 8 PRs; issue #238 (potential HTTP/2 connection/goroutine leak, 2024-11-11) has no maintainer response in 2025–2026 (source: https://github.com/sideshow/apns2/issues, 2026-06-13).
- pkg.go.dev flags `v0.25.0` as not the latest module version; newest released version is ~20 months old (source: https://pkg.go.dev/github.com/sideshow/apns2, 2026-06-13).
- `apns2` master `go.mod` declares go 1.18, pins `x/net v0.33.0` and `jwt v5.2.1`; MVS resolves to the relay's `x/net v0.56.0` / `jwt v5.3.1`, builds under Go 1.25/1.26 (source: https://github.com/sideshow/apns2/blob/master/go.mod, 2026-06-13).
- Fork `ad-mos/apns2` (Live Activity) is less current: untagged pseudo-version 2024-07-30, no stable tags (source: https://pkg.go.dev/github.com/ad-mos/apns2, 2026-06-13).
- `x/net v0.56.0` (HTTP/2 `ReadIdleTimeout`/`PingTimeout` tuning) published 2026-06-09, actively maintained (source: https://pkg.go.dev/golang.org/x/net, 2026-06-13).
- Verified `apns2 v0.25.0` internals to mirror: `HostProduction api.push.apple.com`, `HostDevelopment api.sandbox.push.apple.com`, `TokenTimeout 3000` under a mutex, `ReadIdleTimeout 15s`, one client per process per env (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2026-06-13).
---
## Decision 2 — FCM credentials: default keyless via ADC, SA-JSON-in-secret-manager as last resort
**Decision.** Should the relay authenticate to FCM HTTP v1 via GCP keyless identity (Workload Identity Federation / attached service account) or via a service-account JSON key stored in a secret manager? (Spec §4.4, §15 #4; reference §4.5.)
**Resolution.** **Default to keyless.** The relay MUST authenticate via Application Default Credentials (ADC) using `golang.org/x/oauth2/google` with scope `https://www.googleapis.com/auth/firebase.messaging`, with the concrete credential **source** chosen by deployment:
1. **On GCP compute (GCE/GKE/Cloud Run):** use an **attached service account** — zero key material; the metadata server mints short-lived tokens.
2. **Off-GCP (self-hosted LXC/VM — the documented Silo home-hosting reality):** use **Workload Identity Federation** with a **non-secret** credential-configuration file plus an OIDC token from the host's own IdP — still no exported key.
3. **Only if WIF cannot be stood up** (no usable OIDC issuer for the host): fall back to a **service-account JSON key**, which MUST live in a secret manager loaded once at startup into memory — **never** as a literal-key env var, **never** baked into the image, **never** on disk in the repo.
In all three cases the code path is identical: `google.FindDefaultCredentials` / `DefaultTokenSource(ctx, "…/firebase.messaging")` returns an auto-caching, auto-refreshing `TokenSource`; the relay does **not** branch on credential type. This makes keyless the default and JSON-in-secret-manager the explicit last resort, matching spec §4.4 / reference §4.5 with one refinement: even the off-GCP case is keyless via WIF first.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Keyless via ADC — attached SA on GCP, WIF off-GCP; SA-JSON-in-secret-manager last resort (chosen)** | No long-lived exportable secret in the common path — eliminates the highest-severity leak vector for a service whose whole reason to exist is custody of the official Google push credential (spec G3). Aligns with Google's current guidance that SA keys are "an exception rather than the norm" and that orgs created on/after 2024-05-03 block key creation by default. WIF's credential-config file carries no private key and need not be confidential. Tokens are short-lived (~1h) and auto-rotated — no manual rotation, no rotation-induced outage mode. Single code path through `x/oauth2/google` regardless of source. Org-policy `iam.disableServiceAccountKeyCreation` can be enforced relay-wide with no impact. | WIF off-GCP requires the host to expose a usable OIDC token (or a supported IdP: AWS/Azure/GitHub/GitLab/K8s/AD/OIDC/SAML/X.509); a bare LXC with no IdP must stand one up or fall back to JSON. Slightly more one-time setup (pool + provider + attribute mapping). |
| Service-account JSON key in a secret manager (loaded at startup, never env/in-image) | Works on any host with no IdP or GCP compute — the universal fallback for a self-hosted ecosystem. Simple mental model. Same `x/oauth2/google` minting code via `CredentialsFromJSONWithType`. Secret-manager custody (vs env/in-image) is itself OWASP-aligned hardening, acceptable when keyless is genuinely unavailable. | A long-lived, exportable private key is exactly what Google now treats as an exception and blocks by default for 2024-05-03+ orgs — if the official Silo Firebase project lives in such an org, the policy must be relaxed just to create it. Single high-value theft target (full FCM send authority), manual rotation burden, rotation-induced outage window. The untyped `CredentialsFromJSON` loader is deprecated; must use the typed variant. Fallback, not default. |
| Use the Firebase Admin SDK and let it resolve ADC | Batteries-included: auto token mint/cache/refresh, rich error helpers, also resolves ADC (inherits keyless transparently). | Orthogonal to the credential decision and explicitly **not** the relay's chosen client — plan §3 / reference §3.2 deliberately pick the lighter `x/oauth2/google` path for a fixed payload and direct control of connection reuse and error classification. Adopting it to "solve credentials" re-opens a settled client-library decision for no credential benefit (ADC resolution is identical either way). |
**Rationale.** The relay's single most sensitive asset is the official Google push credential (spec G3, §4.4); a fully compromised relay must not hand an attacker a portable, long-lived FCM send key. Keyless removes that artifact in the common path: on GCP the key never exists; off-GCP, WIF replaces it with a non-secret credential-config file plus a short-lived host-issued OIDC token that Google STS exchanges for a ~1h access token. Google's own current guidance is unambiguous — SA keys are "an exception rather than the norm," and organizations created on or after 2024-05-03 enforce `iam.disableServiceAccountKeyCreation` / `disableServiceAccountKeyUpload` by default (source: https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys, 2026-06-13) — so a brand-new project should treat keyless as the baseline. Reliability-first reinforces this: long-lived keys add a manual-rotation outage mode and a "key silently revoked/expired" failure class that keyless does not have. Crucially, the **implementation cost of defaulting to keyless is near-zero**, because `x/oauth2/google` ADC already resolves all three sources — GCE/GKE metadata, `external_account` WIF config files, and SA JSON — behind one `TokenSource` with the same scope, so the relay does not branch (source: https://pkg.go.dev/golang.org/x/oauth2/google, 2026-02-11). JSON-in-secret-manager remains an honest fallback for a host with no IdP, but is the exception: secret-manager-held and loaded once into memory, never an env-var key or image layer (spec §4.4 ranking secret-manager > mounted file > env var > in-image). This is a refinement, not a contradiction, of spec §4.4 ("On GCP, prefer keyless …; off-GCP, the JSON lives in the secret manager") — the verified addition is that **off-GCP can and should try WIF (keyless) first**, because WIF explicitly targets non-Google-Cloud workloads and its config file carries no secret.
**Consequences (what this commits).**
- **Code.** `internal/fcm/oauth.go` builds the `TokenSource` exclusively through ADC — `google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/firebase.messaging")` (or `FindDefaultCredentials` + `creds.TokenSource`) — and does **not** special-case credential type. The fallback SA-JSON path uses `CredentialsFromJSONWithType(ctx, json, google.ServiceAccount, scope)` (**not** the deprecated `CredentialsFromJSON`) with the JSON sourced from the secret manager.
- **Config.** `internal/config` gains a single `fcm_credential_mode` resolution: attached-SA (no config) > WIF credential-config file path > secret-manager SA JSON, mapped onto ADC (set `GOOGLE_APPLICATION_CREDENTIALS` to a mounted, non-secret WIF config, or inject the SA JSON into the secret-manager-backed loader).
- **Ops/deploy.** On GCP: bind an attached SA with rights to send to the official Firebase project, and apply org policy `iam.disableServiceAccountKeyCreation`. Off-GCP: stand up a workload-identity pool + provider, configure the host's OIDC issuer, deploy the WIF `external_account` credential-configuration file (it may be mounted/baked because this specific file contains **no private key** and need not be kept confidential, per Google's WIF docs — **it is the only artifact exempt, and only because it carries no secret**). **This refines, not overrides, spec §11: the no-in-image rule still applies in full to every actual secret — the APNs `.p8`, the FCM service-account JSON, the HMAC pepper, and DB/Redis credentials remain strictly forbidden in the image under spec §11.** The startup credential health-check (`relayctl ping-upstream --provider fcm`, spec §5.4, plan Phase 4/6) validates the resolved `TokenSource` mints a token regardless of source. Removes the manual key-rotation runbook in the common path; documents the WIF pool/provider setup and the JSON-fallback secret-manager procedure for the exception case.
- **Schema/contract.** No DB schema change; no change to the external `02`/`03` contract or to `pushwire` payloads.
**Revisit if.** (1) The relay must deploy on a host with **neither** GCP attached-SA **nor** any WIF-compatible identity source — then the SA-JSON-in-secret-manager fallback becomes the de facto default for that deployment and its rotation runbook must be activated. (2) The official Silo Firebase project is created under / moved into a Google org that enforces `iam.disableServiceAccountKeyCreation` by default (org created on/after 2024-05-03), making the JSON fallback unavailable without a policy exception — then keyless is mandatory. (3) `x/oauth2/google` changes its ADC resolution order or deprecates the typed-credentials API, requiring a client-library re-verification.
**Sources.**
- Google: "consider the use of service account keys as an exception rather than the norm"; recommended constraints Disable SA key creation/upload; "If your organization was created on or after May 3, 2024, these constraints are enforced by default." (source: https://docs.cloud.google.com/iam/docs/best-practices-for-managing-service-account-keys, 2026-06-13).
- WIF lets external workloads (AWS/Azure/GitHub/GitLab/K8s/on-prem AD/OIDC/SAML 2.0/X.509) access GCP without SA keys via OAuth2/STS exchange; "eliminates the maintenance and security burden associated with service account keys." (source: https://docs.cloud.google.com/iam/docs/workload-identity-federation, 2026-06-13).
- For off-GCP workloads, WIF uses a credential-config file that "doesn't contain a private key and doesn't need to be kept confidential"; ADC auto-discovers it via `GOOGLE_APPLICATION_CREDENTIALS`; supported in Go (source: https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-providers, 2026-06-11).
- `x/oauth2/google v0.36.0` `FindDefaultCredentials` / `DefaultTokenSource` resolves, in order: `GOOGLE_APPLICATION_CREDENTIALS` JSON (incl. WIF/`external_account`), gcloud user creds, then GCE/GKE metadata (attached SA); `CredentialsType` includes `ServiceAccount` and `ExternalAccount`; `CredentialsFromJSON` is deprecated — use `CredentialsFromJSONWithType`; same `TokenSource` regardless of source (source: https://pkg.go.dev/golang.org/x/oauth2/google, 2026-02-11).
- FCM HTTP v1 auth docs recommend ADC on Compute Engine/GKE/App Engine/Functions, warn that referencing the SA JSON file "should be done with extreme care," recommend `GOOGLE_APPLICATION_CREDENTIALS` as "more secure and … strongly recommended," cite Workload Identity as an alternative to downloaded keys, and specify scope `https://www.googleapis.com/auth/firebase.messaging` (source: https://firebase.google.com/docs/cloud-messaging/auth-server, 2026-06-13).
---
## Decision 3 — `apns-expiration` policy: relay-set finite TTL for WAKE pushes
**Decision.** Should the relay set a finite `apns-expiration` (and parallel FCM `android.ttl`) on `private_alert` WAKE pushes so a stale wake doesn't deliver days later, or omit it and accept Apple's ~30-day store-and-retry? What concrete default, and does the policy live in the relay or stay caller-controlled per the `02`/`03` contract? (Spec §5.1, §15 #5; resolves spec open question 5.)
**Resolution.** **Set a FINITE `apns-expiration` by default in the relay.** Ship `apns-expiration = now + 4 hours` for the APNs `private_alert` mode and `now + 1 hour` for `background_wake`, with a parallel FCM `android.ttl` keyed by the FCM wire mode — `"14400s"` for `private_data` (the FCM counterpart of the APNs `private_alert`) / `"3600s"` for `background_wake` — for cross-provider parity. Both values are **per-deployment config** (relay-owned defaults, not caller-supplied) and do **not** change the external `02`/`03` wire contract, which has no expiration field — the relay sets the upstream header itself. Do **not** use `apns-expiration=0` (single-attempt, no store) and do **not** omit it (Apple stores/retries up to ~30 days). This confirms and operationalizes spec open question 5; the firm refinement is to keep it relay-owned with config-tunable defaults rather than promoting it to a caller field.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Finite relay-set default (~4h `private_alert` / ~1h `background_wake`), config-tunable, parallel FCM `ttl` (chosen)** | A WAKE is only useful while it can still produce a near-real-time fetch; the inbox ([`../01-release-events-and-inbox.md`](../01-release-events-and-inbox.md)) is the source of truth, so a wake missed for hours is re-driven by the caller outbox or the next foreground sync, not by a stale push. Avoids a device buzzing for an event already read/deleted server-side days ago. Bounds APNs/FCM store-and-retry use. Keeps the content-free posture intact (no new caller field, no contract change). A multi-day-offline device just gets a clean expiry. With `collapse-id`, a finite TTL compounds correctly: a newer wake supersedes the old one and the old one self-expires. Config-tunable so product can re-tune without a code/contract change. | Bakes a partly product/UX call into infra: a device back online at hour 5 gets no wake and relies on its next foreground sync (acceptable — inbox is authoritative — but a real behavioral choice). One hardcoded default per mode cannot suit future urgency tiers. APNs (absolute epoch) vs FCM (duration) asymmetry means the relay must compute the APNs epoch from `now()+TTL` per request. |
| Omit `apns-expiration` / omit `android.ttl` (Apple ~30-day store-and-retry; FCM 4-week default) | Maximizes raw delivery probability; simplest code; matches provider default (no per-request epoch math). | Verified behavior: omitted `apns-expiration` → store/retry up to ~30 days; omitted FCM `ttl` → 4 weeks. For a content-free WAKE that exists only to trigger a live fetch, a wake fired days later is useless at best, confusing at worst. Spends store-and-retry budget on valueless pushes. Contradicts the "inbox is the source of truth" premise. Spec explicitly rejects the 30-day default. |
| `apns-expiration = 0` / `android.ttl = 0s` (deliver-once-now-or-discard) | Strongest guarantee against stale wakes; trivial constant. | `0` means no storage at all: a device merely briefly offline (locked, poor signal, Doze) loses the wake entirely, defeating the point. Background pushes are already APNs-throttled (~2–3/hr) and may be delayed; with `TTL=0` too many legitimate wakes drop. FCM `ttl=0` also disables collapsible-message throttling — a behavior change with no upside. |
| Promote expiration to a caller-controlled `02`/`03` field | Maximum flexibility: the self-hosted server, which alone knows true urgency, picks the TTL per push; future-proofs urgency tiers without a relay redeploy. | Violates the locked `02`/`03` contract (additive-only, not a listed v1 capability) and widens the content-free surface with a new caller-controlled numeric channel visible to Apple/Google — exactly what §5.1 bounds (cf. the badge field). Adds validation/clamping burden. Premature: v1 has exactly one wake semantic. Can be added later additively if a real urgency-tier requirement emerges. |
**Rationale.** The decision hinges on what a `private_alert` IS in Silo: a content-free **WAKE** whose only job is to make the device fetch fresh notifications from the user server, where the **inbox**, not the push, is the source of truth (spec §5.1, [`../01-release-events-and-inbox.md`](../01-release-events-and-inbox.md)). That framing makes a wake's value decay with time — once hours old, the device's own next foreground sync (or a fresher wake in the same collapse series) has already reconciled state, so a late wake yields no benefit and a small confusion cost. Both extremes are verified-wrong for this use case: omitting the header invokes Apple's ~30-day store-and-retry (verbatim: stored "for 30 days or less") and FCM's 4-week default — far too long; `expiration=0` / `ttl=0s` means deliver-once-or-discard with no storage, dropping wakes for a device merely briefly offline (especially bad given APNs already throttles background pushes). A finite few-hours TTL is the correct middle: long enough to ride out normal transient offline windows (Doze, locked phone, brief signal loss), short enough that a multi-day-offline device gets a clean expiry and relies on its authoritative next sync. `background_wake` gets the shorter ~1h TTL because it is even more time-sensitive and even more throttled (APNs requires `apns-priority 5` for background pushes; priority 10 is an error). The `collapse-id` interaction reinforces this: collapse already means latest-wins on-device, and a finite expiration is the complementary mechanism for the cross-series / no-newer-wake case, so the two together ensure stale wakes neither pile up nor fire late. Keeping it relay-owned and config-tunable (not a caller field) preserves the locked `02`/`03` contract and the structural content-free guarantee while letting product re-tune durations — which is how the spec already resolved open question 5.
**Consequences (what this commits).**
- **Code.** `internal/pushwire/apns.go` computes `apns-expiration = now()+TTL` as a UNIX epoch (UTC, seconds) per request from config, per APNs mode (`private_alert` vs `background_wake`). `internal/pushwire/fcm.go` sets `android.ttl` as the parallel duration string keyed by the **FCM** wire mode — `"14400s"` for `private_data` (the FCM counterpart of the APNs `private_alert`; spec §5.2 lists FCM modes as `private_data` | `background_wake`) / `"3600s"` for `background_wake`. Note the mode-name asymmetry: an implementer building `internal/pushwire/fcm.go` must key on `private_data`, not `private_alert`, on the FCM path. Because the value is `now()`-relative, the golden-file payload/header tests in Phase 3/4 must **inject a fixed clock** or assert the header is present and within tolerance rather than byte-exact; the mirrored `silo-server` `pushwire` copy and its checksum/golden tests must use the same clock-injection approach so the two repos do not drift.
- **Config.** Two new non-secret tuning values (`apns_expiration_private_alert`, `apns_expiration_background_wake`) in `internal/config`, with the FCM `ttl` paired/derived, defaulted in code, env/flag-overridable, loaded and validated at startup (reject values exceeding the APNs storage max or FCM 28-day max; reject `<=0` if omit/0 is disallowed by policy).
- **Contract.** No change to the `02`/`03` wire contract and no new caller field — preserving additive-only API rules and the content-free guarantee.
- **Ops.** A stale-wake or missed-wake-rate concern becomes a **config tune, not a logic redeploy**. No schema change, no new dependency.
- **Tests.** Assert `background_wake` never emits `apns-priority 10` alongside its shorter TTL, and that an out-of-range configured TTL fails startup validation.
**Revisit if.** (1) Silo introduces more than one wake semantic or an urgency/priority tier (today there is exactly one: `wake = notifications.changed`), at which point per-mode or per-request TTL — possibly an additive `02`/`03` caller field with a bounded max — becomes justified. (2) Production metrics show a material missed-wake rate attributable to the ~4h/~1h window being too short for the real offline distribution of Silo devices. (3) Apple or Google changes `apns-expiration` / `android.ttl` semantics or the storage-policy maximum (re-verify the cited pages near deployment). (4) The durable-inbox + foreground-sync assumption weakens (e.g., a future notification type whose value is NOT recoverable from the inbox), which would change whether a missed wake is truly harmless.
**Sources.**
- APNs verbatim: "If the value is 0, APNs attempts to deliver the notification only once and doesn't store it." and "If the value is nonzero, APNs stores the notification and tries to deliver it at least once, repeating the attempt as needed until the specified date." (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- Omitted `apns-expiration` → Apple stores per its storage policy: "it may store the notification for 30 days or less … APNs attempts to deliver … the next time the device activates and is available online." (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- `apns-expiration` carries UNIX epoch seconds (UTC); an invalid value yields `400 BadExpirationDate`; `apns-priority` for background pushes MUST be 5 (priority 10 is an error) (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- `apns-collapse-id` merges notifications on-device (latest of the same id wins), ≤ 64 bytes, independent of expiration — so finite expiration + collapse means a newer wake supersedes an older queued one AND an undelivered wake self-expires (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13).
- FCM `android.ttl`: default when omitted is four weeks; max 2,419,200 s (28 days); `ttl=0` discards messages that can't be delivered immediately and disables collapsible-message throttling (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/setting-message-lifespan, 2026-06-13).
- FCM collapse: with a collapse key, an offline-stored message is replaced by the newer one; without one, both are stored; at most 4 distinct collapse keys per device while offline (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/setting-message-lifespan, 2026-06-13).
- Spec already resolved this as open question 5: v1 ships finite, configurable defaults (~4h `private_alert`, ~1h `background_wake`; parallel FCM `android.ttl`), relay-owned and tunable; the external `02`/`03` contract is unchanged (source: ./00-relay-spec.md, 2026-06-13).
---
## Decision 4 — Per-account rate limits: hard-coded global defaults in v1
**Decision.** Ship hard-coded global rate-limit defaults (~10 req/s burst, 50k/day), or add per-account quota columns (`rate_per_sec`, `burst`, `daily_quota`) on `relay_accounts` settable via `relayctl`, seeded with the documented defaults? (Spec §9.2, §15 #1.)
**Resolution.** **Ship hard-coded global defaults for v1**: ~10 req/s burst (capacity-20 token bucket), 50,000 req/day (fixed-UTC), coarse per-(account, token) ~1 req/3s — enforced as **named config constants with env-var overrides**, NOT per-account DB columns. Keep all per-account **enforcement** plumbing exactly as specified (Redis bucket keyed `rl:{account_id}`, daily counter `rl:day:{account_id}:{yyyymmdd}`, the §9.5 shared-FCM-project circuit breaker). Add per-account override columns only when production logs prove at least one account legitimately needs a different cap. This matches the relay's own stated lean in spec §9.2 ("v1 ships a single global default and tunes from logs") and open Q#1, so it ratifies the plan rather than reopening it.
Make the later per-account path cheap by: (1) reading the three limit values from a single typed struct `accountLimits{RatePerSec, Burst, DailyQuota}` resolved per request, defaulting to the global constants, so introducing a per-account source later is a one-function change, not a call-site sweep; (2) keeping the Redis bucket/daily keys labeled by `account_id` already (done). When the trigger fires, add columns `rate_per_sec int`, `burst int`, `daily_quota int` (all **NULLABLE**, NULL = inherit global) to `relay_accounts` in one timestamped Goose migration, plus a `relayctl quota set --account <id> [--rate-per-sec N] [--burst N] [--daily-quota N]` / `quota show` / `quota reset` subcommand — a backward-compatible, additive change.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Hard-coded global defaults (config constants + env overrides), no per-account columns in v1 (chosen)** | Matches the relay's own documented lean (spec §9.2, open Q#1). Zero new schema/CLI surface — less code, fewer tests, smaller attack surface for a reliability-first first release. The abuse-containment requirement (OWASP API4:2023) is **already** met: the global default is enforced per-account (keyed by `account_id` in Redis) plus the §9.5 shared-project circuit breaker, so a stolen/misbehaving key is capped whether the number is a constant or a column. Operators still adjust globally via env var. Adding columns later is genuinely cheap (PG metadata-only `ADD COLUMN`). | A single hot account legitimately exceeding 50k/day must be handled by raising the GLOBAL default (env var, affects all) or an emergency change — until override columns exist. Slightly less satisfying day-one operability. Mitigated by the §9.2 alert at ≥80% of the daily cap. |
| Per-account columns on `relay_accounts`, settable via `relayctl`, seeded with defaults — in v1 | Per-account tuning from day one; cleanest long-term shape; NULL-default inheritance is clean; avoids any future migration. | Builds an override surface against zero production evidence (the client already paces at 5 req/s, half the 10 req/s default, so real traffic won't press the limit). Adds schema + `relayctl quota` subcommands + tests + an inheritance/precedence rule and its edge cases to a reliability-first v1. More surface for a misconfiguration (`daily_quota=0`, absurd burst) to cause an outage or remove the abuse cap. The migration it "saves" is nearly free anyway. |
| Per-account columns now but NOT exposed via `relayctl` (DB-only `UPDATE`) | Future-proof schema without building/testing the CLI; emergency override via privileged DSN. | Worst of both: pays the schema + inheritance cost yet has no safe, audited workflow — raw `UPDATE`s bypass the `relay_op_logs` `admin.*` audit trail that `relayctl` provides (spec §5.6), contradicting the "all admin ops audited" posture. No advantage over adding columns + CLI together when the trigger fires. |
**Rationale.** Four things make global defaults the right v1 call. (1) The migration-cost argument for "add columns now to avoid pain later" does not hold: PostgreSQL `ALTER TABLE ADD COLUMN` with a **constant** default is a metadata-only operation (no row rewrite) since PG 11, and `relay_accounts` holds one row per opt-in installation — hundreds at most — so even a full rewrite is trivial; NULLABLE columns (NULL = inherit) avoid even needing a default, and adding columns + a `relayctl quota` subcommand later is backward-compatible (source: https://www.postgresql.org/docs/current/ddl-alter.html, 2026-06-13). (2) The security requirement (OWASP API4:2023, Unrestricted Resource Consumption) is satisfied by a per-account **cap**, which the global default already provides — every bucket is keyed by `account_id`, and the §9.5 shared-FCM-project circuit breaker bounds cross-tenant blast radius; OWASP recommends per-client throttling and business-tuned limits but does not require per-account **configurability** (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2026-06-13). (3) Real traffic won't press the default: the Silo server paces client-side at 5 req/s (`02`/`03`), half the 10 req/s relay backstop, so the relay limit exists to catch a misbehaving/compromised caller, not to be tuned per well-behaved tenant (source: ./00-relay-spec.md, 2026-06-13). (4) This is a reliability-first first release; the §15/§9 text already leans "single global default, tune from logs," so shipping that ratifies the plan and minimizes v1 surface. The correct sequencing is: ship one default, watch the ≥80%-of-daily-cap alert and per-account `429` metrics, and add targeted overrides only when a real account demands one — at which point the change is cheap and evidence-driven. The shared-project risk is unavoidable regardless of limit shape: FCM's default per-project quota is 600,000 messages/minute enforced **per project** (shared across all tenants using it), returning `429 RESOURCE_EXHAUSTED` over quota — which is exactly why the §9.5 circuit breaker is required no matter whether the per-account number is a constant or a column (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-12).
**Consequences (what this commits).**
- **Schema.** NO change to the spec §8.1 v1 schema — `relay_accounts` ships **without** `rate_per_sec` / `burst` / `daily_quota` columns.
- **Code.** Limit values live as named config constants in `internal/config` (per plan §2.4, "non-secret defaults … live as constants with env overrides") with env-var overrides; the rate-limit middleware (`internal/ratelimit`) reads them through a single `accountLimits` struct resolved per request, defaulting to the globals, so a future per-account source is a one-function swap, not a call-site sweep.
- **Redis.** Keys remain account-scoped (`rl:{account_id}`, `rl:day:{account_id}:{yyyymmdd}`) — already specified, so enforcement granularity is unchanged.
- **CLI.** NO new `relayctl quota` subcommand in v1; the §5.6 command table is unchanged.
- **Ops.** Operators tune the SINGLE global default via env var + rolling restart; the §9.2 ≥80%-of-daily-cap alert and the `relay_rate_limited_total{kind=account|daily}` / `relay_daily_quota_used_ratio` metrics are the feedback loop. The §9.5 shared-FCM-project circuit breaker is unaffected and still required.
- **Commits the team** to a later additive migration + `relayctl quota set/show/reset` CLI **only if/when** the revisit trigger fires (columns NULL = inherit global).
**Revisit if.** Add the per-account columns + `relayctl quota set/show/reset` CLI as soon as ANY of: (a) production metrics show one or more **legitimate** accounts repeatedly hitting the 50k/day cap or 10 req/s burst (`relay_rate_limited_total{kind=daily|account}` or `relay_daily_quota_used_ratio >= 0.8` sustained for a real, non-abusive account) such that raising the GLOBAL default would over-provision everyone else and erode the abuse cap; (b) operators need to throttle or temporarily zero a SPECIFIC misbehaving account without affecting others (today only all-or-nothing revoke/disable exists); (c) the relay onboards tenant tiers with deliberately different quotas — at which point per-account quotas become a product requirement, not a tuning convenience.
**Sources.**
- PostgreSQL `ALTER TABLE ADD COLUMN` with a CONSTANT default is a fast metadata-only operation (no table rewrite; default materialized lazily); only a VOLATILE default forces a per-row update (source: https://www.postgresql.org/docs/current/ddl-alter.html, 2026-06-13).
- OWASP API4:2023 recommends rate limiting per client/operation and business-tuned limits; it does NOT prescribe per-client configurable quota columns or a specific multi-tenant quota architecture — a per-tenant cap satisfies the control (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2026-06-13).
- FCM default per-project downstream quota is 600,000 messages/minute, enforced PER PROJECT (shared across tenants) via a per-minute token bucket; over quota returns `429 RESOURCE_EXHAUSTED` — hence the shared-project circuit breaker is required regardless of per-account limit shape (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-12).
- Spec §9.2 states the v1 intent: "These are tunable per account (future column on `relay_accounts`); v1 ships a single global default and tunes from logs," and lists per-account columns as open question #1 (source: ./00-relay-spec.md, 2026-06-13).
- The Silo server already paces relay calls client-side at a default 5 req/s shared token bucket (`02`/`03` "Relay pacing"), half the 10 req/s relay backstop (source: ./00-relay-spec.md, 2026-06-13).
---
## Decision 5 — API-key hashing: `HMAC-SHA256(pepper, secret)`
**Decision.** For high-entropy random relay API keys (`rk_<env>_<random>`, ≥128 bits), what is the correct at-rest hashing in 2026: `HMAC-SHA256(pepper)` vs salted SHA-256 vs argon2id? (Spec §8.2, §15 #6; plan Phase 1; reference §4.1.)
**Resolution.** **Store API keys as `HMAC-SHA256(pepper, secret)`** — a fast keyed hash with a server-side pepper held in the secret manager, separate from PostgreSQL. The spec/plan already lean this way (spec §8.2, plan Phase 1, reference §4.1); keep it. Concretely: generate the secret as ≥32 bytes from `crypto/rand`, format `rk_<env>_<base62/base64url>`; store a non-secret, uniquely-indexed prefix (`rk_<env>_<first 6–8 chars>`) cleartext for O(1) lookup; store `key_hash = HMAC-SHA256(pepper, presented_secret)` (32 bytes, `bytea`); verify with `crypto/subtle.ConstantTimeCompare` against the stored hash (and against a fixed decoy hash on prefix-miss to keep the path constant-time). Do **not** use argon2id/bcrypt here — they are for low-entropy human passwords and only add per-request latency and (for bcrypt) a 72-byte input-truncation hazard with no security gain against a 256-bit keyspace. No per-key random salt is required because the full-entropy secret already makes the hash input unique and rainbow tables infeasible; the pepper is the deliberate, shared defense-in-depth lever instead.
**Rotation note (load-bearing):** a pepper change cannot be done in place — it requires re-hashing every live key (impossible without the plaintext, which is never stored) or, the workable path, a **dual-pepper verification window** (try the new pepper, fall back to the old) plus a `pepper_version` column, then re-issuing keys under the new pepper and retiring the old pepper once all live keys are re-issued. Because key secrets are unrecoverable, "re-hash all keys" is really "re-issue all keys," so v1 must (a) tag each `key_hash` row with a `pepper_version`, (b) support reading under N peppers during overlap, and (c) treat pepper rotation as a make-before-break, multi-key-reissue operation aligned with the existing credential-rotation runbook (spec §14.3). Pepper rotation should be rare (compromise-driven), not scheduled.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **`HMAC-SHA256(pepper, secret)` — fast keyed hash with secret-manager pepper (chosen)** | Fast: sub-microsecond verify, no per-request CPU/RAM tax on the hot auth path (the only synchronous DB touch is the prefix lookup). Correct for high-entropy keys: a 256-bit random secret is ~2^256 keyspace, infeasible to brute-force regardless of hash speed. Defense-in-depth: if PostgreSQL leaks but the pepper does not, the stolen `key_hash` values are inert to an offline attacker. Matches OWASP's pepper guidance (shared secret, stored separately, applied via HMAC-SHA256). Plays naturally with prefix-indexed O(1) lookup + constant-time compare. Already the spec/plan choice — zero rework. | Pepper rotation is heavier: cannot re-hash in place, so it needs a dual-pepper overlap window + `pepper_version` column + key re-issuance. The pepper is a single shared secret whose compromise affects all keys at once (mitigated: secret-manager-only, never in DB/image). Slightly more moving parts than plain SHA-256. |
| Per-key salted SHA-256 (fast hash, random salt per key, no pepper) | Also fast and correct for high-entropy keys; trivially simple; no pepper to provision/rotate; salt defeats cross-key precompute (near-moot at 256-bit). | No defense-in-depth on a DB-only leak: with salt+hash an attacker can verify guesses offline — but at 256-bit entropy that offline attack is itself infeasible, so the practical delta is small. The real loss is the cheap, high-value "DB leak but pepper intact ⇒ stolen hashes inert" property, which is exactly this design's headline threat (hostile/compromised relay operator with full DB, spec §3.5). For a credential-custody-focused service holding official Apple/Google push credentials, paying the modest rotation cost to keep that property is the right trade. |
| argon2id (or bcrypt) — slow memory-hard KDF | Industry-default "correct" answer for password storage; unimpeachable if these were human-chosen secrets; brute-force resistance for low-entropy inputs. | Wrong tool. OWASP's slow-KDF requirement is scoped to passwords ("Fast hashing algorithms such as SHA-256 are not suitable for password storage…") — the threat is low password entropy, which does not exist for a 256-bit `crypto/rand` key. Adds real per-request cost (argon2id at OWASP's m=19 MiB, t=2 is tens of ms and tens of MB per verification — a self-inflicted DoS/memory-amplification vector on an auth endpoint). bcrypt additionally truncates input at 72 bytes (needs an HMAC/SHA pre-hash anyway). No security benefit: cannot make an already-infeasible 2^256 search more infeasible. Spec and reference explicitly reject it. |
**Rationale.** The decision turns on one fact: the secret is **high-entropy and machine-generated, not a human password**. Slow KDFs (argon2id/bcrypt) exist to compensate for low password entropy by making each guess expensive — their entire value is throttling a feasible offline guessing attack. Against a ≥128-bit (here 256-bit) `crypto/rand` secret the guessing attack is not feasible to begin with (~2^256 ≈ 10^77 candidates), so the slow hash protects against nothing while imposing real cost; the GPU gap that justifies slow KDFs for an 8-char password (≈180 billion SHA-256 h/s vs ≈1000 argon2 h/s) is irrelevant when the keyspace itself is unreachable (source: https://www.thesslstore.com/blog/what-is-256-bit-encryption/, 2026-06-13). OWASP confines the fast-hash prohibition to "password storage" (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13), and OWASP API2:2023 treats API keys as a distinct category requiring anti-brute-force at the endpoint — which the relay supplies via per-IP throttle + per-account rate limits, not a slow hash (source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2026-06-13). So a **fast** hash is the correct family. Within fast hashes, the pepper-vs-salt choice is a defense-in-depth-vs-rotation-cost trade, and the pepper buys a property that maps directly onto this service's threat model: the headline adversary is a compromised relay operator / DB leak (spec §3.5), and the relay holds the official Apple `.p8` and Google SA credentials for the whole ecosystem. With the pepper in the secret manager (never in PG, never in the image), a Postgres-only compromise yields `key_hash` values that are inert without the pepper key — an attacker cannot even mount the (already-infeasible) offline search, and cannot forge or recognize valid keys. OWASP defines a pepper as a secret "shared between stored passwords," stored separately, applied as an HMAC where "the pepper is acting as the HMAC key" — exactly this design (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13). The rotation cost is real but bounded and rare (compromise-driven), and reuses the existing make-before-break credential-rotation runbook (spec §14.3). Salted SHA-256 is a defensible simpler fallback if the team judges pepper rotation not worth it, but it forfeits the DB-leak-resistance property that is this design's reason to care; argon2id is the only genuinely wrong choice and both spec and reference already reject it.
**Consequences (what this commits).**
- **Schema/DB — amends spec §8.1 / plan Phase 1.** `relay_api_keys` stores `key_hash bytea = HMAC-SHA256(pepper, secret)` (32 bytes), a cleartext uniquely-indexed `key_prefix`, and `account_id` / `created_at` / `last_used_at` / `expires_at` / `revoked_at` (spec §8.1). No salt column. **This decision adds one column the reproduced §8.1 `relay_api_keys` table does not have: `pepper_version smallint NOT NULL DEFAULT 1` (a `key_id` column is an acceptable equivalent), included in the Phase 1 init migration from the start** so dual-pepper rotation is possible later without a migration under pressure. This is an **additive, backward-compatible deviation** from the §8.1 schema reproduced in the spec and the plan's "exact schema from spec §8.1" Phase 1 statement — an implementer must add the column up front rather than discovering the gap during pepper rotation.
- **Code.** `internal/accounts/keys.go`: `crypto/rand` secret generation; HMAC-SHA256 hashing. `internal/httpapi/middleware_auth.go`: `crypto/subtle.ConstantTimeCompare` for verification; a fixed decoy-hash compare on prefix-miss to keep the auth path constant-time (no enumeration oracle); verification tries the active pepper and, during a rotation window, falls back to prior pepper(s) keyed by `pepper_version`.
- **Ops / secret management.** The pepper is a first-class secret in the secret manager (KMS/Vault/cloud), never in PG, never in the image/env where avoidable, loaded once at startup (spec §11, reference §4.5); it must be **backed up / escrowed** because losing it invalidates every live key.
- **Rotation runbook.** Pepper rotation = make-before-break (provision new pepper alongside old, verify under both, re-issue all keys under the new pepper, retire old) — reuse the §14.3 credential-rotation pattern; document that "re-hash all keys" is really "re-issue all keys."
- **Performance.** Auth stays a sub-microsecond hash + one indexed lookup, preserving the stateless-fast hot path (spec §4.1). Commits to NOT pulling argon2/bcrypt onto the request path.
- **Tests.** Unit tests asserting the hash is `HMAC-SHA256(pepper, ·)`, constant-time compare, plaintext never stored, and unknown-prefix vs wrong-secret are timing-indistinguishable (plan Phase 1/2 acceptance criteria).
**Revisit if.** (1) The relay ever accepts a LOW-entropy or human-chosen secret (an operator passphrase, basic-auth admin password, shortened/customer-supplied key) — then a slow KDF (argon2id at OWASP's m=19 MiB, t=2, p=1 minimum) becomes mandatory for that credential class. (2) The pepper would have to live in the same trust boundary as PostgreSQL (same host/backup, in the DB) — its defense-in-depth value evaporates and plain salted SHA-256 becomes the honest, simpler choice. (3) A hard, citable, API-key-specific standard later prescribes a different at-rest scheme. (4) Pepper rotation proves operationally painful and pepper-compromise risk is judged low — drop to per-key salted SHA-256. (5) Key entropy is ever reduced below ~128 bits.
**Sources.**
- OWASP: "Fast hashing algorithms such as SHA-256 are not suitable for **password** storage because they allow attackers to perform large numbers of guesses quickly." (scoped to passwords) (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13).
- OWASP argon2id recommended configs (e.g. m=19456 KiB, t=2, p=1) are a per-verification CPU/RAM tax that would hit every relay auth if wrongly applied to API keys (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13).
- OWASP defines a pepper as a secret "shared between stored passwords," "stored separately from the password database," applied via HMAC where "the pepper is acting as the HMAC key" (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06-13).
- OWASP API2:2023 flags weakly-hashed credentials, requires anti-brute-force mechanisms on auth endpoints, and states "API keys should not be used for user authentication … only for API clients authentication." (source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2026-06-13).
- A 256-bit random key has ~2^256 (~10^77) values — computationally infeasible to brute-force regardless of hash speed; security derives from key entropy, not hash slowness; the ~180-billion-vs-~1000 h/s gap is irrelevant when the keyspace is unreachable (source: https://www.thesslstore.com/blog/what-is-256-bit-encryption/, 2026-06-13).
- bcrypt truncates input at 72 bytes (a long `rk_` key would be silently shortened, needing an HMAC/SHA pre-hash anyway), making a slow KDF not merely unnecessary but operationally hazardous for long high-entropy keys; fast HMAC-SHA256/SHA-256 is appropriate for quick auth checks on high-entropy tokens (source: https://mojoauth.com/compare-hashing-algorithms/hmac-sha256-vs-bcrypt, 2026-06-13).
---
## Decision 6 — `relay_op_logs` retention: PostgreSQL daily RANGE partitioning, retention by partition DROP
**Decision.** For `relay_op_logs` at ~50k/day/account scale with 14-day-success / 30–90-day-failure retention, use native PostgreSQL declarative time partitioning (with partition drop), a simple indexed table with a periodic `DELETE` job, or offload to a log pipeline (Loki/ELK) — and what is the exact retention mechanism for v1? (Spec §8.1, §15 #8; plan Phase 5.)
**Resolution.** **Ship v1 with native PostgreSQL declarative RANGE partitioning** of `relay_op_logs` by `occurred_at`, using **DAILY** partitions, and enforce retention by **DROPPING whole partitions** on a schedule — not by row-level `DELETE`, and not by offloading to a log pipeline. Pre-create partitions ahead of time and drop those older than the retention horizon. Run the maintenance (create-ahead + drop-old) with **`pg_partman 5.4.3`** invoked from a scheduled `relayctl logs --gc` / cron `SELECT run_maintenance()`, configured to **DROP (not just DETACH)** expired partitions. Keep retention simple in v1: a single uniform **90-day** drop horizon for the whole table (the failure-retention ceiling), so no partition straddles two policies. The differential 14-day-success vs 90-day-failure trim is then a cheap row-level `DELETE` **within the youngest partitions only** (success rows older than 14 days), where the working set is tiny and bloat is bounded — the bulk reclamation that matters comes free from dropping 90-day-old partitions. This is the partition-drop mechanism the spec already calls "the recommended store strategy" (§8.1) and resolves open question §15 #8 in favor of partitioning over a plain `DELETE` job or external offload.
**Alternatives considered.**
| Option | Pros | Cons |
|---|---|---|
| **Native declarative RANGE partitioning by `occurred_at` (daily), retention by partition DROP via `pg_partman`/cron (chosen)** | Retention is metadata-only: `DROP TABLE` on a day partition "can very quickly delete millions of records" and "entirely avoids the VACUUM overhead caused by a bulk DELETE." No dead-tuple/index churn from the append workload — the exact failure mode a daily bulk-DELETE on a high-write table hits. Stays inside the existing Postgres dependency (no new store), honoring "operational simplicity that still scales." `pg_partman` (5.4.3, PG14+) automates create-ahead + drop and runs from plain cron via `run_maintenance()` with no background worker, fitting the Goose + scheduled-job model. Inserts still target one parent table; the redacted-by-construction write path is unchanged. Scales linearly; old partitions drop in O(1). | Schema change: a partitioned table's PRIMARY KEY must include the partition key, so the current `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` becomes composite `(occurred_at, id)`. Adds `pg_partman` (or hand-rolled DDL) plus a create-ahead job whose failure (no future partition) would block inserts — needs a monitor. Differential 14d/90d retention is no longer one query; a small DELETE runs inside recent partitions. `DROP TABLE` on a partition briefly needs an ACCESS EXCLUSIVE lock on the parent (mitigated by `DETACH … CONCURRENTLY` first, or accepting a sub-second lock on a low-contention audit table). |
| Simple indexed table + periodic `DELETE` job (the spec's `relayctl logs --gc` baseline, as the only mechanism) | Zero schema change — keeps the §8.1 table and surrogate bigint PK. Trivially expresses differential policy in one statement. Already a Phase 5 deliverable, so lowest-effort and a correct *fallback*. Fine at low account counts. | At 50k/day/account across accounts this is the anti-pattern the PG docs warn against: a large recurring DELETE leaves dead tuples and index bloat that standard VACUUM marks reusable but does not return to disk, so the table and its two `occurred_at` indexes bloat and slow the very append path. Reclaiming space needs `VACUUM FULL` (ACCESS EXCLUSIVE) or `pg_repack` — ongoing toil. The delete competes with hot-path inserts for I/O and locks. Works for months then the disk-growth surprise hits — exactly risk R12. |
| Offload to a log pipeline (Loki/ELK), Postgres holds only recent rows | Purpose-built for high-volume append-only logs with cheap time-based retention and rich ad-hoc querying; removes write pressure from Postgres; scales far past 50k/day/account. | Violates v1's operational-simplicity / self-hosted brief: a whole new stateful system to deploy, secure, scope per-account, and back up for a single-region relay. `relay_op_logs` is an audit/forensics table queried by `relayctl` and joined to `relay_accounts` (FK `account_id`); moving it out breaks the in-Postgres admin/audit model and the single-point redaction guarantees. Over-engineered for the stated scale; reintroduces a deliberately-avoided dependency. The right answer later, not for v1. |
**Rationale.** The workload is unambiguously a high-write, append-only, time-ordered table whose only deletes are time-based retention — the textbook case for declarative range partitioning. PostgreSQL's own current (v18) docs state the decision plainly: dropping a partition "is far faster than a bulk operation" and "entirely avoids the VACUUM overhead caused by a bulk DELETE," and recommend sizing partitions to match the retention cadence (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13). Reliability-first means the hot push path must not contend with a daily bulk DELETE or pay for its dead-tuple/index bloat; partition DROP makes retention a metadata operation that touches neither autovacuum nor the live indexes — whereas recurring large DELETEs accumulate bloat that VACUUM marks reusable but does not shrink on disk, requiring `VACUUM FULL` or `pg_repack` to reclaim (source: https://www.tigerdata.com/learn/how-to-reduce-bloat-in-large-postgresql-tables, 2026-06-13). **Daily** (not weekly/monthly) granularity is chosen because the success horizon is only 14 days and the failure horizon 90 days — daily children keep each partition small, make the 90-day drop precise, and keep the create-ahead window short. The plain-DELETE option is kept as the differential-trim tool inside young partitions and as a degraded fallback, not as bulk reclamation. A log pipeline is the correct answer at much larger multi-region scale, but for a single-region self-hosted v1 it adds a new stateful dependency that contradicts the "stays in Postgres" posture and severs the FK/audit/redaction model that lives in one place. `pg_partman` is the pragmatic automation: de-facto standard, current (5.4.3, 2026-03-05), PG14+, **defaults to DETACH** (so retention must be explicitly configured to DROP via `retention_keep_table = false`), and runs from ordinary cron via `run_maintenance()` so no background worker is required — matching the relay's Goose + scheduled-job model (source: https://github.com/pgpartman/pg_partman, 2026-06-13). The spec already names partitioning "the recommended store strategy"; this decision firms that into "partition + drop, daily, single 90-day horizon."
**Consequences (what this commits).**
- **Schema (§8.1) — amends the plan.** `relay_op_logs` becomes `PARTITION BY RANGE (occurred_at)` with daily child partitions; the surrogate `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` must become a composite PRIMARY KEY `(occurred_at, id)` (id remains identity for ordering/correlation; `occurred_at` carries the partition key). The two indexes — `(account_id, occurred_at DESC)` and `(occurred_at DESC)` — are created on the partitioned parent and propagate to children; the `account_id` FK with `ON DELETE SET NULL` is unaffected. **This decision explicitly supersedes spec §8.1's non-partitioned `relay_op_logs` DDL (its single-column `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY`) and the plan's "exact schema from spec §8.1" Phase 1 statement: the partitioned-from-birth design only works if the structural DDL lands in the Phase 1 init migration.** Converting an already-populated non-partitioned table to partitioned later is exactly the painful full-table rewrite that partitioning exists to avoid, so it must **not** be deferred to Phase 5.
- **Migrations — Phase 1 (structural, amending the §8.1 reproduction).** The Phase 1 init migration creates the partitioned parent + an initial window of daily partitions **in place of** the non-partitioned spec §8.1 `relay_op_logs` DDL and its single-column PK; `pg_partman` is added via `CREATE EXTENSION` + a `part_config` row (parent table, daily interval, premake N future days, `retention = '90 days'`, `retention_keep_table = false` to DROP). The composite `(occurred_at, id)` PK and the `CREATE EXTENSION pg_partman` + `part_config` row are part of this Phase 1 init migration, not a later Phase 5 add-on.
- **Code / ops.** The oplog writer is unchanged (still `INSERT` into the parent). A scheduled job — `relayctl logs --gc` or cron `SELECT run_maintenance()` — must run at least daily to (a) pre-create upcoming day partitions and (b) drop partitions older than 90 days; its **failure** (missing future partition) would reject inserts on the hot path, so it needs a dedicated alert in addition to the existing table-growth/disk alert (R12). The differential 14d-success trim runs as a bounded `DELETE` within recent partitions only.
- **Deps.** Adds `pg_partman 5.4.3` (PG14+) as a Postgres extension dependency; if the deployment cannot install extensions, fall back to hand-rolled create-ahead/drop DDL in `relayctl` (same partition design, no extension).
- **Locking.** Schedule drops at low-traffic times and/or `DETACH … CONCURRENTLY` then `DROP` to avoid the brief ACCESS EXCLUSIVE lock on the parent.
- **Phase split (amends the plan).** The plan's Phase 5 "partitioning DDL (or documented partition/offload decision)" line item is **resolved** by this decision, but the structural DDL itself moves **into Phase 1** (per the Schema/Migrations items above). **Phase 5 owns only** the operational pieces: the scheduled `run_maintenance()` / `relayctl logs --gc` create-ahead+drop job, the create-ahead-failure alert (a missing future partition rejects inserts on the hot path), and the bounded 14d-success differential `DELETE` within recent partitions. The plan's Phase 1 "exact schema from spec §8.1" line and Phase 5 "partitioning DDL" deferral are both superseded.
**Revisit if.** (a) Sustained ingest materially exceeds the v1 envelope — e.g. tens of accounts pushing near 50k/day each so `relay_op_logs` exceeds ~100M live rows, or Postgres shows I/O/bloat pressure from op-log writes — at which point offloading to a dedicated log pipeline (Loki/ELK/ClickHouse) with Postgres keeping a short hot window becomes worth its added ops cost. (b) The deployment target cannot install `pg_partman` AND hand-rolled partition DDL proves error-prone. (c) The differential success/failure retention needs to grow into many distinct per-event horizons such that a single uniform partition-drop horizon no longer fits. (d) Operators need rich ad-hoc/full-text forensic search over op-logs beyond indexed Postgres queries.
**Sources.**
- PostgreSQL: "Dropping an individual partition using DROP TABLE, or doing ALTER TABLE DETACH PARTITION, is far faster than a bulk operation. These commands also entirely avoid the VACUUM overhead caused by a bulk DELETE." (PG 18) (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13).
- PostgreSQL: dropping the no-longer-needed partition "can very quickly delete millions of records because it doesn't have to individually delete every record"; `DROP TABLE` on a partition needs ACCESS EXCLUSIVE on the parent, while `DETACH PARTITION CONCURRENTLY` needs only SHARE UPDATE EXCLUSIVE (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13).
- A unique/primary-key constraint on a declaratively partitioned table must include all partition-key columns — so partitioning by `occurred_at` requires the composite key (source: https://www.postgresql.org/docs/current/ddl-partitioning.html, 2026-06-13).
- `pg_partman` latest is 5.4.3 (2026-03-05); requires PostgreSQL ≥ 14; third-party extension; can run maintenance from external cron via `run_maintenance()`; by default DETACHes rather than DROPs expired partitions, so drop-on-retention must be configured (`retention_keep_table = false`) (source: https://github.com/pgpartman/pg_partman, 2026-06-13).
- For high-churn tables, recurring large DELETEs accumulate dead tuples and index bloat; VACUUM marks them reusable but does not shrink files, requiring `VACUUM FULL` or `pg_repack`; partition DETACH+DROP needs no vacuum at all (source: https://www.tigerdata.com/learn/how-to-reduce-bloat-in-large-postgresql-tables, 2026-06-13).
- `pg_partman` retention: "By default, partitions are not dropped (DROP), but are detached (DETACH)"; the configured interval drops partitions older than that interval; pg_partman never drops the last remaining child (source: https://www.keithf4.com/partman-retention/, 2026-06-13).
---
## What this unblocks
Each decision retires a spec §15 open question and clears a specific implementation-plan phase to proceed without further product/architecture input:
| Decision | Retires | Unblocks |
|---|---|---|
| 1 — APNs client (hand-roll) | §15 #3, plan §3 "decision before Phase 3", risk R2 | **Phase 3** — `internal/apns` (`jwt.go`/`transport.go`/`client.go`/`errors.go`) and `POST /v1/apple/send` can be built directly against `net/http2` + `jwt/v5` with no library evaluation gating the work; the Apple provisioning checklist item "decide `apns2` vs hand-rolled" (plan §7.1) is closed. |
| 2 — FCM credentials (keyless-first) | §15 #4 | **Phase 4** (`internal/fcm/oauth.go` builds the ADC `TokenSource`; `POST /v1/fcm/send`) and **Phase 6** (secret-manager wiring, the GCP attached-SA / off-GCP WIF deploy paths, and the `relayctl ping-upstream --provider fcm` credential-health gate). |
| 3 — `apns-expiration` policy (finite, relay-owned) | §15 #5 (spec open question 5) | **Phase 3** (APNs `apns-expiration` epoch in `internal/pushwire/apns.go`) and **Phase 4** (parallel FCM `android.ttl` in `internal/pushwire/fcm.go`); the two new `internal/config` TTL values and their clock-injected golden tests are now specified. |
| 4 — Rate limits (global defaults) | §15 #1 | **Phase 2** — `internal/ratelimit` ships the global constants + env overrides and the `accountLimits` resolution struct with no new schema, no `relayctl quota` CLI, and no inheritance-precedence edge cases; spec §8.1 schema and §5.6 command table are unchanged. |
| 5 — API-key hashing (HMAC-pepper) | §15 #6 | **Phase 1** (`relay_api_keys` schema with `key_hash bytea` + a `pepper_version smallint NOT NULL DEFAULT 1` column added to the reproduced §8.1 table; the pepper as a first-class secret-manager secret) and **Phase 2** (`internal/httpapi/middleware_auth.go` constant-time verify with decoy-hash and dual-pepper fallback). |
| 6 — Op-log retention (daily partition + DROP) | §15 #8 | **Phase 1** (the structural DDL — partitioned `relay_op_logs` parent, composite `(occurred_at, id)` PK, `CREATE EXTENSION pg_partman` + `part_config` row — lands in the init migration, **amending** the plan's "exactly spec §8.1" Phase 1 statement and superseding the non-partitioned §8.1 DDL) and **Phase 5** (only the `relayctl logs --gc` / `run_maintenance()` schedule, the create-ahead-failure alert, and the bounded differential 14d-success trim). |
@@ -0,0 +1,71 @@
# Silo Push Relay — Design Index
> **Provenance:** Authored 2026-06-13 from a research-and-review workflow (live primary-source research against Apple Developer / Firebase / Google / RFC / pkg.go.dev docs, adversarial fact-checking of time-sensitive claims, draft, multi-lens review, and revision). Reflects **2026-current** APNs and FCM behavior. These design docs live here in `silo-server` alongside the `02`/`03` notification contracts purely for **co-location** — the relay **code** lives in its own repository (provisional name `silo-push-relay`).
This folder specifies the **Silo Push Relay**: the small Silo-operated service that holds the official Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials and forwards **opaque, content-free** push requests to APNs and FCM on behalf of opted-in self-hosted Silo servers.
It is the **server counterpart** to the relay-client contracts in [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md). Those documents define the wire contract from the self-hosted Silo server's perspective (`POST /v1/apple/send`, `POST /v1/fcm/send`); this folder specifies the service that answers it. **That external contract is authoritative and reproduced byte-for-byte; these docs add only the server-internal design (`02`/`03` deliberately left it "to a separate repo").**
## Reading order
1. **[`00-relay-spec.md`](./00-relay-spec.md)** — Engineering spec. Scope, trust/threat model, architecture, the full public API contract (both send endpoints + health/metrics + the `relayctl` admin CLI), the upstream APNs and FCM clients, the PostgreSQL account/key/allowlist schema, rate limiting, idempotency, security, privacy, observability, deployment.
2. **[`01-implementation-plan.md`](./01-implementation-plan.md)** — Phased Go build plan. Repo bootstrap and layout, pinned dependency choices with rationale, seven implementation phases (each with files, tasks, tests, acceptance criteria), testing strategy, deployment, and the Apple/Firebase provisioning checklist.
3. **[`02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md)** — The cited 2026 factual foundation. APNs (token/ES256 auth, endpoints, headers, payloads, status/reason tables, throttling) and FCM v1 (legacy-shutdown status, OAuth2 auth, endpoint, message JSON, error model, quotas) plus the recommended Go ecosystem and security/ops practices. Every non-obvious claim carries an inline source + date.
4. **[`03-decisions.md`](./03-decisions.md)** — Decision record resolving the spec's open implementation questions (APNs client, FCM credentials, `apns-expiration`, rate limits, API-key hashing, op-log retention), each with a firm recommendation, alternatives, rationale, consequences, and cited current sources.
## Status
| Doc | Status |
|---|---|
| 00-relay-spec | Draft (implementation-grade) |
| 01-implementation-plan | Draft (implementation-grade, phased) |
| 02-apns-fcm-2026-reference | Reference (2026-current, cited) |
| 03-decisions | Decided (6 resolutions, high confidence) |
**Implementation status:** Phases 0–2 are built in the separate `silo-push-relay` repository. Phase 0 = the service skeleton (`/healthz` + `/readyz`, config, JSON logging, §5.5 envelope, graceful shutdown). Phase 1 = storage + admin (PostgreSQL schema via embedded Goose migrations, the `accounts` data-access layer with `HMAC-SHA256(pepper)` key hashing, the `relayctl` admin CLI with audited actions). Phase 2 = the full request pipeline on `POST /v1/apple/send` + `/v1/fcm/send` — bearer auth (constant-time compare with decoy, throttled `last_used_at`, soft per-IP brute-force cap, trusted-proxy client-IP resolution), Redis token-bucket rate limiting (per-account + daily + coarse per-token) with bounded in-process fail-open, `DisallowUnknownFields` decode/validate/allowlist, and fail-closed idempotency (NX lock + nonce, replay/409/422, compare-and-set). Upstream APNs/FCM calls are **stubbed** pending Phases 3–4. Two Phase-2-listed items were deferred to Phase 4 because they need the live FCM send path: the shared FCM-project circuit breaker (§9.5) and anti-spike smoothing. Phases 3–6 not started; provider-touching phases gated on Apple/Firebase provisioning (see `01` §7). `relay_op_logs` partitioning (Decision 6) is a dedicated later migration so migrations run on stock PostgreSQL.
## Key design decisions
- **Faithful external contract.** `POST /v1/apple/send` and `POST /v1/fcm/send` — request fields, validation, response shapes, relay-built payloads, and APNs headers — are reproduced exactly from `02`/`03`. Only the relay's server-internal design is newly specified.
- **Content-free by construction.** The send structs admit only the opaque `02`/`03` fields; JSON is decoded with `DisallowUnknownFields`, so any unexpected key is a hard `400`. FCM is always data-only (no `notification` block); badge omitted by default.
- **Stateless request path.** The only synchronous DB touch is an indexed API-key prefix lookup plus a throttled (`≤1/min`) `last_used_at` write; idempotency and rate-limit state live entirely in Redis, so replicas scale horizontally and are kill-safe.
- **Admin = CLI, not HTTP.** Account/key/allowlist administration is `relayctl` writing directly to PostgreSQL; there is **no public admin HTTP API** in v1 (minimal attack surface).
- **API keys.** `rk_<env>_<random>` with a non-secret indexed prefix + `HMAC-SHA256(pepper, secret)` stored, constant-time compare, revocable, multi-live-key rotation; the full token is shown exactly once.
- **Upstream mechanics (2026-correct).** One cached ES256 JWT per APNs team (regenerate ~50 min, never < 20 min apart, eager refresh on `ExpiredProviderToken`); one cached OAuth2 token per FCM project (~1 h); HTTP/2 connection reuse with PING health checks.
- **Idempotency fails closed; rate limiting fails open.** If Redis is unavailable, sends are refused (`503`) rather than risk a double-send; rate limiting degrades to allow (availability over perfect enforcement), with a metric.
- **"Relay-down delays, never loses."** The self-hosted server-side outbox (`push_delivery_attempts` pending rows in `../01-release-events-and-inbox.md`) guarantees re-delivery, so ordinary stateless-replica HA suffices.
### Reconciliations where the 2026 reference corrects the 2026-04 contracts
These are upstream-only details; the **external `02`/`03` contract is unchanged**:
- **APNs sandbox host** is `api.sandbox.push.apple.com` (the canonical name); `02`'s `api.development.push.apple.com` is a still-valid alias (same backend). Internal detail; the external `"environment":"sandbox"` field is untouched.
- **FCM 429** honored with `Retry-After` (60 s fallback) while still surfacing `Retry-After` to the caller as `03` expects.
- **FCM data values** forced to strings (`map<string,string>`).
- **FCM `priority:high`** honored for `private_data`; provider deprioritization is the caller's concern.
## Decisions (resolved in `03-decisions.md`)
The spec's open implementation questions are now decided (all high confidence):
1. **APNs client** — **hand-roll** on `net/http` + `x/net/http2` + `golang-jwt/jwt/v5` behind an `internal/apns` interface; do **not** take a production dependency on `sideshow/apns2` (no release since Oct 2024). Optionally keep apns2 as a throwaway Phase-3 oracle.
2. **FCM credentials** — **keyless by default** via Application Default Credentials: attached service account on GCP, Workload Identity Federation off-GCP; service-account JSON in a secret manager only as a last resort. Identical code path in all three cases.
3. **`apns-expiration`** — relay sets a **finite TTL** (4 h for `private_alert`, 1 h for `background_wake`) with a parallel FCM `android.ttl`; relay-owned config, external `02`/`03` contract unchanged.
4. **Per-account rate limits** — **hard-coded global defaults** for v1 (~10 req/s burst, 50k/day), resolved through a typed `accountLimits` struct so per-account override columns are a cheap additive migration later.
5. **API-key hashing** — `HMAC-SHA256(pepper, secret)` with a `pepper_version` column and a make-before-break dual-pepper rotation window. No argon2id (keys are high-entropy).
6. **Op-log retention** — native PostgreSQL **daily RANGE partitioning**, retention by **partition DROP** (pg_partman), uniform 90-day horizon with a cheap row-level success-trim inside young partitions.
## Confirmed (2026-06-13)
- **Module org** — `github.com/Silo-Server/silo-push-relay` (matches the real org in `silo-server`'s `go.mod`; the plan's `silo-app` was provisional).
- **Deployment target** — the relay is **not** deployed to GCP. Per Decision 2 this selects the off-GCP credential path: **Workload Identity Federation** (keyless, non-secret credential-config + OIDC token) preferred, with **service-account JSON in a secret manager** as the last-resort fallback. No attached-GCP-SA path.
## Still needs human / operational input
1. **Provider provisioning** (the real long pole) — Apple Developer account + APNs `.p8` key + per-platform bundle topics; Firebase project(s) + the WIF binding (or SA JSON in a secret manager). Gates Phases 3/4/6. See `01` §7.
2. **Off-GCP OIDC issuer for WIF** — confirm the relay host has a usable OIDC issuer for Workload Identity Federation; if none, fall back to SA-JSON-in-secret-manager (Decision 2).
3. **Multi-region** — out of scope for v1 (single region); revisit a regional Redis/Postgres strategy if latency to distant self-hosters matters.
## Relationship to the parent notifications design
This relay corresponds to phases 7–8 of the parent plan ([`../README.md`](../README.md) "Phasing"): `notifications.apple_push_enabled` and `notifications.android_push_enabled`. Those phases require Silo to provision Apple Developer + Firebase accounts and operate this service. The self-hosted server side (registration, dispatcher, pacing, retry, the dispatch outbox) is specified in `../02-apns-relay.md`, `../03-fcm-relay.md`, and `../01-release-events-and-inbox.md`.
@@ -0,0 +1,248 @@
# Unified Literary Works Design
## Goal
Silo should expose a server-owned literary work identity that links multiple format-specific catalog items for the same book, especially ebooks and audiobooks, without collapsing those items into one `media_items` row. Clients can then render one detail page with format tabs/actions while existing item detail, playback, reading, download, and catalog APIs keep working.
The implementation should target the ebook stack head, currently `work/ebook-reader-ruler-profiles`, which includes the ebook foundation, #98 local-cover/grouping work, and #99 reader profile/ruler work. That stack already has ebook scanning, ebook series metadata, ebook reader progress, and ebook detail extensions. The work layer should sit above those existing format-specific systems.
## Existing Context
`media_items.content_id` is the stable identity for each catalog item. Audiobooks are `media_items.type = 'audiobook'`; ebooks are introduced as `media_items.type = 'ebook'` in the ebook stack. These identities must remain independent because each format has different files, progress, playback/reader behavior, external compatibility, and library membership.
Audiobooks already store author and narrator credits in `item_people`, series data in `audiobook_series`, file data in `media_files`, and listening progress in existing playback/watch progress tables. The ebook stack adds ebook author credits, `ebook_series`, `ebook_reader_progress`, reader state/config, and ebook file serving. #98 also groups sibling ebook file formats by book identity during scan; that is an intra-ebook item grouping behavior, not the cross-format work identity.
The current catalog detail endpoint is `GET /api/v1/catalog/items/{id}` through `CatalogResourceHandler.HandleGetItemDetail`, backed by `catalog.DetailService.GetItemDetail`. Catalog browse/search remains item-oriented by default through catalog query/browse code.
## Data Model
Add a neutral work layer:
```sql
CREATE TABLE literary_works (
work_id TEXT PRIMARY KEY,
canonical_title TEXT NOT NULL,
sort_title TEXT,
normalized_title TEXT NOT NULL,
primary_author_key TEXT NOT NULL DEFAULT '',
primary_cover_content_id TEXT REFERENCES media_items(content_id) ON DELETE SET NULL,
description TEXT,
published_date DATE,
publisher TEXT,
genres TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE literary_work_items (
work_id TEXT NOT NULL REFERENCES literary_works(work_id) ON DELETE CASCADE,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
format_type TEXT NOT NULL,
link_source TEXT NOT NULL,
confidence DOUBLE PRECISION NOT NULL DEFAULT 1,
confirmed_at TIMESTAMPTZ,
ignored_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (work_id, content_id),
UNIQUE (content_id),
CHECK (format_type IN ('ebook', 'audiobook', 'comic', 'manga')),
CHECK (link_source IN ('manual', 'external_id', 'metadata_match', 'series_match', 'scan_seed'))
);
CREATE TABLE literary_work_match_decisions (
source_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
target_content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
decision TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (source_content_id, target_content_id),
CHECK (source_content_id <> target_content_id),
CHECK (decision IN ('confirmed', 'ignored'))
);
```
Every ebook or audiobook can belong to at most one work through `UNIQUE (content_id)`. A work can contain many format-specific items. Manual/admin actions update `literary_work_items` and `literary_work_match_decisions`; automatic matching must not override an ignored pair or move a manually linked item without an explicit admin request.
Work IDs should be generated by the existing server ID generator, not derived directly from title text. Matching keys can change when metadata improves, but the `work_id` remains stable.
## Matching Rules
Matching runs in a `literaryworks` service after scan/enrichment or as an admin-triggered repair job. It considers literary item types only: `ebook`, `audiobook`, and later `comic`/`manga`.
Authority order:
1. Manual confirmed links and manual unlinks/ignored pairs.
2. Shared external IDs from `media_item_provider_ids`, excluding format-specific aliases that are not true work identity.
3. Normalized title plus normalized author.
4. Normalized series name plus series index plus author.
5. Publication year/date and publisher as supporting evidence only.
Automatic matching should be conservative. Same title alone is not enough. Same title plus different primary author should not link. Same title plus same author can link unless there is strong conflicting series/index evidence. Same series/index plus same author can link even if titles differ slightly due subtitle/edition formatting. Narrator is never required for ebook matching and must not prevent an ebook/audiobook work link; narrator remains audiobook-format metadata.
The matcher should produce scored candidates. Scores at or above the automatic threshold can create links when neither item has conflicting manual state. Lower scores are exposed as possible matches for admin review.
## Service Boundaries
Create an `internal/literaryworks` package with:
- `Repository`: reads/writes `literary_works`, memberships, decisions, and candidate rows.
- `Matcher`: normalizes item metadata, scores candidates, and applies conservative automatic links.
- `Service`: public orchestration used by handlers and background jobs.
The package depends on catalog repositories and the database pool, but catalog detail should depend on a small interface so item detail can include work data without creating a large import cycle.
The matcher should reuse existing item metadata:
- Titles from `media_items`.
- Authors from `item_people.kind = author`.
- Audiobook narrators from `item_people.kind = narrator` only for display inside audiobook format details.
- Series from `audiobook_series` and `ebook_series`.
- External IDs from `media_item_provider_ids`.
- File metadata from `media_files`.
## API Contract
Add a work endpoint:
`GET /api/v1/works/{work_id}`
Response:
```json
{
"work_id": "work_123",
"work_title": "Project Hail Mary",
"authors": [{ "name": "Andy Weir", "person_id": "42" }],
"formats": [
{
"type": "ebook",
"content_id": "ebook_1",
"library_id": 1,
"available_files": [
{
"file_id": 10,
"original_filename": "Project Hail Mary.epub",
"format": "epub",
"mime_type": "application/epub+zip",
"size": 12345
}
],
"progress": {
"kind": "reading",
"progress": 0.42,
"updated_at": "2026-06-09T12:00:00Z"
}
},
{
"type": "audiobook",
"content_id": "audio_1",
"library_id": 2,
"available_files": [
{
"file_id": 20,
"original_filename": "Project Hail Mary.m4b",
"format": "m4b",
"mime_type": "audio/mp4",
"size": 987654321,
"duration_seconds": 58200
}
],
"progress": {
"kind": "listening",
"position_seconds": 1200,
"duration_seconds": 58200,
"updated_at": "2026-06-09T12:00:00Z"
}
}
],
"primary_cover_url": "/api/v1/images/...",
"metadata": {
"description": "...",
"series": { "name": "The Series", "index": 1 },
"genres": ["Science Fiction"],
"published_date": "2021-05-04",
"publisher": "Ballantine Books"
}
}
```
Extend `GET /api/v1/catalog/items/{content_id}` additively:
```json
{
"content_id": "ebook_1",
"type": "ebook",
"work_id": "work_123",
"work_title": "Project Hail Mary",
"work_formats": [
{ "type": "ebook", "content_id": "ebook_1", "library_id": 1 },
{ "type": "audiobook", "content_id": "audio_1", "library_id": 2 }
]
}
```
Existing fields and behavior remain unchanged. Clients that do not know about works can ignore the new fields.
## Catalog Grouping
Add optional work grouping for literary media:
`GET /api/v1/catalog?type=reading&group=work`
The grouped response should return one card per work when a linked work exists, with format badges such as `ebook` and `audiobook`. Unlinked literary items still appear as single-item cards with an implicit one-item work projection. Non-literary media remain item-based.
Do not change default catalog/search behavior. Work grouping must be opt-in so current clients and Jellyfin/ABS compatibility routes are unaffected.
## Progress and Files
Progress remains per format and per existing content identity:
- Ebook progress reads from `ebook_reader_progress`.
- Audiobook progress reads from existing playback/watch progress storage.
The work response aggregates those format-specific progress rows without merging them.
Available files come from `media_files`, grouped by format content ID. File metadata should include `file_id`, original basename, extension/container, MIME type when known, byte size, and duration for audio. Android can continue using existing file/download routes by `file_id`; the work endpoint only tells it what is available.
## Admin Primitives
Add admin endpoints:
- `GET /api/v1/admin/literary-works/items/{content_id}/matches`: list possible work/item matches with score and evidence.
- `POST /api/v1/admin/literary-works/link`: link two or more content IDs into one work.
- `POST /api/v1/admin/literary-works/{work_id}/unlink`: unlink one content ID from a work.
- `POST /api/v1/admin/literary-works/matches/ignore`: mark a candidate pair ignored.
- `POST /api/v1/admin/literary-works/matches/confirm`: confirm a candidate pair and link them.
Admin linking must validate that every content ID is a supported literary type and accessible to the admin. Unlinking the last item can delete the empty work.
## Backward Compatibility
All existing item detail, catalog, reader, playback, download, Jellyfin compatibility, and ABS compatibility routes remain valid. The new tables cascade from `media_items`, so library deletion and orphan cleanup continue to remove format items; work cleanup should delete empty works after membership deletion.
No caller is required to supply `work_id` for existing format actions. Work APIs are additive and read-oriented except for admin management endpoints.
## Testing Strategy
Repository and matcher tests should cover:
- Ebook and audiobook with same normalized title and author link to one work.
- Same title with different authors does not auto-link.
- Same author and series/index links despite title subtitle differences.
- Shared external ID links above fuzzy metadata.
- Manual ignored pair blocks automatic linking.
- Manual confirmed link wins over automatic scoring.
- Unlink removes one item without deleting the format media item.
- Existing item detail JSON still includes normal fields when no work exists.
- Existing catalog browse/search remains item-based unless `group=work`.
- Work endpoint returns separate ebook and audiobook progress.
- Work endpoint returns file metadata for EPUB/PDF/CBZ and M4B/MP3 without changing download behavior.
Handler tests should assert route status codes and response shape. SQL tests should include cascade/empty-work cleanup behavior.
## Rollout Plan
Implement the schema and service behind additive APIs first. Then wire automatic matching after scan/enrichment. Finally add grouped catalog support. This keeps default behavior stable while giving clients and admin tooling a safe way to adopt works.
@@ -0,0 +1,96 @@
# Server-side Kindle→EPUB conversion (MOBI / AZW / AZW3) — design
**Goal:** Let the silo-server render Kindle-family ebooks (mobi, azw, azw3) in the Android in-app reader by converting them to EPUB **server-side, in-process, on demand**, served transparently through the existing read endpoint. No external converter binary, no cgo.
**Status:** Approach approved by the user: convert via **libmobi**, run **built-in** to the server as **WASM executed by `wazero`** (a pure-Go runtime). On-demand + cached, admin-gated, DRM-free only. This is the MOBI half of the "all 9 server formats in-app" goal; the CBR (client-side) half is a separate Android spec.
> **Linchpin PROVEN (build spike, 2026-06-17, linux/amd64).** libmobi + `mobitool` cross-compile to `wasm32-wasi` (wasi-sdk 25, libmobi `9062742`, zlib 1.3.1 built to wasm); `-e` (create EPUB) is present; the module converts real MOBI6 / KF8-hybrid / HUFF-CDIC / unicode samples to **well-formed EPUB** (`mimetype`=`application/epub+zip` first, `META-INF/container.xml`, `OEBPS/*`). Verified end-to-end under **wazero** (the prod runtime) with WASI preopen + argv + `_start` + exit handling. Reproducible build stage committed at `tools/mobitool-wasm/Dockerfile` (with a smoke-conversion gate). Build-config gotcha resolved: build libmobi against a **wasm-compiled zlib** — `--with-zlib=no` makes libmobi vendor miniz that collides with mobitool's own miniz at `wasm-ld`. **DRM finding:** `mobitool` prints `Document is encrypted` to **stdout** but still **exits 0** and writes a (garbage) EPUB — so detect failure by capturing stdout + validating output, never by exit code alone.
## Why this shape
- Pure-Go MOBI/KF8 *parsers* don't exist at usable quality (the Go MOBI packages only *write*). Calibre is the gold standard but ~300MB+ of Python/Qt — rejected as too heavy. **libmobi** (mature C library behind the `mobitool` CLI) handles MOBI6 + KF8/AZW3 + AZW, decompression, and EPUB generation.
- Rather than shell out to an external `mobitool` binary (a deploy moving part) or bind via cgo (forces `CGO_ENABLED=1`, a C toolchain, painful cross-compile, and an LGPLv3 static-link obligation), we compile **`mobitool` to a WASI `.wasm`** once and **run it in-process with `wazero`**.
- This keeps the main Go build **`CGO_ENABLED=0`** and pure; one **architecture-independent** `.wasm` runs on amd64/arm64 servers alike; the `.wasm` is `go:embed`-ed into the binary (self-contained); libmobi's LGPL is cleanly isolated as a separate embedded artifact; and — a real bonus — **untrusted ebook bytes are parsed inside the WASM sandbox**, so a memory-safety bug in libmobi on a malicious file cannot reach the host (only the mapped temp dir).
## Components
### 1. The WASM module (build-time artifact)
- Compile libmobi + its `mobitool` tool to `wasm32-wasi` using the **wasi-sdk** (clang). Output: `mobitool.wasm`.
- Build libmobi **without libxml2** (`--with-libxml2=no` / `--disable-xmlwriter` as available) so the module has no external native deps; mobitool's EPUB writer uses a bundled miniz, which is portable C and compiles to WASI cleanly. (Feasibility of the no-libxml2 build for the `-e` path is the #1 thing to verify in the build spike — see Risks.)
- Build it in a **dedicated Docker build stage** (or a committed `Makefile`/script under `tools/mobitool-wasm/`). **Commit the resulting `mobitool.wasm`** into the repo so the normal Go build does not need the wasi-sdk — it only `go:embed`s the prebuilt artifact. Rebuild the `.wasm` only when bumping libmobi; record the libmobi version + build flags next to it.
### 2. `internal/ebookconvert` (new package) — the in-process converter
```go
// Converter converts a Kindle-family ebook file to EPUB bytes, in-process via wazero.
type Converter interface {
// Convert reads srcPath (mobi/azw/azw3) and returns the path to a converted .epub.
// Returns ErrDRMProtected / ErrUnsupported / ErrConversionFailed as appropriate.
Convert(ctx context.Context, srcPath string) (epubPath string, err error)
}
```
- `//go:embed mobitool.wasm` → compile the module **once** at startup with `wazero.NewRuntime` + `CompileModule` (the compiled module is cached and reused; only cheap instantiation happens per conversion).
- **wazero command-module specifics** (from Codex review): `wasi_snapshot_preview1.MustInstantiate` the host module once; `Runtime.CompileModule` once at startup; per conversion instantiate with `ModuleConfig.WithArgs(...)` (argv ≈ `mobitool -e -o <outdir> <input>`), `WithFSConfig`, `WithStdout`+`WithStderr` (capture **both** — `mobitool.c` prints many failures with `printf` to stdout, not stderr), and the default `_start`. Use `WithName("")` (or a unique name) for repeated instantiation. Non-zero exits surface as `*sys.ExitError`. Honor ctx timeout via `RuntimeConfig.WithCloseOnContextDone(true)`.
- **Filesystem sandboxing** (from Codex review): wazero's `WithDirMount`/`WithReadOnlyDirMount` still permit relative `../../` traversal *within host permissions*, so **never preopen the library directory or the cache root**. Per conversion: copy the source into a private scratch tree, mount input **read-only via an `fs.FS`** (`WithFSMount(os.DirFS(in), …)` — confined, no `..` escape) and a dedicated empty **writable** output dir (`WithDirMount`). **The WASM sandbox is a memory-safety boundary, not a hard filesystem jail** — a writable dir mount can still be traversed by a compromised guest within the server user's reach. Filesystem isolation therefore depends on a **deployment requirement: run silo-server as a constrained, non-root service user (the container already does), so the converter's blast radius is minimal.** This is documented here and in `internal/ebookconvert/converter.go`.
- Enforce a wall-clock **timeout** (ctx), a **max source size**, and a **concurrency semaphore** (N simultaneous conversions). WASM memory is bounded by wazero config. **Resource note:** the defaults are `DefaultMaxMemoryPages` = 1 GiB linear memory per conversion × `DefaultConcurrency` = 2, i.e. up to ~2 GiB of wasm memory under saturation (plus the 256 MiB source / 512 MiB output caps). Generous for a small server; tune `ebookconvert.Options` for tight deployments.
- Map mobitool's exit code / captured stdout+stderr to typed errors. **Deterministic** verdicts (cacheable): DRM-protected input → `ErrDRMProtected`; corrupt / unconvertible layout / oversize-output → `ErrConversionFailed`. **Transient** verdicts (never negatively cached, retried next read): the per-call timeout firing → `ErrConversionTimedOut`; source over the size cap → `ErrSourceTooLarge`; caller cancellation/deadline → the context error propagated verbatim.
### 3. Conversion cache (on-demand, keyed)
- Cache converted EPUBs on disk under a configured cache root, keyed by **strong source identity + converter identity** — `file_id + size + mtime_ns + content hash` (use the scanner checksum if one exists) **plus** the converter **module fingerprint** (`moduleVersion`: a sha256 of the embedded `mobitool.wasm`, which already pins the libmobi commit, build flags, and wasi-sdk used to produce it). (Plain `path|size|mtime` can false-hit when a file is replaced with preserved mtime/size — Codex.) A stale key misses and reconverts.
- **Singleflight** per key so concurrent reads of the same just-opened book convert once. The shared conversion runs on a **detached context** (decoupled from any one caller), so a single caller cancelling its request never aborts the work for the others.
- **Negative caching**: remember only **deterministic-bad** sources — DRM (`ErrDRMProtected`) and conversion failures (`ErrConversionFailed`) — so every reader open does not reconvert a known-bad file. Transient outcomes (timeout, cancellation, oversize source) are **not** cached so they retry.
- A cache **hit refreshes the entry's mtime**, so the mtime-ordered budget eviction acts as a real LRU; eviction never deletes the entry it is handing back or another conversion's in-flight temp.
- Bounded by an LRU / total-size budget with opportunistic eviction (mirrors the existing reader-file/image cache conventions in the codebase).
### 4. Wiring into the read path
- `serveEbookInline` (`internal/api/handlers/ebook_reader.go:602`) currently `os.Open`s `file.FilePath` and `http.ServeContent`s it with `ebookMimeType(...)`.
- Change: when the source format is **kindle-family AND conversion is enabled**, resolve the converted EPUB via the cache (convert-on-miss), then serve **that** file with `Content-Type: application/epub+zip`. All other formats are served exactly as today (raw, byte-range, unchanged).
- Byte-range/`http.ServeContent` semantics are preserved — we serve the cached EPUB `*os.File` the same way, so resume/range still works.
- **Response headers** (from Codex review): `Content-Type: application/epub+zip`, `Content-Disposition` with an `.epub` filename, a strong `ETag` derived from the conversion cache key, and — because the *same URL* can return raw or converted bytes depending on the setting/failure — `Cache-Control: private, max-age=0, must-revalidate` (or `no-store`) to prevent stale client/proxy caching of the wrong representation.
- **HEAD is cache-only.** A `HEAD /read` must never trigger a (possibly minute-long, ~1 GiB) conversion. It answers from the cache: a hit serves the real converted headers + `Content-Length`; a negatively-cached source serves the `failed` contract; a miss advertises the converted headers (`Content-Type`, the cache-key `ETag`, `X-Silo-Ebook-Conversion: converted`) **optimistically with no body and no conversion** — the subsequent `GET` produces the body and the authoritative DRM/failure verdict, which then populates the cache that later HEADs read. *(Client note: a HEAD miss intentionally omits `Content-Length`; coordinate with the Android/Apple clients if any relies on it.)*
### 5. Admin gate + client capability
- An **admin-toggleable setting** (`ebook.kindle_conversion_enabled`, default off) gates the whole feature, via the existing server settings mechanism (`internal/api/handlers/settings.go` / `admin.go` `HandleUpdateSetting`). The flag is read with a short TTL cache so the hot read path (and the capability endpoint clients poll) does not hit the DB per request, while toggles still apply within seconds without a restart. When off: kindle-family files serve raw as today and the capability is not advertised.
- **Two gates to advertise the capability** (from Codex review): advertise conversion availability to clients **only when** (a) the admin flag is on **and** (b) the embedded WASM module compiled + instantiated successfully at startup. The Android client flips mobi/azw/azw3 from `ExternalOnly`→`InApp` **only when the flag is advertised**; otherwise it keeps today's external-open fallback. (Android-side change is small and tracked separately; this spec is server-side.)
- **Capability endpoint (implemented):** `GET /api/v1/ebooks/capability` → `{enabled, source_formats:["mobi","azw","azw3"], served_format:"epub", header:"X-Silo-Ebook-Conversion", header_failed_value:"failed"}`. `enabled` is true only when both gates hold (`EbookConversion.active`). The read response carries `X-Silo-Ebook-Conversion: converted|failed` so the client knows whether the body is EPUB or the raw original (→ external-open). ETag on the converted response is derived from the **exact conversion cache key** (`SourceKey.CacheKey()`) so a validator can never disagree with the cache; the failed/raw fallback is `Cache-Control: no-store` since the same URL flips representations with the flag.
## Data flow (read of a `.azw3`, conversion enabled)
1. `GET /api/v1/ebooks/{cid}/files/{fid}/read` → authorize → `file` is `azw3`, conversion enabled.
2. Cache lookup by source identity+version. Hit → serve cached `.epub`. Miss → singleflight: `Converter.Convert` (wazero runs `mobitool.wasm` on a temp copy) → store under key.
3. `http.ServeContent` the `.epub` with `application/epub+zip`, byte-range supported.
4. Client sees EPUB → existing reflow `EpubReader` renders it. Reader progress stays keyed to the **source** `fileId` (the EPUB is a derived view of the same file), so progress/annotations are unaffected.
## Error handling — explicit client failure contract
Transparent raw fallback is **not** safe on its own: once the server advertises conversion and the Android in-app EPUB path opens `/read`, returning raw MOBI/AZW bytes hands the EPUB reader a non-EPUB body (Codex). So failures need a contract the client can detect:
- On conversion failure (DRM, corrupt, timeout, oversize) the read endpoint returns a **structured signal** the Android client maps to its external-open fallback — preferred: a distinct status (e.g. `422 Unprocessable` / `415`) with an error code, **or** serve raw bytes **plus** an explicit `X-Silo-Ebook-Conversion: failed` (with the original MIME) header that the client checks. Pick one and make both sides honor it. Never silently hand raw bytes to the EPUB path.
- `ErrDRMProtected` vs `ErrConversionFailed` are distinguished in logs + (optionally) the error code, so DRM books get the "open externally" message and transient failures can be retried.
- Module compile/instantiate failure at startup (corrupt embed) → capability **not advertised** (gate b), kindle files serve raw as today, logged loudly.
## Testing
- `internal/ebookconvert` unit tests with **small DRM-free MOBI6, AZW, and KF8/AZW3 fixtures**: `Convert` produces a valid EPUB (zip with `mimetype` first + `META-INF/container.xml` + OPF + at least one content doc), deterministic across runs; corrupt input → `ErrConversionFailed`; oversize → rejected before invocation; timeout honored (ctx cancel).
- Cache tests: miss→convert→hit; key changes on source mtime/size and on module-version bump; singleflight collapses concurrent conversions.
- Handler tests: enabled+kindle → `application/epub+zip` body is a valid EPUB; disabled → raw bytes + original MIME; non-kindle → unchanged; DRM/failed → graceful fallback path.
- A build-stage check (CI) that the committed `mobitool.wasm` matches a rebuild from the pinned libmobi (or at least that it instantiates + converts a fixture), so the artifact can't silently rot.
## Sequencing — spike first (Codex-recommended)
**The first PR is only the build spike + a tiny Go harness that converts fixtures** — no handler/cache/wiring until the linchpin is proven. Codex confirmed libmobi documents both deps as optional (`--with-zlib=no` → bundled `miniz.c`; `--with-libxml2=no` → internal xmlwriter) and that `mobitool.c` gates `-e` behind `USE_XMLWRITER`, not `HAVE_LIBXML2` — so the no-libxml2 wasm build is *plausible*, but the spike must **assert** `mobitool -h` exposes `-e` and a fixture actually converts. Only then design the handler/cache/wiring PR.
## Risks / spike-first items
1. **libmobi+mobitool compiles to `wasm32-wasi` with the `-e` (EPUB) path working without libxml2.** The linchpin. If libxml2 turns out mandatory for `-e`, fall back to compiling libxml2 to wasm too (heavier) or assembling the EPUB in Go from libmobi's `MOBIRawml` parts.
2. **wazero WASI preopen file IO** for the file sizes involved (tens of MB) — confirm mapping + memory/time limits.
3. mobitool CLI surface as a wasm **command module** (argv + preopens) — confirm wazero runs it via `_start` and we capture exit status + stdout + stderr.
## Committed `mobitool.wasm` — guardrails (Codex)
Commit the artifact (so normal Go builds need no wasi-sdk) **with**: the pinned libmobi source commit, the Dockerfile/build script that produced it, its SHA256, the build flags, and the LGPL license notice. Add a **CI smoke test** that instantiates the embedded module and converts a fixture. Do not gate CI on byte-for-byte rebuild reproducibility — `mobitool -v` embeds `__DATE__`/`__TIME__`, so a rebuild won't match byte-for-byte unless normalized; rely on the fixture-conversion smoke + metadata hash instead.
## Process gate (Codex)
`docs/architecture/v1-scope.md` indicates v1 scope is not locked and new user-facing capabilities may need a proposal / project-tracking entry **before** a feature PR. Confirm this process step with the maintainer before opening the conversion PR. *(Decision deferred to the user.)*
## Out of scope
- CBR (separate Android client-side spec).
- DRM removal (never).
- Converting at scan/ingest time (on-demand only).
- Surfacing a separate "EPUB version" in the catalog (we serve transparently through `/read`).
- Reusing the WASM converter for other formats (later, if useful).
## Commit / deploy rules (project)
- silo-server changes go on a **feature branch, committed locally, PR-ready — NOT pushed** (per standing rule; server changes land via PR).
@@ -0,0 +1,246 @@
# Personal Collection Catalog Filter Standardization - Design
- **Date:** 2026-06-23
- **Status:** Draft (rev. 3 after adversarial review). Incorporates three findings: `match: any` merge safety, display-query fragment normalization, and ebook misclassification under unscoped watched filters.
- **Scope:** `silo-server` backend and web collection UI. Client follow-ups in `silo-android` and `silo-apple` are optional unless they adopt the same collection editing surface.
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
## Current Branch State
This design lives on the in-flight `profile-scoped-collection-filters` branch, which has **already implemented** the first cut of the feature but has **not merged to `main` and has not shipped to any client**. Concretely, on this branch today:
- A migration adds `watch_filter` and `media_filter` as `text` columns on `user_personal_collections`, each with a CHECK constraint (`migrations/sql/20260623184858_user_collection_display_filters.sql`).
- `internal/catalog/user_collection_display_filters.go` implements a custom Go filter pass (`FilterUserCollectionDisplayItems`) called from `internal/sections/fetcher.go` and `internal/catalog/catalog_resolver.go`.
- Web controls in `web/src/components/collections/CollectionBuilder.tsx`, `web/src/pages/ImportedCollectionEditor.tsx`, and helpers in `web/src/lib/collectionDisplayFilters.ts` already emit the legacy enums.
Because nothing is merged or released, this is a **clean-replacement** opportunity, not a backward-compatibility problem. The migration can be reshaped before merge; no client depends on the legacy enums. This resolves Open Question #1 below.
## Problem
Personal collection display filtering is growing its own filter model alongside two pre-existing completion implementations. There are **three** places that answer "is this item played/read for this profile?":
- the custom watched/media pass in `internal/catalog/user_collection_display_filters.go` (exact user-collection reads),
- the personalized `watched` query rule in `internal/catalog/query_builder.go` (`userStateCompletionClause`),
- the item user-data path in `internal/api/handlers/user_state.go` (`allEpisodesCompleted`), which is the canonical played/read state surfaced to clients.
That creates more than one way to answer the same product question: "should this item be visible for this profile based on watched state?" If multiple paths persist, they will drift in semantics, performance, and bug fixes. The drift is already visible *and already duplicated in code*:
- The custom personal collection helper treats series/seasons as watched only when all child episodes are complete; `userCollectionAllEpisodesCompleted` is a near-verbatim copy of `allEpisodesCompleted` in `user_state.go`.
- The catalog `watched` rule, by contrast, checks completion for the row's own `content_id` only (`uwp.media_item_id = mi.content_id`), so a series row is effectively never "watched" because progress/history is keyed to episode IDs, not the series ID.
Consolidating onto the catalog predicate therefore removes one Go copy outright and requires teaching the catalog predicate the rollup the other two already perform.
## Goals
- Use the catalog query system as the canonical representation and executor for watched filters.
- Make personal collection display filtering, catalog filtering, and collection-builder UI speak the same `QueryDefinition` / `QueryRule` language.
- Preserve exact collection source order when a collection is rendered as a shelf or source-ordered list.
- Preserve existing access controls and profile scoping.
- Avoid persisting a second watched-filter vocabulary unless needed as a compatibility shim.
- Keep the implementation narrow enough to land as a refactor/standardization of an already intended collection-filter feature.
## Non-Goals
- No new broad smart-collection DSL.
- No change to external sync membership. Imported lists still sync exact item membership before display filtering is applied.
- No change to collection ownership, sharing, or profile visibility rules.
- No client lockstep requirement. Existing clients can ignore new display-filter capabilities until they adopt the collection editor.
- No attempt to optimize very large exact collections beyond the current exact-source behavior in this pass.
## Scope Gate
`docs/architecture/v1-scope.md` says v1 is not locked and the proposal window is open. This design should be treated as a standardization of the profile-scoped collection filter work already underway, not as a separate feature expansion. If the final implementation exposes a new public capability beyond personal collection display filtering, file a v1 capability proposal before opening a feature PR.
## Design Principle
One feature gets one semantic implementation:
```json
{ "field": "watched", "op": "is", "value": true }
```
is the only canonical expression for "show watched items"; `value: false` is the canonical expression for "show unwatched items".
UI controls may present simpler labels such as "All", "Watched", and "Unwatched", but those controls must translate into catalog query rules before execution. Backend code should not add another watched evaluator for personal collections.
## Canonical Semantics
The canonical `watched` filter should match the user-facing played/read state shown by item user data:
| Item kind | `watched is true` should mean |
|---|---|
| Movie / episode / video leaf | Completed progress or visible completed history exists for the active profile. |
| Series | At least one child episode exists, and every child episode is completed for the active profile. |
| Season | At least one child episode exists, and every child episode in the season is completed for the active profile. |
| Ebook | Reader progress is at or above `models.EbookFinishedProgressThreshold`. |
| Manga | Use the same read-state source the catalog uses for manga/chapters once manga catalog filtering is finalized; do not add a collection-only interpretation. |
This intentionally aligns with `internal/api/handlers/user_state.go`, where series and season user data are derived by `allEpisodesCompleted`. If this definition is too expensive for catalog queries, we should optimize the catalog predicate rather than keep a separate personal collection definition.
For this table to hold when a query is *unscoped* (mixed-media exact collections), the predicate must pick the completion source from each row's own type, not from a single query-wide `media_scope` — see Catalog Watched Predicate Work. Picking the source by `media_scope` alone misclassifies any row whose type differs from the scope (notably ebooks under an unscoped query).
## Storage Model
Recommended canonical storage for personal collection display filters:
- Add a dedicated JSON column named for display filtering, `display_query_definition`. Do **not** overload the existing `query_definition` field (see Risks: it already carries `library_ids` for live/smart collections and is fragile enough to have needed a recent sanitization fix).
- Store the standard catalog `QueryDefinition` *shape* in that field, normalized to a filter-only fragment (see normalization rules below).
- For the current "media filter" control, store a **`type` rule**, not `media_scope`:
- `all` -> no `type` rule
- `movie` -> `{ "field": "type", "op": "is", "value": "movie" }`
- `series` -> `{ "field": "type", "op": "is", "value": "series" }`
- For the current "watch filter" control, store a `watched` rule:
- `all` -> no watched rule
- `watched` -> `{ "field": "watched", "op": "is", "value": true }`
- `unwatched` -> `{ "field": "watched", "op": "is", "value": false }`
**Why `type` rule, not `media_scope`:** `media_scope` is a single string on `QueryDefinition`, not a rule, and there is no logic to intersect two scopes (only `intersectCatalogDefinitionLibraries` exists, for library IDs). A `type` rule composes as an ordinary AND group and avoids any collision with a smart collection's own `media_scope`. `type` is already a supported, executable query field, so this needs no new merge machinery. Reserve `media_scope` for the source query's own scoping.
**`display_query_definition` is a filter-only fragment, not a full query.** A full `QueryDefinition` carries fields that are meaningless — and actively dangerous — for a display overlay. In particular the executor honors `def.Limit` internally, so a persisted display query with `limit` set would cap the match set *before* the exact-collection source-order reorder, silently and permanently hiding matching items and producing wrong totals/pages. `sort` and `library_ids` are likewise the source/collection's concern, not the overlay's. Normalize on the API boundary before persisting and before executing:
- Persist only `match` and `groups` (with rules drawn from the allowed display vocabulary — initially `type` and `watched`).
- Reject or strip `limit`, `sort`, `library_ids`, and `media_scope` from the fragment. Prefer reject-with-error on write so clients learn the contract; strip defensively on read for forward-compat.
- Storing the `QueryDefinition` *shape* keeps the door open to a richer rule set later; it does not mean accepting the whole `QueryDefinition` surface.
Compatibility: none required. The legacy `watch_filter` / `media_filter` enums exist only on this unmerged branch (see Current Branch State), so they are dropped rather than aliased. If — and only if — a future build ships the enums to clients before this lands, revisit the compatibility shim; until then there is no legacy surface to preserve.
## Execution Model
### Smart/live collections
Smart and live collections already resolve through `CatalogResolver.resolveQuerySource`. Their stored query is already a catalog `QueryDefinition`. Display filters should be merged into that query before execution.
**Merge must guarantee `(source) AND (display)` — appending a group does not.** `QueryBuilder.Build` joins *all* top-level groups with a single joiner derived from the definition's top-level `match` (`query_builder.go`: `topJoiner` is ` OR ` when `match == "any"`). So a smart/live source with `match: any` and groups `G1, G2` becomes `G1 OR G2`; appending a display group `D` yields `G1 OR G2 OR D` — which *widens* the result past the collection source and lets contradictory watched rules return rows instead of none. The QueryDefinition model has only one top-level `match` and only two levels (groups of rules), so there is no in-model way to express `(G1 OR G2) AND D`.
Merge rule:
- Keep library scope, `media_scope`, limit, and sort from the collection's source query.
- Combine the source definition and the display fragment with a true conjunction that is independent of either side's `match`. The robust implementation is an **executor-level conjunction**: build the source predicate and the display predicate as two separate SQL fragments and join them with ` AND ` (each side already parenthesized), rather than merging group lists. This is net-new merge logic, not an existing capability.
- The append-as-group shortcut is correct **only** when the source's top-level `match == "all"`. When `match == "any"` it is incorrect. Do not special-case "all" and forget "any"; implement the general conjunction and test both.
- Because the media control is stored as a `type` rule (not `media_scope`), it ANDs in through the same conjunction. If the source query's `media_scope` already excludes that type (e.g. a movies-only smart collection plus a `type is series` display filter), the result is empty — no scope-intersection code is needed. (There is no existing media-scope intersection logic to lean on, which is the main reason the media control is modeled as a `type` rule rather than a second `media_scope`.)
- If both the source and display layers contain `watched` rules, the conjunction preserves both; contradictory rules naturally return no rows.
### Exact/manual/imported collections
Exact collections have a source item set from `user_personal_collection_items`. Display filtering is applied after membership is known but before pagination.
**This path replaces two existing filters, not one.** Today the source-ordered exact path runs the in-Go `filterCatalogItems` (a metadata-only matcher that cannot evaluate personalized fields — it returns `false` for `watched`) *and* the separate `FilterUserCollectionDisplayItems` Go helper for the watched dimension. Neither is the SQL executor. Routing through the executor folds both metadata and personalized filtering into one SQL path; `FilterUserCollectionDisplayItems` is deleted and `filterCatalogItems` is no longer used for these collections.
Recommended execution:
1. Load collection item IDs in source order (from `user_personal_collection_items`, `ORDER BY position`).
2. Build a catalog `QueryDefinition` from the normalized display fragment plus any request overlay. Force `Limit = nil` and drop any fragment-level `sort`/`library_ids` so the executor cannot truncate or reorder the match set out from under step 4. (Defense in depth: the fragment is already normalized at persistence per the Storage Model, but the executor call must not reintroduce a limit.)
3. Run the normal catalog executor with `AccessFilter.AllowedContentIDs = itemIDs` so all filtering, including `watched`, uses the catalog query path. (`resolveCandidateItemsWithQuery` already establishes this pattern.)
4. For source-ordered requests, retrieve the *full* matching set (no limit), collect the matching IDs into a set, reapply the original source order, and only then apply request offset/limit. Request pagination is applied after reordering, never inside the executor.
5. For requests that sort via the query, allow the catalog executor to sort normally. **Note:** exact user collections are hardcoded to source order today (`UseSourceOrder: true`); this branch exists for forward-compatibility and is not exercised by current callers. Mark it clearly so it is not mistaken for present behavior.
This preserves exact collection order without duplicating watched logic. Step 4 is a new shape (run the executor for the match-set, then reorder), distinct from the sorted-pagination shape `resolveCandidateItemsWithQuery` uses today — call out the differing limit handling in the implementation.
## Catalog Watched Predicate Work
The existing catalog `watched` rule is already implemented but needs one semantic hardening before it becomes the shared path:
- Extract a reusable played/read predicate for `QueryBuilder`.
- Keep the existing leaf-item checks for progress, completed history, hidden history, and ebook reader progress.
- Add series and season rollup branches so `watched` matches item user-data semantics.
- Add SQL tests that assert series/season predicates are present only when relevant.
- Reuse the same predicate for `last_watched` only if the semantics truly match; otherwise leave `last_watched` as a leaf/history timestamp filter and document the difference. (`last_watched` is a timestamp aggregate built from the `user_last_watched` CTE — different machinery from the completion predicate — so it most likely stays separate.)
**Episode-set parity is the load-bearing requirement, not the rollup branch itself.** The Go rollups (`allEpisodesCompleted` in `user_state.go` and its copy in the collection helper) define "all episodes complete" over the episode set returned by the episode repository (`ListBySeriesIDs` / `ListBySeasonIDs`). If the SQL rollup's notion of "child episodes" differs — specials, unaired, or unavailable episodes included or excluded differently — the catalog `watched` rule will *disagree* with item user-data, recreating the exact drift this design removes, just relocated into SQL. The hardening must therefore reproduce the repository's child-episode set exactly (same joins, same filters), and a test must pin that equivalence (see Testing Strategy). Treat Open Question #4 (all-available vs all-aired) as a question about *what that repository set already is*, and match it.
**Row-type branching is mandatory for unscoped queries, including ebooks.** The current predicate selects the ebook source (`ebook_reader_progress`) *only* when `mediaScope == "ebook"`; otherwise it uses video watch progress/history (`userStateCompletionClause` branches on `qb.mediaScope == "ebook"`). Exact collection membership is not type-restricted, so an exact collection can contain an ebook while the query runs unscoped (the `all` media control emits no `type` rule). Under today's code that ebook would be evaluated against video progress/history — i.e. classified as never-watched regardless of read state — reintroducing exactly the kind of drift this design removes. The hardened predicate must therefore branch by the row's own type when the query is unscoped:
- movie / episode / video leaf -> watch progress + completed history,
- series / season -> episode rollup (with repository-parity episode sets, above),
- ebook -> `ebook_reader_progress >= EbookFinishedProgressThreshold`,
- manga -> deferred (see Non-Goals / Canonical Semantics).
If row-type branching cannot land in the first cut, v1 must instead *enforce* a video-only scope at execution for exact collections (so ebooks are never silently classified by the video source) and say so explicitly — but row-type branching is the correct end state because it is what makes the canonical semantics table hold under unscoped queries.
The first implementation may still gate series/season rollup behavior by `mediaScope == "series"` and exact known season rows, but it must not assume a single item kind for unscoped/video queries that can return movies, series, and ebooks together.
## API Shape
Preferred API model:
- Add `display_query_definition?: QueryDefinition` to personal collection create/update/response types.
- Replace the branch's `watch_filter` / `media_filter` request and response fields with `display_query_definition`. Since the enums are unreleased (see Current Branch State), no aliasing or deprecation header flow is required.
- Keep collection capabilities additive:
- expose supported display-query fields using the existing catalog field vocabulary,
- optionally expose UI presets for "all/watched/unwatched" and "all/movies/series" as client convenience.
Do not add a separate `filter_watched` query parameter to catalog endpoints. Catalog already has the watched rule.
## UI Model
The web UI can keep simple controls:
- media segmented control: All / Movies / Series,
- watched segmented control: All / Unwatched / Watched.
Those controls should edit a `QueryDefinition` fragment using shared helpers, not a separate collection-only state model.
Recommended helper location:
- `web/src/lib/collectionDisplayFilters.ts` should convert between UI presets and catalog `QueryDefinition`.
- `web/src/components/collections/CollectionGuidedRulesEditor.tsx` remains the broader catalog-query editor.
- `web/src/components/collections/CollectionBuilder.tsx` and `web/src/pages/ImportedCollectionEditor.tsx` use the shared helper.
## Migration Strategy
The legacy `watch_filter` / `media_filter` enums exist only on this unmerged branch and have shipped to no client (see Current Branch State), so the clean-replacement path applies. There is no backfill, dual-read, or deprecation window:
- Reshape the existing migration `20260623184858_user_collection_display_filters.sql` rather than layering a second migration on top: drop the `watch_filter` / `media_filter` columns and their CHECK constraints, and add the canonical `display_query_definition jsonb` column (nullable; `NULL` / absent means "no display filter"). Because the branch is unmerged, editing the not-yet-applied migration in place is cleaner than a forward-then-backfill sequence.
- Update API and web types to use display-query-backed helpers; delete the legacy enum request/response fields.
- No server code reads or writes the legacy columns after this change.
A backfill/dual-read migration would only be warranted if a build ships the legacy enums to clients before this lands. If that happens, fall back to: add `display_query_definition`, backfill (`watch_filter = 'watched'` -> watched rule true; `'unwatched'` -> false; `media_filter = 'movie'`/`'series'` -> `type` rule; `all` omits the rule), read legacy as fallback, stop writing legacy, and remove later under the API deprecation flow. Treat this as a contingency, not the plan of record.
## Risks
- **Merge OR-leak (smart/live):** appending a display group to a `match: any` source query widens results past the collection source instead of constraining them. Mitigate with the executor-level conjunction (Execution Model) and a test that a `match: any` source plus a display filter never returns rows the source alone would exclude.
- **Display fragment truncation:** a persisted `limit` (or stray `sort`) on `display_query_definition` would cap or reorder the exact-collection match set before the source-order reorder, hiding items and corrupting totals. Mitigate by normalizing the fragment to filter-only on write and forcing `Limit = nil` on the match-set execution.
- **Ebook misclassification under unscoped queries:** an ebook in a mixed-media exact collection evaluated by an unscoped `watched` rule is classified by the video source today (`media_scope`-gated ebook predicate). Mitigate with row-type branching in the predicate, or enforce a video-only execution scope for v1 (Catalog Watched Predicate Work).
- **Episode-set divergence (highest risk):** if the SQL rollup's child-episode set differs from the repository's `ListBySeriesIDs` / `ListBySeasonIDs` listing, the catalog `watched` rule will disagree with item user-data and reintroduce drift in a new place. Mitigate by reproducing the repository's joins/filters exactly and pinning the equivalence with a test. This, not raw SQL cost, is the thing most likely to ship subtly wrong.
- **Series/season SQL cost:** all-episodes-complete checks are more expensive than leaf checks. Mitigate by only emitting rollup branches when item types can include series/seasons and by relying on indexes on episode parent IDs and progress/history media IDs.
- **Semantics change for catalog series filters:** extending catalog `watched` to match displayed played state can change existing catalog results for series (today they are effectively never "watched"). This is likely correct, but it must be called out in release notes or PR text.
- **Exact collection performance:** source-order rendering requires evaluating the full exact collection before pagination (the executor must return the complete match-set to reorder). This matches current behavior but should not regress further; very large exact collections remain out of scope this pass (see Non-Goals).
- **Field overloading (resolved → dedicated column):** code inspection confirms `query_definition` already carries `library_ids` for live/smart collections and recently needed a `library_ids` sanitization fix. Reusing it for display filters would entangle two concerns; use a dedicated `display_query_definition` column.
## Testing Strategy
Backend tests are justified because this changes shared personalized filtering semantics:
- **Equivalence test (highest value):** for the same series/season/profile fixtures, assert the catalog `watched` rule and `user_state.go`'s `Played` flag agree. This is the "one semantic" goal made executable and is the test most likely to catch episode-set divergence; it should fail loudly if the two definitions drift.
- `internal/catalog/query_builder_test.go`: `watched` emits leaf completion checks and series/season rollup checks as appropriate.
- `internal/catalog/query_executor_test.go` or focused resolver tests: exact collection filtering routes through catalog `watched` and preserves source order; the executor returns the full match-set so reordering is stable across pages.
- `internal/catalog/catalog_resolver_test.go`: contradictory display/source filters return no rows; a `type is series` display filter on a movies-only smart collection yields no rows without any scope-intersection code.
- **Merge safety:** a smart/live source with top-level `match: any` plus a display filter must never return rows the source alone excludes (guards against the OR-leak); a contradictory `watched` overlay on a `match: any` source returns zero rows.
- **Fragment normalization:** a `display_query_definition` carrying `limit`/`sort`/`library_ids` is rejected on write (or stripped on read); an exact collection larger than any plausible page returns the full match set in source order regardless of fragment contents.
- **Ebook under unscoped watched:** a mixed-media exact collection (movie + ebook) with an `unwatched`/`watched` filter classifies the ebook via `ebook_reader_progress`, not video history (guards against the misclassification finding).
- `internal/api/handlers/collections_test.go`: create/update round-trips `display_query_definition`; the removed `watch_filter` / `media_filter` fields are gone from request and response.
- Migration test only if the contingency backfill path is used (it is not, on the plan of record).
Frontend tests should stay targeted:
- helper tests for converting simple UI controls to/from `QueryDefinition`,
- one editor test proving the submit payload carries the canonical display query.
## Implementation Outline
1. Harden catalog `watched` semantics (series/season rollup with repository-parity episode sets) and land the equivalence test. This is independently valuable and reviewable; consider shipping it as its own PR first.
2. Reshape migration `20260623184858` to drop `watch_filter` / `media_filter` and add `display_query_definition jsonb`.
3. Add display-query types and normalization helpers in backend collection handling.
4. Replace both the custom `FilterUserCollectionDisplayItems` pass and the in-Go `filterCatalogItems` source-order path with catalog-query execution constrained by exact item IDs (match-set, then reorder).
5. Update web controls to emit catalog query fragments through shared helpers; delete the legacy enum fields.
6. Run focused Go tests for catalog/query/user-collection paths and targeted frontend helper tests.
Per the v1 process, link the capability sub-issue (`Part of #NNN`) and keep PRs to one concern — predicate-hardening (step 1) is a natural standalone PR separate from the storage/UI reshape (steps 2–5).
## Open Questions
- **Resolved.** The legacy enums exist only on this unmerged, unreleased branch, so it replaces them cleanly with no compatibility shim (see Current Branch State and Migration Strategy).
- **Decided:** the standardized *controls* expose video item scopes (movie/series/episode) for v1; manga read-state filtering is deferred until catalog manga filtering is finalized, then adopts the same catalog read-state source rather than a collection-only interpretation. Note this constrains the *control vocabulary*, not collection *membership* — exact collections can still contain ebooks, so the watched predicate must branch by row type (see Catalog Watched Predicate Work) regardless of what the control offers.
- **Decided:** first version exposes only the `type` and `watched` controls, stored as a **filter-only `QueryDefinition` fragment** (`match` + `groups`; no `limit`/`sort`/`library_ids`/`media_scope`). Keeping the fragment shape lets a richer rule set turn on later without a storage change, while normalization prevents the fragment from carrying execution-controlling fields (see Storage Model).
- Should catalog `watched` rollup for series mean "all available episodes complete" or "all aired episodes complete"? This is a question about *what the episode repository already returns* — match that set exactly (see Catalog Watched Predicate Work / episode-set parity). Only change the definition by changing the repository path and item user-data together, never the catalog rule alone.
- **New:** confirm no `silo-android` / `silo-apple` build has already consumed the branch's `watch_filter` / `media_filter` fields. The design assumes not (the branch is unreleased); if a client has pinned them, the contingency backfill path applies instead.
+4 -1
View File
@@ -20,6 +20,7 @@ require (
)
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abadojack/whatlanggo v1.0.1
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
@@ -30,6 +31,8 @@ require (
github.com/oklog/ulid/v2 v2.1.0
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/tetratelabs/wazero v1.12.0
github.com/wneessen/go-mail v0.7.3
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.41.0
@@ -74,7 +77,7 @@ require (
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.6.0
github.com/Silo-Server/silo-plugin-sdk v0.8.1
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
+70 -2
View File
@@ -2,8 +2,10 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.8.1 h1:6DLm2s/O3N9KTx2d09Nq3bbV8z4u9HJwO2gueQDPyPw=
github.com/Silo-Server/silo-plugin-sdk v0.8.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
@@ -69,10 +71,12 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -193,6 +197,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
@@ -209,12 +215,15 @@ github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vb
github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
@@ -249,31 +258,90 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
+3
View File
@@ -99,6 +99,9 @@ func (s stubStore) ListHistory(context.Context, string, int, int) ([]userstore.W
func (s stubStore) ListCompletedHistory(context.Context, userstore.CompletedHistoryQuery) ([]userstore.WatchHistoryEntry, error) {
panic("unused")
}
func (s stubStore) ListCompletedHistoryItems(context.Context, userstore.CompletedHistoryItemQuery) ([]userstore.CompletedHistoryItem, error) {
panic("unused")
}
func (s stubStore) RemoveHistoryItems(context.Context, string, []string, time.Time) error {
panic("unused")
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/api/handlers"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/ebookconvert"
)
// ebookKindleConversionSettingKey is the admin flag that gates Kindle->EPUB
// conversion. Off by default; toggled via PUT /admin/settings/{key}.
const ebookKindleConversionSettingKey = "ebook.kindle_conversion_enabled"
// buildEbookConversion wires the in-process Kindle->EPUB converter for the read
// handler. It compiles the embedded WASM module once at startup; if that fails
// the feature stays off (returns nil) and the raw original is served as before.
// The admin flag is read with a short TTL (see ebookFlagPredicate), so it can be
// toggled without a restart yet does not hit the DB on every read.
//
// The Converter (a wazero runtime, ~no external resources — per-conversion
// scratch dirs are cleaned as they go) is intentionally owned for the process
// lifetime: it is not Close()d on shutdown because process exit reclaims it and
// there is nothing to flush. Memory is bounded per conversion by
// ebookconvert.DefaultMaxMemoryPages × DefaultConcurrency (see the design doc's
// resource notes); tune via ebookconvert.Options if the deployment is tight.
func buildEbookConversion(deps Dependencies, settings catalog.SettingsStore) *handlers.EbookConversion {
if settings == nil {
return nil
}
converter, err := ebookconvert.NewConverter(context.Background(), ebookconvert.Options{})
if err != nil {
slog.Warn("ebook Kindle->EPUB conversion unavailable: converter init failed", "error", err)
return nil
}
cache, err := ebookconvert.NewCache(converter, ebookconvert.CacheOptions{Dir: ebookConversionCacheDir(deps.CurrentConfig())})
if err != nil {
slog.Warn("ebook Kindle->EPUB conversion unavailable: cache init failed", "error", err)
_ = converter.Close(context.Background())
return nil
}
slog.Info("ebook Kindle->EPUB conversion ready (admin-flag gated)", "setting", ebookKindleConversionSettingKey)
return &handlers.EbookConversion{
Converter: cache,
Enabled: ebookFlagPredicate(settings, ebookKindleConversionSettingKey),
}
}
// ebookFlagPredicate returns a cached boolean reader for a server setting. The
// read path (every ebook read + the capability endpoint, which clients may
// poll) would otherwise issue a DB query per request; a short TTL collapses
// bursts to one query while keeping admin toggles effectively instant. On a read
// error it keeps the last known value rather than flapping the feature off.
func ebookFlagPredicate(settings catalog.SettingsStore, key string) func(context.Context) bool {
const ttl = 3 * time.Second
var (
mu sync.Mutex
value bool
fetched time.Time
hasVal bool
)
return func(ctx context.Context) bool {
mu.Lock()
defer mu.Unlock()
if hasVal && time.Since(fetched) < ttl {
return value
}
v, err := settings.Get(ctx, key)
if err != nil {
return value // keep last known (false until the first successful read)
}
value = isTruthySetting(v)
fetched = time.Now()
hasVal = true
return value
}
}
// ebookConversionCacheDir derives the converted-EPUB cache directory, a sibling
// of the transcode dir so it lives alongside other derived media.
func ebookConversionCacheDir(cfg *config.Config) string {
base := os.TempDir()
if cfg != nil && strings.TrimSpace(cfg.Playback.TranscodeDir) != "" {
base = filepath.Dir(cfg.Playback.TranscodeDir)
}
return filepath.Join(base, "silo-ebook-epub")
}
func isTruthySetting(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on", "enabled":
return true
default:
return false
}
}
+39
View File
@@ -0,0 +1,39 @@
package api
import (
"context"
"sync/atomic"
"testing"
)
// stubSettings counts Get calls so we can assert the flag predicate caches.
type stubSettings struct {
value string
gets int64
}
func (s *stubSettings) Get(_ context.Context, _ string) (string, error) {
atomic.AddInt64(&s.gets, 1)
return s.value, nil
}
func (s *stubSettings) Set(context.Context, string, string) error { return nil }
func (s *stubSettings) GetAll(context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
// The flag predicate must not hit the settings store on every call: the read
// path (every ebook read + capability poll) would otherwise issue a DB query
// per request.
func TestEbookFlagPredicate_CachesWithinTTL(t *testing.T) {
s := &stubSettings{value: "true"}
pred := ebookFlagPredicate(s, "ebook.kindle_conversion_enabled")
for i := 0; i < 50; i++ {
if !pred(context.Background()) {
t.Fatalf("call %d: predicate returned false for a truthy flag", i)
}
}
if got := atomic.LoadInt64(&s.gets); got > 2 {
t.Fatalf("settings.Get called %d times across 50 predicate calls; expected it to be cached", got)
}
}
+98 -1
View File
@@ -8,7 +8,9 @@ import (
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
@@ -28,6 +30,7 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
@@ -85,7 +88,9 @@ type AdminHandler struct {
accountProvisioner *auth.AccountProvisioner
DetailSvc *catalog.DetailService
StatsSource AdminStatsSource
Config *config.Config
EventBus cache.EventBus
EventsHub *evt.Hub
SettingsRepo ServerSettingsStore
JobRepo AdminJobCreator
ItemRefreshResolver ItemRefreshScopeResolver
@@ -95,6 +100,8 @@ type AdminHandler struct {
BootstrapSensitiveValues map[string]string
OnUserSessionsRevoked func(ctx context.Context, userID int)
OnServerSettingUpdated func(ctx context.Context, key, value string)
RestartStatus *ServerRestartStatusTracker
CatalogSearchStatus catalog.CatalogSearchStatusProvider
}
// NewAdminHandler creates a new AdminHandler backed by the given
@@ -2134,6 +2141,94 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
"subtitle_ai.transcribe_quota_period must be day, week, or month")
return
}
case catalog.SearchSettingProvider:
switch strings.TrimSpace(strings.ToLower(req.Value)) {
case catalog.SearchProviderPostgres, catalog.SearchProviderMeilisearch:
req.Value = strings.TrimSpace(strings.ToLower(req.Value))
default:
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.provider must be postgres or meilisearch")
return
}
case catalog.SearchSettingMeilisearchURL:
value := strings.TrimSpace(req.Value)
if value != "" {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.url must include scheme and host")
return
}
}
req.Value = value
case catalog.SearchSettingMeilisearchIndex:
req.Value = strings.TrimSpace(req.Value)
if req.Value == "" {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.index is required")
return
}
case catalog.SearchSettingMeilisearchTimeoutMS:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.timeout_ms must be an integer greater than 0")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchMatchingStrategy:
switch strings.TrimSpace(strings.ToLower(req.Value)) {
case "last", "all":
req.Value = strings.TrimSpace(strings.ToLower(req.Value))
default:
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.matching_strategy must be last or all")
return
}
case catalog.SearchSettingMeilisearchSyncBatchSize:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n < 1 || n > catalog.MaxMeilisearchSyncBatchSize {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.sync_batch_size must be an integer between 1 and 10000")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchRebuildBatchSize:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n < 1 || n > catalog.MaxMeilisearchRebuildBatchSize {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.rebuild_batch_size must be an integer between 1 and 25000")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchRebuildQueue:
n, err := strconv.Atoi(strings.TrimSpace(req.Value))
if err != nil || n < 1 || n > catalog.MaxMeilisearchRebuildQueueDepth {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.rebuild_task_queue_depth must be an integer between 1 and 16")
return
}
req.Value = strconv.Itoa(n)
case catalog.SearchSettingMeilisearchIndexTypes:
itemTypes, err := catalog.NormalizeCatalogSearchIndexTypesValue(req.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
req.Value = catalog.FormatCatalogSearchIndexTypesValue(itemTypes)
case catalog.SearchSettingMeilisearchSemanticEnabled:
enabled, err := strconv.ParseBool(strings.TrimSpace(req.Value))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.semantic_enabled must be true or false")
return
}
req.Value = strconv.FormatBool(enabled)
case catalog.SearchSettingMeilisearchSemanticRatio:
ratio, err := strconv.ParseFloat(strings.TrimSpace(req.Value), 64)
if err != nil || math.IsNaN(ratio) || ratio < 0 || ratio > 1 {
writeError(w, http.StatusBadRequest, "bad_request", "catalog.search.meilisearch.semantic_ratio must be a number between 0 and 1")
return
}
req.Value = strconv.FormatFloat(ratio, 'f', -1, 64)
case catalog.SearchSettingMeilisearchEmbedder:
embedder, err := catalog.NormalizeCatalogSearchEmbedderName(req.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
req.Value = embedder
}
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
@@ -2148,8 +2243,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
if h.OnServerSettingUpdated != nil {
h.OnServerSettingUpdated(r.Context(), key, req.Value)
}
restartRequired := config.RestartRequired(key)
if restartRequired {
h.markServerRestartRequired("server_settings")
}
if sensitiveSettingKeys[key] {
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
return
@@ -0,0 +1,30 @@
package handlers
import (
"net/http"
"github.com/Silo-Server/silo-server/internal/catalog"
)
func (h *AdminHandler) HandleGetCatalogSearchStatus(w http.ResponseWriter, r *http.Request) {
if h == nil || h.CatalogSearchStatus == nil {
writeJSON(w, http.StatusOK, catalog.CatalogSearchRuntimeStatus{
ConfiguredProvider: catalog.SearchProviderPostgres,
ActiveProvider: catalog.SearchProviderPostgres,
Meilisearch: catalog.CatalogSearchMeiliStatus{
Configured: false,
Healthy: false,
CircuitState: "not_configured",
},
Index: catalog.CatalogSearchIndexStateStatus{
ExpectedSchemaVersion: catalog.SearchMeilisearchSchemaVersion,
},
Tasks: []catalog.CatalogSearchTaskLink{
{Key: "sync_catalog_search_index", Name: "Sync Catalog Search Index", Href: "/admin/tasks/sync_catalog_search_index"},
{Key: "rebuild_catalog_search_index", Name: "Rebuild Catalog Search Index", Href: "/admin/tasks/rebuild_catalog_search_index"},
},
})
return
}
writeJSON(w, http.StatusOK, h.CatalogSearchStatus.Status(r.Context()))
}
+29 -16
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -56,12 +57,13 @@ func NewAdminMatchHandler(
// --- Request/Response types ---
type matchSearchRequest struct {
Title string `json:"title"`
Year int `json:"year"`
ImdbID string `json:"imdb_id"`
TmdbID string `json:"tmdb_id"`
TvdbID string `json:"tvdb_id"`
LibraryID *int `json:"library_id,omitempty"`
Title string `json:"title"`
Year int `json:"year"`
ImdbID string `json:"imdb_id"`
TmdbID string `json:"tmdb_id"`
TvdbID string `json:"tvdb_id"`
ProviderIDs map[string]string `json:"provider_ids,omitempty"`
LibraryID *int `json:"library_id,omitempty"`
}
type matchSearchResponse struct {
@@ -119,7 +121,7 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite
Title: req.Title,
Year: req.Year,
ContentType: item.Type,
ProviderIDs: make(map[string]string),
ProviderIDs: normalizeMatchProviderIDs(req.ProviderIDs),
}
if query.Title == "" {
query.Title = item.Title
@@ -129,15 +131,9 @@ func (h *AdminMatchHandler) HandleSearchItemMatchCandidates(w http.ResponseWrite
}
// Inject any provider IDs from the request.
if req.ImdbID != "" {
query.ProviderIDs["imdb"] = req.ImdbID
}
if req.TmdbID != "" {
query.ProviderIDs["tmdb"] = req.TmdbID
}
if req.TvdbID != "" {
query.ProviderIDs["tvdb"] = req.TvdbID
}
setMatchProviderID(query.ProviderIDs, "imdb", req.ImdbID)
setMatchProviderID(query.ProviderIDs, "tmdb", req.TmdbID)
setMatchProviderID(query.ProviderIDs, "tvdb", req.TvdbID)
candidates, err := h.metadata.SearchAndNormalize(r.Context(), query, folderID)
if err != nil {
@@ -211,6 +207,23 @@ func (h *AdminMatchHandler) HandleApplyItemMatch(w http.ResponseWriter, r *http.
})
}
func normalizeMatchProviderIDs(input map[string]string) map[string]string {
out := make(map[string]string, len(input))
for provider, providerID := range input {
setMatchProviderID(out, provider, providerID)
}
return out
}
func setMatchProviderID(providerIDs map[string]string, provider string, providerID string) {
provider = strings.ToLower(strings.TrimSpace(provider))
providerID = strings.TrimSpace(providerID)
if provider == "" || providerID == "" {
return
}
providerIDs[provider] = providerID
}
// PoolFolderLookup implements MatchFolderLookup using a direct pgx pool query
// against the media_item_libraries table.
type PoolFolderLookup struct {
+56 -2
View File
@@ -199,6 +199,58 @@ func TestAdminMatchSearch_FallsBackToItemMetadata(t *testing.T) {
}
}
func TestAdminMatchSearch_AcceptsGenericProviderIDsAndLibraryID(t *testing.T) {
items := &fakeMatchItemLookup{
items: map[string]*models.MediaItem{
"ebook-1": {ContentID: "ebook-1", Title: "Example Book", Year: 2024, Type: "ebook"},
},
}
folders := &fakeMatchFolderLookup{
folderIDs: map[string][]int{"ebook-1": {7, 8}},
}
captureSvc := &capturingMetadataService{
inner: &fakeMatchMetadataService{searchResults: []metadata.MatchCandidate{}},
}
h := NewAdminMatchHandler(items, folders, captureSvc)
router := buildMatchRouter(h)
libraryID := 8
body, _ := json.Marshal(matchSearchRequest{
ProviderIDs: map[string]string{
" ISBN ": " 9780000000001 ",
"goodreads": " ",
},
TmdbID: "12345",
LibraryID: &libraryID,
})
req := httptest.NewRequest(http.MethodPost, "/admin/items/ebook-1/match/search", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if captureSvc.lastFolderID != 8 {
t.Fatalf("folderID = %d, want 8", captureSvc.lastFolderID)
}
if captureSvc.lastQuery.ContentType != "ebook" {
t.Fatalf("ContentType = %q, want ebook", captureSvc.lastQuery.ContentType)
}
if got := captureSvc.lastQuery.ProviderIDs["isbn"]; got != "9780000000001" {
t.Fatalf("ProviderIDs[isbn] = %q, want 9780000000001", got)
}
if got := captureSvc.lastQuery.ProviderIDs["tmdb"]; got != "12345" {
t.Fatalf("ProviderIDs[tmdb] = %q, want 12345", got)
}
if _, ok := captureSvc.lastQuery.ProviderIDs["goodreads"]; ok {
t.Fatalf("blank provider IDs should be ignored, got %v", captureSvc.lastQuery.ProviderIDs)
}
}
func TestAdminMatchApply_PreservesContentID(t *testing.T) {
items := &fakeMatchItemLookup{
items: map[string]*models.MediaItem{
@@ -293,12 +345,14 @@ func TestAdminMatchApply_ItemNotFound(t *testing.T) {
// capturingMetadataService wraps a fake to capture the query passed to SearchAndNormalize.
type capturingMetadataService struct {
inner *fakeMatchMetadataService
lastQuery metadata.SearchQuery
inner *fakeMatchMetadataService
lastQuery metadata.SearchQuery
lastFolderID int
}
func (c *capturingMetadataService) SearchAndNormalize(ctx context.Context, query metadata.SearchQuery, folderID int) ([]metadata.MatchCandidate, error) {
c.lastQuery = query
c.lastFolderID = folderID
return c.inner.SearchAndNormalize(ctx, query, folderID)
}
@@ -0,0 +1,232 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
// AdminServerChannelsHandler exposes admin CRUD for server notification
// channels (community broadcast destinations). All routes are mounted inside
// the admin-only group.
type AdminServerChannelsHandler struct {
system *notifications.System
}
// NewAdminServerChannelsHandler creates the handler.
func NewAdminServerChannelsHandler(system *notifications.System) *AdminServerChannelsHandler {
return &AdminServerChannelsHandler{system: system}
}
func (h *AdminServerChannelsHandler) service() *notifications.ServerChannelService {
if h == nil || h.system == nil {
return nil
}
return h.system.ServerChannels
}
// serverChannelResponse is the API view of a server channel. Like webhooks it
// never includes the destination URL (Discord webhook tokens are bearer
// credentials in the URL path) or the stored signing secret — only url_host.
type serverChannelResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URLHost string `json:"url_host"`
Enabled bool `json:"enabled"`
NotifyNewMovies bool `json:"notify_new_movies"`
NotifyNewEpisodes bool `json:"notify_new_episodes"`
NotifyRequestSubmitted bool `json:"notify_request_submitted"`
NotifyRequestApproved bool `json:"notify_request_approved"`
NotifyRequestDeclined bool `json:"notify_request_declined"`
NotifyRequestFulfilled bool `json:"notify_request_fulfilled"`
ConsecutiveFailures int `json:"consecutive_failures"`
DisabledReason *string `json:"disabled_reason"`
LastSuccessAt *time.Time `json:"last_success_at"`
LastFailureAt *time.Time `json:"last_failure_at"`
LastFailureStatus *int `json:"last_failure_status"`
LastFailureMessage *string `json:"last_failure_message"`
CreatedAt time.Time `json:"created_at"`
// SigningSecret is present only in create / rotate-secret responses.
SigningSecret string `json:"signing_secret,omitempty"`
}
func serverChannelToResponse(ch notifications.ServerChannel) serverChannelResponse {
return serverChannelResponse{
ID: ch.ID,
Name: ch.Name,
Type: ch.Type,
URLHost: ch.URLHost,
Enabled: ch.Enabled,
NotifyNewMovies: ch.NotifyNewMovies,
NotifyNewEpisodes: ch.NotifyNewEpisodes,
NotifyRequestSubmitted: ch.NotifyRequestSubmitted,
NotifyRequestApproved: ch.NotifyRequestApproved,
NotifyRequestDeclined: ch.NotifyRequestDeclined,
NotifyRequestFulfilled: ch.NotifyRequestFulfilled,
ConsecutiveFailures: ch.ConsecutiveFailures,
DisabledReason: ch.DisabledReason,
LastSuccessAt: ch.LastSuccessAt,
LastFailureAt: ch.LastFailureAt,
LastFailureStatus: ch.LastFailureStatus,
LastFailureMessage: ch.LastFailureMessage,
CreatedAt: ch.CreatedAt,
}
}
type serverChannelRequest struct {
Name *string `json:"name"`
URL *string `json:"url"`
Type *string `json:"type"`
Enabled *bool `json:"enabled"`
NotifyNewMovies *bool `json:"notify_new_movies"`
NotifyNewEpisodes *bool `json:"notify_new_episodes"`
NotifyRequestSubmitted *bool `json:"notify_request_submitted"`
NotifyRequestApproved *bool `json:"notify_request_approved"`
NotifyRequestDeclined *bool `json:"notify_request_declined"`
NotifyRequestFulfilled *bool `json:"notify_request_fulfilled"`
}
func (r serverChannelRequest) toInput() notifications.ServerChannelInput {
return notifications.ServerChannelInput{
Name: r.Name,
URL: r.URL,
Type: r.Type,
Enabled: r.Enabled,
NotifyNewMovies: r.NotifyNewMovies,
NotifyNewEpisodes: r.NotifyNewEpisodes,
NotifyRequestSubmitted: r.NotifyRequestSubmitted,
NotifyRequestApproved: r.NotifyRequestApproved,
NotifyRequestDeclined: r.NotifyRequestDeclined,
NotifyRequestFulfilled: r.NotifyRequestFulfilled,
}
}
func writeServerChannelError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, notifications.ErrServerChannelsDisabled):
writeError(w, http.StatusForbidden, "server_channels_disabled", "Server channels are disabled")
case errors.Is(err, notifications.ErrServerChannelNotFound):
writeError(w, http.StatusNotFound, "not_found", "Server channel not found")
case errors.Is(err, notifications.ErrServerChannelLimit):
writeError(w, http.StatusUnprocessableEntity, "limit_reached", "Server channel limit reached")
case errors.Is(err, notifications.ErrServerChannelInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Server channel operation failed")
}
}
// HandleList handles GET /admin/notifications/server-channels.
func (h *AdminServerChannelsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
channels, err := service.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list server channels")
return
}
responses := make([]serverChannelResponse, 0, len(channels))
for _, ch := range channels {
responses = append(responses, serverChannelToResponse(ch))
}
writeJSON(w, http.StatusOK, map[string]any{"channels": responses})
}
// HandleCreate handles POST /admin/notifications/server-channels. For generic
// channels the response carries the signing secret exactly once.
func (h *AdminServerChannelsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
var req serverChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
ch, signingSecret, err := service.Create(r.Context(), apimw.GetUserID(r.Context()), req.toInput())
if err != nil {
writeServerChannelError(w, err)
return
}
response := serverChannelToResponse(*ch)
response.SigningSecret = signingSecret
writeJSON(w, http.StatusCreated, response)
}
// HandleUpdate handles PUT /admin/notifications/server-channels/{id}.
func (h *AdminServerChannelsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
var req serverChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
ch, err := service.Update(r.Context(), chi.URLParam(r, "id"), req.toInput())
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, serverChannelToResponse(*ch))
}
// HandleDelete handles DELETE /admin/notifications/server-channels/{id}.
// Idempotent.
func (h *AdminServerChannelsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
if err := service.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeServerChannelError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRotateSecret handles POST /admin/notifications/server-channels/{id}/rotate-secret.
func (h *AdminServerChannelsHandler) HandleRotateSecret(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
signingSecret, err := service.RotateSecret(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"signing_secret": signingSecret})
}
// HandleTest handles POST /admin/notifications/server-channels/{id}/test. The
// test send is synchronous and never touches the watermark or failure
// counters.
func (h *AdminServerChannelsHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
service := h.service()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Server channels are not available")
return
}
result, err := service.Test(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeServerChannelError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
@@ -0,0 +1,82 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/notifications"
)
func TestWriteServerChannelErrorMapping(t *testing.T) {
cases := []struct {
err error
want int
}{
{notifications.ErrServerChannelsDisabled, http.StatusForbidden},
{notifications.ErrServerChannelNotFound, http.StatusNotFound},
{notifications.ErrServerChannelLimit, http.StatusUnprocessableEntity},
{fmt.Errorf("%w: name is required", notifications.ErrServerChannelInvalid), http.StatusBadRequest},
{fmt.Errorf("boom"), http.StatusInternalServerError},
}
for _, tc := range cases {
rec := httptest.NewRecorder()
writeServerChannelError(rec, tc.err)
if rec.Code != tc.want {
t.Errorf("writeServerChannelError(%v) = %d, want %d", tc.err, rec.Code, tc.want)
}
}
}
// The response shape must never leak the destination URL (a bearer credential
// for Discord webhooks) or the stored signing secret; signing_secret appears
// only when explicitly set by the create/rotate paths.
func TestServerChannelResponseNeverLeaksSecrets(t *testing.T) {
secret := "ciphertext-secret"
ch := notifications.ServerChannel{
ID: "ch-1",
Name: "Community",
Type: "discord",
URLCiphertext: "enc:v1:secret-url-material",
URLHost: "discord.com",
SigningSecretCiphertext: &secret,
Enabled: true,
}
body, err := json.Marshal(serverChannelToResponse(ch))
if err != nil {
t.Fatal(err)
}
encoded := string(body)
if strings.Contains(encoded, "secret-url-material") || strings.Contains(encoded, "ciphertext-secret") {
t.Fatalf("response leaked ciphertext material: %s", encoded)
}
if strings.Contains(encoded, "signing_secret") {
t.Fatalf("signing_secret must be omitted unless explicitly set: %s", encoded)
}
if !strings.Contains(encoded, `"url_host":"discord.com"`) {
t.Fatalf("url_host missing from response: %s", encoded)
}
response := serverChannelToResponse(ch)
response.SigningSecret = "shown-once"
body, err = json.Marshal(response)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `"signing_secret":"shown-once"`) {
t.Fatalf("explicit signing_secret missing: %s", body)
}
}
// A handler with no notifications system reports 503 instead of panicking.
func TestServerChannelHandlersUnavailableWithoutSystem(t *testing.T) {
h := NewAdminServerChannelsHandler(nil)
rec := httptest.NewRecorder()
h.HandleList(rec, httptest.NewRequest(http.MethodGet, "/admin/notifications/server-channels", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("HandleList without system = %d, want 503", rec.Code)
}
}
@@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"time"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
type adminServerStatusResponse struct {
StartedAt time.Time `json:"started_at"`
RestartRequired bool `json:"restart_required"`
RestartRequiredAt *time.Time `json:"restart_required_at,omitempty"`
RestartRequiredReason string `json:"restart_required_reason,omitempty"`
RestartRequested bool `json:"restart_requested"`
RestartRequestedAt *time.Time `json:"restart_requested_at,omitempty"`
}
// HandleGetServerStatus handles GET /admin/server/status.
func (h *AdminHandler) HandleGetServerStatus(w http.ResponseWriter, r *http.Request) {
snapshot := h.RestartStatus.Snapshot()
resp := adminServerStatusResponse{
StartedAt: snapshot.StartedAt,
RestartRequired: snapshot.RestartRequired,
RestartRequiredAt: snapshot.RestartRequiredAt,
RestartRequiredReason: snapshot.RestartRequiredReason,
RestartRequested: snapshot.RestartRequested,
RestartRequestedAt: snapshot.RestartRequestedAt,
}
if h.SettingsRepo != nil {
settings, err := h.SettingsRepo.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
return
}
if jellycompat.WebComponentStatusForConfig(h.Config, settings).RestartRequired {
resp.RestartRequired = true
if resp.RestartRequiredReason == "" {
resp.RestartRequiredReason = "jellyfin_compat"
}
}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *AdminHandler) markServerRestartRequired(reason string) {
if h == nil {
return
}
h.RestartStatus.MarkRequired(reason)
}
@@ -0,0 +1,87 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/config"
)
func TestAdminServerStatusClearsAfterProcessRestart(t *testing.T) {
t.Parallel()
restartStatus := NewServerRestartStatusTracker()
restartStatus.MarkRequired("settings")
handler := &AdminHandler{RestartStatus: restartStatus}
req := httptest.NewRequest(http.MethodGet, "/admin/server/status", nil)
rec := httptest.NewRecorder()
handler.HandleGetServerStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp adminServerStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.RestartRequired {
t.Fatal("RestartRequired = false, want true")
}
restartedHandler := &AdminHandler{RestartStatus: NewServerRestartStatusTracker()}
rec = httptest.NewRecorder()
restartedHandler.HandleGetServerStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status after restart = %d, body = %s", rec.Code, rec.Body.String())
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response after restart: %v", err)
}
if resp.RestartRequired {
t.Fatal("RestartRequired = true after new process tracker, want false")
}
}
func TestAdminServerStatusDoesNotPromoteLiveJellyfinIdentitySettings(t *testing.T) {
t.Parallel()
cfg, err := config.LoadFromDB(map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.listen": ":8096",
"jellyfin_compat.public_url": "http://127.0.0.1:8096",
"jellyfin_compat.server_name": "Silo",
"jellyfin_compat.emulated_server_version": "10.11.0",
})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
handler := &AdminHandler{
Config: cfg,
RestartStatus: NewServerRestartStatusTracker(),
SettingsRepo: &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.public_url": "https://compat.example.test",
"jellyfin_compat.server_name": "Silo Compat",
"jellyfin_compat.emulated_server_version": "10.11.6",
}},
}
req := httptest.NewRequest(http.MethodGet, "/admin/server/status", nil)
rec := httptest.NewRecorder()
handler.HandleGetServerStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp adminServerStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.RestartRequired {
t.Fatal("RestartRequired = true, want false for live Jellyfin identity settings")
}
}
@@ -12,6 +12,7 @@ import (
"github.com/redis/go-redis/v9"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/recommendations/embeddings"
"github.com/Silo-Server/silo-server/internal/s3client"
@@ -116,6 +117,8 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
response = checkRedisConnection(r.Context(), cfg)
case "recommendations_embedding":
response = checkRecommendationsEmbeddingConnection(r.Context(), cfg)
case "meilisearch":
response = checkMeilisearchConnection(r.Context(), effectiveSettings)
default:
writeError(w, http.StatusBadRequest, "bad_request", "Unsupported connection check kind")
return
@@ -124,6 +127,22 @@ func (h *AdminHandler) HandleCheckSettingsConnection(w http.ResponseWriter, r *h
writeJSON(w, http.StatusOK, response)
}
func checkMeilisearchConnection(ctx context.Context, settings map[string]string) connectionCheckResponse {
searchSettings, err := catalog.CatalogSearchSettingsFromMap(settings)
if err != nil {
return connectionCheckResponse{Success: false, Message: err.Error()}
}
if searchSettings.MeilisearchURL == "" {
return connectionCheckResponse{Success: false, Message: "Meilisearch URL is required"}
}
searchSettings.Provider = catalog.SearchProviderMeilisearch
indexer := catalog.NewCatalogSearchIndexer(nil, nil)
if err := indexer.CheckConnection(ctx, searchSettings); err != nil {
return connectionCheckResponse{Success: false, Message: err.Error()}
}
return connectionCheckResponse{Success: true, Message: "Meilisearch connection successful"}
}
func (h *AdminHandler) effectiveSettingsForConnectionCheck(
ctx context.Context,
req adminSettingsConnectionCheckRequest,
@@ -11,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/s3client"
)
@@ -335,13 +336,15 @@ func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
// Infrastructure settings are captured at startup.
{key: "database.max_connections", value: "40", restartRequired: true},
{key: "s3.public_bucket", value: "assets", restartRequired: true},
{key: "jellyfin_compat.enabled", value: "false", restartRequired: true},
// Branding is read live from the settings repo per request.
{key: "branding.server_name", value: "Casa", restartRequired: false},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
restartStatus := NewServerRestartStatusTracker()
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: restartStatus}
req := httptest.NewRequest(
http.MethodPut,
@@ -363,6 +366,81 @@ func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
if resp.RestartRequired != tc.restartRequired {
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
}
snapshot := restartStatus.Snapshot()
if snapshot.RestartRequired != tc.restartRequired {
t.Fatalf("tracker restart required = %v, want %v", snapshot.RestartRequired, tc.restartRequired)
}
if !tc.restartRequired && snapshot.RestartRequiredReason != "" {
t.Fatalf("tracker reason = %q, want empty", snapshot.RestartRequiredReason)
}
})
}
}
func TestAdminUpdateCatalogSearchSemanticSettingsValidation(t *testing.T) {
valid := []struct {
key string
value string
want string
}{
{key: catalog.SearchSettingMeilisearchSemanticEnabled, value: "TRUE", want: "true"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: ".30", want: "0.3"},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "", want: catalog.DefaultMeilisearchEmbedder},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "custom_embedder-2", want: "custom_embedder-2"},
}
for _, tc := range valid {
t.Run("valid "+tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: NewServerRestartStatusTracker()}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if settings.values[tc.key] != tc.want {
t.Fatalf("stored value = %q, want %q", settings.values[tc.key], tc.want)
}
})
}
invalid := []struct {
key string
value string
}{
{key: catalog.SearchSettingMeilisearchSemanticEnabled, value: "sometimes"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "-0.01"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "1.01"},
{key: catalog.SearchSettingMeilisearchSemanticRatio, value: "NaN"},
{key: catalog.SearchSettingMeilisearchEmbedder, value: "bad.name"},
}
for _, tc := range invalid {
t.Run("invalid "+tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
if _, ok := settings.values[tc.key]; ok {
t.Fatalf("invalid setting was stored: %#v", settings.values)
}
})
}
}
+55 -13
View File
@@ -18,8 +18,10 @@ type audiobookGroupResponse struct {
}
type audiobookGroupsResponse struct {
Total int `json:"total"`
Groups []audiobookGroupResponse `json:"groups"`
Total int `json:"total"`
TotalExact bool `json:"total_exact"`
HasMore bool `json:"has_more"`
Groups []audiobookGroupResponse `json:"groups"`
}
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
@@ -28,7 +30,7 @@ type audiobookGroupsResponse struct {
// aggregate stats (book count, total duration, per-profile progress counts)
// and up to four poster URLs for cover stacks. Query parameters:
// library_id=<id> (required), group_by=author|narrator|series (required),
// sort=name|count|duration (default name), limit, offset.
// sort=name|count|duration (default name), limit, offset, include_total, q.
//
// Group names round-trip into the corresponding catalog filter fields
// (author / narrator / series), which match case-insensitively.
@@ -68,16 +70,27 @@ func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http
limit = n
}
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
includeTotal := true
if raw := strings.TrimSpace(r.URL.Query().Get("include_total")); raw != "" {
parsed, parseErr := strconv.ParseBool(raw)
if parseErr != nil {
writeError(w, http.StatusBadRequest, "bad_request", "include_total must be true or false")
return
}
includeTotal = parsed
}
groups, total, err := catalog.ListAudiobookGroups(
result, err := catalog.ListAudiobookGroups(
r.Context(),
h.itemsH.browseRepo.Pool(),
catalog.AudiobookGroupsQuery{
LibraryID: libraryID,
GroupBy: groupBy,
Sort: sort,
Limit: limit,
Offset: offset,
LibraryID: libraryID,
GroupBy: groupBy,
SearchPrefix: strings.TrimSpace(r.URL.Query().Get("q")),
IncludeTotal: includeTotal,
Sort: sort,
Limit: limit,
Offset: offset,
},
h.itemsH.accessFilter(r),
)
@@ -86,12 +99,18 @@ func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http
return
}
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
for _, g := range groups {
resolvedPosters := h.resolveAudiobookGroupPosterURLs(r, result.Groups)
resp := audiobookGroupsResponse{
Total: result.Total,
TotalExact: result.TotalExact,
HasMore: result.HasMore,
Groups: make([]audiobookGroupResponse, 0, len(result.Groups)),
}
for _, g := range result.Groups {
posterURLs := make([]string, 0, len(g.PosterPaths))
for _, path := range g.PosterPaths {
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
posterURLs = append(posterURLs, url)
if resolved := resolvedPosters[cardThumbnailPath(path)]; resolved.URL != "" {
posterURLs = append(posterURLs, resolved.URL)
}
}
resp.Groups = append(resp.Groups, audiobookGroupResponse{
@@ -106,3 +125,26 @@ func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http
writeJSON(w, http.StatusOK, resp)
}
func (h *CatalogHandler) resolveAudiobookGroupPosterURLs(r *http.Request, groups []catalog.AudiobookGroup) map[string]catalog.ResolvedImageURL {
if h == nil || h.itemsH == nil || h.itemsH.detailSvc == nil || len(groups) == 0 {
return map[string]catalog.ResolvedImageURL{}
}
paths := make([]string, 0, len(groups)*4)
seen := make(map[string]struct{}, len(groups)*4)
for _, group := range groups {
for _, path := range group.PosterPaths {
normalized := cardThumbnailPath(path)
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
paths = append(paths, normalized)
}
}
return h.itemsH.detailSvc.PresignURLsWithExpiry(r.Context(), paths, "card")
}
+167
View File
@@ -0,0 +1,167 @@
package handlers
import (
"errors"
"io"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/branding"
)
// BrandingHandler exposes the public branding read, the public asset serving
// endpoint, and the admin asset upload/delete endpoints. All branding logic is
// delegated to the branding.Service.
type BrandingHandler struct {
svc *branding.Service
}
// NewBrandingHandler constructs a BrandingHandler around a branding service.
func NewBrandingHandler(svc *branding.Service) *BrandingHandler {
return &BrandingHandler{svc: svc}
}
// brandingResponse is returned by GET /theme/branding. It is a superset of the
// historical {server_name, login_subtitle} shape — new fields are additive per
// the v1 API rules. Asset URLs are stable, cache-bustable paths (empty when no
// custom asset is set).
type brandingResponse struct {
ServerName string `json:"server_name"`
LoginSubtitle string `json:"login_subtitle"`
AccentColor string `json:"accent_color,omitempty"`
DefaultTheme string `json:"default_theme,omitempty"`
WordmarkURL string `json:"wordmark_url,omitempty"`
MarkURL string `json:"mark_url,omitempty"`
FaviconURL string `json:"favicon_url,omitempty"`
LoginBgURL string `json:"login_bg_url,omitempty"`
}
// HandleGetBranding returns the server branding configuration. Public endpoint —
// no authentication required so branding applies before login (white-label).
func (h *BrandingHandler) HandleGetBranding(w http.ResponseWriter, r *http.Request) {
snap := h.svc.Load(r.Context())
w.Header().Set("Cache-Control", "public, max-age=60")
writeJSON(w, http.StatusOK, brandingResponse{
ServerName: snap.ServerName,
LoginSubtitle: snap.LoginSubtitle,
AccentColor: snap.AccentColor,
DefaultTheme: snap.DefaultTheme,
WordmarkURL: snap.AssetURL(branding.KindWordmark),
MarkURL: snap.AssetURL(branding.KindMark),
FaviconURL: snap.AssetURL(branding.KindFavicon),
LoginBgURL: snap.AssetURL(branding.KindLoginBg),
})
}
// HandleServeAsset streams a custom branding asset. Public endpoint — assets are
// non-sensitive and must load before login. Assets are content-addressed, so
// they are served with a long immutable cache lifetime.
func (h *BrandingHandler) HandleServeAsset(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
if !branding.IsValidKind(kind) {
writeError(w, http.StatusNotFound, "not_found", "Unknown branding asset")
return
}
data, contentType, ref, err := h.svc.GetAsset(r.Context(), branding.AssetKind(kind))
switch {
case errors.Is(err, branding.ErrAssetNotConfigured):
writeError(w, http.StatusNotFound, "not_found", "No custom asset configured")
return
case errors.Is(err, branding.ErrStorageUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Asset storage is not configured")
return
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load asset")
return
}
etag := `"` + ref + `"`
// Uploaded assets (e.g. SVG favicons) are admin-controlled but served from
// the app origin. Prevent content-type sniffing and neutralize scripts in a
// directly-navigated SVG (stored-XSS defense).
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", branding.AssetContentSecurityPolicy)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
// HandleUploadAsset accepts a multipart image upload for a branding asset kind
// and records it. Admin-only (wired behind requireActingAdmin).
func (h *BrandingHandler) HandleUploadAsset(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
if !branding.IsValidKind(kind) {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown branding asset")
return
}
if !h.svc.HasStorage() {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Asset upload storage (S3) is not configured")
return
}
maxBytes := branding.MaxUploadBytes(branding.AssetKind(kind))
if err := r.ParseMultipartForm(maxBytes + (1 << 20)); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Missing file field")
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload")
return
}
if int64(len(data)) > maxBytes {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Image exceeds the maximum size for this asset")
return
}
ref, err := h.svc.UploadAsset(r.Context(), branding.AssetKind(kind), data, header.Header.Get("Content-Type"))
switch {
case errors.Is(err, branding.ErrUnsupportedImage):
writeError(w, http.StatusBadRequest, "bad_request", "Unsupported image type; use PNG, JPEG, WebP (or PNG/ICO/SVG for favicon)")
return
case errors.Is(err, branding.ErrStorageUnavailable):
writeError(w, http.StatusServiceUnavailable, "unavailable", "Asset upload storage (S3) is not configured")
return
case errors.Is(err, branding.ErrInvalidKind):
writeError(w, http.StatusBadRequest, "bad_request", "Unknown branding asset")
return
case err != nil:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store asset")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"kind": kind,
"ref": ref,
"url": branding.AssetURLFor(branding.AssetKind(kind), ref),
})
}
// HandleDeleteAsset clears a custom branding asset. Admin-only.
func (h *BrandingHandler) HandleDeleteAsset(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
if !branding.IsValidKind(kind) {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown branding asset")
return
}
if err := h.svc.DeleteAsset(r.Context(), branding.AssetKind(kind)); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove asset")
return
}
w.WriteHeader(http.StatusNoContent)
}
+315 -34
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
@@ -16,9 +17,28 @@ import (
"github.com/Silo-Server/silo-server/internal/sections"
)
// audiobookGroupsCacheTTL matches the client's React Query staleTime: the
// grouped author/narrator browse is cached server-side for the same window so
// the sequential page fetches and a quick refresh reuse one aggregation.
const audiobookGroupsCacheTTL = 60 * time.Second
type CatalogHandler struct {
resolver *catalog.CatalogResolver
itemsH *ItemsHandler
resolver *catalog.CatalogResolver
itemsH *ItemsHandler
workSummary catalog.WorkSummaryProvider
groupsCacheOnce sync.Once
groupsCache *catalog.AudiobookGroupsCache
}
// audiobookGroups returns the lazily-initialized grouped-browse cache. Built on
// first use (only the route-mounted singleton handler serves audiobook-groups)
// so the per-request CatalogHandler instances never spawn a cache sweeper.
func (h *CatalogHandler) audiobookGroups() *catalog.AudiobookGroupsCache {
h.groupsCacheOnce.Do(func() {
h.groupsCache = catalog.NewAudiobookGroupsCache(h.itemsH.browseRepo.Pool(), audiobookGroupsCacheTTL)
})
return h.groupsCache
}
func NewCatalogHandler(resolver *catalog.CatalogResolver, itemsH *ItemsHandler) *CatalogHandler {
@@ -28,12 +48,31 @@ func NewCatalogHandler(resolver *catalog.CatalogResolver, itemsH *ItemsHandler)
}
}
func (h *CatalogHandler) SetWorkSummaryProvider(provider catalog.WorkSummaryProvider) {
h.workSummary = provider
}
type catalogResponse struct {
Total int `json:"total"`
TotalExact bool `json:"total_exact"`
HasMore bool `json:"has_more"`
Items []itemListResponse `json:"items"`
Snapshot string `json:"snapshot,omitempty"`
Total int `json:"total"`
TotalExact bool `json:"total_exact"`
HasMore bool `json:"has_more"`
Items []itemListResponse `json:"items"`
Snapshot string `json:"snapshot,omitempty"`
SearchDiagnostics *searchDiagnostics `json:"search_diagnostics,omitempty"`
}
// searchDiagnostics is an additive, per-query observability object emitted on
// /api/v1/catalog only when a relevance-sorted search actually ran through a
// CatalogSearchProvider. mode/semantic_used reflect POST-downgrade reality
// (a hybrid request that fell back to keyword reports mode="keyword",
// semantic_used=false). fallback_reason and index_pending_updates are omitted
// when empty.
type searchDiagnostics struct {
Provider string `json:"provider"`
Mode string `json:"mode"`
SemanticUsed bool `json:"semantic_used"`
FallbackReason string `json:"fallback_reason,omitempty"`
IndexPendingUpdates int `json:"index_pending_updates,omitempty"`
}
type catalogFiltersResponse struct {
@@ -62,8 +101,36 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
accessFilter := h.itemsH.accessFilter(r)
groupedByWork := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("group")), "work")
if groupedByWork {
result, entries, err := h.resolveGroupedCatalogByWork(r, req, accessFilter)
if err != nil {
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if errors.Is(err, catalog.ErrCatalogSourceNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Catalog source not found")
return
}
slog.Error("catalog: resolve grouped by work failed", "err_msg", err.Error())
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog")
return
}
result, err := h.resolver.Resolve(r.Context(), req, h.itemsH.accessFilter(r))
resultItems := groupedCatalogItems(entries)
items := h.catalogItemResponses(r, resultItems, catalog.NormalizeQuerySort(req.Query.Sort).Field, accessFilter)
for i := range items {
if i < len(entries) && entries[i].summary != nil {
applyWorkSummaryToCatalogItem(&items[i], entries[i].summary)
}
}
h.writeCatalogResponse(w, result, items, groupedByWork)
return
}
result, err := h.resolver.Resolve(r.Context(), req, accessFilter)
if err != nil {
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
@@ -77,53 +144,267 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve catalog")
return
}
items := h.catalogItemResponses(r, result.Items, catalog.NormalizeQuerySort(req.Query.Sort).Field, accessFilter)
h.writeCatalogResponse(w, result, items, groupedByWork)
}
overlaySummaries := h.itemsH.listOverlaySummaries(r.Context(), result.Items, h.itemsH.accessFilter(r))
userStates := h.itemsH.listItemUserStates(r, result.Items)
episodeMetadata := h.itemsH.listEpisodeBrowseMetadata(r.Context(), result.Items)
sortField := catalog.NormalizeQuerySort(req.Query.Sort).Field
store, profileID, _ := h.itemsH.userStoreForRequest(r)
sortMetrics := h.itemsH.listSortMetrics(
r.Context(),
result.Items,
sortField,
h.itemsH.accessFilter(r),
overlaySummaries,
store,
apimw.GetUserID(r.Context()),
profileID,
func (h *CatalogHandler) catalogItemResponses(r *http.Request, resultItems []*models.MediaItem, sortField string, accessFilter catalog.AccessFilter) []itemListResponse {
var (
localizedItems []*models.MediaItem
overlaySummaries map[string]*models.OverlaySummary
userStates map[string]*itemUserStateResponse
episodeMetadata map[string]struct {
SeriesTitle string
SeasonNumber *int
EpisodeNumber *int
}
)
items := make([]itemListResponse, 0, len(result.Items))
for _, item := range result.Items {
resp := h.itemsH.toItemListResponseWithOverlay(
r,
item,
overlaySummaries[item.ContentID],
userStates[item.ContentID],
var enrichWG sync.WaitGroup
enrichWG.Add(4)
go func() {
defer enrichWG.Done()
localizedItems = h.itemsH.localizeItemListModels(r.Context(), resultItems, accessFilter)
}()
go func() {
defer enrichWG.Done()
overlaySummaries = h.itemsH.listOverlaySummaries(r.Context(), resultItems, accessFilter)
}()
go func() {
defer enrichWG.Done()
userStates = h.itemsH.listItemUserStates(r, resultItems)
}()
go func() {
defer enrichWG.Done()
episodeMetadata = h.itemsH.listEpisodeBrowseMetadata(r.Context(), resultItems)
}()
enrichWG.Wait()
var (
imageURLs map[string]itemListImageURLs
sortMetrics map[string]*sortMetricsResponse
)
store, profileID, _ := h.itemsH.userStoreForRequest(r)
var responseWG sync.WaitGroup
responseWG.Add(2)
go func() {
defer responseWG.Done()
imageURLs = h.itemsH.itemListCardImageURLs(r.Context(), localizedItems)
}()
go func() {
defer responseWG.Done()
sortMetrics = h.itemsH.listSortMetrics(
r.Context(),
resultItems,
sortField,
accessFilter,
overlaySummaries,
store,
apimw.GetUserID(r.Context()),
profileID,
)
}()
responseWG.Wait()
items := make([]itemListResponse, 0, len(localizedItems))
for _, item := range localizedItems {
if item == nil {
continue
}
resp := itemListResponseShell(item, overlaySummaries[item.ContentID], userStates[item.ContentID])
if meta, ok := episodeMetadata[item.ContentID]; ok {
resp.SeriesTitle = meta.SeriesTitle
resp.SeasonNumber = meta.SeasonNumber
resp.EpisodeNumber = meta.EpisodeNumber
}
resp.PosterURL = imageURLs[item.ContentID].posterURL
resp.BackdropURL = imageURLs[item.ContentID].backdropURL
resp.SortMetrics = sortMetrics[item.ContentID]
items = append(items, resp)
}
return items
}
func (h *CatalogHandler) writeCatalogResponse(w http.ResponseWriter, result *catalog.CatalogResult, items []itemListResponse, groupedByWork bool) {
var snapshot string
if !result.SnapshotAt.IsZero() {
snapshot = result.SnapshotAt.Format(time.RFC3339Nano)
}
// A non-empty Provider is the single gate: only the direct-search path sets
// it. Browse / preview / non-relevance-sort q= (which never run a provider)
// and group=work (fresh CatalogResult with empty Provider) all omit it.
var diag *searchDiagnostics
if result.Provider != "" {
diag = &searchDiagnostics{
Provider: result.Provider,
Mode: result.Mode,
SemanticUsed: result.SemanticUsed,
FallbackReason: result.FallbackReason,
IndexPendingUpdates: result.IndexPendingEvents,
}
}
writeJSON(w, http.StatusOK, catalogResponse{
Total: result.Total,
TotalExact: result.TotalExact,
HasMore: result.HasMore,
Items: items,
Snapshot: snapshot,
Total: result.Total,
TotalExact: result.TotalExact && !groupedByWork,
HasMore: result.HasMore,
Items: items,
Snapshot: snapshot,
SearchDiagnostics: diag,
})
}
type groupedCatalogEntry struct {
item *models.MediaItem
summary *catalog.WorkSummary
}
func (h *CatalogHandler) resolveGroupedCatalogByWork(r *http.Request, req catalog.CatalogRequest, accessFilter catalog.AccessFilter) (*catalog.CatalogResult, []groupedCatalogEntry, error) {
fetchReq := req
fetchReq.Offset = 0
fetchReq.Limit = groupedCatalogFetchLimit(req.Limit)
fetchReq.SkipTotal = true
seen := map[string]struct{}{}
entries := make([]groupedCatalogEntry, 0, req.Limit+1)
groupIndex := 0
var snapshot time.Time
for {
result, err := h.resolver.Resolve(r.Context(), fetchReq, accessFilter)
if err != nil {
return nil, nil, err
}
if snapshot.IsZero() {
snapshot = result.SnapshotAt
if !snapshot.IsZero() {
fetchReq.SnapshotAt = &snapshot
}
}
summaries, err := h.workSummariesForItems(r, result.Items, accessFilter)
if err != nil {
return nil, nil, err
}
for _, item := range result.Items {
summary := summaries[item.ContentID]
key := groupedCatalogEntryKey(item, summary)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if groupIndex >= req.Offset {
entries = append(entries, groupedCatalogEntry{item: item, summary: summary})
}
groupIndex++
if len(entries) > req.Limit {
break
}
}
if len(entries) > req.Limit || !result.HasMore || len(result.Items) == 0 {
break
}
fetchReq.Offset += len(result.Items)
}
hasMore := len(entries) > req.Limit
if hasMore {
entries = entries[:req.Limit]
}
total := req.Offset + len(entries)
if hasMore {
total++
}
result := &catalog.CatalogResult{
Items: groupedCatalogItems(entries),
Total: total,
HasMore: hasMore,
TotalExact: false,
SnapshotAt: snapshot,
}
return result, entries, nil
}
func groupedCatalogFetchLimit(limit int) int {
switch {
case limit <= 0:
return 100
case limit >= 100:
return 100
default:
return min(100, limit*4+10)
}
}
func (h *CatalogHandler) workSummariesForItems(r *http.Request, items []*models.MediaItem, filter catalog.AccessFilter) (map[string]*catalog.WorkSummary, error) {
summaries := map[string]*catalog.WorkSummary{}
if h == nil || h.workSummary == nil || len(items) == 0 {
return summaries, nil
}
contentIDs := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
if !catalogItemCanGroupByWork(item) {
continue
}
if _, ok := seen[item.ContentID]; ok {
continue
}
seen[item.ContentID] = struct{}{}
contentIDs = append(contentIDs, item.ContentID)
}
if len(contentIDs) == 0 {
return summaries, nil
}
if batch, ok := h.workSummary.(catalog.WorkSummaryBatchProvider); ok {
return batch.ListSummariesForContentIDs(r.Context(), contentIDs, filter)
}
for _, contentID := range contentIDs {
summary, err := h.workSummary.GetSummaryForContentID(r.Context(), contentID, filter)
if err != nil {
return nil, err
}
if summary != nil && summary.WorkID != "" {
summaries[contentID] = summary
}
}
return summaries, nil
}
func groupedCatalogItems(entries []groupedCatalogEntry) []*models.MediaItem {
items := make([]*models.MediaItem, 0, len(entries))
for _, entry := range entries {
items = append(items, entry.item)
}
return items
}
func groupedCatalogEntryKey(item *models.MediaItem, summary *catalog.WorkSummary) string {
if catalogItemCanGroupByWork(item) && summary != nil && summary.WorkID != "" {
return "work:" + summary.WorkID
}
if item == nil {
return "item:"
}
return "item:" + item.ContentID
}
func catalogItemCanGroupByWork(item *models.MediaItem) bool {
return item != nil && (item.Type == "ebook" || item.Type == "audiobook")
}
func applyWorkSummaryToCatalogItem(item *itemListResponse, summary *catalog.WorkSummary) {
if item == nil || summary == nil || summary.WorkID == "" {
return
}
item.Type = "work"
item.WorkID = summary.WorkID
item.WorkTitle = summary.Title
item.WorkFormats = summary.Formats
if summary.Title != "" {
item.Title = summary.Title
}
}
func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.Request) {
if h == nil || h.resolver == nil || h.itemsH == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
@@ -0,0 +1,123 @@
package handlers
import (
"encoding/json"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
)
// decodeCatalogResponse runs writeCatalogResponse against an httptest recorder
// and returns the decoded JSON body as a generic map so individual keys can be
// asserted for presence/absence (search_diagnostics is omitempty).
func decodeCatalogResponse(t *testing.T, result *catalog.CatalogResult, grouped bool) map[string]any {
t.Helper()
rec := httptest.NewRecorder()
// writeCatalogResponse uses no handler state, so a zero-value handler is fine.
(&CatalogHandler{}).writeCatalogResponse(rec, result, []itemListResponse{}, grouped)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding catalog response: %v\nbody: %s", err, rec.Body.String())
}
return body
}
func TestWriteCatalogResponse_DiagnosticsKeywordFallback(t *testing.T) {
body := decodeCatalogResponse(t, &catalog.CatalogResult{
Total: 3,
TotalExact: true,
HasMore: false,
Provider: catalog.SearchProviderMeilisearch,
Mode: "keyword",
SemanticUsed: false,
FallbackReason: `semantic_not_ready: type "movie" coverage 40% below threshold`,
}, false)
// Existing keys must stay present and unchanged (byte-stable contract).
for _, key := range []string{"total", "total_exact", "has_more", "items"} {
if _, ok := body[key]; !ok {
t.Fatalf("response missing existing key %q: %v", key, body)
}
}
if body["total"].(float64) != 3 {
t.Fatalf("total = %v, want 3", body["total"])
}
if body["total_exact"].(bool) != true {
t.Fatalf("total_exact = %v, want true", body["total_exact"])
}
diagRaw, ok := body["search_diagnostics"]
if !ok {
t.Fatalf("expected search_diagnostics in response: %v", body)
}
diag := diagRaw.(map[string]any)
if diag["provider"] != catalog.SearchProviderMeilisearch {
t.Fatalf("provider = %v, want %q", diag["provider"], catalog.SearchProviderMeilisearch)
}
if diag["mode"] != "keyword" {
t.Fatalf("mode = %v, want keyword", diag["mode"])
}
if diag["semantic_used"].(bool) != false {
t.Fatalf("semantic_used = %v, want false", diag["semantic_used"])
}
if diag["fallback_reason"] != `semantic_not_ready: type "movie" coverage 40% below threshold` {
t.Fatalf("fallback_reason = %v", diag["fallback_reason"])
}
}
func TestWriteCatalogResponse_DiagnosticsHybridOmitsFallbackReason(t *testing.T) {
body := decodeCatalogResponse(t, &catalog.CatalogResult{
Provider: catalog.SearchProviderMeilisearch,
Mode: "hybrid",
SemanticUsed: true,
IndexPendingEvents: 7,
}, false)
diagRaw, ok := body["search_diagnostics"]
if !ok {
t.Fatalf("expected search_diagnostics in response: %v", body)
}
diag := diagRaw.(map[string]any)
if diag["semantic_used"].(bool) != true {
t.Fatalf("semantic_used = %v, want true", diag["semantic_used"])
}
if diag["mode"] != "hybrid" {
t.Fatalf("mode = %v, want hybrid", diag["mode"])
}
if _, ok := diag["fallback_reason"]; ok {
t.Fatalf("fallback_reason should be omitted when empty: %v", diag)
}
if diag["index_pending_updates"].(float64) != 7 {
t.Fatalf("index_pending_updates = %v, want 7", diag["index_pending_updates"])
}
}
func TestWriteCatalogResponse_NoProviderOmitsDiagnostics(t *testing.T) {
// Browse / preview / non-relevance q= paths never set Provider.
body := decodeCatalogResponse(t, &catalog.CatalogResult{
Total: 5,
TotalExact: true,
}, false)
if _, ok := body["search_diagnostics"]; ok {
t.Fatalf("search_diagnostics should be omitted when no provider search ran: %v", body)
}
}
func TestWriteCatalogResponse_GroupedByWorkOmitsDiagnostics(t *testing.T) {
// group=work builds a fresh CatalogResult with an empty Provider.
body := decodeCatalogResponse(t, &catalog.CatalogResult{
Total: 2,
TotalExact: true,
}, true)
if _, ok := body["search_diagnostics"]; ok {
t.Fatalf("grouped response should omit search_diagnostics: %v", body)
}
// Grouped responses force total_exact false regardless of result.TotalExact.
if body["total_exact"].(bool) != false {
t.Fatalf("grouped total_exact = %v, want false", body["total_exact"])
}
}
@@ -0,0 +1,53 @@
package handlers
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeCatalogWorkSummaryProvider struct{}
func (fakeCatalogWorkSummaryProvider) GetSummaryForContentID(ctx context.Context, contentID string, filter catalog.AccessFilter) (*catalog.WorkSummary, error) {
if contentID != "ebook-1" && contentID != "audio-1" {
return nil, nil
}
return &catalog.WorkSummary{
WorkID: "work-1",
Title: "Project Hail Mary",
Formats: []catalog.WorkFormatSummary{
{Type: "ebook", ContentID: "ebook-1", LibraryID: 1},
{Type: "audiobook", ContentID: "audio-1", LibraryID: 2},
},
}, nil
}
func TestGroupedCatalogEntryKeyDeduplicatesLinkedFormats(t *testing.T) {
summary := &catalog.WorkSummary{
WorkID: "work-1",
Title: "Project Hail Mary",
Formats: []catalog.WorkFormatSummary{
{Type: "ebook", ContentID: "ebook-1", LibraryID: 1},
{Type: "audiobook", ContentID: "audio-1", LibraryID: 2},
},
}
ebookKey := groupedCatalogEntryKey(&models.MediaItem{ContentID: "ebook-1", Type: "ebook"}, summary)
audioKey := groupedCatalogEntryKey(&models.MediaItem{ContentID: "audio-1", Type: "audiobook"}, summary)
if ebookKey != audioKey {
t.Fatalf("linked format keys differ: %q vs %q", ebookKey, audioKey)
}
movieKey := groupedCatalogEntryKey(&models.MediaItem{ContentID: "movie-1", Type: "movie"}, nil)
if movieKey != "item:movie-1" {
t.Fatalf("movie key = %q, want item key", movieKey)
}
resp := itemListResponse{ContentID: "ebook-1", Type: "ebook", Title: "Project Hail Mary"}
applyWorkSummaryToCatalogItem(&resp, summary)
if resp.Type != "work" || resp.WorkID != "work-1" || len(resp.WorkFormats) != 2 {
t.Fatalf("work response = %#v", resp)
}
}
+73 -6
View File
@@ -87,6 +87,37 @@ func (h *CatalogResourceHandler) HandleGetItemVersions(w http.ResponseWriter, r
writeJSON(w, http.StatusOK, detail.Versions)
}
// HandleGetMangaFiles returns the local file listing for a manga series (the
// series "View Details" dialog): folder paths plus per-chapter file rows.
// Folder and file paths are stripped for viewers without file-path visibility,
// matching the item-versions policy; file names and sizes remain.
func (h *CatalogResourceHandler) HandleGetMangaFiles(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required")
return
}
files, err := h.items.detailSvc.GetMangaChapterFiles(r.Context(), id, h.items.accessFilter(r))
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get manga files")
return
}
if !h.items.requestCanViewFilePaths(r) {
files.FolderPaths = nil
for i := range files.Files {
files.Files[i].FilePath = ""
}
}
writeJSON(w, http.StatusOK, files)
}
func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r *http.Request) {
filter := h.items.accessFilter(r)
id := chi.URLParam(r, "id")
@@ -197,12 +228,24 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
}
if len(seasons) > 0 {
episodesBySeason, err := h.items.episodeRepo.ListBySeriesGroupedBySeason(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
return
}
progressMap, hasProgressMap := h.items.progressMapForEpisodes(r, flattenEpisodeGroups(episodesBySeason))
resp := make([]seasonResponse, 0, len(seasons))
for _, s := range seasons {
sr := h.items.toSeasonResponse(r, id, s)
if sr.EpisodeCount == 0 {
episodes := episodesBySeason[s.SeasonNumber]
if len(episodes) == 0 {
continue
}
var userData *catalog.SeasonUserData
if hasProgressMap {
userData = catalog.EpisodeRollupUserData(episodes, progressMap)
}
sr := h.items.toSeasonResponseFromEpisodes(r, id, s, episodes, userData)
resp = append(resp, sr)
}
@@ -224,13 +267,14 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
title = "Specials"
}
episodes, _ := h.items.episodeRepo.ListBySeason(r.Context(), id, s.SeasonNumber)
userData := h.items.getAggregateUserData(r, episodes)
resp = append(resp, seasonResponse{
ContentID: fmt.Sprintf("%s-S%02d", id, s.SeasonNumber),
SeasonNumber: s.SeasonNumber,
IsSpecials: s.SeasonNumber == 0,
EpisodeCount: s.EpisodeCount,
Title: title,
UserData: h.items.getAggregateUserData(r, episodes),
UserData: userData,
})
}
@@ -273,10 +317,27 @@ func (h *CatalogResourceHandler) HandleGetSeason(w http.ResponseWriter, r *http.
season, err := h.items.seasonRepo.GetBySeriesAndNumber(r.Context(), id, num)
switch {
case err == nil:
episodes, _ := h.items.episodeRepo.ListBySeasonID(r.Context(), season.ContentID)
episodes, err := h.items.episodeRepo.ListBySeasonID(r.Context(), season.ContentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
if len(episodes) == 0 {
episodes, err = h.items.episodeRepo.ListBySeason(r.Context(), id, season.SeasonNumber)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get season")
return
}
}
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
writeJSON(w, http.StatusOK, seasonDetailResponse{
Season: h.items.toSeasonResponse(r, id, season),
Season: h.items.toSeasonResponseFromEpisodes(
r,
id,
season,
episodes,
h.items.getAggregateUserData(r, episodes),
),
})
return
case !errors.Is(err, catalog.ErrSeasonNotFound):
@@ -388,7 +449,13 @@ func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID
} else {
season.Title = "Season " + strconv.Itoa(seasonNum)
}
seasonResp := h.items.toSeasonResponse(r, seriesID, season)
seasonResp := h.items.toSeasonResponseFromEpisodes(
r,
seriesID,
season,
episodes,
h.items.getAggregateUserData(r, episodes),
)
return &catalog.ItemDetail{
ContentID: seasonID,
Type: "season",
+58 -1
View File
@@ -43,6 +43,7 @@ type createCollectionRequest struct {
AllowedProfileIDs []string `json:"allowed_profile_ids"`
QueryDefinition json.RawMessage `json:"query_definition"`
SortConfig json.RawMessage `json:"sort_config"`
DisplayQueryDefinition json.RawMessage `json:"display_query_definition"`
IncludeInServerCollections bool `json:"include_in_server_collections"`
PosterSourceURL string `json:"poster_source_url"`
}
@@ -56,6 +57,8 @@ type updateCollectionRequest struct {
SortConfig json.RawMessage `json:"sort_config"`
SourceURL *string `json:"source_url"`
MaxItems *int `json:"max_items"`
LibraryIDs *[]int `json:"library_ids"`
DisplayQueryDefinition json.RawMessage `json:"display_query_definition"`
IncludeInServerCollections *bool `json:"include_in_server_collections"`
PosterSourceURL *string `json:"poster_source_url"`
GroupID optionalNullableString `json:"group_id"`
@@ -85,6 +88,7 @@ type collectionResponse struct {
LastSyncAt string `json:"last_sync_at,omitempty"`
LastSyncStatus string `json:"last_sync_status,omitempty"`
LastSyncMessage string `json:"last_sync_message,omitempty"`
DisplayQueryDefinition json.RawMessage `json:"display_query_definition,omitempty"`
ItemCount int `json:"item_count"`
IncludeInServerCollections bool `json:"include_in_server_collections"`
PosterURL string `json:"poster_url,omitempty"`
@@ -98,6 +102,19 @@ type collectionListResponse struct {
Groups []collectionGroupResponse `json:"groups"`
}
type collectionCapabilitiesResponse struct {
// DisplayFilterFields are the catalog query fields a personal-collection
// display filter may use. Clients build a display_query_definition fragment
// from these rather than a bespoke enum.
DisplayFilterFields []string `json:"display_filter_fields"`
DisplayFilterPresets collectionDisplayFilterPresets `json:"display_filter_presets"`
}
type collectionDisplayFilterPresets struct {
Watched []string `json:"watched"`
Media []string `json:"media"`
}
type collectionGroupResponse struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -208,6 +225,17 @@ func (h *CollectionHandler) HandleListCollections(w http.ResponseWriter, r *http
writeJSON(w, http.StatusOK, resp)
}
// HandleCapabilities exposes additive feature support for collection clients.
func (h *CollectionHandler) HandleCapabilities(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, collectionCapabilitiesResponse{
DisplayFilterFields: []string{"type", "watched"},
DisplayFilterPresets: collectionDisplayFilterPresets{
Watched: []string{"all", "watched", "unwatched"},
Media: []string{"all", "movie", "series"},
},
})
}
// HandleCreateCollection handles POST /collections.
func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
@@ -247,6 +275,11 @@ func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *htt
}
queryDefinition := string(queryDefinitionJSON)
sortConfig := string(defaultJSON(req.SortConfig))
displayQueryDefinition, err := catalog.NormalizeDisplayQueryFragment(req.DisplayQueryDefinition)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
collection, err := store.CreateCollection(r.Context(), userstore.CreateCollectionInput{
CreatorProfileID: profileID,
Name: req.Name,
@@ -255,6 +288,7 @@ func (h *CollectionHandler) HandleCreateCollection(w http.ResponseWriter, r *htt
AllowedProfileIDs: req.AllowedProfileIDs,
QueryDefinition: queryDefinition,
SortConfig: sortConfig,
DisplayQueryDefinition: displayQueryDefinition,
IncludeInServerCollections: req.IncludeInServerCollections,
})
if err != nil {
@@ -306,6 +340,14 @@ func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *htt
AllowedProfileIDs: req.AllowedProfileIDs,
IncludeInServerCollections: req.IncludeInServerCollections,
}
if req.DisplayQueryDefinition != nil {
displayQueryDefinition, err := catalog.NormalizeDisplayQueryFragment(req.DisplayQueryDefinition)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input.DisplayQueryDefinition = &displayQueryDefinition
}
if len(req.QueryDefinition) > 0 {
normalized, err := normalizeSmartCollectionQueryDefinitionJSON(req.QueryDefinition, true, true)
if err != nil {
@@ -341,7 +383,7 @@ func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *htt
// Source URL and max items both live inside source_config (Limit / URL).
// Load the existing collection and re-marshal so the unaffected fields
// (preset, media_type, etc.) survive untouched.
if req.SourceURL != nil || req.MaxItems != nil {
if req.SourceURL != nil || req.MaxItems != nil || req.LibraryIDs != nil {
existing, err := store.GetCollection(r.Context(), collectionID)
if err != nil {
if strings.Contains(err.Error(), "not found") {
@@ -356,6 +398,18 @@ func (h *CollectionHandler) HandleUpdateCollection(w http.ResponseWriter, r *htt
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse source config")
return
}
if req.LibraryIDs != nil {
if !catalog.IsSyncableType(existing.CollectionType) {
writeError(w, http.StatusBadRequest, "bad_request", "library_ids can only be edited for imported collections")
return
}
if !validateOptionalLibraryIDs(*req.LibraryIDs, w) {
return
}
cfg.LibraryIDs = append([]int(nil), (*req.LibraryIDs)...)
emptyQuery := "{}"
input.QueryDefinition = &emptyQuery
}
if req.MaxItems != nil {
if *req.MaxItems < 0 {
writeError(w, http.StatusBadRequest, "bad_request", "max_items must be zero or positive")
@@ -805,6 +859,9 @@ func toCollectionResponse(c userstore.Collection) collectionResponse {
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if strings.TrimSpace(c.DisplayQueryDefinition) != "" {
resp.DisplayQueryDefinition = json.RawMessage(c.DisplayQueryDefinition)
}
if c.SourceConfig != "" && c.SourceConfig != "{}" {
resp.SourceConfig = json.RawMessage(c.SourceConfig)
}
@@ -0,0 +1,184 @@
package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/ebookconvert"
"github.com/Silo-Server/silo-server/internal/models"
)
// ConversionHeader tells the client how a kindle-family read was served.
// "converted" => body is EPUB; "failed" => body is the raw original and the
// client should fall back to opening it externally. Absent => no conversion
// was attempted (feature off or non-kindle format), serve/behave as before.
const ConversionHeader = "X-Silo-Ebook-Conversion"
// EbookConverter produces (and caches) a converted EPUB for a source file.
// Implemented by *ebookconvert.Cache; an interface here for testability.
type EbookConverter interface {
GetOrConvert(ctx context.Context, srcPath string, key ebookconvert.SourceKey) (string, error)
// Lookup reports a cached conversion WITHOUT converting (used by HEAD so a
// metadata probe never kicks off a full conversion): a path on hit, a
// non-nil error for a negatively-cached source, else a miss.
Lookup(key ebookconvert.SourceKey) (string, error, bool)
}
// EbookConversion bundles the converter with the admin-flag predicate. A nil
// *EbookConversion (or nil Converter) means the feature is off.
type EbookConversion struct {
Converter EbookConverter
// Enabled reports whether the admin flag is on for this request.
Enabled func(ctx context.Context) bool
}
func (c *EbookConversion) active(ctx context.Context) bool {
return c != nil && c.Converter != nil && c.Enabled != nil && c.Enabled(ctx)
}
// kindleReaderFormats are the Kindle-family formats the converter handles.
var kindleReaderFormats = map[string]bool{"mobi": true, "azw": true, "azw3": true}
func isKindleReaderFile(file *models.MediaFile) bool {
return file != nil && kindleReaderFormats[ebookReaderFormat(file.FilePath, file.Container)]
}
// serveEbook serves an ebook file, converting Kindle-family formats to EPUB
// when the feature is enabled. On conversion failure it serves the raw original
// with the ConversionHeader=failed contract so the client opens it externally.
func (h *EbookReaderHandler) serveEbook(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
if h.Conversion.active(r.Context()) && isKindleReaderFile(file) {
// Strong identity for both the conversion cache and the response ETag:
// id + size + mtime (stat) + content hash + module version.
key, statErr := ebookconvert.SourceKeyFromStat(file.ID, file.FilePath)
if statErr != nil {
return h.serveRawKindleFallback(w, r, file)
}
key.Checksum = file.FileHash
// HEAD must not trigger a (possibly minute-long, 1 GiB) conversion. Answer
// from cache only; on a miss, advertise the converted representation
// cheaply and let the GET produce the body + authoritative verdict.
if r.Method == http.MethodHead {
return h.serveKindleHead(w, r, file, key)
}
epubPath, err := h.Conversion.Converter.GetOrConvert(r.Context(), file.FilePath, key)
switch {
case err == nil:
if serveErr := serveConvertedEpub(w, r, file, epubPath, key); serveErr != nil {
// Produced but unservable (open/stat) — honor the contract, not a 500.
return h.serveRawKindleFallback(w, r, file)
}
return nil
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
// Caller went away / request cancelled — not a conversion verdict.
return err
default:
// DRM, corrupt, oversize, timed out, unavailable: fall back to the raw
// original and tell the client so it can open externally.
return h.serveRawKindleFallback(w, r, file)
}
}
// Feature off or non-kindle format: serve the raw original with its native
// MIME and no conversion header. This path deliberately omits the failed
// contract — when conversion is disabled the capability endpoint already
// reports `enabled:false`, so clients keep their external-open path and never
// expect an EPUB here. (No representation flip to guard against.)
return serveEbookInline(w, r, file)
}
// serveRawKindleFallback serves the raw original with the failed-conversion
// contract. no-store because the same URL flips raw/converted with the flag.
func (h *EbookReaderHandler) serveRawKindleFallback(w http.ResponseWriter, r *http.Request, file *models.MediaFile) error {
w.Header().Set(ConversionHeader, "failed")
w.Header().Set("Cache-Control", "no-store")
return serveEbookInline(w, r, file)
}
// serveKindleHead answers a HEAD for a kindle-family file without converting:
// - cache hit → real converted headers (and ServeContent's Content-Length);
// - neg-cached → the failed contract (raw original headers);
// - miss → converted headers advertised optimistically (no body, no
// Content-Length, no conversion). A HEAD is only a hint; the GET delivers
// the body and the authoritative DRM/failure verdict, which then populates
// the (positive or negative) cache that subsequent HEADs read.
func (h *EbookReaderHandler) serveKindleHead(w http.ResponseWriter, r *http.Request, file *models.MediaFile, key ebookconvert.SourceKey) error {
path, lookupErr, ok := h.Conversion.Converter.Lookup(key)
switch {
case ok:
if serveErr := serveConvertedEpub(w, r, file, path, key); serveErr != nil {
return h.serveRawKindleFallback(w, r, file)
}
return nil
case lookupErr != nil:
return h.serveRawKindleFallback(w, r, file)
default:
setConvertedEpubHeaders(w, file, key)
w.WriteHeader(http.StatusOK)
return nil
}
}
// setConvertedEpubHeaders writes the headers for a converted-EPUB response.
// Because the same URL can return raw or converted bytes depending on the
// flag/outcome, the ETag is derived from the exact conversion cache key (so a
// validator can never disagree with the cache) with a revalidate policy that
// keeps clients/proxies from serving a stale representation.
func setConvertedEpubHeaders(w http.ResponseWriter, file *models.MediaFile, key ebookconvert.SourceKey) {
name := strings.TrimSuffix(filepath.Base(file.FilePath), filepath.Ext(file.FilePath)) + ".epub"
w.Header().Set("Content-Type", "application/epub+zip")
w.Header().Set("Content-Disposition", inlineContentDisposition(name))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set(ConversionHeader, "converted")
w.Header().Set("ETag", fmt.Sprintf("%q", "epubconv-"+key.CacheKey()))
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
}
// serveConvertedEpub serves the cached EPUB with EPUB headers (see
// setConvertedEpubHeaders). For a HEAD, ServeContent emits the headers and
// Content-Length without a body.
func serveConvertedEpub(w http.ResponseWriter, r *http.Request, file *models.MediaFile, epubPath string, key ebookconvert.SourceKey) error {
f, err := os.Open(epubPath)
if err != nil {
return fmt.Errorf("opening converted epub: %w", err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return fmt.Errorf("stat converted epub: %w", err)
}
name := strings.TrimSuffix(filepath.Base(file.FilePath), filepath.Ext(file.FilePath)) + ".epub"
setConvertedEpubHeaders(w, file, key)
http.ServeContent(w, r, name, stat.ModTime(), f)
return nil
}
// ebookConversionCapability is the client-facing advertisement for the
// Kindle->EPUB feature. Clients flip mobi/azw/azw3 to in-app reading only when
// Enabled is true; otherwise they keep the external-open path.
type ebookConversionCapability struct {
Enabled bool `json:"enabled"`
SourceFormats []string `json:"source_formats"`
ServedFormat string `json:"served_format"`
Header string `json:"header"`
HeaderOnFailed string `json:"header_failed_value"`
}
// HandleConversionCapability reports whether Kindle->EPUB conversion is active
// (admin flag on AND the converter initialized). GET /api/v1/ebooks/capability.
func (h *EbookReaderHandler) HandleConversionCapability(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, ebookConversionCapability{
Enabled: h.Conversion.active(r.Context()),
SourceFormats: []string{"mobi", "azw", "azw3"},
ServedFormat: "epub",
Header: ConversionHeader,
HeaderOnFailed: "failed",
})
}
@@ -0,0 +1,271 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/ebookconvert"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeConverter struct {
epubPath string
err error
calls int
lookupPath string
lookupErr error
lookupOK bool
}
func (f *fakeConverter) GetOrConvert(_ context.Context, _ string, _ ebookconvert.SourceKey) (string, error) {
f.calls++
return f.epubPath, f.err
}
func (f *fakeConverter) Lookup(_ ebookconvert.SourceKey) (string, error, bool) {
return f.lookupPath, f.lookupErr, f.lookupOK
}
func writeTemp(t *testing.T, name, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func serveAndRecord(t *testing.T, h *EbookReaderHandler, file *models.MediaFile) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/read", nil)
if err := h.serveEbook(rec, req, file); err != nil {
t.Fatalf("serveEbook: %v", err)
}
return rec
}
func enabledConversion(conv EbookConverter, on bool) *EbookConversion {
return &EbookConversion{Converter: conv, Enabled: func(context.Context) bool { return on }}
}
func TestServeEbook_ConvertsKindleWhenEnabled(t *testing.T) {
epub := writeTemp(t, "out.epub", "PK\x03\x04 fake-epub-bytes")
src := writeTemp(t, "book.azw3", "raw-azw3-source")
conv := &fakeConverter{epubPath: epub}
h := &EbookReaderHandler{Conversion: enabledConversion(conv, true)}
file := &models.MediaFile{ID: 1, FilePath: src, Container: "azw3", FileHash: "abc123"}
rec := serveAndRecord(t, h, file)
if got := rec.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("Content-Type = %q, want application/epub+zip", got)
}
if got := rec.Header().Get(ConversionHeader); got != "converted" {
t.Fatalf("%s = %q, want converted", ConversionHeader, got)
}
if rec.Header().Get("ETag") == "" {
t.Fatal("expected an ETag on converted response")
}
if rec.Body.String() != "PK\x03\x04 fake-epub-bytes" {
t.Fatalf("body = %q, want the epub bytes", rec.Body.String())
}
if conv.calls != 1 {
t.Fatalf("converter called %d times, want 1", conv.calls)
}
}
func TestServeEbook_ConversionFailureFallsBackToRawWithHeader(t *testing.T) {
raw := writeTemp(t, "book.mobi", "raw-mobi-bytes")
conv := &fakeConverter{err: ebookconvert.ErrDRMProtected}
h := &EbookReaderHandler{Conversion: enabledConversion(conv, true)}
file := &models.MediaFile{ID: 2, FilePath: raw, Container: "mobi"}
rec := serveAndRecord(t, h, file)
if got := rec.Header().Get(ConversionHeader); got != "failed" {
t.Fatalf("%s = %q, want failed", ConversionHeader, got)
}
if got := rec.Header().Get("Content-Type"); got != "application/x-mobipocket-ebook" {
t.Fatalf("Content-Type = %q, want raw mobi mime", got)
}
if rec.Body.String() != "raw-mobi-bytes" {
t.Fatalf("body = %q, want raw mobi bytes", rec.Body.String())
}
}
func TestServeEbook_DisabledServesRaw(t *testing.T) {
raw := writeTemp(t, "book.mobi", "raw-mobi-bytes")
conv := &fakeConverter{epubPath: "should-not-be-used"}
h := &EbookReaderHandler{Conversion: enabledConversion(conv, false)}
file := &models.MediaFile{ID: 3, FilePath: raw, Container: "mobi"}
rec := serveAndRecord(t, h, file)
if got := rec.Header().Get(ConversionHeader); got != "" {
t.Fatalf("%s = %q, want empty (no conversion attempted)", ConversionHeader, got)
}
if got := rec.Header().Get("Content-Type"); got != "application/x-mobipocket-ebook" {
t.Fatalf("Content-Type = %q, want raw mobi mime", got)
}
if conv.calls != 0 {
t.Fatalf("converter called %d times while disabled, want 0", conv.calls)
}
}
func TestServeEbook_NonKindleUntouched(t *testing.T) {
raw := writeTemp(t, "book.epub", "epub-bytes")
conv := &fakeConverter{epubPath: "should-not-be-used"}
h := &EbookReaderHandler{Conversion: enabledConversion(conv, true)}
file := &models.MediaFile{ID: 4, FilePath: raw, Container: "epub"}
rec := serveAndRecord(t, h, file)
if rec.Header().Get(ConversionHeader) != "" {
t.Fatal("non-kindle file must not trigger conversion")
}
if conv.calls != 0 {
t.Fatalf("converter called %d times for epub, want 0", conv.calls)
}
if got := rec.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("Content-Type = %q, want epub", got)
}
}
func TestServeEbook_NilConversionServesRaw(t *testing.T) {
raw := writeTemp(t, "book.mobi", "raw")
h := &EbookReaderHandler{} // Conversion nil
file := &models.MediaFile{ID: 5, FilePath: raw, Container: "mobi"}
rec := serveAndRecord(t, h, file)
if rec.Header().Get(ConversionHeader) != "" {
t.Fatal("nil Conversion must not set the conversion header")
}
}
func TestHandleConversionCapability(t *testing.T) {
for _, tc := range []struct {
name string
conv *EbookConversion
want bool
}{
{"enabled", enabledConversion(&fakeConverter{}, true), true},
{"flag-off", enabledConversion(&fakeConverter{}, false), false},
{"not-wired", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
h := &EbookReaderHandler{Conversion: tc.conv}
rec := httptest.NewRecorder()
h.HandleConversionCapability(rec, httptest.NewRequest(http.MethodGet, "/ebooks/capability", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
wantField := `"enabled":false`
if tc.want {
wantField = `"enabled":true`
}
if !strings.Contains(body, wantField) {
t.Fatalf("body %q missing %s", body, wantField)
}
if !strings.Contains(body, `"served_format":"epub"`) || !strings.Contains(body, "mobi") {
t.Fatalf("capability body incomplete: %s", body)
}
})
}
}
// A HEAD on a cache miss must NOT trigger a (possibly minute-long) conversion;
// it advertises the converted representation cheaply and lets the GET do the work.
func TestServeEbook_HeadDoesNotConvertOnMiss(t *testing.T) {
src := writeTemp(t, "book.azw3", "raw-azw3-source")
conv := &fakeConverter{lookupOK: false} // cache miss
h := &EbookReaderHandler{Conversion: enabledConversion(conv, true)}
file := &models.MediaFile{ID: 1, FilePath: src, Container: "azw3"}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodHead, "/read", nil)
if err := h.serveEbook(rec, req, file); err != nil {
t.Fatalf("serveEbook HEAD: %v", err)
}
if conv.calls != 0 {
t.Fatalf("HEAD on a cache miss triggered %d conversions, want 0", conv.calls)
}
if got := rec.Header().Get(ConversionHeader); got != "converted" {
t.Fatalf("%s = %q, want converted (advertised on HEAD miss)", ConversionHeader, got)
}
if got := rec.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("Content-Type = %q, want application/epub+zip", got)
}
if rec.Header().Get("ETag") == "" {
t.Fatal("HEAD miss should still carry the conversion ETag")
}
}
// A HEAD that hits the cache serves the converted headers from the cached file,
// without converting.
func TestServeEbook_HeadServesCachedConverted(t *testing.T) {
epub := writeTemp(t, "out.epub", "PK\x03\x04 cached-epub")
src := writeTemp(t, "book.mobi", "raw-mobi-source")
conv := &fakeConverter{lookupPath: epub, lookupOK: true}
h := &EbookReaderHandler{Conversion: enabledConversion(conv, true)}
file := &models.MediaFile{ID: 2, FilePath: src, Container: "mobi"}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodHead, "/read", nil)
if err := h.serveEbook(rec, req, file); err != nil {
t.Fatalf("serveEbook HEAD: %v", err)
}
if conv.calls != 0 {
t.Fatalf("HEAD on a cache hit triggered %d conversions, want 0", conv.calls)
}
if got := rec.Header().Get(ConversionHeader); got != "converted" {
t.Fatalf("%s = %q, want converted", ConversionHeader, got)
}
if got := rec.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("Content-Type = %q, want application/epub+zip", got)
}
}
// A HEAD on a negatively-cached (DRM/failed) source reports the failed contract
// without converting.
func TestServeEbook_HeadDRMReturnsFailed(t *testing.T) {
src := writeTemp(t, "book.mobi", "raw-mobi-source")
conv := &fakeConverter{lookupErr: ebookconvert.ErrDRMProtected}
h := &EbookReaderHandler{Conversion: enabledConversion(conv, true)}
file := &models.MediaFile{ID: 3, FilePath: src, Container: "mobi"}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodHead, "/read", nil)
if err := h.serveEbook(rec, req, file); err != nil {
t.Fatalf("serveEbook HEAD: %v", err)
}
if conv.calls != 0 {
t.Fatalf("HEAD on a negatively-cached source triggered %d conversions, want 0", conv.calls)
}
if got := rec.Header().Get(ConversionHeader); got != "failed" {
t.Fatalf("%s = %q, want failed", ConversionHeader, got)
}
}
func TestServeEbook_ContextCancelPropagates(t *testing.T) {
src := writeTemp(t, "book.mobi", "raw-mobi-source")
conv := &fakeConverter{err: context.Canceled}
h := &EbookReaderHandler{Conversion: enabledConversion(conv, true)}
file := &models.MediaFile{ID: 6, FilePath: src, Container: "mobi"}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/read", nil)
err := h.serveEbook(rec, req, file)
if err == nil {
t.Fatal("expected context cancellation to propagate, not fall back")
}
if rec.Header().Get(ConversionHeader) == "failed" {
t.Fatal("cancellation must not be reported as a conversion failure")
}
}
+17 -4
View File
@@ -96,6 +96,9 @@ type EbookReaderHandler struct {
ProgressStore EbookReaderProgressStore
ConfigStore EbookReaderConfigStore
AnnotationStore EbookReaderAnnotationStore
// Conversion, when set and enabled, transparently converts Kindle-family
// files to EPUB on read. Nil means the feature is off.
Conversion *EbookConversion
}
func NewEbookReaderHandler(authorizer *MediaFileAuthorizer) *EbookReaderHandler {
@@ -130,7 +133,7 @@ func (h *EbookReaderHandler) HandleReadFile(w http.ResponseWriter, r *http.Reque
return
}
if err := serveEbookInline(w, r, file); err != nil {
if err := h.serveEbook(w, r, file); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
@@ -1046,15 +1049,25 @@ func (s *PGEbookReaderProgressStore) Upsert(ctx context.Context, progress EbookR
if s == nil || s.pool == nil {
return fmt.Errorf("ebook reader progress store is not configured")
}
if _, err := s.pool.Exec(ctx, `
// A routine autosave (e.g. reopening a finished book) must not silently drop
// a "finished" item below the threshold and un-mark it read; once finished,
// progress only moves on an explicit unread (which deletes the row). Below
// the threshold, progress tracks freely. Manga chapter ✓ marks ride on this
// same row, so the guard protects them too.
query := fmt.Sprintf(`
INSERT INTO ebook_reader_progress
(user_id, profile_id, content_id, file_id, location, progress, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, profile_id, content_id) DO UPDATE SET
file_id = EXCLUDED.file_id,
location = EXCLUDED.location,
progress = EXCLUDED.progress,
updated_at = EXCLUDED.updated_at`,
progress = CASE
WHEN ebook_reader_progress.progress >= %[1]v AND EXCLUDED.progress < %[1]v
THEN ebook_reader_progress.progress
ELSE EXCLUDED.progress
END,
updated_at = EXCLUDED.updated_at`, models.EbookFinishedProgressThreshold)
if _, err := s.pool.Exec(ctx, query,
progress.UserID,
progress.ProfileID,
progress.ContentID,
+79
View File
@@ -0,0 +1,79 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"net/mail"
"time"
silomail "github.com/Silo-Server/silo-server/internal/mail"
)
// EmailHandler exposes admin operations for the shared outbound email
// facility (internal/mail). Feature-specific email content lives with the
// features; this handler only owns configuration verification.
type EmailHandler struct {
sender silomail.Sender
}
// NewEmailHandler creates an EmailHandler.
func NewEmailHandler(sender silomail.Sender) *EmailHandler {
return &EmailHandler{sender: sender}
}
type emailTestRequest struct {
To string `json:"to"`
}
type emailTestResponse struct {
OK bool `json:"ok"`
DurationMS int64 `json:"duration_ms"`
Message string `json:"message,omitempty"`
}
// HandleTest handles POST /admin/email/test: synchronously sends a test
// message so admins can verify SMTP settings before any feature depends on
// them.
func (h *EmailHandler) HandleTest(w http.ResponseWriter, r *http.Request) {
if h == nil || h.sender == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Email is not available")
return
}
var req emailTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if _, err := mail.ParseAddress(req.To); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "A valid recipient address is required")
return
}
started := time.Now()
err := h.sender.Send(r.Context(), silomail.Message{
To: []string{req.To},
Subject: "Silo test email",
TextBody: "This is a test email from your Silo server.\n\n" +
"If you received it, outbound email is configured correctly.",
HTMLBody: silomail.RenderLayout(silomail.LayoutOptions{
Preheader: "Outbound email from your Silo server is configured correctly.",
Title: "Outbound email is working",
BodyHTML: silomail.EmailParagraph("This is a test email from your Silo server.") +
silomail.EmailParagraph("If you're reading it, the SMTP settings are correct and "+
"notification emails will look like this one."),
}),
})
response := emailTestResponse{
OK: err == nil,
DurationMS: time.Since(started).Milliseconds(),
}
switch {
case err == nil:
case errors.Is(err, silomail.ErrNotConfigured):
response.Message = "Email is not configured. Set the SMTP host, from address, and enable email first."
default:
response.Message = err.Error()
}
writeJSON(w, http.StatusOK, response)
}
+91 -14
View File
@@ -12,6 +12,7 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/gorilla/websocket"
@@ -27,8 +28,10 @@ type taskInfoLister interface {
ListTasks(includeHidden bool) []taskmanager.TaskInfo
}
const maxRealtimeScanSnapshotRuns = 500
type activeScanLister interface {
ListActive(ctx context.Context) ([]evt.ScanRun, error)
ListActiveSnapshot(ctx context.Context, limit int) ([]evt.ScanRun, error)
}
type EventsHandler struct {
@@ -39,6 +42,15 @@ type EventsHandler struct {
scans *evt.ScanRegistry
persistedScans activeScanLister
historyImports historyImportActiveLister
notifications *notifications.System
}
// SetNotificationsSystem wires the user-notification system: websocket
// handshake tickets and the notifications channel snapshot.
func (h *EventsHandler) SetNotificationsSystem(system *notifications.System) {
if h != nil {
h.notifications = system
}
}
func NewEventsHandler(
@@ -73,6 +85,27 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
}
// Browsers cannot set custom headers on websocket handshakes, so profile
// identity arrives as a short-lived single-use ticket minted via
// POST /events/ws-ticket. A connection without a ticket stays unbound and
// simply cannot subscribe to the profile-scoped notifications channel.
boundProfileID := ""
if ticket := r.URL.Query().Get("ticket"); ticket != "" && h.notifications != nil {
ticketUserID, ticketProfileID, ok := h.notifications.Tickets.Consume(r.Context(), ticket)
if ok && ticketUserID == claims.UserID {
boundProfileID = ticketProfileID
} else {
// Expired, reused, consumed on a different node, or minted for
// another user: degrade to an unbound connection instead of
// failing the handshake. The binding grants nothing on its own —
// the client retries it when its notifications subscription is
// rejected — whereas a hard 403 would take down every realtime
// channel over a notifications-only concern.
slog.Warn("events: websocket ticket rejected; connection unbound",
"user_id", claims.UserID)
}
}
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -142,7 +175,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
)
return
case data := <-readMessages:
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, boundProfileID, data, allowedChannels)
if !ok {
return
}
@@ -164,10 +197,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if _, subscribed := subscriptions[env.Channel]; !subscribed {
continue
}
if !allowsEventForClaims(claims, env) {
if !allowsEventForClaims(claims, boundProfileID, env) {
continue
}
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
if err := h.writeEventFrame(conn, r, claims, boundProfileID, env); err != nil {
return
}
}
@@ -178,6 +211,7 @@ func (h *EventsHandler) handleEventsClientMessage(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
boundProfileID string,
data []byte,
allowed []evt.EventChannel,
) (map[evt.EventChannel]struct{}, bool, bool) {
@@ -245,6 +279,16 @@ func (h *EventsHandler) handleEventsClientMessage(
})
continue
}
// The notifications channel is profile-scoped: it requires a
// connection bound to a profile via a websocket ticket.
if channel == evt.ChannelNotifications && boundProfileID == "" {
rejected = append(rejected, evt.EventsRejectedChannel{
Channel: channel,
Code: "profile_required",
Message: "A profile-bound websocket ticket is required",
})
continue
}
if _, seen := nextSubs[channel]; seen {
continue
}
@@ -262,7 +306,7 @@ func (h *EventsHandler) handleEventsClientMessage(
}
for _, channel := range accepted {
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
if err := h.writeSnapshotFrame(conn, r, claims, boundProfileID, channel); err != nil {
return nil, false, false
}
}
@@ -275,6 +319,7 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelUserState,
evt.ChannelNotifications,
}
if role == "admin" {
channels = append(channels,
@@ -282,18 +327,26 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
evt.ChannelSessions,
evt.ChannelTasks,
evt.ChannelScans,
evt.ChannelSettings,
)
}
return channels
}
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
func allowsEventForClaims(claims *auth.Claims, boundProfileID string, env evt.Envelope) bool {
if claims == nil {
return false
}
if env.AdminOnly && claims.Role != "admin" {
return false
}
if env.Channel == evt.ChannelNotifications {
// Notifications are personal: even admins only receive their own
// profile's deliveries, and only on a profile-bound connection.
return boundProfileID != "" &&
env.UserID == claims.UserID &&
env.ProfileID == boundProfileID
}
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
return false
}
@@ -314,11 +367,24 @@ func marshalJSON(value any) json.RawMessage {
func (h *EventsHandler) snapshotForChannel(
r *http.Request,
claims *auth.Claims,
boundProfileID string,
channel evt.EventChannel,
) (json.RawMessage, error) {
switch channel {
case evt.ChannelCatalog, evt.ChannelUserState:
return json.RawMessage("null"), nil
case evt.ChannelNotifications:
// Recent unread deliveries for the bound profile so reconnecting
// clients hydrate without a separate REST call. Same row shape as the
// inbox list API.
if h == nil || h.notifications == nil || boundProfileID == "" {
return json.RawMessage("[]"), nil
}
rows, err := h.notifications.Deliveries.RecentUnread(r.Context(), boundProfileID, 25)
if err != nil {
return nil, err
}
return marshalJSON(h.notifications.PayloadsForRows(r.Context(), rows)), nil
case evt.ChannelJobs:
if h == nil || h.jobs == nil || h.jobs.repo == nil {
return json.RawMessage("[]"), nil
@@ -350,16 +416,21 @@ func (h *EventsHandler) snapshotForChannel(
if h == nil {
return json.RawMessage("[]"), nil
}
runs := make([]evt.ScanRun, 0)
if h.persistedScans != nil {
persisted, err := h.persistedScans.ListActive(r.Context())
runs := make([]evt.ScanRun, 0, maxRealtimeScanSnapshotRuns)
remaining := maxRealtimeScanSnapshotRuns
if h.persistedScans != nil && remaining > 0 {
persisted, err := h.persistedScans.ListActiveSnapshot(r.Context(), remaining)
if err != nil {
return nil, err
}
if len(persisted) > remaining {
persisted = persisted[:remaining]
}
runs = append(runs, persisted...)
remaining -= len(persisted)
}
if h.scans != nil {
runs = append(runs, h.scans.ListActive()...)
if h.scans != nil && remaining > 0 {
runs = append(runs, h.scans.ListActiveLimit(remaining)...)
}
return marshalJSON(runs), nil
case evt.ChannelHistoryImport:
@@ -387,9 +458,10 @@ func (h *EventsHandler) writeSnapshotFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
boundProfileID string,
channel evt.EventChannel,
) error {
data, err := h.snapshotForChannel(r, claims, channel)
data, err := h.snapshotForChannel(r, claims, boundProfileID, channel)
if err != nil {
slog.Error(
"events: failed to build initial snapshot",
@@ -415,14 +487,19 @@ func (h *EventsHandler) writeEventFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
boundProfileID string,
env evt.Envelope,
) error {
data := env.Data
if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") {
snapshot, err := h.snapshotForChannel(r, claims, env.Channel)
snapshot, err := h.snapshotForChannel(r, claims, boundProfileID, env.Channel)
if err != nil {
// Drop the frame but keep the stream open (same contract as
// writeSnapshotFrame): durable state covers the gap on the next
// event or reconnect, while closing the socket tears down every
// channel the client subscribed to.
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
return err
return nil
}
data = snapshot
}
+79
View File
@@ -0,0 +1,79 @@
package handlers
import (
"context"
"encoding/json"
"net/http/httptest"
"strconv"
"strings"
"testing"
evt "github.com/Silo-Server/silo-server/internal/events"
)
type fakeActiveScanSnapshotLister struct {
runs []evt.ScanRun
limit int
}
func (f *fakeActiveScanSnapshotLister) ListActiveSnapshot(_ context.Context, limit int) ([]evt.ScanRun, error) {
f.limit = limit
if limit > 0 && len(f.runs) > limit {
return f.runs[:limit], nil
}
return f.runs, nil
}
func TestScanSnapshotCapsActiveRuns(t *testing.T) {
persisted := &fakeActiveScanSnapshotLister{runs: makeScanRuns("persisted-", 300)}
registry := evt.NewScanRegistry()
for _, run := range makeScanRuns("registry-", 250) {
registry.Upsert(run)
}
registry.Upsert(evt.ScanRun{ID: "registry-completed", Status: "completed"})
handler := &EventsHandler{
persistedScans: persisted,
scans: registry,
}
raw, err := handler.snapshotForChannel(httptest.NewRequest("GET", "/", nil), nil, "", evt.ChannelScans)
if err != nil {
t.Fatalf("snapshotForChannel: %v", err)
}
var runs []evt.ScanRun
if err := json.Unmarshal(raw, &runs); err != nil {
t.Fatalf("decode snapshot: %v", err)
}
if len(runs) != maxRealtimeScanSnapshotRuns {
t.Fatalf("snapshot length = %d, want %d", len(runs), maxRealtimeScanSnapshotRuns)
}
if persisted.limit != maxRealtimeScanSnapshotRuns {
t.Fatalf("persisted snapshot limit = %d, want %d", persisted.limit, maxRealtimeScanSnapshotRuns)
}
registryRuns := 0
for _, run := range runs {
if strings.HasPrefix(run.ID, "registry-") {
registryRuns++
}
}
if registryRuns != maxRealtimeScanSnapshotRuns-len(persisted.runs) {
t.Fatalf("registry runs in snapshot = %d, want %d", registryRuns, maxRealtimeScanSnapshotRuns-len(persisted.runs))
}
}
func makeScanRuns(prefix string, count int) []evt.ScanRun {
runs := make([]evt.ScanRun, 0, count)
for i := 0; i < count; i++ {
runs = append(runs, evt.ScanRun{
ID: prefix + strconv.Itoa(i),
LibraryID: 1,
Mode: "file",
Path: "/library/file-" + strconv.Itoa(i) + ".mkv",
Trigger: "autoscan",
Status: "accepted",
})
}
return runs
}
+272 -85
View File
@@ -38,6 +38,11 @@ type batchEpisodeFileProvider interface {
ListByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error)
}
type overlayFileProvider interface {
ListOverlayFilesByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error)
ListOverlayFilesByEpisodeIDs(ctx context.Context, episodeIDs []string) (map[string][]*models.MediaFile, error)
}
type MetadataRefreshRequester interface {
RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error
}
@@ -116,6 +121,13 @@ func (h *ItemsHandler) SetMetadataRefreshRequester(requester MetadataRefreshRequ
h.metadataRefreshRequester = requester
}
func (h *ItemsHandler) SetCatalogSearchProvider(provider catalog.CatalogSearchProvider) {
if h == nil || h.catalogResolver == nil || provider == nil {
return
}
h.catalogResolver.WithSearchProvider(provider)
}
func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDispatcher) {
h.localWatchDispatcher = dispatcher
}
@@ -195,37 +207,42 @@ func (h *ItemsHandler) requestStaleMetadataRefresh(ctx context.Context, targetTy
// itemListResponse is the shape of a single item in browse/search list responses.
type itemListResponse struct {
ContentID string `json:"content_id"`
Type string `json:"type"`
Title string `json:"title"`
SeriesTitle string `json:"series_title,omitempty"`
SeasonNumber *int `json:"season_number,omitempty"`
EpisodeNumber *int `json:"episode_number,omitempty"`
Year int `json:"year,omitempty"`
Runtime int `json:"runtime,omitempty"`
Genres []string `json:"genres"`
Keywords []string `json:"keywords"`
Studios []string `json:"studios,omitempty"`
Networks []string `json:"networks,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
Status string `json:"status"`
ShowStatus string `json:"show_status,omitempty"`
RatingIMDB *float64 `json:"rating_imdb,omitempty"`
RatingTMDB *float64 `json:"rating_tmdb,omitempty"`
RatingRTCritic *int `json:"rating_rt_critic,omitempty"`
RatingRTAudience *int `json:"rating_rt_audience,omitempty"`
OriginalLanguage string `json:"original_language,omitempty"`
Overview string `json:"overview,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
BackdropURL string `json:"backdrop_url,omitempty"`
BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"`
ReleaseDate *string `json:"release_date,omitempty"`
LastAirDate *string `json:"last_air_date,omitempty"`
AddedAt *time.Time `json:"added_at,omitempty"`
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"`
UserState *itemUserStateResponse `json:"user_state,omitempty"`
ContentID string `json:"content_id"`
Type string `json:"type"`
Title string `json:"title"`
SeriesTitle string `json:"series_title,omitempty"`
SeasonNumber *int `json:"season_number,omitempty"`
EpisodeNumber *int `json:"episode_number,omitempty"`
Year int `json:"year,omitempty"`
Runtime int `json:"runtime,omitempty"`
Genres []string `json:"genres"`
Keywords []string `json:"keywords"`
Studios []string `json:"studios,omitempty"`
Networks []string `json:"networks,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
Status string `json:"status"`
ShowStatus string `json:"show_status,omitempty"`
RatingIMDB *float64 `json:"rating_imdb,omitempty"`
RatingTMDB *float64 `json:"rating_tmdb,omitempty"`
RatingRTCritic *int `json:"rating_rt_critic,omitempty"`
RatingRTAudience *int `json:"rating_rt_audience,omitempty"`
OriginalLanguage string `json:"original_language,omitempty"`
Overview string `json:"overview,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
BackdropURL string `json:"backdrop_url,omitempty"`
BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"`
ReleaseDate *string `json:"release_date,omitempty"`
LastAirDate *string `json:"last_air_date,omitempty"`
AddedAt *time.Time `json:"added_at,omitempty"`
MangaChapterCount *int `json:"manga_chapter_count,omitempty"`
MangaVolumeCount *int `json:"manga_volume_count,omitempty"`
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
SortMetrics *sortMetricsResponse `json:"sort_metrics,omitempty"`
UserState *itemUserStateResponse `json:"user_state,omitempty"`
WorkID string `json:"work_id,omitempty"`
WorkTitle string `json:"work_title,omitempty"`
WorkFormats []catalog.WorkFormatSummary `json:"work_formats,omitempty"`
}
type sortMetricsResponse struct {
@@ -241,11 +258,17 @@ type sortMetricsResponse struct {
SeriesName string `json:"series_name,omitempty"`
}
type itemListImageURLs struct {
posterURL string
backdropURL string
}
// browseResponse is the paginated response for the /items endpoint.
type browseResponse struct {
Total int `json:"total"`
HasMore bool `json:"has_more"`
Items []itemListResponse `json:"items"`
Total int `json:"total"`
TotalExact bool `json:"total_exact"`
HasMore bool `json:"has_more"`
Items []itemListResponse `json:"items"`
}
type itemFiltersResponse struct {
@@ -387,7 +410,7 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
}
}
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" || detail.Type == "audiobook" {
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
}
@@ -578,9 +601,10 @@ func (h *ItemsHandler) writeCatalogBrowseResponse(w http.ResponseWriter, r *http
}
writeJSON(w, http.StatusOK, browseResponse{
Total: result.Total,
HasMore: result.HasMore,
Items: items,
Total: result.Total,
TotalExact: result.TotalExact,
HasMore: result.HasMore,
Items: items,
})
return true
}
@@ -628,6 +652,13 @@ func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *mode
item = localized
}
}
resp := itemListResponseShell(item, overlaySummary, userState)
resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card")
resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card")
return resp
}
func itemListResponseShell(item *models.MediaItem, overlaySummary *models.OverlaySummary, userState *itemUserStateResponse) itemListResponse {
resp := itemListResponse{
ContentID: item.ContentID,
Type: item.Type,
@@ -654,10 +685,10 @@ func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *mode
}
resp.AddedAt = item.AddedAt
resp.MangaChapterCount = item.MangaChapterCount
resp.MangaVolumeCount = item.MangaVolumeCount
resp.ReleaseDate = item.ReleaseDate
resp.LastAirDate = item.LastAirDate
resp.PosterURL = h.presignURL(r, cardThumbnailPath(item.PosterPath), "card")
resp.BackdropURL = h.presignURL(r, cardThumbnailPath(item.BackdropPath), "card")
if resp.Genres == nil {
resp.Genres = []string{}
}
@@ -668,6 +699,67 @@ func (h *ItemsHandler) toItemListResponseWithOverlay(r *http.Request, item *mode
return resp
}
func (h *ItemsHandler) localizeItemListModels(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) []*models.MediaItem {
if h == nil || h.detailSvc == nil || len(items) == 0 {
return items
}
localized, err := h.detailSvc.LocalizeItemModels(ctx, items, filter)
if err != nil || len(localized) != len(items) {
return items
}
return localized
}
func (h *ItemsHandler) itemListCardImageURLs(ctx context.Context, items []*models.MediaItem) map[string]itemListImageURLs {
urls := make(map[string]itemListImageURLs, len(items))
if h == nil || h.detailSvc == nil || len(items) == 0 {
return urls
}
type pendingImages struct {
contentID string
posterPath string
backdropPath string
}
pending := make([]pendingImages, 0, len(items))
paths := make([]string, 0, len(items)*2)
seenPaths := make(map[string]struct{}, len(items)*2)
addPath := func(path string) {
if path == "" || path == "-" {
return
}
if _, ok := seenPaths[path]; ok {
return
}
seenPaths[path] = struct{}{}
paths = append(paths, path)
}
for _, item := range items {
if item == nil || item.ContentID == "" {
continue
}
images := pendingImages{
contentID: item.ContentID,
posterPath: cardThumbnailPath(item.PosterPath),
backdropPath: cardThumbnailPath(item.BackdropPath),
}
pending = append(pending, images)
addPath(images.posterPath)
addPath(images.backdropPath)
}
resolved := h.detailSvc.PresignURLsWithExpiry(ctx, paths, "card")
for _, images := range pending {
urls[images.contentID] = itemListImageURLs{
posterURL: resolved[images.posterPath].URL,
backdropURL: resolved[images.backdropPath].URL,
}
}
return urls
}
func (h *ItemsHandler) listEpisodeBrowseMetadata(
ctx context.Context,
items []*models.MediaItem,
@@ -954,7 +1046,7 @@ func (h *ItemsHandler) listOverlaySummaries(ctx context.Context, items []*models
return summaries
}
groupedFiles := h.listBrowseItemFiles(ctx, items, filter)
groupedFiles := h.listBrowseItemOverlayFiles(ctx, items, filter)
for contentID, files := range groupedFiles {
if summary := overlays.BuildSummary(files); summary != nil {
summaries[contentID] = summary
@@ -1256,21 +1348,7 @@ func (h *ItemsHandler) listBrowseItemFiles(ctx context.Context, items []*models.
contentIDs := make([]string, 0, len(items))
episodeIDs := make([]string, 0)
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if item == nil || item.ContentID == "" {
continue
}
if _, ok := seen[item.ContentID]; ok {
continue
}
seen[item.ContentID] = struct{}{}
if item.Type == "episode" {
episodeIDs = append(episodeIDs, item.ContentID)
} else {
contentIDs = append(contentIDs, item.ContentID)
}
}
collectBrowseFileIDs(items, &contentIDs, &episodeIDs)
if len(contentIDs) > 0 {
contentFiles, err := h.fileRepo.ListByContentIDs(ctx, contentIDs)
@@ -1295,6 +1373,60 @@ func (h *ItemsHandler) listBrowseItemFiles(ctx context.Context, items []*models.
return grouped
}
func (h *ItemsHandler) listBrowseItemOverlayFiles(ctx context.Context, items []*models.MediaItem, filter catalog.AccessFilter) map[string][]*models.MediaFile {
if h.fileRepo == nil || len(items) == 0 {
return map[string][]*models.MediaFile{}
}
overlayProvider, ok := h.fileRepo.(overlayFileProvider)
if !ok {
return h.listBrowseItemFiles(ctx, items, filter)
}
contentIDs := make([]string, 0, len(items))
episodeIDs := make([]string, 0)
collectBrowseFileIDs(items, &contentIDs, &episodeIDs)
grouped := make(map[string][]*models.MediaFile, len(items))
if len(contentIDs) > 0 {
contentFiles, err := overlayProvider.ListOverlayFilesByContentIDs(ctx, contentIDs)
if err != nil {
return h.listBrowseItemFiles(ctx, items, filter)
}
for contentID, files := range contentFiles {
grouped[contentID] = catalog.FilterMediaFilesByAccess(files, filter)
}
}
if len(episodeIDs) > 0 {
episodeFiles, err := overlayProvider.ListOverlayFilesByEpisodeIDs(ctx, episodeIDs)
if err != nil {
return h.listBrowseItemFiles(ctx, items, filter)
}
for episodeID, files := range episodeFiles {
grouped[episodeID] = catalog.FilterMediaFilesByAccess(files, filter)
}
}
return grouped
}
func collectBrowseFileIDs(items []*models.MediaItem, contentIDs *[]string, episodeIDs *[]string) {
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if item == nil || item.ContentID == "" {
continue
}
if _, ok := seen[item.ContentID]; ok {
continue
}
seen[item.ContentID] = struct{}{}
if item.Type == "episode" {
*episodeIDs = append(*episodeIDs, item.ContentID)
} else {
*contentIDs = append(*contentIDs, item.ContentID)
}
}
}
func firstNonBlankPtr(values ...*string) *string {
for _, value := range values {
if value == nil || strings.TrimSpace(*value) == "" {
@@ -1318,12 +1450,22 @@ func maxFileBitrate(files []*models.MediaFile) int {
// toSeasonResponse converts a Season model to an API response.
func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *models.Season) seasonResponse {
episodes, _ := h.episodeRepo.ListBySeason(r.Context(), seriesID, s.SeasonNumber)
return h.toSeasonResponseFromEpisodes(r, seriesID, s, episodes, h.getAggregateUserData(r, episodes))
}
func (h *ItemsHandler) toSeasonResponseFromEpisodes(
r *http.Request,
seriesID string,
s *models.Season,
episodes []*models.Episode,
userData *catalog.SeasonUserData,
) seasonResponse {
if h.detailSvc != nil {
if localized, err := h.detailSvc.LocalizeSeasonModel(r.Context(), s, h.accessFilter(r)); err == nil && localized != nil {
s = localized
}
}
episodes, _ := h.episodeRepo.ListBySeason(r.Context(), seriesID, s.SeasonNumber)
resp := seasonResponse{
ContentID: s.ContentID,
@@ -1338,7 +1480,7 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
resp.AirDate = s.AirDate.Format("2006-01-02")
}
resp.PosterURL = h.presignURL(r, featuredPosterPath(s.PosterPath), "featured")
resp.UserData = h.getAggregateUserData(r, episodes)
resp.UserData = userData
return resp
}
@@ -1353,12 +1495,11 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemTy
return nil
}
progress, err := store.GetProgress(r.Context(), profileID, contentID)
if err != nil || progress == nil {
progress, err := userstore.GetProgressWithCompletedHistory(r.Context(), store, profileID, contentID)
if err != nil {
return nil
}
return leafUserDataFromProgress(*progress)
return leafUserDataFromProgress(progress)
}
// listLeafUserData batch-fetches watch progress for the given content IDs in a
@@ -1369,19 +1510,23 @@ func (h *ItemsHandler) listLeafUserData(r *http.Request, contentIDs []string) ma
return nil
}
progressMap, err := store.ListProgressByMediaItems(r.Context(), profileID, contentIDs)
progressMap, err := userstore.ListProgressWithCompletedHistory(r.Context(), store, profileID, contentIDs)
if err != nil {
return nil
}
result := make(map[string]*catalog.SeasonUserData, len(progressMap))
for contentID, progress := range progressMap {
result[contentID] = leafUserDataFromProgress(progress)
progressCopy := progress
result[contentID] = leafUserDataFromProgress(&progressCopy)
}
return result
}
func leafUserDataFromProgress(progress userstore.WatchProgress) *catalog.SeasonUserData {
func leafUserDataFromProgress(progress *userstore.WatchProgress) *catalog.SeasonUserData {
if progress == nil {
return nil
}
return &catalog.SeasonUserData{
PositionSeconds: progress.PositionSeconds,
DurationSeconds: progress.DurationSeconds,
@@ -1446,29 +1591,71 @@ func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.
return nil
}
var watchedCount int
var inProgressCount int
for _, ep := range episodes {
progress, progressErr := store.GetProgress(r.Context(), profileID, ep.ContentID)
if progressErr != nil || progress == nil {
continue
}
if progress.Completed {
watchedCount++
continue
}
if progress.PositionSeconds > 0 {
inProgressCount++
}
progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeContentIDs(episodes))
if err != nil {
return nil
}
return catalog.EpisodeRollupUserData(episodes, progressMap)
}
unplayedCount := len(episodes) - watchedCount
return &catalog.SeasonUserData{
WatchedCount: watchedCount,
UnplayedCount: unplayedCount,
InProgressCount: inProgressCount,
Played: watchedCount == len(episodes),
func (h *ItemsHandler) progressMapForEpisodes(r *http.Request, episodes []*models.Episode) (map[string]userstore.WatchProgress, bool) {
store, profileID, ok := h.userStoreForRequest(r)
if !ok {
return nil, false
}
episodeIDs := episodeContentIDs(episodes)
progressMap, err := h.listProgressForEpisodeIDs(r.Context(), store, profileID, episodeIDs)
if err != nil {
return nil, false
}
return progressMap, true
}
func (h *ItemsHandler) listProgressForEpisodeIDs(ctx context.Context, store userstore.UserStore, profileID string, episodeIDs []string) (map[string]userstore.WatchProgress, error) {
const chunkSize = 500
progressMap := make(map[string]userstore.WatchProgress, len(episodeIDs))
for start := 0; start < len(episodeIDs); start += chunkSize {
end := start + chunkSize
if end > len(episodeIDs) {
end = len(episodeIDs)
}
chunk, err := userstore.ListProgressWithCompletedHistory(ctx, store, profileID, episodeIDs[start:end])
if err != nil {
return nil, err
}
for id, progress := range chunk {
progressMap[id] = progress
}
}
return progressMap, nil
}
func episodeContentIDs(episodes []*models.Episode) []string {
ids := make([]string, 0, len(episodes))
seen := make(map[string]struct{}, len(episodes))
for _, ep := range episodes {
if ep == nil || ep.ContentID == "" {
continue
}
if _, ok := seen[ep.ContentID]; ok {
continue
}
seen[ep.ContentID] = struct{}{}
ids = append(ids, ep.ContentID)
}
return ids
}
func flattenEpisodeGroups(groups map[int][]*models.Episode) []*models.Episode {
var total int
for _, episodes := range groups {
total += len(episodes)
}
flattened := make([]*models.Episode, 0, total)
for _, episodes := range groups {
flattened = append(flattened, episodes...)
}
return flattened
}
// requestProfileID resolves the active profile for a request, falling back to
@@ -0,0 +1,185 @@
package handlers
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestBrowseResponseIncludesTotalExact(t *testing.T) {
data, err := json.Marshal(browseResponse{
Total: 3,
TotalExact: true,
HasMore: false,
Items: []itemListResponse{},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"total_exact":true`) {
t.Fatalf("browse response missing total_exact: %s", data)
}
}
type countingItemListImageResolver struct {
singleCalls int
batchCalls int
batchPaths []string
}
func (r *countingItemListImageResolver) ResolveImageURL(_ context.Context, path string, variant string) string {
r.singleCalls++
return "single:" + variant + ":" + path
}
func (r *countingItemListImageResolver) ResolveImageURLs(_ context.Context, paths []string, variant string) map[string]string {
resolved := r.ResolveImageURLsWithExpiry(context.Background(), paths, variant)
out := make(map[string]string, len(resolved))
for path, value := range resolved {
out[path] = value.URL
}
return out
}
func (r *countingItemListImageResolver) ResolveImageURLWithExpiry(_ context.Context, path string, variant string) catalog.ResolvedImageURL {
r.singleCalls++
return catalog.ResolvedImageURL{URL: "single:" + variant + ":" + path}
}
func (r *countingItemListImageResolver) ResolveImageURLsWithExpiry(_ context.Context, paths []string, variant string) map[string]catalog.ResolvedImageURL {
r.batchCalls++
r.batchPaths = append(r.batchPaths[:0], paths...)
out := make(map[string]catalog.ResolvedImageURL, len(paths))
for _, path := range paths {
out[path] = catalog.ResolvedImageURL{URL: "batch:" + variant + ":" + path}
}
return out
}
func TestItemListCardImageURLsUsesBatchResolver(t *testing.T) {
resolver := &countingItemListImageResolver{}
detailSvc := &catalog.DetailService{}
detailSvc.SetImageResolver(resolver)
handler := &ItemsHandler{detailSvc: detailSvc}
items := []*models.MediaItem{
{
ContentID: "movie-1",
PosterPath: "plugin://poster-1/original.jpg",
BackdropPath: "plugin://backdrop-1/original.jpg",
},
{
ContentID: "movie-2",
PosterPath: "https://cdn.example/poster-2.jpg",
BackdropPath: "plugin://backdrop-1/original.jpg",
},
}
urls := handler.itemListCardImageURLs(context.Background(), items)
if resolver.singleCalls != 0 {
t.Fatalf("single resolver calls = %d, want 0", resolver.singleCalls)
}
if resolver.batchCalls != 1 {
t.Fatalf("batch resolver calls = %d, want 1", resolver.batchCalls)
}
if got := urls["movie-1"].posterURL; got != "batch:card:plugin://poster-1/original.jpg" {
t.Fatalf("movie-1 poster URL = %q", got)
}
if got := urls["movie-1"].backdropURL; got != "batch:card:plugin://backdrop-1/original.jpg" {
t.Fatalf("movie-1 backdrop URL = %q", got)
}
if got := urls["movie-2"].posterURL; got != "https://cdn.example/poster-2.jpg" {
t.Fatalf("movie-2 poster URL = %q", got)
}
if got := len(resolver.batchPaths); got != 2 {
t.Fatalf("batch resolver path count = %d, want 2", got)
}
}
type overlayFastPathFileRepo struct {
fullContentCalls int
overlayContentCalls int
overlayEpisodeCalls int
}
func (r *overlayFastPathFileRepo) GetByContentID(context.Context, string) ([]*models.MediaFile, error) {
return nil, nil
}
func (r *overlayFastPathFileRepo) GetByEpisodeID(context.Context, string) ([]*models.MediaFile, error) {
return nil, nil
}
func (r *overlayFastPathFileRepo) ListByContentIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
r.fullContentCalls++
return nil, nil
}
func (r *overlayFastPathFileRepo) ListOverlayFilesByContentIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
r.overlayContentCalls++
return map[string][]*models.MediaFile{
"movie-1": {
{
ContentID: "movie-1",
Resolution: "4k",
CodecVideo: "hevc",
CodecAudio: "eac3",
AudioChannels: 6,
HDR: true,
Container: "mkv",
MediaFolderID: 1,
AudioTracks: []models.AudioTrack{{Codec: "eac3", Channels: 6, Default: true}},
SubtitleTracks: []models.SubtitleTrack{{Language: "en"}},
},
},
}, nil
}
func (r *overlayFastPathFileRepo) ListOverlayFilesByEpisodeIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
r.overlayEpisodeCalls++
return map[string][]*models.MediaFile{
"episode-1": {
{
EpisodeID: "episode-1",
Resolution: "1080p",
CodecVideo: "h264",
Container: "mp4",
MediaFolderID: 1,
},
},
}, nil
}
func TestListOverlaySummariesUsesOverlayFileProjection(t *testing.T) {
repo := &overlayFastPathFileRepo{}
handler := &ItemsHandler{fileRepo: repo}
summaries := handler.listOverlaySummaries(context.Background(), []*models.MediaItem{
{ContentID: "movie-1", Type: "movie"},
{ContentID: "episode-1", Type: "episode"},
}, catalog.AccessFilter{})
if repo.fullContentCalls != 0 {
t.Fatalf("full content file calls = %d, want 0", repo.fullContentCalls)
}
if repo.overlayContentCalls != 1 {
t.Fatalf("overlay content calls = %d, want 1", repo.overlayContentCalls)
}
if repo.overlayEpisodeCalls != 1 {
t.Fatalf("overlay episode calls = %d, want 1", repo.overlayEpisodeCalls)
}
if got := summaries["movie-1"].Resolution; got != "2160p" {
t.Fatalf("movie overlay resolution = %q, want 2160p", got)
}
if got := summaries["movie-1"].VideoCodec; got != "H.265" {
t.Fatalf("movie overlay video codec = %q, want H.265", got)
}
if got := summaries["episode-1"].VideoCodec; got != "H.264" {
t.Fatalf("episode overlay video codec = %q, want H.264", got)
}
}
@@ -1,12 +1,18 @@
package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
@@ -45,3 +51,168 @@ func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
t.Fatalf("completed ebook user data = %#v", complete)
}
}
// Audiobooks resolve user_data through the same watch-progress store as
// movies/episodes (not the ebook reader store), so an in-progress audiobook
// reports its saved position_seconds — which is what lets the player resume.
func TestGetLeafUserDataReturnsAudiobookProgress(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgress(context.Background(), "profile-1", "audiobook-1", 1234, 5000, userstore.ProgressThresholds{}); err != nil {
t.Fatalf("seed progress: %v", err)
}
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := httptest.NewRequest("GET", "/watch/audiobook-1", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
progress := handler.getLeafUserData(req, "audiobook-1", "audiobook")
if progress == nil {
t.Fatal("audiobook user data = nil, want saved progress")
}
if progress.PositionSeconds != 1234 {
t.Fatalf("PositionSeconds = %v, want 1234", progress.PositionSeconds)
}
}
func TestGetLeafUserDataUsesCompletedHistoryWhenProgressMissing(t *testing.T) {
store := newPlaybackTestStore(t)
addCompletedHistoryForUserDataTest(t, store, "movie-history-only")
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := authorizedUserDataRequest()
userData := handler.getLeafUserData(req, "movie-history-only", "movie")
if userData == nil {
t.Fatal("movie user data = nil, want history-backed watched state")
}
if !userData.Played {
t.Fatalf("Played = false, want true from completed history")
}
if userData.PositionSeconds != 0 || userData.IsInProgress {
t.Fatalf("history-only user data = %+v, want no resume position", userData)
}
}
func TestGetLeafUserDataPreservesResumeWhenCompletedHistoryExists(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgressAt(
context.Background(),
"profile-1",
"movie-rewatch",
600,
7200,
false,
time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC),
); err != nil {
t.Fatalf("seed progress: %v", err)
}
addCompletedHistoryForUserDataTest(t, store, "movie-rewatch")
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := authorizedUserDataRequest()
userData := handler.getLeafUserData(req, "movie-rewatch", "movie")
if userData == nil {
t.Fatal("movie user data = nil, want progress-backed user data")
}
if !userData.Played {
t.Fatalf("Played = false, want true from completed history")
}
if userData.PositionSeconds != 600 || userData.DurationSeconds != 7200 || !userData.IsInProgress {
t.Fatalf("resume fields = %+v, want in-progress resume preserved", userData)
}
}
func TestGetAggregateUserDataReturnsNilWhenProgressBatchFails(t *testing.T) {
store := &failingBatchProgressStore{}
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := httptest.NewRequest("GET", "/series/series-1/seasons/1", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
episodes := make([]*models.Episode, 501)
for i := range episodes {
episodes[i] = &models.Episode{ContentID: "episode-" + strconv.Itoa(i+1)}
}
if userData := handler.getAggregateUserData(req, episodes); userData != nil {
t.Fatalf("getAggregateUserData() = %#v, want nil after batch failure", userData)
}
if store.calls != 2 {
t.Fatalf("ListProgressByMediaItems calls = %d, want 2", store.calls)
}
}
func TestGetAggregateUserDataCountsCompletedHistory(t *testing.T) {
store := newPlaybackTestStore(t)
if err := store.SetProgressAt(
context.Background(),
"profile-1",
"episode-progress-complete",
0,
1800,
true,
time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC),
); err != nil {
t.Fatalf("seed progress: %v", err)
}
addCompletedHistoryForUserDataTest(t, store, "episode-history-complete")
handler := &ItemsHandler{storeProvider: testUserStoreProvider{store: store}}
req := authorizedUserDataRequest()
userData := handler.getAggregateUserData(req, []*models.Episode{
{ContentID: "episode-progress-complete"},
{ContentID: "episode-history-complete"},
{ContentID: "episode-unplayed"},
})
if userData == nil {
t.Fatal("aggregate user data = nil, want counts")
}
if userData.WatchedCount != 2 || userData.UnplayedCount != 1 || userData.Played {
t.Fatalf("aggregate user data = %+v, want two watched and one unplayed", userData)
}
}
type failingBatchProgressStore struct {
userstore.UserStore
calls int
}
func (s *failingBatchProgressStore) ListProgressByMediaItems(
_ context.Context,
_ string,
mediaItemIDs []string,
) (map[string]userstore.WatchProgress, error) {
s.calls++
if s.calls == 2 {
return nil, errors.New("progress batch failed")
}
progress := make(map[string]userstore.WatchProgress, len(mediaItemIDs))
for _, id := range mediaItemIDs {
progress[id] = userstore.WatchProgress{MediaItemID: id, Completed: true}
}
return progress, nil
}
func authorizedUserDataRequest() *http.Request {
req := httptest.NewRequest("GET", "/items/movie-1", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func addCompletedHistoryForUserDataTest(t *testing.T, store userstore.UserStore, mediaItemID string) {
t.Helper()
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
ProfileID: "profile-1",
MediaItemID: mediaItemID,
WatchedAt: "2026-05-04T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceTrakt,
}); err != nil {
t.Fatalf("seed completed history: %v", err)
}
}
+308
View File
@@ -0,0 +1,308 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
const jellyfinCompatWebOperationUpdatedEvent = "jellyfin_compat.web_operation.updated"
type jellyfinCompatSettingsRequest struct {
Enabled *bool `json:"enabled,omitempty"`
PublicURL *string `json:"public_url,omitempty"`
ServerName *string `json:"server_name,omitempty"`
EmulatedServerVersion *string `json:"emulated_server_version,omitempty"`
WebEnabled *bool `json:"web_enabled,omitempty"`
WebVersion *string `json:"web_version,omitempty"`
WebDir *string `json:"web_dir,omitempty"`
WebInstallDir *string `json:"web_install_dir,omitempty"`
}
type jellyfinCompatWebInstallRequest struct {
Version string `json:"version,omitempty"`
SourceURL string `json:"source_url,omitempty"`
}
// HandleGetJellyfinCompatStatus handles GET /admin/jellyfin-compat/status.
func (h *AdminHandler) HandleGetJellyfinCompatStatus(w http.ResponseWriter, r *http.Request) {
settings, ok := h.jellyfinCompatSettings(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, jellycompat.WebComponentStatusForConfig(h.Config, settings))
}
// HandleUpdateJellyfinCompatSettings handles PATCH /admin/jellyfin-compat/settings.
func (h *AdminHandler) HandleUpdateJellyfinCompatSettings(w http.ResponseWriter, r *http.Request) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
return
}
var req jellyfinCompatSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
updates := map[string]string{}
if req.Enabled != nil {
if *req.Enabled {
updates["jellyfin_compat.enabled"] = "true"
} else {
updates["jellyfin_compat.enabled"] = "false"
}
}
setOptionalString(updates, "jellyfin_compat.public_url", req.PublicURL)
setOptionalString(updates, "jellyfin_compat.server_name", req.ServerName)
setOptionalString(updates, "jellyfin_compat.emulated_server_version", req.EmulatedServerVersion)
if req.WebEnabled != nil {
updates["jellyfin_compat.web_enabled"] = strconv.FormatBool(*req.WebEnabled)
}
if req.Enabled != nil && !*req.Enabled {
updates["jellyfin_compat.web_enabled"] = "false"
}
setOptionalString(updates, "jellyfin_compat.web_version", req.WebVersion)
setOptionalString(updates, "jellyfin_compat.web_install_dir", req.WebInstallDir)
if req.WebDir != nil {
root := strings.TrimSpace(updates["jellyfin_compat.web_install_dir"])
if root == "" {
settings, ok := h.jellyfinCompatSettings(w, r)
if !ok {
return
}
root = strings.TrimSpace(settings["jellyfin_compat.web_install_dir"])
}
if root == "" {
root = config.DefaultJellyfinWebInstallDir
}
managedPath := jellycompat.ManagedWebInstallPath(root)
if raw := strings.TrimSpace(*req.WebDir); raw != "" && filepath.Clean(raw) != filepath.Clean(managedPath) {
writeError(w, http.StatusBadRequest, "bad_request", "Jellyfin Web active directory is managed by Silo and cannot point at an arbitrary path")
return
}
updates["jellyfin_compat.web_dir"] = managedPath
}
if len(updates) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "At least one setting is required")
return
}
for key, value := range updates {
if err := h.SettingsRepo.Set(r.Context(), key, value); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
return
}
h.publishSettingChanged(r, key, value)
}
if jellyfinCompatSettingsRequireRestart(updates) {
h.markServerRestartRequired("jellyfin_compat")
}
settings, ok := h.jellyfinCompatSettings(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, jellycompat.WebComponentStatusForConfig(h.Config, settings))
}
func jellyfinCompatSettingsRequireRestart(updates map[string]string) bool {
for key := range updates {
if config.RestartRequired(key) {
return true
}
}
return false
}
// HandleInstallJellyfinCompatWeb handles POST /admin/jellyfin-compat/web/install.
func (h *AdminHandler) HandleInstallJellyfinCompatWeb(w http.ResponseWriter, r *http.Request) {
h.installJellyfinCompatWeb(w, r)
}
// HandleUpdateJellyfinCompatWeb handles POST /admin/jellyfin-compat/web/update.
func (h *AdminHandler) HandleUpdateJellyfinCompatWeb(w http.ResponseWriter, r *http.Request) {
h.installJellyfinCompatWeb(w, r)
}
// HandleRemoveJellyfinCompatWeb handles POST /admin/jellyfin-compat/web/remove.
func (h *AdminHandler) HandleRemoveJellyfinCompatWeb(w http.ResponseWriter, r *http.Request) {
settings, ok := h.jellyfinCompatSettings(w, r)
if !ok {
return
}
root := strings.TrimSpace(settings["jellyfin_compat.web_install_dir"])
if root == "" {
root = config.DefaultJellyfinWebInstallDir
}
_, err := jellycompat.StartWebComponentRemove(jellycompat.WebComponentRemoveOptions{
InstallRoot: root,
OnProgress: h.publishJellyfinCompatWebOperationProgress,
})
if err != nil {
writeJellyfinCompatOperationError(w, err)
return
}
if err := h.persistJellyfinCompatWebEnabled(r.Context(), false); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update setting")
return
}
settings, ok = h.jellyfinCompatSettings(w, r)
if !ok {
return
}
writeJSON(w, http.StatusAccepted, jellycompat.WebComponentStatusForConfig(h.Config, settings))
}
func (h *AdminHandler) installJellyfinCompatWeb(w http.ResponseWriter, r *http.Request) {
settings, ok := h.jellyfinCompatSettings(w, r)
if !ok {
return
}
var req jellyfinCompatWebInstallRequest
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
}
root := strings.TrimSpace(settings["jellyfin_compat.web_install_dir"])
if root == "" {
root = config.DefaultJellyfinWebInstallDir
}
sourceURL := strings.TrimSpace(req.SourceURL)
if sourceURL == "" {
sourceURL = strings.TrimSpace(settings["jellyfin_compat.web_source_url"])
}
version := strings.TrimSpace(req.Version)
if version == "" {
version = strings.TrimSpace(settings["jellyfin_compat.web_version"])
}
if version == "" {
emulatedVersion := strings.TrimSpace(settings["jellyfin_compat.emulated_server_version"])
if emulatedVersion == "" && h.Config != nil {
emulatedVersion = h.Config.JellyfinCompat.EmulatedServerVersion
}
if emulatedVersion == "" {
emulatedVersion = config.DefaultJellyfinCompatEmulatedServerVersion
}
resolvedVersion, err := jellycompat.ResolveCompatibleWebVersion(r.Context(), sourceURL, emulatedVersion)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "web_version_unavailable", err.Error())
return
}
version = resolvedVersion
}
status, err := jellycompat.StartWebComponentInstall(jellycompat.WebComponentInstallOptions{
InstallRoot: root,
SourceURL: sourceURL,
Version: version,
OnProgress: h.publishJellyfinCompatWebOperationProgress,
}, func(ctx context.Context, status jellycompat.WebComponentStatus) error {
return h.persistJellyfinCompatWebInstallSettings(ctx, status)
})
if err != nil {
writeJellyfinCompatOperationError(w, err)
return
}
writeJSON(w, http.StatusAccepted, status)
}
func (h *AdminHandler) persistJellyfinCompatWebInstallSettings(ctx context.Context, status jellycompat.WebComponentStatus) error {
if h.SettingsRepo == nil {
return nil
}
updates := map[string]string{
"jellyfin_compat.web_enabled": "true",
"jellyfin_compat.web_install_dir": status.InstallRoot,
"jellyfin_compat.web_dir": jellycompat.ManagedWebInstallPath(status.InstallRoot),
"jellyfin_compat.web_version": status.PinnedVersion,
}
if status.SourceURL != "" {
updates["jellyfin_compat.web_source_url"] = status.SourceURL
}
for key, value := range updates {
if err := h.SettingsRepo.Set(ctx, key, value); err != nil {
return fmt.Errorf("persist Jellyfin Web setting %s: %w", key, err)
}
h.publishSettingChangedContext(ctx, key, value)
}
return nil
}
func (h *AdminHandler) persistJellyfinCompatWebEnabled(ctx context.Context, enabled bool) error {
if h.SettingsRepo == nil {
return nil
}
value := strconv.FormatBool(enabled)
if err := h.SettingsRepo.Set(ctx, "jellyfin_compat.web_enabled", value); err != nil {
return fmt.Errorf("persist Jellyfin Web setting jellyfin_compat.web_enabled: %w", err)
}
h.publishSettingChangedContext(ctx, "jellyfin_compat.web_enabled", value)
return nil
}
func (h *AdminHandler) publishJellyfinCompatWebOperationProgress(op jellycompat.WebComponentOperationStatus) {
if h == nil || h.EventsHub == nil {
return
}
_ = h.EventsHub.PublishJSON(
context.Background(),
evt.ChannelSettings,
jellyfinCompatWebOperationUpdatedEvent,
op,
evt.PublishOptions{AdminOnly: true},
)
}
func (h *AdminHandler) jellyfinCompatSettings(w http.ResponseWriter, r *http.Request) (map[string]string, bool) {
if h.SettingsRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Settings store not configured")
return nil, false
}
settings, err := h.SettingsRepo.GetAll(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load settings")
return nil, false
}
return settings, true
}
func setOptionalString(updates map[string]string, key string, value *string) {
if value == nil {
return
}
updates[key] = strings.TrimSpace(*value)
}
func (h *AdminHandler) publishSettingChanged(r *http.Request, key, value string) {
h.publishSettingChangedContext(r.Context(), key, value)
}
func (h *AdminHandler) publishSettingChangedContext(ctx context.Context, key, value string) {
if h.EventBus != nil {
_ = h.EventBus.Publish(ctx, cache.ChannelAdmin,
cache.Event{Type: cache.EventSettingsChanged, Payload: key})
}
if h.OnServerSettingUpdated != nil {
h.OnServerSettingUpdated(ctx, key, value)
}
}
func writeJellyfinCompatOperationError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, jellycompat.ErrWebComponentOperationActive):
writeError(w, http.StatusConflict, "operation_active", "A Jellyfin Web operation is already running")
case errors.Is(err, jellycompat.ErrWebInstallerUnavailable):
writeError(w, http.StatusServiceUnavailable, "installer_unavailable", err.Error())
default:
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
}
}
@@ -0,0 +1,339 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
func TestUpdateJellyfinCompatSettingsRejectsArbitraryWebDir(t *testing.T) {
cfg, err := config.LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
handler := &AdminHandler{
Config: cfg,
SettingsRepo: &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.web_install_dir": "/var/lib/silo/compat/jellyfin-web",
}},
}
req := httptest.NewRequest(
http.MethodPatch,
"/admin/jellyfin-compat/settings",
strings.NewReader(`{"web_dir":"/etc"}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateJellyfinCompatSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if !strings.Contains(rec.Body.String(), "cannot point at an arbitrary path") {
t.Fatalf("unexpected response body %q", rec.Body.String())
}
}
func TestUpdateJellyfinCompatSettingsUpdatesWebEnabled(t *testing.T) {
cfg, err := config.LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
settings := &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.web_install_dir": "/var/lib/silo/compat/jellyfin-web",
}}
restartStatus := NewServerRestartStatusTracker()
handler := &AdminHandler{
Config: cfg,
SettingsRepo: settings,
RestartStatus: restartStatus,
}
req := httptest.NewRequest(
http.MethodPatch,
"/admin/jellyfin-compat/settings",
strings.NewReader(`{"web_enabled":false}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateJellyfinCompatSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := settings.values["jellyfin_compat.web_enabled"]; got != "false" {
t.Fatalf("jellyfin_compat.web_enabled = %q, want false", got)
}
if !strings.Contains(rec.Body.String(), `"web_enabled":false`) {
t.Fatalf("response body %q does not include disabled web_enabled", rec.Body.String())
}
if snapshot := restartStatus.Snapshot(); snapshot.RestartRequired {
t.Fatalf("RestartRequired = true, want false for web_enabled-only update")
}
}
func TestUpdateJellyfinCompatSettingsDoesNotMarkRestartForLiveFields(t *testing.T) {
cfg, err := config.LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
root := t.TempDir()
settings := &fakeServerSettingsStore{values: map[string]string{}}
restartStatus := NewServerRestartStatusTracker()
handler := &AdminHandler{
Config: cfg,
SettingsRepo: settings,
RestartStatus: restartStatus,
}
body := `{"public_url":"https://compat.example.test","server_name":"Silo Compat","emulated_server_version":"10.11.6","web_version":"10.11.6","web_install_dir":"` + root + `"}`
req := httptest.NewRequest(
http.MethodPatch,
"/admin/jellyfin-compat/settings",
strings.NewReader(body),
)
rec := httptest.NewRecorder()
handler.HandleUpdateJellyfinCompatSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := settings.values["jellyfin_compat.public_url"]; got != "https://compat.example.test" {
t.Fatalf("jellyfin_compat.public_url = %q, want updated value", got)
}
if got := settings.values["jellyfin_compat.server_name"]; got != "Silo Compat" {
t.Fatalf("jellyfin_compat.server_name = %q, want updated value", got)
}
if got := settings.values["jellyfin_compat.emulated_server_version"]; got != "10.11.6" {
t.Fatalf("jellyfin_compat.emulated_server_version = %q, want 10.11.6", got)
}
if got := settings.values["jellyfin_compat.web_version"]; got != "10.11.6" {
t.Fatalf("jellyfin_compat.web_version = %q, want 10.11.6", got)
}
if got := settings.values["jellyfin_compat.web_install_dir"]; got != root {
t.Fatalf("jellyfin_compat.web_install_dir = %q, want %q", got, root)
}
if snapshot := restartStatus.Snapshot(); snapshot.RestartRequired {
t.Fatalf("RestartRequired = true, want false for live Jellyfin settings")
}
}
func TestJellyfinCompatSettingsRequireRestartFollowsRestartRegistry(t *testing.T) {
cases := []struct {
name string
updates map[string]string
want bool
}{
{
name: "listener setting",
updates: map[string]string{
"jellyfin_compat.enabled": "true",
},
want: true,
},
{
name: "live identity settings",
updates: map[string]string{
"jellyfin_compat.public_url": "https://compat.example.test",
"jellyfin_compat.server_name": "Silo Compat",
"jellyfin_compat.emulated_server_version": "10.11.6",
},
},
{
name: "live web settings",
updates: map[string]string{
"jellyfin_compat.web_enabled": "false",
"jellyfin_compat.web_version": "10.11.6",
"jellyfin_compat.web_dir": "/var/lib/silo/compat/jellyfin-web/current",
"jellyfin_compat.web_install_dir": "/var/lib/silo/compat/jellyfin-web",
},
},
{
name: "mixed live and listener settings",
updates: map[string]string{
"jellyfin_compat.enabled": "false",
"jellyfin_compat.web_enabled": "false",
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := jellyfinCompatSettingsRequireRestart(tc.updates); got != tc.want {
t.Fatalf("jellyfinCompatSettingsRequireRestart() = %v, want %v", got, tc.want)
}
})
}
}
func TestUpdateJellyfinCompatSettingsDisablesWebWhenAPIDisabled(t *testing.T) {
cfg, err := config.LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
settings := &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_enabled": "true",
}}
restartStatus := NewServerRestartStatusTracker()
published := map[string]string{}
handler := &AdminHandler{
Config: cfg,
SettingsRepo: settings,
RestartStatus: restartStatus,
OnServerSettingUpdated: func(_ context.Context, key, value string) {
published[key] = value
},
}
req := httptest.NewRequest(
http.MethodPatch,
"/admin/jellyfin-compat/settings",
strings.NewReader(`{"enabled":false,"web_enabled":true}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateJellyfinCompatSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := settings.values["jellyfin_compat.enabled"]; got != "false" {
t.Fatalf("jellyfin_compat.enabled = %q, want false", got)
}
if got := settings.values["jellyfin_compat.web_enabled"]; got != "false" {
t.Fatalf("jellyfin_compat.web_enabled = %q, want false", got)
}
if got := published["jellyfin_compat.web_enabled"]; got != "false" {
t.Fatalf("published jellyfin_compat.web_enabled = %q, want false", got)
}
if !strings.Contains(rec.Body.String(), `"enabled":false`) {
t.Fatalf("response body %q does not include disabled API", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"web_enabled":false`) {
t.Fatalf("response body %q does not include disabled web_enabled", rec.Body.String())
}
if snapshot := restartStatus.Snapshot(); !snapshot.RestartRequired {
t.Fatal("RestartRequired = false, want true for API setting update")
}
}
func TestRemoveJellyfinCompatWebDisablesWebSetting(t *testing.T) {
cfg, err := config.LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
settings := &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.enabled": "true",
"jellyfin_compat.web_enabled": "true",
"jellyfin_compat.web_install_dir": t.TempDir(),
}}
published := map[string]string{}
handler := &AdminHandler{
Config: cfg,
SettingsRepo: settings,
OnServerSettingUpdated: func(_ context.Context, key, value string) {
published[key] = value
},
}
req := httptest.NewRequest(
http.MethodPost,
"/admin/jellyfin-compat/web/remove",
strings.NewReader(`{}`),
)
rec := httptest.NewRecorder()
handler.HandleRemoveJellyfinCompatWeb(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusAccepted, rec.Body.String())
}
if got := settings.values["jellyfin_compat.web_enabled"]; got != "false" {
t.Fatalf("jellyfin_compat.web_enabled = %q, want false", got)
}
if got := published["jellyfin_compat.web_enabled"]; got != "false" {
t.Fatalf("published jellyfin_compat.web_enabled = %q, want false", got)
}
if !strings.Contains(rec.Body.String(), `"web_enabled":false`) {
t.Fatalf("response body %q does not include disabled web_enabled", rec.Body.String())
}
}
func TestUpdateJellyfinCompatSettingsMarksRestartForProxyChanges(t *testing.T) {
cfg, err := config.LoadFromDB(map[string]string{})
if err != nil {
t.Fatalf("LoadFromDB: %v", err)
}
settings := &fakeServerSettingsStore{values: map[string]string{}}
restartStatus := NewServerRestartStatusTracker()
handler := &AdminHandler{
Config: cfg,
SettingsRepo: settings,
RestartStatus: restartStatus,
}
req := httptest.NewRequest(
http.MethodPatch,
"/admin/jellyfin-compat/settings",
strings.NewReader(`{"enabled":true}`),
)
rec := httptest.NewRecorder()
handler.HandleUpdateJellyfinCompatSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := settings.values["jellyfin_compat.enabled"]; got != "true" {
t.Fatalf("jellyfin_compat.enabled = %q, want true", got)
}
snapshot := restartStatus.Snapshot()
if !snapshot.RestartRequired {
t.Fatal("RestartRequired = false, want true for proxy setting update")
}
if snapshot.RestartRequiredReason != "jellyfin_compat" {
t.Fatalf("RestartRequiredReason = %q, want jellyfin_compat", snapshot.RestartRequiredReason)
}
}
func TestPersistJellyfinCompatWebInstallSettingsEnablesWebUI(t *testing.T) {
settings := &fakeServerSettingsStore{values: map[string]string{
"jellyfin_compat.web_enabled": "false",
}}
handler := &AdminHandler{SettingsRepo: settings}
err := handler.persistJellyfinCompatWebInstallSettings(context.Background(), jellycompat.WebComponentStatus{
InstallRoot: "/var/lib/silo/compat/jellyfin-web",
PinnedVersion: "10.11.6",
InstalledVersion: "10.11.6",
SourceURL: jellycompat.DefaultWebSourceURL,
})
if err != nil {
t.Fatalf("persistJellyfinCompatWebInstallSettings: %v", err)
}
if got := settings.values["jellyfin_compat.web_enabled"]; got != "true" {
t.Fatalf("jellyfin_compat.web_enabled = %q, want true", got)
}
if got := settings.values["jellyfin_compat.web_install_dir"]; got != "/var/lib/silo/compat/jellyfin-web" {
t.Fatalf("jellyfin_compat.web_install_dir = %q", got)
}
if got := settings.values["jellyfin_compat.web_dir"]; got != jellycompat.ManagedWebInstallPath("/var/lib/silo/compat/jellyfin-web") {
t.Fatalf("jellyfin_compat.web_dir = %q", got)
}
if got := settings.values["jellyfin_compat.web_version"]; got != "10.11.6" {
t.Fatalf("jellyfin_compat.web_version = %q, want 10.11.6", got)
}
if got := settings.values["jellyfin_compat.web_source_url"]; got != jellycompat.DefaultWebSourceURL {
t.Fatalf("jellyfin_compat.web_source_url = %q", got)
}
}
+2
View File
@@ -2102,6 +2102,8 @@ func metadataContentLevelsForLibraryType(libraryType string) []string {
return []string{"audiobook"}
case "ebooks", "ebook":
return []string{"ebook"}
case "manga":
return []string{"manga"}
case "mixed":
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
default:
@@ -14,6 +14,7 @@ func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
{name: "manga", libraryType: "manga", want: []string{"manga"}},
}
for _, tc := range cases {
+8 -31
View File
@@ -795,7 +795,8 @@ func (h *LibraryCollectionHandler) HandleListLibraryUserCollections(w http.Respo
}
userID := apimw.GetUserID(r.Context())
collections, err := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID)
profileID := apimw.GetProfileID(r.Context())
collections, err := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, profileID, libraryID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections")
return
@@ -833,6 +834,7 @@ func (h *LibraryCollectionHandler) HandleListLibraryCollections(w http.ResponseW
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
groups, err := h.GroupRepo.ListByLibrary(r.Context(), libraryID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load groups")
@@ -855,7 +857,7 @@ func (h *LibraryCollectionHandler) HandleListLibraryCollections(w http.ResponseW
continue
}
if !userCollectionsLoaded {
loadedUserCollections, loadErr := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, libraryID)
loadedUserCollections, loadErr := usercollections.ListServerVisibleByLibrary(r.Context(), h.UserCollectionPool, userID, profileID, libraryID)
if loadErr != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load user collections")
return
@@ -3131,12 +3133,12 @@ func (h *LibraryCollectionHandler) loadLiveCollectionItems(r *http.Request, coll
switch {
case len(collection.LibraryIDs) > 0:
def.LibraryIDs = intersectCollectionLibraryIDs(def.LibraryIDs, collection.LibraryIDs)
def.LibraryIDs = catalog.IntersectCollectionLibraryIDs(def.LibraryIDs, collection.LibraryIDs)
if len(def.LibraryIDs) == 0 {
return []itemListResponse{}, nil
}
case collection.LibraryID > 0:
def.LibraryIDs = intersectCollectionLibraryIDs(def.LibraryIDs, []int{collection.LibraryID})
def.LibraryIDs = catalog.IntersectCollectionLibraryIDs(def.LibraryIDs, []int{collection.LibraryID})
if len(def.LibraryIDs) == 0 {
return []itemListResponse{}, nil
}
@@ -3175,33 +3177,8 @@ func (e smartCollectionQueryError) Unwrap() error {
return e.err
}
func intersectCollectionLibraryIDs(existing, required []int) []int {
if len(required) == 0 {
return append([]int(nil), existing...)
}
if len(existing) == 0 {
return append([]int(nil), required...)
}
allowed := make(map[int]struct{}, len(required))
for _, id := range required {
allowed[id] = struct{}{}
}
result := make([]int, 0, len(existing))
seen := make(map[int]struct{}, len(existing))
for _, id := range existing {
if _, ok := allowed[id]; !ok {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
// intersectCollectionLibraryIDs moved to catalog.IntersectCollectionLibraryIDs
// so the web API and the Jellyfin-compat resolver share one implementation.
func (h *LibraryCollectionHandler) toItemListResponse(r *http.Request, item *models.MediaItem) itemListResponse {
resp := itemListResponse{
+182
View File
@@ -0,0 +1,182 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/literaryworks"
)
type LiteraryWorkService interface {
GetWork(ctx context.Context, workID string, filter catalog.AccessFilter) (*literaryworks.DetailResponse, error)
ListCandidates(ctx context.Context, contentID string, limit int) ([]literaryworks.Candidate, error)
LinkItems(ctx context.Context, workID string, contentIDs []string) (string, error)
UnlinkItem(ctx context.Context, workID, contentID string) error
ConfirmMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) (string, error)
IgnoreMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) error
}
type LiteraryWorkHandler struct {
Service LiteraryWorkService
}
type literaryWorkLinkRequest struct {
WorkID string `json:"work_id"`
ContentIDs []string `json:"content_ids"`
}
type literaryWorkDecisionRequest struct {
SourceContentID string `json:"source_content_id"`
TargetContentID string `json:"target_content_id"`
}
func (h *LiteraryWorkHandler) HandleGetWork(w http.ResponseWriter, r *http.Request) {
workID := strings.TrimSpace(chi.URLParam(r, "work_id"))
if workID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "work_id is required")
return
}
if h == nil || h.Service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured")
return
}
resp, err := h.Service.GetWork(r.Context(), workID, requestAccessFilter(r))
if err != nil {
if errors.Is(err, literaryworks.ErrWorkNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Work not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load work")
return
}
writeJSON(w, http.StatusOK, resp)
}
func (h *LiteraryWorkHandler) HandleListCandidates(w http.ResponseWriter, r *http.Request) {
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "content_id is required")
return
}
if h == nil || h.Service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured")
return
}
limit := 20
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
candidates, err := h.Service.ListCandidates(r.Context(), contentID, limit)
if err != nil {
if errors.Is(err, literaryworks.ErrWorkNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list work candidates")
return
}
writeJSON(w, http.StatusOK, map[string]any{"candidates": candidates})
}
func (h *LiteraryWorkHandler) HandleLinkItems(w http.ResponseWriter, r *http.Request) {
if h == nil || h.Service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured")
return
}
var req literaryWorkLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
workID, err := h.Service.LinkItems(r.Context(), req.WorkID, req.ContentIDs)
if err != nil {
if errors.Is(err, literaryworks.ErrWorkNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to link work items")
return
}
writeJSON(w, http.StatusOK, map[string]string{"work_id": workID})
}
func (h *LiteraryWorkHandler) HandleUnlinkItem(w http.ResponseWriter, r *http.Request) {
workID := strings.TrimSpace(chi.URLParam(r, "work_id"))
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
if workID == "" || contentID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "work_id and content_id are required")
return
}
if h == nil || h.Service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured")
return
}
if err := h.Service.UnlinkItem(r.Context(), workID, contentID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to unlink work item")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *LiteraryWorkHandler) HandleConfirmMatch(w http.ResponseWriter, r *http.Request) {
h.handleDecision(w, r, true)
}
func (h *LiteraryWorkHandler) HandleIgnoreMatch(w http.ResponseWriter, r *http.Request) {
h.handleDecision(w, r, false)
}
func (h *LiteraryWorkHandler) handleDecision(w http.ResponseWriter, r *http.Request, confirm bool) {
if h == nil || h.Service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Literary works are not configured")
return
}
var req literaryWorkDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
req.SourceContentID = strings.TrimSpace(req.SourceContentID)
req.TargetContentID = strings.TrimSpace(req.TargetContentID)
if req.SourceContentID == "" || req.TargetContentID == "" || req.SourceContentID == req.TargetContentID {
writeError(w, http.StatusBadRequest, "bad_request", "source_content_id and target_content_id are required")
return
}
claims := apimw.GetClaims(r.Context())
userID := 0
if claims != nil {
userID = claims.UserID
}
var (
workID string
err error
)
if confirm {
workID, err = h.Service.ConfirmMatch(r.Context(), req.SourceContentID, req.TargetContentID, userID)
} else {
err = h.Service.IgnoreMatch(r.Context(), req.SourceContentID, req.TargetContentID, userID)
}
if err != nil {
if errors.Is(err, literaryworks.ErrWorkNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to record work match decision")
return
}
if confirm {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "work_id": workID})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
@@ -0,0 +1,73 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/literaryworks"
)
type fakeLiteraryWorkService struct {
work *literaryworks.DetailResponse
err error
}
func (f fakeLiteraryWorkService) GetWork(ctx context.Context, workID string, filter catalog.AccessFilter) (*literaryworks.DetailResponse, error) {
return f.work, f.err
}
func (f fakeLiteraryWorkService) ListCandidates(ctx context.Context, contentID string, limit int) ([]literaryworks.Candidate, error) {
return []literaryworks.Candidate{{SourceContentID: contentID, TargetContentID: "audio-1", Score: 0.9}}, f.err
}
func (f fakeLiteraryWorkService) LinkItems(ctx context.Context, workID string, contentIDs []string) (string, error) {
if workID != "" {
return workID, f.err
}
return "work-1", f.err
}
func (f fakeLiteraryWorkService) UnlinkItem(ctx context.Context, workID, contentID string) error {
return f.err
}
func (f fakeLiteraryWorkService) ConfirmMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) (string, error) {
return "work-1", f.err
}
func (f fakeLiteraryWorkService) IgnoreMatch(ctx context.Context, sourceContentID, targetContentID string, userID int) error {
return f.err
}
func TestLiteraryWorkHandlerGetWork(t *testing.T) {
handler := &LiteraryWorkHandler{Service: fakeLiteraryWorkService{
work: &literaryworks.DetailResponse{
WorkID: "work-1",
WorkTitle: "Project Hail Mary",
Formats: []literaryworks.FormatResponse{
{Type: "ebook", ContentID: "ebook-1"},
{Type: "audiobook", ContentID: "audio-1"},
},
},
}}
req := httptest.NewRequest(http.MethodGet, "/api/v1/works/work-1", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("work_id", "work-1")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.HandleGetWork(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"formats"`) || !strings.Contains(rec.Body.String(), `"audiobook"`) {
t.Fatalf("body = %s, want work formats", rec.Body.String())
}
}
+395
View File
@@ -0,0 +1,395 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
const (
notificationsDefaultLimit = 25
notificationsMaxLimit = 100
notificationsSyncLimit = 50
)
// NotificationsHandler serves the profile-scoped notification inbox,
// preferences, capability, and websocket-ticket endpoints. All routes are
// mounted behind RequireProfile.
type NotificationsHandler struct {
system *notifications.System
hub *evt.Hub
}
// NewNotificationsHandler creates a NotificationsHandler.
func NewNotificationsHandler(system *notifications.System, hub *evt.Hub) *NotificationsHandler {
return &NotificationsHandler{system: system, hub: hub}
}
type notificationListResponse struct {
Notifications []notifications.DeliveryRowPayload `json:"notifications"`
// NextCursor pages further into the past via the `before` query param.
// Empty when this page may be the last.
NextCursor string `json:"next_cursor,omitempty"`
}
type notificationSyncResponse struct {
Notifications []notifications.DeliveryRowPayload `json:"notifications"`
NextCursor string `json:"next_cursor,omitempty"`
UnreadCount int `json:"unread_count"`
}
type unreadCountResponse struct {
Count int `json:"count"`
}
type wsTicketResponse struct {
Ticket string `json:"ticket"`
ExpiresIn int `json:"expires_in"`
}
func parseNotificationsLimit(r *http.Request, fallback int) int {
raw := r.URL.Query().Get("limit")
if raw == "" {
return fallback
}
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 {
return fallback
}
return min(limit, notificationsMaxLimit)
}
// HandleList handles GET /notifications (newest-first inbox page).
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
unreadOnly := r.URL.Query().Get("status") == "unread"
limit := parseNotificationsLimit(r, notificationsDefaultLimit)
var before *notifications.Cursor
if raw := r.URL.Query().Get("before"); raw != "" {
cursor, err := notifications.DecodeCursor(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid before cursor")
return
}
before = &cursor
}
rows, err := h.system.Deliveries.ListInbox(r.Context(), profileID, unreadOnly, limit, before)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
return
}
response := notificationListResponse{Notifications: h.system.PayloadsForRows(r.Context(), rows)}
if len(rows) == limit {
last := rows[len(rows)-1]
response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode()
}
writeJSON(w, http.StatusOK, response)
}
// HandleSync handles GET /notifications/sync — the forward (ascending) cursor
// sync used by clients waking from a push or reconnecting after a gap.
func (h *NotificationsHandler) HandleSync(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
limit := parseNotificationsLimit(r, notificationsSyncLimit)
var since *notifications.Cursor
if raw := r.URL.Query().Get("since"); raw != "" {
cursor, err := notifications.DecodeCursor(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid since cursor")
return
}
since = &cursor
}
rows, err := h.system.Deliveries.ListSync(r.Context(), profileID, since, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to sync notifications")
return
}
unread, err := h.system.Deliveries.UnreadCount(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications")
return
}
response := notificationSyncResponse{
Notifications: h.system.PayloadsForRows(r.Context(), rows),
UnreadCount: unread,
}
if len(rows) > 0 {
last := rows[len(rows)-1]
response.NextCursor = notifications.Cursor{CreatedAt: last.CreatedAt, ID: last.ID}.Encode()
} else if since != nil {
response.NextCursor = since.Encode()
}
writeJSON(w, http.StatusOK, response)
}
// HandleGet handles GET /notifications/{id}; 404 for other profiles' rows.
func (h *NotificationsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
id := chi.URLParam(r, "id")
row, err := h.system.Deliveries.GetByID(r.Context(), profileID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification")
return
}
if row == nil {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
writeJSON(w, http.StatusOK, h.system.PayloadForRow(r.Context(), *row))
}
// HandleUnreadCount handles GET /notifications/unread-count.
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
count, err := h.system.Deliveries.UnreadCount(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unread notifications")
return
}
writeJSON(w, http.StatusOK, unreadCountResponse{Count: count})
}
// HandleMarkRead handles POST /notifications/{id}/read. Idempotent.
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
id := chi.URLParam(r, "id")
transitioned, err := h.system.Deliveries.MarkRead(r.Context(), profileID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read")
return
}
if !transitioned {
// Already read is fine (idempotent); unknown IDs are a 404.
exists, err := h.system.Deliveries.Exists(r.Context(), profileID, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notification read")
return
}
if !exists {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
}
if transitioned {
h.publishReadEvent(r, userID, profileID, id)
}
w.WriteHeader(http.StatusNoContent)
}
// HandleReadAll handles POST /notifications/read-all.
func (h *NotificationsHandler) HandleReadAll(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if _, err := h.system.Deliveries.MarkAllRead(r.Context(), profileID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
return
}
h.publishReadEvent(r, userID, profileID, "")
w.WriteHeader(http.StatusNoContent)
}
// publishReadEvent lets other connected tabs of the same profile reconcile
// read state. An empty id means "all read".
func (h *NotificationsHandler) publishReadEvent(r *http.Request, userID int, profileID, id string) {
if h.hub == nil {
return
}
payload := map[string]any{"profile_id": profileID}
if id != "" {
payload["id"] = id
} else {
payload["all"] = true
}
_ = h.hub.PublishJSON(r.Context(), evt.ChannelNotifications, notifications.EventNotificationRead,
payload, evt.PublishOptions{UserID: userID, ProfileID: profileID})
}
// HandleGetPreferences handles GET /notifications/preferences.
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
prefs, err := h.system.Preferences.Get(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences")
return
}
writeJSON(w, http.StatusOK, prefs)
}
type updatePreferencesRequest struct {
Enabled *bool `json:"enabled"`
NotifyFavorites *bool `json:"notify_favorites"`
NotifyWatchlist *bool `json:"notify_watchlist"`
NotifyContinueWatching *bool `json:"notify_continue_watching"`
NotifyNextUp *bool `json:"notify_next_up"`
}
// HandleUpdatePreferences handles PUT /notifications/preferences. Fields are
// optional; omitted fields keep their current value. Idempotent.
func (h *NotificationsHandler) HandleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
profileID := apimw.GetProfileID(r.Context())
var req updatePreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
prefs, err := h.system.Preferences.Get(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load notification preferences")
return
}
if req.Enabled != nil {
prefs.Enabled = *req.Enabled
}
if req.NotifyFavorites != nil {
prefs.NotifyFavorites = *req.NotifyFavorites
}
if req.NotifyWatchlist != nil {
prefs.NotifyWatchlist = *req.NotifyWatchlist
}
if req.NotifyContinueWatching != nil {
prefs.NotifyContinueWatching = *req.NotifyContinueWatching
}
if req.NotifyNextUp != nil {
prefs.NotifyNextUp = *req.NotifyNextUp
}
if err := h.system.Preferences.Upsert(r.Context(), prefs); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save notification preferences")
return
}
writeJSON(w, http.StatusOK, prefs)
}
type capabilityResponse struct {
InApp capabilityInApp `json:"in_app"`
ApplePush capabilityPush `json:"apple_push"`
AndroidPush capabilityPush `json:"android_push"`
WebPush capabilityWebPush `json:"web_push"`
Webhooks capabilityWebhooks `json:"webhooks"`
Email capabilityAccountChannel `json:"email"`
Discord capabilityAccountChannel `json:"discord"`
}
// capabilityAccountChannel describes an account-level digest channel (email,
// Discord DMs).
type capabilityAccountChannel struct {
Available bool `json:"available"`
// Modes lists the cadences users may pick (per-episode is an admin
// allowance); DigestHour tells the UI when daily digests go out.
Modes []string `json:"modes"`
DigestHour int `json:"digest_hour"`
}
type capabilityWebPush struct {
Available bool `json:"available"`
PublicKey string `json:"public_key,omitempty"`
}
type capabilityInApp struct {
Enabled bool `json:"enabled"`
}
type capabilityPush struct {
Available bool `json:"available"`
Provider string `json:"provider"`
SupportedModes []string `json:"supported_modes"`
}
type capabilityWebhooks struct {
Available bool `json:"available"`
MaxPerProfile int `json:"max_per_profile"`
SupportedTypes []string `json:"supported_types"`
}
// HandleCapability handles GET /notifications/capability. Clients render
// setup UI from this response instead of introspecting admin settings. Push
// channels report unavailable until they ship
// (docs/superpowers/plans/notifications/02-03).
func (h *NotificationsHandler) HandleCapability(w http.ResponseWriter, r *http.Request) {
webhooks := capabilityWebhooks{Available: false, MaxPerProfile: 0, SupportedTypes: []string{}}
if h.system.Webhooks != nil && h.system.Settings.WebhooksEnabled(r.Context()) {
webhooks = capabilityWebhooks{
Available: true,
MaxPerProfile: h.system.Settings.WebhooksMaxPerProfile(r.Context()),
SupportedTypes: []string{"discord", "generic"},
}
}
webPush := capabilityWebPush{}
if h.system.WebPush != nil && h.system.Settings.WebPushEnabled(r.Context()) {
if publicKey, err := h.system.WebPush.PublicKey(r.Context()); err == nil && publicKey != "" {
webPush = capabilityWebPush{Available: true, PublicKey: publicKey}
}
}
email := capabilityAccountChannel{Modes: []string{}}
if h.system.EmailAvailable(r.Context()) {
modes := []string{notifications.ChannelModeDailyDigest}
if h.system.Settings.EmailAllowPerEpisode(r.Context()) {
modes = append(modes,
notifications.ChannelModePerEpisode,
notifications.ChannelModePerEpisodeAndDigest)
}
email = capabilityAccountChannel{
Available: true,
Modes: modes,
DigestHour: h.system.Settings.EmailDigestHour(r.Context()),
}
}
discordCap := capabilityAccountChannel{Modes: []string{}}
if h.system.DiscordAvailable(r.Context()) {
modes := []string{notifications.ChannelModeDailyDigest}
if h.system.Settings.DiscordAllowPerEpisode(r.Context()) {
modes = append(modes,
notifications.ChannelModePerEpisode,
notifications.ChannelModePerEpisodeAndDigest)
}
discordCap = capabilityAccountChannel{
Available: true,
Modes: modes,
DigestHour: h.system.Settings.DiscordDigestHour(r.Context()),
}
}
writeJSON(w, http.StatusOK, capabilityResponse{
InApp: capabilityInApp{Enabled: h.system.Settings.UIEnabled(r.Context())},
ApplePush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}},
AndroidPush: capabilityPush{Available: false, Provider: "off", SupportedModes: []string{"in_app_only"}},
WebPush: webPush,
Webhooks: webhooks,
Email: email,
Discord: discordCap,
})
}
// HandleMintWSTicket handles POST /events/ws-ticket: mints a short-lived
// single-use websocket handshake ticket bound to (user, profile). Long-lived
// tokens must never ride the websocket query string — reverse-proxy access
// logs capture it.
func (h *NotificationsHandler) HandleMintWSTicket(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
ticket, ttl, err := h.system.Tickets.Mint(r.Context(), userID, profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mint websocket ticket")
return
}
writeJSON(w, http.StatusOK, wsTicketResponse{
Ticket: ticket,
ExpiresIn: int(ttl.Seconds()),
})
}
@@ -0,0 +1,230 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/discord"
"github.com/Silo-Server/silo-server/internal/notifications"
)
// discordSettingsPath is the SPA page the OAuth callback redirects back to,
// with ?discord_linked=1 or ?discord_error=<reason> for the page to toast.
const discordSettingsPath = "/settings/notifications"
// DiscordNotificationsHandler serves the account-level Discord DM channel:
// preferences, the OAuth account-link flow, and the admin bot test. The
// preferences and link-init endpoints require auth; the link callback is
// public because Discord redirects the browser there without credentials —
// the one-time server-side state row authenticates it instead.
type DiscordNotificationsHandler struct {
system *notifications.System
publicURL string
}
// NewDiscordNotificationsHandler creates a DiscordNotificationsHandler.
// publicURL may be empty, in which case linking is reported unavailable
// (Discord needs a stable redirect_uri origin).
func NewDiscordNotificationsHandler(system *notifications.System, publicURL string) *DiscordNotificationsHandler {
return &DiscordNotificationsHandler{system: system, publicURL: strings.TrimRight(publicURL, "/")}
}
func (h *DiscordNotificationsHandler) redirectURI() string {
return h.publicURL + "/api/v1/notifications/discord/link/callback"
}
// discordPreferencesResponse is the account-level Discord DM setting plus
// link state and health.
type discordPreferencesResponse struct {
Linked bool `json:"linked"`
DiscordUsername string `json:"discord_username,omitempty"`
Mode string `json:"mode"`
LinkFailure string `json:"link_failure,omitempty"`
}
type updateDiscordPreferencesRequest struct {
Mode string `json:"mode"`
}
func discordPrefsResponse(prefs notifications.DiscordPrefs) discordPreferencesResponse {
return discordPreferencesResponse{
Linked: prefs.Linked(),
DiscordUsername: prefs.DiscordUsername,
Mode: prefs.Mode,
LinkFailure: prefs.LinkFailure,
}
}
// HandleGetPreferences handles GET /notifications/discord-preferences.
func (h *DiscordNotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
prefs, err := h.system.DiscordPrefsFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load Discord preferences")
return
}
writeJSON(w, http.StatusOK, discordPrefsResponse(prefs))
}
// HandleUpdatePreferences handles PUT /notifications/discord-preferences.
func (h *DiscordNotificationsHandler) HandleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
var req updateDiscordPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
err := h.system.SetDiscordMode(r.Context(), userID, req.Mode)
switch {
case err == nil:
case errors.Is(err, notifications.ErrDiscordModeInvalid):
writeError(w, http.StatusBadRequest, "bad_request", "Unknown Discord notification mode")
return
case errors.Is(err, notifications.ErrDiscordModeNotAllowed):
writeError(w, http.StatusBadRequest, "not_allowed", "Per-episode Discord DMs are disabled by the administrator")
return
case errors.Is(err, notifications.ErrDiscordNotLinked):
writeError(w, http.StatusBadRequest, "not_linked", "Link a Discord account first")
return
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save Discord preferences")
return
}
prefs, err := h.system.DiscordPrefsFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load Discord preferences")
return
}
writeJSON(w, http.StatusOK, discordPrefsResponse(prefs))
}
// HandleUnlink handles DELETE /notifications/discord-link: removes the
// linked identity and switches the channel off.
func (h *DiscordNotificationsHandler) HandleUnlink(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if err := h.system.UnlinkDiscord(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to unlink Discord account")
return
}
writeJSON(w, http.StatusOK, discordPreferencesResponse{Mode: notifications.ChannelModeOff})
}
type discordLinkInitResponse struct {
URL string `json:"url"`
}
// HandleLinkInit handles POST /notifications/discord/link/init: records a
// one-time state for the signed-in account and returns the Discord consent
// URL for the SPA to navigate to.
func (h *DiscordNotificationsHandler) HandleLinkInit(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
if !h.system.DiscordAvailable(r.Context()) {
writeError(w, http.StatusConflict, "not_configured", "Discord integration is not enabled by the administrator")
return
}
if h.publicURL == "" {
writeError(w, http.StatusConflict, "no_public_url", "Linking requires SILO_PUBLIC_URL to be configured")
return
}
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start Discord link")
return
}
state := hex.EncodeToString(stateBytes)
if err := h.system.BeginDiscordLink(r.Context(), state, userID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start Discord link")
return
}
query := url.Values{
"client_id": {h.system.Settings.DiscordClientID(r.Context())},
"response_type": {"code"},
"scope": {"identify"},
"redirect_uri": {h.redirectURI()},
"state": {state},
}
writeJSON(w, http.StatusOK, discordLinkInitResponse{
URL: discord.AuthorizeURL + "?" + query.Encode(),
})
}
// HandleLinkCallback handles GET /notifications/discord/link/callback.
// Public route: Discord redirects the user's browser here. The one-time
// state row authenticates the request and recovers which account started
// the flow; the browser is then sent back to the settings page.
func (h *DiscordNotificationsHandler) HandleLinkCallback(w http.ResponseWriter, r *http.Request) {
redirectBack := func(params url.Values) {
http.Redirect(w, r, discordSettingsPath+"?"+params.Encode(), http.StatusFound)
}
// The admin may have switched the integration off between init and
// callback; don't complete a link for a disabled channel.
if !h.system.DiscordAvailable(r.Context()) {
redirectBack(url.Values{"discord_error": {"disabled"}})
return
}
query := r.URL.Query()
if query.Get("error") != "" {
// The user declined on Discord's consent screen.
redirectBack(url.Values{"discord_error": {"denied"}})
return
}
state := query.Get("state")
code := query.Get("code")
if state == "" || code == "" {
redirectBack(url.Values{"discord_error": {"invalid_callback"}})
return
}
userID, ok, err := h.system.ConsumeDiscordLinkState(r.Context(), state)
if err != nil || !ok {
redirectBack(url.Values{"discord_error": {"state_invalid"}})
return
}
if _, err := h.system.CompleteDiscordLink(r.Context(), userID, code, h.redirectURI()); err != nil {
redirectBack(url.Values{"discord_error": {"exchange_failed"}})
return
}
redirectBack(url.Values{"discord_linked": {"1"}})
}
type discordTestResponse struct {
OK bool `json:"ok"`
DurationMS int64 `json:"duration_ms"`
Message string `json:"message"`
}
// HandleAdminTest handles POST /admin/notifications/discord/test: verifies
// the configured bot token by fetching the bot's own identity.
func (h *DiscordNotificationsHandler) HandleAdminTest(w http.ResponseWriter, r *http.Request) {
start := time.Now()
botUser, err := h.system.TestDiscordBot(r.Context())
duration := time.Since(start).Milliseconds()
if errors.Is(err, notifications.ErrDiscordNotConfigured) {
writeJSON(w, http.StatusOK, discordTestResponse{
OK: false, DurationMS: duration, Message: "Bot token is not configured",
})
return
}
if err != nil {
writeJSON(w, http.StatusOK, discordTestResponse{
OK: false, DurationMS: duration, Message: err.Error(),
})
return
}
writeJSON(w, http.StatusOK, discordTestResponse{
OK: true, DurationMS: duration, Message: "Connected as " + botUser.Username,
})
}
@@ -0,0 +1,209 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
)
// emailPreferencesResponse is one profile's email notification state. The
// channel is profile-scoped: each profile verifies its own destination
// address and receives nothing until it has one — there is no account-email
// fallback.
type emailPreferencesResponse struct {
Mode string `json:"mode"`
// CustomEmail is the verified destination ('' = none; channel inert).
CustomEmail string `json:"custom_email"`
// PendingEmail is an address awaiting link-click verification.
PendingEmail string `json:"pending_email"`
// CanEditAddress is false for child profiles, which cannot set
// addresses (and so cannot receive email notifications).
CanEditAddress bool `json:"can_edit_address"`
}
type updateEmailPreferencesRequest struct {
Mode string `json:"mode"`
}
type updateEmailAddressRequest struct {
Email string `json:"email"`
}
func emailPreferencesPayload(state notifications.EmailPreferencesState) emailPreferencesResponse {
return emailPreferencesResponse{
Mode: state.Mode,
CustomEmail: state.CustomEmail,
PendingEmail: state.PendingEmail,
CanEditAddress: !state.IsChild,
}
}
// respondEmailPreferences re-reads and writes the profile's full email state,
// so every mutation returns the same shape as GET.
func (h *NotificationsHandler) respondEmailPreferences(w http.ResponseWriter, r *http.Request, userID int, profileID string) {
state, err := h.system.EmailPreferences(r.Context(), userID, profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load email preferences")
return
}
writeJSON(w, http.StatusOK, emailPreferencesPayload(state))
}
// HandleGetEmailPreferences handles GET /notifications/email-preferences.
func (h *NotificationsHandler) HandleGetEmailPreferences(w http.ResponseWriter, r *http.Request) {
h.respondEmailPreferences(w, r, apimw.GetUserID(r.Context()), apimw.GetProfileID(r.Context()))
}
// HandleUpdateEmailPreferences handles PUT /notifications/email-preferences.
func (h *NotificationsHandler) HandleUpdateEmailPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req updateEmailPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
err := h.system.SetEmailMode(r.Context(), userID, profileID, req.Mode)
switch {
case err == nil:
case errors.Is(err, notifications.ErrEmailModeInvalid):
writeError(w, http.StatusBadRequest, "bad_request", "Unknown email notification mode")
return
case errors.Is(err, notifications.ErrEmailModeNotAllowed):
writeError(w, http.StatusBadRequest, "not_allowed", "Per-episode email is disabled by the administrator")
return
case errors.Is(err, notifications.ErrEmailNoAddress):
writeError(w, http.StatusBadRequest, "no_email", "Verify an email address for this profile first")
return
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save email preferences")
return
}
h.respondEmailPreferences(w, r, userID, profileID)
}
// HandleRequestEmailAddress handles PUT /notifications/email-preferences/address.
// It stores the candidate address and emails it a verification link; the
// address only becomes the destination once that link is clicked.
func (h *NotificationsHandler) HandleRequestEmailAddress(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req updateEmailAddressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
err := h.system.RequestEmailAddress(r.Context(), userID, profileID, req.Email)
switch {
case err == nil:
case errors.Is(err, notifications.ErrEmailInvalidAddress):
writeError(w, http.StatusBadRequest, "bad_request", "Invalid email address")
return
case errors.Is(err, notifications.ErrEmailChildProfile):
writeError(w, http.StatusForbidden, "child_profile", "Child profiles cannot set a custom notification address")
return
case errors.Is(err, notifications.ErrEmailAddressInUse):
writeError(w, http.StatusConflict, "address_in_use", "That email address is already used by another profile or account")
return
case errors.Is(err, notifications.ErrEmailVerifyRateLimited):
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many verification emails; try again later")
return
case errors.Is(err, notifications.ErrEmailNoLinkBase):
writeError(w, http.StatusConflict, "no_external_url", "The server has no external URL configured for verification links")
return
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send the verification email")
return
}
h.respondEmailPreferences(w, r, userID, profileID)
}
// HandleClearEmailAddress handles DELETE /notifications/email-preferences/address.
func (h *NotificationsHandler) HandleClearEmailAddress(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
err := h.system.ClearEmailAddress(r.Context(), userID, profileID)
switch {
case err == nil:
case errors.Is(err, notifications.ErrEmailChildProfile):
writeError(w, http.StatusForbidden, "child_profile", "Child profiles cannot change the notification address")
return
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove the custom address")
return
}
h.respondEmailPreferences(w, r, userID, profileID)
}
// EmailLinkHandler serves the public tokenized email endpoints: address
// verification and unsubscribe. Both are clicked from email clients on
// devices that may have no Silo session, so they render minimal standalone
// HTML instead of redirecting into the authenticated app.
type EmailLinkHandler struct {
system *notifications.System
}
// NewEmailLinkHandler creates an EmailLinkHandler.
func NewEmailLinkHandler(system *notifications.System) *EmailLinkHandler {
return &EmailLinkHandler{system: system}
}
// HandleVerify handles GET /notifications/email/verify?token=...
func (h *EmailLinkHandler) HandleVerify(w http.ResponseWriter, r *http.Request) {
outcome, err := h.system.VerifyEmailToken(r.Context(), r.URL.Query().Get("token"))
switch {
case err != nil:
writeEmailLinkPage(w, http.StatusInternalServerError, "Something went wrong",
"The address could not be verified. Try the link again in a moment.")
case outcome == notifications.EmailVerifyConflict:
writeEmailLinkPage(w, http.StatusConflict, "Address already in use",
"This address now belongs to another profile or account. Choose a different address in Silo's notification settings.")
case outcome == notifications.EmailVerifyInvalid:
writeEmailLinkPage(w, http.StatusBadRequest, "Link expired or already used",
"Request a new verification email from Silo's notification settings.")
default:
writeEmailLinkPage(w, http.StatusOK, "Address verified",
"Silo notifications for this profile will now be delivered here. You can close this page.")
}
}
// HandleUnsubscribe handles GET and POST /notifications/email/unsubscribe?token=...
// POST is the RFC 8058 one-click target mail clients call directly.
func (h *EmailLinkHandler) HandleUnsubscribe(w http.ResponseWriter, r *http.Request) {
ok, err := h.system.UnsubscribeEmail(r.Context(), r.URL.Query().Get("token"))
switch {
case err != nil:
writeEmailLinkPage(w, http.StatusInternalServerError, "Something went wrong",
"Could not unsubscribe. Try the link again in a moment.")
case !ok:
writeEmailLinkPage(w, http.StatusBadRequest, "Link invalid",
"This unsubscribe link is no longer valid. Manage notifications in Silo's settings.")
default:
writeEmailLinkPage(w, http.StatusOK, "Unsubscribed",
"This profile will no longer receive notification emails. Re-enable them any time in Silo's settings.")
}
}
// writeEmailLinkPage renders the minimal standalone page behind tokenized
// email links.
func writeEmailLinkPage(w http.ResponseWriter, status int, title, detail string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s — Silo</title></head>
<body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#101014;color:#e8e8ec;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">
<div style="max-width:420px;padding:32px;text-align:center;">
<h1 style="font-size:20px;margin:0 0 12px;">%s</h1>
<p style="font-size:14px;color:#9a9aa4;margin:0;">%s</p>
</div></body></html>`,
html.EscapeString(title), html.EscapeString(title), html.EscapeString(detail))
}
@@ -0,0 +1,221 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
// webhookResponse is the API view of a webhook. It never includes the
// destination URL (Discord webhook tokens are bearer credentials in the URL
// path) or the signing secret — only url_host for identification.
type webhookResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URLHost string `json:"url_host"`
Enabled bool `json:"enabled"`
NotifyFavorites bool `json:"notify_favorites"`
NotifyWatchlist bool `json:"notify_watchlist"`
NotifyContinueWatching bool `json:"notify_continue_watching"`
NotifyNextUp bool `json:"notify_next_up"`
NotifyRequests bool `json:"notify_requests"`
ConsecutiveFailures int `json:"consecutive_failures"`
DisabledReason *string `json:"disabled_reason"`
LastSuccessAt *time.Time `json:"last_success_at"`
LastFailureAt *time.Time `json:"last_failure_at"`
LastFailureStatus *int `json:"last_failure_status"`
LastFailureMessage *string `json:"last_failure_message"`
// SigningSecret is present only in create / rotate-secret responses.
SigningSecret string `json:"signing_secret,omitempty"`
}
func webhookToResponse(hook notifications.Webhook) webhookResponse {
return webhookResponse{
ID: hook.ID,
Name: hook.Name,
Type: hook.Type,
URLHost: hook.URLHost,
Enabled: hook.Enabled,
NotifyFavorites: hook.NotifyFavorites,
NotifyWatchlist: hook.NotifyWatchlist,
NotifyContinueWatching: hook.NotifyContinueWatching,
NotifyNextUp: hook.NotifyNextUp,
NotifyRequests: hook.NotifyRequests,
ConsecutiveFailures: hook.ConsecutiveFailures,
DisabledReason: hook.DisabledReason,
LastSuccessAt: hook.LastSuccessAt,
LastFailureAt: hook.LastFailureAt,
LastFailureStatus: hook.LastFailureStatus,
LastFailureMessage: hook.LastFailureMessage,
}
}
type webhookRequest struct {
Name *string `json:"name"`
URL *string `json:"url"`
Type *string `json:"type"`
Enabled *bool `json:"enabled"`
NotifyFavorites *bool `json:"notify_favorites"`
NotifyWatchlist *bool `json:"notify_watchlist"`
NotifyContinueWatching *bool `json:"notify_continue_watching"`
NotifyNextUp *bool `json:"notify_next_up"`
NotifyRequests *bool `json:"notify_requests"`
}
func (r webhookRequest) toInput() notifications.WebhookInput {
return notifications.WebhookInput{
Name: r.Name,
URL: r.URL,
Type: r.Type,
Enabled: r.Enabled,
NotifyFavorites: r.NotifyFavorites,
NotifyWatchlist: r.NotifyWatchlist,
NotifyContinueWatching: r.NotifyContinueWatching,
NotifyNextUp: r.NotifyNextUp,
NotifyRequests: r.NotifyRequests,
}
}
func (h *NotificationsHandler) webhooks() *notifications.WebhookService {
if h == nil || h.system == nil {
return nil
}
return h.system.Webhooks
}
func writeWebhookError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, notifications.ErrWebhooksDisabled):
writeError(w, http.StatusForbidden, "webhooks_disabled", "Webhooks are disabled by the server administrator")
case errors.Is(err, notifications.ErrWebhookNotFound):
writeError(w, http.StatusNotFound, "not_found", "Webhook not found")
case errors.Is(err, notifications.ErrWebhookLimit):
writeError(w, http.StatusUnprocessableEntity, "limit_reached", "Webhook limit reached for this profile")
case errors.Is(err, notifications.ErrWebhookInvalid):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Webhook operation failed")
}
}
// HandleListWebhooks handles GET /notifications/webhooks.
func (h *NotificationsHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) {
service := h.webhooks()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available")
return
}
profileID := apimw.GetProfileID(r.Context())
hooks, err := service.List(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list webhooks")
return
}
responses := make([]webhookResponse, 0, len(hooks))
for _, hook := range hooks {
responses = append(responses, webhookToResponse(hook))
}
writeJSON(w, http.StatusOK, map[string]any{"webhooks": responses})
}
// HandleCreateWebhook handles POST /notifications/webhooks. For generic
// webhooks the response carries the signing secret exactly once.
func (h *NotificationsHandler) HandleCreateWebhook(w http.ResponseWriter, r *http.Request) {
service := h.webhooks()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available")
return
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req webhookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
hook, signingSecret, err := service.Create(r.Context(), userID, profileID, req.toInput())
if err != nil {
writeWebhookError(w, err)
return
}
response := webhookToResponse(*hook)
response.SigningSecret = signingSecret
writeJSON(w, http.StatusCreated, response)
}
// HandleUpdateWebhook handles PUT /notifications/webhooks/{id}.
func (h *NotificationsHandler) HandleUpdateWebhook(w http.ResponseWriter, r *http.Request) {
service := h.webhooks()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available")
return
}
profileID := apimw.GetProfileID(r.Context())
var req webhookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
hook, err := service.Update(r.Context(), profileID, chi.URLParam(r, "id"), req.toInput())
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, webhookToResponse(*hook))
}
// HandleDeleteWebhook handles DELETE /notifications/webhooks/{id}. Idempotent.
func (h *NotificationsHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
service := h.webhooks()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available")
return
}
profileID := apimw.GetProfileID(r.Context())
if err := service.Delete(r.Context(), profileID, chi.URLParam(r, "id")); err != nil {
writeWebhookError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRotateWebhookSecret handles POST /notifications/webhooks/{id}/rotate-secret.
func (h *NotificationsHandler) HandleRotateWebhookSecret(w http.ResponseWriter, r *http.Request) {
service := h.webhooks()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available")
return
}
profileID := apimw.GetProfileID(r.Context())
signingSecret, err := service.RotateSecret(r.Context(), profileID, chi.URLParam(r, "id"))
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"signing_secret": signingSecret})
}
// HandleTestWebhook handles POST /notifications/webhooks/{id}/test. The test
// send is synchronous and never touches the retry/auto-disable counters.
func (h *NotificationsHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) {
service := h.webhooks()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Webhooks are not available")
return
}
profileID := apimw.GetProfileID(r.Context())
result, err := service.Test(r.Context(), profileID, chi.URLParam(r, "id"))
if err != nil {
writeWebhookError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
@@ -0,0 +1,144 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/go-chi/chi/v5"
)
// webPushSubscriptionResponse is the API view of a browser push
// registration. The keys are write-only: clients re-subscribe rather than
// read them back.
type webPushSubscriptionResponse struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
DeviceName string `json:"device_name,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
LastSuccessAt *time.Time `json:"last_success_at"`
LastFailureAt *time.Time `json:"last_failure_at"`
}
func webPushToResponse(sub notifications.WebPushSubscription) webPushSubscriptionResponse {
return webPushSubscriptionResponse{
ID: sub.ID,
Endpoint: sub.Endpoint,
DeviceName: sub.DeviceName,
Enabled: sub.Enabled,
CreatedAt: sub.CreatedAt,
LastSuccessAt: sub.LastSuccessAt,
LastFailureAt: sub.LastFailureAt,
}
}
func (h *NotificationsHandler) webPush() *notifications.WebPushService {
if h == nil || h.system == nil {
return nil
}
return h.system.WebPush
}
type webPushSubscribeRequest struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
DeviceName string `json:"device_name"`
}
// HandleWebPushSubscribe handles POST /notifications/web-push/subscriptions.
// The body matches PushSubscription.toJSON() plus an optional device name.
func (h *NotificationsHandler) HandleWebPushSubscribe(w http.ResponseWriter, r *http.Request) {
service := h.webPush()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available")
return
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req webPushSubscribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
sub, err := service.Subscribe(r.Context(), userID, profileID,
req.Endpoint, req.Keys.P256dh, req.Keys.Auth, req.DeviceName)
if err != nil {
if errors.Is(err, notifications.ErrWebPushInvalid) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push subscription")
return
}
writeJSON(w, http.StatusCreated, webPushToResponse(*sub))
}
// HandleWebPushList handles GET /notifications/web-push/subscriptions.
func (h *NotificationsHandler) HandleWebPushList(w http.ResponseWriter, r *http.Request) {
service := h.webPush()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available")
return
}
profileID := apimw.GetProfileID(r.Context())
subs, err := service.List(r.Context(), profileID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list push subscriptions")
return
}
responses := make([]webPushSubscriptionResponse, 0, len(subs))
for _, sub := range subs {
responses = append(responses, webPushToResponse(sub))
}
writeJSON(w, http.StatusOK, map[string]any{"subscriptions": responses})
}
// HandleWebPushDelete handles DELETE /notifications/web-push/subscriptions/{id}.
func (h *NotificationsHandler) HandleWebPushDelete(w http.ResponseWriter, r *http.Request) {
service := h.webPush()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available")
return
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
if err := service.Unsubscribe(r.Context(), userID, profileID, chi.URLParam(r, "id"), ""); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove push subscription")
return
}
w.WriteHeader(http.StatusNoContent)
}
type webPushUnsubscribeRequest struct {
Endpoint string `json:"endpoint"`
}
// HandleWebPushUnsubscribe handles POST /notifications/web-push/unsubscribe.
// Browsers only know their endpoint, not the server-side row ID. Idempotent.
func (h *NotificationsHandler) HandleWebPushUnsubscribe(w http.ResponseWriter, r *http.Request) {
service := h.webPush()
if service == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Web push is not available")
return
}
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req webPushUnsubscribeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Endpoint == "" {
writeError(w, http.StatusBadRequest, "bad_request", "An endpoint is required")
return
}
if err := service.Unsubscribe(r.Context(), userID, profileID, "", req.Endpoint); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to remove push subscription")
return
}
w.WriteHeader(http.StatusNoContent)
}
+23 -7
View File
@@ -134,12 +134,12 @@ type PlaybackHandler struct {
// hwaccel, transcode dir). Wired to the live config in integrated mode
// so admin changes apply to newly started transcodes. Read it through
// playbackConfig(), which falls back to defaults when unset.
PlaybackConfig func() config.PlaybackConfig
FFmpegLogSink playback.FFmpegLogSink
transcodeMu sync.RWMutex
transcodes map[string]*playback.TranscodeSession
realtimeCommandMu sync.Mutex
realtimeCommands map[string]playbackCommandRecord
PlaybackConfig func() config.PlaybackConfig
FFmpegLogSink playback.FFmpegLogSink
transcodeMu sync.RWMutex
transcodes map[string]*playback.TranscodeSession
realtimeCommandMu sync.Mutex
realtimeCommands map[string]playbackCommandRecord
}
type PlaybackWatchScrobbler interface {
@@ -1262,10 +1262,12 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
return
}
clientInfo := playbackClientInfoFromRequest(r)
sessionCtx := playback.WithClientInfo(r.Context(), clientInfo)
var session *playback.Session
if starter, ok := h.sessionMgr.(sessionStarterWithFilesContext); ok {
session, err = starter.StartSessionWithFilesContext(
r.Context(),
sessionCtx,
userID,
profileID,
effectiveFile.ID,
@@ -1321,6 +1323,9 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
AudioTrackIndex: audioTrackIndex,
TranscodeAudio: session.TranscodeAudio,
ClientIP: clientip.FromContext(r.Context()),
ClientName: clientInfo.Name,
ClientVersion: clientInfo.Version,
ClientUserAgent: clientInfo.UserAgent,
StreamBitrateKbps: streamBitrateKbps,
TargetAudioCodec: targetAudioCodec,
}); err != nil {
@@ -1442,6 +1447,17 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusCreated, resp)
}
func playbackClientInfoFromRequest(r *http.Request) playback.ClientInfo {
if r == nil {
return playback.ClientInfo{}
}
return playback.ClientInfo{
Name: strings.TrimSpace(r.Header.Get("X-Silo-Client")),
Version: strings.TrimSpace(r.Header.Get("X-Silo-Client-Version")),
UserAgent: r.UserAgent(),
}
}
// subtitleURLExt returns the URL file extension for a subtitle codec.
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side
+145 -1
View File
@@ -46,6 +46,10 @@ type playbackSessionRow struct {
IsPaused bool `json:"is_paused"`
HasPlaybackControl bool `json:"has_playback_control"`
ClientIP string `json:"client_ip,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
ClientLabel string `json:"client_label,omitempty"`
ClientUserAgent string `json:"client_user_agent,omitempty"`
AudioTrackIndex int `json:"audio_track_index"`
TranscodeAudio bool `json:"transcode_audio"`
StreamBitrateKbps *int `json:"stream_bitrate_kbps"`
@@ -149,6 +153,9 @@ func (l *PlaybackSessionsLoader) Load(
COALESCE(s.is_paused, FALSE),
COALESCE(s.has_websocket, FALSE),
COALESCE(HOST(s.client_ip), ''),
COALESCE(s.client_name, ''),
COALESCE(s.client_version, ''),
COALESCE(s.client_user_agent, ''),
COALESCE(s.audio_track_index, 0),
COALESCE(s.transcode_audio, FALSE),
s.stream_bitrate_kbps,
@@ -203,7 +210,8 @@ func (l *PlaybackSessionsLoader) Load(
&s.MediaTitle, &s.MediaType, &s.SeriesName, &s.EpisodeName, &s.SeasonNumber, &s.EpisodeNumber,
&posterPath,
&s.PlayMethod, &s.ReportingNode, &s.NodeDisplayName, &s.FileDuration, &s.StartedAt, &s.UpdatedAt,
&s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
&s.PositionSeconds, &s.IsPaused, &s.HasPlaybackControl, &s.ClientIP, &s.ClientName, &s.ClientVersion,
&s.ClientUserAgent, &s.AudioTrackIndex, &s.TranscodeAudio, &streamBitrateKbps,
&s.TranscodeNodeURL, &s.TargetResolution, &s.TargetVideoCodec, &s.TargetAudioCodec, &targetBitrateKbps,
&s.TranscodeHWAccel, &s.SourceContainer, &sourceBitrateKbps, &s.SourceVideoCodec, &s.SourceVideoResolution,
&s.SourceAudioCodec, &sourceAudioChannels, &audioTracksJSON, &s.RequestedVideoCodec, &s.RequestedVideoResolution,
@@ -215,6 +223,7 @@ func (l *PlaybackSessionsLoader) Load(
s.TargetBitrateKbps = targetBitrateKbps
s.SourceBitrateKbps = sourceBitrateKbps
s.SourceAudioChannels = sourceAudioChannels
s.ClientLabel = playbackClientDisplayName(s.ClientName, s.ClientVersion, s.ClientUserAgent)
enrichPlaybackSessionRow(&s, audioTracksJSON)
sessions = append(sessions, s)
}
@@ -335,6 +344,141 @@ func firstNonEmptyValue(values ...string) string {
return ""
}
func playbackClientDisplayName(name, version, userAgent string) string {
name = strings.TrimSpace(name)
version = shortPlaybackClientVersion(version)
if name != "" {
if version != "" {
return name + " " + version
}
return name
}
userAgent = strings.TrimSpace(userAgent)
if userAgent == "" {
return ""
}
rules := []struct {
label string
tokens []string
versionTokens []string
}{
{label: "Infuse", tokens: []string{"infuse"}, versionTokens: []string{"infuse-direct", "infuse"}},
{label: "Findroid", tokens: []string{"findroid"}, versionTokens: []string{"findroid"}},
{label: "Streamyfin", tokens: []string{"streamyfin"}, versionTokens: []string{"streamyfin"}},
{label: "Swiftfin", tokens: []string{"swiftfin"}, versionTokens: []string{"swiftfin"}},
{label: "Jellyfin", tokens: []string{"jellyfin"}, versionTokens: []string{"jellyfin"}},
{label: "JellyCon", tokens: []string{"jellycon"}, versionTokens: []string{"jellycon"}},
{label: "Wholphin", tokens: []string{"wholphin"}, versionTokens: []string{"wholphin"}},
{label: "Fladder", tokens: []string{"fladder"}, versionTokens: []string{"fladder"}},
{label: "VidHub", tokens: []string{"vidhub"}, versionTokens: []string{"vidhub"}},
{label: "SenPlayer", tokens: []string{"senplayer"}, versionTokens: []string{"senplayer"}},
{label: "Kodi", tokens: []string{"kodi"}, versionTokens: []string{"kodi"}},
{label: "MPV", tokens: []string{"mpv"}, versionTokens: []string{"mpv"}},
{label: "Edge", tokens: []string{"edg/"}, versionTokens: []string{"edg"}},
{label: "Opera", tokens: []string{"opr/", "opera"}, versionTokens: []string{"opr", "opera"}},
{label: "Firefox", tokens: []string{"firefox/", "fxios/"}, versionTokens: []string{"firefox", "fxios"}},
{label: "Chrome", tokens: []string{"chrome/", "crios/"}, versionTokens: []string{"chrome", "crios"}},
{label: "Safari", tokens: []string{"safari/"}, versionTokens: []string{"version"}},
}
lower := strings.ToLower(userAgent)
for _, rule := range rules {
if !containsAny(lower, rule.tokens) {
continue
}
if version := firstProductVersion(userAgent, rule.versionTokens); version != "" {
return rule.label + " " + version
}
return rule.label
}
switch {
case strings.Contains(lower, "applecoremedia"):
return "Apple player"
case strings.Contains(lower, "okhttp"):
return "Android client"
case strings.Contains(lower, "dart/"):
return "Flutter client"
case strings.Contains(lower, "go-http-client"):
return "Go client"
case strings.Contains(lower, "curl/"):
return "curl"
case strings.Contains(lower, "python-requests"):
return "Python requests"
default:
return firstUserAgentProduct(userAgent)
}
}
func containsAny(value string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}
func firstProductVersion(userAgent string, tokens []string) string {
for _, field := range strings.Fields(userAgent) {
name, version, ok := strings.Cut(field, "/")
if !ok {
continue
}
name = strings.TrimSpace(strings.ToLower(name))
for _, token := range tokens {
if name == strings.TrimSpace(strings.ToLower(token)) {
return shortPlaybackClientVersion(version)
}
}
}
return ""
}
func shortPlaybackClientVersion(version string) string {
version = strings.Trim(strings.TrimSpace(version), `";),`)
if version == "" {
return ""
}
version = strings.Map(func(r rune) rune {
if (r >= '0' && r <= '9') || r == '.' {
return r
}
return -1
}, version)
parts := strings.Split(version, ".")
filtered := parts[:0]
for _, part := range parts {
if part != "" {
filtered = append(filtered, part)
}
}
if len(filtered) == 0 {
return ""
}
if len(filtered) > 2 {
filtered = filtered[:2]
}
if len(filtered) == 2 && filtered[1] == "0" {
filtered = filtered[:1]
}
return strings.Join(filtered, ".")
}
func firstUserAgentProduct(userAgent string) string {
for _, field := range strings.Fields(userAgent) {
name, _, ok := strings.Cut(field, "/")
if ok {
name = strings.Trim(name, `";(),`)
if name != "" && !strings.EqualFold(name, "Mozilla") {
return name
}
}
}
return "Unknown client"
}
func (l *PlaybackSessionsLoader) populateProfileNames(ctx context.Context, sessions []playbackSessionRow) {
if l == nil || l.storeProv == nil || len(sessions) == 0 {
return
+4 -37
View File
@@ -40,6 +40,7 @@ type PluginHandler struct {
chainRepo *metadata.ChainRepository
imageResolver *metadata.PluginImageResolver
uploads *uploads.Manager
restartStatus *ServerRestartStatusTracker
}
func NewPluginHandler(
@@ -51,6 +52,7 @@ func NewPluginHandler(
proxy *plugins.HTTPProxy,
chainRepo *metadata.ChainRepository,
imageResolver *metadata.PluginImageResolver,
restartStatus *ServerRestartStatusTracker,
) *PluginHandler {
return &PluginHandler{
repositories: repositories,
@@ -61,6 +63,7 @@ func NewPluginHandler(
proxy: proxy,
chainRepo: chainRepo,
imageResolver: imageResolver,
restartStatus: restartStatus,
uploads: uploads.NewManager(uploads.ManagerOptions{
MaxSize: maxPluginUploadSize,
MaxChunkSize: maxPluginUploadChunkSize,
@@ -500,7 +503,6 @@ func (h *PluginHandler) HandleCreateInstallation(w http.ResponseWriter, r *http.
}
h.syncMetadataProviders(r.Context(), result.Installation)
h.syncImageResolvers(r.Context(), result.Installation)
response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest)
if err != nil {
@@ -659,7 +661,6 @@ func (h *PluginHandler) installUploadedPlugin(ctx context.Context, path string)
func (h *PluginHandler) writeUploadedPluginResponse(w http.ResponseWriter, r *http.Request, result *plugins.InstallResult) {
h.syncMetadataProviders(r.Context(), result.Installation)
h.syncImageResolvers(r.Context(), result.Installation)
response, err := h.buildInstallationResponse(r.Context(), result.Installation, result.Manifest)
if err != nil {
@@ -700,35 +701,6 @@ func (h *PluginHandler) syncMetadataProviders(ctx context.Context, installation
}
}
// syncImageResolvers registers image resolver sources for any metadata_provider.v1
// capabilities from the given installation so that plugin-prefixed image URLs
// (e.g. "tmdb://poster/abc.jpg") can be resolved at runtime.
func (h *PluginHandler) syncImageResolvers(ctx context.Context, installation *plugins.Installation) {
if h.imageResolver == nil || h.service == nil {
return
}
caps, err := h.installations.ListCapabilities(ctx, installation.ID)
if err != nil {
slog.Error("listing capabilities for image resolver sync",
"installation_id", installation.ID, "error", err)
return
}
for _, cap := range caps {
if cap.Type != "metadata_provider.v1" {
continue
}
source := metadata.NewPluginClientSource(installation.ID, cap.ID, func(
ctx context.Context, installationID int, capabilityID string,
) (metadata.PluginMetadataClient, error) {
return h.service.MetadataProviderClient(ctx, installationID, capabilityID)
})
h.imageResolver.RegisterSource(cap.ID, source)
slog.Info("registered plugin image resolver", "capability_id", cap.ID, "installation_id", installation.ID)
}
}
func isZipUpload(data []byte) bool {
if len(data) < 4 {
return false
@@ -846,11 +818,6 @@ func (h *PluginHandler) HandleUpdateInstallation(w http.ResponseWriter, r *http.
return
}
// When a plugin is being enabled, register its image resolvers.
if req.Enabled != nil && *req.Enabled && !currentInstallation.Enabled {
h.syncImageResolvers(r.Context(), installation)
}
response, err := h.buildInstallationResponse(r.Context(), installation, nil)
if err != nil {
slog.Error("building updated plugin installation response", "error", err)
@@ -880,7 +847,6 @@ func (h *PluginHandler) HandleApplyUpdate(w http.ResponseWriter, r *http.Request
}
h.syncMetadataProviders(r.Context(), installation)
h.syncImageResolvers(r.Context(), installation)
response, err := h.buildInstallationResponse(r.Context(), installation, nil)
if err != nil {
@@ -1037,6 +1003,7 @@ func (h *PluginHandler) HandlePutTaskBinding(w http.ResponseWriter, r *http.Requ
return
}
h.restartStatus.MarkRequired("plugin_task_binding")
writeJSON(w, http.StatusOK, pluginTaskBindingUpdateResponse{RestartRequired: true})
}
+65 -12
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"github.com/Silo-Server/silo-server/internal/access"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/userstore"
@@ -14,6 +15,9 @@ import (
// ProgressLibraryLookup resolves which progress items belong to a library.
type ProgressLibraryLookup interface {
GetItemsInFolder(ctx context.Context, contentIDs []string, folderID int) (map[string]bool, error)
// FilterAccessibleContentIDs returns the subset of contentIDs the viewer
// may access given their library scope and content-rating ceiling.
FilterAccessibleContentIDs(ctx context.Context, contentIDs []string, allowedFolderIDs, disabledFolderIDs []int, maxContentRating string) (map[string]bool, error)
}
// ProgressHandler handles watch progress and sync endpoints.
@@ -102,6 +106,25 @@ func (h *ProgressHandler) HandleListProgress(w http.ResponseWriter, r *http.Requ
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list progress")
return
}
// Drop entries the viewer can't access before they reach the client.
// Without this, a library-restricted profile receives progress rows for
// items outside its scope (e.g. an XXX title) and the client then fans out
// per-item detail fetches that 404 — a dead Continue Watching tile. Only
// runs for restricted profiles; unrestricted viewers are unaffected.
if scope, ok := access.GetScope(r.Context()); ok &&
(scope.AllowedLibraryIDs != nil || len(scope.DisabledLibraryIDs) > 0 || scope.MaxContentRating != "") {
if h.LibraryLookup == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply access filter")
return
}
entries, err = filterProgressEntriesByAccess(r.Context(), entries, scope, h.LibraryLookup)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply access filter")
return
}
}
if libraryID > 0 {
if h.LibraryLookup == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to apply library filter")
@@ -144,6 +167,27 @@ func parseLibraryIDParam(r *http.Request) (int, error) {
return libraryID, nil
}
// progressContentIDs collects the media item IDs from a progress slice.
func progressContentIDs(entries []userstore.WatchProgress) []string {
contentIDs := make([]string, 0, len(entries))
for _, entry := range entries {
contentIDs = append(contentIDs, entry.MediaItemID)
}
return contentIDs
}
// keepAccessibleEntries returns, in order, the entries whose media item ID maps
// to true in accessible.
func keepAccessibleEntries(entries []userstore.WatchProgress, accessible map[string]bool) []userstore.WatchProgress {
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
if accessible[entry.MediaItemID] {
filtered = append(filtered, entry)
}
}
return filtered
}
func filterProgressEntriesByLibrary(
ctx context.Context,
entries []userstore.WatchProgress,
@@ -154,24 +198,33 @@ func filterProgressEntriesByLibrary(
return entries, nil
}
contentIDs := make([]string, 0, len(entries))
for _, entry := range entries {
contentIDs = append(contentIDs, entry.MediaItemID)
}
allowed, err := lookup.GetItemsInFolder(ctx, contentIDs, libraryID)
allowed, err := lookup.GetItemsInFolder(ctx, progressContentIDs(entries), libraryID)
if err != nil {
return nil, err
}
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
if allowed[entry.MediaItemID] {
filtered = append(filtered, entry)
}
return keepAccessibleEntries(entries, allowed), nil
}
// filterProgressEntriesByAccess removes progress entries whose item falls
// outside the viewer's access scope (allowed/disabled libraries and the
// content-rating ceiling).
func filterProgressEntriesByAccess(
ctx context.Context,
entries []userstore.WatchProgress,
scope access.Scope,
lookup ProgressLibraryLookup,
) ([]userstore.WatchProgress, error) {
if len(entries) == 0 {
return entries, nil
}
return filtered, nil
accessible, err := lookup.FilterAccessibleContentIDs(ctx, progressContentIDs(entries), scope.AllowedLibraryIDs, scope.DisabledLibraryIDs, scope.MaxContentRating)
if err != nil {
return nil, err
}
return keepAccessibleEntries(entries, accessible), nil
}
// HandleSyncProgress handles POST /sync/progress.
@@ -0,0 +1,111 @@
package handlers
import (
"context"
"slices"
"testing"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// fakeProgressLookup records the arguments it was called with and returns a
// fixed accessibility map.
type fakeProgressLookup struct {
accessible map[string]bool
gotContentIDs []string
gotAllowed []int
gotDisabled []int
gotRating string
}
func (f *fakeProgressLookup) GetItemsInFolder(context.Context, []string, int) (map[string]bool, error) {
return nil, nil
}
func (f *fakeProgressLookup) FilterAccessibleContentIDs(
_ context.Context, contentIDs []string, allowedFolderIDs, disabledFolderIDs []int, maxContentRating string,
) (map[string]bool, error) {
f.gotContentIDs = contentIDs
f.gotAllowed = allowedFolderIDs
f.gotDisabled = disabledFolderIDs
f.gotRating = maxContentRating
return f.accessible, nil
}
func entries(ids ...string) []userstore.WatchProgress {
out := make([]userstore.WatchProgress, 0, len(ids))
for _, id := range ids {
out = append(out, userstore.WatchProgress{MediaItemID: id})
}
return out
}
func ids(entries []userstore.WatchProgress) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.MediaItemID)
}
return out
}
func TestFilterProgressEntriesByAccess(t *testing.T) {
// access.Scope sets DisabledLibraryIDs only when AllowedLibraryIDs is nil
// (see access.Scope docs), so the two restriction shapes are tested
// separately rather than as one impossible combined scope.
cases := []struct {
name string
scope access.Scope
}{
{
name: "allowed libraries + rating",
scope: access.Scope{AllowedLibraryIDs: []int{1, 2}, MaxContentRating: "PG-13"},
},
{
name: "disabled libraries + rating",
scope: access.Scope{DisabledLibraryIDs: []int{9}, MaxContentRating: "PG-13"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
lookup := &fakeProgressLookup{accessible: map[string]bool{"a": true, "c": true}}
got, err := filterProgressEntriesByAccess(context.Background(), entries("a", "b", "c"), tc.scope, lookup)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"a", "c"}
if g := ids(got); len(g) != len(want) || g[0] != want[0] || g[1] != want[1] {
t.Fatalf("filtered entries = %v, want %v", g, want)
}
// Scope must be forwarded verbatim to the lookup.
if !slices.Equal(lookup.gotAllowed, tc.scope.AllowedLibraryIDs) {
t.Errorf("allowed folders = %v, want %v", lookup.gotAllowed, tc.scope.AllowedLibraryIDs)
}
if !slices.Equal(lookup.gotDisabled, tc.scope.DisabledLibraryIDs) {
t.Errorf("disabled folders = %v, want %v", lookup.gotDisabled, tc.scope.DisabledLibraryIDs)
}
if len(lookup.gotContentIDs) != 3 {
t.Errorf("content ids = %v, want 3 entries", lookup.gotContentIDs)
}
if lookup.gotRating != tc.scope.MaxContentRating {
t.Errorf("max content rating = %q, want %q", lookup.gotRating, tc.scope.MaxContentRating)
}
})
}
}
func TestFilterProgressEntriesByAccessEmpty(t *testing.T) {
lookup := &fakeProgressLookup{accessible: map[string]bool{}}
got, err := filterProgressEntriesByAccess(context.Background(), nil, access.Scope{}, lookup)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Fatalf("expected no entries, got %v", ids(got))
}
}
+13 -5
View File
@@ -10,14 +10,15 @@ import (
// RateLimitHandler handles rate limit config admin endpoints.
type RateLimitHandler struct {
store ratelimit.SettingsStore
mw *ratelimit.Middleware
eventBus cache.EventBus
store ratelimit.SettingsStore
mw *ratelimit.Middleware
eventBus cache.EventBus
restartStatus *ServerRestartStatusTracker
}
// NewRateLimitHandler creates a new RateLimitHandler.
func NewRateLimitHandler(store ratelimit.SettingsStore, mw *ratelimit.Middleware, eventBus cache.EventBus) *RateLimitHandler {
return &RateLimitHandler{store: store, mw: mw, eventBus: eventBus}
func NewRateLimitHandler(store ratelimit.SettingsStore, mw *ratelimit.Middleware, eventBus cache.EventBus, restartStatus *ServerRestartStatusTracker) *RateLimitHandler {
return &RateLimitHandler{store: store, mw: mw, eventBus: eventBus, restartStatus: restartStatus}
}
type rateLimitConfigResponse struct {
@@ -156,10 +157,17 @@ func (h *RateLimitHandler) HandleUpdateConfig(w http.ResponseWriter, r *http.Req
// Save backend setting (infrastructure-level, requires restart)
if req.Backend == "memory" || req.Backend == "redis" {
currentBackend, _ := h.store.Get(r.Context(), "ratelimit.backend")
if currentBackend == "" {
currentBackend = "memory"
}
if err := h.store.Set(r.Context(), "ratelimit.backend", req.Backend); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save backend setting")
return
}
if currentBackend != req.Backend {
h.restartStatus.MarkRequired("ratelimit_backend")
}
}
// The limiter is constructed at startup, so enabling while it is absent
+2 -2
View File
@@ -86,7 +86,7 @@ func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
// endpoints must still work so an admin can re-enable it from the UI.
store := newFakeRateLimitStore()
store.values["ratelimit.enabled"] = "false"
h := NewRateLimitHandler(store, nil, nil)
h := NewRateLimitHandler(store, nil, nil, NewServerRestartStatusTracker())
got := getRateLimitConfig(t, h)
if got.Enabled {
@@ -116,7 +116,7 @@ func TestRateLimitHandlerWithoutRunningLimiter(t *testing.T) {
func TestRateLimitHandlerWithRunningLimiter(t *testing.T) {
store := newFakeRateLimitStore()
mw := newRunningRateLimitMiddleware(t, store)
h := NewRateLimitHandler(store, mw, nil)
h := NewRateLimitHandler(store, mw, nil, NewServerRestartStatusTracker())
got := getRateLimitConfig(t, h)
if !got.Active {
+100
View File
@@ -764,6 +764,8 @@ func (h *SectionHandler) loadResolvedHomeSections(r *http.Request) ([]sections.R
}
}
resolved = filterResolvedSectionsByAccess(resolved, accessFilter)
return resolved, libraryIDs, accessFilter, profileID, nil
}
@@ -1047,6 +1049,7 @@ func (h *SectionHandler) HandleSectionSettings(w http.ResponseWriter, r *http.Re
}
resolved := sections.ResolveForSettings(adminSections, overrides)
resolved = filterResolvedSectionsByAccess(resolved, requestAccessFilter(r))
type settingsEntry struct {
ID string `json:"id"`
@@ -1080,6 +1083,64 @@ func (h *SectionHandler) HandleSectionSettings(w http.ResponseWriter, r *http.Re
writeJSON(w, http.StatusOK, map[string][]settingsEntry{"sections": entries})
}
func filterResolvedSectionsByAccess(resolved []sections.ResolvedSection, filter catalog.AccessFilter) []sections.ResolvedSection {
if filter.AllowedLibraryIDs == nil && len(filter.DisabledLibraryIDs) == 0 {
return resolved
}
out := resolved[:0]
for _, section := range resolved {
if sectionAllowedByAccess(section, filter) {
out = append(out, section)
}
}
return out
}
func sectionAllowedByAccess(section sections.ResolvedSection, filter catalog.AccessFilter) bool {
configLibraryIDs := sections.ParseConfigFilters(section.Config).LibraryIDs()
if len(configLibraryIDs) == 0 {
return true
}
if filter.AllowedLibraryIDs != nil {
return intSlicesIntersect(configLibraryIDs, filter.AllowedLibraryIDs)
}
for _, libraryID := range configLibraryIDs {
if !intSliceContains(filter.DisabledLibraryIDs, libraryID) {
return true
}
}
return false
}
func intSlicesIntersect(left, right []int) bool {
if len(left) == 0 || len(right) == 0 {
return false
}
set := make(map[int]struct{}, len(right))
for _, value := range right {
set[value] = struct{}{}
}
for _, value := range left {
if _, ok := set[value]; ok {
return true
}
}
return false
}
func intSliceContains(values []int, target int) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
// applyDiversityFilter removes items from sections whose recipe has
// AvoidDuplicates=true if the same content ID was already surfaced by an
// earlier section in the same render. Operates in-place — preserves order.
@@ -1179,6 +1240,7 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect
userStates := h.listSectionItemUserStates(r, allItems)
imageURLs := h.resolveSectionItemImageURLs(r.Context(), withItems)
episodeMeta := h.listSectionEpisodeItemMeta(r.Context(), withItems, requestAccessFilter(r))
mangaChapterMeta := h.listSectionMangaChapterItemMeta(r.Context(), allItems)
for _, s := range withItems {
items := make([]sectionItemResponse, 0, len(s.Items))
for _, item := range s.Items {
@@ -1193,6 +1255,17 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect
meta = &value
}
}
// Manga chapters carry their series linkage on top of whatever
// meta (e.g. reading progress) the section already resolved, so
// continue-reading cards can head to the series.
if value, ok := mangaChapterMeta[item.ContentID]; ok {
if meta == nil {
empty := sections.SectionItemMeta{}
meta = &empty
}
meta.SeriesID = value.SeriesID
meta.SeriesTitle = value.SeriesTitle
}
imageKey := sectionItemImageKey{sectionID: s.ID, contentID: item.ContentID}
items = append(items, h.toSectionItemResponse(s.SectionType, item, meta, overlaySummaries[item.ContentID], userStates[item.ContentID], imageURLs[imageKey]))
}
@@ -1211,6 +1284,33 @@ func (h *SectionHandler) buildSectionsResponse(r *http.Request, withItems []sect
return resp
}
// listSectionMangaChapterItemMeta resolves series linkage for every manga
// chapter (type='ebook' linked via manga_chapters) among the section items.
// Non-chapter ebooks simply get no entry.
func (h *SectionHandler) listSectionMangaChapterItemMeta(ctx context.Context, items []*models.MediaItem) map[string]sections.SectionItemMeta {
if h == nil || h.fetcher == nil {
return map[string]sections.SectionItemMeta{}
}
ids := make([]string, 0)
seen := make(map[string]struct{})
for _, item := range items {
if item == nil || item.Type != "ebook" || strings.TrimSpace(item.ContentID) == "" {
continue
}
if _, ok := seen[item.ContentID]; ok {
continue
}
seen[item.ContentID] = struct{}{}
ids = append(ids, item.ContentID)
}
meta, err := h.fetcher.FetchMangaChapterSeriesMeta(ctx, ids)
if err != nil {
slog.Warn("loading section manga chapter metadata", "error", err)
return map[string]sections.SectionItemMeta{}
}
return meta
}
func (h *SectionHandler) listSectionEpisodeItemMeta(ctx context.Context, withItems []sections.SectionWithItems, filter catalog.AccessFilter) map[string]sections.SectionItemMeta {
if h == nil || h.episodeFetcher == nil {
return map[string]sections.SectionItemMeta{}
+62
View File
@@ -390,6 +390,68 @@ func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) {
}
}
func TestFilterResolvedSectionsByAccessHidesBlockedLibraryRows(t *testing.T) {
sectionsIn := []sections.ResolvedSection{
{
ID: "continue",
SectionType: sections.SectionContinueWatching,
Title: "Continue Watching",
Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching),
},
{
ID: "allowed-library",
SectionType: sections.SectionRecentlyAdded,
Title: "Recently Added in Allowed",
Config: sections.GeneratedHomeLibraryRecentConfig(11),
},
{
ID: "blocked-library",
SectionType: sections.SectionRecentlyAdded,
Title: "Recently Added in Blocked",
Config: sections.GeneratedHomeLibraryRecentConfig(42),
},
}
got := filterResolvedSectionsByAccess(sectionsIn, catalog.AccessFilter{
AllowedLibraryIDs: []int{11},
})
if len(got) != 2 {
t.Fatalf("filtered sections length = %d, want 2", len(got))
}
if got[0].ID != "continue" || got[1].ID != "allowed-library" {
t.Fatalf("filtered section ids = [%s %s], want [continue allowed-library]", got[0].ID, got[1].ID)
}
}
func TestFilterResolvedSectionsByAccessHidesDisabledOnlyRows(t *testing.T) {
sectionsIn := []sections.ResolvedSection{
{
ID: "disabled-library",
SectionType: sections.SectionRecentlyReleased,
Title: "Recently Released in Disabled",
Config: sections.GeneratedHomeLibraryRecentConfig(42),
},
{
ID: "mixed-libraries",
SectionType: sections.SectionRecentlyReleased,
Title: "Mixed",
Config: []byte(`{"filter_library_ids":[11,42]}`),
},
}
got := filterResolvedSectionsByAccess(sectionsIn, catalog.AccessFilter{
DisabledLibraryIDs: []int{42},
})
if len(got) != 1 {
t.Fatalf("filtered sections length = %d, want 1", len(got))
}
if got[0].ID != "mixed-libraries" {
t.Fatalf("filtered section id = %s, want mixed-libraries", got[0].ID)
}
}
func TestApplyDiversityFilterSkipsDuplicatesInLaterAvoidSection(t *testing.T) {
in := []sections.SectionWithItems{
{ResolvedSection: sections.ResolvedSection{ID: "ra", SectionType: sections.SectionRecentlyAdded}, Items: []*models.MediaItem{{ContentID: "abc"}}},
+111
View File
@@ -0,0 +1,111 @@
package handlers
import (
"net/http"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// serverCollectionsPerLibraryCap bounds how many collections each library
// contributes to the aggregated server-collections response. The frontend
// renders each library as a horizontal teaser row with a "See all" link into
// the per-library Collections tab, so it never needs the full set up front.
// Capping keeps the payload and the per-library presign cost bounded.
const serverCollectionsPerLibraryCap = 20
// serverCollectionsLibrary is one library's bucket of visible server (admin)
// collections. Collections is a capped teaser slice; TotalCount is the full
// visible count, used to drive the "See all (N)" affordance.
type serverCollectionsLibrary struct {
LibraryID int `json:"library_id"`
LibraryName string `json:"library_name"`
TotalCount int `json:"total_count"`
Collections []libraryTabCollection `json:"collections"`
}
type serverCollectionsResponse struct {
Libraries []serverCollectionsLibrary `json:"libraries"`
}
// capServerCollections trims a library's collections to the per-library teaser
// cap and returns the capped slice alongside the original total count (used for
// the frontend's "See all (N)" affordance).
func capServerCollections(collections []*models.LibraryCollection) ([]*models.LibraryCollection, int) {
total := len(collections)
if total > serverCollectionsPerLibraryCap {
return collections[:serverCollectionsPerLibraryCap], total
}
return collections, total
}
// HandleListServerCollections aggregates visible server (admin-curated library)
// collections across every library the requester can access, bucketed by
// library. It powers the "Server collections" section of the user-facing
// Collections tab, surfacing collections that otherwise live only inside each
// individual library's tab.
//
// This is intentionally a separate endpoint from GET /collections (which
// returns the user's editable personal collections): the two have different
// access semantics and cache/refetch lifecycles, and overloading the personal
// response with read-only server collections would be a client-facing trap.
func (h *LibraryCollectionHandler) HandleListServerCollections(w http.ResponseWriter, r *http.Request) {
resp := serverCollectionsResponse{Libraries: []serverCollectionsLibrary{}}
if h.FolderRepo == nil {
writeJSON(w, http.StatusOK, resp)
return
}
folders, err := h.FolderRepo.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load libraries")
return
}
// Folders are already ordered by sort_order; iterate in that order so the
// rendered rows match the user's library ordering.
for _, f := range folders {
if !f.Enabled {
continue
}
// Honor the requester's library access scope; never reveal collections
// from libraries outside AllowedLibraryIDs.
if !requestCanAccessLibrary(r, f.ID) {
continue
}
// ListByLibrary returns only visible collections, ordered
// featured-first, so the capped slice surfaces the best ones.
collections, err := h.repo.ListByLibrary(r.Context(), f.ID, catalog.ListLibraryCollectionsOptions{})
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load collections")
return
}
if len(collections) == 0 {
continue
}
collections, total := capServerCollections(collections)
colls := make([]libraryTabCollection, 0, len(collections))
for _, c := range collections {
colls = append(colls, libraryTabCollection{
ID: c.ID,
Title: c.Title,
PosterURL: h.presignGPURL(r, c.PosterURL),
PosterThumbhash: c.PosterThumbhash,
ItemCount: c.ItemCount,
Featured: c.Featured,
})
}
resp.Libraries = append(resp.Libraries, serverCollectionsLibrary{
LibraryID: f.ID,
LibraryName: f.Name,
TotalCount: total,
Collections: colls,
})
}
writeJSON(w, http.StatusOK, resp)
}
@@ -0,0 +1,47 @@
package handlers
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestCapServerCollections(t *testing.T) {
makeN := func(n int) []*models.LibraryCollection {
out := make([]*models.LibraryCollection, n)
for i := range out {
out[i] = &models.LibraryCollection{ID: string(rune('a' + i%26))}
}
return out
}
t.Run("under cap returns all, total equals length", func(t *testing.T) {
in := makeN(serverCollectionsPerLibraryCap - 3)
got, total := capServerCollections(in)
if total != serverCollectionsPerLibraryCap-3 {
t.Fatalf("total = %d, want %d", total, serverCollectionsPerLibraryCap-3)
}
if len(got) != len(in) {
t.Fatalf("len(got) = %d, want %d", len(got), len(in))
}
})
t.Run("at cap returns all", func(t *testing.T) {
in := makeN(serverCollectionsPerLibraryCap)
got, total := capServerCollections(in)
if total != serverCollectionsPerLibraryCap || len(got) != serverCollectionsPerLibraryCap {
t.Fatalf("got len=%d total=%d, want both %d", len(got), total, serverCollectionsPerLibraryCap)
}
})
t.Run("over cap trims slice but reports full total", func(t *testing.T) {
in := makeN(serverCollectionsPerLibraryCap + 17)
got, total := capServerCollections(in)
if len(got) != serverCollectionsPerLibraryCap {
t.Fatalf("len(got) = %d, want %d", len(got), serverCollectionsPerLibraryCap)
}
if total != serverCollectionsPerLibraryCap+17 {
t.Fatalf("total = %d, want %d", total, serverCollectionsPerLibraryCap+17)
}
})
}
+5
View File
@@ -18,6 +18,7 @@ var ErrServerRestartAlreadyRequested = errors.New("server restart already reques
type ServerControlHandler struct {
requestRestart func(ctx context.Context) error
commands *playback.CommandDispatcher
restartStatus *ServerRestartStatusTracker
}
type serverRestartRequest struct {
@@ -35,10 +36,12 @@ type serverRestartResponse struct {
func NewServerControlHandler(
requestRestart func(ctx context.Context) error,
commands *playback.CommandDispatcher,
restartStatus *ServerRestartStatusTracker,
) *ServerControlHandler {
return &ServerControlHandler{
requestRestart: requestRestart,
commands: commands,
restartStatus: restartStatus,
}
}
@@ -59,6 +62,7 @@ func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Requ
if err := h.requestRestart(context.Background()); err != nil {
if errors.Is(err, ErrServerRestartAlreadyRequested) {
h.restartStatus.MarkRestartRequested()
writeJSON(w, http.StatusAccepted, serverRestartResponse{
Status: "already_requested",
Message: "Server restart is already in progress.",
@@ -71,6 +75,7 @@ func (h *ServerControlHandler) HandleRestart(w http.ResponseWriter, r *http.Requ
return
}
h.restartStatus.MarkRestartRequested()
writeJSON(w, http.StatusAccepted, serverRestartResponse{
Status: "restart_requested",
Message: "Server restart requested. The process will shut down gracefully.",
+8 -4
View File
@@ -24,10 +24,11 @@ func TestServerControlRestartRequestsShutdown(t *testing.T) {
t.Parallel()
called := 0
restartStatus := NewServerRestartStatusTracker()
handler := NewServerControlHandler(func(context.Context) error {
called++
return nil
}, nil)
}, nil, restartStatus)
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
@@ -50,12 +51,15 @@ func TestServerControlRestartRequestsShutdown(t *testing.T) {
if resp.NotifiedSessions != 0 {
t.Fatalf("notified_sessions = %d, want 0", resp.NotifiedSessions)
}
if snapshot := restartStatus.Snapshot(); !snapshot.RestartRequested {
t.Fatal("RestartRequested = false, want true")
}
}
func TestServerControlRestartUnavailable(t *testing.T) {
t.Parallel()
handler := NewServerControlHandler(nil, nil)
handler := NewServerControlHandler(nil, nil, NewServerRestartStatusTracker())
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
@@ -71,7 +75,7 @@ func TestServerControlRestartAlreadyRequested(t *testing.T) {
handler := NewServerControlHandler(func(context.Context) error {
return ErrServerRestartAlreadyRequested
}, nil)
}, nil, NewServerRestartStatusTracker())
req := httptest.NewRequest(http.MethodPost, "/admin/server/restart", nil)
rec := httptest.NewRecorder()
@@ -110,7 +114,7 @@ func TestServerControlRestartNotifiesPlaybackSessions(t *testing.T) {
dispatcher := playback.NewCommandDispatcher(sessionMgr, realtimeHub, nil)
handler := NewServerControlHandler(func(context.Context) error {
return nil
}, dispatcher)
}, dispatcher, NewServerRestartStatusTracker())
req := httptest.NewRequest(
http.MethodPost,
@@ -0,0 +1,102 @@
package handlers
import (
"strings"
"sync"
"time"
)
// ServerRestartStatusTracker keeps process-local restart state. A real server
// restart creates a new tracker, which clears any pending restart banner state.
type ServerRestartStatusTracker struct {
mu sync.RWMutex
startedAt time.Time
restartRequired bool
restartRequiredAt time.Time
restartRequiredReason string
restartRequested bool
restartRequestedAt time.Time
}
type ServerRestartStatusSnapshot struct {
StartedAt time.Time
RestartRequired bool
RestartRequiredAt *time.Time
RestartRequiredReason string
RestartRequested bool
RestartRequestedAt *time.Time
}
func NewServerRestartStatusTracker() *ServerRestartStatusTracker {
return &ServerRestartStatusTracker{
startedAt: time.Now().UTC(),
}
}
func (s *ServerRestartStatusTracker) MarkRequired(reason string) {
if s == nil {
return
}
now := time.Now().UTC()
reason = strings.TrimSpace(reason)
s.mu.Lock()
defer s.mu.Unlock()
if !s.restartRequired {
s.restartRequired = true
s.restartRequiredAt = now
}
if reason != "" {
s.restartRequiredReason = reason
}
}
func (s *ServerRestartStatusTracker) MarkRestartRequested() {
if s == nil {
return
}
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
if !s.restartRequested {
s.restartRequested = true
s.restartRequestedAt = now
}
}
func (s *ServerRestartStatusTracker) Snapshot() ServerRestartStatusSnapshot {
if s == nil {
now := time.Now().UTC()
return ServerRestartStatusSnapshot{StartedAt: now}
}
s.mu.RLock()
defer s.mu.RUnlock()
var restartRequiredAt *time.Time
if !s.restartRequiredAt.IsZero() {
t := s.restartRequiredAt
restartRequiredAt = &t
}
var restartRequestedAt *time.Time
if !s.restartRequestedAt.IsZero() {
t := s.restartRequestedAt
restartRequestedAt = &t
}
return ServerRestartStatusSnapshot{
StartedAt: s.startedAt,
RestartRequired: s.restartRequired,
RestartRequiredAt: restartRequiredAt,
RestartRequiredReason: s.restartRequiredReason,
RestartRequested: s.restartRequested,
RestartRequestedAt: restartRequestedAt,
}
}
+40 -6
View File
@@ -9,13 +9,23 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// deviceSeenThrottle bounds how often a device's last_seen_at is refreshed from
// request traffic. Device-setting reads each registered the device (an upsert
// on a single per-device row), so a page that fetches many settings serialized
// hundreds of upserts on that row's lock. Skipping the upsert when the device
// was seen within this window removes the contention while keeping last_seen
// fresh to within the window.
const deviceSeenThrottle = 5 * time.Minute
const subtitleAppearanceSettingKey = "subtitle_appearance"
const (
libraryPageStateSettingKey = "ui.library_page_state"
@@ -38,11 +48,32 @@ type ServerSettingReader interface {
type SettingsHandler struct {
storeProvider userstore.UserStoreProvider
serverSettings ServerSettingReader
deviceSeen *cache.TTLCache[struct{}]
}
// NewSettingsHandler creates a new SettingsHandler.
func NewSettingsHandler(provider userstore.UserStoreProvider) *SettingsHandler {
return &SettingsHandler{storeProvider: provider}
return &SettingsHandler{
storeProvider: provider,
deviceSeen: cache.NewTTLCache[struct{}](),
}
}
// shouldRegisterDevice reports whether the device's last_seen_at should be
// refreshed now, throttling to one upsert per deviceSeenThrottle window per
// (profile, device). It marks the device seen before returning true so a burst
// of concurrent reads collapses to a single upsert instead of contending on the
// device row.
func (h *SettingsHandler) shouldRegisterDevice(profileID, deviceID string) bool {
if h == nil || h.deviceSeen == nil {
return true
}
key := profileID + "\x00" + deviceID
if _, seen := h.deviceSeen.Get(key); seen {
return false
}
h.deviceSeen.Set(key, struct{}{}, deviceSeenThrottle)
return true
}
// SetServerSettings configures the optional server settings reader for overlay config etc.
@@ -382,7 +413,7 @@ func (h *SettingsHandler) HandleGetDeviceSetting(w http.ResponseWriter, r *http.
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
registerRequestDevice(r.Context(), store, profileID, device)
h.registerRequestDevice(r.Context(), store, profileID, device)
value, err := store.GetDeviceSetting(r.Context(), profileID, device.DeviceID, key)
if err != nil {
@@ -482,7 +513,7 @@ func (h *SettingsHandler) HandleDeleteDeviceSetting(w http.ResponseWriter, r *ht
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
registerRequestDevice(r.Context(), store, profileID, device)
h.registerRequestDevice(r.Context(), store, profileID, device)
if err := store.DeleteDeviceSetting(r.Context(), profileID, device.DeviceID, key); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete device setting")
@@ -512,7 +543,7 @@ func (h *SettingsHandler) HandleGetEffectiveSettings(w http.ResponseWriter, r *h
}
device := deviceMetadataFromRequest(r)
registerRequestDevice(r.Context(), store, profileID, device)
h.registerRequestDevice(r.Context(), store, profileID, device)
keys := parseSettingKeys(keysParam)
resp := effectiveSettingsResponse{
Settings: make([]effectiveSettingResponse, 0, len(keys)),
@@ -543,7 +574,7 @@ func (h *SettingsHandler) HandleGetEffectiveSubtitleAppearance(w http.ResponseWr
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
registerRequestDevice(r.Context(), store, profileID, device)
h.registerRequestDevice(r.Context(), store, profileID, device)
resolved, err := h.resolveEffectiveSetting(r.Context(), store, profileID, device, subtitleAppearanceSettingKey)
if err != nil {
@@ -604,7 +635,7 @@ func deviceMetadataFromRequest(r *http.Request) requestDeviceMetadata {
}
}
func registerRequestDevice(
func (h *SettingsHandler) registerRequestDevice(
ctx context.Context,
store userstore.UserStore,
profileID string,
@@ -616,6 +647,9 @@ func registerRequestDevice(
if store == nil {
return
}
if !h.shouldRegisterDevice(profileID, device.DeviceID) {
return
}
registry, ok := store.(userstore.DeviceRegistry)
if !ok {
return

Some files were not shown because too many files have changed in this diff Show More