Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c5e6d1a1 | ||
|
|
0cd37aa618 | ||
|
|
4d894379df | ||
|
|
44fc0090c7 | ||
|
|
38c7fb44e1 | ||
|
|
c0222a55e1 | ||
|
|
ea6636f9d9 | ||
|
|
8c172ec9dd | ||
|
|
514b72cddb | ||
|
|
6983925b2e | ||
|
|
d897124e94 | ||
|
|
543534a058 | ||
|
|
234491f1cc | ||
|
|
cb0cb04534 | ||
|
|
d4dc695e1d | ||
|
|
f4b9d4cf38 | ||
|
|
fd41f56381 | ||
|
|
aef670c3bc | ||
|
|
3a43537e67 | ||
|
|
bc2b815391 | ||
|
|
7af0cf241e | ||
|
|
209fb50f16 | ||
|
|
a5a0fad86e | ||
|
|
9c6964e1a1 | ||
|
|
901285b225 | ||
|
|
620cd2746b | ||
|
|
355da1a4fd | ||
|
|
8deb18273d | ||
|
|
a8227723bf | ||
|
|
28ef3ac3c8 | ||
|
|
6d77e5a135 |
@@ -1,12 +1,6 @@
|
||||
# Silo Docker deployment
|
||||
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
|
||||
|
||||
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
|
||||
# Linux/macOS example for enabling NVIDIA override:
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
# Windows example:
|
||||
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
|
||||
|
||||
# Required for docker compose: host path to your media library.
|
||||
# Example: MEDIA_ROOT=/srv/media
|
||||
MEDIA_ROOT=/srv/media
|
||||
@@ -42,27 +36,10 @@ 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
|
||||
|
||||
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
|
||||
# NVIDIA_GPU_COUNT=1
|
||||
|
||||
# REQUIRED: master key for at-rest credential encryption. Silo refuses to start
|
||||
# without it. Integration API keys, S3 keys, and other server-owned secrets are
|
||||
# encrypted under a key derived from this value, so it must be at least 32
|
||||
# characters and kept secret. Generate one with:
|
||||
# openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps (treat it like a CA private
|
||||
# key): losing it makes every encrypted secret unrecoverable. See
|
||||
# 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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# v1 scope lock — changes to the lock artifact require maintainer review
|
||||
/docs/architecture/v1-scope.md @Quick104
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
@@ -1,26 +0,0 @@
|
||||
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
|
||||
+1
-2
@@ -20,7 +20,6 @@ coverage.html
|
||||
# Temp
|
||||
/tmp/
|
||||
*.tmp
|
||||
.local/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
@@ -41,7 +40,6 @@ go.work.sum
|
||||
|
||||
# Frontend
|
||||
web/node_modules/
|
||||
.pnpm-store/
|
||||
web/dist/
|
||||
plugins/metadb/dist/
|
||||
web/*.tsbuildinfo
|
||||
@@ -59,5 +57,6 @@ node_modules/
|
||||
logs/
|
||||
.playwright-mcp/
|
||||
demo/
|
||||
docker-compose.dev.yml
|
||||
.playwright-cli/
|
||||
output/
|
||||
|
||||
@@ -88,21 +88,3 @@ 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)
|
||||
|
||||
Silo is working toward a locked v1 feature set so client teams can plan against a stable core. These rules apply to every change.
|
||||
|
||||
**Scope gate.** Before implementing a new user-facing capability, check `docs/architecture/v1-scope.md`. If the capability is not listed there — or the file says scope is not yet locked — do NOT open a feature PR. File a **v1 capability proposal** issue (template in this repo) and wait for triage. Bug fixes, refactors, and work on already-locked capabilities proceed normally.
|
||||
|
||||
**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.
|
||||
|
||||
**Pre-push checklist.** `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`).
|
||||
|
||||
+4
-9
@@ -3,7 +3,6 @@ FROM node:22-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -23,7 +22,6 @@ ENV GONOSUMDB=github.com/Silo-Server/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
@@ -50,16 +48,13 @@ 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 git libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 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 /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
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
COPY --from=build /silo /usr/local/bin/silo
|
||||
EXPOSE 8080 8096 13378
|
||||
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
|
||||
EXPOSE 8080 8096
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
|
||||
ENTRYPOINT ["silo"]
|
||||
|
||||
+4
-9
@@ -5,7 +5,6 @@ FROM node:22-slim AS frontend
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/pnpm-lock.yaml ./
|
||||
COPY web/vendor/foliate-js ./vendor/foliate-js
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
COPY web/ .
|
||||
@@ -20,7 +19,6 @@ ENV GONOSUMDB=github.com/Silo-Server/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
@@ -55,16 +53,13 @@ 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 git libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 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 /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
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
COPY --from=build /silo /usr/local/bin/silo
|
||||
EXPOSE 8080 8096 13378
|
||||
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
|
||||
EXPOSE 8080 8080 8096
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
|
||||
ENTRYPOINT ["silo"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.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
|
||||
.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
|
||||
|
||||
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,8 +15,7 @@ else ifneq ($(wildcard $(SHARED_PLUGIN_SDK_DIR)),)
|
||||
DEV_PLUGIN_SDK_DIR ?= $(SHARED_PLUGIN_SDK_DIR)
|
||||
endif
|
||||
|
||||
JELLYFIN_WEB_INSTALL_DIR ?= .local/compat/jellyfin-web
|
||||
JELLYFIN_WEB_VERSION ?= 10.11.6
|
||||
JELLYFIN_WEB_OUTPUT_DIR ?= third_party/jellyfin-web
|
||||
|
||||
# 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).
|
||||
@@ -83,9 +82,9 @@ install-hooks:
|
||||
fi
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
# 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)"
|
||||
# Fetch and build the pinned Jellyfin Web bundle
|
||||
jellyfin-web-bundle:
|
||||
JELLYFIN_WEB_OUTPUT_DIR=$(JELLYFIN_WEB_OUTPUT_DIR) scripts/fetch-jellyfin-web.sh
|
||||
|
||||
# Read-only preflight for Continuum Docker installs moving to Silo.
|
||||
migrate-continuum-check:
|
||||
|
||||
@@ -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. Includes optional Jellyfin/Emby-compatible app support for clients such as VidHub and Findroid.
|
||||
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.
|
||||
|
||||
Join the community on [Discord](https://discord.gg/4RxuUQAEnW).
|
||||
|
||||
@@ -30,33 +30,10 @@ 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`. Jellyfin-compatible app support is disabled until an administrator enables it in onboarding or admin settings.
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
### Optional NVIDIA/NVENC
|
||||
|
||||
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
|
||||
|
||||
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
|
||||
|
||||
Use the optional override file when you want NVENC:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
|
||||
```
|
||||
|
||||
If you want this controlled from `.env`, set `COMPOSE_FILE`:
|
||||
|
||||
```dotenv
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
|
||||
NVIDIA_GPU_COUNT=1
|
||||
```
|
||||
|
||||
Windows uses `;` instead of `:` between compose files.
|
||||
|
||||
Then `docker compose up -d` will include the NVIDIA override automatically.
|
||||
|
||||
4. **Configure through the admin UI**
|
||||
|
||||
Add libraries, users, metadata providers, and playback settings from the web interface.
|
||||
|
||||
@@ -5,11 +5,11 @@ material needed for Silo server builds.
|
||||
|
||||
## Jellyfin Web
|
||||
|
||||
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.
|
||||
`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.
|
||||
|
||||
## Collection Template Posters
|
||||
|
||||
@@ -17,3 +17,4 @@ license, and source/provenance metadata beside the installed assets.
|
||||
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.
|
||||
|
||||
|
||||
+95
-462
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -19,12 +18,9 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -36,13 +32,11 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/activitylog"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks"
|
||||
"github.com/Silo-Server/silo-server/internal/api"
|
||||
"github.com/Silo-Server/silo-server/internal/api/handlers"
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks"
|
||||
"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"
|
||||
@@ -50,7 +44,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/database"
|
||||
"github.com/Silo-Server/silo-server/internal/ebooks"
|
||||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"github.com/Silo-Server/silo-server/internal/imagecache"
|
||||
@@ -59,7 +52,6 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/libraryingest"
|
||||
"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/markers"
|
||||
"github.com/Silo-Server/silo-server/internal/mdblist"
|
||||
"github.com/Silo-Server/silo-server/internal/metadata"
|
||||
@@ -77,10 +69,11 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/ratelimit"
|
||||
"github.com/Silo-Server/silo-server/internal/recommendations"
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/radarr"
|
||||
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
|
||||
"github.com/Silo-Server/silo-server/internal/s3client"
|
||||
"github.com/Silo-Server/silo-server/internal/scanner"
|
||||
"github.com/Silo-Server/silo-server/internal/scanqueue"
|
||||
"github.com/Silo-Server/silo-server/internal/secret"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
"github.com/Silo-Server/silo-server/internal/server"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
@@ -124,7 +117,7 @@ func resolvePluginCacheDir() string {
|
||||
return filepath.Join(os.TempDir(), "silo-plugins")
|
||||
}
|
||||
|
||||
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
|
||||
func buildBaseHandler(format string, level slog.Level) slog.Handler {
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if strings.EqualFold(format, "json") {
|
||||
return slog.NewJSONHandler(os.Stderr, opts)
|
||||
@@ -132,19 +125,6 @@ func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
|
||||
return slog.NewTextHandler(os.Stderr, opts)
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func mustGetSetting(store interface {
|
||||
Get(context.Context, string) (string, error)
|
||||
}, ctx context.Context, key, fallback string) string {
|
||||
@@ -158,10 +138,11 @@ func mustGetSetting(store interface {
|
||||
func configureOperationalLogging(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
settingsRepo catalog.SettingsStore,
|
||||
settingsRepo *catalog.ServerSettingsRepo,
|
||||
redisCfg config.RedisConfig,
|
||||
logStreamHub *logstream.Hub,
|
||||
filteredHandler slog.Handler,
|
||||
baseHandler slog.Handler,
|
||||
logQuiet string,
|
||||
nodeID string,
|
||||
) (opslog.Writer, *opslog.Repo, *partman.Manager) {
|
||||
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
|
||||
@@ -169,10 +150,7 @@ func configureOperationalLogging(
|
||||
}
|
||||
opsPM := partman.NewManager(pool, "operational_logs", partman.Daily, 3)
|
||||
if err := opsPM.EnsureFuturePartitions(ctx); err != nil {
|
||||
// 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)
|
||||
log.Fatalf("ensure operational log partitions: %v", err)
|
||||
}
|
||||
|
||||
var operationalWriter opslog.Writer
|
||||
@@ -201,6 +179,7 @@ func configureOperationalLogging(
|
||||
opsCaptureLevel = slog.LevelError
|
||||
}
|
||||
|
||||
filteredHandler := logfilter.New(baseHandler, logQuiet)
|
||||
slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID)))
|
||||
|
||||
return operationalWriter, opslog.NewRepo(pool), opsPM
|
||||
@@ -270,88 +249,7 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
|
||||
}
|
||||
}
|
||||
|
||||
// runCredentialBackfills sweeps any plaintext server-owned credential to
|
||||
// ciphertext on the primary (migration-running) node. All passes are
|
||||
// best-effort: a failed row leaves the prior plaintext (no new exposure) and
|
||||
// still reads via the read-path pass-through, so a backfill error must never
|
||||
// block boot. The sensitive-settings pass runs first so the arr
|
||||
// resolve-then-encrypt pass sees consistent referenced settings.
|
||||
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
|
||||
settingsN, err := settings.BackfillSensitiveSettings(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: sensitive settings", "error", err)
|
||||
}
|
||||
columnsN, err := secret.BackfillColumns(ctx, pool, cipher, secret.ColumnBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: credential columns", "error", err)
|
||||
}
|
||||
historyServersN, err := historyimport.NewRepository(pool, cipher).BackfillSessionServerSecrets(ctx)
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: history import session server credentials", "error", err)
|
||||
}
|
||||
// The arr resolver is the encrypting settings decorator: it decrypts a
|
||||
// sensitive target (e.g. requests.radarr.api_key) or passes through a
|
||||
// plaintext custom key, exactly replicating the deleted resolveAPIKey.
|
||||
arrN, err := secret.BackfillReferencedColumns(ctx, pool, cipher, settings.Get, secret.ArrKeyBackfillTargets())
|
||||
if err != nil {
|
||||
slog.Error("secret backfill: arr api keys", "error", err)
|
||||
}
|
||||
if total := settingsN + columnsN + historyServersN + arrN; total > 0 {
|
||||
slog.Info("secret backfill: encrypted plaintext credentials at rest",
|
||||
"settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -365,15 +263,6 @@ func main() {
|
||||
log.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
// Construct the at-rest credential cipher from SECRET_KEY immediately after
|
||||
// bootstrap, before any settings repo is built. It is threaded explicitly as
|
||||
// a dependency into every repo that stores a server-owned secret — never a
|
||||
// package-level global.
|
||||
dataCipher, err := secret.New(bc.SecretKey)
|
||||
if err != nil {
|
||||
log.Fatalf("secret cipher: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Connect to PostgreSQL (bootstrap pool with default max connections)
|
||||
bootstrapDBCfg := config.DatabaseConfig{URL: bc.DatabaseURL, MaxConnections: 20}
|
||||
pool, err := database.NewPool(ctx, bootstrapDBCfg)
|
||||
@@ -384,7 +273,7 @@ func main() {
|
||||
slog.Info("connected to PostgreSQL")
|
||||
|
||||
if *migrateStatus {
|
||||
migCtx, migCancel := database.MigrationContext(ctx)
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
statuses, statusErr := database.MigrationStatuses(migCtx, pool, migrations.FS, "sql")
|
||||
migCancel()
|
||||
if statusErr != nil {
|
||||
@@ -409,7 +298,7 @@ func main() {
|
||||
}
|
||||
|
||||
if *migrateOnly {
|
||||
migCtx, migCancel := database.MigrationContext(ctx)
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql")
|
||||
migCancel()
|
||||
if migErr != nil {
|
||||
@@ -422,12 +311,8 @@ func main() {
|
||||
// Run migrations only for integrated/api modes. Proxy and transcode nodes
|
||||
// should never alter the schema — they may scale independently and would
|
||||
// race or apply migrations before the primary node is deliberately upgraded.
|
||||
// The same gate decides whether this node runs the credential-encryption
|
||||
// backfills: only the primary (migration-running) node sweeps plaintext to
|
||||
// ciphertext; secondary nodes read whatever the primary encrypted.
|
||||
isPrimaryNode := bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == ""
|
||||
if isPrimaryNode {
|
||||
migCtx, migCancel := database.MigrationContext(ctx)
|
||||
if bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == "" {
|
||||
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql"); migErr != nil {
|
||||
migCancel()
|
||||
log.Fatalf("failed to run migrations: %v", migErr)
|
||||
@@ -436,14 +321,8 @@ func main() {
|
||||
slog.Info("database migrations applied")
|
||||
}
|
||||
|
||||
// Step 3: Load settings from DB. settingsRepo is the encrypting decorator so
|
||||
// every consumer (config.LoadFromDB, admin, ABS, watchers) transparently sees
|
||||
// plaintext while sensitive keys rest as ciphertext. The settings backfill
|
||||
// (run after migrations, before this GetAll) is wired further below.
|
||||
settingsRepo := catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
if isPrimaryNode {
|
||||
runCredentialBackfills(ctx, pool, dataCipher, settingsRepo)
|
||||
}
|
||||
// Step 3: Load settings from DB
|
||||
settingsRepo := catalog.NewServerSettingsRepo(pool)
|
||||
settings, err := settingsRepo.GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("loading settings: %v", err)
|
||||
@@ -514,9 +393,7 @@ func main() {
|
||||
log.Fatalf("recreating pool with configured max_connections: %v", err)
|
||||
}
|
||||
}
|
||||
// Re-wrap with the encrypting decorator so the recreated pool's settings repo
|
||||
// still encrypts/decrypts — no raw settings repo may escape into later wiring.
|
||||
settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
|
||||
settingsRepo = catalog.NewServerSettingsRepo(pool)
|
||||
nodeID := resolveNodeIdentity()
|
||||
|
||||
// Step 9: Validate
|
||||
@@ -524,14 +401,20 @@ func main() {
|
||||
log.Fatalf("config validation: %v", err)
|
||||
}
|
||||
|
||||
// Step 10: Configure log level. The level var and quiet filter are
|
||||
// shared with the operational-logging handler chain and hot-reloaded by
|
||||
// the config watcher in integrated mode.
|
||||
logLevelVar := new(slog.LevelVar)
|
||||
logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel))
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar)
|
||||
quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet)
|
||||
slog.SetDefault(slog.New(quietFilter))
|
||||
// Step 10: Configure log level
|
||||
var slogLevel slog.Level
|
||||
switch strings.ToLower(cfg.Server.LogLevel) {
|
||||
case "debug":
|
||||
slogLevel = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
slogLevel = slog.LevelWarn
|
||||
case "error":
|
||||
slogLevel = slog.LevelError
|
||||
default:
|
||||
slogLevel = slog.LevelInfo
|
||||
}
|
||||
baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel)
|
||||
slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet)))
|
||||
|
||||
mode := cfg.Server.Mode
|
||||
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
|
||||
@@ -539,8 +422,6 @@ func main() {
|
||||
|
||||
appCtx, appCancel := context.WithCancel(ctx)
|
||||
defer appCancel()
|
||||
restartReqCh := make(chan struct{}, 1)
|
||||
var restartRequested atomic.Bool
|
||||
|
||||
eventBus := cache.NewEventBus(cfg.Redis.URL)
|
||||
logStreamHub := logstream.NewHub(nodeID, eventBus)
|
||||
@@ -553,7 +434,7 @@ func main() {
|
||||
}
|
||||
eventsHub := realtimeHub.EventsHub()
|
||||
scanRegistry := evt.NewScanRegistry()
|
||||
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
|
||||
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, baseHandler, cfg.Server.LogQuiet, nodeID)
|
||||
defer func() {
|
||||
if err := eventBus.Close(); err != nil {
|
||||
slog.Warn("event bus close error", "error", err)
|
||||
@@ -573,9 +454,8 @@ func main() {
|
||||
Mode: cfg.Server.Mode,
|
||||
DatabaseURL: cfg.Database.URL,
|
||||
JFListen: cfg.JellyfinCompat.Listen,
|
||||
RedisURL: bc.RedisURL,
|
||||
}
|
||||
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
|
||||
watcher := nodeconfig.NewWatcher(pool, eventBus, bootstrap)
|
||||
if err := watcher.Start(appCtx); err != nil {
|
||||
slog.Error("config watcher start failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -615,29 +495,6 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Hot-reload config watcher for integrated/api mode. Reloads on
|
||||
// EventSettingsChanged (Redis) with a 60s poll fallback, so settings
|
||||
// changes apply without restart even on Redis-less deployments. The
|
||||
// watcher's config supersedes the startup snapshot from here on.
|
||||
configWatcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, nodeconfig.BootstrapOverrides{
|
||||
Listen: bc.Listen,
|
||||
Mode: bc.Mode,
|
||||
DatabaseURL: bc.DatabaseURL,
|
||||
JFListen: bc.JFListen,
|
||||
RedisURL: bc.RedisURL,
|
||||
})
|
||||
if err := configWatcher.Start(appCtx); err != nil {
|
||||
log.Fatalf("config watcher start: %v", err)
|
||||
}
|
||||
cfg = configWatcher.Config()
|
||||
|
||||
// Apply server.log_level / server.log_quiet changes live. Both feed the
|
||||
// shared level var and quiet filter inside the default logger chain.
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
logLevelVar.Set(parseLogLevel(updated.Server.LogLevel))
|
||||
quietFilter.SetQuiet(updated.Server.LogQuiet)
|
||||
})
|
||||
|
||||
// Determine which components to initialize based on mode.
|
||||
needsS3 := mode == "integrated" || mode == "api"
|
||||
needsScanner := mode == "integrated" || mode == "api"
|
||||
@@ -651,27 +508,13 @@ 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,
|
||||
OnConfigChange: configWatcher.OnChange,
|
||||
BootstrapSensitiveConfigured: bootstrapSensitiveConfigured,
|
||||
BootstrapSensitiveValues: bootstrapSensitiveValues,
|
||||
AppContext: appCtx,
|
||||
DB: pool,
|
||||
SecretCipher: dataCipher,
|
||||
EventBus: eventBus,
|
||||
RedisClient: apiRedisClient,
|
||||
LogStreamHub: logStreamHub,
|
||||
RealtimeHub: realtimeHub,
|
||||
EventsHub: eventsHub,
|
||||
@@ -679,25 +522,6 @@ func main() {
|
||||
OpsLogRepo: opsRepo,
|
||||
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
|
||||
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
|
||||
RequestServerRestart: func(context.Context) error {
|
||||
if !restartRequested.CompareAndSwap(false, true) {
|
||||
return handlers.ErrServerRestartAlreadyRequested
|
||||
}
|
||||
restartReqCh <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
OnServerSettingUpdated: func(_ context.Context, _, _ string) {
|
||||
// Nudge the hot-reload watcher so same-process settings changes
|
||||
// apply immediately even without Redis (the event bus is a no-op
|
||||
// then, leaving only the 60s poll).
|
||||
configWatcher.RequestReload()
|
||||
},
|
||||
}
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
|
||||
if err != nil {
|
||||
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
|
||||
absCompatEnabled = false
|
||||
}
|
||||
adminJobCancelRegistry := adminjob.NewCancelRegistry()
|
||||
deps.AdminJobCancelRegistry = adminJobCancelRegistry
|
||||
@@ -753,7 +577,7 @@ func main() {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
watchProviderService = watchsync.NewService(
|
||||
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
|
||||
watchsync.NewPostgresRepository(deps.DB),
|
||||
watchProviderRegistry,
|
||||
)
|
||||
deps.WatchProviderService = watchProviderService
|
||||
@@ -774,7 +598,6 @@ func main() {
|
||||
|
||||
deps.ProxyPool = proxyPool
|
||||
deps.TranscodePool = transcodePool
|
||||
deps.NodePlanner = nodepool.NewPlanner(proxyPool, transcodePool)
|
||||
|
||||
healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo)
|
||||
healthChecker.Start(appCtx)
|
||||
@@ -811,9 +634,6 @@ func main() {
|
||||
|
||||
ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)
|
||||
s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
s.SetWorkers(updated.Scanner.Workers)
|
||||
})
|
||||
deps.Scanner = s
|
||||
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
|
||||
slog.Info("scanner initialized")
|
||||
@@ -1049,8 +869,6 @@ func main() {
|
||||
var groupClaimRepo *catalog.GroupClaimRepository
|
||||
var seasonRepo *catalog.SeasonRepository
|
||||
var episodeRepo *catalog.EpisodeRepository
|
||||
var audiobookEnricher *audiobooks.Enricher
|
||||
var ebookEnricher *ebooks.Enricher
|
||||
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
|
||||
chainRepo := metadata.NewChainRepository(deps.DB)
|
||||
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
|
||||
@@ -1121,26 +939,6 @@ func main() {
|
||||
personRefreshService = metadata.NewPersonRefreshService(deps.DB, pluginResolver, personRepo)
|
||||
personRefreshService.SetImageResolver(imageResolver)
|
||||
|
||||
// Wire the audiobook enricher. It uses the same plugin resolver and chain
|
||||
// repo as the movie/TV pipeline, but resolves providers at
|
||||
// content_level='audiobook' and sweeps items directly rather than via a queue.
|
||||
audiobookEnricher = audiobooks.NewEnricher(
|
||||
deps.DB,
|
||||
chainRepo,
|
||||
pluginResolver,
|
||||
itemRepo,
|
||||
personRepo,
|
||||
providerIDRepo,
|
||||
)
|
||||
ebookEnricher = ebooks.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.
|
||||
metadataService.SetImageResolver(imageResolver)
|
||||
@@ -1151,30 +949,13 @@ func main() {
|
||||
imageCacher := imagecache.New(deps.S3Public)
|
||||
metadataService.SetImageCacher(imageCacher)
|
||||
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
metadataService.SetAutoCacheImages(updated.Metadata.CacheImages)
|
||||
})
|
||||
if deps.Scanner != nil {
|
||||
deps.Scanner.SetImageCacher(imageCacher)
|
||||
}
|
||||
if cfg.Metadata.CacheImages {
|
||||
personRefreshService.SetImageCacher(imageCacher)
|
||||
slog.Info("metadata image caching enabled")
|
||||
}
|
||||
if audiobookEnricher != nil {
|
||||
audiobookEnricher.SetImageCacher(imageCacher)
|
||||
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
ebookEnricher.SetImageCacher(imageCacher)
|
||||
}
|
||||
}
|
||||
|
||||
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
|
||||
mwForReload := matchWorker
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mwForReload.SetConcurrency(updated.Matcher.Workers, updated.Matcher.BatchSize)
|
||||
})
|
||||
matchWorker.SetRealtimeHub(deps.RealtimeHub)
|
||||
if movieQueueRepo != nil {
|
||||
matchWorker.SetMovieFileClaimer(movieQueueRepo)
|
||||
@@ -1275,10 +1056,8 @@ func main() {
|
||||
cfg.Scanner.MaxConcurrentLibraries,
|
||||
cfg.Scanner.MaxConcurrentScoped,
|
||||
)
|
||||
// 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.
|
||||
libraryScanQueue.Start()
|
||||
defer libraryScanQueue.Stop()
|
||||
deps.LibraryScanQueue = libraryScanQueue
|
||||
}
|
||||
if deps.DB != nil && deps.FileRepo != nil && metadataService != nil {
|
||||
@@ -1340,48 +1119,6 @@ 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)
|
||||
}
|
||||
@@ -1405,7 +1142,7 @@ func main() {
|
||||
deps.UserStoreProvider = userStoreProvider
|
||||
}
|
||||
if watchProviderService != nil {
|
||||
historyRepo := historyimport.NewRepository(deps.DB, deps.SecretCipher)
|
||||
historyRepo := historyimport.NewRepository(deps.DB)
|
||||
historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, catalog.NewProviderIDRepository(deps.DB))
|
||||
watchProviderService.
|
||||
WithMatcher(historyimport.NewMatcher(historyRepo)).
|
||||
@@ -1584,9 +1321,7 @@ func main() {
|
||||
}
|
||||
activityPM := partman.NewManager(pool, "activity_log", partman.Weekly, 2)
|
||||
if err := activityPM.EnsureFuturePartitions(appCtx); err != nil {
|
||||
// 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)
|
||||
log.Fatalf("ensure activity log partitions: %v", err)
|
||||
}
|
||||
var activityWriter activitylog.Writer
|
||||
activityConsumer := activitylog.NewConsumer(pool, nil, logStreamHub)
|
||||
@@ -1655,10 +1390,6 @@ func main() {
|
||||
deps.UserCollectionSync = userSync
|
||||
deps.UserCollectionScheduler = userCollectionScheduler
|
||||
deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil)
|
||||
mdblistForReload := deps.MDBListClient
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
mdblistForReload.SetAPIKey(updated.MDBListAPIKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1674,7 +1405,6 @@ func main() {
|
||||
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
|
||||
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
|
||||
}
|
||||
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
|
||||
if deps.IntroAnalyzer != nil {
|
||||
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
|
||||
}
|
||||
@@ -1688,11 +1418,6 @@ 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))
|
||||
}
|
||||
@@ -1715,15 +1440,15 @@ func main() {
|
||||
taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService))
|
||||
}
|
||||
requestReconcileSvc := mediarequests.NewService(
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
mediarequests.NewRepository(deps.DB),
|
||||
nil,
|
||||
mediarequests.NewCatalogPresence(
|
||||
catalog.NewItemRepository(deps.DB),
|
||||
catalog.NewProviderIDRepository(deps.DB),
|
||||
),
|
||||
)
|
||||
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
|
||||
api.AttachRequestRouter(requestReconcileSvc, pluginService)
|
||||
requestReconcileSvc.SetSecretResolver(settingsRepo)
|
||||
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
|
||||
if userStoreProvider != nil {
|
||||
reconcileResolver := access.NewResolver(
|
||||
auth.NewUserRepository(deps.DB),
|
||||
@@ -1732,20 +1457,15 @@ 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)
|
||||
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
|
||||
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
|
||||
}
|
||||
autoscanRepo := autoscan.NewRepository(deps.DB)
|
||||
autoscanSvc := api.BuildAutoscanService(
|
||||
autoscanRepo,
|
||||
pluginService,
|
||||
pluginInstallationStore,
|
||||
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
|
||||
mediarequests.NewRepository(deps.DB),
|
||||
settingsRepo,
|
||||
deps.FolderRepo,
|
||||
deps.LibraryScanQueue,
|
||||
deps.RedisClient,
|
||||
@@ -1768,13 +1488,6 @@ func main() {
|
||||
historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver)
|
||||
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
|
||||
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
|
||||
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
|
||||
if audiobookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
|
||||
}
|
||||
if ebookEnricher != nil {
|
||||
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
|
||||
}
|
||||
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
|
||||
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
|
||||
if err != nil {
|
||||
@@ -1791,59 +1504,11 @@ func main() {
|
||||
slog.Info("task manager started")
|
||||
}
|
||||
|
||||
// Build the ABS-compatible REST + Socket.io handler when a DB pool is
|
||||
// available. Routes are mounted at the root level by NewRouter (not under
|
||||
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
|
||||
// /abs/socket.io/* without path prefix hacks.
|
||||
if absCompatEnabled && deps.DB != nil {
|
||||
absUserRepo := auth.NewUserRepository(deps.DB)
|
||||
absSessionRepo := auth.NewSessionRepository(deps.DB)
|
||||
absJWTService := auth.NewJWTService(
|
||||
cfg.Auth.JWTSecret,
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
absJWTService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
absAuthSvc := auth.NewService(
|
||||
auth.NewLocalProvider(absUserRepo, absSessionRepo),
|
||||
absJWTService,
|
||||
absSessionRepo,
|
||||
absUserRepo,
|
||||
nil, // invite codes: not needed for ABS compat
|
||||
nil, // settings: not needed here
|
||||
nil, // user store: not needed here
|
||||
)
|
||||
absItemRepo := catalog.NewItemRepository(deps.DB)
|
||||
absEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
|
||||
absSeasonRepo := catalog.NewSeasonRepository(deps.DB)
|
||||
absPersonRepo := catalog.NewPersonRepository(deps.DB)
|
||||
var absFileFetcher catalog.FileVersionFetcher
|
||||
if deps.FileRepo != nil {
|
||||
absFileFetcher = deps.FileRepo
|
||||
}
|
||||
absDetailSvc := catalog.NewDetailService(absItemRepo, absEpisodeRepo, absSeasonRepo, absPersonRepo, absFileFetcher)
|
||||
if deps.ImageResolver != nil {
|
||||
absDetailSvc.SetImageResolver(deps.ImageResolver)
|
||||
}
|
||||
absHDeps := audiobooks.ABSHandlerDeps{
|
||||
Pool: deps.DB,
|
||||
Items: absItemRepo,
|
||||
Files: deps.FileRepo,
|
||||
Settings: settingsRepo,
|
||||
Auth: &audiobooks.SiloCredValidator{
|
||||
Auth: absAuthSvc,
|
||||
Pool: deps.DB,
|
||||
},
|
||||
AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider),
|
||||
Recs: recommendations.NewRepo(deps.DB),
|
||||
Detail: absDetailSvc,
|
||||
}
|
||||
absH := audiobooksService.BuildABSHandler(absHDeps)
|
||||
deps.ABSHandler = absH
|
||||
}
|
||||
_ = audiobooksService
|
||||
// Scaffold audiobooks service (sub-plan 1: kill-switch reader only).
|
||||
// audiobooksSettingsAdapter (defined at package level below) bridges
|
||||
// catalog.ServerSettingsRepo.Get to audiobooks.SettingsReader.GetString.
|
||||
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
|
||||
_ = audiobooksService // sub-plan 2+ will register routes / scheduled tasks
|
||||
|
||||
if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil {
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
@@ -1960,32 +1625,12 @@ 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).
|
||||
metricsMux := http.NewServeMux()
|
||||
metricsMux.Handle("/metrics", promhttp.Handler())
|
||||
metricsMux.Handle("/api/", router)
|
||||
// ABS-compat is NOT mounted on the main listener — see the "ABS compat
|
||||
// listener" block below. It binds its own port so the discovery probes
|
||||
// (/ping, /healthcheck, /status, /init, /login, /socket.io) own the URL
|
||||
// space without collision with silo's SPA fallback. Mirrors how the
|
||||
// Jellyfin compat server is set up at :8096.
|
||||
metricsMux.Handle("/", server.FrontendHandler())
|
||||
|
||||
// Step 9: Start background workers (if needed).
|
||||
@@ -2082,14 +1727,13 @@ func main() {
|
||||
}
|
||||
|
||||
var compatSrv *http.Server
|
||||
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Enabled && cfg.JellyfinCompat.Listen != "" {
|
||||
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" {
|
||||
compatDeps := jellycompat.Dependencies{
|
||||
Config: cfg,
|
||||
LiveConfig: configWatcher.Config,
|
||||
DB: deps.DB,
|
||||
SecretCipher: dataCipher,
|
||||
ClientIPResolver: ipResolver,
|
||||
NodePlanner: deps.NodePlanner,
|
||||
ProxyPool: deps.ProxyPool,
|
||||
TranscodePool: deps.TranscodePool,
|
||||
JWTSecret: cfg.Auth.JWTSecret,
|
||||
RecWorker: recWorker,
|
||||
}
|
||||
@@ -2140,7 +1784,7 @@ func main() {
|
||||
compatDeps.FileResolver = deps.FileRepo
|
||||
}
|
||||
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher)
|
||||
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB)
|
||||
|
||||
// Construct auth service for jellycompat login.
|
||||
userRepo := auth.NewUserRepository(deps.DB)
|
||||
@@ -2153,23 +1797,50 @@ func main() {
|
||||
cfg.Auth.AccessTokenExpiry,
|
||||
cfg.Auth.RefreshTokenExpiry,
|
||||
)
|
||||
configWatcher.OnChange(func(_, updated *config.Config) {
|
||||
jwtService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
|
||||
})
|
||||
provider := auth.NewLocalProvider(userRepo, sessionRepo)
|
||||
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
|
||||
|
||||
// Access filter resolver for viewer-scoped library access.
|
||||
// Backed by the shared access.Resolver so account-level library
|
||||
// restrictions (users.library_ids), profile restrictions,
|
||||
// user-disabled libraries, and rating/quality ceilings apply to
|
||||
// the compat API exactly as they do to the native API.
|
||||
if userStoreProvider != nil {
|
||||
compatDeps.AccessFilterFn = jellycompat.NewScopeAccessFilter(access.NewResolver(
|
||||
userRepo,
|
||||
userStoreProvider,
|
||||
nil, // profile tokens unused: compat login already verifies PINs
|
||||
))
|
||||
// Access filter resolver for profile-scoped library access.
|
||||
compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
||||
if userStoreProvider == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
store, err := userStoreProvider.ForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
profile, err := store.GetProfile(ctx, profileID)
|
||||
if err != nil || profile == nil {
|
||||
return catalog.AccessFilter{}
|
||||
}
|
||||
filter := catalog.AccessFilter{
|
||||
MaxContentRating: profile.MaxContentRating,
|
||||
}
|
||||
if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 {
|
||||
filter.AllowedLibraryIDs = profile.AllowedLibraryIDs
|
||||
}
|
||||
// Apply user-disabled library IDs.
|
||||
if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" {
|
||||
var disabled []int
|
||||
if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 {
|
||||
if filter.AllowedLibraryIDs != nil {
|
||||
filtered := make([]int, 0, len(filter.AllowedLibraryIDs))
|
||||
disSet := make(map[int]struct{}, len(disabled))
|
||||
for _, id := range disabled {
|
||||
disSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range filter.AllowedLibraryIDs {
|
||||
if _, ok := disSet[id]; !ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
filter.AllowedLibraryIDs = filtered
|
||||
} else {
|
||||
filter.DisabledLibraryIDs = disabled
|
||||
}
|
||||
}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2182,28 +1853,6 @@ func main() {
|
||||
compatSrv.IdleTimeout = 120 * time.Second
|
||||
}
|
||||
|
||||
// ABS-compat listener — dedicated http.Server bound to its own port
|
||||
// (default :13378) that hosts the Audiobookshelf-compatible API.
|
||||
// Mirrors the Jellyfin compat layout above. The ABS handler mounts
|
||||
// onto a fresh chi router here so /ping, /healthcheck, /status, /login,
|
||||
// /socket.io, etc. own the URL space at the root — no SPA fallback,
|
||||
// no collision with silo's /api/v1.
|
||||
var absSrv *http.Server
|
||||
if (mode == "integrated" || mode == "api") && deps.ABSHandler != nil && cfg.AudiobookshelfCompat.Listen != "" {
|
||||
absRouter := chi.NewRouter()
|
||||
absRouter.Use(chimiddleware.Recoverer)
|
||||
absRouter.Use(chimiddleware.Compress(5))
|
||||
deps.ABSHandler.Mount(absRouter)
|
||||
absSrv = &http.Server{
|
||||
Addr: cfg.AudiobookshelfCompat.Listen,
|
||||
Handler: absRouter,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 0,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Run non-critical startup work in the background so it doesn't delay the
|
||||
// HTTP listener from accepting connections. Steps run sequentially and stop
|
||||
// early if the app context is cancelled (shutdown).
|
||||
@@ -2228,7 +1877,7 @@ func main() {
|
||||
}()
|
||||
}
|
||||
|
||||
errCh := make(chan error, 3)
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
slog.Info("HTTP server listening", "addr", cfg.Server.Listen)
|
||||
if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
@@ -2243,14 +1892,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
if absSrv != nil {
|
||||
go func() {
|
||||
slog.Info("ABS compat server listening", "addr", absSrv.Addr)
|
||||
if listenErr := absSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("abs compat server error: %w", listenErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Step 11: Wait for termination signal.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -2261,9 +1902,6 @@ func main() {
|
||||
case sig := <-sigCh:
|
||||
appCancel()
|
||||
slog.Info("received signal, shutting down", "signal", sig)
|
||||
case <-restartReqCh:
|
||||
appCancel()
|
||||
slog.Info("server restart requested, shutting down")
|
||||
case serverErr := <-errCh:
|
||||
appCancel()
|
||||
slog.Error("server error, shutting down", "error", serverErr)
|
||||
@@ -2283,11 +1921,6 @@ func main() {
|
||||
slog.Error("jellyfin compat shutdown error", "error", shutdownErr)
|
||||
}
|
||||
}
|
||||
if absSrv != nil {
|
||||
if shutdownErr := absSrv.Shutdown(shutdownCtx); shutdownErr != nil {
|
||||
slog.Error("abs compat shutdown error", "error", shutdownErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clean up stale sessions.
|
||||
if sessionCleaner != nil {
|
||||
@@ -2643,7 +2276,7 @@ func mapFolderTypeToMediaType(t string) string {
|
||||
// exposes Get) to the audiobooks.SettingsReader interface (which
|
||||
// requires GetString). The two signatures are identical modulo name.
|
||||
type audiobooksSettingsAdapter struct {
|
||||
repo catalog.SettingsStore
|
||||
repo *catalog.ServerSettingsRepo
|
||||
}
|
||||
|
||||
func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
# 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:
|
||||
@@ -1,20 +0,0 @@
|
||||
services:
|
||||
silo:
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: ${NVIDIA_GPU_COUNT:-1}
|
||||
capabilities: [gpu]
|
||||
|
||||
# Optional distributed-mode example. Most single-host installs should
|
||||
# just use the integrated service above.
|
||||
# silo-transcode:
|
||||
# deploy:
|
||||
# resources:
|
||||
# reservations:
|
||||
# devices:
|
||||
# - driver: nvidia
|
||||
# count: ${NVIDIA_GPU_COUNT:-1}
|
||||
# capabilities: [gpu]
|
||||
@@ -36,11 +36,6 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: integrated
|
||||
# Master key for at-rest credential encryption. Required — the server
|
||||
# refuses to start without it. Generate with: openssl rand -base64 48
|
||||
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
|
||||
# secrets unrecoverable. See docs/architecture/secret-encryption.md.
|
||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
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
|
||||
@@ -48,15 +43,11 @@ services:
|
||||
ports:
|
||||
- "${PORT:-8090}:8080"
|
||||
- "${JF_PORT:-8096}:8096"
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- ${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
|
||||
- /proc/meminfo:/host/proc/meminfo:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -77,8 +68,6 @@ services:
|
||||
# profiles: [proxy]
|
||||
# environment:
|
||||
# MODE: proxy
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
# REDIS_URL: redis://redis:6379
|
||||
# PORT: "8080"
|
||||
@@ -103,8 +92,6 @@ services:
|
||||
# profiles: [transcode]
|
||||
# environment:
|
||||
# MODE: transcode
|
||||
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
|
||||
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
|
||||
# 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
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,49 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,142 +0,0 @@
|
||||
# At-rest credential encryption
|
||||
|
||||
Silo encrypts third-party integration credentials and other server-owned secrets
|
||||
at rest. Sonarr/Radarr API keys, S3 keys, watch-sync OAuth tokens, the
|
||||
Audiobookshelf-compat signing key, and the sensitive `server_settings` entries
|
||||
are stored as ciphertext in PostgreSQL and decrypted only in memory. The key that
|
||||
protects them lives **outside** the database, so a full DB dump or compromise
|
||||
does not expose the secrets.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Cipher:** AES-256-GCM with a random 12-byte nonce per value. Implemented in
|
||||
`internal/secret`.
|
||||
- **Key derivation:** the data key is HKDF-SHA256–derived from the `SECRET_KEY`
|
||||
environment variable with a versioned domain label. `SECRET_KEY` is the
|
||||
encryption root; it is never stored in the database.
|
||||
- **Envelope:** every ciphertext is stored as `enc:v1:<base64url(nonce‖sealed)>`.
|
||||
The `enc:v1:` prefix is the version marker (see *Key rotation* below).
|
||||
- **Row binding (AAD):** each ciphertext is GCM-bound to its logical row via
|
||||
additional-authenticated data — `table:column:<pk>` for per-row columns,
|
||||
`server_settings:<key>` for settings. A DB-write attacker therefore cannot move
|
||||
a credential blob from one row/column to another: decrypting under a different
|
||||
binding fails authentication.
|
||||
- **Read path:** an empty value stays empty; a non-`enc:v1:` value is treated as
|
||||
legacy plaintext and passed through unchanged (so a not-yet-migrated row keeps
|
||||
working); an `enc:v1:` value is decrypted and **any** failure (wrong key,
|
||||
tampering, truncation) is surfaced as an error — it is never silently used as a
|
||||
credential.
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
`SECRET_KEY` is **required**. The server refuses to start without it (it fatals
|
||||
in `config.LoadBootstrap`), and it must be at least 32 characters.
|
||||
|
||||
Generate one with:
|
||||
|
||||
```bash
|
||||
openssl rand -base64 48
|
||||
```
|
||||
|
||||
Set it in the deployment environment (or in `.env` for source/dev runs, which
|
||||
`godotenv` loads at startup). See `.env.example`.
|
||||
|
||||
### Back it up — separately from the database
|
||||
|
||||
Treat `SECRET_KEY` like a CA private key:
|
||||
|
||||
- **Store it outside your database backups.** A DB dump contains only ciphertext;
|
||||
the value of at-rest encryption is entirely lost if the key travels with the
|
||||
dump.
|
||||
- Keep it in a secrets manager / sealed secret / password manager, not in the
|
||||
repo or in the same bucket as your Postgres backups.
|
||||
- Rotating the *deployment* (new host, restored DB) requires the **same**
|
||||
`SECRET_KEY`. Restoring a database backup onto a node with a different
|
||||
`SECRET_KEY` makes every encrypted secret unreadable.
|
||||
|
||||
### Key loss
|
||||
|
||||
If `SECRET_KEY` is lost, the encrypted secrets are **unrecoverable** — that is the
|
||||
point of keeping the key out of the database. Recovery means re-entering the
|
||||
affected credentials:
|
||||
|
||||
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections,
|
||||
history-import tokens, and subtitle provider credentials.
|
||||
- `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and
|
||||
users must log in again (a new secret is generated on next boot if the row is
|
||||
cleared — see below).
|
||||
|
||||
## Rollout (automatic backfill)
|
||||
|
||||
On the first boot after deploying this change, the primary (migration-running)
|
||||
node runs an idempotent, best-effort startup backfill that encrypts any
|
||||
remaining plaintext in place:
|
||||
|
||||
1. sensitive `server_settings` keys,
|
||||
2. the per-table credential columns (subtitles, watch-sync, webhook-sync,
|
||||
history-import, including temporary server-list credentials stored in
|
||||
session JSON),
|
||||
3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a
|
||||
legacy row that held a `server_settings` reference (e.g.
|
||||
`requests.radarr.api_key`) is collapsed to the real credential before being
|
||||
encrypted.
|
||||
|
||||
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
|
||||
a per-row guard makes concurrent multi-node boots converge without
|
||||
double-encrypting. A failed row is logged and left as plaintext (no new exposure)
|
||||
rather than blocking boot. Proxy/transcode nodes skip the backfill; they read
|
||||
whatever the primary encrypted. No manual steps are required.
|
||||
|
||||
## Rollback / downgrade hazard
|
||||
|
||||
Downgrading to a binary that predates this change is **not** safe while secrets
|
||||
are encrypted, because the old binary has no read path: it would read
|
||||
`enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and
|
||||
read `enc:v1:`-prefixed integration keys as garbage credentials.
|
||||
|
||||
To downgrade safely you must first return the affected values to plaintext, for
|
||||
example:
|
||||
|
||||
- Decrypt the sensitive `server_settings` back to plaintext (run a one-off that
|
||||
reads each via the encrypting repo and writes the plaintext via the raw repo),
|
||||
**or**
|
||||
- At minimum, clear `auth.jwt_secret` so the older binary regenerates a fresh
|
||||
plaintext one (this still logs everyone out), and re-enter any integration
|
||||
credentials as plaintext.
|
||||
|
||||
There is no automatic "decrypt everything" downgrade path in this release; plan
|
||||
downgrades accordingly.
|
||||
|
||||
## Key rotation (future)
|
||||
|
||||
The `enc:v1:` envelope and the HKDF domain label are versioned. A future rotation
|
||||
would introduce `enc:v2:` with a new derived key (and `Decrypt` already dispatches
|
||||
on the version, returning an explicit error for an unknown version). Rotation is
|
||||
out of scope for this release; the versioning exists so it can be added without a
|
||||
data migration of the envelope format.
|
||||
|
||||
## Scope and known gaps
|
||||
|
||||
Covered: arr (Requests + Autoscan) API keys, S3 keys, all sensitive
|
||||
`server_settings`, watch-sync tokens, webhook-sync `access_token`,
|
||||
history-import admin/session tokens and temporary server-list credentials,
|
||||
subtitle provider `api_key`/`password`,
|
||||
the Jellyfin-compat session's bridged Silo access/refresh tokens
|
||||
(`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and
|
||||
the ABS signing key.
|
||||
|
||||
Deliberately **not** encrypted (tracked as follow-ups):
|
||||
|
||||
- **Equality-looked-up secrets** — these are matched by exact value
|
||||
(`WHERE … = $1`), and AES-GCM is randomized, so encrypting them would break the
|
||||
lookup. They need a deterministic **blind-index hash** column instead:
|
||||
`api_keys.api_key`, `webhook_sync_connections.webhook_secret`,
|
||||
`jellycompat_sessions.token`, and `watch_together_rooms.join_token`.
|
||||
- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque
|
||||
plugin-defined JSONB whose secret fields are manifest-declared and whose runtime
|
||||
lives in a separate repo; encrypting it correctly needs a coordinated,
|
||||
manifest-aware design.
|
||||
|
||||
Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go
|
||||
references); `oauth_completion.token_ciphertext` is already AES-GCM;
|
||||
`users.password_hash` and the `*_hash` columns are already hashed.
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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`).
|
||||
@@ -1,98 +0,0 @@
|
||||
# Wholphin → jellycompat Endpoint Coverage Report
|
||||
|
||||
Date: 2026-06-09
|
||||
Scope: every Jellyfin server endpoint the Wholphin Android TV client (jellyfin-sdk-kotlin based) can call, cross-referenced against the routes registered in `internal/jellycompat/router.go`. Wholphin source analyzed from a local clone of `damontecres/Wholphin` (`app/src/main`). Commands assume the repository root is the cwd.
|
||||
|
||||
Wholphin's Seerr integration (`/api/v1/...` discover/request endpoints) targets a separate Seerr server, not Jellyfin, and is out of scope here.
|
||||
|
||||
## Methodology
|
||||
|
||||
1. Grepped Wholphin for every `*Api.method()` SDK call (`itemsApi`, `playStateApi`, etc.), websocket subscriptions, and raw URL construction; mapped each to its HTTP endpoint per the Jellyfin OpenAPI spec.
|
||||
2. Inventoried every route registered in `internal/jellycompat/router.go`, including path normalization (`path_normalization.go` handles casing and `/emby`//`jellyfin` prefixes).
|
||||
3. For each endpoint Wholphin uses but silo does not serve, checked whether a server-side gate (user policy flag, DTO field) prevents the client from ever calling it.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Covered (full):** 22 endpoint families — everything on the core path: auth, browse, item detail, episodes/next-up, resume, playback info, direct/HLS streaming, subtitles, progress reporting, favorites/played, images, genres, persons, suggestions, media segments (skip intro), display preferences.
|
||||
- **Covered (partial/stub):** 4 — websocket (keep-alive only, no remote control), `/Studios`, `/Users/Public`, `/QuickConnect/Enabled` (deliberate `false`).
|
||||
- **Missing but safely gated:** 8 — the client never calls them because we don't advertise the capability (delete, subtitle download, trickplay, local trailers, additional parts, quick connect auth, live TV, playlists).
|
||||
- **Missing and reachable:** 4 — theme songs, universal audio stream, item download, client log upload. These return 404 when the client calls them. None block video playback.
|
||||
|
||||
**Bottom line: the entire core video path is covered.** The reachable gaps are quality-of-life features (theme songs, photo screensaver, debug log upload) plus one advertised-but-broken inconsistency (`CanDownload=true` with no `/Items/{id}/Download` route).
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
### Fully covered
|
||||
|
||||
| Wholphin call | Endpoint | silo handler |
|
||||
|---|---|---|
|
||||
| `userApi.authenticateUserByName` | `POST /Users/AuthenticateByName` | `handlers_auth.go` |
|
||||
| `userApi.getCurrentUser` | `GET /Users/Me` | `handlers_auth.go` |
|
||||
| `userApi.getUserById` | `GET /Users/{id}` | `handlers_auth.go` |
|
||||
| `systemApi.getPublicSystemInfo` | `GET /System/Info/Public` | `handlers_system.go` |
|
||||
| `sessionApi.postCapabilities` | `POST /Sessions/Capabilities` | `handlers_playback.go` |
|
||||
| `itemsApi.getItems` | `GET /Items` | `handlers_items.go` |
|
||||
| `itemsApi.getResumeItems` | `GET /UserItems/Resume` (+ legacy `/Users/{id}/Items/Resume`) | `handlers_items.go` |
|
||||
| `userLibraryApi.getItem` | `GET /Items/{id}` (+ legacy user-scoped form) | `handlers_items.go` |
|
||||
| `tvShowsApi.getEpisodes` | `GET /Shows/{id}/Episodes` | `handlers_items.go` |
|
||||
| `tvShowsApi.getNextUp` | `GET /Shows/NextUp` | `handlers_items.go` |
|
||||
| `suggestionsApi.getSuggestions` | `GET /Items/Suggestions` | `handlers_items.go` |
|
||||
| `genresApi.getGenres` | `GET /Genres` | `handlers_items.go` |
|
||||
| `personsApi.getPersons` | `GET /Persons` | `handlers_persons.go` (full when PersonRepo is wired; empty stub otherwise) |
|
||||
| `searchApi.getSearchHints` | `GET /Search/Hints` | `handlers_items.go` (Wholphin's call is currently commented out upstream) |
|
||||
| `mediaInfoApi.getPostedPlaybackInfo` | `POST /Items/{id}/PlaybackInfo` | `handlers_playback.go` |
|
||||
| `videosApi.getVideoStreamUrl` | `GET/HEAD /Videos/{id}/stream(.{container})` | `streams.go` |
|
||||
| (transcode path from PlaybackInfo) | `GET /Videos/{id}/master.m3u8` + HLS playlist/segments | `streams.go` |
|
||||
| (subtitle delivery URLs) | `GET /Videos/{id}/{source}/Subtitles/{idx}/stream.{fmt}` | `streams.go` |
|
||||
| `playStateApi.reportPlaybackStart/Progress/Stopped` | `POST /Sessions/Playing[/Progress|/Stopped]` | `streams.go` |
|
||||
| `playStateApi.markPlayedItem/markUnplayedItem` | `POST/DELETE /UserPlayedItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `userLibraryApi.markFavoriteItem/unmarkFavoriteItem` | `POST/DELETE /UserFavoriteItems/{id}` (+ legacy) | `handlers_userdata.go` |
|
||||
| `mediaSegmentsApi.getItemSegments` | `GET /MediaSegments/{id}` | `handlers_items.go` (skip intro/credits) |
|
||||
| `displayPreferencesApi.updateDisplayPreferences` | `GET/POST /DisplayPreferences/{id}` | `handlers_displayprefs.go` |
|
||||
| `imageApi.getItemImageUrl` | `GET /Items/{id}/Images/{type}[/{index}]` | `handlers_images.go` |
|
||||
| `imageApi.getUserImageUrl` | `GET /Users/{id}/Images/Primary` | `handlers_images.go` (placeholder avatar) |
|
||||
|
||||
### Partial / stub (graceful degradation)
|
||||
|
||||
| Endpoint | Status | Client impact |
|
||||
|---|---|---|
|
||||
| `GET /socket` (websocket) | Accepts upgrade, answers KeepAlive only (`handlers_websocket.go`) | Wholphin subscribes to `GeneralCommandMessage` and `PlaystateMessage` for server-initiated remote control. Connection stays healthy; remote-control commands are simply never delivered. No errors, feature inert. |
|
||||
| `GET /Studios` | Empty-array stub | Studio browse rows render empty. |
|
||||
| `GET /Users/Public` | Empty-array stub | No user tiles on the login screen; manual login works. |
|
||||
| `GET /QuickConnect/Enabled` | Hardcoded `false` | Deliberate: Wholphin hides Quick Connect UI, so the unimplemented `POST /QuickConnect/Authorize` and `POST /Users/AuthenticateWithQuickConnect` are never called. |
|
||||
|
||||
### Missing but safely gated (client never calls them)
|
||||
|
||||
| Endpoint | Why it's unreachable |
|
||||
|---|---|
|
||||
| `DELETE /Items/{id}` | We return `CanDelete=false` per item (`mapping.go`) and `EnableContentDeletion=false` in user policy (`handlers_auth.go`), so Wholphin hides delete UI. |
|
||||
| `POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}` | `EnableSubtitleManagement=false` in user policy hides subtitle search/download. |
|
||||
| `GET /Videos/{id}/Trickplay/...` | We never emit trickplay metadata on item DTOs, so the scrubber never requests tiles. |
|
||||
| `GET /Items/{id}/LocalTrailers` | Wholphin gates on `localTrailerCount > 0` (`TrailerService.kt`); we never set `LocalTrailerCount`. |
|
||||
| `GET /Videos/{id}/AdditionalParts` | Gated on `partCount > 1` (`PlaylistCreator.kt`); we never set `PartCount`. |
|
||||
| Quick Connect auth endpoints | Gated by `/QuickConnect/Enabled` = false. |
|
||||
| Live TV suite (`/LiveTv/Channels`, `/Programs`, `/Recordings`, timers) | We expose no live-TV views or channels, so the feature never activates. |
|
||||
| Playlists (`GET/POST/DELETE /Playlists/{id}/Items`) | We expose no playlist items in the catalog, so playlist screens are unreachable. Becomes a real gap the day silo grows playlists. |
|
||||
|
||||
### Missing and reachable (return 404 today)
|
||||
|
||||
| Endpoint | Wholphin caller | Impact | Severity |
|
||||
|---|---|---|---|
|
||||
| `GET /Items/{id}/ThemeSongs` | `ThemeSongPlayer.kt` | ~~404 → feature silently dead.~~ **Resolved 2026-06-09:** now stubbed with an empty `ThemeMediaResult` (including the `OwnerId` field jellyfin-sdk-kotlin requires). | Resolved |
|
||||
| `GET /Audio/{id}/universal` | `ThemeSongPlayer.kt`, `MusicService.kt` | Audio streaming for theme songs and music playback. No music libraries are exposed today, but this is the second half of the theme-song path and the blocker for any future audio support in jellycompat. | Low (today) |
|
||||
| `GET /Items/{id}/Download` | `SlideshowViewModel.kt`, `ScreensaverService.kt` | ~~Inconsistency: `CanDownload=true` while the route 404s.~~ **Resolved 2026-06-09:** a real `/Items/{id}/Download` route now serves the original file (`streams.go`), and `CanDownload=true` again. The earlier `CanDownload=false` workaround broke Infuse, which requires the flag for Direct Play. | Resolved |
|
||||
| `POST /ClientLog/Document` | `MediaReportService.kt`, `DebugPage.kt` | "Upload logs to server" debug action fails. | Low |
|
||||
| `GET /Audio/{id}/Lyrics` | `NowPlayingViewModel.kt` | Music-only; unreachable until audio libraries exist. | Low |
|
||||
|
||||
## Recommendations (priority order)
|
||||
|
||||
1. **Merge the repeated-`Fields` fix** (PR #110). `parseItemsQuery` still reads `q.Get("Fields")` (`internal/jellycompat/query.go`), which truncates jellyfin-sdk-kotlin's repeated query params and breaks Wholphin episode auto-advance. This is the only *covered* endpoint with a known correctness bug for this client. Follow up by sweeping the remaining single-value reads (`Ids`, `GenreIds`, `PersonIds`, `Filters`, `SortBy`, `SortOrder`) to `q.Values`, matching how `IncludeItemTypes`/`MediaTypes`/`ImageTypes` are already handled.
|
||||
2. ~~Resolve the `CanDownload` inconsistency~~ — **done 2026-06-09**: real `/Items/{id}/Download` route added; `CanDownload` stays `true` (Infuse requires it for Direct Play).
|
||||
3. ~~Stub `GET /Items/{id}/ThemeSongs`~~ — **done 2026-06-09**: empty `ThemeMediaResult` stub registered.
|
||||
4. **Websocket server-push** (remote control: server-initiated pause/stop, display messages) — tracked in issue #122.
|
||||
5. **Optional, later:** `POST /ClientLog/Document` accept-and-discard stub and real `/Studios` data. Playlists, lyrics, and universal audio only matter once silo exposes those content types through jellycompat.
|
||||
|
||||
## Notes
|
||||
|
||||
- Path normalization (`path_normalization.go`) already handles Wholphin's casing and any `/emby`//`jellyfin` prefixes, so none of the covered routes are at risk from URL-shape differences.
|
||||
- Unmatched jellycompat paths fall through to chi's default 404 with no body; jellyfin-sdk-kotlin surfaces these as `InvalidStatusException`, which Wholphin generally catches per-feature.
|
||||
@@ -1,82 +0,0 @@
|
||||
# PR #124 (ebooks) — Review & Hardening Pass
|
||||
|
||||
Date: 2026-06-10
|
||||
Scope: full review of the ebook feature branch (`work/ebooks-reader-base`) followed by
|
||||
fixes for every finding. Two review rounds were performed (a full-PR review, then an
|
||||
adversarial edge-case/security pass over both the original code and the first round of
|
||||
fixes), with all changes verified against the full Go test suite, the full web test
|
||||
suite, production frontend build, ESLint/Prettier, and `make verify-local-paths`.
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Security fixes
|
||||
|
||||
- **Stored XSS via book content (high):** book sections render in same-origin blob
|
||||
iframes with `allow-scripts` (WebKit requirement). A Content-Security-Policy is now
|
||||
served with all SPA HTML responses (`internal/server/frontend.go`) — blob/srcdoc
|
||||
documents inherit it, so `script-src 'self' 'wasm-unsafe-eval'` blocks script
|
||||
execution from book content on all browsers. Threat model documented on the policy
|
||||
constant. `X-Content-Type-Options: nosniff` added to frontend, jellycompat, and
|
||||
ebook file responses.
|
||||
- Ebook file serving can no longer fall through to `application/octet-stream` for an
|
||||
admitted file (extension/container whitelist and MIME resolution share one resolver),
|
||||
closing a browser-sniff escape combined with the `?token=` query-auth fallback.
|
||||
- External links in books are intercepted app-side: http(s) only, opened with
|
||||
`noopener,noreferrer` (blocks reverse tabnabbing and `javascript:` URLs).
|
||||
- Request size caps (`http.MaxBytesReader`, 413) on progress/config/annotation writes;
|
||||
`Content-Disposition` built with `mime.FormatMediaType` (RFC 5987, no header
|
||||
injection); annotation PATCH is atomic (`SELECT ... FOR UPDATE`) with
|
||||
presence-aware field semantics and invariant re-validation.
|
||||
- Verified safe under adversarial review: no IDOR (all reader-state SQL scoped to
|
||||
user+profile+content), no path traversal, range serving via stdlib, progress input
|
||||
validation. Accepted residual: CSS-only loads inside book iframes can leak the
|
||||
book's own content but cannot reach tokens, DOM, or navigation.
|
||||
|
||||
## Reliability fixes
|
||||
|
||||
- **Scanner missing-file reconciliation (critical class):** ebook scans now mark
|
||||
missing files like video/audio, with real walk-failure tracking (failed roots are
|
||||
excluded from deletion), symlinked-root support via the shared logical walker, and
|
||||
the empty-root cleanup allowance — an unmounted share can no longer wipe a library.
|
||||
- **Enrichment:** provider errors record failures (dedicated `ebook_enrichment_state`
|
||||
backoff table, capped retries) instead of permanently stamping items done;
|
||||
unconfigured chains and scan-window races skip without stamping. No longer shares
|
||||
`media_items.refresh_failures` with the metadata refresh-debt system.
|
||||
- Ebook items are created `pending` and promoted to `matched` on enrichment (backfill
|
||||
migration included), making curated-metadata protection real: matched items keep
|
||||
provider titles/people/series on re-scan (fill-empty only).
|
||||
- PDF metadata: head+tail window scan (non-linearized PDFs keep the Info dict at the
|
||||
end), delimiter-aware key matching, head-wins merge. `.md` dropped as an ebook
|
||||
format; plain `.fb2` reads capped like `.fbz`.
|
||||
- FK-cascade indexes added for the reader-state tables.
|
||||
|
||||
## Consistency & correctness fixes
|
||||
|
||||
- Hidden-history (`user_history_hidden_items`) gating applied to every ebook progress
|
||||
surface (watched/in-progress filters, all sort plans, Continue Reading, sort
|
||||
metrics, `Played` state, recommendation signals) with the exact video semantics.
|
||||
- `GetItemWatchers` counts distinct watchers (no binge inflation) and the
|
||||
`minWatchers` floor counts distinct accounts, not profiles; ebook reading now feeds
|
||||
implicit taste signals and taste-seed candidates; Continue Reading pages past
|
||||
dismissals and dedupes across pages.
|
||||
- The 0.9 finished threshold is centralized (`models.EbookFinishedProgressThreshold`).
|
||||
- Reader frontend: open-flow race teardown (no wrong-file progress saves), ordered
|
||||
progress writes with `pagehide`/`visibilitychange` flush, settings no longer
|
||||
clobbered by late server config, TTS stop actually stops, Media Session cleanup,
|
||||
512 MiB download guard, fraction bookmarks navigable.
|
||||
|
||||
## Feature completion
|
||||
|
||||
- Native read-state endpoints: `POST/DELETE /watched/{id}` and `/history/remove`
|
||||
accept ebook content IDs (mark read = progress 1.0 preserving location; mark unread
|
||||
mirrors video unwatch and clears position; history removal hides without losing
|
||||
position). Web UI exposes Mark Read/Unread on the item page and card menus, with
|
||||
Continue Reading dismiss copy and dismissal-path ID encoding fixed.
|
||||
|
||||
## Client follow-ups (Android / Apple)
|
||||
|
||||
- Ebook leaf user data encodes the reading ratio as `position_seconds` (0..1) with
|
||||
`duration_seconds = 1`; do not render absolute times for ebooks.
|
||||
- `/watched/{id}` returns `{type: "ebook", affected_count: 1, played: bool}` and the
|
||||
existing watched SSE event fires.
|
||||
- Reading progress is deliberately per-book (cross-format): one position per content
|
||||
ID.
|
||||
@@ -1,138 +0,0 @@
|
||||
# ABS Wire-Shape Verification (post collections-unify cutover)
|
||||
|
||||
Breadcrumbs for the next person debugging an ABS endpoint wire-shape issue
|
||||
after the canonical-tables cutover (migration 156 + commits `0dc830e`,
|
||||
`8c7fe1b`, `b64ce17`).
|
||||
|
||||
The Go in-memory structs (`abs.Collection`, `abs.Playlist`,
|
||||
`abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem`) have **no
|
||||
`json:"..."` struct tags**. The JSON wire contract is defined entirely by
|
||||
the `*ToABS()` map-builder helpers in `internal/audiobooks/abs/`. As long as
|
||||
the store layer populates the struct fields with the same values, the wire
|
||||
shape is preserved. The rewrites in `0dc830e`, `8c7fe1b`, `b64ce17` did NOT
|
||||
modify the emitters — only the SQL-backed store implementations.
|
||||
|
||||
## Envelope tests — which one guards which endpoint
|
||||
|
||||
Run with `go test ./internal/audiobooks/abs/ -run Envelope -v -count=1`.
|
||||
|
||||
| Test file | Functions | Guards |
|
||||
|---|---|---|
|
||||
| `collections_envelope_test.go` | `TestCollectionEnvelope_HasRequiredKeys`, `TestCollectionListShape_OmitsBooks` | `collectionToABS` keys; list-shape (no `books`) vs detail-shape (with `books`) for `GET /api/collections`, `GET /api/collections/{id}`, `GET /api/libraries/{id}/collections`, and all POST/PATCH/DELETE collection endpoints |
|
||||
| `playlists_envelope_test.go` | `TestPlaylistEnvelope_HasRequiredKeys`, `TestPlaylistEnvelope_OmitsCoverPathWhenEmpty`, `TestPlaylistListShape_OmitsItems` | `playlistToABS` keys; list-shape (no `items`) vs detail-shape (with `items`); `coverPath` is omitted when `CoverItem == ""` — covers `GET /api/playlists`, `GET /api/playlists/{id}`, `GET /api/libraries/{id}/playlists`, batch and item-add/remove endpoints |
|
||||
| `smart_collections_envelope_test.go` | `TestSmartCollectionEnvelope_HasRequiredKeys`, `TestSmartCollectionEnvelope_EmptyQueryDef` | `smartCollectionToABS` keys; `queryDef` decoded from raw JSONB bytes into nested object, empty bytes → `{}` — covers `GET /api/me/smart-collections`, `GET /api/me/smart-collections/{id}`, POST/PATCH equivalents |
|
||||
| `bookmarks_envelope_test.go` | `TestBookmarkEnvelope_HasRequiredKeys` | Bookmarks emitter (separate from this cutover, not affected by migration 156) |
|
||||
| `login_envelope_test.go` | `TestLoginEnvelope_HasRequiredKeys` and three xReturnTokens / displayName variants | Login envelope (not affected by migration 156) |
|
||||
|
||||
In addition, handler-level round-trip tests live in
|
||||
`playlists_handler_test.go` and `bookmarks_handler_test.go`. There is NO
|
||||
snapshot/goldenfile harness in the repo today — these envelope tests are
|
||||
the primary regression guard.
|
||||
|
||||
## Manual live-DB diff procedure
|
||||
|
||||
For a pre/post-deploy wire-shape verification against a live silo, see the
|
||||
plan's Task 5 "manual verification" section at
|
||||
`docs/superpowers/plans/2026-05-27-collections-unify-3-abs-adapters.md`
|
||||
(§ `Task 5: Wire-shape regression test`). Summary:
|
||||
|
||||
1. Pre-cutover, seed one of each (collection, playlist with item,
|
||||
smart collection) via the old `abs_*` tables, then capture each list
|
||||
endpoint's response to `/tmp/wire_before_<kind>.json` using a curl
|
||||
against the running silo with a valid ABS bearer token (HS256 JWT —
|
||||
minted by the login flow, NOT the raw `abs_sessions.token` value).
|
||||
2. Apply migration 156. Seed equivalent rows in `user_personal_collections`
|
||||
with the same IDs and content. Capture again to
|
||||
`/tmp/wire_after_<kind>.json`.
|
||||
3. `diff /tmp/wire_before_<kind>.json /tmp/wire_after_<kind>.json` for each
|
||||
`kind in {collections,playlists,smart_collections}`. Expected: empty
|
||||
diff.
|
||||
|
||||
This is an MR-description-level manual step, not a committed test.
|
||||
|
||||
## Intentionally-zero fields after the rewrite
|
||||
|
||||
These wire keys are still emitted, but the store always populates the
|
||||
in-memory field with the zero value because the canonical
|
||||
`user_personal_collections` schema has no analog column (per spec §6 of the
|
||||
collections-unify plan). They are NOT bugs — do not "fix" them by reaching
|
||||
for some other column.
|
||||
|
||||
| In-memory field | Wire key | Zero value | Spec ref | Disposition |
|
||||
|---|---|---|---|---|
|
||||
| `abs.Playlist.CoverItem` | `coverPath` | `""` (key omitted entirely when empty — see `playlistToABS`) | spec §6.1 | Dropped. PATCH `coverPath` body field is silently ignored by the store. Cover regeneration from first-item poster is the chosen long-term path. |
|
||||
| `abs.SmartCollection.Color` | `color` | `""` (key always emitted as empty string) | spec §6.3 | Deferred. No column on `user_personal_collections`. Wire key stays present for client compatibility. |
|
||||
| `abs.SmartCollection.IsPinned` | `isPinned` | `false` (key always emitted) | spec §6.2 | Deferred. Same rationale. |
|
||||
|
||||
If you're adding a "Pin this smart collection" feature later, the column
|
||||
needs to land in a new migration on `user_personal_collections` first;
|
||||
don't try to thread it through some adjacent column.
|
||||
|
||||
## Canonical mapping — struct field → source column
|
||||
|
||||
The full pre-cutover wire contract was captured in a working note that does
|
||||
not persist (`/tmp/abs_wire_contract.md`). The essentials are reproduced
|
||||
here so the next maintainer doesn't have to re-derive them.
|
||||
|
||||
All three struct families now read from `user_personal_collections`
|
||||
(and `user_personal_collection_items` for collections + playlists),
|
||||
discriminated by `collection_type IN ('manual','playlist','smart')`.
|
||||
|
||||
### `abs.Collection` (`collection_type = 'manual'`)
|
||||
|
||||
| Field | Source column |
|
||||
|---|---|
|
||||
| ID | `user_personal_collections.id` |
|
||||
| UserID | `user_personal_collections.user_id::text` (column is `integer`) |
|
||||
| ProfileID | `user_personal_collections.profile_id` |
|
||||
| Name | `user_personal_collections.name` |
|
||||
| Description | `user_personal_collections.description` |
|
||||
| IsPublic | `user_personal_collections.is_shared` |
|
||||
| CreatedAt | `user_personal_collections.created_at` |
|
||||
| UpdatedAt | `user_personal_collections.updated_at` |
|
||||
|
||||
`abs.CollectionItem` reads `user_personal_collection_items` with
|
||||
`sub_item_id = ''` filter (the manual-collection sentinel established in
|
||||
migration 156 step 1). LibraryItemID ← `media_item_id`. ORDER BY
|
||||
`added_at ASC`.
|
||||
|
||||
### `abs.Playlist` (`collection_type = 'playlist'`)
|
||||
|
||||
Same column mapping as `abs.Collection` (modulo `collection_type` filter)
|
||||
EXCEPT `CoverItem` which is always `""` — see "Intentionally-zero fields"
|
||||
above.
|
||||
|
||||
`abs.PlaylistItem` reads `user_personal_collection_items` with NO
|
||||
`sub_item_id` filter (playlists can carry episode entries). Mapping:
|
||||
LibraryItemID ← `media_item_id`, EpisodeID ← `sub_item_id`,
|
||||
Position ← `position`. ORDER BY `position ASC, added_at ASC`.
|
||||
|
||||
### `abs.SmartCollection` (`collection_type = 'smart'`)
|
||||
|
||||
Same column mapping as `abs.Collection` EXCEPT:
|
||||
|
||||
- `Color`, `IsPinned` → always zero (see above).
|
||||
- `QueryDef` ← `user_personal_collections.query_definition` (JSONB → `[]byte`
|
||||
round-trip; column is `NOT NULL DEFAULT '{}'::jsonb` per migration 016).
|
||||
|
||||
No items table — smart-collection membership is evaluated at request time
|
||||
via the `smartcoll` package.
|
||||
|
||||
### Wire-shape quirks worth remembering
|
||||
|
||||
- Collection/Playlist emit `lastUpdate` (NOT `updatedAt`). SmartCollection
|
||||
emits `updatedAt`. Cross-struct inconsistency, carry forward verbatim.
|
||||
- All timestamps are `UnixMilli()` int64, NOT RFC3339 strings.
|
||||
- `ProfileID` is carried in memory but NEVER emitted on the wire — it's
|
||||
scope/auth only.
|
||||
- The list vs detail shape distinction is implicit: list responses pass
|
||||
`nil` for the items/books slice; the emitter then omits the key
|
||||
entirely. Clients differentiate on key presence.
|
||||
|
||||
## Verification status (2026-05-27)
|
||||
|
||||
- All 13 envelope tests pass (run: `go test ./internal/audiobooks/abs/
|
||||
-run Envelope -v -count=1`).
|
||||
- Full audiobooks test suite passes (`go test ./internal/audiobooks/...
|
||||
-short -count=1 -timeout 120s`).
|
||||
- Live-DB diff was NOT executed in CI — see manual procedure above.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,326 +0,0 @@
|
||||
# Audiobooks Absorption — Sub-plan 3: Silo-native API + Frontend (MVP)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Land the minimum viable silo-native audiobooks UI: backend REST endpoints + React pages so a user can browse audiobook libraries scanned by sub-plan 2, see book details with chapters, play with progress saved. Author/series index pages, smart collections, share links, and other features from the spec's "nice-to-haves" list are deferred to a future sub-plan if the demand surfaces.
|
||||
|
||||
**Architecture:** Three new Go handler files under `internal/api/handlers/audiobooks_*.go` for list/detail/progress. New `web/src/pages/audiobooks/` directory with three pages: Library, Detail, and an inline player. Hooks under `web/src/hooks/audiobooks/`. The streaming path reuses silo's existing `/api/v1/stream/{session_id}` endpoint — no new transcode code.
|
||||
|
||||
**Tech stack:** Go + chi router + pgx for backend; React 19 + TanStack Query + radix-ui + tailwind for frontend.
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
|
||||
**Predecessor:** `docs/superpowers/plans/2026-05-24-audiobooks-absorption-2-scanner.md` (scanner produces `media_items.type='audiobook'`)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Path | C/M | Purpose |
|
||||
|---|---|---|
|
||||
| `internal/api/handlers/audiobooks_list.go` | C | `HandleListAudiobooks` — GET /api/v1/audiobooks (paginated, filterable by library) |
|
||||
| `internal/api/handlers/audiobooks_detail.go` | C | `HandleGetAudiobook` — GET /api/v1/audiobooks/{id} (returns item + chapters + author/narrator + listening progress) |
|
||||
| `internal/api/handlers/audiobooks_progress.go` | C | `HandleReportAudiobookProgress` — POST /api/v1/audiobooks/{id}/progress |
|
||||
| `internal/api/router.go` | M | Mount three new routes under `/api/v1/audiobooks/*` |
|
||||
| `web/src/hooks/audiobooks/useAudiobookLibrary.ts` | C | TanStack Query hook for list |
|
||||
| `web/src/hooks/audiobooks/useAudiobook.ts` | C | Detail + progress hooks |
|
||||
| `web/src/pages/audiobooks/AudiobookLibrary.tsx` | C | Grid page (poster + title + author) |
|
||||
| `web/src/pages/audiobooks/AudiobookDetail.tsx` | C | Detail page (cover, metadata, chapter list, play button) |
|
||||
| `web/src/pages/audiobooks/AudiobookPlayer.tsx` | C | HTML5 `<audio>` player with chapter nav + progress reporting |
|
||||
| `web/src/App.tsx` or routing entry | M | Add the three new routes |
|
||||
| `web/src/components/Sidebar.tsx` (or equivalent) | M | Add "Audiobooks" navigation link when at least one audiobook library exists |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — list audiobooks
|
||||
|
||||
GET /api/v1/audiobooks?library_id=N&limit=...&offset=... → paginated audiobook list scoped to libraries the user has access to.
|
||||
|
||||
**Files:** Create `internal/api/handlers/audiobooks_list.go`, modify `internal/api/router.go`.
|
||||
|
||||
### 1.1 Test
|
||||
|
||||
Add `internal/api/handlers/audiobooks_list_test.go` with a test that:
|
||||
- Inserts two `media_items` rows (one `type='audiobook'`, one `type='movie'`)
|
||||
- Calls the handler with a fake request
|
||||
- Asserts only the audiobook is returned
|
||||
|
||||
If no test harness exists for handler-level tests in `internal/api/handlers/`, search neighbors (`grep -lE 'func.*Handler.*Test' internal/api/handlers/*_test.go`) for the established pattern. If integration testing requires a real DB and no harness exists, write the handler logic + a simple compile-time test only; defer the integration test (DONE_WITH_CONCERNS).
|
||||
|
||||
### 1.2 Handler
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
type AudiobookHandler struct {
|
||||
Items *catalog.ItemRepository
|
||||
}
|
||||
|
||||
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
filter := apimw.AccessFilterFor(r)
|
||||
items, total, err := h.Items.Search(r.Context(), "", []string{"audiobook"}, limit, offset, filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
|
||||
return
|
||||
}
|
||||
resp := struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}{
|
||||
Items: itemSummariesAsMaps(items),
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func itemSummariesAsMaps(items []*models.MediaItem) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, map[string]any{
|
||||
"content_id": it.ContentID,
|
||||
"title": it.Title,
|
||||
"year": it.Year,
|
||||
"poster_url": it.PosterPath, // silo's poster column; UI prefixes with media-token base URL
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Verify `catalog.ItemRepository.Search` matches the signature above by reading `internal/catalog/item_repo.go:739`. Adjust if needed.
|
||||
|
||||
### 1.3 Route + commit
|
||||
|
||||
In `internal/api/router.go`, find where other v1 routes are mounted and add:
|
||||
|
||||
```go
|
||||
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
|
||||
```
|
||||
|
||||
Wire the handler construction near other handler constructors. The handler needs `catalog.ItemRepository` — already available in the router's deps.
|
||||
|
||||
Commit: `feat(audiobooks): list endpoint at GET /api/v1/audiobooks`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend — audiobook detail
|
||||
|
||||
GET /api/v1/audiobooks/{id} → media_item + media_files (with chapters) + author/narrator from item_people + per-profile listening progress.
|
||||
|
||||
### 2.1 Handler
|
||||
|
||||
Create `internal/api/handlers/audiobooks_detail.go`. The handler queries:
|
||||
1. `media_items WHERE content_id = $1 AND type = 'audiobook'`
|
||||
2. `media_files WHERE content_id = $1` (returns paths + chapters JSONB)
|
||||
3. `item_people JOIN people ON ... WHERE content_id = $1 AND kind IN (7, 8)`
|
||||
4. `user_watch_progress WHERE content_id = $1 AND profile_id = $current`
|
||||
|
||||
Reuse existing repo methods where they exist. If not, add inline SQL.
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"audiobook": { "content_id": "...", "title": "...", "year": 2024, "overview": "...", "poster_url": "..." },
|
||||
"author": "Test Author",
|
||||
"narrator": "Test Narrator",
|
||||
"files": [
|
||||
{ "id": 123, "path": "/...", "duration_seconds": 3600, "chapters": [ {"index": 0, "title": "Intro", "start_seconds": 0, "end_seconds": 27.9} ] }
|
||||
],
|
||||
"progress": { "position_seconds": 1842.5, "updated_at": "2026-05-24T..." }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Route + commit
|
||||
|
||||
In `router.go`: `r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)`.
|
||||
|
||||
Commit: `feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Backend — progress endpoint
|
||||
|
||||
POST /api/v1/audiobooks/{id}/progress with body `{"position_seconds": 1842.5, "media_file_id": 123}`. UPSERTs `user_watch_progress`.
|
||||
|
||||
### 3.1 Handler
|
||||
|
||||
Create `internal/api/handlers/audiobooks_progress.go`. Body is JSON; handler parses, validates `position_seconds >= 0`, then upserts into `user_watch_progress` keyed on `(user_id, profile_id, media_item_id)`.
|
||||
|
||||
Reuse silo's existing progress-write helper if one exists (grep `INSERT INTO user_watch_progress` or `UpsertWatchProgress`). If not, write inline SQL using the existing DB pool from handler deps.
|
||||
|
||||
### 3.2 Route + commit
|
||||
|
||||
`r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)`.
|
||||
|
||||
Commit: `feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Frontend — TanStack Query hooks + types
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/lib/audiobooks/types.ts`
|
||||
- Create: `web/src/hooks/audiobooks/useAudiobookLibrary.ts`
|
||||
- Create: `web/src/hooks/audiobooks/useAudiobook.ts`
|
||||
- Create: `web/src/hooks/audiobooks/useReportAudiobookProgress.ts`
|
||||
|
||||
### 4.1 Types
|
||||
|
||||
```ts
|
||||
// web/src/lib/audiobooks/types.ts
|
||||
export interface AudiobookSummary {
|
||||
content_id: string;
|
||||
title: string;
|
||||
year: number;
|
||||
poster_url: string | null;
|
||||
}
|
||||
|
||||
export interface AudiobookListResponse {
|
||||
items: AudiobookSummary[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface AudiobookChapter {
|
||||
index: number;
|
||||
title: string;
|
||||
start_seconds: number;
|
||||
end_seconds: number;
|
||||
}
|
||||
|
||||
export interface AudiobookFile {
|
||||
id: number;
|
||||
path: string;
|
||||
duration_seconds: number;
|
||||
chapters: AudiobookChapter[];
|
||||
}
|
||||
|
||||
export interface AudiobookProgress {
|
||||
position_seconds: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AudiobookDetail {
|
||||
audiobook: { content_id: string; title: string; year: number; overview: string; poster_url: string | null };
|
||||
author: string;
|
||||
narrator: string;
|
||||
files: AudiobookFile[];
|
||||
progress: AudiobookProgress | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Hooks
|
||||
|
||||
Use TanStack Query patterns silo already uses (grep `useQuery` for examples). Cache keys: `["audiobooks", "library", libraryId]` and `["audiobook", id]`. Progress mutation invalidates the detail query on success.
|
||||
|
||||
### 4.3 Commit
|
||||
|
||||
Commit: `feat(audiobooks): frontend types and TanStack Query hooks`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend — Audiobook Library page
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/audiobooks/AudiobookLibrary.tsx`
|
||||
|
||||
Grid of audiobook cards (poster + title + author). Use silo's existing card / grid components — grep `web/src/components/` for `MediaCard` or similar. Infinite scroll via `useInfiniteQuery` would be ideal but a simple paginated load-more is fine for MVP.
|
||||
|
||||
Route URL: `/audiobooks` (or `/audiobooks/library/:id` if there are multiple libraries). For MVP, single library route `/audiobooks` is fine.
|
||||
|
||||
Commit: `feat(audiobooks): library grid page`.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Frontend — Audiobook Detail page
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/audiobooks/AudiobookDetail.tsx`
|
||||
|
||||
Layout: cover image (poster) + metadata (title, author, year, overview) + chapter list + play button. Chapter list is collapsible; clicking a chapter starts playback at that chapter's start_seconds.
|
||||
|
||||
Route URL: `/audiobooks/book/:id`.
|
||||
|
||||
Commit: `feat(audiobooks): detail page with chapter list`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Frontend — Audiobook Player
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/pages/audiobooks/AudiobookPlayer.tsx` (or `web/src/player/AudiobookPlayer.tsx`)
|
||||
|
||||
HTML5 `<audio>` element. The stream URL for an audiobook file is silo's existing `/api/v1/stream/{session_id}` — call POST /api/v1/playback/session to start a session (existing silo endpoint), then point the `<audio src>` at the stream URL. Position updates throttled to every 5-10 seconds + on pause/seek.
|
||||
|
||||
Controls: play/pause, skip-30s-forward, skip-30s-back, playback rate select (0.5×, 0.75×, 1×, 1.25×, 1.5×, 2×, 3×), chapter list panel.
|
||||
|
||||
If silo's existing stream-session-start endpoint shape isn't obvious, grep for `playback/session` and copy the pattern from the video player.
|
||||
|
||||
Commit: `feat(audiobooks): HTML5 audio player with chapter navigation`.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Navigation + routing integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/App.tsx` (or wherever routes are registered) — add three new routes
|
||||
- Modify: `web/src/components/Sidebar.tsx` (or equivalent) — add "Audiobooks" nav link
|
||||
|
||||
The sidebar link should only show when at least one audiobook library exists. Use a `useAudiobookLibrary` query with a short staleTime to gate visibility, OR a separate `useLibrariesOfKind("audiobooks")` hook.
|
||||
|
||||
Commit: `feat(audiobooks): wire navigation and routes`.
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Build, lint, smoke
|
||||
|
||||
```bash
|
||||
cd /opt/silo-server
|
||||
go build ./...
|
||||
go test ./internal/api/handlers/audiobooks_*_test.go ./internal/scanner/... ./internal/models/... ./internal/audiobooks/...
|
||||
go vet ./...
|
||||
cd web && pnpm run lint && pnpm run format:check && cd ..
|
||||
make build
|
||||
```
|
||||
|
||||
Rebuild silo image + force-recreate container:
|
||||
|
||||
```bash
|
||||
sudo docker build -t silo:latest /opt/silo-server
|
||||
sudo docker compose -p silo-prod up -d --force-recreate silo
|
||||
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
|
||||
curl -sS -o /dev/null -w "GET /api/v1/audiobooks -> %{http_code}\n" http://localhost:8090/api/v1/audiobooks
|
||||
```
|
||||
|
||||
If any sweep changes (gofmt, prettier) were made, commit them with `chore(audiobooks): sweep formatting`.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- Frontend tasks depend on silo's existing component shapes. Subagents will need to grep `web/src/components/` for the right patterns. If they invent ad-hoc components, ask them to use existing ones.
|
||||
- The progress endpoint UPSERT shape depends on `user_watch_progress` PK shape — which discovery D4 confirmed is `(user_id, profile_id, content_id)`. Confirm and use exactly that ON CONFLICT target.
|
||||
- Audiobook libraries may not yet exist on the test stack. To smoke-test, an operator must manually update a `media_folders.type` to `'audiobooks'` and trigger a rescan after the sub-plan 2 scanner branch lands.
|
||||
@@ -1,148 +0,0 @@
|
||||
# Audiobooks Absorption — Sub-plan 4: ABS-compat REST + Socket.io
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Port the audiobookshelf-compatible REST + Socket.io surface from `silo-plugin-audiobooks/internal/abs/` and `silo-plugin-audiobooks/internal/abssocket/` into `internal/audiobooks/abs/` and `internal/audiobooks/abssocket/`. **Preserve the plugin's file layout** but **rewrite every SQL query** against silo's existing tables (`media_items`, `media_files`, `episodes`, `user_watch_progress`, `user_playback_sessions`, `abs_sessions`, `people`, `item_people`). No new tables.
|
||||
|
||||
**Architecture:** Stage-based port. Support files first, then auth, then file serving, then progress, then author/series, then Socket.io, then router wiring. Out-of-scope features (bookmarks, smart collections, share links, playlists, podcasts/RSS in this sub-plan) are dropped — files containing them are not copied, or are copied with the unsupported handlers stubbed to 501 Not Implemented.
|
||||
|
||||
**Source plugin:** `/opt/silo_plugins/silo-plugin-audiobooks/internal/{abs,abssocket}/`
|
||||
|
||||
---
|
||||
|
||||
## Stage 1: Support files
|
||||
|
||||
**Files to copy and adapt:**
|
||||
- `mount.go` — package mount + route registration shape
|
||||
- `handler.go` — top-level handler type + dependency wiring
|
||||
- `filter.go` — generic helpers
|
||||
- `access_log.go` — request-log helper
|
||||
- `minified.go` — generic helpers (likely small)
|
||||
|
||||
**Adapt steps:**
|
||||
1. Replace import paths: `github.com/RXWatcher/silo-plugin-audiobooks/...` → `github.com/Silo-Server/silo-server/internal/audiobooks/...`
|
||||
2. Replace plugin SDK references with direct silo equivalents:
|
||||
- Plugin's `*pgx.Pool` field on Handler → silo's `*pgxpool.Pool` (same type via different import alias)
|
||||
- Plugin's `Logger` → silo's `log/slog` package-level functions
|
||||
- Plugin's config types → struct fields on `audiobooks.Service` (extended)
|
||||
3. Remove any code that talks to the plugin SDK runtime (plugin proxy callbacks etc.)
|
||||
4. Get `go build ./internal/audiobooks/abs/...` to compile cleanly even if no routes are wired yet.
|
||||
|
||||
Commit: `feat(audiobooks): port ABS handler scaffolding (mount/handler/filter)`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: Auth + JWT
|
||||
|
||||
**Files:**
|
||||
- `jwt.go` — token generation + validation
|
||||
- `login_internal_test.go` (keep as test) + `login_ratelimit.go`
|
||||
- Whatever the actual `login.go` file is called (look for the login handler — likely inside `handler.go` or a dedicated file)
|
||||
|
||||
**Adapt:**
|
||||
- The plugin's login handler posts credentials to silo's `/api/v1/auth/login` over HTTP. **In silo's in-process port, replace this HTTP round-trip with a direct call to silo's auth service** (find via `grep -nE 'auth.*Authenticate|auth.*Login' /opt/silo-server/internal/auth/*.go`). The plugin's network call is no longer needed because the code is now in-process.
|
||||
- ABS sessions are stored in `abs_sessions` (migration 139 from sub-plan 1).
|
||||
- JWT signing secret: store in `server_settings` under key `audiobooks.abs.jwt_secret` (auto-generated on first request if missing).
|
||||
|
||||
Commit: `feat(audiobooks): port ABS auth (JWT + login bridge)`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3: File serving + play session
|
||||
|
||||
**Files:**
|
||||
- `file_handler.go` — handles ABS GET requests for audio file streams
|
||||
- `play_response.go` — issues a play session to the client (URL + token)
|
||||
|
||||
**Adapt:**
|
||||
- Plugin's `bookref` lookup table (linking ABS-style IDs to backend file IDs) → use silo's `media_files.id` directly. ABS-side item ID = silo's `content_id`; ABS-side file ID = silo's `media_files.id`.
|
||||
- The stream URL ABS clients expect must point back at silo. Issue them a `/api/v1/direct-download?file_id={id}` URL signed with silo's existing media-token signer (grep `mediatoken` in `internal/`).
|
||||
|
||||
Commit: `feat(audiobooks): port ABS file handler + play session`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4: Progress tracking
|
||||
|
||||
**Files:**
|
||||
- `progress_internal_test.go` (test, port)
|
||||
- Whatever file owns the `/api/me/progress` ABS endpoint family — look in `handler.go` or grep ABS progress route paths
|
||||
- `continue_listening.go`
|
||||
|
||||
**Adapt:**
|
||||
- All progress reads/writes hit silo's `user_watch_progress` keyed on `(user_id, profile_id, content_id)`. Sub-plan 3's progress handler did this already — copy the patterns.
|
||||
|
||||
Commit: `feat(audiobooks): port ABS progress + continue listening`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 5: Author / Series / Browse
|
||||
|
||||
**Files:**
|
||||
- `author_series_handler.go`
|
||||
- `collapse.go`
|
||||
|
||||
**Adapt:**
|
||||
- Author rows come from silo's `people` + `item_people` (kind=7).
|
||||
- Series grouping: if silo doesn't have an explicit "audiobook series" concept, use `media_items.title` prefix matching (e.g., books named "Foundation #1", "Foundation #2") for MVP. Document the heuristic.
|
||||
|
||||
Commit: `feat(audiobooks): port ABS author/series browse`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 6: Socket.io
|
||||
|
||||
**Files:**
|
||||
- `abssocket/server.go` + `abssocket/server_test.go`
|
||||
|
||||
**Adapt:**
|
||||
- Reuse silo's `gorilla/websocket` upgrader. The plugin's standalone-listener escape hatch is no longer needed — mount under `/abs/socket.io/` on silo's main listener.
|
||||
- Redis adapter behavior preserved if `REDIS_URL` is set (silo always has one in the docker setup).
|
||||
|
||||
Commit: `feat(audiobooks): port ABS Socket.io endpoint`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 7: Router wiring
|
||||
|
||||
**Files:**
|
||||
- Modify `internal/api/router.go` — mount `audiobookABSHandler` under `/abs/*` and the small set of `/api/*` legacy paths ABS clients hardcode
|
||||
- Modify `internal/audiobooks/service.go` — add `ABSHandler` field, wire it from `audiobooks.New(...)`
|
||||
- Modify `cmd/silo/main.go` — construct the ABS handler and pass it into the audiobooks service
|
||||
|
||||
**Routes to expose** (the minimum ABS-required set):
|
||||
- `POST /abs/login` (login flow)
|
||||
- `GET /abs/api/me` (profile)
|
||||
- `GET /abs/api/libraries` (library list)
|
||||
- `GET /abs/api/libraries/{id}/items` (browse)
|
||||
- `GET /abs/api/items/{id}` (item detail)
|
||||
- `GET /abs/api/items/{id}/file/{ino}` (stream)
|
||||
- `POST /abs/api/me/progress/{libraryItemId}` (progress)
|
||||
- `GET /abs/socket.io/` (Socket.io)
|
||||
|
||||
For ABS-legacy hard-coded paths that don't fit under `/abs/`: register them explicitly with the ABS auth middleware so they don't collide with silo's existing `/api/v1/*`.
|
||||
|
||||
Build + smoke:
|
||||
```bash
|
||||
sudo docker build -t silo:latest /opt/silo-server
|
||||
sudo docker compose -p silo-prod up -d --force-recreate silo
|
||||
curl -sS -o /dev/null -w "POST /abs/login -> %{http_code}\n" -X POST -H 'Content-Type: application/json' -d '{}' http://localhost:8090/abs/login
|
||||
```
|
||||
|
||||
Expected: 400 (bad request body) or 401 (validation failed), NOT 404.
|
||||
|
||||
Commit: `feat(audiobooks): wire ABS routes into main router`.
|
||||
|
||||
---
|
||||
|
||||
## Stage 8: Build + smoke + lint sweep
|
||||
|
||||
Single final task. Same shape as sub-plan 2 Task 10.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
- The plugin's code expects a specific database schema. Some plugin queries may have no clean silo-table equivalent (e.g., the plugin's `bookref` mapping table). In that case, stub the handler to return 501 + log + flag in PR notes — don't add new tables.
|
||||
- ABS clients have undocumented expectations (CORS headers, specific error shapes, etc.). Smoke testing against a real ABS client is the only way to find these. Defer to sub-plan 6's manual verification.
|
||||
- Each stage commits independently and produces partial functionality (e.g., after stage 2 you can log in but can't list libraries). That's intentional — clients won't connect end-to-end until stage 7.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,187 +0,0 @@
|
||||
# Audiobook-native catalog filter fields
|
||||
|
||||
**Status:** Draft — not yet approved or executed.
|
||||
|
||||
**Context.** The audiobooks library page currently inherits the catalog's
|
||||
movie/TV filter set. We've already (a) wired `libraryType="audiobook[s]"`
|
||||
into the sort-relevance scope so video-only sorts (resolution, IMDb/RT
|
||||
ratings, content rating) drop out, and (b) gated the obviously-irrelevant
|
||||
video-only filter sections (Director, Writer, Producer, Studio, Network,
|
||||
Video Quality) in `CollectionGuidedRulesEditor`. What remains is
|
||||
**affirmative** support for audiobook-native filter dimensions: **author**,
|
||||
**narrator**, **series**.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Goal
|
||||
|
||||
Surface three new filter dimensions on the audiobook library page:
|
||||
|
||||
1. **Author** — `item_people.kind = PersonKindAuthor` (7).
|
||||
2. **Narrator** — `item_people.kind = PersonKindNarrator` (8).
|
||||
3. **Series** — `audiobook_series.series_name` (free-text or distinct list).
|
||||
|
||||
The user should be able to:
|
||||
- See per-dimension counts/options in the filter sheet (populated from the
|
||||
current scope, like Genres/Studios already do).
|
||||
- Add a rule via the guided editor that filters items by exact author /
|
||||
narrator / series.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Series **ordering** (we already have `audiobook_series.series_index`).
|
||||
Sort by series is a separate piece of work; this plan focuses on
|
||||
filtering only.
|
||||
- Author/narrator typeahead UI improvements beyond the existing
|
||||
`PersonSearchSelect` (reuse it with kind-scoped lookups).
|
||||
|
||||
## Backend
|
||||
|
||||
### `internal/catalog/catalog_resolver.go`
|
||||
|
||||
`CatalogFiltersResult` currently has `Genres`, `Studios`, `Networks`,
|
||||
`Countries`, `OriginalLanguages`, `ContentRatings`, `Resolutions`,
|
||||
`AudioLanguages`, `SubtitleLanguages`. Add:
|
||||
|
||||
```go
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Narrators []string `json:"narrators,omitempty"`
|
||||
Series []string `json:"series,omitempty"`
|
||||
```
|
||||
|
||||
In `listFiltersForSource`, add three parallel facet queries:
|
||||
|
||||
- `listDistinctPeopleByKind(ctx, scope, models.PersonKindAuthor)` — joins
|
||||
`item_people` + `people` on the result set defined by the current
|
||||
request scope (library_ids, media_scope=audiobook, etc.) and returns
|
||||
distinct `people.name`.
|
||||
- Same for `PersonKindNarrator`.
|
||||
- `listDistinctAudiobookSeriesNames(ctx, scope)` — distinct
|
||||
`audiobook_series.series_name` for the scope, joined via
|
||||
`audiobook_series.content_id = media_items.content_id`.
|
||||
|
||||
All three need to respect the access filter the caller passes in (mirror
|
||||
how Genres/Studios already gate by access).
|
||||
|
||||
### `internal/api/handlers/catalog.go`
|
||||
|
||||
Extend `catalogFiltersResponse` and `HandleGetCatalogFilters` to emit the
|
||||
new fields. Mirror the pattern used for the existing ones — no flag
|
||||
gating, audiobook filters should always be included when present.
|
||||
|
||||
### `internal/catalog/query_builder.go`
|
||||
|
||||
Three new field names: `author`, `narrator`, `series`. The first two
|
||||
reuse `buildPersonClause` with the appropriate `PersonKind`. `series`
|
||||
needs a small new clause:
|
||||
|
||||
```go
|
||||
case "series":
|
||||
// EXISTS (SELECT 1 FROM audiobook_series s
|
||||
// WHERE s.content_id = media_items.content_id
|
||||
// AND lower(s.series_name) = lower($N))
|
||||
```
|
||||
|
||||
Register the three field names in `catalogQueryRuleFields` so the parser
|
||||
accepts them. None of them are personalized — they don't go into
|
||||
`catalogPersonalRuleFields`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `internal/catalog/catalog_resolver_test.go` — extend with audiobook
|
||||
scope: assert authors/narrators/series come back, scoped to libraries
|
||||
the test user has access to.
|
||||
- `internal/catalog/query_builder_test.go` — add cases for `author`,
|
||||
`narrator`, `series` rules; verify the emitted SQL clauses match the
|
||||
expected shape.
|
||||
|
||||
## Frontend
|
||||
|
||||
### `web/src/api/types.ts`
|
||||
|
||||
Extend `CatalogFiltersResponse` (and any `ItemFiltersResponse` base, if
|
||||
shared) with optional `authors?: string[]`, `narrators?: string[]`,
|
||||
`series?: string[]`.
|
||||
|
||||
Extend `QueryRule['field']` to include `'author' | 'narrator' | 'series'`
|
||||
if it's a literal union (or no-op if it's `string`).
|
||||
|
||||
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
|
||||
|
||||
Add to `GuidedFormState`:
|
||||
- `author: string`
|
||||
- `narrator: string`
|
||||
- `series: string`
|
||||
|
||||
Update `queryDefinitionToGuidedState` / `guidedStateToQueryDefinition` to
|
||||
round-trip the three rules (mirror how `actor` / `director` are wired).
|
||||
|
||||
In the audiobook-only render path (already gated via `isAudiobookLibrary`),
|
||||
add a new section *above* the country row:
|
||||
|
||||
```tsx
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Author picker /> {/* PersonSearchSelect with kind="author" */}
|
||||
<Narrator picker /> {/* PersonSearchSelect with kind="narrator" */}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Series picker /> {/* SearchableSelect over filters.series */}
|
||||
</div>
|
||||
```
|
||||
|
||||
`PersonSearchSelect` likely needs a new `kind` prop so it can scope its
|
||||
backend lookup to authors or narrators. If today it queries all kinds,
|
||||
we'll add a query param + handler-side filter.
|
||||
|
||||
### `ActiveFilterBadges` + `catalogFilterBadges`
|
||||
|
||||
Add badge rendering for the three new fields so they appear in the
|
||||
selected-filters chip row above the editor.
|
||||
|
||||
### Tests
|
||||
|
||||
- `web/src/components/collections/CollectionGuidedRulesEditor.test.tsx`
|
||||
— render with `libraryType="audiobooks"` and a filters payload
|
||||
containing authors/narrators/series; assert all three sections appear
|
||||
and update the query definition correctly.
|
||||
|
||||
## Migration / data
|
||||
|
||||
No schema changes required. `PersonKindAuthor=7` and `PersonKindNarrator=8`
|
||||
already exist in `internal/models/media.go`. `audiobook_series` already
|
||||
holds series names (migration 145). The scanner is already writing both.
|
||||
|
||||
## Risk
|
||||
|
||||
- **Filter facet performance.** Three new distinct-value queries per
|
||||
`/catalog/filters` request. The existing facet queries use the same
|
||||
`facetFetcher` infrastructure, so they'll inherit the same query-time
|
||||
budget. For an audiobook library on the order of a few thousand items
|
||||
this should be fine; if it's not, we can cache author/narrator lookups
|
||||
more aggressively (they change rarely).
|
||||
- **PersonSearchSelect kind scoping.** If the component today emits a
|
||||
single combined kind=any search, we need to add kind-filtered variants
|
||||
without breaking the existing actor/director/writer/producer pickers.
|
||||
- **Series name normalization.** Backfill at migration 145 used regex
|
||||
parsing; some items may have inconsistent casing or whitespace.
|
||||
Distinct facet query should `TRIM(series_name)` and present `LOWER`
|
||||
for matching; otherwise duplicates show up in the picker.
|
||||
|
||||
## Rollout
|
||||
|
||||
This is gated entirely behind `libraryType === "audiobook[s]"` on the
|
||||
frontend, and the new filter fields are additive on the backend. No
|
||||
feature flag needed. Ship in two PRs if reviewer prefers:
|
||||
|
||||
1. **Backend** — filter response fields + query-builder clauses + tests.
|
||||
2. **Frontend** — types + GuidedFormState + editor sections + tests.
|
||||
|
||||
Or a single PR if the diff stays reviewable (~600 lines including tests).
|
||||
|
||||
## Verification
|
||||
|
||||
- Add new tests as above; `make lint` and `cd web && pnpm vitest run`
|
||||
should pass.
|
||||
- Manually: open the audiobook library filter sheet, pick an author and
|
||||
a narrator, confirm the result set narrows. Pick a series, confirm
|
||||
same.
|
||||
@@ -1,123 +0,0 @@
|
||||
# `audiobook_series` data cleanup
|
||||
|
||||
**Status:** Draft. Findings + options; no implementation yet.
|
||||
|
||||
## Problem
|
||||
|
||||
Migration `145_audiobook_series.up.sql` (which created the
|
||||
`audiobook_series` table) shipped with a regex backfill that
|
||||
over-matched. On this server's 220k-book audiobook library it produced
|
||||
**162,322 distinct series names**, of which **141,591 (87%) are
|
||||
singletons** — books that are the only "member" of their named series.
|
||||
|
||||
Spot checks make the failure mode obvious: rows like
|
||||
|
||||
- `series_name = "Computer Programming: This Book Includes: SQL, …"` for a standalone book whose subtitle happened to contain `"#2022 Version"`
|
||||
- `series_name = "Reparación de crédito [Credit Repair]"` (the title verbatim)
|
||||
- `series_name = "Cómo Atraer a las Mujeres … [How to Attract Women …]"` (the title verbatim)
|
||||
|
||||
The regex (`'^.+[^\s-]\s+\d+(?:\.\d+)?\s*-\s*.+$'`) treats any numeric
|
||||
chapter / edition / part token followed by ` - ` as series-N-of-book
|
||||
syntax. The real series (Star Wars, Warhammer 40k, Animorphs, Redwall,
|
||||
*In Death*, etc.) live in the 733-row "large series" bucket; the
|
||||
9,075 pairs and 10,923 small clusters mix real and false-positive.
|
||||
|
||||
The cap added in `b95494c` keeps the user-facing dropdown manageable
|
||||
(top 1000 alphabetically), but the underlying data is still polluted —
|
||||
sorting by series, the Series filter dropdown, and any future
|
||||
"recommended" surfacing of series will all surface noise until the
|
||||
backfill is repaired.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Options
|
||||
|
||||
### A — delete singletons (destructive)
|
||||
|
||||
```sql
|
||||
DELETE FROM audiobook_series
|
||||
WHERE series_name IN (
|
||||
SELECT series_name FROM audiobook_series GROUP BY series_name HAVING COUNT(*) = 1
|
||||
);
|
||||
```
|
||||
|
||||
- **Pros:** simple, removes the bulk of the noise in one shot, no
|
||||
schema change.
|
||||
- **Cons:** also deletes legitimate "first book of a not-yet-complete
|
||||
series" rows. Reversal requires a re-scan.
|
||||
|
||||
### B — filter singletons at query time, leave data alone
|
||||
|
||||
In `listDistinctAudiobookSeriesWithSource`, group by series name and
|
||||
`HAVING COUNT(*) >= 2`.
|
||||
|
||||
- **Pros:** non-destructive; the table keeps full information for
|
||||
detail-page lookups and series-detail surfaces.
|
||||
- **Cons:** hides legitimate one-book-only series from the filter
|
||||
dropdown. Series-detail and sort behaviour unchanged (still surface
|
||||
singletons). The 11.7 MB payload was already capped to 1000 in
|
||||
`b95494c`, so the size win from this option is small.
|
||||
|
||||
### C — mark backfill provenance, delete only backfilled singletons
|
||||
|
||||
Add a `source` column to `audiobook_series` (`'scanner' | 'backfill'`)
|
||||
in a new migration. Migration 145's backfill rows get `source =
|
||||
'backfill'`; scanner-written rows get `'scanner'`. Then delete only the
|
||||
backfilled singletons.
|
||||
|
||||
- **Pros:** distinguishes legitimate from spurious without guessing.
|
||||
- **Cons:** retroactive — we'd have to assume all current rows are
|
||||
backfill (which they mostly aren't anymore — the scanner has been
|
||||
writing too). Without a marker added at backfill time, can't tell
|
||||
what's what.
|
||||
|
||||
### D — wipe + re-scan (clean slate)
|
||||
|
||||
```sql
|
||||
TRUNCATE audiobook_series;
|
||||
```
|
||||
|
||||
Then trigger a full library re-scan so the scanner writes only the
|
||||
real series_name values it extracts from tags.
|
||||
|
||||
- **Pros:** the cleanest end state; the scanner is authoritative.
|
||||
- **Cons:** long re-scan window (the user already noticed 219k books
|
||||
is slow to scan); any books not currently scannable lose their
|
||||
series info entirely. Destructive.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Option B as a quick win** — non-destructive, ships as a one-line SQL
|
||||
change in the facet helper, makes the Series filter dropdown useful
|
||||
immediately. The dropdown stops showing 87% noise.
|
||||
|
||||
**Option D as the eventual fix** — once the scanner has been verified
|
||||
to write series_name authoritatively (including the series_index from
|
||||
real tags), `TRUNCATE audiobook_series` + re-scan is the right end
|
||||
state. Need to confirm scanner behaviour before pulling the trigger.
|
||||
|
||||
Option A is too blunt (drops real first-books-of-series). Option C is
|
||||
over-engineered without a marker added at backfill time.
|
||||
|
||||
## What this plan does not cover
|
||||
|
||||
- Whether the scanner currently writes `audiobook_series` rows
|
||||
authoritatively for every audiobook it touches, or only when tags
|
||||
contain explicit series metadata. Need to check
|
||||
`internal/scanner/audiobook.go` (or wherever the audiobook_series
|
||||
upsert lives) before proposing option D as the long-term path.
|
||||
- Whether *Search Series* (the user-facing series-detail page, if
|
||||
any) would also benefit from the same cleanup.
|
||||
- Sort-by-series on the library page — even with option B in place,
|
||||
the sort still groups by every series_name including singletons,
|
||||
so books in fake "series" appear under their fake series name.
|
||||
Acceptable for now; revisit alongside option D.
|
||||
|
||||
## Verification (once an option is picked)
|
||||
|
||||
- Re-run the data sanity query in this doc's intro and confirm the
|
||||
singleton count dropped where expected.
|
||||
- Open the Series filter dropdown on the audiobook library page and
|
||||
confirm only multi-book series show up.
|
||||
- `go test ./internal/catalog/...` + `cd web && pnpm vitest run` still
|
||||
pass.
|
||||
@@ -1,143 +0,0 @@
|
||||
# Server-side typeahead for catalog facets
|
||||
|
||||
**Status:** Draft. Architecture sketch; no implementation yet.
|
||||
|
||||
## Why
|
||||
|
||||
`b95494c` capped `/api/v1/catalog/filters` facet responses to 1000
|
||||
distinct values each, which fixed an 11.7 MB payload on the audiobook
|
||||
library (88k authors, 92k narrators, 161k series). The cap is a
|
||||
stopgap — a user looking for an author past the first 1000
|
||||
alphabetically can't find them through the dropdown.
|
||||
|
||||
Parallel context: a separate investigation (transcript in the
|
||||
2026-05-27 sessions) compared how `librarymanagerre`, `booklore-ng`,
|
||||
and `audiobookshelf` paginate large libraries. The cursor +
|
||||
virtual-scroll camp (librarymanagerre, booklore-ng's
|
||||
`@tanstack/react-virtual`) scales to 250k+ items by never sending the
|
||||
client a full list; the eager-shelves camp (audiobookshelf web +
|
||||
mobile) renders one DOM div per item and falls over above ~30–50k.
|
||||
The 11.7 MB facet issue here is the same shape on a different surface
|
||||
— "too much data shipped to the client at once."
|
||||
|
||||
The cleanest fix is **server-side typeahead**: the client sends a
|
||||
search prefix as the user types, the server returns the top N matches
|
||||
for that prefix.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Surface
|
||||
|
||||
### `/api/v1/catalog/filters/search`
|
||||
|
||||
New endpoint, scoped per facet, query parameters mirror
|
||||
`/api/v1/catalog/filters` (`source`, `library_id`, etc.) plus:
|
||||
|
||||
- `facet=author|narrator|series|studio|network|country|genre|original_language|content_rating`
|
||||
- `q=<prefix>` — the user's typed prefix (1-64 chars, trimmed)
|
||||
- `limit=<N>` — capped at e.g. 50
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "matches": ["Brandon Sanderson", "Brandon Sanderson & Steven Erikson", ...], "has_more": false }
|
||||
```
|
||||
|
||||
Returns up to `limit` matches sorted by:
|
||||
1. exact-prefix match first (`q` matches the start of the value)
|
||||
2. then case-insensitive substring match
|
||||
3. then alphabetical
|
||||
|
||||
`has_more` is true when the underlying result set was truncated.
|
||||
|
||||
### `/api/v1/catalog/filters` keeps the current shape
|
||||
|
||||
No change to the existing endpoint — it still returns the capped top
|
||||
1000 per facet for the initial dropdown render. The typeahead surface
|
||||
takes over once the user starts typing.
|
||||
|
||||
## Backend changes
|
||||
|
||||
### `internal/catalog/catalog_resolver.go`
|
||||
|
||||
New method `SearchFacetWithOptions(ctx, req, access, facet, prefix,
|
||||
limit)` that:
|
||||
1. Resolves the same access/scope as `ListFiltersWithOptions`.
|
||||
2. Dispatches to a facet-specific helper based on `facet`.
|
||||
3. Returns the matches + has_more flag.
|
||||
|
||||
Add to `facetFetcher` interface:
|
||||
|
||||
```go
|
||||
SearchPeopleByKind(ctx, kind models.PersonKind, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
SearchAudiobookSeries(ctx, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
SearchDistinctArrayColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
SearchDistinctScalarColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
|
||||
```
|
||||
|
||||
Each helper runs the existing facet SQL with an added
|
||||
`WHERE LOWER(<value>) LIKE LOWER($N || '%')` (prefix match) and
|
||||
`LIMIT N+1` (so we can detect has_more by checking if the result set
|
||||
exceeds N).
|
||||
|
||||
### `internal/api/handlers/catalog.go`
|
||||
|
||||
New handler `HandleCatalogFacetSearch` mounted at
|
||||
`/api/v1/catalog/filters/search`.
|
||||
|
||||
## Frontend changes
|
||||
|
||||
### `web/src/components/ui/searchable-select.tsx` (or a new variant)
|
||||
|
||||
`SearchableSelect` today does client-side filtering over the full
|
||||
`options` array. Replace it for the high-cardinality facets with a
|
||||
debounced server search:
|
||||
|
||||
- Type `<= 100ms` of inactivity → fire `/catalog/filters/search?facet=author&q=...`
|
||||
- Reset focus + cancel in-flight when the user keeps typing
|
||||
- Show the first 50 matches; "show more" disabled (the user types more
|
||||
to narrow further)
|
||||
|
||||
The existing `SearchableSelect` stays for low-cardinality facets
|
||||
(genres, content_ratings, etc.) where the initial 1000 is plenty.
|
||||
|
||||
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
|
||||
|
||||
Author / Narrator / Series sections switch to the new typeahead-backed
|
||||
select. Studio / Network / Country can stay on `SearchableSelect`
|
||||
(low-cardinality) or migrate later for consistency.
|
||||
|
||||
## Tests
|
||||
|
||||
- Backend: search SQL emits the LIKE prefix + LIMIT N+1, has_more
|
||||
semantics, access filter still gates the result set.
|
||||
- Frontend: typeahead component debounces, cancels stale requests,
|
||||
renders empty / loading / no-results states.
|
||||
|
||||
## Migration
|
||||
|
||||
None — additive endpoint. Frontend can be rolled out incrementally:
|
||||
audiobook-only sections first (since they're the worst offenders),
|
||||
others later.
|
||||
|
||||
## What this plan does not cover
|
||||
|
||||
- The audiobookshelf-app pagination question from the separate
|
||||
transcript. That's a different surface (the ABS-compat
|
||||
`/abs/api/libraries/{id}/items` endpoint on `:13378`), not the silo
|
||||
native catalog. Same architectural family but the patch lives in
|
||||
`internal/audiobooks/abs/libraries_handler.go`, not catalog.
|
||||
- A general client-side virtual scroll for the library grid itself.
|
||||
That's the cousin problem ("250k books rendered as 125k shelf
|
||||
divs"); cross-applies but is its own work.
|
||||
|
||||
## Risk / rollout
|
||||
|
||||
- New endpoint is opt-in for the frontend — existing callers keep
|
||||
using the bulk `/filters` response.
|
||||
- Typeahead latency budget: each keystroke fires one DB query against
|
||||
the same indexes the bulk facets already use. Should be fast
|
||||
(`item_people(content_id, kind)` covers people lookups,
|
||||
`audiobook_series(content_id)` covers series, and a `LOWER()`
|
||||
functional index already exists on
|
||||
`audiobook_series_name_lower`). Verify with EXPLAIN before shipping.
|
||||
@@ -1,314 +0,0 @@
|
||||
# Collections Unification — Sub-project 1: Schema Migration
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** Land migration 156 — extend `user_personal_collections` to admit `'playlist'` and `'smart'` types, add nullable `sub_item_id` to its items table, add `media_types` filter to `page_sections`, move the single existing `abs_playlists` row into the canonical store, and drop the five `abs_*` collection tables.
|
||||
|
||||
**Architecture:** Pure schema migration + one-row data move. No application code changes. The follow-up sub-projects 2–4 wire silo to the new schema; this one is just the storage foundation. Hard cutover — the `abs_*` tables are near-empty (1 playlist row, 0 of everything else) so rollback risk is minimal.
|
||||
|
||||
**Tech Stack:** PostgreSQL 18, raw SQL migrations under `migrations/`, no Go code in this sub-project.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.1, §4.4, §5.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Create:**
|
||||
- `migrations/156_unify_user_collections.up.sql`
|
||||
- `migrations/156_unify_user_collections.down.sql`
|
||||
|
||||
**No Go code modified in this sub-project.** Application code keeps querying the (now-dropped) `abs_*` tables until sub-projects 3 and 4 land, which means **silo will break in dev/staging until sub-project 3 ships.** Land this sub-project and sub-project 3 together (single MR) if a production deploy is imminent. Otherwise it's safe to ship in isolation on a feature branch that hasn't been merged yet.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Up-migration body
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/156_unify_user_collections.up.sql`
|
||||
|
||||
- [ ] **Step 1: Write the migration file**
|
||||
|
||||
```sql
|
||||
-- Unify user-owned lists into user_personal_collections.
|
||||
--
|
||||
-- See docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md
|
||||
-- for design rationale.
|
||||
|
||||
-- 1. Sub-item granularity column on the canonical items table.
|
||||
-- Empty string for whole-item entries; populated for podcast-episode
|
||||
-- playlist entries (sub_item_id == abs_playlist_items.episode_id).
|
||||
ALTER TABLE user_personal_collection_items
|
||||
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
|
||||
|
||||
-- 2. Widen the collection_type domain. The pre-existing CHECK (if any)
|
||||
-- only admits 'manual' and 'synced'.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
|
||||
|
||||
-- 3. Move the existing abs_playlists row(s) into the canonical store.
|
||||
-- is_public maps to is_shared; profile_id (uuid) is stringified.
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
COALESCE(profile_id::text, ''),
|
||||
name,
|
||||
description,
|
||||
'playlist',
|
||||
is_public,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(profile_id::text, '')
|
||||
FROM abs_playlists;
|
||||
|
||||
INSERT INTO user_personal_collection_items
|
||||
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
|
||||
SELECT
|
||||
p.user_id,
|
||||
i.playlist_id,
|
||||
i.library_item_id,
|
||||
i.episode_id,
|
||||
i.position,
|
||||
i.added_at
|
||||
FROM abs_playlist_items i
|
||||
JOIN abs_playlists p ON p.id = i.playlist_id;
|
||||
|
||||
-- 4. Drop the abs_* collection tables. abs_playlist_items has a FK to
|
||||
-- abs_playlists, so the order matters.
|
||||
DROP TABLE abs_playlist_items;
|
||||
DROP TABLE abs_playlists;
|
||||
DROP TABLE abs_collection_items;
|
||||
DROP TABLE abs_user_collections;
|
||||
DROP TABLE abs_smart_collections;
|
||||
|
||||
-- 5. Media-type filter on page_sections. Default preserves current
|
||||
-- behavior (existing rails surface movies+series only).
|
||||
ALTER TABLE page_sections
|
||||
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the file compiles as valid SQL**
|
||||
|
||||
The repo embeds migrations via `migrations/embed.go`. The migration runner will syntax-check on load.
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: clean (the embed package compiles fine even if a new file is added).
|
||||
|
||||
- [ ] **Step 3: Commit (up only — down comes next task)**
|
||||
|
||||
Don't commit yet. Commit happens after Task 2's down-migration so the up/down pair lands atomically.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Down-migration body
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/156_unify_user_collections.down.sql`
|
||||
|
||||
The down migration is intentionally lossy in reverse: rolling back loses any `'playlist'`/`'smart'` collections that were created after the up. With the up bringing in 1 row, lossy reverse is acceptable.
|
||||
|
||||
- [ ] **Step 1: Find the abs_* CREATE TABLE statements to inline**
|
||||
|
||||
Read these files and copy their `CREATE TABLE` bodies (and `CREATE INDEX` statements; **omit** any `DROP TABLE IF EXISTS` boilerplate at the top — the down migration creates from clean state):
|
||||
|
||||
```bash
|
||||
cat migrations/149_abs_user_collections.up.sql
|
||||
cat migrations/150_abs_collection_items.up.sql
|
||||
cat migrations/151_abs_playlists.up.sql
|
||||
cat migrations/152_abs_playlist_items.up.sql
|
||||
cat migrations/153_abs_smart_collections.up.sql
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the down migration**
|
||||
|
||||
```sql
|
||||
-- Reverse migration 156. Lossy in reverse — playlist/smart rows
|
||||
-- created after the up migration are deleted, not migrated back.
|
||||
|
||||
-- 1. Remove the page_sections column.
|
||||
ALTER TABLE page_sections DROP COLUMN media_types;
|
||||
|
||||
-- 2. Recreate the abs_* tables empty. Schemas are inlined from the
|
||||
-- original up migrations 149–153 — keep identical (column types,
|
||||
-- constraint names, index names) so any tool keyed off those names
|
||||
-- sees the same shape.
|
||||
--
|
||||
-- BEGIN inlined from migrations/149_abs_user_collections.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/150_abs_collection_items.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/151_abs_playlists.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/152_abs_playlist_items.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
|
||||
-- END inlined
|
||||
|
||||
-- BEGIN inlined from migrations/153_abs_smart_collections.up.sql
|
||||
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
|
||||
-- END inlined
|
||||
|
||||
-- 3. Remove rows we promoted from abs_playlists during the up.
|
||||
DELETE FROM user_personal_collection_items
|
||||
WHERE collection_id IN (
|
||||
SELECT id FROM user_personal_collections
|
||||
WHERE collection_type IN ('playlist', 'smart')
|
||||
);
|
||||
DELETE FROM user_personal_collections
|
||||
WHERE collection_type IN ('playlist', 'smart');
|
||||
|
||||
-- 4. Restore the narrow CHECK constraint.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced'));
|
||||
|
||||
-- 5. Drop the sub_item_id column.
|
||||
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
|
||||
```
|
||||
|
||||
Replace each `<PASTE EXACT ... statements>` block with the actual SQL from the corresponding `migrations/14X_abs_*.up.sql` file. Do NOT skip any constraint or index — the round-trip test below verifies parity.
|
||||
|
||||
- [ ] **Step 3: Verify SQL syntactic validity**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 4: Commit the up+down pair**
|
||||
|
||||
```bash
|
||||
git add migrations/156_unify_user_collections.up.sql migrations/156_unify_user_collections.down.sql
|
||||
git commit -m "feat(migrations): 156 unify user collections
|
||||
|
||||
Adds sub_item_id to user_personal_collection_items, widens
|
||||
collection_type to include 'playlist' and 'smart', adds media_types
|
||||
filter to page_sections, moves the existing abs_playlists row into
|
||||
the canonical store, and drops the abs_* collection tables.
|
||||
|
||||
Lays the storage foundation for sub-projects 2-4 to wire the
|
||||
application against."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Migration round-trip test
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/156_unify_user_collections_test.go` (only if a similar pattern exists in the repo)
|
||||
|
||||
The repo doesn't have a standard "migration round-trip test" harness today (verify with `find . -name "migration*_test.go" -o -name "*round_trip*_test.go" | head`). If no harness exists, this task is **best-effort manual verification**:
|
||||
|
||||
- [ ] **Step 1: Verify a harness pattern exists, or skip to manual verification**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
find . -name "migration*_test.go" -not -path "*/node_modules/*" 2>/dev/null | head
|
||||
grep -rln "migrate.Up\|migrate.Down\|migrate.Steps" --include="*_test.go" 2>/dev/null | head
|
||||
```
|
||||
|
||||
If no harness emerges, fall through to Step 2 (manual). Otherwise add a Go test following the existing harness pattern that:
|
||||
1. Migrates to 155.
|
||||
2. Inserts a fake `abs_playlists` row (id `test-pl-1`, user 1, name `test`).
|
||||
3. Inserts a fake `abs_playlist_items` row referencing it.
|
||||
4. Migrates up to 156.
|
||||
5. Asserts `user_personal_collections WHERE collection_type='playlist' AND id='test-pl-1'` exists and `user_personal_collection_items WHERE collection_id='test-pl-1'` exists.
|
||||
6. Migrates down to 155.
|
||||
7. Asserts the `abs_playlists` table exists and is empty (the row is GONE — down is lossy by design).
|
||||
|
||||
- [ ] **Step 2: Manual verification against a throwaway DB**
|
||||
|
||||
If no harness, do the following manually once and capture the output in the MR description:
|
||||
|
||||
```bash
|
||||
# 1. Spin up an empty postgres
|
||||
docker run --rm -d --name pgcheck -e POSTGRES_PASSWORD=x -p 5443:5432 pgvector/pgvector:pg18
|
||||
sleep 5
|
||||
|
||||
# 2. Apply migrations up to 155 (use the existing migrate binary or schema dump)
|
||||
# Adapt this to whatever the repo uses to run migrations against an arbitrary DB.
|
||||
|
||||
# 3. Insert a fake abs_playlists + items row.
|
||||
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
|
||||
INSERT INTO abs_playlists(id, user_id, name) VALUES ('test-pl-1', 1, 'test');
|
||||
INSERT INTO abs_playlist_items(playlist_id, library_item_id, position) VALUES ('test-pl-1', 'foo', 0);
|
||||
"
|
||||
|
||||
# 4. Apply migration 156.
|
||||
# 5. Verify the row landed in user_personal_collections.
|
||||
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
|
||||
SELECT collection_type, name FROM user_personal_collections WHERE id='test-pl-1';
|
||||
SELECT collection_id, media_item_id, sub_item_id FROM user_personal_collection_items WHERE collection_id='test-pl-1';
|
||||
"
|
||||
# Expected: collection_type='playlist', name='test'; one item row with sub_item_id=''
|
||||
|
||||
# 6. Apply down migration.
|
||||
# 7. Verify abs_playlists exists (empty) and user_personal_collections has no playlist rows.
|
||||
|
||||
# Cleanup
|
||||
docker rm -f pgcheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: If you added a Go test, commit it**
|
||||
|
||||
```bash
|
||||
git add migrations/156_unify_user_collections_test.go
|
||||
git commit -m "test(migrations): 156 round-trip verifies abs_playlists migrate to canonical"
|
||||
```
|
||||
|
||||
If only manual verification was done, capture the output in the MR description instead — no commit needed.
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. **Dev DB** — run `make dev-backend` against a fresh DB. Migration 156 applies cleanly. `\d user_personal_collection_items` shows the `sub_item_id` column. `\d page_sections` shows `media_types`.
|
||||
|
||||
2. **Existing data migration** — on a copy of the production DB (or staging if available), run migration 156 and verify the single existing `abs_playlists` row is now in `user_personal_collections` with `collection_type='playlist'`:
|
||||
|
||||
```sql
|
||||
SELECT id, name, collection_type FROM user_personal_collections WHERE collection_type='playlist';
|
||||
```
|
||||
|
||||
3. **abs_* tables gone** — confirm:
|
||||
|
||||
```sql
|
||||
SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name LIKE 'abs_%collection%';
|
||||
-- Should return 0 rows.
|
||||
```
|
||||
|
||||
`abs_sessions`, `abs_bookmarks`, `abs_playback_sessions`, `abs_rss_feeds` remain — those aren't in scope here.
|
||||
|
||||
4. **Silo binary builds against the new schema** — `go build ./...` is green even though the ABS store adapters still reference the dropped tables. They reference them via SQL strings, so compile-time is fine; runtime will fail until sub-project 3 rewrites them. **Do NOT** deploy without sub-project 3, or stash this migration behind a feature flag.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- `sub_item_id` column ✓ (Task 1)
|
||||
- `collection_type` widened to playlist/smart ✓ (Task 1)
|
||||
- Move existing abs_playlists row ✓ (Task 1)
|
||||
- Drop abs_* tables ✓ (Task 1)
|
||||
- `page_sections.media_types` ✓ (Task 1)
|
||||
- Down migration ✓ (Task 2)
|
||||
- Round-trip test ✓ (Task 3, best-effort)
|
||||
|
||||
**Placeholder scan:** The `<PASTE EXACT ... statements>` blocks in Task 2 Step 2 are template placeholders that the implementer must fill in by copying from the existing migration files. Each is bounded by explicit BEGIN/END comment markers naming the source file. The instructions in Step 1 say to read those files first; Step 2's block then becomes literal SQL. Not a "TBD" in the bad sense — it's a deliberate copy-from-source step.
|
||||
|
||||
**Type consistency:** `sub_item_id` (text, NOT NULL DEFAULT '') consistent in up + down + verification queries. `collection_type` enum values consistent across the spec, up, down, and CHECK constraint.
|
||||
|
||||
**Risk:** All schema-level, no application changes. The biggest risk is forgetting to ship sub-project 3 in the same release — runtime will start erroring on ABS endpoints the moment migration 156 applies, because the adapters still query the dropped tables.
|
||||
@@ -1,274 +0,0 @@
|
||||
# Collections Unification — Sub-project 2: Smartcoll Engine Lift
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** Move the smart-collection rule engine from `internal/audiobooks/smartcoll/` to `internal/smartcoll/` so all media types (movie, series, audiobook) share one evaluator. Pure code move + import update; no logic changes.
|
||||
|
||||
**Architecture:** The 4-file package (~1060 LOC) currently lives in `internal/audiobooks/smartcoll/`. We relocate it verbatim and rewire its imports. The existing in-place audiobook caller (`internal/audiobooks/abs_smart_collection_store.go`) updates its import path. Sub-projects 3 and 4 will later call the engine from new sites (ABS adapter rewrites, section recipes); those plans cover their own wiring.
|
||||
|
||||
**Tech Stack:** Go. Standard `gopls`-style refactor: move, fix imports, run tests.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.2, §4.6.
|
||||
|
||||
**Predecessor sub-project:** None — this lands independent of sub-project 1. Order doesn't matter.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Move (4 files):**
|
||||
- `internal/audiobooks/smartcoll/evaluator.go` → `internal/smartcoll/evaluator.go`
|
||||
- `internal/audiobooks/smartcoll/evaluator_test.go` → `internal/smartcoll/evaluator_test.go`
|
||||
- `internal/audiobooks/smartcoll/query.go` → `internal/smartcoll/query.go`
|
||||
- `internal/audiobooks/smartcoll/query_test.go` → `internal/smartcoll/query_test.go`
|
||||
|
||||
**Modify (import path):**
|
||||
- Every file that imports `github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll` — change to `github.com/Silo-Server/silo-server/internal/smartcoll`.
|
||||
|
||||
**Add (new test, cross-type coverage):**
|
||||
- `internal/smartcoll/cross_type_test.go` — three tests verifying audiobook-specific rules no-op against non-audiobook items.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Move the package directory
|
||||
|
||||
**Files:** see "Move" list above.
|
||||
|
||||
- [ ] **Step 1: Pre-check current contents**
|
||||
|
||||
```bash
|
||||
ls internal/audiobooks/smartcoll/
|
||||
wc -l internal/audiobooks/smartcoll/*.go
|
||||
```
|
||||
|
||||
Expected: 4 files (evaluator.go, evaluator_test.go, query.go, query_test.go). If the contents differ, pause and report — the plan assumes this exact set.
|
||||
|
||||
- [ ] **Step 2: Verify nothing outside the package references it (yet) besides the known caller**
|
||||
|
||||
```bash
|
||||
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" .
|
||||
```
|
||||
|
||||
Expected: `internal/audiobooks/abs_smart_collection_store.go` and possibly its test, plus the package's own test files. If you find any other importer, pause and add it to the import-update list in Task 2.
|
||||
|
||||
- [ ] **Step 3: Create the new directory and move the files**
|
||||
|
||||
```bash
|
||||
mkdir -p internal/smartcoll
|
||||
git mv internal/audiobooks/smartcoll/evaluator.go internal/smartcoll/evaluator.go
|
||||
git mv internal/audiobooks/smartcoll/evaluator_test.go internal/smartcoll/evaluator_test.go
|
||||
git mv internal/audiobooks/smartcoll/query.go internal/smartcoll/query.go
|
||||
git mv internal/audiobooks/smartcoll/query_test.go internal/smartcoll/query_test.go
|
||||
rmdir internal/audiobooks/smartcoll
|
||||
```
|
||||
|
||||
`git mv` preserves blame history.
|
||||
|
||||
- [ ] **Step 4: Update the package declaration in each moved file**
|
||||
|
||||
The package name was `smartcoll`; it stays `smartcoll`. Open each moved file and **verify** the `package smartcoll` line is unchanged. If any file declares `package audiobooks_smartcoll` or similar, fix it to `package smartcoll`.
|
||||
|
||||
```bash
|
||||
head -1 internal/smartcoll/*.go
|
||||
```
|
||||
|
||||
Expected: every line reads `package smartcoll`. If any differ, fix with sed:
|
||||
|
||||
```bash
|
||||
sed -i '1s/^package .*/package smartcoll/' internal/smartcoll/<filename>.go
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build — this WILL fail until Task 2 runs**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: compilation error in `internal/audiobooks/abs_smart_collection_store.go` because its import path now points at a non-existent directory. That's expected — Task 2 fixes it. **Do not commit yet.**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update all imports
|
||||
|
||||
**Files:**
|
||||
- Modify: every file from Task 1 Step 2's grep result.
|
||||
|
||||
- [ ] **Step 1: Update each importer**
|
||||
|
||||
The typical pattern in the existing caller is:
|
||||
|
||||
```go
|
||||
import (
|
||||
// ...
|
||||
"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"
|
||||
)
|
||||
```
|
||||
|
||||
Becomes:
|
||||
|
||||
```go
|
||||
import (
|
||||
// ...
|
||||
"github.com/Silo-Server/silo-server/internal/smartcoll"
|
||||
)
|
||||
```
|
||||
|
||||
For each file in the Task 1 Step 2 grep result, run:
|
||||
|
||||
```bash
|
||||
sed -i 's|"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"|"github.com/Silo-Server/silo-server/internal/smartcoll"|' <file>
|
||||
```
|
||||
|
||||
Or edit by hand using your editor's find-and-replace.
|
||||
|
||||
- [ ] **Step 2: Verify no stale import remains**
|
||||
|
||||
```bash
|
||||
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" . || echo "clean"
|
||||
```
|
||||
|
||||
Expected: `clean` (no matches).
|
||||
|
||||
- [ ] **Step 3: Build + test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./internal/smartcoll/ ./internal/audiobooks/... -short -timeout 60s
|
||||
go vet ./internal/smartcoll/ ./internal/audiobooks/...
|
||||
```
|
||||
|
||||
Expected: all green. The moved tests still pass because the engine logic is unchanged; the package path changed but the test code itself is package-local.
|
||||
|
||||
- [ ] **Step 4: Commit the move + import fix**
|
||||
|
||||
```bash
|
||||
git add internal/smartcoll/ internal/audiobooks/
|
||||
git commit -m "refactor(smartcoll): lift engine to internal/smartcoll
|
||||
|
||||
Moves the smart-collection rule engine out of internal/audiobooks/
|
||||
so movie/TV smart collections can share the same evaluator.
|
||||
|
||||
No logic changes. Tests move with their package and continue to pass."
|
||||
```
|
||||
|
||||
`git log --follow internal/smartcoll/evaluator.go` should show the prior history at the old path.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Cross-type smoke tests
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/smartcoll/cross_type_test.go`
|
||||
|
||||
The existing tests cover audiobook-specific rule evaluation. Sub-project 4 will start asking the engine to evaluate against movies + TV, so we add three small tests that nail down the no-op contract for audiobook-specific predicates against non-audiobook items.
|
||||
|
||||
- [ ] **Step 1: Find the audiobook-specific rule kinds**
|
||||
|
||||
```bash
|
||||
grep -nE 'narrator|series_position|"type":"audiobook"' internal/smartcoll/query.go | head -10
|
||||
```
|
||||
|
||||
Expected: rule-kind constants named like `RuleKindNarrator`, `RuleKindSeriesPosition`, or similar. Note the exact names — the test below references them. **If the rule kinds are not actually present (rule registration happens elsewhere), pause and report — the spec assumed they exist.**
|
||||
|
||||
- [ ] **Step 2: Read the existing query/evaluator test fixtures**
|
||||
|
||||
```bash
|
||||
sed -n '/func Test/,/^func /p' internal/smartcoll/evaluator_test.go | head -60
|
||||
```
|
||||
|
||||
Note the harness pattern used (how an evaluator is constructed in tests, how it's given items, how match-results are asserted). Mirror that pattern in the new tests.
|
||||
|
||||
- [ ] **Step 3: Write the failing tests**
|
||||
|
||||
Create `internal/smartcoll/cross_type_test.go`:
|
||||
|
||||
```go
|
||||
package smartcoll
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// TestNarratorRuleNoopOnMovie verifies that an audiobook-specific narrator
|
||||
// rule treats movie items as a no-op (matches everything, since the rule
|
||||
// can't apply). This is the cross-type contract that lets the engine
|
||||
// evaluate against mixed-type item sets without errors.
|
||||
func TestNarratorRuleNoopOnMovie(t *testing.T) {
|
||||
// Adapt to the actual evaluator-construction API discovered in Step 2.
|
||||
// Expected: build an evaluator with a narrator-name predicate, evaluate
|
||||
// it against a movie item, assert no error AND the item is matched
|
||||
// (or excluded — pick whichever the existing audiobook tests treat as
|
||||
// "no-op" and document it).
|
||||
t.Skip("Implement once the evaluator API is read from existing tests")
|
||||
}
|
||||
|
||||
// TestSeriesPositionRuleNoopOnSeries verifies the audiobook series_position
|
||||
// rule has no effect on a TV series item (which has its own series concept,
|
||||
// distinct from book series).
|
||||
func TestSeriesPositionRuleNoopOnSeries(t *testing.T) {
|
||||
t.Skip("Implement once the evaluator API is read from existing tests")
|
||||
}
|
||||
|
||||
// TestLibraryIdFilterAppliesToAllTypes verifies that the simple library_id
|
||||
// predicate (the silo-native query_definition subset) evaluates correctly
|
||||
// against any media type. This locks in compat with existing
|
||||
// user_personal_collections rows after migration 156.
|
||||
func TestLibraryIdFilterAppliesToAllTypes(t *testing.T) {
|
||||
t.Skip("Implement once the evaluator API is read from existing tests")
|
||||
}
|
||||
|
||||
var _ = models.MediaItem{} // import-stability marker; remove once tests are real
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fill in the test bodies**
|
||||
|
||||
Replace each `t.Skip(...)` with a real test using the harness pattern from the existing tests. The exact code depends on the evaluator's public surface (constructor, evaluate method, predicate registration). **Read `internal/smartcoll/evaluator.go` and the existing test file once before writing the bodies** — don't guess at the API.
|
||||
|
||||
If the evaluator's no-op behavior for type-mismatched rules turns out to be "exclude the item" rather than "match the item", adjust the assertions to reflect that — but document the chosen semantic in a comment above each test so future readers know which it is.
|
||||
|
||||
- [ ] **Step 5: Run the new tests**
|
||||
|
||||
```bash
|
||||
go test ./internal/smartcoll/ -run "Cross|Noop|LibraryIdFilter" -v
|
||||
```
|
||||
|
||||
Expected: all three PASS, and the existing tests in the package continue to pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/smartcoll/cross_type_test.go
|
||||
git commit -m "test(smartcoll): cross-type evaluator contract
|
||||
|
||||
Locks in the no-op semantics for audiobook-specific rules evaluated
|
||||
against non-audiobook items, plus library_id filter compat across
|
||||
all types."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. `git log --follow internal/smartcoll/evaluator.go` shows the prior history at `internal/audiobooks/smartcoll/evaluator.go` — confirms blame preserved.
|
||||
2. `go test ./internal/smartcoll/ -v` runs both the moved tests and the three new cross-type tests, all passing.
|
||||
3. `grep -rln 'internal/audiobooks/smartcoll' --include="*.go" .` returns empty.
|
||||
4. No production behavior change. Smart collections in the ABS API still resolve via the engine; the engine just lives at a new path.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Move package to `internal/smartcoll/` ✓ (Task 1)
|
||||
- Update import paths ✓ (Task 2)
|
||||
- Cross-type evaluator tests ✓ (Task 3)
|
||||
|
||||
**Placeholder scan:** Task 3's `t.Skip(...)` lines are explicit failing-test stubs that Step 4 fills in. The instructions in Step 4 say to read the existing tests first rather than guess. Not a TBD — it's a "read-existing, then write" workflow with explicit guidance.
|
||||
|
||||
**Type consistency:** Package name `smartcoll` consistent. Import path `github.com/Silo-Server/silo-server/internal/smartcoll` consistent. Rule-kind names depend on what Step 1 of Task 3 discovers — the plan flags this explicitly.
|
||||
|
||||
**Risk:** Pure refactor. The chance of breakage is low (Go's import system catches missed renames at compile time). The cross-type tests are new functionality, but they assert behavior that already exists in the engine (no-op on type-mismatch) so they should pass without code changes.
|
||||
@@ -1,515 +0,0 @@
|
||||
# Collections Unification — Sub-project 3: ABS Store Adapter Rewrites
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** Rewrite the three ABS store adapters (`abs_collection_store.go`, `abs_playlist_store.go`, `abs_smart_collection_store.go`) to query the canonical `user_personal_collections` tables instead of the dropped `abs_*` tables. The wire shape returned by ABS HTTP endpoints must remain byte-identical so the ABS Android/iOS apps don't notice anything changed.
|
||||
|
||||
**Architecture:** The three adapter files expose a struct + ~6 methods each (List, Get, Create, Update, Delete, ListItems, AddItem, RemoveItem) consumed by `internal/audiobooks/abs/` HTTP handlers. We keep every public method signature unchanged and replace each SQL body with one that maps to `user_personal_collections` filtered by `collection_type`. Granular podcast-episode entries map to the new `sub_item_id` column. Library scoping (which library this list "belongs to" in ABS UI) is stored in `query_definition.library_ids`.
|
||||
|
||||
**Tech Stack:** Go, `pgx/v5`, `database/sql`-style query patterns from the existing adapters.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.3, §4.5.
|
||||
|
||||
**Predecessor sub-projects:**
|
||||
- Sub-project 1 (migration 156) **must** land before this. The new tables/columns are required.
|
||||
- Sub-project 2 (smartcoll lift) needed only for the smart-collection adapter — if 2 hasn't landed, Task 3 below can either wait for it or temporarily keep the old import path.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Modify (rewrite bodies, keep method signatures):**
|
||||
- `internal/audiobooks/abs_collection_store.go` (193 lines today; ~8 methods)
|
||||
- `internal/audiobooks/abs_playlist_store.go` (210 lines today; ~9 methods including `coverArg`)
|
||||
- `internal/audiobooks/abs_smart_collection_store.go` (~140 lines today; ~6 methods)
|
||||
|
||||
**Add (snapshot tests for wire-shape preservation):**
|
||||
- `internal/audiobooks/abs_collection_store_test.go` (if not already present — check first)
|
||||
- `internal/audiobooks/abs_playlist_store_test.go`
|
||||
- `internal/audiobooks/abs_smart_collection_store_test.go`
|
||||
|
||||
**No new files** beyond those three test files. No new exported types.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Capture pre-cutover wire shapes (snapshots)
|
||||
|
||||
This task runs against the **pre-migration** ABS endpoints. Capture JSON now so we can diff after.
|
||||
|
||||
**Files:** none modified; output captured to `/tmp/abs_wire_*.json`.
|
||||
|
||||
- [ ] **Step 1: Identify the wire-shape contract**
|
||||
|
||||
The ABS HTTP handlers live in `internal/audiobooks/abs/`. They call the store adapters, which return Go structs. The structs (`abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, etc.) marshal to JSON via standard struct tags. The wire shape is fixed by those struct tag declarations.
|
||||
|
||||
Read them:
|
||||
|
||||
```bash
|
||||
grep -n "type Collection struct\|type Playlist struct\|type SmartCollection struct\|type CollectionItem struct\|type PlaylistItem struct" internal/audiobooks/abs/*.go
|
||||
```
|
||||
|
||||
For each struct, note every field's JSON tag. **This is your reference contract.** The rewritten store must populate every field with the same semantic content as today.
|
||||
|
||||
- [ ] **Step 2: Capture sample JSON from existing endpoints (if any data exists)**
|
||||
|
||||
The DB today has 1 abs_playlists row, 0 collections, 0 smart collections. Even one sample helps. Hit each endpoint via `curl` against the running silo and save the output:
|
||||
|
||||
```bash
|
||||
# Adapt to your env's auth / port
|
||||
PORT=8090 # silo's API port from .env
|
||||
TOKEN=... # admin or user JWT
|
||||
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/collections \
|
||||
> /tmp/abs_wire_collections.json
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/playlists \
|
||||
> /tmp/abs_wire_playlists.json
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/smart-collections \
|
||||
> /tmp/abs_wire_smart_collections.json
|
||||
```
|
||||
|
||||
If you don't have a token or the data is uninteresting (empty arrays), skip this step — the field-by-field reference from Step 1 is enough to write correct code.
|
||||
|
||||
- [ ] **Step 3: Note any non-obvious mappings**
|
||||
|
||||
For each adapter, write out the mapping between abs.* struct fields and `user_personal_collections` columns. Sample for Collection:
|
||||
|
||||
| `abs.Collection` field | Source column |
|
||||
|---|---|
|
||||
| `Id` | `user_personal_collections.id` |
|
||||
| `Name` | `user_personal_collections.name` |
|
||||
| `Description` | `user_personal_collections.description` |
|
||||
| `UserId` | `user_personal_collections.user_id` |
|
||||
| `LibraryId` | derived from `query_definition.library_ids[0]` (single library scope per collection) |
|
||||
| `IsPublic` | `user_personal_collections.is_shared` |
|
||||
| `CreatedAt` / `UpdatedAt` | timestamps as-is |
|
||||
|
||||
Similar table for Playlist and SmartCollection. **Write this out in a working note** — you'll reference it in Task 2.
|
||||
|
||||
No commit for this task.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rewrite `ABSCollectionStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/audiobooks/abs_collection_store.go`
|
||||
- Test: `internal/audiobooks/abs_collection_store_test.go` (create if missing)
|
||||
|
||||
The current 8 methods are: `ListUserCollections`, `GetCollection`, `CreateCollection`, `UpdateCollection`, `DeleteCollection`, `ListCollectionItems`, `AddCollectionItem`, `RemoveCollectionItem`.
|
||||
|
||||
For each, the rewrite swaps `FROM abs_user_collections` / `FROM abs_collection_items` for the canonical equivalents.
|
||||
|
||||
- [ ] **Step 1: Write a failing test for `ListUserCollections`**
|
||||
|
||||
Append to `internal/audiobooks/abs_collection_store_test.go` (create the file if absent, declaring `package audiobooks`):
|
||||
|
||||
```go
|
||||
package audiobooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
// ...add the test DB harness import discovered by the next step
|
||||
)
|
||||
|
||||
func TestABSCollectionStoreListUserCollections(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires test DB")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// Set up a test pool against migration head (>= 156).
|
||||
pool := newTestPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
// Insert one user_personal_collections row with collection_type='manual',
|
||||
// is_shared=true, library_ids=[9] in query_definition.
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id, query_definition)
|
||||
VALUES
|
||||
('test-c-1', 1, '', 'TestColl', 'desc', 'manual',
|
||||
true, NOW(), NOW(), '', '{"library_ids":[9]}'::jsonb)
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
store := &ABSCollectionStore{pool: pool}
|
||||
got, err := store.ListUserCollections(ctx, "1", "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserCollections: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d collections, want 1", len(got))
|
||||
}
|
||||
if got[0].Id != "test-c-1" || got[0].Name != "TestColl" || !got[0].IsPublic {
|
||||
t.Errorf("unexpected fields: %+v", got[0])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Verify the test-pool harness:** `grep -rln "func newTestPool\|func testPool" --include="*_test.go" .` finds the project's convention. If none exists, this test stays gated by `testing.Short()` and is best-effort — flag in the PR.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
|
||||
```
|
||||
|
||||
Expected: FAIL because `ListUserCollections` still queries the dropped `abs_user_collections` table.
|
||||
|
||||
- [ ] **Step 3: Rewrite `ListUserCollections`**
|
||||
|
||||
Replace the existing function body with:
|
||||
|
||||
```go
|
||||
func (s *ABSCollectionStore) ListUserCollections(ctx context.Context, userID, profileID string) ([]abs.Collection, error) {
|
||||
const q = `
|
||||
SELECT
|
||||
id, name, description, COALESCE(query_definition->'library_ids'->>0, '0')::int AS library_id,
|
||||
user_id, is_shared, created_at, updated_at
|
||||
FROM user_personal_collections
|
||||
WHERE collection_type = 'manual'
|
||||
AND user_id = $1::int
|
||||
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, q, userID, profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("abs collection list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []abs.Collection
|
||||
for rows.Next() {
|
||||
var c abs.Collection
|
||||
if err := rows.Scan(&c.Id, &c.Name, &c.Description, &c.LibraryId, &c.UserId, &c.IsPublic, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan abs collection: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
```
|
||||
|
||||
Adjust the `Scan` order to match `abs.Collection`'s actual field types — read the struct from `internal/audiobooks/abs/` to confirm. If `Id` is named `ID` per Go convention, fix accordingly.
|
||||
|
||||
- [ ] **Step 4: Run the test, verify it passes**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Rewrite the remaining 7 methods using the same pattern**
|
||||
|
||||
For each method, apply this template:
|
||||
|
||||
| Method | Canonical operation |
|
||||
|---|---|
|
||||
| `GetCollection(id)` | `SELECT … WHERE id=$1 AND collection_type='manual'` |
|
||||
| `CreateCollection(c)` | `INSERT INTO user_personal_collections (...) VALUES (..., 'manual', ..., jsonb_build_object('library_ids', jsonb_build_array(c.LibraryId)))` |
|
||||
| `UpdateCollection(c)` | `UPDATE user_personal_collections SET name=$2, description=$3, is_shared=$4, query_definition=$5, updated_at=NOW() WHERE id=$1 AND collection_type='manual'` |
|
||||
| `DeleteCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='manual'` (cascades to items via the existing FK) |
|
||||
| `ListCollectionItems(collectionID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `media_item_id`→`abs.CollectionItem.LibraryItemId`, `sub_item_id`→`abs.CollectionItem.EpisodeId` |
|
||||
| `AddCollectionItem(collectionID, libraryItemID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, '', COALESCE(MAX(position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
|
||||
| `RemoveCollectionItem(collectionID, libraryItemID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=''` |
|
||||
|
||||
Each gets its own targeted test mirroring Step 1's structure: insert seed data, call the method, assert the canonical-table side-effect (`SELECT … FROM user_personal_collection_items …`).
|
||||
|
||||
For brevity, the plan shows the SQL above as a table; the actual implementation has each method as a complete function. Reuse the connection-error-wrapping style and the `fmt.Errorf("...: %w", err)` pattern already in the file.
|
||||
|
||||
- [ ] **Step 6: Run all tests for the file**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSCollectionStore -v
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/audiobooks/abs_collection_store.go internal/audiobooks/abs_collection_store_test.go
|
||||
git commit -m "refactor(audiobooks): ABS collection store queries canonical tables
|
||||
|
||||
Rewrites ABSCollectionStore's 8 methods to read/write
|
||||
user_personal_collections (collection_type='manual') instead of the
|
||||
dropped abs_user_collections / abs_collection_items tables. Library
|
||||
scope is now encoded in query_definition.library_ids.
|
||||
|
||||
ABS HTTP handlers consume the same method signatures; wire shape
|
||||
is unchanged."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Rewrite `ABSPlaylistStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/audiobooks/abs_playlist_store.go`
|
||||
- Test: `internal/audiobooks/abs_playlist_store_test.go` (create if missing)
|
||||
|
||||
Same pattern as Task 2. Methods: `ListUserPlaylists`, `GetPlaylist`, `coverArg`, `CreatePlaylist`, `UpdatePlaylist`, `DeletePlaylist`, `ListPlaylistItems`, `AddPlaylistItem`, `RemovePlaylistItem`.
|
||||
|
||||
Filter on `collection_type='playlist'` instead of `'manual'`.
|
||||
|
||||
**Special handling for `coverArg`:** `abs_playlists` has a `cover_item` foreign key to `media_items`; the canonical `user_personal_collections` has only `poster_url` (text). Per spec §6 open question, the design recommends dropping `cover_item` and regenerating poster URLs from the first item. For this rewrite:
|
||||
|
||||
- `coverArg` becomes a noop / removed: the new CreatePlaylist doesn't accept a cover_item field on insert.
|
||||
- The wire-shape struct can keep its `CoverItem` field; the rewrite always serializes it as `""` (empty). If the ABS app surfaces a cover, it falls back to the first item's poster — same as the audiobook UI does today.
|
||||
|
||||
- [ ] **Step 1: Write a failing test for `ListUserPlaylists`**
|
||||
|
||||
```go
|
||||
func TestABSPlaylistStoreListUserPlaylists(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("requires test DB")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id)
|
||||
VALUES
|
||||
('test-pl-1', 1, '', 'TestPlaylist', 'desc', 'playlist',
|
||||
false, NOW(), NOW(), '')
|
||||
`)
|
||||
if err != nil { t.Fatalf("seed: %v", err) }
|
||||
|
||||
store := &ABSPlaylistStore{pool: pool}
|
||||
got, err := store.ListUserPlaylists(ctx, "1", "")
|
||||
if err != nil { t.Fatalf("ListUserPlaylists: %v", err) }
|
||||
if len(got) != 1 || got[0].Id != "test-pl-1" || got[0].Name != "TestPlaylist" {
|
||||
t.Errorf("unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test, fail, then rewrite**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSPlaylistStoreListUserPlaylists -v
|
||||
```
|
||||
|
||||
Expected: FAIL until rewrite lands.
|
||||
|
||||
- [ ] **Step 3: Rewrite `ListUserPlaylists`**
|
||||
|
||||
```go
|
||||
func (s *ABSPlaylistStore) ListUserPlaylists(ctx context.Context, userID, profileID string) ([]abs.Playlist, error) {
|
||||
const q = `
|
||||
SELECT id, name, description, user_id, is_shared, created_at, updated_at
|
||||
FROM user_personal_collections
|
||||
WHERE collection_type = 'playlist'
|
||||
AND user_id = $1::int
|
||||
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := s.pool.Query(ctx, q, userID, profileID)
|
||||
if err != nil { return nil, fmt.Errorf("abs playlist list: %w", err) }
|
||||
defer rows.Close()
|
||||
|
||||
var out []abs.Playlist
|
||||
for rows.Next() {
|
||||
var p abs.Playlist
|
||||
if err := rows.Scan(&p.Id, &p.Name, &p.Description, &p.UserId, &p.IsPublic, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan abs playlist: %w", err)
|
||||
}
|
||||
// CoverItem deliberately left as zero value; see plan + spec §6.
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rewrite the remaining methods**
|
||||
|
||||
| Method | Canonical operation |
|
||||
|---|---|
|
||||
| `GetPlaylist(id)` | `SELECT … WHERE id=$1 AND collection_type='playlist'` |
|
||||
| `CreatePlaylist(p)` | `INSERT … VALUES (..., 'playlist', ...)` — drop cover_item handling |
|
||||
| `UpdatePlaylist(p)` | `UPDATE … SET name=$2, description=$3, is_shared=$4, updated_at=NOW() WHERE id=$1 AND collection_type='playlist'` |
|
||||
| `DeletePlaylist(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='playlist'` |
|
||||
| `ListPlaylistItems(playlistID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `sub_item_id`→`abs.PlaylistItem.EpisodeId` (empty string when no episode) |
|
||||
| `AddPlaylistItem(playlistID, libraryItemID, episodeID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, $3, COALESCE(MAX(i.position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
|
||||
| `RemovePlaylistItem(playlistID, libraryItemID, episodeID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=$3` |
|
||||
| `coverArg(cover string)` | Delete entirely — no longer used. |
|
||||
|
||||
- [ ] **Step 5: Run all playlist tests**
|
||||
|
||||
```bash
|
||||
go test ./internal/audiobooks/ -run TestABSPlaylistStore -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/audiobooks/abs_playlist_store.go internal/audiobooks/abs_playlist_store_test.go
|
||||
git commit -m "refactor(audiobooks): ABS playlist store queries canonical tables
|
||||
|
||||
Rewrites ABSPlaylistStore methods to read/write user_personal_collections
|
||||
(collection_type='playlist') and user_personal_collection_items
|
||||
(sub_item_id for episode-level entries). Drops the cover_item field
|
||||
(no longer modeled in the canonical store; clients fall back to
|
||||
first-item poster, same as audiobook UI today).
|
||||
|
||||
Wire shape preserved otherwise."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewrite `ABSSmartCollectionStore`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/audiobooks/abs_smart_collection_store.go`
|
||||
- Test: `internal/audiobooks/abs_smart_collection_store_test.go` (create if missing)
|
||||
|
||||
Filter on `collection_type='smart'`. The rule DSL stored in `query_definition` (jsonb) — same column the silo-native query_definition uses. Note: the source column on `abs_smart_collections` was named `query_def` (not `query_definition`); the canonical column is `query_definition`. **Read+write target the canonical name.**
|
||||
|
||||
`color` and `is_pinned` from `abs_smart_collections` have no canonical equivalent (spec §6 defers them). The rewrite emits empty/false for those fields in the JSON output. If the ABS app silently relies on either, ship a follow-up to add the columns.
|
||||
|
||||
- [ ] **Step 1: Write a failing test**
|
||||
|
||||
```go
|
||||
func TestABSSmartCollectionStoreListUserSmart(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t)
|
||||
defer pool.Close()
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id, query_definition)
|
||||
VALUES
|
||||
('test-sc-1', 1, '', 'TestSmart', '', 'smart',
|
||||
false, NOW(), NOW(), '', '{"rules":[]}'::jsonb)
|
||||
`)
|
||||
if err != nil { t.Fatalf("seed: %v", err) }
|
||||
|
||||
store := &ABSSmartCollectionStore{pool: pool}
|
||||
got, err := store.ListUserSmartCollections(ctx, "1", "")
|
||||
if err != nil { t.Fatalf("ListUserSmartCollections: %v", err) }
|
||||
if len(got) != 1 || got[0].Id != "test-sc-1" {
|
||||
t.Errorf("unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite the 6 methods**
|
||||
|
||||
| Method | Canonical operation |
|
||||
|---|---|
|
||||
| `ListUserSmartCollections` | `SELECT id, name, description, query_definition, user_id, is_shared FROM user_personal_collections WHERE collection_type='smart' AND user_id=$1::int AND (profile_id=$2 OR ...) ORDER BY created_at DESC` |
|
||||
| `GetSmartCollection(id)` | same with `WHERE id=$1 AND collection_type='smart'` |
|
||||
| `CreateSmartCollection(c)` | `INSERT … 'smart' … query_definition=$N::jsonb` |
|
||||
| `UpdateSmartCollection(c)` | `UPDATE … SET name=$2, description=$3, query_definition=$4, is_shared=$5, updated_at=NOW() WHERE id=$1 AND collection_type='smart'` |
|
||||
| `DeleteSmartCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='smart'` |
|
||||
| Materializer (if present) | Calls `internal/smartcoll.Evaluate(...)` against `media_items` filtered by `query_definition.library_ids` |
|
||||
|
||||
For `color` and `is_pinned`: read from `abs.SmartCollection` zero-value on read paths; ignore on write paths. If the wire shape unconditionally includes them, leave the zero values in place — clients receive `"color":"", "is_pinned":false`.
|
||||
|
||||
- [ ] **Step 3: Tests + commit**
|
||||
|
||||
Same shape as Tasks 2 and 3. Commit message:
|
||||
|
||||
```
|
||||
refactor(audiobooks): ABS smart collection store queries canonical tables
|
||||
|
||||
Rewrites ABSSmartCollectionStore methods to read/write user_personal_collections
|
||||
(collection_type='smart'). Rule DSL goes into query_definition (formerly
|
||||
the abs_smart_collections.query_def column).
|
||||
|
||||
color and is_pinned have no canonical analog; emitted as zero values.
|
||||
See spec §6 for the deferred decision on those columns.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire-shape regression test (snapshot diff)
|
||||
|
||||
**Files:**
|
||||
- Add or extend: an integration test that drives the ABS handlers and asserts the JSON output for each endpoint hasn't changed.
|
||||
|
||||
If a snapshot/golden-file test framework already exists in the repo, use it (`grep -rln 'goldenfile\|snapshot' --include="*_test.go" internal/audiobooks/`). Otherwise this task is **manual verification** captured in the MR description:
|
||||
|
||||
- [ ] **Step 1: Seed identical fixtures pre- and post-rewrite**
|
||||
|
||||
Before the rewrite commits, seed one of each:
|
||||
- An `abs_user_collections` row (pre-migration) with name "FixtureColl", 1 item.
|
||||
- An `abs_playlists` row (pre-migration) with name "FixturePL", 1 item with sub_item.
|
||||
- An `abs_smart_collections` row (pre-migration) with name "FixtureSmart", rules `{"any":[…]}`.
|
||||
|
||||
Capture the JSON response from each list endpoint. Save to `/tmp/wire_before_*.json`.
|
||||
|
||||
- [ ] **Step 2: Run migration 156, then seed equivalent rows in canonical tables**
|
||||
|
||||
Insert one of each `user_personal_collections` row with the same Id, Name, etc.
|
||||
|
||||
Capture the JSON response. Save to `/tmp/wire_after_*.json`.
|
||||
|
||||
- [ ] **Step 3: Diff**
|
||||
|
||||
```bash
|
||||
for kind in collections playlists smart_collections; do
|
||||
diff /tmp/wire_before_${kind}.json /tmp/wire_after_${kind}.json
|
||||
done
|
||||
```
|
||||
|
||||
Expected: empty diff for each. If anything differs, note the field and either patch the rewrite to match or flag it as a wire-shape regression in the PR description so the ABS team is aware.
|
||||
|
||||
- [ ] **Step 4: Commit the test (if framework supports it) or paste the diff into the PR description**
|
||||
|
||||
```bash
|
||||
git add internal/audiobooks/abs_wireshape_test.go # if applicable
|
||||
git commit -m "test(audiobooks): wire-shape snapshot for ABS adapters
|
||||
|
||||
Locks in JSON parity for /api/libraries/{id}/collections|playlists|
|
||||
smart-collections after the canonical-table cutover."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. Silo binary starts cleanly against a migration-156 DB.
|
||||
2. ABS endpoints respond:
|
||||
- `GET /api/libraries/9/collections` — returns the 0 manual collections, no errors.
|
||||
- `GET /api/libraries/9/playlists` — returns the 1 promoted playlist with `Id` preserved.
|
||||
- `GET /api/libraries/9/smart-collections` — returns 0 smart collections, no errors.
|
||||
3. ABS Android app (or whatever client is in use) opens the library, sees the same content it saw pre-migration.
|
||||
4. Creating a new collection from the ABS app inserts into `user_personal_collections`, not anywhere else:
|
||||
|
||||
```sql
|
||||
SELECT collection_type, COUNT(*) FROM user_personal_collections GROUP BY 1;
|
||||
```
|
||||
|
||||
Expected: counts increase for the relevant `collection_type` as the user creates content.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Collection store rewrite ✓ (Task 2)
|
||||
- Playlist store rewrite ✓ (Task 3)
|
||||
- Smart collection store rewrite ✓ (Task 4)
|
||||
- Wire-shape preservation ✓ (Tasks 1, 5)
|
||||
- Drop cover_item ✓ (Task 3, per spec §6)
|
||||
- color / is_pinned deferred ✓ (Task 4, per spec §6)
|
||||
|
||||
**Placeholder scan:** Task 2 Step 5 lists 7 remaining methods as a table rather than spelling out the full Go body of each. The table gives the exact SQL semantics, the column mappings, and a pointer at the existing file's error-wrap style — sufficient for a competent implementer to write the bodies. The full code for each method would balloon this plan from ~700 to ~2000 lines; the table form is the right granularity. Same applies to Task 3 Step 4 and Task 4 Step 2. Each table entry could equally be a TDD task on its own; the implementer is free to expand any row into a write-test-fail-implement-pass-commit cycle.
|
||||
|
||||
**Type consistency:** `abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem` referenced consistently. `collection_type` enum values `'manual'`, `'playlist'`, `'smart'` consistent. `sub_item_id` column name consistent.
|
||||
|
||||
**Risk:** Largest sub-project of the four. Three files rewritten end-to-end. The biggest risk is wire-shape regressions where a struct field's serialized name differs subtly between adapters — Task 5 mitigates with the diff approach, but it's manual unless a snapshot harness exists. Sub-project 1 must land first; sub-project 2 must land first if Task 4 imports the new `internal/smartcoll` path.
|
||||
@@ -1,711 +0,0 @@
|
||||
# Collections Unification — Sub-project 4: Section Recipes for Audiobooks
|
||||
|
||||
> **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.
|
||||
|
||||
**Goal:** Make `page_sections` media-type-parameterized so existing recipes can include audiobooks, and add two new audiobook-flavored recipes (`continue_listening`, `by_audiobook_series`).
|
||||
|
||||
**Architecture:** Each recipe declaration in `internal/sections/recipes/` exposes a `SupportedMediaTypes` field. `PageSection` rows carry a `media_types text[]` column (added by migration 156 — sub-project 1). The fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes default to `['movie','series']` so current behavior is preserved.
|
||||
|
||||
**Tech Stack:** Go, PostgreSQL, existing `internal/sections/` + `internal/sections/recipes/` packages.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.4.
|
||||
|
||||
**Predecessor sub-project:** Sub-project 1 (migration 156) must land first — `page_sections.media_types` is required.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Modify:**
|
||||
- `internal/sections/types.go` — add `media_types []string` to the `PageSection` Go struct
|
||||
- `internal/sections/registry.go` (or wherever recipes are registered — verify via grep) — surface `SupportedMediaTypes()` on the Recipe interface
|
||||
- `internal/sections/recipes/*.go` — every recipe file declares its supported types
|
||||
- `internal/sections/recipes/registry.go` — recipe registry exposes the supported-types info to the admin API
|
||||
- Section fetchers (likely `internal/sections/fetcher.go` or similar) — wire `media_types` into the SQL `WHERE` clause
|
||||
|
||||
**Create:**
|
||||
- `internal/sections/recipes/continue_listening.go` + test
|
||||
- `internal/sections/recipes/by_audiobook_series.go` + test
|
||||
|
||||
**Add to admin API:**
|
||||
- `internal/api/handlers/recipes.go` — the `GET /api/sections/recipes` endpoint surfaces the per-recipe supported-media-types in its response
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Audit current recipe shape
|
||||
|
||||
This task is read-only — produces a working note for the rest of the plan.
|
||||
|
||||
- [ ] **Step 1: Map the current recipe interface**
|
||||
|
||||
```bash
|
||||
grep -nE "type Recipe interface|type RecipeDefinition|func.*Type\(\) string|func.*Definition\(\)|SupportedMediaTypes" internal/sections/recipes/*.go internal/sections/types.go internal/sections/registry.go 2>/dev/null
|
||||
```
|
||||
|
||||
Read the `Recipe` interface and any current registration code. Note:
|
||||
- The exact method signatures recipes already implement.
|
||||
- How a recipe is registered (`init()` calls? explicit `Register`? slice literal?).
|
||||
- Whether `RecipeDefinition` carries metadata returned to the admin UI; that's where `SupportedMediaTypes` will surface.
|
||||
|
||||
- [ ] **Step 2: Map the fetcher's current SQL**
|
||||
|
||||
```bash
|
||||
grep -nE "FetchOne|FetchSection|SELECT.*FROM media_items|type Fetcher" internal/sections/*.go 2>/dev/null | head -20
|
||||
```
|
||||
|
||||
Find the function that turns a `ResolvedSection` into a list of `media_items`. Note its query shape — where the `WHERE` clause lives, what parameters it already takes.
|
||||
|
||||
- [ ] **Step 3: Note the section persistence shape**
|
||||
|
||||
```bash
|
||||
grep -nE "INSERT INTO page_sections|UPDATE page_sections|SELECT.*FROM page_sections" internal/sections/*.go internal/api/handlers/sections*.go 2>/dev/null | head -10
|
||||
```
|
||||
|
||||
Find where `page_sections` rows are read and written. The `media_types` column needs to be read/written there too.
|
||||
|
||||
No commit for this task — it produces a mental map you'll reference in Tasks 2–5.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `media_types` to the `PageSection` struct + serde
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/sections/types.go` (`PageSection` struct)
|
||||
- Modify: wherever `page_sections` rows are scanned and written (from Task 1 Step 3)
|
||||
|
||||
- [ ] **Step 1: Write failing test for `media_types` round-trip**
|
||||
|
||||
The test goes in whichever package owns the `PageSection` repository — likely `internal/sections/` itself. If a repository test file exists, append; otherwise create one:
|
||||
|
||||
```go
|
||||
func TestPageSectionMediaTypesRoundTrip(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t) // adapt to project's test pool harness
|
||||
|
||||
repo := NewSectionRepository(pool)
|
||||
|
||||
sec := &PageSection{
|
||||
Name: "Audiobook test",
|
||||
RecipeType: "library_staples",
|
||||
MediaTypes: []string{"audiobook"},
|
||||
// ...fill in other required fields from the struct definition
|
||||
}
|
||||
if err := repo.Create(ctx, sec); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
got, err := repo.GetByID(ctx, sec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.MediaTypes, []string{"audiobook"}) {
|
||||
t.Errorf("MediaTypes = %v, want [audiobook]", got.MediaTypes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Adapt the constructor name, struct fields, and DB harness to actual project shape (found in Task 1).
|
||||
|
||||
- [ ] **Step 2: Run the test to confirm failure**
|
||||
|
||||
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
|
||||
Expected: FAIL — the `MediaTypes` field doesn't exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the field to `PageSection`**
|
||||
|
||||
In `internal/sections/types.go`, find the `PageSection` struct (or whatever it's called per Task 1's audit) and add:
|
||||
|
||||
```go
|
||||
type PageSection struct {
|
||||
// ...existing fields
|
||||
MediaTypes []string `json:"media_types" db:"media_types"`
|
||||
}
|
||||
```
|
||||
|
||||
If the project's struct tagging convention differs, follow that convention. The DB column is `text[]`; the Go type is `[]string`. pgx's default decoder handles this directly.
|
||||
|
||||
- [ ] **Step 4: Update the repository's read/write SQL**
|
||||
|
||||
For each `INSERT`/`UPDATE` against `page_sections`, add `media_types` to the column list and bind parameter list. For each `SELECT`, add it to the projection and scan into the new field.
|
||||
|
||||
Example pattern (adapt to actual code):
|
||||
|
||||
```go
|
||||
const insertSQL = `
|
||||
INSERT INTO page_sections (id, name, recipe_type, ..., media_types)
|
||||
VALUES ($1, $2, $3, ..., $N::text[])
|
||||
`
|
||||
// ...
|
||||
_, err := pool.Exec(ctx, insertSQL, sec.ID, sec.Name, sec.RecipeType, ..., sec.MediaTypes)
|
||||
```
|
||||
|
||||
For SELECTs, add `media_types` to the projection and `&sec.MediaTypes` to the scan list.
|
||||
|
||||
- [ ] **Step 5: Run the test, verify pass**
|
||||
|
||||
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Build + broader test**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./internal/sections/ ./internal/api/handlers/ -short -timeout 90s
|
||||
```
|
||||
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/types.go internal/sections/*.go internal/api/handlers/sections*.go
|
||||
git commit -m "feat(sections): expose page_sections.media_types in Go
|
||||
|
||||
Adds MediaTypes []string to PageSection + repo read/write paths.
|
||||
Migration 156 (sub-project 1) added the underlying column with
|
||||
DEFAULT ARRAY['movie','series']; existing rows retain that default,
|
||||
preserving today's behavior."
|
||||
```
|
||||
|
||||
Only stage the files you actually modified. Verify with `git diff --cached --stat` before commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Recipe interface — `SupportedMediaTypes`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/sections/recipes/registry.go` (interface declaration)
|
||||
- Modify: every `*.go` recipe file in `internal/sections/recipes/` (each gains a one-line method)
|
||||
|
||||
- [ ] **Step 1: Find the Recipe interface**
|
||||
|
||||
```bash
|
||||
grep -n "type Recipe " internal/sections/recipes/*.go internal/sections/*.go
|
||||
```
|
||||
|
||||
Note the file + line of the interface declaration.
|
||||
|
||||
- [ ] **Step 2: Add a `SupportedMediaTypes()` method to the interface**
|
||||
|
||||
In whichever file declares the interface:
|
||||
|
||||
```go
|
||||
type Recipe interface {
|
||||
// ...existing methods
|
||||
SupportedMediaTypes() []string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement on every existing recipe**
|
||||
|
||||
For each `*Recipe` struct in `internal/sections/recipes/`, add the method. Existing recipes default to movies+series to preserve behavior:
|
||||
|
||||
```go
|
||||
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series"} }
|
||||
func (moodRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
|
||||
func (handPickedRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
|
||||
// ...and so on for every recipe in the directory
|
||||
```
|
||||
|
||||
Use this enumeration as a checklist (from `ls internal/sections/recipes/*.go`, excluding tests):
|
||||
|
||||
- admin_curated_list
|
||||
- custom
|
||||
- discovery
|
||||
- editorial
|
||||
- hand_picked
|
||||
- library_staples
|
||||
- mood
|
||||
- personalized
|
||||
|
||||
Each gets one method. Audiobook-eligible recipes — `library_staples`, `discovery`, `hand_picked`, `mood`, `personalized` — extend to include `"audiobook"` if their underlying query already handles all `media_items.type` values transparently. Conservative call: only `library_staples` extends to all three on this pass. The rest stay movies+series until each one's recipe-specific query is audited. **Bias toward the conservative default.**
|
||||
|
||||
Specifically:
|
||||
|
||||
```go
|
||||
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series", "audiobook"} }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
|
||||
Expected: clean. If any recipe is missed, the Go compiler will complain that the type doesn't satisfy the interface.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/recipes/
|
||||
git commit -m "feat(recipes): SupportedMediaTypes per recipe
|
||||
|
||||
Adds the SupportedMediaTypes() method to the Recipe interface and
|
||||
implements it on each existing recipe. Conservative defaults preserve
|
||||
today's behavior: only library_staples opens to audiobooks; others
|
||||
stay movies+series until their underlying queries are audited."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Fetcher applies the `media_types` filter
|
||||
|
||||
**Files:**
|
||||
- Modify: the section fetcher implementation file (found in Task 1 Step 2)
|
||||
|
||||
- [ ] **Step 1: Write a failing test**
|
||||
|
||||
Append to (or create) the fetcher's test file. Pattern:
|
||||
|
||||
```go
|
||||
func TestFetcherFiltersByMediaTypes(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
ctx := context.Background()
|
||||
pool := newTestPool(t)
|
||||
|
||||
// Seed: one movie, one audiobook, both eligible for the same recipe.
|
||||
seedTestMediaItem(t, pool, "mov-1", "movie", "Test Movie")
|
||||
seedTestMediaItem(t, pool, "ab-1", "audiobook", "Test Book")
|
||||
|
||||
fetcher := NewFetcher(pool)
|
||||
|
||||
t.Run("media_types movies only", func(t *testing.T) {
|
||||
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
|
||||
SectionType: "library_staples",
|
||||
MediaTypes: []string{"movie"},
|
||||
}, nil, nil, 1, "", catalog.AccessFilter{})
|
||||
assertContains(t, got.Items, "mov-1")
|
||||
assertExcludes(t, got.Items, "ab-1")
|
||||
})
|
||||
|
||||
t.Run("media_types audiobook only", func(t *testing.T) {
|
||||
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
|
||||
SectionType: "library_staples",
|
||||
MediaTypes: []string{"audiobook"},
|
||||
}, nil, nil, 1, "", catalog.AccessFilter{})
|
||||
assertContains(t, got.Items, "ab-1")
|
||||
assertExcludes(t, got.Items, "mov-1")
|
||||
})
|
||||
|
||||
t.Run("media_types both", func(t *testing.T) {
|
||||
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
|
||||
SectionType: "library_staples",
|
||||
MediaTypes: []string{"movie", "audiobook"},
|
||||
}, nil, nil, 1, "", catalog.AccessFilter{})
|
||||
assertContains(t, got.Items, "mov-1", "ab-1")
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Adapt `ResolvedSection`, `FetchOne` signature, `seedTestMediaItem`, `assertContains` / `assertExcludes` to the project's actual shapes. The exact assertions depend on what `SectionWithItems` exposes — likely a `.Items []SectionItem` slice.
|
||||
|
||||
- [ ] **Step 2: Run the test, verify fail**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
|
||||
```
|
||||
|
||||
Expected: FAIL — `ResolvedSection.MediaTypes` field doesn't exist yet OR the fetcher SQL doesn't filter.
|
||||
|
||||
- [ ] **Step 3: Add `MediaTypes` to `ResolvedSection`**
|
||||
|
||||
In `internal/sections/types.go` (or wherever `ResolvedSection` lives):
|
||||
|
||||
```go
|
||||
type ResolvedSection struct {
|
||||
// ...existing fields
|
||||
MediaTypes []string
|
||||
}
|
||||
```
|
||||
|
||||
The resolver code that builds `ResolvedSection` from a `PageSection` row needs one more line to pass `MediaTypes` through. Find it:
|
||||
|
||||
```bash
|
||||
grep -n "ResolvedSection{" internal/sections/*.go internal/api/handlers/*.go
|
||||
```
|
||||
|
||||
For each instance that builds one from a `PageSection`, add `MediaTypes: section.MediaTypes,`.
|
||||
|
||||
- [ ] **Step 4: Wire the filter into the fetcher SQL**
|
||||
|
||||
In the fetcher implementation, find the query that pulls `media_items`. Add to its `WHERE` clause:
|
||||
|
||||
```go
|
||||
const query = `
|
||||
SELECT ...
|
||||
FROM media_items mi
|
||||
WHERE ...
|
||||
AND mi.type = ANY($N::text[])
|
||||
...
|
||||
`
|
||||
// ...
|
||||
rows, err := pool.Query(ctx, query, ..., mediaTypes)
|
||||
```
|
||||
|
||||
If the fetcher dispatches to per-recipe resolvers, the filter goes inside each resolver's query. Most likely there's a shared query-building helper — find it via `grep -n "type = ANY\|recipe.*Resolve" internal/sections/`.
|
||||
|
||||
If the fetcher receives a `ResolvedSection` and dispatches to recipe resolvers based on `SectionType`, the resolvers each accept the `MediaTypes` slice as part of their `ResolverContext` (or whatever the recipe-side context type is). Add a field to that context type and thread it through.
|
||||
|
||||
**Validation rule**: the fetcher must reject a request where `ResolvedSection.MediaTypes` contains a type not in the recipe's `SupportedMediaTypes()`. Add this guard in the dispatch path:
|
||||
|
||||
```go
|
||||
recipe, ok := registry.Get(resolved.SectionType)
|
||||
if !ok { return result, fmt.Errorf("unknown recipe %q", resolved.SectionType) }
|
||||
supported := setOf(recipe.SupportedMediaTypes())
|
||||
for _, mt := range resolved.MediaTypes {
|
||||
if _, ok := supported[mt]; !ok {
|
||||
return result, fmt.Errorf("recipe %q does not support media type %q", resolved.SectionType, mt)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`setOf` is a 3-line helper that returns `map[string]struct{}` from a slice.
|
||||
|
||||
- [ ] **Step 5: Run the test, verify pass**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
|
||||
```
|
||||
|
||||
Expected: PASS for all three subtests.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/
|
||||
git commit -m "feat(sections): fetcher honors media_types filter
|
||||
|
||||
ResolvedSection carries a MediaTypes slice. Fetcher dispatch validates
|
||||
the slice against the recipe's SupportedMediaTypes() and forwards it
|
||||
into the underlying SQL (WHERE mi.type = ANY(\$N::text[]))."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: New recipe — `continue_listening`
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/sections/recipes/continue_listening.go`
|
||||
- Create: `internal/sections/recipes/continue_listening_test.go`
|
||||
|
||||
The audiobook analog of `continue_watching`. Surfaces audiobook items the user has progress on but hasn't finished.
|
||||
|
||||
- [ ] **Step 1: Read the existing `continue_watching` recipe (if present)**
|
||||
|
||||
```bash
|
||||
ls internal/sections/recipes/ | grep -i continue
|
||||
grep -rln "continue_watching\|user_watch_progress" internal/sections/recipes/ internal/sections/
|
||||
```
|
||||
|
||||
Identify the closest analog. Copy its overall structure (registration, params struct, Resolve method shape). The audiobook version differs only in `type = 'audiobook'` filter and possibly which progress columns it inspects (audiobook progress may live in the same `user_watch_progress` table — verify with `\d user_watch_progress` against the DB).
|
||||
|
||||
- [ ] **Step 2: Write a failing test**
|
||||
|
||||
```go
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContinueListeningRecipeRegistered(t *testing.T) {
|
||||
r, ok := registry.Get("continue_listening")
|
||||
if !ok {
|
||||
t.Fatal("continue_listening recipe not registered")
|
||||
}
|
||||
got := r.SupportedMediaTypes()
|
||||
want := []string{"audiobook"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Errorf("SupportedMediaTypes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueListeningResolvesProgressedAudiobooks(t *testing.T) {
|
||||
if testing.Short() { t.Skip("requires test DB") }
|
||||
// Seed two audiobook items: one with progress > 0 and not finished;
|
||||
// one with progress = 0. Run the recipe's Resolve method. Assert only
|
||||
// the first is returned.
|
||||
t.Skip("Implement once existing continue_watching test pattern is read")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement the recipe**
|
||||
|
||||
```go
|
||||
// Package recipes
|
||||
package recipes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type continueListeningRecipe struct{}
|
||||
|
||||
type ContinueListeningParams struct {
|
||||
// e.g. max items, library scope; mirror continue_watching's shape
|
||||
MaxItems int `json:"max_items"`
|
||||
}
|
||||
|
||||
func (continueListeningRecipe) Type() string { return "continue_listening" }
|
||||
func (continueListeningRecipe) NewParams() any { return &ContinueListeningParams{} }
|
||||
func (continueListeningRecipe) DefaultCacheTTL() time.Duration { return 5 * time.Minute }
|
||||
func (continueListeningRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
|
||||
|
||||
func (continueListeningRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
||||
const q = `
|
||||
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
|
||||
FROM media_items mi
|
||||
JOIN user_watch_progress uwp ON uwp.content_id = mi.content_id
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND uwp.user_id = $1
|
||||
AND uwp.position_seconds > 0
|
||||
AND COALESCE(uwp.completed, false) = false
|
||||
ORDER BY uwp.updated_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
params := rc.Params.(*ContinueListeningParams)
|
||||
limit := params.MaxItems
|
||||
if limit <= 0 || limit > 50 { limit = 20 }
|
||||
|
||||
rows, err := rc.Pool.Query(rc.Ctx, q, rc.UserID, limit)
|
||||
if err != nil { return ResolvedItems{}, fmt.Errorf("continue_listening: %w", err) }
|
||||
defer rows.Close()
|
||||
|
||||
var items []ResolvedItem
|
||||
for rows.Next() {
|
||||
var it ResolvedItem
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
|
||||
return ResolvedItems{}, fmt.Errorf("continue_listening scan: %w", err)
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
return ResolvedItems{Items: items}, rows.Err()
|
||||
}
|
||||
|
||||
func (continueListeningRecipe) Validate(raw json.RawMessage) error {
|
||||
var p ContinueListeningParams
|
||||
return json.Unmarshal(raw, &p)
|
||||
}
|
||||
|
||||
func (continueListeningRecipe) Definition() RecipeDefinition {
|
||||
return RecipeDefinition{
|
||||
Type: "continue_listening",
|
||||
Name: "Continue Listening",
|
||||
Description: "Audiobooks you've started but haven't finished.",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Register(continueListeningRecipe{})
|
||||
}
|
||||
```
|
||||
|
||||
**Adapt the field names** (`PosterPath`, `Pool`, `UserID`, `Ctx`, `Params`, etc.) to whatever the existing recipes use. **Adapt the SQL** to the actual `user_watch_progress` column names — verify with `\d user_watch_progress` against the DB.
|
||||
|
||||
- [ ] **Step 4: Run tests, verify pass**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/recipes/ -run TestContinueListening -v
|
||||
```
|
||||
|
||||
Expected: registration test PASS; the resolve test still skipped (or implemented and passing).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/recipes/continue_listening.go internal/sections/recipes/continue_listening_test.go
|
||||
git commit -m "feat(recipes): continue_listening audiobook rail
|
||||
|
||||
Audiobook analog of continue_watching. Returns books the user has
|
||||
progress on but hasn't finished, sorted by most-recently-listened."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: New recipe — `by_audiobook_series`
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/sections/recipes/by_audiobook_series.go`
|
||||
- Create: `internal/sections/recipes/by_audiobook_series_test.go`
|
||||
|
||||
Surfaces books grouped by series, drawing from the `audiobook_series` table populated by the scanner.
|
||||
|
||||
- [ ] **Step 1: Write the registration test**
|
||||
|
||||
```go
|
||||
func TestByAudiobookSeriesRecipeRegistered(t *testing.T) {
|
||||
r, ok := registry.Get("by_audiobook_series")
|
||||
if !ok { t.Fatal("by_audiobook_series not registered") }
|
||||
if got := r.SupportedMediaTypes(); !equalStringSlices(got, []string{"audiobook"}) {
|
||||
t.Errorf("SupportedMediaTypes = %v, want [audiobook]", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement the recipe**
|
||||
|
||||
```go
|
||||
type byAudiobookSeriesRecipe struct{}
|
||||
|
||||
type ByAudiobookSeriesParams struct {
|
||||
SeriesName string `json:"series_name"`
|
||||
}
|
||||
|
||||
func (byAudiobookSeriesRecipe) Type() string { return "by_audiobook_series" }
|
||||
func (byAudiobookSeriesRecipe) NewParams() any { return &ByAudiobookSeriesParams{} }
|
||||
func (byAudiobookSeriesRecipe) DefaultCacheTTL() time.Duration { return 30 * time.Minute }
|
||||
func (byAudiobookSeriesRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
|
||||
|
||||
func (byAudiobookSeriesRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
|
||||
const q = `
|
||||
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
|
||||
FROM media_items mi
|
||||
JOIN audiobook_series s ON s.content_id = mi.content_id
|
||||
WHERE mi.type = 'audiobook'
|
||||
AND s.series_name = $1
|
||||
ORDER BY COALESCE(s.series_index, 9999), mi.title
|
||||
`
|
||||
params := rc.Params.(*ByAudiobookSeriesParams)
|
||||
if params.SeriesName == "" {
|
||||
return ResolvedItems{}, fmt.Errorf("by_audiobook_series: series_name required")
|
||||
}
|
||||
rows, err := rc.Pool.Query(rc.Ctx, q, params.SeriesName)
|
||||
if err != nil { return ResolvedItems{}, fmt.Errorf("by_audiobook_series: %w", err) }
|
||||
defer rows.Close()
|
||||
|
||||
var items []ResolvedItem
|
||||
for rows.Next() {
|
||||
var it ResolvedItem
|
||||
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
|
||||
return ResolvedItems{}, fmt.Errorf("by_audiobook_series scan: %w", err)
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
return ResolvedItems{Items: items}, rows.Err()
|
||||
}
|
||||
|
||||
func (byAudiobookSeriesRecipe) Validate(raw json.RawMessage) error {
|
||||
var p ByAudiobookSeriesParams
|
||||
if err := json.Unmarshal(raw, &p); err != nil { return err }
|
||||
if p.SeriesName == "" { return fmt.Errorf("series_name is required") }
|
||||
return nil
|
||||
}
|
||||
|
||||
func (byAudiobookSeriesRecipe) Definition() RecipeDefinition {
|
||||
return RecipeDefinition{
|
||||
Type: "by_audiobook_series",
|
||||
Name: "By Audiobook Series",
|
||||
Description: "Books in a specific audiobook series, ordered by series position.",
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Register(byAudiobookSeriesRecipe{})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test, verify pass**
|
||||
|
||||
```bash
|
||||
go test ./internal/sections/recipes/ -run TestByAudiobookSeries -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/sections/recipes/by_audiobook_series.go internal/sections/recipes/by_audiobook_series_test.go
|
||||
git commit -m "feat(recipes): by_audiobook_series rail
|
||||
|
||||
Surfaces books in a named series ordered by series_index. Backed by
|
||||
the audiobook_series table populated by the scanner."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Surface `SupportedMediaTypes` in admin API response
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/handlers/recipes.go` (`GET /api/sections/recipes` handler)
|
||||
|
||||
The admin section-builder UI needs to know which recipes support audiobooks so it can offer the media-types multi-select to the operator. The handler's response shape adds one field per recipe.
|
||||
|
||||
- [ ] **Step 1: Read the current handler**
|
||||
|
||||
```bash
|
||||
cat internal/api/handlers/recipes.go
|
||||
```
|
||||
|
||||
Note the response struct shape — probably `[]RecipeInfo` or similar with `Type`, `Name`, `Description`.
|
||||
|
||||
- [ ] **Step 2: Add `SupportedMediaTypes` to the response**
|
||||
|
||||
In the response struct definition, add:
|
||||
|
||||
```go
|
||||
type recipeInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SupportedMediaTypes []string `json:"supported_media_types"`
|
||||
}
|
||||
```
|
||||
|
||||
In the handler body, set `SupportedMediaTypes: recipe.SupportedMediaTypes()`.
|
||||
|
||||
- [ ] **Step 3: Test**
|
||||
|
||||
If the project has a handler test for `GET /api/sections/recipes`, extend it. Otherwise the manual verification step covers this:
|
||||
|
||||
```bash
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '.[] | select(.type=="continue_listening")'
|
||||
# Expected: { "type":"continue_listening", "name":"Continue Listening", ..., "supported_media_types":["audiobook"] }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/api/handlers/recipes.go
|
||||
git commit -m "feat(api): expose supported_media_types in /api/sections/recipes
|
||||
|
||||
Admin UI section-builder uses this to gate the media-types
|
||||
multi-select per recipe."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification (after merge)
|
||||
|
||||
1. Existing sections continue to work — their `media_types` defaults to `['movie','series']` so query results are unchanged.
|
||||
2. Creating a section with `media_types=['audiobook']` and `recipe_type='library_staples'` returns audiobook items.
|
||||
3. Two new recipes registered:
|
||||
```bash
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '[.[] | .type] | sort'
|
||||
```
|
||||
Expected: includes `"continue_listening"` and `"by_audiobook_series"` along with the existing types.
|
||||
4. A user with active audiobook progress sees the `continue_listening` rail return their in-progress books.
|
||||
5. The admin section-builder UI (if updated) lets the operator pick `media_types` for any recipe whose `SupportedMediaTypes` includes that type.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- `page_sections.media_types` Go-side serde ✓ (Task 2)
|
||||
- `Recipe.SupportedMediaTypes()` on every recipe ✓ (Task 3)
|
||||
- Fetcher filter by `media_types` ✓ (Task 4)
|
||||
- `continue_listening` recipe ✓ (Task 5)
|
||||
- `by_audiobook_series` recipe ✓ (Task 6)
|
||||
- Admin API exposes per-recipe types ✓ (Task 7)
|
||||
|
||||
**Placeholder scan:** Test bodies in Tasks 5 and 6 are partly stubbed with `t.Skip(...)` to be filled in by reading the existing `continue_watching` test pattern. That's a deliberate "read-existing-then-write" instruction, not a TBD. The other stubs (test pool harness lookups, schema verification queries) are explicit grep commands the implementer runs at start of the task. No abstract "implement appropriately."
|
||||
|
||||
**Type consistency:** `SupportedMediaTypes`, `MediaTypes`, `ResolverContext`, `ResolvedItems`, recipe `Type()` string values all consistent across tasks.
|
||||
|
||||
**Risk:** Lowest-risk of the 4 sub-projects. The fetcher change has an explicit failing-test guard (Task 4 Step 1) so regression is unlikely. The two new recipes are additive; even if one's SQL is mis-specified, existing recipes are unaffected. The main risk is the audit decision in Task 3 Step 3 — being too aggressive (extending too many recipes to audiobooks) could surface bad audiobook rails on existing sections; being too conservative wastes the parameterization. Conservative is the safer default.
|
||||
@@ -1,174 +0,0 @@
|
||||
# Audiobooks Stacked PR Split 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.
|
||||
|
||||
**Goal:** Replace the oversized audiobook PR with a stacked series of smaller, reviewable PRs.
|
||||
|
||||
**Architecture:** Preserve the existing `feat/audiobooks` work as the source branch, then create branch cut points that expose one logical layer at a time. Each PR targets the previous branch so reviewers see only the incremental diff.
|
||||
|
||||
**Tech Stack:** Git, GitHub CLI, Go, pnpm/Vite, PostgreSQL migrations.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Close The Oversized PR
|
||||
|
||||
**Files:**
|
||||
- Modify: GitHub PR #17 only.
|
||||
|
||||
- [ ] **Step 1: Post a replacement note**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr comment 17 --repo Silo-Server/silo-server --body 'Closing this oversized PR in favor of a stacked series of smaller PRs. The existing branch is preserved as the source-of-truth while the stack is rebuilt into logical review units.'
|
||||
```
|
||||
|
||||
Expected: GitHub prints the created comment URL.
|
||||
|
||||
- [ ] **Step 2: Close PR #17 without deleting the branch**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr close 17 --repo Silo-Server/silo-server
|
||||
```
|
||||
|
||||
Expected: PR #17 state becomes `CLOSED`; branch `feat/audiobooks` remains available.
|
||||
|
||||
### Task 2: Create Stacked Branches
|
||||
|
||||
**Files:**
|
||||
- Modify: remote Git branches only.
|
||||
|
||||
- [ ] **Step 1: Create branch 1, foundation**
|
||||
|
||||
Branch name: `stack/audiobooks-foundation`
|
||||
|
||||
Scope:
|
||||
- audiobook feature flag/settings
|
||||
- audiobook and podcast schema foundations
|
||||
- scanner support
|
||||
- audiobook media item write path
|
||||
- no native UI
|
||||
- no ABS listener
|
||||
|
||||
- [ ] **Step 2: Create branch 2, native UI MVP**
|
||||
|
||||
Branch name: `stack/audiobooks-native-ui`
|
||||
|
||||
Base: `stack/audiobooks-foundation`
|
||||
|
||||
Scope:
|
||||
- `/api/v1/audiobooks`
|
||||
- audiobook detail/progress endpoints
|
||||
- audiobook frontend route/sidebar/player MVP
|
||||
|
||||
- [ ] **Step 3: Create branch 3, ABS core**
|
||||
|
||||
Branch name: `stack/audiobooks-abs-core`
|
||||
|
||||
Base: `stack/audiobooks-native-ui`
|
||||
|
||||
Scope:
|
||||
- ABS auth/login/refresh/logout
|
||||
- ABS listener and feature-flag listener gating
|
||||
- ABS library/items/play/progress
|
||||
- ABS access control and security hardening
|
||||
|
||||
- [ ] **Step 4: Create branch 4, ABS collections**
|
||||
|
||||
Branch name: `stack/audiobooks-abs-collections`
|
||||
|
||||
Base: `stack/audiobooks-abs-core`
|
||||
|
||||
Scope:
|
||||
- ABS bookmarks
|
||||
- ABS collections
|
||||
- ABS playlists
|
||||
- ABS smart collections
|
||||
- unified collection migration
|
||||
|
||||
- [ ] **Step 5: Create branch 5, extras and polish**
|
||||
|
||||
Branch name: `stack/audiobooks-extras`
|
||||
|
||||
Base: `stack/audiobooks-abs-collections`
|
||||
|
||||
Scope:
|
||||
- podcasts/RSS
|
||||
- stats
|
||||
- author/series extras
|
||||
- catalog audiobook filters/typeahead
|
||||
- performance and enrichment cleanup
|
||||
|
||||
### Task 3: Open The Stacked PRs
|
||||
|
||||
**Files:**
|
||||
- Modify: GitHub PRs only.
|
||||
|
||||
- [ ] **Step 1: Open PR 1**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base main --head stack/audiobooks-foundation --title 'feat(audiobooks): foundation and scanner support' --body 'First PR in the audiobook stack. Adds the schema/settings/scanner foundation without native UI or ABS compatibility.'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open PR 2**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-foundation --head stack/audiobooks-native-ui --title 'feat(audiobooks): native API and player MVP' --body 'Second PR in the audiobook stack. Adds native Silo audiobook endpoints and the MVP web player UI.'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Open PR 3**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-native-ui --head stack/audiobooks-abs-core --title 'feat(audiobooks): Audiobookshelf compatibility core' --body 'Third PR in the audiobook stack. Adds ABS auth, listener, library/items/play/progress, and security hardening.'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Open PR 4**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-core --head stack/audiobooks-abs-collections --title 'feat(audiobooks): ABS collections, playlists, and smart collections' --body 'Fourth PR in the audiobook stack. Adds ABS bookmarks, collections, playlists, smart collections, and unified collection storage.'
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Open PR 5**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-collections --head stack/audiobooks-extras --title 'feat(audiobooks): podcasts, catalog filters, and enrichment polish' --body 'Final PR in the audiobook stack. Adds podcast/RSS extras, audiobook catalog filters/typeahead, and scan/enrichment performance polish.'
|
||||
```
|
||||
|
||||
### Task 4: Verify And Report
|
||||
|
||||
**Files:**
|
||||
- Modify: none.
|
||||
|
||||
- [ ] **Step 1: Verify PR states**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr list --repo Silo-Server/silo-server --state open --search 'audiobooks in:title' --json number,title,headRefName,baseRefName,url
|
||||
```
|
||||
|
||||
Expected: five open stacked PRs with the base/head chain shown above.
|
||||
|
||||
- [ ] **Step 2: Run branch verification where feasible**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
cd web && pnpm run build
|
||||
```
|
||||
|
||||
Expected: both commands pass on the final stack tip.
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
# Ebook Reader Full Parity 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.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the personal reader feature set with server-persisted config, annotations/bookmarks, selection tools, reading aids, and advanced settings.
|
||||
|
||||
**Architecture:** Keep ebook reading in core, matching audiobooks: core owns the ebook media type and reader behavior; metadata remains plugin-provided. Extend the existing `/ebooks/{content_id}` reader API and `EbookReaderHandler` rather than creating a separate plugin-style reader subsystem. Persist per-user/per-profile/per-book state in Postgres so settings, annotations, and bookmarks roam across devices.
|
||||
|
||||
**Tech Stack:** Go/Chi handlers, pgx/Postgres migrations, React 19, TanStack Query, Foliate/readest reader wrapper, Vitest, Go unit tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Creates `ebook_reader_config` and `ebook_reader_annotations`.
|
||||
- `internal/api/handlers/ebook_reader.go`
|
||||
- Adds reader config and annotation types, store interfaces, handlers, validation, and Postgres store methods.
|
||||
- `internal/api/handlers/ebook_reader_test.go`
|
||||
- Adds handler tests for config, annotation CRUD, access scoping, and validation.
|
||||
- `internal/api/router.go`
|
||||
- Wires config/annotation stores and routes under `/ebooks/{content_id}`.
|
||||
- `web/src/reader/FoliateBookReader.tsx`
|
||||
- Adds selection/annotation/readable-text/content-popup reader handles and applies saved config.
|
||||
- `web/src/pages/EbookReader.tsx`
|
||||
- Adds server-backed settings, annotation/bookmark UI, selection tools, TTS controls, wake-lock/e-ink controls, and advanced reader controls.
|
||||
- `web/src/hooks/useTTS.ts`, `web/src/hooks/useScreenWakeLock.ts`, `web/src/hooks/useEinkMode.ts`
|
||||
- Port focused personal-reader hooks into core web.
|
||||
- `web/src/reader/ebookReaderApi.ts`
|
||||
- Centralizes reader config and annotation API calls for tests and page usage.
|
||||
- `web/src/pages/EbookReader.test.tsx`, `web/src/reader/FoliateBookReader.test.ts`
|
||||
- Adds UI and wrapper tests.
|
||||
|
||||
## Task 1: Server-Persisted Reader Config
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Create: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing Go handler tests**
|
||||
|
||||
Add tests that:
|
||||
- `GET /ebooks/{content_id}/reader-config` returns `{ "config": {} }` when no row exists.
|
||||
- `PUT /ebooks/{content_id}/reader-config` saves JSON config for the authenticated user/profile/content.
|
||||
- inaccessible content returns 404 through the existing `ItemAccess` path.
|
||||
- invalid non-object config returns 400.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because config store/handlers do not exist.
|
||||
|
||||
- [ ] **Step 2: Add migration**
|
||||
|
||||
Create `ebook_reader_config`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_config (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, profile_id, content_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_config_profile_updated
|
||||
ON ebook_reader_config (user_id, profile_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement config store and handlers**
|
||||
|
||||
Add `EbookReaderConfig`, `EbookReaderConfigStore`, `HandleGetConfig`, `HandleSaveConfig`, and `PGEbookReaderConfigStore` methods in `ebook_reader.go`.
|
||||
|
||||
Validation:
|
||||
- authenticated user required
|
||||
- `content_id` required
|
||||
- `config` must decode to a JSON object
|
||||
- content access checked with `FileAuthorizer.ItemAccess.EnsureAccessible`
|
||||
|
||||
- [ ] **Step 4: Wire routes and store**
|
||||
|
||||
In `internal/api/router.go`, create the store when `deps.DB != nil`, assign it to `ebookReaderHandler`, and add:
|
||||
|
||||
```go
|
||||
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
|
||||
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add web API wrapper**
|
||||
|
||||
Create `web/src/reader/ebookReaderApi.ts` with:
|
||||
|
||||
```ts
|
||||
export function ebookReaderConfigPath(contentID: string): string
|
||||
export async function fetchEbookReaderConfig(contentID: string): Promise<Record<string, unknown>>
|
||||
export async function saveEbookReaderConfig(contentID: string, config: Record<string, unknown>): Promise<Record<string, unknown>>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Move settings persistence from localStorage to server**
|
||||
|
||||
Update `EbookReader.tsx` to fetch reader config on load and save settings through the config endpoint. Keep a local fallback only until the server value arrives.
|
||||
|
||||
- [ ] **Step 7: Verify and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
|
||||
cd web && pnpm test src/pages/EbookReader.test.tsx src/reader/FoliateBookReader.test.ts --run
|
||||
cd web && pnpm build
|
||||
```
|
||||
|
||||
Commit:
|
||||
|
||||
```bash
|
||||
git add migrations/sql/20260608000300_ebook_reader_state.sql internal/api/handlers/ebook_reader.go internal/api/handlers/ebook_reader_test.go internal/api/router.go web/src/reader/ebookReaderApi.ts web/src/pages/EbookReader.tsx web/src/pages/EbookReader.test.tsx
|
||||
git commit -m "feat: persist ebook reader config"
|
||||
```
|
||||
|
||||
## Task 2: Server-Persisted Annotations And Bookmarks
|
||||
|
||||
**Files:**
|
||||
- Modify: `migrations/sql/20260608000300_ebook_reader_state.sql`
|
||||
- Modify: `internal/api/handlers/ebook_reader.go`
|
||||
- Modify: `internal/api/handlers/ebook_reader_test.go`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `web/src/reader/ebookReaderApi.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing annotation handler tests**
|
||||
|
||||
Cover list, create, update, delete. Test annotation fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "highlight",
|
||||
"cfi_range": "epubcfi(/6/4,/1:0,/1:12)",
|
||||
"selected_text": "sample text",
|
||||
"note": "note text",
|
||||
"style": "highlight",
|
||||
"color": "#facc15"
|
||||
}
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handlers -run 'TestEbookReader.*Annotation|TestEbookReader.*Bookmark' -count=1
|
||||
```
|
||||
|
||||
Expected: fail because annotation APIs do not exist.
|
||||
|
||||
- [ ] **Step 2: Add annotation table**
|
||||
|
||||
Extend migration with:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ebook_reader_annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('highlight', 'note', 'bookmark')),
|
||||
cfi_range TEXT,
|
||||
location TEXT,
|
||||
selected_text TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
style TEXT NOT NULL DEFAULT 'highlight',
|
||||
color TEXT NOT NULL DEFAULT '#facc15',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CHECK ((kind = 'bookmark' AND location IS NOT NULL) OR (kind <> 'bookmark' AND cfi_range IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX ebook_reader_annotations_book
|
||||
ON ebook_reader_annotations (user_id, profile_id, content_id, updated_at DESC);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement annotation store and handlers**
|
||||
|
||||
Add handlers:
|
||||
- `GET /ebooks/{content_id}/annotations`
|
||||
- `POST /ebooks/{content_id}/annotations`
|
||||
- `PATCH /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
- `DELETE /ebooks/{content_id}/annotations/{annotation_id}`
|
||||
|
||||
- [ ] **Step 4: Wire Foliate annotation drawing and selection**
|
||||
|
||||
Extend `FoliateBookReaderHandle` with:
|
||||
- `createSelectionAnnotation()`
|
||||
- `clearSelection()`
|
||||
|
||||
Draw stored annotations using Foliate `addAnnotation`.
|
||||
|
||||
- [ ] **Step 5: Add reader UI**
|
||||
|
||||
Add:
|
||||
- selection popover
|
||||
- highlight button
|
||||
- note button
|
||||
- bookmark button
|
||||
- annotation list in the side panel
|
||||
- delete/update note controls
|
||||
|
||||
- [ ] **Step 6: Verify and commit**
|
||||
|
||||
Run handler tests, web tests, and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook annotations and bookmarks"
|
||||
```
|
||||
|
||||
## Task 3: Reader Tools, TTS, And Reading Aids
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/hooks/useTTS.ts`
|
||||
- Create: `web/src/hooks/useScreenWakeLock.ts`
|
||||
- Create: `web/src/hooks/useEinkMode.ts`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Port focused hooks from personal reader**
|
||||
|
||||
Bring over:
|
||||
- Web Speech TTS controller
|
||||
- screen wake lock hook
|
||||
- e-ink body-class hook
|
||||
|
||||
- [ ] **Step 2: Add readable text handle**
|
||||
|
||||
Expose `getReadableText()` from `FoliateBookReader` for TTS.
|
||||
|
||||
- [ ] **Step 3: Add UI controls**
|
||||
|
||||
Add side-panel controls for:
|
||||
- Speak current text
|
||||
- Pause/resume/stop
|
||||
- voice/rate/pitch
|
||||
- wake lock
|
||||
- e-ink mode
|
||||
|
||||
- [ ] **Step 4: Add content popups and helpers**
|
||||
|
||||
Add selection popover actions:
|
||||
- define
|
||||
- translate
|
||||
|
||||
Add reader content popup handling for footnotes/images/tables when Foliate emits them.
|
||||
|
||||
- [ ] **Step 5: Verify and commit**
|
||||
|
||||
Run focused web tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook reader tools and aids"
|
||||
```
|
||||
|
||||
## Task 4: Advanced Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add advanced reader settings**
|
||||
|
||||
Add server-persisted settings for:
|
||||
- RTL
|
||||
- writing mode
|
||||
- zoom/scale
|
||||
- brightness
|
||||
- hyphenation toggle
|
||||
- custom font selection/upload if API support is added in a later task
|
||||
|
||||
- [ ] **Step 2: Verify and commit**
|
||||
|
||||
Run focused tests and build. Commit:
|
||||
|
||||
```bash
|
||||
git commit -m "feat: add ebook advanced reader settings"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
- The plan covers server persistence first, matching the user's explicit preference.
|
||||
- The plan keeps ebook features in core and metadata in plugins, matching the audiobook pattern.
|
||||
- The plan avoids extra ebook catalog tables; added tables are reader-user-state only.
|
||||
- The biggest implementation risk is annotation rendering against Foliate selection APIs; Task 2 isolates it after backend persistence exists.
|
||||
- The migration filename is intentionally after existing ebook migrations and can be adjusted if another migration is added before implementation.
|
||||
@@ -1,68 +0,0 @@
|
||||
# Ebook Reader Shell Parity 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.
|
||||
|
||||
**Goal:** Bring the core ebook reader up to the first useful parity slice from `personal/silo-plugin-ebooks`: TOC, search, progress scrub, reader settings, and keyboard navigation.
|
||||
|
||||
**Architecture:** Keep the current core reader API and persistence model. Extend `FoliateBookReader` with imperative methods for TOC, search, href/fraction navigation, and runtime style updates; keep `EbookReader` responsible for the chrome/panels/settings state. Store reader preferences in localStorage for this first local slice so no backend schema is added.
|
||||
|
||||
**Tech Stack:** React 19, React Router, TanStack Query, foliate-js custom element, existing Silo UI components, Vitest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Reader Handle Capabilities
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/reader/FoliateBookReader.test.ts`
|
||||
|
||||
- [ ] Add exported types `ReaderSettings`, `ReaderSearchOptions`, `ReaderSearchResult`, and `ReaderReadyState`.
|
||||
- [ ] Extend `FoliateBookReaderHandle` with `goToFraction`, `goTo`, `search`, `clearSearch`, and `applySettings`.
|
||||
- [ ] Add a `settings?: ReaderSettings` prop and convert `readerStyles()` to accept settings.
|
||||
- [ ] Preserve existing progress save/restore behavior.
|
||||
- [ ] Verify with `pnpm vitest run src/reader/FoliateBookReader.test.ts`.
|
||||
|
||||
### Task 2: TOC and Search Panel
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add panel state for `toc`, `searchResults`, `searchTerm`, and current tab.
|
||||
- [ ] Render side panel tabs for Contents, Search, and Settings.
|
||||
- [ ] Wire TOC items to `readerRef.current.goTo(href)`.
|
||||
- [ ] Wire search form to `readerRef.current.search(term)` and result clicks to `goTo(cfi)`.
|
||||
- [ ] Verify with `pnpm vitest run src/pages/EbookReader.test.tsx`.
|
||||
|
||||
### Task 3: Reader Settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add localStorage-backed settings for theme, font size, font family, line height, margin, max width, spread, and flow.
|
||||
- [ ] Render compact controls in the Settings tab.
|
||||
- [ ] Apply settings live through `FoliateBookReader`.
|
||||
- [ ] Verify settings persist across remount in tests.
|
||||
|
||||
### Task 4: Progress Scrub and Keyboard Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/EbookReader.tsx`
|
||||
- Modify: `web/src/reader/FoliateBookReader.tsx`
|
||||
- Test: `web/src/pages/EbookReader.test.tsx`
|
||||
|
||||
- [ ] Add a progress range input in the header.
|
||||
- [ ] Call `goToFraction` on commit/change and update display from relocate events.
|
||||
- [ ] Add ArrowLeft/ArrowRight keyboard navigation on the reader page.
|
||||
- [ ] Verify with page-level tests.
|
||||
|
||||
### Task 5: Local Verification
|
||||
|
||||
**Files:**
|
||||
- No source changes.
|
||||
|
||||
- [ ] Run `cd web && pnpm run build`.
|
||||
- [ ] Run `GOWORK=off go test ./internal/api/handlers ./internal/scanner`.
|
||||
- [ ] Keep changes local; do not push.
|
||||
@@ -1,518 +0,0 @@
|
||||
# Encrypt integration credentials at rest (issue #45) — 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. Do not edit this plan file while implementing.
|
||||
|
||||
**Issue:** [#45 — Encrypt arr integration credentials at rest (Requests + Autoscan)](https://github.com/Silo-Server/silo-server/issues/45)
|
||||
|
||||
> **Revision note (post-review):** This plan was revised after a code review. Key corrections baked in:
|
||||
> the arr backfill now **resolves the legacy `server_settings` reference before encrypting** (otherwise
|
||||
> rows holding a setting-name like `requests.radarr.api_key` would be encrypted as the *name*, not the
|
||||
> credential); the sensitive-settings allowlist is **audited from the config loader's real inputs**
|
||||
> (incl. `redis.sentinel_password` and legacy `s3.operational_*` aliases) rather than copied from the
|
||||
> admin redaction map; AAD binds to **row identity** (`table:column:pk`); read paths **pass through
|
||||
> legacy plaintext** and hard-fail only on corrupt ciphertext; `settingsRepo` is **re-wrapped after the
|
||||
> max-connections pool recreation**; history-import session tokens are **included**; and
|
||||
> `plugin_runtime_configs.config_value` is an **explicit, documented gap** (see Out of scope).
|
||||
|
||||
**Goal:** Stop storing third-party integration credentials in plaintext at rest. Today Sonarr/Radarr
|
||||
API keys, S3 keys, and a dozen other secrets sit in the database as naked `text` — `request_integrations.api_key_ref`
|
||||
and `autoscan_connections.api_key_ref` are resolved through a plaintext `server_settings` key/value
|
||||
store, and both resolvers contain a **ref-vs-literal fallback**: when the secret lookup returns empty,
|
||||
the stored string is used verbatim as the API key. This plan introduces a real symmetric-encryption
|
||||
layer (AES-256-GCM, `SECRET_KEY`-derived via HKDF), encrypts every recoverable server-owned credential
|
||||
**inline** in its owning row, deletes the ambiguous resolver fallback, and backfills existing rows
|
||||
automatically on startup.
|
||||
|
||||
**Decisions (locked with the requester):**
|
||||
- **Master key:** a **required** `SECRET_KEY` environment variable. The server fatals at startup if it
|
||||
is missing. The key lives outside Postgres, so encrypted secrets survive a full DB compromise/dump.
|
||||
- **Scope:** **all silo-server-owned credential storage** (was framed "full sweep"). Every recoverable
|
||||
server-owned secret — arr keys, S3 keys, all sensitive `server_settings`, and the per-table token
|
||||
columns — is covered. Two categories are deliberately *not* in this PR because they are a different
|
||||
*shape* of problem, not because they are unimportant: equality-looked-up secrets (need **hashing**,
|
||||
not encryption) and plugin runtime config JSONB (cross-repo + manifest-driven). Both are called out in
|
||||
Out of scope with follow-ups.
|
||||
- **Data model (best practice):** **inline-encrypted**. Store the ciphertext directly in the owning
|
||||
column and decrypt on read. Drop the `server_settings` "ref" indirection and the `SecretResolver`
|
||||
literal fallback entirely — this kills the ambiguity at its root and matches the repo's one existing
|
||||
good example (`oauth_completion.token_ciphertext` lives inline in its own row).
|
||||
- **Rollout:** idempotent Go startup **backfill** encrypts any plaintext (no-envelope-prefix) value in
|
||||
place on first boot after deploy. No manual steps.
|
||||
|
||||
**Architecture:** A new `internal/secret` package owns the one cryptographic primitive everything reuses:
|
||||
AES-256-GCM with a random 12-byte nonce, `base64.RawURLEncoding`, and a versioned `enc:v1:` envelope
|
||||
prefix; the data key is HKDF-SHA256–derived from `SECRET_KEY` with a domain label. Each ciphertext is
|
||||
GCM-bound to its logical **row** via additional-authenticated-data (`"table:column:<pk>"`, or
|
||||
`"server_settings:<key>"`) so a DB-write attacker cannot transplant a credential blob into another row
|
||||
or column. Three wiring seams consume the cipher: (1) an `EncryptedSettingsRepo` **decorator** around
|
||||
`catalog.ServerSettingsRepo` that transparently encrypts/decrypts the sensitive `server_settings` keys,
|
||||
so `config.LoadFromDB` and every downstream consumer keep seeing plaintext; (2) the two `api_key_ref`
|
||||
columns become inline ciphertext and the entire `SecretResolver` indirection is deleted from
|
||||
`internal/requests` and `internal/autoscan`; (3) a handful of per-table repos gain a `*secret.Cipher`
|
||||
and encrypt-on-write / decrypt-on-read. A startup backfill sweeps all groups idempotently — with a
|
||||
**special resolve-then-encrypt path for the two arr columns** so legacy `server_settings` references are
|
||||
collapsed to the real credential before encryption.
|
||||
|
||||
**Out of scope (each a deliberate, documented decision — not an oversight):**
|
||||
- **Equality-looked-up secrets → need hashing, not encryption.** `api_keys.api_key`
|
||||
(`WHERE api_key = $1`) and `webhook_sync_connections.webhook_secret` (`WHERE webhook_secret = $1`,
|
||||
[repo.go:101](internal/webhooksync/repo.go:101)) are matched by exact value. AES-GCM uses a fresh
|
||||
random nonce, so the same plaintext encrypts differently each time — encrypting these would silently
|
||||
break API-key auth and inbound-webhook routing. The correct fix is a deterministic **hash** column +
|
||||
lookup-by-hash, a distinct lookup-semantics + migration change. **Follow-up issue.**
|
||||
- **`plugin_runtime_configs.config_value` → cross-repo + manifest-driven.** This is opaque plugin-defined
|
||||
JSONB ([runtime_config.go:55](internal/plugins/runtime_config.go:55)); some plugins store secrets in it
|
||||
(e.g. the legacy one-time copy of `introdb.api_key` at [main.go:2341](cmd/silo/main.go:2341)). It is
|
||||
read/written only through `RuntimeConfigStore` *in this repo*, so whole-blob encrypt-on-write /
|
||||
decrypt-on-read at that store is technically feasible — **but** host-side plugin runtime lives in the
|
||||
separate **`Silo`** repo (per CLAUDE.md) and may read this table directly, and which fields are secret
|
||||
is declared in each plugin's manifest. Doing this correctly needs a coordinated, manifest-aware design
|
||||
across repos. **Explicitly deferred — known gap.** Note: while deferred, the legacy `introdb.api_key`
|
||||
copy means that secret still exists in plaintext inside plugin config even after the `server_settings`
|
||||
copy is encrypted; the follow-up must re-encrypt or purge it.
|
||||
- **`plex_sync_connections`** — dead table, zero Go references (superseded by `webhook_sync_connections`
|
||||
in migration `060`). Not touched.
|
||||
- **Already-protected / non-secret:** `users.password_hash` (bcrypt), `oauth_completion.token_ciphertext`
|
||||
(already AES-GCM), `abs_sessions.token_hash` / `oauth_completion.code_hash` (hashed), `oauth_session.state`
|
||||
(equality CSRF), `subtitle_provider_config.username` (a username, not a secret),
|
||||
`history_import_plex_sessions.pin_id`/`pin_code` (transient OAuth pairing handshake, not a stored
|
||||
access credential).
|
||||
- `internal/auth/oauth_store.go` is left as-is (its key is jwt-derived and its data is ephemeral).
|
||||
Optionally a shared low-level seal/open helper can be extracted later; not required here.
|
||||
|
||||
**Tech Stack:** Go 1.26.3 (module declares `go 1.26.3`; stdlib `crypto/hkdf` is available — no new
|
||||
dependency). PostgreSQL via `pgxpool`. **No schema migration** — every target column is already `text`
|
||||
(or `jsonb` left untouched) and now simply holds ciphertext; the backfill is pure Go. No frontend or
|
||||
client (silo-android / silo-apple) changes — API responses already mask these values to `has_api_key` /
|
||||
`has_credentials` booleans, so no contract changes.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Running tests
|
||||
|
||||
The `internal/secret` package is pure Go (no libvips/CGO). If a Go toolchain is on the host:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
`GOWORK=off` is required when running from a `.claude/worktrees/` checkout — the parent `go.work`
|
||||
otherwise pins the module to `main` and the build fails with "main module does not contain package".
|
||||
|
||||
If the host has no Go toolchain, run in a throwaway container (a named volume caches module downloads):
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
|
||||
-e GOFLAGS=-mod=mod golang:1.26 \
|
||||
go test ./internal/secret/... -v
|
||||
```
|
||||
|
||||
Full backend build/vet before opening the MR: `GOWORK=off go build ./...` then `make lint` and
|
||||
`make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**New files:**
|
||||
- `internal/secret/cipher.go` — **create**. `Cipher` type; `New`, `Encrypt`, `Decrypt`, `IsEncrypted`,
|
||||
`EncryptIfPlaintext`; envelope + HKDF + version dispatch. Row-bound AAD passed by callers.
|
||||
- `internal/secret/cipher_test.go` — **create**. Round-trip, tamper/AAD-mismatch rejection, wrong-key,
|
||||
`IsEncrypted` edge cases, version dispatch, idempotency.
|
||||
- `internal/secret/backfill.go` — **create**. Generic in-place backfill for literal-credential columns +
|
||||
the special resolve-then-encrypt backfill for the two arr columns.
|
||||
- `internal/secret/backfill_test.go` — **create**. Idempotency, mixed plaintext/ciphertext rows, empty
|
||||
values skipped, failure isolation, arr resolve-then-encrypt.
|
||||
|
||||
**Modified files:**
|
||||
- `internal/config/bootstrap.go` — add `SecretKey []byte` to `BootstrapConfig`; read `SECRET_KEY`; fatal
|
||||
if missing or `< 32` chars.
|
||||
- `cmd/silo/main.go` — construct `*secret.Cipher` after bootstrap; build the `EncryptedSettingsRepo`;
|
||||
run the settings + column backfills after migrations and before settings are read; **re-wrap
|
||||
`settingsRepo` after the max-connections pool recreation (line ~399)**; thread the cipher into the
|
||||
affected repo constructors; delete the `SetSecretResolver` wiring.
|
||||
- `internal/catalog/encrypted_settings_repo.go` — **create**. `EncryptedSettingsRepo` decorator +
|
||||
exported, **audited** `SensitiveSettingKeys` (single source of truth) + `BackfillSensitiveSettings`.
|
||||
- `internal/catalog/server_settings_repo.go` — unchanged behavior; remains the raw inner store.
|
||||
- `internal/api/handlers/admin.go` — replace the local `sensitiveSettingKeys` map with
|
||||
`catalog.SensitiveSettingKeys` (redaction now shares the audited allowlist; closes the prior gap where
|
||||
`redis.sentinel_password` and `s3.operational_*` were returned in plaintext).
|
||||
- `internal/requests/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
insert/update (AAD bound to integration id); decrypt (pass-through legacy plaintext) in `scanIntegration`.
|
||||
- `internal/requests/service.go` — delete `SecretResolver` interface, `secrets` field,
|
||||
`SetSecretResolver`, and `resolveAPIKey`; call sites use the already-decrypted `Integration.APIKeyRef`.
|
||||
- `internal/autoscan/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
|
||||
create/update (AAD bound to connection id); decrypt (pass-through legacy) in `scanConnection`.
|
||||
- `internal/autoscan/connection.go` — delete `SecretResolver` interface, `secrets` field, and the
|
||||
secret-resolution branch in `Resolve`; linked-integration path uses the requests repo's decrypted key.
|
||||
- `internal/api/autoscan_wiring.go` — drop the `AutoscanSecretResolver` param from `BuildAutoscanService`.
|
||||
- `internal/api/router.go` — drop the `SetSecretResolver(settingsRepo)` calls; pass the
|
||||
`EncryptedSettingsRepo` where `settingsRepo` is used; thread the cipher into repo constructors.
|
||||
- `internal/subtitles/pgrepo.go` — `*secret.Cipher`; encrypt `api_key` + `password` on upsert; decrypt on
|
||||
read. PK for AAD = `provider_name`.
|
||||
- `internal/watchsync/repository.go` — `*secret.Cipher`; encrypt `access_token` + `refresh_token` on
|
||||
upsert; decrypt in a `decryptConnection` helper called after every scan. PK for AAD = connection `id`.
|
||||
- `internal/webhooksync/repo.go` — `*secret.Cipher`; encrypt `access_token` on create; decrypt on scan.
|
||||
**`webhook_secret` is NOT encrypted** (equality lookup — carved out). PK for AAD = connection `id`.
|
||||
- `internal/historyimport/repo_admin.go` — `*secret.Cipher`; encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`. PK for AAD = source `id`.
|
||||
- `internal/historyimport/repo.go` — `*secret.Cipher`; encrypt `connect_access_token`
|
||||
(`history_import_connect_sessions`) + `auth_token` (`history_import_plex_sessions`) on insert/update;
|
||||
decrypt on scan. PK for AAD = session `id`.
|
||||
- `docs/architecture/secret-encryption.md` — **create**. Operator runbook (SECRET_KEY generation, backup,
|
||||
key-loss, rollback/downgrade).
|
||||
|
||||
**No migration files.** All target columns are already `text`; the backfill is Go-side.
|
||||
|
||||
---
|
||||
|
||||
## Reference tables
|
||||
|
||||
### What is encrypted vs hashed vs excluded
|
||||
|
||||
AAD column shows the GCM additional-authenticated-data string; `<pk>` is the row's stable primary key.
|
||||
|
||||
| Group | Table.column | AAD binding | Action |
|
||||
| --- | --- | --- | --- |
|
||||
| Arr keys | `request_integrations.api_key_ref` | `request_integrations:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Arr keys | `autoscan_connections.api_key_ref` | `autoscan_connections:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
|
||||
| Settings | every key in `SensitiveSettingKeys` (see audited list below) | `server_settings:<key>` | **Encrypt via decorator** |
|
||||
| Subtitles | `subtitle_provider_config.api_key`, `.password` | `subtitle_provider_config:<col>:<provider_name>` | **Encrypt inline** |
|
||||
| Watch sync | `watch_provider_connections.access_token`, `.refresh_token` | `watch_provider_connections:<col>:<id>` | **Encrypt inline** |
|
||||
| Webhook sync | `webhook_sync_connections.access_token` | `webhook_sync_connections:access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_sources.admin_token` | `history_import_sources:admin_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_connect_sessions.connect_access_token` | `history_import_connect_sessions:connect_access_token:<id>` | **Encrypt inline** |
|
||||
| History import | `history_import_plex_sessions.auth_token` | `history_import_plex_sessions:auth_token:<id>` | **Encrypt inline** |
|
||||
| — | `api_keys.api_key` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `webhook_sync_connections.webhook_secret` | — | **Carved out → hash** (follow-up) |
|
||||
| — | `plugin_runtime_configs.config_value` | — | **Deferred → cross-repo/manifest design** (follow-up) |
|
||||
| — | `plex_sync_connections.*` | — | **Excluded** (dead table) |
|
||||
|
||||
### Audited `SensitiveSettingKeys` (single source of truth)
|
||||
|
||||
Built from the config loader's actual secret inputs ([internal/config/db_loader.go](internal/config/db_loader.go))
|
||||
plus the admin redaction map plus other known consumers — **not** just the current redaction map. The
|
||||
union is intentional and also fixes a pre-existing redaction leak. Implementers must re-audit
|
||||
`db_loader.go` and `grep` for any `*secret*`/`*token*`/`*password*`/`*api_key*` settings keys before
|
||||
finalizing.
|
||||
|
||||
```
|
||||
auth.jwt_secret
|
||||
s3.public_access_key s3.public_secret_key s3.public_token_secret
|
||||
s3.private_access_key s3.private_secret_key
|
||||
s3.user_db_access_key s3.user_db_secret_key
|
||||
s3.operational_access_key s3.operational_secret_key s3.operational_token_secret # legacy aliases still read as fallbacks (db_loader L165-192); migration 086 copied but did not delete them
|
||||
redis.url # may embed credentials (redis://:pass@host)
|
||||
redis.sentinel_password # read at db_loader L334 — MISSING from the old redaction map
|
||||
tmdb.api_key
|
||||
mdblist.api_key
|
||||
introdb.api_key # read in main.go (legacy → plugin copy)
|
||||
subtitle_ai.api_key
|
||||
recommendations.embedding_auth_token recommendations.openai_api_key # legacy alias (db_loader L409)
|
||||
watchsync.trakt.client_id watchsync.trakt.client_secret
|
||||
watchsync.simkl.client_id watchsync.simkl.client_secret
|
||||
requests.radarr.api_key requests.sonarr.api_key # legacy single-instance arr keys
|
||||
```
|
||||
|
||||
`auth.jwt_secret` IS encrypted at rest under `SECRET_KEY` (it is no longer the encryption root — `SECRET_KEY`
|
||||
is). `redis.url`/client-id entries are not high-value secrets but are kept in the set as a harmless safe
|
||||
superset and to match existing redaction.
|
||||
|
||||
### Envelope format
|
||||
|
||||
```
|
||||
enc:v1:<base64.RawURLEncoding( nonce[12] ‖ gcm.Seal(...) )>
|
||||
```
|
||||
|
||||
- `enc:v1:` — exactly 7 ASCII chars; not valid base64url, not a UUID/JWT/URL/integer prefix. Collision-proof
|
||||
against any real plaintext credential in practice.
|
||||
- `IsEncrypted(s)` ≡ `strings.HasPrefix(s, "enc:v1:")`.
|
||||
- Decrypt parses the version (`strings.SplitN(s, ":", 3)`), dispatches to the v1 opener; an unknown
|
||||
version returns an explicit `ErrUnknownVersion`. This is the forward-compat hook for a future `enc:v2:`
|
||||
key rotation — implement the dispatch now even though only `v1` exists.
|
||||
|
||||
### Read-path contract (applies to every decrypt site — decorator and repos)
|
||||
|
||||
1. `value == ""` → return `""`.
|
||||
2. `!IsEncrypted(value)` → **return the value unchanged** (legacy plaintext pass-through during the
|
||||
backfill window; the value is no worse than today). This is unambiguous — there is no longer any
|
||||
"ref vs literal" question because the `_ref` indirection is gone.
|
||||
3. `IsEncrypted(value)` → `Decrypt`; **any error (wrong key, tamper, truncation) is propagated, never
|
||||
swallowed and never returned as the ciphertext string.** A corrupt ciphertext fails the operation.
|
||||
|
||||
This reconciles the best-effort backfill (a skipped/failed row stays readable as plaintext) with the
|
||||
hard guarantee that a real `enc:v1:` value never silently degrades to using ciphertext as a credential.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: The `internal/secret` cipher package
|
||||
|
||||
**Files:** Create `internal/secret/cipher.go`, `internal/secret/cipher_test.go`.
|
||||
|
||||
This is the foundation; it has no dependencies on `catalog`/`config` and is independently testable.
|
||||
|
||||
- [ ] **Step 1: `Cipher` type + `New`.** Derive a 32-byte AES key with stdlib
|
||||
`hkdf.Key(sha256.New, masterKey, nil /*salt*/, "silo/data-encryption/v1", 32)`. Return an error if
|
||||
`len(masterKey) < 32`. Store the derived key as `[32]byte`. The type is safe for concurrent use.
|
||||
- [ ] **Step 2: `Encrypt(plaintext, aad string) (string, error)`.** Empty plaintext returns `"", nil`
|
||||
(do not produce an `enc:v1:` blob for an empty secret). Otherwise: `aes.NewCipher` → `cipher.NewGCM` →
|
||||
12-byte `rand` nonce → `gcm.Seal(nonce, nonce, []byte(plaintext), []byte(aad))` →
|
||||
`envelopePrefix + base64.RawURLEncoding.EncodeToString(sealed)`. `aad` is the row-bound context string.
|
||||
- [ ] **Step 3: `Decrypt(ciphertext, aad string) (string, error)`.** Strip + validate the version prefix;
|
||||
unknown version → `ErrUnknownVersion`. base64-decode; reject if shorter than the nonce size; split
|
||||
nonce/body; `gcm.Open(nil, nonce, body, []byte(aad))`. **Any failure returns an error — never fall back
|
||||
to returning the ciphertext or an empty string.** (Pass-through of *non-prefixed* plaintext is the
|
||||
caller's job per the read-path contract, not `Decrypt`'s.)
|
||||
- [ ] **Step 4: `IsEncrypted(s string) bool`** and `EncryptIfPlaintext(s, aad string) (string, bool, error)`.
|
||||
`EncryptIfPlaintext` returns `(s, false, nil)` when already encrypted or empty; otherwise encrypts and
|
||||
returns `(ct, true, nil)`. This is the idempotent primitive the backfill uses.
|
||||
- [ ] **Step 5: Tests** (`cipher_test.go`):
|
||||
- round-trip with AAD recovers plaintext; ciphertext has the `enc:v1:` prefix.
|
||||
- two encryptions of the same plaintext+AAD differ (random nonce works).
|
||||
- tamper: flip one byte of the decoded body → `Decrypt` errors.
|
||||
- AAD mismatch: encrypt with `"t:c:1"`, decrypt with `"t:c:2"` → errors (proves row binding).
|
||||
- wrong key: encrypt with key A, decrypt with key B → errors.
|
||||
- `New` with a `< 32`-byte key → error.
|
||||
- `IsEncrypted`: `"enc:v1:x"`→true, `"sa_abc"`→false, `""`→false, `"ENC:V1:x"`→false.
|
||||
- unknown version `"enc:v99:..."` → `ErrUnknownVersion`.
|
||||
- `EncryptIfPlaintext` is idempotent (second call is a no-op, `changed=false`).
|
||||
- [ ] **Step 6:** `GOWORK=off go test ./internal/secret/... -v` passes.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SECRET_KEY` bootstrap + cipher construction
|
||||
|
||||
**Files:** Modify `internal/config/bootstrap.go`, `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1:** Add `SecretKey []byte` to `BootstrapConfig`. In `LoadBootstrap`, after the
|
||||
`DATABASE_URL` check, read `os.Getenv("SECRET_KEY")`; return an error (which the existing
|
||||
`log.Fatalf` path surfaces) if it is empty or `< 32` characters. Message must be actionable, e.g.
|
||||
`SECRET_KEY is required (>=32 chars); generate one with: openssl rand -base64 48`. `godotenv` already
|
||||
loads `.env`, so dev sets it there.
|
||||
- [ ] **Step 2:** In `cmd/silo/main.go`, immediately after `LoadBootstrap` returns, construct the cipher:
|
||||
`dataCipher, err := secret.New(bc.SecretKey)` and `log.Fatalf` on error. The cipher is threaded
|
||||
explicitly as a dependency — **never** a package-level global.
|
||||
- [ ] **Step 3:** Add `SECRET_KEY=...` to `.env.example` (or the documented dev env) and note it in the
|
||||
operator runbook (Task 7).
|
||||
- [ ] **Step 4:** `GOWORK=off go build ./cmd/silo` compiles; starting without `SECRET_KEY` fatals with the
|
||||
actionable message.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `server_settings` encryption decorator
|
||||
|
||||
**Files:** Create `internal/catalog/encrypted_settings_repo.go`; modify `internal/api/handlers/admin.go`,
|
||||
`cmd/silo/main.go`, `internal/api/router.go`.
|
||||
|
||||
- [ ] **Step 1: Build the audited allowlist.** Create exported `var SensitiveSettingKeys map[string]bool`
|
||||
in `internal/catalog` populated from the "Audited `SensitiveSettingKeys`" list above. **Do not** simply
|
||||
move the admin redaction map — re-audit `internal/config/db_loader.go` (and other `server_settings`
|
||||
consumers) for every secret-bearing key, and include the legacy aliases (`s3.operational_*`,
|
||||
`recommendations.openai_api_key`) and `redis.sentinel_password`. Update `admin.go`
|
||||
(`HandleGetSettings` redaction, `HandleGetSensitiveStatus`, the `PUT` response branch) to reference
|
||||
`catalog.SensitiveSettingKeys`. One allowlist now drives **both** redaction and encryption.
|
||||
- [ ] **Step 2:** Implement `EncryptedSettingsRepo` wrapping `*ServerSettingsRepo` + `*secret.Cipher`:
|
||||
- `Set(key, value)`: if `SensitiveSettingKeys[key]` and `value != ""`, encrypt with AAD
|
||||
`"server_settings:"+key` before delegating; else delegate raw.
|
||||
- `Get(key)` / `GetAll()`: delegate, then apply the **read-path contract** to each sensitive key
|
||||
(pass through legacy plaintext; decrypt prefixed values; propagate decrypt errors).
|
||||
- [ ] **Step 3:** Add `BackfillSensitiveSettings(ctx)` on the decorator: for each key in
|
||||
`SensitiveSettingKeys`, read the raw inner value; if non-empty and not `IsEncrypted`, `inner.Set` the
|
||||
encrypted value (AAD `"server_settings:"+key`). Idempotent. Returns a count + aggregated errors.
|
||||
- [ ] **Step 4:** In `cmd/silo/main.go`, build `settingsRepo` as the `EncryptedSettingsRepo` wrapping the
|
||||
raw `catalog.NewServerSettingsRepo(pool)`. Confirm the auto-generate-`jwt_secret` step still works: the
|
||||
`GetAll` map carries plaintext, the `== ""` check is unchanged, and `Set("auth.jwt_secret", encoded)`
|
||||
now writes **encrypted** while the in-memory map keeps the plaintext for `LoadFromDB`. Any function
|
||||
currently typed to the concrete `*catalog.ServerSettingsRepo` changes to the `ServerSettingsStore`
|
||||
interface that both already satisfy.
|
||||
- [ ] **Step 5 (re-wrap after pool recreation):** `cmd/silo/main.go` rebuilds a **raw**
|
||||
`catalog.NewServerSettingsRepo(pool)` after recreating the pool for `max_connections` (line ~399).
|
||||
Re-wrap it there too (`settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)`)
|
||||
so every later consumer gets the encrypting repo, not a raw one. Audit for any other raw construction
|
||||
of the settings repo and wrap each.
|
||||
- [ ] **Step 6:** Tests: decorator round-trip (raw store holds `enc:v1:`, decorator `Get` returns
|
||||
plaintext); non-sensitive keys pass through unencrypted; legacy-plaintext pass-through on read;
|
||||
`BackfillSensitiveSettings` idempotency.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...` compiles; admin settings GET still redacts (now including
|
||||
`redis.sentinel_password`); a sensitive key written via the admin API lands as `enc:v1:` in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Inline-encrypt arr keys + delete the `SecretResolver` indirection
|
||||
|
||||
**Files:** Modify `internal/requests/repository.go`, `internal/requests/service.go`,
|
||||
`internal/autoscan/repository.go`, `internal/autoscan/connection.go`,
|
||||
`internal/api/autoscan_wiring.go`, `internal/api/router.go`, `cmd/silo/main.go`.
|
||||
|
||||
> The legacy-data resolution for these two columns is handled by the **resolve-then-encrypt backfill** in
|
||||
> Task 6 (a row may currently hold a `server_settings` key name, not a literal key). The repo write path
|
||||
> below only ever encrypts the literal credential an admin submits going forward.
|
||||
|
||||
- [ ] **Step 1:** `requests.NewRepository` gains a `*secret.Cipher` parameter. In `insertIntegration` and
|
||||
`updateIntegration`, encrypt `i.APIKeyRef` (AAD `"request_integrations:api_key_ref:"+i.ID`) before
|
||||
binding — preserve the existing `CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END` keep-existing
|
||||
semantics (encrypt only the non-empty incoming value). In `scanIntegration`, apply the read-path
|
||||
contract to the scanned `api_key_ref`.
|
||||
- [ ] **Step 2:** Delete `requests.SecretResolver` (interface), the `Service.secrets` field,
|
||||
`Service.SetSecretResolver`, and `Service.resolveAPIKey`. Every former `resolveAPIKey(ctx, integration)`
|
||||
call site uses `strings.TrimSpace(integration.APIKeyRef)` directly (the repo already decrypted it).
|
||||
- [ ] **Step 3:** `autoscan.NewRepository` gains a `*secret.Cipher`. Encrypt `api_key_ref` in
|
||||
`CreateConnection` / `UpdateConnection` (AAD `"autoscan_connections:api_key_ref:"+id`, same
|
||||
keep-existing CASE semantics); apply the read-path contract in `scanConnection`.
|
||||
- [ ] **Step 4:** In `internal/autoscan/connection.go`, delete the `SecretResolver` interface, the
|
||||
`ConnectionResolver.secrets` field, and the secret-resolution branch in `Resolve`. For a linked Requests
|
||||
integration, `RequestIntegrationLookup.Get` returns the requests repo's **already-decrypted** key — use
|
||||
it directly. For a standalone connection, the autoscan repo already decrypted it. `Resolve` now only
|
||||
resolves base URL + linkage and returns plaintext.
|
||||
- [ ] **Step 5:** Remove the `AutoscanSecretResolver` parameter from `BuildAutoscanService`
|
||||
(`internal/api/autoscan_wiring.go`) and drop the `SetSecretResolver(settingsRepo)` calls in
|
||||
`internal/api/router.go` and `cmd/silo/main.go` (including the reconcile-worker service). Thread
|
||||
`dataCipher` into the requests/autoscan repo constructors at all wiring sites.
|
||||
- [ ] **Step 6:** Verify no API response serializes the raw key: the integration/connection response DTOs
|
||||
still emit only `has_api_key`. Add/extend a test asserting the create→store→read round-trip yields the
|
||||
original key, and that the stored column value is `enc:v1:`-prefixed.
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; `GOWORK=off go test ./internal/requests/... ./internal/autoscan/...`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Per-table credential columns
|
||||
|
||||
**Files:** Modify `internal/subtitles/pgrepo.go`, `internal/watchsync/repository.go`,
|
||||
`internal/webhooksync/repo.go`, `internal/historyimport/repo_admin.go`, `internal/historyimport/repo.go`,
|
||||
plus their constructor call sites.
|
||||
|
||||
Each repo gains a `*secret.Cipher` constructor arg and follows encrypt-on-write / decrypt-on-read with a
|
||||
row-bound AAD. Preserve existing keep-existing CASE/COALESCE semantics — encrypt only non-empty incoming
|
||||
values. All read paths use the read-path contract (pass through legacy plaintext, hard-fail corrupt
|
||||
ciphertext). These columns **never** had the resolver indirection — they always stored literal
|
||||
credentials — so no resolve-then-encrypt is needed (unlike the arr columns).
|
||||
|
||||
- [ ] **Step 1: subtitles** — encrypt `api_key` + `password` in `UpsertProviderConfig`; decrypt in
|
||||
`ListProviderConfigs` and `GetProviderConfig`. Keep the existing `HasAPIKey`/`HasCredentials` flags
|
||||
(set them from the decrypted values). AAD PK = `provider_name`.
|
||||
- [ ] **Step 2: watchsync** — encrypt `access_token` + `refresh_token` in `UpsertConnection`. Add a
|
||||
`decryptConnection(*Connection)` helper and call it after **every** scan path (list/get/by-provider/etc.
|
||||
— there are several). AAD PK = connection `id`.
|
||||
- [ ] **Step 3: webhooksync** — encrypt `access_token` in `CreateConnection`; decrypt in `scanConnection`.
|
||||
**Do NOT touch `webhook_secret`** — it is equality-looked-up in `GetConnectionBySecret`
|
||||
(`WHERE webhook_secret = $1`) and is carved out for the hashing follow-up. AAD PK = connection `id`.
|
||||
- [ ] **Step 4: historyimport admin token** — encrypt in `SetSourceAdminToken`; decrypt in
|
||||
`GetSourceWithAdminToken`; `ClearSourceAdminToken` (writes NULL) is unchanged. AAD PK = source `id`.
|
||||
- [ ] **Step 5: historyimport session tokens** — in `internal/historyimport/repo.go`, encrypt
|
||||
`connect_access_token` (`history_import_connect_sessions`) and `auth_token`
|
||||
(`history_import_plex_sessions`) on insert/update; decrypt on scan (note the existing
|
||||
`COALESCE(auth_token, '')` read). These are read by session `id`, not by token value. `pin_id`/`pin_code`
|
||||
are transient pairing artifacts — leave them. AAD PK = session `id`.
|
||||
- [ ] **Step 6:** Thread `dataCipher` into each constructor at its wiring site (`router.go` / `main.go`).
|
||||
- [ ] **Step 7:** `GOWORK=off go build ./...`; package tests for each modified repo pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Idempotent startup backfill
|
||||
|
||||
**Files:** Create `internal/secret/backfill.go`, `internal/secret/backfill_test.go`; modify `cmd/silo/main.go`.
|
||||
|
||||
- [ ] **Step 1: Generic in-place backfill** for the literal-credential columns (subtitle ×2, watchsync ×2,
|
||||
webhook `access_token`, historyimport `admin_token`, historyimport session `connect_access_token` +
|
||||
`auth_token`). Per `BackfillTarget {table, column, idColumn, aadPrefix}`: select rows where the column
|
||||
is non-null, non-empty, and `NOT LIKE 'enc:v1:%'`; for each, compute AAD = `aadPrefix + ":" + id`,
|
||||
`EncryptIfPlaintext`, and `UPDATE ... SET col = $1 WHERE id = $2 AND col = $3` (the `AND col = $3` guard
|
||||
makes concurrent multi-node boots converge without double-encrypting). Empty values are skipped (never
|
||||
encrypt `""`).
|
||||
- [ ] **Step 2: Resolve-then-encrypt backfill for the two arr columns** (`request_integrations.api_key_ref`,
|
||||
`autoscan_connections.api_key_ref`). This is **separate** from Step 1 because legacy rows may hold a
|
||||
`server_settings` key name rather than a literal key. For each row whose value is not already
|
||||
`enc:v1:`-prefixed, replicate the old `resolveAPIKey` semantics **once**:
|
||||
1. `resolved := encryptedSettingsRepo.Get(ctx, value)` (the decrypting decorator — handles both a
|
||||
sensitive-and-encrypted target like `requests.radarr.api_key` and a plaintext custom key).
|
||||
2. If `resolved` is non-empty, the row held a *reference* → encrypt `resolved` inline.
|
||||
Else the row held a *literal* key → encrypt `value` inline.
|
||||
3. `UPDATE ... SET api_key_ref = <enc> WHERE id = $2 AND api_key_ref = <original>`, AAD bound to the row id.
|
||||
Run this **after** `BackfillSensitiveSettings` so the referenced settings are consistent (the decorator
|
||||
decrypts regardless, but ordering keeps it deterministic). Optionally log/flag now-orphaned
|
||||
`requests.{radarr,sonarr}.api_key` settings rows for a later cleanup (do not delete in this PR).
|
||||
- [ ] **Step 3: Failure posture (decided):** best-effort. Collect per-row/per-target errors, log each at
|
||||
ERROR, and emit a single startup summary line (`secret backfill: encrypted N, failed M`). A failed
|
||||
encrypt leaves the pre-existing plaintext (no *new* exposure vs. today) and reads still succeed via the
|
||||
read-path pass-through, so it must **not** block boot — reliability over a hard stop. (Read-path decrypt
|
||||
failures on an `enc:v1:` value still hard-fail the individual operation.)
|
||||
- [ ] **Step 4: Ordering in `cmd/silo/main.go`** — gate on migration-running modes only (mirror the
|
||||
existing migration guard; proxy/transcode nodes skip the backfill). Sequence:
|
||||
`LoadBootstrap (read SECRET_KEY) → open pool → RunMigrations → construct dataCipher → wrap settingsRepo →
|
||||
BackfillSensitiveSettings → generic column backfill → arr resolve-then-encrypt backfill → GetAll(decrypts)
|
||||
→ auto-gen jwt_secret (writes encrypted) → [pool recreate? re-wrap settingsRepo] → LoadFromDB(plaintext) → validate`.
|
||||
The settings backfill + `GetAll` must precede `LoadFromDB` so config consumers read decrypted values.
|
||||
- [ ] **Step 5: Concurrency note** — the `AND col = $3` idempotency guard is sufficient for the common
|
||||
single-primary deploy. For multi-primary races, optionally wrap the backfill in a dedicated Postgres
|
||||
advisory lock with a **fresh** lock id (do **not** reuse the migration `legacyBootstrapLocker` id — that
|
||||
would extend the migration lock's held duration unpredictably).
|
||||
- [ ] **Step 6: Tests** (`backfill_test.go`, against a test DB): seed plaintext rows → backfill →
|
||||
all become `enc:v1:` and decrypt to the originals; second run is a no-op (no double-encrypt); mixed
|
||||
plaintext/ciphertext rows handled; empty values skipped; one failing row does not abort the rest;
|
||||
**arr resolve-then-encrypt:** a row holding `requests.radarr.api_key` (with that settings key present)
|
||||
ends up encrypting the *resolved* credential, and a row holding a literal key encrypts the literal.
|
||||
- [ ] **Step 7:** Boot against a DB pre-seeded with plaintext credentials **and** a legacy arr row that
|
||||
references a settings key; confirm the summary log, that every target column is `enc:v1:` afterward, that
|
||||
the arr row decrypts to the real Radarr key (not the setting name), and that a second boot reports
|
||||
`encrypted 0`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Verification, docs, follow-up
|
||||
|
||||
- [ ] **Step 1: Regression guard tests:**
|
||||
- API key auth still works — `api_keys.api_key` is stored as the literal `sa_`-prefixed token and
|
||||
`GetByKey` (`WHERE api_key = $1`) still resolves (proves it was *not* routed through encryption).
|
||||
- `auth.jwt_secret` is consistent across two simulated boots (encrypted at rest, plaintext to
|
||||
`LoadFromDB`), and the stored `server_settings` row is `enc:v1:`-prefixed.
|
||||
- Inbound webhook routing still works (`webhook_secret` untouched).
|
||||
- A legacy arr integration that referenced `requests.sonarr.api_key` still fulfills after backfill.
|
||||
- [ ] **Step 2:** `GOWORK=off go build ./...`, `make lint`, `make verify-local-paths`,
|
||||
`cd web && pnpm run lint && pnpm run format:check` (frontend unaffected — confirm green).
|
||||
- [ ] **Step 3:** Write `docs/architecture/secret-encryption.md`: how `SECRET_KEY` is generated, that it
|
||||
must be backed up **separately** from DB dumps (treat like a CA private key), what happens on key loss
|
||||
(encrypted secrets unrecoverable → re-enter integrations), the rollback/downgrade hazard (an old binary
|
||||
reads `enc:v1:auth.jwt_secret` as a literal JWT secret → all sessions invalid; document that downgrade
|
||||
requires decrypting settings back to plaintext or clearing `auth.jwt_secret`), and the envelope/version
|
||||
scheme for future rotation.
|
||||
- [ ] **Step 4: File the follow-up issues:**
|
||||
1. **Hash equality-lookup secrets** (`api_keys.api_key`, `webhook_sync_connections.webhook_secret`) —
|
||||
store a deterministic hash, look up by hash.
|
||||
2. **Encrypt plugin runtime config** (`plugin_runtime_configs.config_value`) — coordinated with the
|
||||
`Silo` host repo and plugin-manifest secret-field metadata; must also re-encrypt/purge the legacy
|
||||
plaintext `introdb.api_key` copy. Note the dead `plex_sync_connections` table as a drop candidate.
|
||||
- [ ] **Step 5:** Update the issue #45 acceptance check: no plaintext arr keys at rest (Requests +
|
||||
Autoscan); `api_key_ref` is unambiguous (inline ciphertext, no literal fallback); existing rows
|
||||
encrypted by the backfill (arr rows resolved-then-encrypted); S3 + the broader server-owned credential
|
||||
set covered; remaining gaps (equality-lookup hashing, plugin JSONB) tracked as follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## Safety checklist (the implementer must not skip)
|
||||
|
||||
1. `SECRET_KEY` is read in `LoadBootstrap` and **fatals** if absent/short — never defaults to a zero key
|
||||
or a hash of `DATABASE_URL`.
|
||||
2. The cipher is constructed once, right after bootstrap, before any `settingsRepo` call — threaded as a
|
||||
dependency, never a global.
|
||||
3. The settings backfill + decryption happen **before** `config.LoadFromDB` consumes the map.
|
||||
4. The auto-generated `auth.jwt_secret` is written **encrypted**, while the in-memory map keeps plaintext
|
||||
for `LoadFromDB`.
|
||||
5. **`settingsRepo` is re-wrapped as the `EncryptedSettingsRepo` after the max-connections pool recreation
|
||||
(main.go ~399)** and at every other construction site — no raw settings repo escapes into later wiring.
|
||||
6. The two **arr columns** use the **resolve-then-encrypt** backfill (not in-place) so legacy
|
||||
`server_settings` references become the real credential before encryption; the other columns use the
|
||||
plain in-place backfill.
|
||||
7. `SensitiveSettingKeys` is **audited from the config loader's real inputs** (incl.
|
||||
`redis.sentinel_password` and the legacy `s3.operational_*` / `recommendations.openai_api_key`
|
||||
aliases), not copied from the old redaction map.
|
||||
8. `api_keys.api_key` and `webhook_sync_connections.webhook_secret` are **never** routed through
|
||||
encryption — they are equality-looked-up and would break auth/routing (separate hashing task).
|
||||
9. Every decrypt site obeys the **read-path contract**: pass through non-prefixed plaintext; **error
|
||||
(never fall back)** on a corrupt `enc:v1:` value. Audit every old `if resolved == ""` site removed in Task 4.
|
||||
10. AAD binds to **row identity** (`table:column:<pk>`); PKs in scope (integration/connection ids, uuids,
|
||||
`provider_name`) are stable. server_settings binds to its key.
|
||||
11. The backfill is prefix-gated and idempotent (`AND col = $3` guard); proxy/transcode nodes skip it.
|
||||
12. `plex_sync_connections` is not a target (dead table); `plugin_runtime_configs.config_value` is a
|
||||
documented deferred gap, not silently ignored; `subtitle_provider_config.username` stays plaintext.
|
||||
@@ -1,377 +0,0 @@
|
||||
# AI Services Core, Metadata Translation & Whisper ASR — 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.
|
||||
|
||||
**Design:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md` (read it first; this plan does not repeat the rationale).
|
||||
|
||||
**Goal:** Extract the subtitle-AI LLM plumbing into shared packages (`internal/ai/llm`, `internal/ai/translate`, `internal/ai/jobrunner`), add AI translation of overviews/taglines into the existing localization tables with field provenance, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). Single PR.
|
||||
|
||||
**Architecture:** New `internal/ai/*` packages consumed by a refactored `internal/subtitles/ai` and a new `internal/metadata/translation` service. One new Goose migration (jobs table, provenance columns, library auto-translate flag). New `ai.*` settings keys with loader fallback to legacy `subtitle_ai.*` rows (encrypted values are GCM-bound to their key — never rename rows in SQL). Frontend: AI Services settings page, metadata-editor translate action, library toggle, player "generate from audio" mode.
|
||||
|
||||
**Tech stack:** Go (chi, pgx), PostgreSQL via Goose SQL migrations, React + TypeScript (TanStack Query), Vitest.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## Ordering and verification
|
||||
|
||||
Tasks 1–4 are a pure refactor and must leave subtitle translation behaviorally unchanged — run `go build ./... && go test ./internal/...` after each. Tasks 5–11 are backend features; 12–15 frontend; 16 final verification.
|
||||
|
||||
Before opening the MR: `make lint`, `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Create**
|
||||
- `internal/ai/llm/client.go`, `client_test.go`, `config.go` — shared OpenAI-compatible client (chat + transcription)
|
||||
- `internal/ai/llm/transcribe.go` — multipart `/v1/audio/transcriptions`, `verbose_json` types
|
||||
- `internal/ai/translate/translate.go`, `translate_test.go` — generic segment batch translator (package `aitranslate`)
|
||||
- `internal/ai/jobrunner/runner.go`, `runner_test.go` — dispatch/semaphore/heartbeat/reaper/cancel registry
|
||||
- `internal/metadata/translation/{job.go,pgrepo.go,service.go,prompt.go,service_test.go}`
|
||||
- `internal/subtitles/ai/transcriber.go`, `transcriber_test.go` — ASR pipeline
|
||||
- `internal/playback/audio_extract.go` — ffmpeg chunked audio extraction helper
|
||||
- `internal/api/handlers/metadata_ai.go`
|
||||
- `migrations/sql/<timestamp>_ai_metadata_translation_and_asr.sql` (via `make migrate-create`)
|
||||
- `web/src/pages/admin-settings/AIServicesSettings.tsx`
|
||||
|
||||
**Modify**
|
||||
- `internal/subtitles/ai/{client.go→deleted,translator.go,service.go,engine.go,job.go,config.go,pgrepo.go}`
|
||||
- `internal/config/{config.go,db_loader.go}`; `internal/catalog/encrypted_settings_repo.go`
|
||||
- `internal/catalog/{localization_repo.go,detail.go}`; `internal/models/media.go`
|
||||
- `internal/metadata/service.go` (provider provenance + auto-enqueue hook)
|
||||
- `internal/api/handlers/subtitle_ai.go`; router registration; `cmd/silo/main.go` wiring
|
||||
- `web/src/pages/admin-settings/SubtitlesSettings.tsx`, `web/src/components/EditMetadataDialog.tsx`, `web/src/player/components/SubtitleTranslateModal.tsx`, library settings form, `web/src/api/` types/client, admin settings nav/route registration
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `internal/ai/llm` — shared client
|
||||
|
||||
- [ ] **Step 1: Move the chat client.** Create `internal/ai/llm` (package `llm`). Move `Client`, `chat` (export as `Chat`), `chatMessage` (export as `Message`), retry helpers (`sleepCtx`, `rateLimitBackoff`, `truncate`) from `internal/subtitles/ai/client.go`. New `llm.Config`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
BaseURL string // chat endpoint, no trailing /v1
|
||||
APIKey string
|
||||
ChatModel string
|
||||
ASRBaseURL string // empty = BaseURL
|
||||
ASRAPIKey string // empty = APIKey
|
||||
ASRModel string
|
||||
MaxConcurrentJobs int
|
||||
}
|
||||
```
|
||||
|
||||
Log lines lose the "subtitle" wording (they are shared now).
|
||||
|
||||
- [ ] **Step 2: Add `Transcribe`.** In `transcribe.go`:
|
||||
|
||||
```go
|
||||
type TranscribeRequest struct {
|
||||
Filename string
|
||||
Audio io.Reader
|
||||
Language string // optional ISO-639-1 hint
|
||||
Timeout time.Duration // per-request; sized to chunk length by the caller
|
||||
}
|
||||
type TranscriptionSegment struct{ Start, End float64; Text string }
|
||||
type Transcription struct{ Language string; Segments []TranscriptionSegment }
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error)
|
||||
```
|
||||
|
||||
Multipart fields: `file`, `model` (=`ASRModel`), `response_format=verbose_json`, `temperature=0`, optional `language`. Reuse the same 429/5xx/transport retry-with-backoff loop as `Chat` (extract a shared `doWithRetry` rather than duplicating it). Empty `segments` in the response is an error (`transcription returned no segments`), not a silent fallback.
|
||||
|
||||
- [ ] **Step 3: Tests** (`client_test.go`, httptest): chat retries on 429 (honors `Retry-After`), 5xx, 200-with-error-object, empty choices; transcribe happy path (multipart fields present, segments parsed), missing-segments error, ASR base-url/key override falling back to chat values.
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared OpenAI-compatible LLM client with transcription support`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `internal/ai/translate` — generic segment translator
|
||||
|
||||
- [ ] **Step 1: Move the batch logic.** Package `aitranslate`. Move `buildIndexedJSON`, `extractJSONObject`, and the batch loop from `internal/subtitles/ai/translator.go`, generalized:
|
||||
|
||||
```go
|
||||
type Segment struct{ ID, Text string }
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // caller-supplied; domain-specific
|
||||
BatchSize int
|
||||
ContextNeighbors int // preceding source segments sent untranslated
|
||||
}
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request,
|
||||
onBatch func(batch []Segment, done, total int)) ([]Segment, error)
|
||||
```
|
||||
|
||||
Wire protocol unchanged: 1-based indexed JSON per batch, same-keys response, malformed-response retries (`maxRetries = 2`), completeness check. `ChatFn` keeps the package free of an `llm` dependency and trivially testable.
|
||||
|
||||
- [ ] **Step 2: Tests:** batch splitting/boundaries, context neighbors, code-fence tolerance, omitted-key retry then failure, ctx cancellation between batches.
|
||||
|
||||
- [ ] **Step 3: Commit.** `refactor(ai): extract generic batched segment translator`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `internal/ai/jobrunner` — shared job lifecycle
|
||||
|
||||
- [ ] **Step 1: Extract the runner.** Move dispatch/heartbeat/reaper/cancel mechanics from `internal/subtitles/ai/service.go` (lines ~136–319) behind:
|
||||
|
||||
```go
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
MarkCancelled(ctx context.Context, id int64, message string) error
|
||||
}
|
||||
type Runner struct{ /* baseCtx, sem (shared), store, logger, cancels, wg */ }
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, logger *slog.Logger) *Runner
|
||||
func (r *Runner) Recover() // reap + background reaper loop
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context)) // semaphore + heartbeat + cancel registry
|
||||
func (r *Runner) Cancel(id int64) bool // true if an in-flight goroutine was cancelled
|
||||
```
|
||||
|
||||
Heartbeat-while-queued behavior (the comment block in `dispatch`) must be preserved verbatim — it is load-bearing for multi-instance safety. Constants (30 s heartbeat, 2 min stale, 1 min reaper) move here.
|
||||
|
||||
- [ ] **Step 2: Shared semaphore.** The semaphore is **constructed by the caller** (`cmd/silo`) at size `ai.max_concurrent_jobs` and passed to every `Runner`, so subtitle + metadata + ASR jobs share one bound.
|
||||
|
||||
- [ ] **Step 3: Tests:** dispatch bounded by a size-1 shared semaphore across two runners; cancel of queued job marks cancelled without running; reaper resets a stale heartbeat row (fake store).
|
||||
|
||||
- [ ] **Step 4: Commit.** `refactor(ai): extract shared job lifecycle runner`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Refactor `internal/subtitles/ai` onto the shared core
|
||||
|
||||
- [ ] **Step 1:** Delete `client.go`; `LLMTranslator` becomes an adapter: cues → `aitranslate.Segment` (ID = 1-based index, Text = joined lines), subtitle system prompt stays here, `splitCueLines` maps back. `Service` keeps job semantics but delegates lifecycle to `jobrunner.Runner`; `pgrepo` satisfies `jobrunner.Store`.
|
||||
- [ ] **Step 2:** Update `cmd/silo/main.go` wiring (build `llm.Client` + shared semaphore once; pass into the subtitle service).
|
||||
- [ ] **Step 3:** `go build ./... && go test ./internal/...` — subtitle translation behavior unchanged (same prompts, same batching, same job rows).
|
||||
- [ ] **Step 4: Commit.** `refactor(subtitles): consume shared AI core`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Migration
|
||||
|
||||
- [ ] **Step 1:** `make migrate-create NAME=ai_metadata_translation_and_asr`, then fill the generated file:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
CREATE TABLE public.metadata_translation_jobs (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
|
||||
content_id text NOT NULL,
|
||||
include_children boolean NOT NULL DEFAULT true,
|
||||
source_language text NOT NULL DEFAULT '',
|
||||
target_language text NOT NULL,
|
||||
engine text NOT NULL DEFAULT 'openai',
|
||||
model text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
|
||||
progress double precision NOT NULL DEFAULT 0,
|
||||
progress_message text NOT NULL DEFAULT '',
|
||||
fields_done integer NOT NULL DEFAULT 0,
|
||||
fields_total integer NOT NULL DEFAULT 0,
|
||||
force boolean NOT NULL DEFAULT false,
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
idempotency_key text NOT NULL,
|
||||
requested_by integer,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
heartbeat_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
|
||||
ON public.metadata_translation_jobs (idempotency_key)
|
||||
WHERE status IN ('pending', 'running');
|
||||
CREATE INDEX metadata_translation_jobs_content_idx
|
||||
ON public.metadata_translation_jobs (content_id, created_at DESC);
|
||||
CREATE INDEX metadata_translation_jobs_status_idx
|
||||
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
|
||||
|
||||
-- Field provenance: 'provider' | 'ai' | 'manual'. manual > provider > ai.
|
||||
ALTER TABLE media_item_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual')),
|
||||
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE season_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
ALTER TABLE episode_localizations
|
||||
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
|
||||
CHECK (overview_source IN ('provider', 'ai', 'manual'));
|
||||
|
||||
ALTER TABLE media_folders
|
||||
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
|
||||
ALTER TABLE episode_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE season_localizations DROP COLUMN overview_source;
|
||||
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
|
||||
DROP TABLE IF EXISTS public.metadata_translation_jobs;
|
||||
-- +goose StatementEnd
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `make migrate-up` against the local stack (`docker compose up -d postgres redis`), then `make migrate-status`.
|
||||
- [ ] **Step 3: Commit.** `feat(metadata): migration for translation jobs, localization provenance, library auto-translate`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Settings & config
|
||||
|
||||
- [ ] **Step 1: Config structs** (`internal/config/config.go`): add `AIConfig` (fields mirroring `llm.Config`); slim `SubtitleAIConfig` to `Enabled`, `TranscribeEnabled`, `BatchSize`, `ContextNeighbors`; add `MetadataAIConfig{Enabled bool}`.
|
||||
- [ ] **Step 2: Loader** (`internal/config/db_loader.go`): load `ai.*` with legacy fallback, following the `recommendations.embedding_auth_token` precedent at line ~409:
|
||||
|
||||
```go
|
||||
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
|
||||
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
|
||||
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
|
||||
// ai.max_concurrent_jobs ← subtitle_ai.max_concurrent_jobs ← 2
|
||||
// ai.asr_model (default "whisper-1"), ai.asr_base_url, ai.asr_api_key (default "")
|
||||
// subtitle_ai.transcribe_enabled, metadata_ai.enabled (default false)
|
||||
```
|
||||
|
||||
**Never** rename the legacy rows in SQL — encrypted values are GCM-bound to their setting key (`internal/catalog/encrypted_settings_repo.go`).
|
||||
|
||||
- [ ] **Step 3:** Add `ai.api_key`, `ai.asr_api_key` to `sensitiveSettingKeys`.
|
||||
- [ ] **Step 4:** Readiness helpers: subtitle translate = `SubtitleAI.Enabled && AI chat ready`; ASR = `SubtitleAI.TranscribeEnabled && AI.ASRModel != "" && (asr or chat base URL set)`; metadata = `MetadataAI.Enabled && AI chat ready`.
|
||||
- [ ] **Step 5: Commit.** `feat(config): shared ai.* settings with legacy subtitle_ai fallback`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Provenance-aware localization writes
|
||||
|
||||
- [ ] **Step 1: Models** (`internal/models/media.go`): add `OverviewSource`/`TaglineSource` to `MediaItemLocalization`, `OverviewSource` to `SeasonLocalization`/`EpisodeLocalization`.
|
||||
- [ ] **Step 2: Provider upsert rules** (`internal/catalog/localization_repo.go`): rewrite the three `Upsert` statements so, per AI-writable field:
|
||||
- existing source `manual` → keep existing value and source;
|
||||
- incoming value empty → keep existing value and source;
|
||||
- otherwise → take incoming value, set source `provider`.
|
||||
|
||||
Single-statement `ON CONFLICT … DO UPDATE` with `CASE` (no read-modify-write). Pattern for one field:
|
||||
|
||||
```sql
|
||||
overview = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview
|
||||
ELSE EXCLUDED.overview END,
|
||||
overview_source = CASE
|
||||
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview_source
|
||||
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview_source
|
||||
ELSE 'provider' END
|
||||
```
|
||||
|
||||
- [ ] **Step 3: AI upsert methods**: `UpsertAITranslation(ctx, contentID, language string, overview, tagline *string, force bool)` per repo (nil pointer = field not part of this write). Insert path populates only the translated fields (other text columns empty). Update path per field: write when existing source is `ai`, existing value is empty, or `force` — never when `manual`; `force` may overwrite `provider` (the admin asked). Sets source `ai`.
|
||||
- [ ] **Step 4: Serving hardening** (`internal/catalog/detail.go`): verify `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel` only override base fields with **non-empty** localization values (AI rows carry empty titles). Fix any field that clobbers; add a regression test.
|
||||
- [ ] **Step 5: Tests** for the upsert matrix (provider-over-ai, ai-skips-provider-unless-force, manual-untouchable, empty-never-blanks), following the existing repo/service test patterns in `internal/catalog`.
|
||||
- [ ] **Step 6: Commit.** `feat(catalog): field provenance for localizations with provider/ai/manual precedence`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: `internal/metadata/translation` service
|
||||
|
||||
- [ ] **Step 1: Job model + repo** (`job.go`, `pgrepo.go`): mirror `internal/subtitles/ai/{job.go,pgrepo.go}` shapes against `metadata_translation_jobs`; satisfy `jobrunner.Store`. Idempotency key = SHA-256 of `content_id|target_kind|target_language|model`.
|
||||
- [ ] **Step 2: Service** (`service.go`): `Enqueue`, `GetJob`, `ListJobs(contentID)`, `Cancel`, `Recover`, using `jobrunner.Runner` + the shared semaphore. The run loop:
|
||||
1. Expand targets: `item` → item overview+tagline; series item with `include_children` → + every season overview + every episode overview; `season` (+children → its episodes); `episode` → its overview. Source text comes from the **base** rows (default metadata language); `source_language` recorded from the item's `default_metadata_language`.
|
||||
2. Skip-if-filled: drop any field whose target-language localization value is already non-empty, unless `force`. All skipped → complete immediately ("Nothing to translate").
|
||||
3. Build `aitranslate.Segment`s (IDs `item:overview`, `item:tagline`, `season:<content_id>:overview`, `episode:<content_id>:overview`), translate with `prompt.go`'s system prompt (names the title/year, "translate media catalog descriptions", preserve proper nouns/character names/tone, no added information), `BatchSize` = package constant `metadataBatchSize = 10`, `ContextNeighbors = 0`.
|
||||
4. Per batch: provenance-aware AI upserts (Task 7) + `fields_done`/progress update. Persisting per batch means a cancelled job keeps completed fields.
|
||||
- [ ] **Step 3: Tests** (fake repos + fake `ChatFn`): series expansion counts, skip-if-filled short-circuit (zero chat calls), force overwrites `ai`+`provider` but never `manual`, per-batch persistence on cancellation mid-job.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): AI translation service for overviews and taglines`
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Ingestion hook + library flag
|
||||
|
||||
- [ ] **Step 1:** Switch the provider localization writes in `internal/metadata/service.go` (item ~line 1476, season ~2921, episode ~3080) to the provenance-aware upserts — no other behavior change.
|
||||
- [ ] **Step 2:** Thread `auto_translate_metadata` through the `MediaFolder` model, folder repo scan/update, and the folder settings API payload.
|
||||
- [ ] **Step 3: Auto-enqueue hook.** After an item's refresh persists (single point at the end of the item flow — not per season/episode), when: folder flag set, folder `metadata_language` non-empty and ≠ item `default_metadata_language`, metadata AI ready, and the item's target-language localization is missing overview (or tagline) → `Enqueue` a non-force `item` job with `include_children=true`. Fire-and-forget (log on error); the active-idempotency index plus skip-if-filled make repeat refreshes free.
|
||||
- [ ] **Step 4: Commit.** `feat(metadata): per-library auto-translate fallback on refresh`
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Metadata AI API
|
||||
|
||||
- [ ] **Step 1:** `internal/api/handlers/metadata_ai.go`, mirroring `subtitle_ai.go` shapes:
|
||||
- `GET /api/v1/metadata/ai/status` → `{enabled}`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}` → `{job}` (joins an in-flight duplicate, same as subtitles)
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}`, `POST /api/v1/metadata/ai/jobs/{job_id}/cancel`, `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
- [ ] **Step 2:** Gate with the same permission as metadata editing (metadata curation permission; see `internal/catalog/update.go` callers). Register routes; wire the service in `cmd/silo/main.go`.
|
||||
- [ ] **Step 3:** Handler tests following `internal/api/handlers` conventions (validation: bad kind, missing language, disabled engine → 400/503 mapping).
|
||||
- [ ] **Step 4: Commit.** `feat(api): metadata AI translation endpoints`
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Whisper ASR
|
||||
|
||||
- [ ] **Step 1: Audio extraction** (`internal/playback/audio_extract.go`), alongside the existing subtitle extraction helpers:
|
||||
|
||||
```go
|
||||
// ExtractAudioChunks extracts one audio track to 16 kHz mono WAV chunks in dir.
|
||||
// Returns ordered chunk paths; chunkSeconds = 600 in production.
|
||||
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int,
|
||||
dir, ffmpegPath string, chunkSeconds int) ([]string, error)
|
||||
```
|
||||
|
||||
Single ffmpeg pass: `-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time <n> <dir>/chunk%05d.wav`.
|
||||
|
||||
- [ ] **Step 2: Transcriber** (`internal/subtitles/ai/transcriber.go`):
|
||||
|
||||
```go
|
||||
type Transcriber interface {
|
||||
Transcribe(ctx context.Context, req TranscribeJobRequest,
|
||||
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string /*detected lang*/, error)
|
||||
}
|
||||
```
|
||||
|
||||
`WhisperTranscriber` implementation: temp dir (`os.MkdirTemp`, removed on all exit paths) → `ExtractAudioChunks` → per chunk `llm.Client.Transcribe` (per-request timeout ∝ chunk duration; pass the job's language hint) → offset segment times by `chunkIndex*chunkSeconds` → cues (one per segment, wrapped to ≤2 lines ×~42 chars via a `wrapCueText` helper; whitespace-only segments dropped). Chunk processing order starts at the chunk containing `StartPosition`, then wraps (mirrors `reorderFromPosition` semantics).
|
||||
|
||||
- [ ] **Step 3: Wire kinds into the service.** `Service.run` branches on `job.Kind`:
|
||||
- `translate`: existing path.
|
||||
- `transcribe`: resolve audio track (`source_index` = audio index, `-1` → default/first; reject files without audio), transcribe (progress 10–70 %, live cue streaming via the existing notifier callbacks), store SRT as provider `transcribed`, language = hint or detected, release name `"<Language> (AI transcribed)"`, notify `SubtitleReady`.
|
||||
- `transcribe_translate`: `transcribe`, store the transcript track, then run the existing `Translator` on the cues (70–95 %), store provider `translated`, `result_subtitle_id` = translated track.
|
||||
|
||||
Enqueue validation per kind (ASR requires `TranscribeEnabled`; idempotency model component = `asr_model` or `asr_model+chat_model` for the chained kind).
|
||||
|
||||
- [ ] **Step 4:** API surface: `kind` field (optional, default `translate`) on the existing enqueue request in `subtitle_ai.go`; `transcribe_enabled` on the status response.
|
||||
- [ ] **Step 5: Tests** (`transcriber_test.go`, fake client): timestamp stitching across chunks, playhead-first chunk ordering, cue wrapping, empty-segments chunk → job error, temp-dir cleanup on failure (assert via `t.TempDir` layout).
|
||||
- [ ] **Step 6: Commit.** `feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs`
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Frontend — AI Services settings page
|
||||
|
||||
- [ ] **Step 1:** `web/src/pages/admin-settings/AIServicesSettings.tsx`: connection card (`ai.base_url`, `ai.api_key` via the sensitive-key pattern already used in `SubtitlesSettings.tsx:264`, `ai.chat_model`, `ai.asr_model`, optional `ai.asr_base_url`/`ai.asr_api_key`, `ai.max_concurrent_jobs`) + features card (`subtitle_ai.enabled`, `subtitle_ai.transcribe_enabled`, `metadata_ai.enabled`, `subtitle_ai.batch_size`, `subtitle_ai.context_neighbors`). Reads show effective values (new key, falling back to the legacy key when unset — same fallback order as the loader); writes always target the new `ai.*` keys.
|
||||
- [ ] **Step 2:** Remove the AI card from `SubtitlesSettings.tsx` (leave a link/hint to the new page); register the page in the admin settings nav + route following the existing page registrations.
|
||||
- [ ] **Step 3: Commit.** `feat(web): AI services settings page`
|
||||
|
||||
---
|
||||
|
||||
## Task 13: Frontend — metadata translate action
|
||||
|
||||
- [ ] **Step 1:** API client functions + types for the Task 10 endpoints.
|
||||
- [ ] **Step 2:** `EditMetadataDialog.tsx`: "Translate with AI" action (visible when `metadata_ai` status is enabled): target language (default = library `metadata_language`), `include_children` (series only), `force` checkbox; enqueue, poll the job every ~1.5 s, show `progress_message`/`fields_done`, invalidate the item detail query on completion.
|
||||
- [ ] **Step 3: Commit.** `feat(web): translate descriptions from the metadata editor`
|
||||
|
||||
---
|
||||
|
||||
## Task 14: Frontend — library auto-translate toggle
|
||||
|
||||
- [ ] **Step 1:** Add the `auto_translate_metadata` switch to the library settings form next to the metadata-language field (helper text: "When metadata providers have no translation for this library's language, translate descriptions with AI"). Disabled state with hint when `metadata_ai` is off.
|
||||
- [ ] **Step 2: Commit.** `feat(web): library auto-translate toggle`
|
||||
|
||||
---
|
||||
|
||||
## Task 15: Frontend — player "Generate from audio"
|
||||
|
||||
- [ ] **Step 1:** `SubtitleTranslateModal.tsx`: when `transcribe_enabled`, add a source option "Generate from audio (AI)" listing the file's audio tracks; submit with `kind: "transcribe"` (or `transcribe_translate` when the chosen output language differs from the audio language). Surface it prominently when no text subtitle source exists (today's dead end for bitmap-only files).
|
||||
- [ ] **Step 2: Commit.** `feat(web): generate subtitles from audio in the player`
|
||||
|
||||
---
|
||||
|
||||
## Task 16: Verification
|
||||
|
||||
- [ ] `go build ./... && go test ./...`
|
||||
- [ ] `make lint`
|
||||
- [ ] `cd web && pnpm run lint && pnpm run format:check`
|
||||
- [ ] `make verify-local-paths`
|
||||
- [ ] Manual smoke against the local stack: enqueue a metadata translation for a series (verify localization rows + provenance values), re-run a refresh (verify provider values overwrite `ai` rows and `manual` simulation survives), run a `transcribe` job on a short file against a local Whisper-compatible server.
|
||||
- [ ] MR description: problem, approach, link to the design doc, risks (Whisper endpoint variance, chunk-boundary artifacts), AI-use disclosure, screenshots of the new settings page / editor action / player mode.
|
||||
@@ -1,276 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,224 +0,0 @@
|
||||
# Audiobook Player QoL Improvements
|
||||
|
||||
Commands assume the repository root is the cwd. All paths are repository-relative.
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the web audiobook player to feature parity with best-in-class audiobook apps
|
||||
(Audiobookshelf, Audible, BookPlayer, Prologue) on the quality-of-life axis. The current
|
||||
surface architecture (persistent mini bar + expandable Now Listening view, playback
|
||||
survives navigation) is already the de-facto standard and stays as-is.
|
||||
|
||||
Explicitly **out of scope**: Media Session / lock-screen integration, bookmarks (needs a
|
||||
server endpoint — follow-up), offline/download, queue.
|
||||
|
||||
## Features
|
||||
|
||||
1. **Keyboard shortcuts** — space/K play-pause, arrow skips, volume, chapter nav, speed step.
|
||||
2. **Configurable skip intervals** — asymmetric defaults (back 10s, forward 30s), user-tunable.
|
||||
3. **Expanded speed control** — 0.5×–3.0× with fine stepping, presets, and per-book memory.
|
||||
4. **Chapter prev/next buttons** — alongside the seconds-skip buttons.
|
||||
5. **Volume control** — reuse the shared `VolumeControl`, persisted like the video player.
|
||||
6. **Smart rewind** — auto-rewind on resume, scaled by how long playback was paused.
|
||||
7. **Time remaining at current speed** — real-clock remaining display in Now Listening.
|
||||
|
||||
No server/API changes are required; everything is web-frontend only, so no Android/Apple
|
||||
client coordination is needed. (Per-book speed memory is device-local in v1; syncing it
|
||||
server-side via profile settings is a noted follow-up.)
|
||||
|
||||
## Existing seams (read these first)
|
||||
|
||||
| File | Relevance |
|
||||
| --- | --- |
|
||||
| `web/src/pages/audiobooks/player/useAudiobookPlayback.ts` | Playback engine. `togglePlay` (~line 499), `skip` (~534), `setRate` (~541), returned API (~690+). All engine work lands here. |
|
||||
| `web/src/pages/audiobooks/player/MiniBar.tsx` | Bottom bar. Hardcoded `SKIP_*_SECONDS = 30` and `PLAYBACK_RATES` at lines 10–12. |
|
||||
| `web/src/pages/audiobooks/player/NowListening.tsx` | Full-screen view. Same hardcoded constants; dead "More" button (lines 50–56) becomes the settings menu trigger; remaining-time toggle at lines 33–37. |
|
||||
| `web/src/pages/audiobooks/player/AudiobookPlayer.tsx` | Wrapper that owns mini/now-listening mode — mount point for the keyboard shortcuts hook. |
|
||||
| `web/src/player/components/VolumeControl.tsx` | Existing slider + `getPersistedVolume`/`persistVolume` helpers (shared `player-volume`/`player-muted` storage keys). Styled for dark video overlay — needs a surface-toned variant. |
|
||||
| `web/src/player/components/SpeedMenu.tsx` | Current preset-only menu (shared with video player). Audiobook gets a richer `SpeedControl`; video keeps `SpeedMenu` untouched. |
|
||||
| `web/src/player/hooks/useKeyboardShortcuts.ts` | Video player's shortcut hook — pattern to mirror (input-field guard, single document listener). |
|
||||
| `web/src/utils/storage.ts` | Typed localStorage wrapper — add new keys here, never call `localStorage` directly. |
|
||||
| `web/src/player/components/SeekBar.tsx`, `CircleButton.tsx`, `SleepTimerMenu.tsx`, `ChaptersMenu.tsx` | Established player design language; reuse, don't fork. |
|
||||
|
||||
## Design language
|
||||
|
||||
These are additions to an existing, refined system — extend it, don't restyle it:
|
||||
|
||||
- **Surfaces**: mini bar is a themed surface (`bg-background`, `border-t`, token colors).
|
||||
Popovers anchored to it use the established dark-glass treatment
|
||||
(`bg-black/90 backdrop-blur-sm rounded-lg shadow-xl`, white/75 text, `data-active` rows)
|
||||
exactly as `SpeedMenu`/`SleepTimerMenu` do today.
|
||||
- **Type**: time and rate values are always `font-mono`/`tabular-nums` so nothing shifts as
|
||||
digits tick. The new speed readout (`1.45×`) must reserve width for two decimals.
|
||||
- **Buttons**: all transport controls are `CircleButton` (`sm` secondary flanks, `md`/`lg`
|
||||
primary center). Chapter prev/next use `SkipBack`/`SkipForward` lucide glyphs at the same
|
||||
stroke weight (1.6) as the rotate icons, so the cluster reads as one family.
|
||||
- **Micro-interactions**, small and purposeful:
|
||||
- Skip buttons: a quick ~18° icon flick in the skip direction on press
|
||||
(CSS transform transition, ~150ms ease-out) — confirms the action without a toast.
|
||||
- Speed stepper: value crossfades (opacity 120ms) on change; the active preset chip gets
|
||||
the same `bg-white/5 text-white` treatment as current menu rows.
|
||||
- Smart rewind: when it fires, the seek bar playhead animates the small jump back rather
|
||||
than teleporting (SeekBar already animates position; verify it holds for ≤30s deltas).
|
||||
- **Accessibility**: every new control keeps the existing patterns — `aria-label`,
|
||||
`aria-expanded`/`aria-haspopup` on triggers, roving focus + Escape in menus (copy from
|
||||
`SleepTimerMenu`), `role="slider"` semantics from `VolumeControl`/`SeekBar`.
|
||||
|
||||
## Phase 1 — Engine + preferences model
|
||||
|
||||
### 1a. Preferences hook
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookPrefs.ts`:
|
||||
|
||||
- Add storage keys: `AUDIOBOOK_SKIP_BACK`, `AUDIOBOOK_SKIP_FORWARD`,
|
||||
`AUDIOBOOK_SMART_REWIND` (bool), `AUDIOBOOK_RATES` (JSON map) in `web/src/utils/storage.ts`.
|
||||
- Exposes `{ skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind }`.
|
||||
- Defaults: back **10s**, forward **30s** (the asymmetric convention — small "what did she
|
||||
say?" hops back, bigger hops forward), smart rewind **on**.
|
||||
- Allowed skip values: 5, 10, 15, 30, 45, 60, 90 (two digits max keeps `SkipIcon`'s inset
|
||||
number legible).
|
||||
- Per-book rate map: single JSON object `{ [contentId]: rate }` under `AUDIOBOOK_RATES`,
|
||||
LRU-capped at 50 entries (store `{ rate, at }` and evict oldest on insert).
|
||||
Helpers `getBookRate(contentId)` / `setBookRate(contentId, rate)` live here too.
|
||||
|
||||
### 1b. Engine extensions (`useAudiobookPlayback.ts`)
|
||||
|
||||
- **Volume/mute**: initialize from `getPersistedVolume()`; apply to the `<audio>` element in
|
||||
an effect; expose `volume`, `muted`, `setVolume`, `setMuted`; persist via `persistVolume`.
|
||||
Sharing the video player's keys is intentional — one volume preference per device.
|
||||
- **Per-book rate**: on mount, initialize `rate` from `getBookRate(contentId)` (fallback 1);
|
||||
in `setRate`, also `setBookRate`. The hook needs `contentId` passed in (it already
|
||||
receives the files/options object — extend that input).
|
||||
- **Chapter navigation**: expose `nextChapter()` and `prevChapter()`.
|
||||
- `next`: seek to start of the chapter after `currentChapter`; no-op at the last chapter.
|
||||
- `prev`: if more than 3s into the current chapter, seek to its start; otherwise seek to
|
||||
the previous chapter's start (the universal music/audiobook convention).
|
||||
- **Smart rewind**: record `pausedAtRef = performance.now()` whenever playback pauses (in
|
||||
the `pause` event handler so it also catches OS-initiated pauses). On resume, before
|
||||
`audio.play()`, compute rewind from pause duration and `seekTo(currentTime - rewind)`:
|
||||
- < 10s paused → 0s; < 1min → 3s; < 10min → 10s; < 1h → 20s; ≥ 1h → 30s.
|
||||
- Clamp at 0; skip entirely when the smart-rewind pref is off or the resume immediately
|
||||
follows a user seek (seeking sets a short suppress flag so explicit jumps aren't undone).
|
||||
- Also apply on **cold resume**: when starting with `initialPositionSeconds > 0` from the
|
||||
detail page, rewind by the same schedule using the age of the saved progress if the
|
||||
API exposes an updated-at timestamp; otherwise apply a flat 10s. Keep this logic in one
|
||||
pure function `smartRewindSeconds(pauseMs)` so it's unit-testable.
|
||||
- **Skip intervals**: no engine change — `skip(delta)` already takes a signed delta; the UI
|
||||
passes the configured values.
|
||||
|
||||
### 1c. Tests
|
||||
|
||||
Vitest, colocated like existing `*.test.tsx`:
|
||||
|
||||
- `smartRewindSeconds` schedule boundaries.
|
||||
- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
|
||||
- `prevChapter` 3s threshold behavior and first/last chapter edges
|
||||
(chapter math is pure — test against `buildChapterList` fixtures from
|
||||
`web/src/lib/audiobooks/chapters.ts`).
|
||||
|
||||
## Phase 2 — Controls UI
|
||||
|
||||
### 2a. SpeedControl (new, audiobook-specific)
|
||||
|
||||
New `web/src/pages/audiobooks/player/SpeedControl.tsx`, replacing `SpeedMenu` usage in both
|
||||
audiobook views (video player keeps `SpeedMenu`):
|
||||
|
||||
- Trigger: same `player-utility-btn` showing the current rate (`1.45×`, tabular-nums).
|
||||
- Popover (dark-glass, bottom-anchored like today):
|
||||
- A stepper row: `−` / big mono readout / `+`, stepping **0.05** per tap, clamped to
|
||||
**0.5–3.0**. Press-and-hold repeats (250ms initial, 80ms repeat).
|
||||
- A preset chip row: 1× · 1.25× · 1.5× · 1.75× · 2× · 2.5× · 3×.
|
||||
- Footnote line: "Remembered for this book" (quiet `text-white/40`, 11px) — makes the
|
||||
per-book memory discoverable instead of magical.
|
||||
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
|
||||
`SleepTimerMenu`).
|
||||
|
||||
### 2b. Chapter prev/next buttons
|
||||
|
||||
- Add `SkipBack`/`SkipForward` `CircleButton size="sm" variant="secondary"` on the outside
|
||||
of the transport cluster in both `MiniBar` and `NowListening`:
|
||||
`⏮ ↺10 ▶ ↻30 ⏭`. Wire to `prevChapter`/`nextChapter`; render only when
|
||||
`playback.chapters.length > 0`; disable at the ends (prev stays enabled mid-chapter).
|
||||
- In `MiniBar`, hide the chapter buttons below `sm:` — the 3-column grid is already tight
|
||||
on phones and the chapters menu remains available.
|
||||
|
||||
### 2c. Volume
|
||||
|
||||
- Add a `tone` prop to `web/src/player/components/VolumeControl.tsx`
|
||||
(`"overlay"` = current white-on-dark, `"surface"` = theme tokens: track `bg-muted`,
|
||||
fill `bg-foreground`, focus ring `ring-ring`). Default `"overlay"` so the video player is
|
||||
untouched.
|
||||
- Mount in `MiniBar`'s right cluster (before the sleep timer) at `md:` and up, and in
|
||||
`NowListening`'s utility row. Wire to the new engine `volume`/`muted` state.
|
||||
|
||||
### 2d. Player settings menu
|
||||
|
||||
New `web/src/pages/audiobooks/player/PlayerSettingsMenu.tsx` (dark-glass popover):
|
||||
|
||||
- **Skip back** / **Skip forward**: rows of small value chips (5/10/15/30/45/60/90s).
|
||||
- **Smart rewind**: toggle row with a one-line description ("Backs up a little after a pause").
|
||||
- Triggers: the currently-dead "More" (`MoreHorizontal`) button in `NowListening`
|
||||
(lines 50–56) and a matching trigger in `MiniBar`'s right cluster.
|
||||
- `MiniBar`/`NowListening` read `useAudiobookPrefs()` and pass the configured seconds to
|
||||
the skip buttons and `SkipIcon` (which already renders a dynamic number) — delete the
|
||||
`SKIP_*_SECONDS` constants.
|
||||
|
||||
## Phase 3 — Shortcuts + time display + polish
|
||||
|
||||
### 3a. Keyboard shortcuts
|
||||
|
||||
New `web/src/pages/audiobooks/player/useAudiobookKeyboardShortcuts.ts`, mounted in
|
||||
`AudiobookPlayer.tsx` (so it's active in both mini and expanded modes, on every route):
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| Space / K | play-pause |
|
||||
| ← / → | skip by configured back/forward seconds |
|
||||
| ↑ / ↓ | volume ±5% |
|
||||
| M | mute toggle |
|
||||
| N / P | next / previous chapter |
|
||||
| Shift+. / Shift+, | speed +0.05 / −0.05 (YouTube convention) |
|
||||
| E | expand / collapse Now Listening |
|
||||
| Esc | collapse Now Listening (expanded mode only) |
|
||||
|
||||
- Mirror `useKeyboardShortcuts.ts` guards: ignore when target is input/textarea/
|
||||
contentEditable, single document listener, cleanup on unmount.
|
||||
- Additional guard: **do nothing while a video session is active** — the video player binds
|
||||
the same keys. Check `WatchPlaybackProvider` state (or simply whether a `<video>` watch
|
||||
route is mounted) and bail; the audiobook bar is backgrounded in that case anyway. Space
|
||||
must also not fire when a button has focus (let the focused control handle Enter/Space).
|
||||
- Add `title` tooltips with the shortcut hint to transport buttons
|
||||
(e.g. `Back 10 seconds (←)`) for discoverability.
|
||||
|
||||
### 3b. Time remaining at current speed
|
||||
|
||||
In `NowListening`, the right time label currently toggles total ↔ remaining. Make it cycle
|
||||
three states (persist last choice in component state only):
|
||||
|
||||
1. total (`12:04:00`)
|
||||
2. remaining (`−3:21:09`)
|
||||
3. remaining at speed (`−2:14:06 at 1.5×`) — `remaining / rate`, hidden (skipped in the
|
||||
cycle) when `rate === 1`.
|
||||
|
||||
Keep the `data-testid="now-listening-right-time"` hook and update its test.
|
||||
|
||||
### 3c. Polish pass
|
||||
|
||||
- Skip-flick and speed-crossfade micro-interactions from the design section.
|
||||
- Verify mini bar layout on 360px-wide viewports with all new controls (chapter buttons and
|
||||
volume hidden, settings menu accessible).
|
||||
- `cd web && pnpm run lint && pnpm run format:check`; run the player test suites.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- `useAudiobookPlayback.ts` is 707 lines and growing — extract smart rewind + prefs-coupled
|
||||
logic into small pure modules (`smartRewind.ts`) rather than inflating the hook; consider
|
||||
splitting volume/rate concerns into a `useAudioElementSettings` helper if the hook passes
|
||||
~800 lines.
|
||||
- Smart rewind must not fight the 10s progress reporter: rewinding on resume changes
|
||||
`currentTime`, which triggers a progress write — that's correct (the rewound position is
|
||||
the truth), just confirm no oscillation with the suppress-after-seek flag.
|
||||
- Per-book rate uses `contentId`; switching narrators navigates to a different `contentId`,
|
||||
so each narration remembers its own speed — acceptable, arguably desirable.
|
||||
- Cross-device sync of prefs/per-book speed is a candidate follow-up once a profile
|
||||
settings endpoint exists; the `useAudiobookPrefs` API is the seam to swap storage behind.
|
||||
@@ -1,388 +0,0 @@
|
||||
# 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
@@ -1,891 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,748 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,731 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,169 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,109 +0,0 @@
|
||||
# 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`).
|
||||
@@ -1,88 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,281 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,967 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,738 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,554 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,268 +0,0 @@
|
||||
# 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). |
|
||||
@@ -1,71 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,447 +0,0 @@
|
||||
# Audiobook UI Redesign — Design Spec
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Branch context:** `feat/audiobooks` in `silo-server`
|
||||
**Status:** Approved — ready for implementation plan
|
||||
**Related spec:** [`2026-05-24-audiobooks-absorption-design.md`](./2026-05-24-audiobooks-absorption-design.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the audiobook detail page and audiobook player to visual and
|
||||
interaction parity with Silo's existing video player, translated for
|
||||
audio. Today the audiobook surfaces work but feel generic, hide useful
|
||||
information, and re-implement primitives the video player already has
|
||||
in a polished form.
|
||||
|
||||
User-visible outcome:
|
||||
|
||||
- The audiobook player has the same two-mode shape as the video player
|
||||
(compact HUD + immersive full-screen mode), so listening can be
|
||||
background or foreground depending on intent.
|
||||
- The detail page foregrounds the information audiobook listeners care
|
||||
about — current chapter, narrator, listened progress — instead of
|
||||
burying it.
|
||||
- The audiobook-only affordances that don't exist today (sleep timer,
|
||||
speed menu, bookmarks, narrator emphasis) have a home.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **Reuse video-player primitives.** `SeekBar`, `ChaptersMenu`, and the
|
||||
glass-disc button visual treatment all live in `web/src/player/` and
|
||||
are already wired up correctly. The audiobook player must consume
|
||||
them, not re-implement them.
|
||||
- **One audio element.** Switching between mini and Now Listening must
|
||||
not remount the audio element or restart the stream. Both modes are
|
||||
chrome layered over the same playback state.
|
||||
- **No new top-level routes.** The Now Listening view is an overlay on
|
||||
whatever route the user is on, not a `/audiobooks/listen` page. This
|
||||
mirrors how fullscreen video works — fullscreen is a mode, not a
|
||||
destination.
|
||||
- **Player state must be liftable.** The split between
|
||||
`useAudiobookPlayback` (state hook) and `MiniBar` / `NowListening`
|
||||
(chrome) must allow a future v1.1 change to lift the hook into an
|
||||
app-level provider so the mini bar can survive page navigation.
|
||||
v1 itself ships with the player tied to the detail route, same as
|
||||
today.
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- New mini-bar layout (cover tile, chapter title, glass-disc transport,
|
||||
utility rail with sleep / chapters / speed / expand / close).
|
||||
- New Now Listening full-overlay mode (large cover, chapter heading,
|
||||
tall seek bar, sleep / chapters / speed / bookmark / car-mode row,
|
||||
remaining-time toggle, overflow menu).
|
||||
- Restructured audiobook detail page (progress + chapter-aware Resume
|
||||
action, chapters expanded by default with currently-playing
|
||||
highlight, narrator card, embedding-based "Similar audiobooks"
|
||||
rail, "Also by author", "In this series").
|
||||
- Extraction of `CircleButton` and a new `SpeedMenu` into
|
||||
`web/src/player/components/` as shared primitives between video and
|
||||
audiobook players.
|
||||
- Split of today's monolithic `AudiobookPlayer.tsx` into a state hook
|
||||
(`useAudiobookPlayback`) plus two chrome components (`MiniBar`,
|
||||
`NowListening`) under `web/src/pages/audiobooks/player/`.
|
||||
- Sleep timer (client-side only; pauses + short fade-out when the
|
||||
timer fires).
|
||||
|
||||
### Out (deferred to v1.1)
|
||||
|
||||
- **Bookmarks.** UI affordances (mini-bar button, Now Listening
|
||||
utility-row button, "Bookmarks (n)" detail-page action) ship
|
||||
**hidden** in v1. Full feature needs `audiobook_bookmarks` table +
|
||||
CRUD endpoints, which is its own sub-spec.
|
||||
- **"Also by narrator" / cross-book "In this series" rails.** Render
|
||||
only when the backend already exposes the data; otherwise hide.
|
||||
Server-side joins to power them are a follow-up.
|
||||
- **Car mode.** Hidden in v1; v1.1 will add a huge-buttons layout
|
||||
variant reached from the utility row.
|
||||
- **Audiobook library page redesign.** Out of scope; current grid
|
||||
stays.
|
||||
- **Persistent player across page navigation as a lifted React
|
||||
context.** The current detail page owns the player state; lifting it
|
||||
to an app-level provider so the bar survives navigating away from
|
||||
the detail page is a follow-up. v1 ships with the bar tied to the
|
||||
detail route, same as today, but visually and structurally ready
|
||||
for that lift.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three connected surfaces
|
||||
|
||||
```
|
||||
detail page ──opens──► mini bar ──expand──► Now Listening
|
||||
▲ │
|
||||
└─────── collapse ──────┘
|
||||
```
|
||||
|
||||
- **Detail page** (`/audiobooks/book/:id`) is the landing surface.
|
||||
Stays mounted while the player is open.
|
||||
- **Mini bar** is a fixed bottom strip (current behavior) that appears
|
||||
once the user clicks Play/Resume.
|
||||
- **Now Listening** is a full-viewport overlay (z-index above the mini
|
||||
bar, below modals) reached by clicking the cover-art tile or the
|
||||
expand chevron in the mini bar. Dismissed via a collapse chevron.
|
||||
|
||||
The mini ↔ Now Listening transition uses a CSS view transition keyed
|
||||
on the cover art element so the small tile appears to grow into the
|
||||
big cover. The same primitive already used by `ViewTransitionLink`.
|
||||
|
||||
### Shared player primitives
|
||||
|
||||
`web/src/player/components/` already houses the visual building blocks
|
||||
used by the video player. Two of them get extracted so both players
|
||||
consume the same source of truth:
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `SeekBar.tsx` | reused as-is | already shared |
|
||||
| `ChaptersMenu.tsx` | reused as-is | already shared |
|
||||
| `CircleButton.tsx` | **new** — extracted from `PlayerControls.tsx` | the glass-disc primary/secondary button used in both transport clusters |
|
||||
| `SpeedMenu.tsx` | **new** | popover speed menu matching the styling of `ChaptersMenu`; replaces the raw `<select>` today; video player can adopt later |
|
||||
| `SleepTimerMenu.tsx` | **new** | popover with off / 5 / 15 / 30 / 45 / 60 / end-of-chapter; audiobook-only but lives here for symmetry |
|
||||
|
||||
Extracting `CircleButton` removes a divergence rather than adding a
|
||||
layer — it's the kind of focused improvement that earns its keep
|
||||
because today the video player and the audiobook player each render
|
||||
their own visually-similar-but-not-identical disc buttons.
|
||||
|
||||
### Audiobook player component tree
|
||||
|
||||
```
|
||||
web/src/pages/audiobooks/
|
||||
AudiobookDetail.tsx (restructured per "Detail page" below)
|
||||
AudiobookLibrary.tsx (unchanged)
|
||||
player/ (new folder)
|
||||
AudiobookPlayer.tsx (top level: owns audio element and state)
|
||||
MiniBar.tsx (compact chrome)
|
||||
NowListening.tsx (overlay chrome)
|
||||
CoverExpandTile.tsx (left-edge tile inside MiniBar — also the view-transition anchor)
|
||||
useAudiobookPlayback.ts (state hook: audio events, progress reporting, sleep timer)
|
||||
```
|
||||
|
||||
`AudiobookPlayer` becomes a thin shell:
|
||||
|
||||
```tsx
|
||||
function AudiobookPlayer(props) {
|
||||
const playback = useAudiobookPlayback(props);
|
||||
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
|
||||
return (
|
||||
<>
|
||||
<audio ref={playback.audioRef} src={playback.streamUrl} preload="metadata" hidden />
|
||||
{mode === "mini"
|
||||
? <MiniBar playback={playback} onExpand={() => setMode("now-listening")} onClose={props.onClose} />
|
||||
: <NowListening playback={playback} onCollapse={() => setMode("mini")} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The audio element lives in the parent so a mode swap never touches it.
|
||||
|
||||
### useAudiobookPlayback responsibilities
|
||||
|
||||
Lifts today's monolithic `AudiobookPlayer.tsx` into a single hook
|
||||
returning a stable shape:
|
||||
|
||||
```ts
|
||||
{
|
||||
audioRef, // ref<HTMLAudioElement>
|
||||
streamUrl, // string
|
||||
playing, currentTime, duration, buffered, rate,
|
||||
chapters, // PlayerChapter[] (flattened across files)
|
||||
currentChapter, // PlayerChapter | null
|
||||
sleep, // { remainingMs: number | null, end: SleepEndCondition | null }
|
||||
togglePlay, seekTo, skip,
|
||||
setRate,
|
||||
setSleep, // arms the timer
|
||||
// bookmarks (v1.1 — initially returns []/no-op stubs)
|
||||
bookmarks, addBookmark, removeBookmark,
|
||||
}
|
||||
```
|
||||
|
||||
The hook owns: audio event wiring, periodic progress reporting (the
|
||||
existing 10s `useReportAudiobookProgress` cadence), sleep-timer state
|
||||
+ fade-out, computing `currentChapter` from `currentTime`, and the
|
||||
unmount-time pause + final report. The chrome components stay pure
|
||||
presentational.
|
||||
|
||||
## Mini bar
|
||||
|
||||
Layout (one seek-bar row above a controls row):
|
||||
|
||||
```
|
||||
[ full-width SeekBar with chapter ticks ]
|
||||
[ cover tile | title + chapter + time | ◀30 ▶❚❚ 30▶ | ⌛ ☰ 1× ⌃ ✕ ]
|
||||
↑ left col middle center right rail
|
||||
36×54px truncating shared CircleButton cluster
|
||||
sleep, chapters, speed,
|
||||
expand chevron, close
|
||||
(bookmark hidden in v1)
|
||||
```
|
||||
|
||||
Differences from today's implementation:
|
||||
|
||||
- **CoverExpandTile** added at the left edge. 36×54 (portrait
|
||||
aspect-2:3). On hover: subtle scale + a small "expand" chevron
|
||||
overlay. Clicking it triggers the view transition to Now Listening.
|
||||
- **Left text column** shows title (line 1) and current chapter title
|
||||
(line 2). Time row stays where it is. Today the chapter title isn't
|
||||
shown anywhere while playing — this is the single most useful
|
||||
"where am I" cue and it's missing.
|
||||
- **Center cluster** swaps the bespoke `CircleButton` defined inline
|
||||
in the current `AudiobookPlayer` for the extracted shared one. Same
|
||||
three controls (back 30 / play-pause / forward 30).
|
||||
- **Right rail** replaces the raw `<select>` for speed with
|
||||
`SpeedMenu` (popover, same styling as `ChaptersMenu`). Adds
|
||||
`SleepTimerMenu`, an expand-up chevron (`ChevronUp`), and the
|
||||
existing close `X`. The bookmark button is **hidden in v1** (mini
|
||||
bar has no bookmark control until v1.1 lands).
|
||||
|
||||
Mini bar height is unchanged from today.
|
||||
|
||||
## Now Listening overlay
|
||||
|
||||
Full-viewport overlay, dark surface (`bg-background`). Z-index above
|
||||
the mini bar, below modals.
|
||||
|
||||
Layout on desktop (width ≥ md):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ⌄ (collapse, top-left) ⋯ More (top-right) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ │ PROJECT HAIL MARY │
|
||||
│ │ [ COVER ] │ Andy Weir │
|
||||
│ │ ~360 × 540 │ Narrated by Ray Porter │
|
||||
│ │ │ │
|
||||
│ │ │ ── CHAPTER 7 ── │
|
||||
│ │ │ The Astrophage │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ [ tall SeekBar with chapter ticks ] │
|
||||
│ 12:43 -4:08:35 │
|
||||
│ │
|
||||
│ ◀30 ▶❚❚ (large) 30▶ │
|
||||
│ │
|
||||
│ ⌛ Sleep ☰ Chapters (24) 1× Speed │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Layout on mobile (portrait): same elements stacked top-to-bottom —
|
||||
header row, cover (centered, smaller), metadata block, seek bar,
|
||||
transport, utility row. No horizontal split.
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- **Tap right time** toggles between `-remaining` and the wall-clock
|
||||
end time. The toggle preference is in-memory only (resets per
|
||||
session) — no persistence concern.
|
||||
- **Sleep timer** opens `SleepTimerMenu`. When armed, the trigger
|
||||
shows the countdown (`Sleep 04:32`). When the timer hits zero the
|
||||
player fades volume to 0 over 5s and pauses.
|
||||
- **Car mode** and **bookmark** buttons are **hidden in v1** — the
|
||||
utility row ships with sleep / chapters / speed only. Car mode +
|
||||
bookmark slots are documented here so v1.1 has a clear home for
|
||||
them.
|
||||
- **Overflow ⋯** menu items for v1: "Go to detail page", "Report a
|
||||
sync problem" (opens a mailto/issue link). Future additions live
|
||||
here.
|
||||
|
||||
The same `<audio>` element backs both modes, so all controls in Now
|
||||
Listening are bound to the same `useAudiobookPlayback` state.
|
||||
|
||||
## Detail page restructure
|
||||
|
||||
The page keeps its current top-level shape (hero band + content
|
||||
below) and adds/rearranges these blocks:
|
||||
|
||||
### Hero band
|
||||
|
||||
Today: `DetailHero` with a stack of `Play` / `Play from Start`
|
||||
actions and a small progress bar that appears below the buttons only
|
||||
when progress exists.
|
||||
|
||||
New version (still using `DetailHero` but with a richer `actions`
|
||||
slot):
|
||||
|
||||
```
|
||||
[ progress bar — full width of actions column, always shown if any progress ]
|
||||
[ "3h 12m listened · 19%" caption ]
|
||||
|
||||
▶ Resume Ch 7 · The Astrophage ↻ Start Over 🔖 Bookmarks (3)
|
||||
```
|
||||
|
||||
- Progress bar moves up *above* the buttons so the listener sees
|
||||
position context first.
|
||||
- Resume button label includes the chapter the listener will land
|
||||
in. Pulled from the same `buildChapterList` already in the file.
|
||||
- "Bookmarks (n)" button visible only when n > 0; opens a side sheet
|
||||
listing them. v1 ships with an empty stub (always 0, button never
|
||||
shown) — full feature in v1.1.
|
||||
|
||||
### Chapters section
|
||||
|
||||
Today: collapsed-by-default disclosure showing a chapter list.
|
||||
|
||||
New version:
|
||||
|
||||
- Expanded by default. (Collapsed in this position made sense when
|
||||
the page was sparse; once we're foregrounding chapter awareness
|
||||
everywhere else, hiding the list here is contradictory.)
|
||||
- Currently-playing chapter row gets a left-edge accent and a
|
||||
"▶ playing" badge on the right.
|
||||
- Lightweight sort menu in the section header: "By position" (default),
|
||||
"Longest first" — no UI for "By title" since chapter titles rarely
|
||||
sort meaningfully. Sort preference is in-memory only for v1.
|
||||
- Clicking a chapter row still calls `openPlayer(absoluteStart)` as
|
||||
today; if the player is open, it seeks rather than reopens.
|
||||
|
||||
### Narrator card
|
||||
|
||||
New section between Chapters and the cross-rails. Renders only when
|
||||
`data.narrator` is non-empty.
|
||||
|
||||
```
|
||||
── NARRATOR ──
|
||||
[ avatar 64×64 ] Ray Porter
|
||||
47 audiobooks in your library →
|
||||
```
|
||||
|
||||
Avatar source: if the absorbed audiobook data model exposes a
|
||||
narrator photo, use it; otherwise an initials avatar in a circle.
|
||||
The `→` link goes to a narrator detail page if one exists, or a
|
||||
search-results page filtered by narrator name as a fallback.
|
||||
|
||||
### Cross-book rails
|
||||
|
||||
Three sections, each rendered only when its data is present, ordered
|
||||
top-to-bottom:
|
||||
|
||||
- **"Similar audiobooks"** — embedding-based recommendations. Ranked
|
||||
by vector similarity against this book; ordering and inclusion
|
||||
decided server-side. Renders with a subtitle "Based on listening
|
||||
patterns" to give the rail a small bit of provenance copy that the
|
||||
metadata rails don't need.
|
||||
- **"Also by {author}"** — horizontal scroller of cover tiles, same
|
||||
pattern as `MediaRow` used elsewhere in Silo.
|
||||
- **"In this series"** — same pattern, with "Book n of m" subtitle and
|
||||
the current book non-clickable / highlighted.
|
||||
|
||||
All three rails ship behind feature-detection: if the API response
|
||||
for the book includes the related array, render it; if not, hide the
|
||||
section without showing a placeholder. This avoids blocking v1 on any
|
||||
specific backend rail support — each lights up as the data lands.
|
||||
|
||||
### What's *not* in the new detail page
|
||||
|
||||
- Reviews / ratings — out of scope, not in the data model.
|
||||
- Tabs — page is short enough that one scroll surface beats tabs.
|
||||
- Social / cohost listening — no signal it's wanted.
|
||||
|
||||
## Data & API impact
|
||||
|
||||
Most of the work is pure frontend, but three small backend-adjacent
|
||||
items to call out:
|
||||
|
||||
| Item | Impact |
|
||||
|---|---|
|
||||
| Sleep timer | Frontend-only. No API change. |
|
||||
| Speed menu | Frontend-only — already a client-side audio setting. |
|
||||
| Bookmarks | **Deferred to v1.1.** Needs `audiobook_bookmarks` table (`content_id`, `profile_id`, `position_seconds`, `note`, `created_at`) plus CRUD endpoints. v1 ships with the button hidden and `useAudiobookPlayback` exposing no-op bookmark stubs. |
|
||||
| Narrator card with "n audiobooks in library" count | Needs narrator-aware query. If the detail endpoint already returns it, render; otherwise hide the count line and show only the name. |
|
||||
| "Also by author" / "In this series" rails | Render only when arrays present. v1 doesn't require backend changes — it just adapts to whatever the response contains. |
|
||||
| "Similar audiobooks" rail (embedding-based) | Render only when `similar_audiobooks` array present on the detail response. Backend computes similarity from per-book embeddings server-side and includes the top-N already ranked. v1 frontend has no embedding logic — it just renders whatever ordered list the API returns. |
|
||||
|
||||
## Routing & view-transition wiring
|
||||
|
||||
- `/audiobooks/book/:contentId` continues to be the only audiobook
|
||||
route added by this work. Mini bar and Now Listening are layered
|
||||
inside this route's component tree.
|
||||
- View transition name: `audiobook-cover-{contentId}`. Set on the
|
||||
cover tile inside `CoverExpandTile` and on the large cover element
|
||||
inside `NowListening`. Same `contentId` on both = the browser
|
||||
animates the rect change automatically.
|
||||
- The detail page's hero cover and the mini bar's cover tile share
|
||||
the same view-transition name so the *initial* open (clicking
|
||||
Play/Resume on the hero) also animates the cover into the mini bar
|
||||
position.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All transport controls have `aria-label`s (already true today; keep
|
||||
parity).
|
||||
- The expand chevron and the cover tile are both labeled "Expand
|
||||
player" / "Open Now Listening" — two ways in, both discoverable to
|
||||
screen readers.
|
||||
- `SleepTimerMenu`, `SpeedMenu`, `ChaptersMenu` use the existing
|
||||
`Popover` primitive with proper roving focus + Escape-to-close
|
||||
(inherits from `ChaptersMenu` behavior).
|
||||
- Now Listening overlay traps focus while open and restores focus to
|
||||
the mini bar's expand chevron on close.
|
||||
- Color contrast for the chapter-title text in the mini bar must
|
||||
meet WCAG AA against the bar's background (use `text-foreground`
|
||||
with a `text-muted-foreground` chapter title is the existing
|
||||
pattern and passes).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** `useAudiobookPlayback` — chapter computation from current
|
||||
time across multi-file audiobooks, sleep-timer arm/disarm/fire,
|
||||
progress reporting cadence and pause/seek/end triggers.
|
||||
- **Component:** `MiniBar` and `NowListening` render expected
|
||||
elements given a mocked playback object; expand/collapse calls the
|
||||
right callbacks; speed menu / sleep menu open and emit the right
|
||||
values.
|
||||
- **Integration / Playwright** (if the repo has Playwright wired for
|
||||
these flows): start playback from detail page → mini bar appears
|
||||
with chapter title → click cover tile → Now Listening overlay
|
||||
appears → seek bar works in both modes → collapse returns to mini
|
||||
bar with playback uninterrupted (key assertion: `audio.currentTime`
|
||||
monotonically advances across the mode swap).
|
||||
|
||||
## Out of scope (recap)
|
||||
|
||||
- Bookmarks backend (v1.1 sub-spec)
|
||||
- Cross-book rails backend joins
|
||||
- Car mode full layout
|
||||
- Audiobook library grid redesign
|
||||
- Lifting the player to an app-level provider so it survives
|
||||
navigation away from the detail page
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Multi-file audiobooks.** Today's player only plays `files[0]` —
|
||||
multi-file audiobooks display all chapters but can't actually cross
|
||||
file boundaries. This redesign doesn't fix that; it inherits the
|
||||
limitation. Worth flagging in the implementation plan whether to
|
||||
fold a fix into v1 or leave it for a separate ticket.
|
||||
- **View transition browser support.** CSS view transitions are
|
||||
supported in modern Chromium and Safari Tech Preview; Firefox is
|
||||
not there yet. The mini ↔ Now Listening swap must work without
|
||||
the transition (graceful fallback to a plain unmount/mount).
|
||||
- **Sleep-timer fade.** A 5s gain ramp on `HTMLAudioElement.volume`
|
||||
works but is not as smooth as a Web Audio API gain node. Starting
|
||||
simple; revisit if it feels janky.
|
||||
@@ -1,262 +0,0 @@
|
||||
# ABS Bookmarks — Phase 1 Sub-Project 1
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** First sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
|
||||
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Bookmarks bullet.
|
||||
|
||||
Commands in this document assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let an Audiobookshelf mobile client (official Android, official iOS, and well-behaved third parties such as Plappa) create, edit, and delete in-book bookmarks against a silo audiobook library, and see those bookmarks sync in real time to the user's other connected devices.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- No ebook bookmarks (audiobook only — matches ABS's own surface).
|
||||
- No bulk bookmark import / export, no merge-by-title, no fuzzy time matching.
|
||||
- No `/api/me/bookmarks` aggregate-list endpoint. The Android client only calls per-item endpoints; adding the aggregate would be dead code today.
|
||||
- No client UI work — silo's web admin doesn't ship a bookmarks view yet, and the mobile clients already render bookmarks from these endpoints.
|
||||
- No socket-server overhaul; Phase 1 just publishes events through the existing `Handler.publish` wrapper. Phase 2 covers the full socket event surface.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Three ABS-compatible REST handlers + one socket event family + one migration.
|
||||
|
||||
- **HTTP layer:** new `internal/audiobooks/abs/bookmarks_handler.go` alongside existing `items_handler.go` / `progress.go`. Handler-method pattern mirrors `handleSetItemProgress`. Routes registered under both `/abs/api` and `/api` prefixes inside the existing bearerAuth group.
|
||||
- **Storage layer:** new `BookmarkStore` interface in `internal/audiobooks/abs/bookmarks.go` (split out rather than crowd `progress.go`) plus a concrete `ABSBookmarkStore` Postgres implementation in `internal/audiobooks/abs_bookmark_store.go` — naming mirrors `abs_playback_session_store.go`. Wiring lives in `internal/audiobooks/service.go`, alongside the other store constructions in `BuildABSHandler`.
|
||||
- **Migration:** `migrations/148_abs_bookmarks.up.sql` + `.down.sql`.
|
||||
- **Socket events:** the existing nil-safe `Handler.publish(userID, event, payload)` wrapper. Each handler fires `user_updated` with a `reason` discriminator on success — three lines per handler. No new event publisher code.
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All mounted at both `/abs/api/*` and `/api/*` inside the bearerAuth group.
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| POST | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list |
|
||||
| PATCH | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list (upserts at `time`) |
|
||||
| DELETE | `/me/item/{itemId}/bookmark/{time}` | — | full item bookmark list (idempotent) |
|
||||
|
||||
All three handlers fire `user_updated` on success with the relevant `reason`:
|
||||
|
||||
| Handler | `reason` value |
|
||||
|---|---|
|
||||
| Create | `bookmark_created` |
|
||||
| Update | `bookmark_updated` |
|
||||
| Delete | `bookmark_deleted` |
|
||||
|
||||
Socket event payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "bookmark_created",
|
||||
"bookmark": { "id": "01K…", "libraryItemId": "126887…", "time": 1234.5, "title": "Cliffhanger", "createdAt": 1779786284823, "updatedAt": 1779786284823 }
|
||||
}
|
||||
```
|
||||
|
||||
For `bookmark_deleted`, `bookmark.title` is the pre-delete title; for `bookmark_created` / `bookmark_updated` it is the freshly-persisted row.
|
||||
|
||||
### 4.1 Wire shape
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01KSHR…",
|
||||
"libraryItemId": "126887440911695876",
|
||||
"time": 1234.5,
|
||||
"title": "Good cliffhanger",
|
||||
"createdAt": 1779786284823,
|
||||
"updatedAt": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
- All six fields always present (never `omitempty`).
|
||||
- `time` is fractional seconds (`double`).
|
||||
- `createdAt` / `updatedAt` are JS-epoch milliseconds (`int64`), matching every other ABS handler's timestamp emission.
|
||||
- Empty title serializes as `"title": ""`.
|
||||
|
||||
### 4.2 Why no GET-list endpoint
|
||||
|
||||
The canonical continuum plugin handler, the Android `BookmarksModal.vue`, booklore-ng's per-item route, and the official ABS server all return the full item bookmark list from the mutating endpoints. A separate `GET /me/item/{id}/bookmark` would be dead code.
|
||||
|
||||
## 5. Data model
|
||||
|
||||
### 5.1 Migration 148_abs_bookmarks
|
||||
|
||||
`migrations/148_abs_bookmarks.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE abs_bookmarks (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
library_item_id text NOT NULL,
|
||||
time_seconds double precision NOT NULL,
|
||||
title text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX abs_bookmarks_user_profile_item_time_uniq
|
||||
ON abs_bookmarks (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
library_item_id,
|
||||
time_seconds
|
||||
);
|
||||
|
||||
CREATE INDEX abs_bookmarks_user_item_idx
|
||||
ON abs_bookmarks (user_id, library_item_id);
|
||||
```
|
||||
|
||||
`migrations/148_abs_bookmarks.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP TABLE abs_bookmarks;
|
||||
```
|
||||
|
||||
### 5.2 Schema rationale
|
||||
|
||||
- **`id` text (ULID), not UUID** — matches every other ABS surface ID emitted by silo (`abs_tokens.id`, `abs_playback_sessions.id`). Clients get a consistent shape; ULIDs are also lexicographically sortable by creation time, which simplifies debug output.
|
||||
- **`profile_id` nullable, with COALESCE sentinel in the unique index** — silo's primary-profile convention is `NULL` profile. Postgres treats raw `NULL` as distinct for uniqueness, so the COALESCE-to-fixed-UUID trick collapses NULL to a single bucket per user, which is what makes "one bookmark per timestamp per profile" work.
|
||||
- **Unique on `(user, profile, item, time)`** — encodes the "one bookmark per timestamp" invariant directly, which is what makes `Upsert(...,time,title)` the natural primitive: PATCH at an existing time updates the title; PATCH at a new time creates.
|
||||
- **Secondary index on `(user_id, library_item_id)`** — covers the hot-path `List` query that runs after every mutation. Avoids a sort or full scan when a user has many bookmarks across many items.
|
||||
- **`title` NOT NULL DEFAULT ''** — Android lets users create quick bookmarks without a title; empty string stores cleanly. Avoids null-guard branches in client iteration code.
|
||||
|
||||
### 5.3 Go model
|
||||
|
||||
```go
|
||||
type Bookmark struct {
|
||||
ID string // ULID
|
||||
LibraryItemID string
|
||||
Time float64 // fractional seconds
|
||||
Title string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Handlers convert to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
|
||||
|
||||
## 6. Storage contract
|
||||
|
||||
```go
|
||||
type BookmarkStore interface {
|
||||
// List returns all bookmarks for (user, profile, item) ordered by time
|
||||
// ASC. Empty slice (never nil) when none exist.
|
||||
List(ctx context.Context, userID, profileID, itemID string) ([]Bookmark, error)
|
||||
|
||||
// Upsert inserts a bookmark or updates the title at the exact
|
||||
// (user, profile, item, time) tuple. ID is generated on insert and
|
||||
// preserved on update. Returns the resulting row.
|
||||
Upsert(ctx context.Context, userID, profileID, itemID string, time float64, title string) (Bookmark, error)
|
||||
|
||||
// Delete removes the bookmark at (user, profile, item, time). Returns
|
||||
// nil when no row matched — DELETE is idempotent (a UX convenience,
|
||||
// not a 404 surface).
|
||||
Delete(ctx context.Context, userID, profileID, itemID string, time float64) error
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- `Upsert` uses a single `INSERT ... ON CONFLICT (user_id, COALESCE(profile_id,'…'), library_item_id, time_seconds) DO UPDATE SET title = EXCLUDED.title, updated_at = now() RETURNING *` — one round-trip, no read-then-write race.
|
||||
- `List` sort happens in SQL (`ORDER BY time_seconds ASC`), not in Go.
|
||||
- Profile mapping: handlers pass `a.ProfileID` (string, empty = primary) straight through to the store; the store maps empty → NULL on writes and uses the same COALESCE-to-sentinel-UUID trick on reads. Mirrors the pattern already used by `abs_session_store.go` (`*string` round-trip) — pick whichever pgx approach reads cleanest in the impl, just keep the empty-as-primary convention consistent with the rest of `internal/audiobooks/`.
|
||||
- Time precision: exact float64 equality on PATCH/DELETE-at-time. Plappa and the official client always echo back the exact value they received, so epsilon comparison would be complexity nothing exercises.
|
||||
- `Delete` on a non-existent row returns `nil`. The caller still re-fetches the list and returns it, so double-tapped deletes produce consistent state.
|
||||
|
||||
## 7. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
|
||||
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
|
||||
| `time` missing or NaN | 400 | `time required` |
|
||||
| `itemId` not in `MediaStore` | 404 | `item not found` |
|
||||
| Store upsert / mutate fails | 500 | sanitized `bookmark persist failed`; err logged via `slog.Error` |
|
||||
| Store delete fails (DB error, not missing row) | 500 | sanitized `bookmark delete failed` |
|
||||
| List fetch fails after a successful mutation | 200 + empty `[]` + `slog.Warn` | mutation already committed; returning empty list beats 500 |
|
||||
|
||||
**Cross-cutting:**
|
||||
|
||||
- **No leaked-existence side channel.** DELETE for a bookmark belonging to another user/profile returns 200 with the caller's list (which doesn't contain the target) — indistinguishable from DELETE for a non-existent bookmark. No 403, no enumeration vector.
|
||||
- **Item validation.** Validate `itemId` via `MediaStore.GetAudiobookByID` before touching the store (same pattern as `handleItem`). Catches typos and deleted items early; avoids orphan bookmark rows whose item no longer exists.
|
||||
- **Body size limit.** Request body wrapped in `io.LimitReader(r.Body, 1<<20)` — same 1 MiB cap as `handleStandaloneLogin`.
|
||||
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Unit tests — `bookmarks_handler_test.go`
|
||||
|
||||
In-memory fake `BookmarkStore` (mirrors `memTokenStore` / `fakePlaybackSessionStore` pattern already used in this package).
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `Create_NewBookmark_ReturnsListContainingIt` | POST → 200, response is `[{id, time, title, …}]`, list length 1, fields populated |
|
||||
| `Create_TwoAtDifferentTimes_ListOrderedByTime` | POST × 2 (times 100 and 50), list returned `[{time:50},{time:100}]` |
|
||||
| `Upsert_SameTime_UpdatesTitleNoDuplicate` | POST then PATCH at same time with new title → list length 1, title updated, id preserved |
|
||||
| `Delete_ExistingBookmark_RemovedFromList` | POST then DELETE same time → 200, list empty |
|
||||
| `Delete_NonExistentTime_IdempotentReturnsEmptyList` | DELETE without prior POST → 200, empty list |
|
||||
| `Delete_OtherUserBookmark_NoOpAndNoExistenceLeak` | seed bookmark for user B, user A DELETEs same item+time → 200, user B's bookmark untouched |
|
||||
| `ProfileIsolation_BookmarksScopedPerProfile` | (user, profile A) POST → (user, profile B) List returns empty |
|
||||
| `MissingItem_404` | POST against unknown itemId → 404 |
|
||||
| `InvalidBody_400` | POST with malformed JSON → 400, no DB write |
|
||||
| `MissingTime_400` | POST with body `{title:"x"}` (no time) → 400 |
|
||||
| `SocketEvent_FiredOnCreate` | mock `EventPublisher` captures the publish; assert userID + `user_updated` + `reason:"bookmark_created"` |
|
||||
| `SocketEvent_FiredOnUpdate` | same for `bookmark_updated` |
|
||||
| `SocketEvent_FiredOnDelete` | same for `bookmark_deleted` |
|
||||
|
||||
### 8.2 Wire-shape test — `bookmarks_envelope_test.go`
|
||||
|
||||
One marshal test that takes a `Bookmark` (including the empty-title case) and asserts every required JSON key is present and uses the camelCase spelling: `id`, `libraryItemId`, `time`, `title`, `createdAt`, `updatedAt`. Mirrors the existing `TestSiloItemToMetadata_JSONKeysAlwaysPresent` and `TestLoginEnvelope_HasRequiredKeys` patterns.
|
||||
|
||||
### 8.3 Live integration smoke (post-deploy)
|
||||
|
||||
```
|
||||
TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' -H 'x-return-tokens: true' \
|
||||
-d '{"username":"<u>","password":"<p>"}' http://127.0.0.1:13378/login \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['accessToken'])")
|
||||
|
||||
ITEM=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
||||
http://127.0.0.1:13378/api/libraries/<lid>/items?limit=1 \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['results'][0]['id'])")
|
||||
|
||||
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"smoke","time":42.5}' \
|
||||
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
|
||||
|
||||
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"title":"updated","time":42.5}' \
|
||||
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
|
||||
|
||||
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
|
||||
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark/42.5 | python3 -m json.tool
|
||||
```
|
||||
|
||||
Expected: each call returns the updated list; the final DELETE returns `[]`.
|
||||
|
||||
### 8.4 Explicitly out-of-scope
|
||||
|
||||
- No real-Postgres integration test for the SQL. The `ON CONFLICT` clause and unique-index COALESCE behavior are small enough that the in-memory fake covers the semantics; CI does not currently run a Postgres for the ABS subdomain.
|
||||
- No load test. Bookmark mutations are user-driven and rare (handful per session).
|
||||
|
||||
## 9. Risks & open questions
|
||||
|
||||
- **Time-precision collision risk.** If a future client rounds `time` differently between POST and DELETE (e.g. POST sends `42.500001`, DELETE sends `42.5`), the DELETE would silently no-op. Acceptable for the current client set (Android and official iOS echo exactly), but worth a comment on the `Delete` method so future-us doesn't burn time chasing a ghost.
|
||||
- **No migration rollback test.** The `.down.sql` is one `DROP TABLE` — low risk — but the migration suite doesn't currently exercise rolls. Same posture as recent migrations; not a blocker.
|
||||
- **Bookmark fan-out on socket events.** `Handler.publish(userID, ...)` targets the user room. If the user has many connected sockets, the publish fans out per-socket. Volume here is trivial (manual user action). No backpressure concern at Phase 1 scale.
|
||||
|
||||
## 10. Out-of-scope follow-ups
|
||||
|
||||
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
|
||||
|
||||
- Aggregate `GET /api/me/bookmarks` (caller's full bookmark inventory). Adds value only when a "Bookmarks" tab lands in a client that needs it.
|
||||
- Hydrating `user.bookmarks` in the `/login` envelope. Currently emits `[]` placeholder; populating costs a join on every login and only matters to clients that read from there at startup.
|
||||
- Embedding `bookmarks` on item-detail (`GET /api/items/{id}`). The Android player fetches its bookmarks via the per-item bookmark endpoint on modal-open, not from item-detail.
|
||||
|
||||
## 11. References
|
||||
|
||||
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
|
||||
- Wire-shape reference (canonical, working against real ABS Android): the continuum-plugin-audiobooks `bookmarks_handler.go` + the `r.Post/Patch/Delete` mounts in its `handler.go`. Diff against silo before flagging response-shape concerns.
|
||||
- Client wire usage: `audiobookshelf-app/components/modals/BookmarksModal.vue` (the only place the official client builds bookmark requests).
|
||||
- Booklore-ng has its own `me/bookmarks` aggregate endpoint that the official client does not use; ignored here.
|
||||
@@ -1,523 +0,0 @@
|
||||
# ABS Collections + Playlists — Phase 1 Sub-Project 2
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** Second sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
|
||||
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Manual Collections + Playlists bullets.
|
||||
**Sibling sub-project:** `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md` (sub-project 1; the bookmarks data model, in-memory test fakes, store wiring, and error-handling conventions established there are reused here without re-justification).
|
||||
|
||||
Commands in this document assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Land manual user collections and ordered playlists on the silo audiobook surface so the official Audiobookshelf Android, iOS, and Plappa clients can create, browse, mutate, and share named groupings of audiobooks. "Manual collection" means a user-curated unordered set of audiobooks with a name + description (think: "Favorites", "To Read"). "Playlist" means an ordered queue with a cover image (think: "Wind-down listening"). Both are owned by a profile, optionally public to other users on the same silo instance.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- **No podcast episode hydration.** Playlist items accept and echo `episodeId`, but only audiobook items are looked up in `MediaStore`. A future sub-project will wire `internal/audiobooks/podcastfeed` hydration.
|
||||
- **No reorder API.** Clients simulate reorder via remove+add (the re-added item lands at the end).
|
||||
- **No smart collections.** Those are sub-project 3 (separate `query_def` storage + DSL evaluator).
|
||||
- **No silo-web UI.** The web admin doesn't ship a collections/playlists view yet; ABS mobile clients render these.
|
||||
- **No socket-server overhaul.** Playlists publish three existing-pattern events; collections publish none. Full socket parity is Phase 2 of the parent spec.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Two REST surfaces sharing a uniform shape, mounted under `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group.
|
||||
|
||||
- **HTTP layer:**
|
||||
- `internal/audiobooks/abs/collections_handler.go` — 7 routes (list, create, get, update, delete, add item, remove item).
|
||||
- `internal/audiobooks/abs/playlists_handler.go` — 9 routes (list, create, get, update, delete, add single item, batch add, batch remove, remove single item; remove-episode variant uses two URL params).
|
||||
- **Storage layer:**
|
||||
- New interfaces in `internal/audiobooks/abs/collections.go` and `playlists.go` (one file each, parallel to `bookmarks.go`):
|
||||
- `CollectionStore` — `ListUserCollections / GetCollection / CreateCollection / UpdateCollection / DeleteCollection / ListCollectionItems / AddCollectionItem / RemoveCollectionItem`.
|
||||
- `PlaylistStore` — `ListUserPlaylists / GetPlaylist / CreatePlaylist / UpdatePlaylist / DeletePlaylist / ListPlaylistItems / AddPlaylistItem / RemovePlaylistItem`.
|
||||
- Concrete pgx impls in `internal/audiobooks/abs_collection_store.go` and `abs_playlist_store.go` (parallel to `abs_bookmark_store.go`).
|
||||
- **Migrations** (paired up/down per `CLAUDE.md`):
|
||||
- `149_abs_user_collections`, `150_abs_collection_items`, `151_abs_playlists`, `152_abs_playlist_items`.
|
||||
- **Socket events:** Playlists publish `playlist_added` / `playlist_updated` / `playlist_removed` (continuum-canonical event names). Collections publish nothing. All publishes go through the existing nil-safe `Handler.publish(...)` wrapper.
|
||||
- **Service wiring:** Both new stores wired in `BuildABSHandler` (mirrors the BookmarkStore wiring landed in sub-project 1).
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All routes mounted under both `/abs/api/*` and `/api/*` inside `bearerAuth`. Success status code is **200 OK** in every row of the tables below, except where the `Returns` column explicitly says **204 No Content** (DELETE collection / DELETE playlist themselves; the item-mutation DELETEs return 200 with the parent's full-shape body, matching continuum).
|
||||
|
||||
### 4.1 Collections (7 routes)
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/collections` | — | `{"collections": [Collection list-shape]}` |
|
||||
| POST | `/collections` | `{name, description, isPublic?}` | Collection full-shape |
|
||||
| GET | `/collections/{id}` | — | Collection full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
|
||||
| PATCH | `/collections/{id}` | `{name?, description?, isPublic?}` | Collection full-shape |
|
||||
| DELETE | `/collections/{id}` | — | 204 No Content |
|
||||
| POST | `/collections/{id}/book/{bookId}` | — | Collection full-shape (with updated `books[]`) |
|
||||
| DELETE | `/collections/{id}/book/{bookId}` | — | Collection full-shape |
|
||||
|
||||
### 4.2 Playlists (9 routes)
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/playlists` | — | `{"playlists": [Playlist list-shape]}` |
|
||||
| POST | `/playlists` | `{name, description, cover_item?, isPublic?}` | Playlist full-shape |
|
||||
| GET | `/playlists/{id}` | — | Playlist full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
|
||||
| PATCH | `/playlists/{id}` | `{name?, description?, cover_item?, isPublic?}` | Playlist full-shape |
|
||||
| DELETE | `/playlists/{id}` | — | 204 No Content |
|
||||
| POST | `/playlists/{id}/item` | `{libraryItemId, episodeId?}` | Playlist full-shape |
|
||||
| POST | `/playlists/{id}/batch/add` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
|
||||
| POST | `/playlists/{id}/batch/remove` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
|
||||
| DELETE | `/playlists/{id}/item/{libraryItemId}` | — | Playlist full-shape |
|
||||
| DELETE | `/playlists/{id}/item/{libraryItemId}/{episodeId}` | — | Playlist full-shape |
|
||||
|
||||
### 4.3 Wire shape — Collection
|
||||
|
||||
**List shape (no `books[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "Favorites",
|
||||
"description": "My top picks",
|
||||
"isPublic": false,
|
||||
"lastUpdate": 1779786284823,
|
||||
"createdAt": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
**Full shape (with `books[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "Favorites",
|
||||
"description": "My top picks",
|
||||
"isPublic": false,
|
||||
"lastUpdate": 1779786284823,
|
||||
"createdAt": 1779786284823,
|
||||
"books": [
|
||||
{
|
||||
"id": "126887...",
|
||||
"libraryId": "9",
|
||||
"media": { "metadata": { "title": "Book Title", "authors": [...] } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All seven top-level keys always present (`description` is `""` when unset, never omitted — fixes the continuum-reference bug where description was always emitted as empty regardless of stored value). `books[]` is always an array (possibly empty) on the full shape; omitted on the list shape.
|
||||
|
||||
### 4.4 Wire shape — Playlist
|
||||
|
||||
**List shape (no `items[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "My Queue",
|
||||
"description": "",
|
||||
"isPublic": false,
|
||||
"coverPath": "126887...",
|
||||
"createdAt": 1779786284823,
|
||||
"lastUpdate": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
**Full shape (with `items[]`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXXX",
|
||||
"userId": "1",
|
||||
"name": "My Queue",
|
||||
"description": "",
|
||||
"isPublic": false,
|
||||
"coverPath": "126887...",
|
||||
"createdAt": 1779786284823,
|
||||
"lastUpdate": 1779786284823,
|
||||
"items": [
|
||||
{ "libraryItemId": "126887...", "position": 0, "title": "Book Title", "libraryId": "9" },
|
||||
{ "libraryItemId": "podcast-x", "episodeId": "ep-1", "position": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`coverPath` is emitted when set; omitted when unset. `description` always present. Items always sorted by `position` ASC. Entries with `episodeId` set are emitted with the field; audiobook entries omit it. Audiobook items hydrate `title` + `libraryId` via `MediaStore.GetAudiobookByID`; episode items emit the bare reference.
|
||||
|
||||
### 4.5 Hydration semantics
|
||||
|
||||
- `MediaStore.GetAudiobookByID(libraryItemId)` is called per book on the **full** shape only (the list shape skips it to keep response sizes small).
|
||||
- On miss (item deleted between collection/playlist mutation and list-fetch), the entry degrades to `{id, libraryId}` only — clients render a placeholder.
|
||||
- `libraryId` resolution: same `resolveDefaultLibrary` helper from `items_handler.go` (returns numeric library ID as decimal string).
|
||||
|
||||
### 4.6 Socket events
|
||||
|
||||
| Event | When | Payload |
|
||||
|---|---|---|
|
||||
| `playlist_added` | After POST `/playlists` | `{id, name}` |
|
||||
| `playlist_updated` | After PATCH or any item mutation (single or batch) | `{id}` |
|
||||
| `playlist_removed` | After DELETE `/playlists/{id}` | `{id}` |
|
||||
|
||||
Collections fire no socket events (matches continuum; Phase 2 of the parent spec covers full event parity).
|
||||
|
||||
## 5. Data model
|
||||
|
||||
### 5.1 Migration 149 — `abs_user_collections`
|
||||
|
||||
`migrations/149_abs_user_collections.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_user_collections (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_user_collections_user_profile_idx
|
||||
ON public.abs_user_collections (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/149_abs_user_collections.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_user_collections_user_profile_idx;
|
||||
DROP TABLE IF EXISTS public.abs_user_collections;
|
||||
```
|
||||
|
||||
### 5.2 Migration 150 — `abs_collection_items`
|
||||
|
||||
`migrations/150_abs_collection_items.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_collection_items (
|
||||
collection_id text NOT NULL REFERENCES public.abs_user_collections(id) ON DELETE CASCADE,
|
||||
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (collection_id, library_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_collection_items_library_item_idx
|
||||
ON public.abs_collection_items (library_item_id);
|
||||
```
|
||||
|
||||
`migrations/150_abs_collection_items.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_collection_items_library_item_idx;
|
||||
DROP TABLE IF EXISTS public.abs_collection_items;
|
||||
```
|
||||
|
||||
### 5.3 Migration 151 — `abs_playlists`
|
||||
|
||||
`migrations/151_abs_playlists.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_playlists (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
cover_item text REFERENCES public.media_items(content_id) ON DELETE SET NULL,
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_playlists_user_profile_idx
|
||||
ON public.abs_playlists (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/151_abs_playlists.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_playlists_user_profile_idx;
|
||||
DROP TABLE IF EXISTS public.abs_playlists;
|
||||
```
|
||||
|
||||
### 5.4 Migration 152 — `abs_playlist_items`
|
||||
|
||||
`migrations/152_abs_playlist_items.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_playlist_items (
|
||||
playlist_id text NOT NULL REFERENCES public.abs_playlists(id) ON DELETE CASCADE,
|
||||
library_item_id text NOT NULL,
|
||||
episode_id text NOT NULL DEFAULT '',
|
||||
position integer NOT NULL,
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (playlist_id, library_item_id, episode_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_playlist_items_playlist_position_idx
|
||||
ON public.abs_playlist_items (playlist_id, position);
|
||||
```
|
||||
|
||||
`migrations/152_abs_playlist_items.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_playlist_items_playlist_position_idx;
|
||||
DROP TABLE IF EXISTS public.abs_playlist_items;
|
||||
```
|
||||
|
||||
### 5.5 Schema rationale
|
||||
|
||||
- **`is_public boolean`** — cross-user-public read semantics. `false` default. List endpoints never expose other users' rows; GET-by-id allows non-owner reads only when `is_public = true`.
|
||||
- **Collection items: FK + CASCADE on `library_item_id`** — when a book is deleted from the library, drop it from all collections (clean orphan-free state). Same pattern as migration 143's `abs_playback_sessions.content_id`.
|
||||
- **Collection items: composite PK on `(collection_id, library_item_id)`** — enforces "a book appears at most once in a collection" without a synthetic ID.
|
||||
- **Playlist items: `library_item_id` NOT FK'd** — collections enforce one-shot membership; playlists are ordered queues that may legitimately reference items the user hasn't bookmarked. Decoupling lets a future migration FK it when episode support lands properly. Today: handler validates via MediaStore (same pattern as bookmarks).
|
||||
- **Playlist items: `episode_id text NOT NULL DEFAULT ''`** — empty string for audiobook items, populated for podcast episodes. Empty-string-default lets `(playlist_id, library_item_id, episode_id)` be a clean unique key without COALESCE.
|
||||
- **Playlist items: no synthetic ID** — `(playlist_id, library_item_id, episode_id)` is naturally unique; `position` is sort hint (gaps allowed when items are removed).
|
||||
- **`cover_item REFERENCES media_items(content_id) ON DELETE SET NULL`** — playlist survives cover-item deletion; cover gracefully becomes nothing.
|
||||
|
||||
### 5.6 Go models
|
||||
|
||||
```go
|
||||
type Collection struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
Name string
|
||||
Description string
|
||||
IsPublic bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type CollectionItem struct {
|
||||
CollectionID string
|
||||
LibraryItemID string
|
||||
AddedAt time.Time
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
Name string
|
||||
Description string
|
||||
CoverItem string // empty when unset
|
||||
IsPublic bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistItem struct {
|
||||
PlaylistID string
|
||||
LibraryItemID string
|
||||
EpisodeID string // empty for audiobook items
|
||||
Position int
|
||||
AddedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Handlers convert these to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
|
||||
|
||||
## 6. Storage contract
|
||||
|
||||
### 6.1 `CollectionStore` (in `internal/audiobooks/abs/collections.go`)
|
||||
|
||||
```go
|
||||
type CollectionStore interface {
|
||||
ListUserCollections(ctx context.Context, userID, profileID string) ([]Collection, error)
|
||||
GetCollection(ctx context.Context, id string) (Collection, error)
|
||||
CreateCollection(ctx context.Context, c Collection) error
|
||||
UpdateCollection(ctx context.Context, c Collection) error
|
||||
DeleteCollection(ctx context.Context, id string) error
|
||||
ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error)
|
||||
AddCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
|
||||
RemoveCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 `PlaylistStore` (in `internal/audiobooks/abs/playlists.go`)
|
||||
|
||||
```go
|
||||
type PlaylistStore interface {
|
||||
ListUserPlaylists(ctx context.Context, userID, profileID string) ([]Playlist, error)
|
||||
GetPlaylist(ctx context.Context, id string) (Playlist, error)
|
||||
CreatePlaylist(ctx context.Context, p Playlist) error
|
||||
UpdatePlaylist(ctx context.Context, p Playlist) error
|
||||
DeletePlaylist(ctx context.Context, id string) error
|
||||
ListPlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error)
|
||||
AddPlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
|
||||
RemovePlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Behavior
|
||||
|
||||
- **List ordering:** Collections list ordered by `created_at DESC`. Playlists list ordered by `created_at DESC`. Playlist items ordered by `position ASC`. Collection items ordered by `added_at ASC`. All sorting happens in SQL, not in Go.
|
||||
- **Empty results:** All List methods return an empty slice (never nil) when no rows match.
|
||||
- **`GetCollection` / `GetPlaylist`:** Return `ErrNotFound` when absent. NO owner check — caller authorizes via the response's `UserID` and `IsPublic`.
|
||||
- **`AddPlaylistItem` position assignment:** Single SQL statement computing `position = COALESCE(MAX(position), 0) + 1 WHERE playlist_id = $1` inside the INSERT. No read-before-write race.
|
||||
- **`AddCollectionItem` / `AddPlaylistItem` idempotency:** `INSERT ... ON CONFLICT (...) DO NOTHING`. Re-adding an existing tuple is a silent no-op (no error, no row mutation, no `updated_at` bump beyond what the handler does separately).
|
||||
- **`RemoveCollectionItem` / `RemovePlaylistItem` idempotency:** Return `nil` on no-match.
|
||||
- **Profile mapping:** Handlers pass `a.ProfileID` straight through (empty = primary). Store uses `profileArg` helper (returns `nil` for empty, the string otherwise). `COALESCE(profile_id, '00000000-...'::uuid)` in WHERE clauses on both column and bind value, mirroring the BookmarkStore convention.
|
||||
- **`updated_at` bump on item mutations:** Both `Add*Item` and `Remove*Item` execute the item mutation and an `UPDATE abs_user_collections SET updated_at = now() WHERE id = $1` (or `abs_playlists`) in the same transaction. Wire field `lastUpdate` reflects this.
|
||||
- **Cascade cleanup:** Deleting a collection drops all its `abs_collection_items` via FK CASCADE. Deleting a playlist drops all its `abs_playlist_items`. Deleting a `media_items` row drops it from all `abs_collection_items` (via FK CASCADE); playlists surface stale references on the next List, where the handler degrades to bare-id entries.
|
||||
|
||||
### 6.4 Cross-user public read
|
||||
|
||||
Handler pattern after `Get*`:
|
||||
|
||||
```go
|
||||
c, err := store.GetCollection(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) || (c.UserID != a.UserID && !c.IsPublic) {
|
||||
http.Error(w, "collection not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern for playlists. The condition collapses unknown-id and not-authorized into one branch so existence-leak vectors don't open up.
|
||||
|
||||
## 7. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
|
||||
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
|
||||
| Required field missing (POST: `name`; POST item: `libraryItemId`) | 400 | `<field> required` |
|
||||
| Unknown collection/playlist on GET (owner or not) | 404 | `collection not found` / `playlist not found` |
|
||||
| Non-owner GET on private collection/playlist | 404 | same body — no existence leak |
|
||||
| Non-owner PATCH/DELETE/item-mutation | 404 | same body |
|
||||
| `library_item_id` not in MediaStore (audiobook add-item) | 404 | `item not found` |
|
||||
| Store insert/update/delete fails | 500 | sanitized `<collection\|playlist> persist failed`; err logged via `slog.Error` |
|
||||
| List fetch fails after a successful mutation | 200 + best-effort response (in-memory state) + `slog.Warn` | mutation already committed; falling back beats 500 |
|
||||
|
||||
### 7.1 Cross-cutting
|
||||
|
||||
- **Anti-enumeration.** All non-owner-or-private paths funnel to the same `<type> not found` 404 (never 403, never "private collection"). Indistinguishable from real not-found.
|
||||
- **Item validation on add.** `handleAddCollectionBook` and `handleAddPlaylistItem` call `MediaStore.GetAudiobookByID(libraryItemID)` before touching the store. Catches typos and orphan refs early. **Exception:** `handleAddPlaylistItem` with non-empty `episodeId` SKIPS item validation (audiobook-only hydration scope; podcast episodes are stored opaque-id-style).
|
||||
- **Body size limit.** All POST/PATCH wrap `r.Body` in `io.LimitReader(r.Body, 1<<20)` before `json.NewDecoder`. Same as bookmarks.
|
||||
- **Batch endpoints.** `POST /playlists/{id}/batch/add` and `/batch/remove` tolerate per-item failures silently (matching continuum's `_ = h.store.Add...`). Total failure (e.g., body decode error) is still a 400. Item validation on the batch path: each item validated individually; failed validations skipped with `slog.Debug` (no 404 — the operation as a whole succeeds with the remaining items).
|
||||
- **Socket event on batch mutation.** A batch add/remove fires exactly one `playlist_updated` event, regardless of how many items succeeded or failed. Clients re-render from the response.
|
||||
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Unit tests — `collections_handler_test.go`
|
||||
|
||||
In-memory fake `memCollectionStore` (parallel to `memBookmarkStore`). Reuses `stubMediaStore` and `recordingPublisher` from `bookmarks_handler_test.go`. The `dispatchBookmark` helper is generalized in-place (or a sibling `dispatchABS` is added) so the same wiring (URL params + `ctxAuth` injection) drives both new surfaces.
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `Collection_Create_ReturnsFullShape` | POST `{name:"x"}` → 200, response carries all 7 top-level keys + empty `books[]`, ID is a ULID |
|
||||
| `Collection_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
|
||||
| `Collection_List_ReturnsWrappedEnvelope` | GET → 200, body is `{"collections":[…]}`, list-shape omits `books` |
|
||||
| `Collection_List_DoesNotLeakOtherUsers` | User 1 creates; User 2 GETs → empty list |
|
||||
| `Collection_Get_Owner_ReturnsFullShape` | POST → owner GET → 200 + `books[]` |
|
||||
| `Collection_Get_NonOwner_Public_OK` | User 1 creates with `isPublic:true`; User 2 GET → 200 |
|
||||
| `Collection_Get_NonOwner_Private_404` | User 1 creates (default private); User 2 GET → 404 |
|
||||
| `Collection_Patch_OwnerUpdatesNameAndDescription` | POST → PATCH `{name:"y", description:"d"}` → 200, fields updated, `lastUpdate` advanced |
|
||||
| `Collection_Patch_NonOwner_404` | User 2 PATCH on User 1's collection → 404 (no existence leak) |
|
||||
| `Collection_Delete_OwnerRemovesItAndItems` | POST + add book → DELETE → 204; subsequent GET → 404; items table empty |
|
||||
| `Collection_Delete_NonOwner_404` | User 2 DELETE → 404, User 1's collection still present |
|
||||
| `Collection_AddBook_Owner_HydratesInResponse` | POST + add book → 200 + `books[]` contains the entry with `media.metadata.title` |
|
||||
| `Collection_AddBook_Idempotent` | Add same book twice → 200 both times, `books[]` length stays at 1 |
|
||||
| `Collection_AddBook_UnknownItem_404` | Add a non-existent item → 404 `item not found` |
|
||||
| `Collection_RemoveBook_Idempotent` | Remove book not in collection → 200, `books[]` unchanged |
|
||||
| `Collection_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
|
||||
| `Collection_Envelope_HasRequiredKeys` | Marshal-test: 7 top-level keys present even when description and books are empty |
|
||||
|
||||
### 8.2 Unit tests — `playlists_handler_test.go`
|
||||
|
||||
In-memory fake `memPlaylistStore`.
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `Playlist_Create_ReturnsFullShape` | POST `{name:"x", description:"d", isPublic:true}` → 200, all 8 keys present, ID is ULID |
|
||||
| `Playlist_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
|
||||
| `Playlist_Create_FiresPlaylistAddedEvent` | POST → `recordingPublisher` captures `playlist_added` with `{id, name}` |
|
||||
| `Playlist_List_WrappedEnvelope` | GET → 200, body `{"playlists":[…]}`, list-shape omits `items` |
|
||||
| `Playlist_AddItem_AppendsAtNextPosition` | POST + add 3 items → response `items[]` positions are 1,2,3 in insertion order |
|
||||
| `Playlist_AddItem_Idempotent` | Add same `(libraryItemId, episodeId)` twice → list length 1 |
|
||||
| `Playlist_AddItem_AudiobookHydrates` | Add audiobook item → response item has `title` |
|
||||
| `Playlist_AddItem_Episode_AcceptsAndEchoes` | Add `{libraryItemId, episodeId:"ep-1"}` → response item has `episodeId`, no `title` (un-hydrated by design) |
|
||||
| `Playlist_AddItem_UnknownAudiobook_404` | Add non-existent audiobook → 404; episode adds skip validation, so episode-only adds succeed |
|
||||
| `Playlist_BatchAdd_TolerantOfPartialFailures` | Batch of `[valid, invalid, valid]` → 200, response has 2 items, no 404 |
|
||||
| `Playlist_BatchAdd_FiresOneUpdatedEvent` | Batch add → single `playlist_updated` event (not one per item) |
|
||||
| `Playlist_BatchRemove` | Seed 3 items, batch-remove 2 → response items[] length 1 |
|
||||
| `Playlist_RemoveItem_Single` | DELETE /item/{libraryItemId} → 200, item gone |
|
||||
| `Playlist_RemoveItem_WithEpisode` | DELETE /item/{libraryItemId}/{episodeId} → 200, episode-keyed item removed; audiobook-keyed item with same libraryItemId still present |
|
||||
| `Playlist_Patch_UpdatesCover` | PATCH `{cover_item:"id"}` → 200, `coverPath` updated; `playlist_updated` event fires |
|
||||
| `Playlist_Delete_FiresPlaylistRemovedEvent` | DELETE → 204 + `playlist_removed` event |
|
||||
| `Playlist_Get_NonOwner_Public_OK` | User 1 creates `isPublic:true`; User 2 GET → 200 |
|
||||
| `Playlist_Get_NonOwner_Private_404` | Private playlist; non-owner GET → 404 |
|
||||
| `Playlist_NonOwner_Mutation_404` | User 2 add-item / remove-item / PATCH / DELETE → 404 each, User 1's playlist intact |
|
||||
| `Playlist_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
|
||||
| `Playlist_Envelope_HasRequiredKeys` | Marshal-test: 8 top-level keys present; `coverPath` omitted when empty |
|
||||
|
||||
### 8.3 Shared envelope tests
|
||||
|
||||
One marshal test per surface (`collections_envelope_test.go`, `playlists_envelope_test.go`) asserting the wire shape end-to-end. Parallel to `bookmarks_envelope_test.go`.
|
||||
|
||||
### 8.4 Live integration smoke (post-deploy, operator)
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl ... /login | jq -r .accessToken)
|
||||
ITEM=$(curl ... /api/libraries/9/items?limit=1 | jq -r .results[0].id)
|
||||
|
||||
# Collections
|
||||
COLL=$(curl -X POST -d '{"name":"smoke"}' ... /api/collections | jq -r .id)
|
||||
curl -X POST ... /api/collections/$COLL/book/$ITEM
|
||||
curl ... /api/collections/$COLL # books[] contains $ITEM
|
||||
curl -X DELETE ... /api/collections/$COLL/book/$ITEM
|
||||
curl -X DELETE ... /api/collections/$COLL # 204
|
||||
|
||||
# Playlists
|
||||
PL=$(curl -X POST -d '{"name":"queue"}' ... /api/playlists | jq -r .id)
|
||||
curl -X POST -d "{\"libraryItemId\":\"$ITEM\"}" ... /api/playlists/$PL/item
|
||||
curl ... /api/playlists/$PL # items[] contains $ITEM at position 1
|
||||
curl -X DELETE ... /api/playlists/$PL/item/$ITEM
|
||||
curl -X DELETE ... /api/playlists/$PL # 204
|
||||
```
|
||||
|
||||
Expected: each call returns the wire shape from §4; `playlist_added` / `_updated` / `_removed` events appear on the user's Socket.io channel.
|
||||
|
||||
### 8.5 Explicitly out of scope
|
||||
|
||||
- **No real-Postgres SQL test.** Same posture as bookmarks (§8.4 of that spec): in-memory fakes cover the semantics; CI does not run Postgres for the ABS subdomain.
|
||||
- **No episode hydration.** Episode IDs accepted and echoed but not resolved to podcast metadata. A future podcast-playlist sub-project will plug in `podcastfeed` hydration.
|
||||
- **No reorder API.** Future follow-up.
|
||||
- **No two-user smoke.** Cross-user-public visibility is unit-tested but the operator smoke uses one user.
|
||||
|
||||
## 9. Risks & open questions
|
||||
|
||||
- **Position gaps after remove.** Removing the middle item from a playlist leaves a `position` gap (1, 3, 4 instead of 1, 2, 3). Clients sort by position; gaps are harmless. If a future reorder API lands, it'll compact positions in a single statement.
|
||||
- **Cross-user public + profile scoping interaction.** A public collection owned by `(user A, profile primary)` is visible to user B regardless of B's active profile. The collection's `userId` field discloses A's user identity. Acceptable for v1; if A wanted to hide who owns the collection, that's a separate feature.
|
||||
- **No migration rollback test.** Down migrations are `DROP INDEX + DROP TABLE` — low risk, same posture as bookmarks. Not a blocker.
|
||||
- **`cover_item` references a `media_items.content_id` but the playlist owner may not have access to that library** — silo's library visibility isn't enforced at the schema level. The cover image hydrator (when added) will need to check visibility. For now, the field round-trips opaquely.
|
||||
- **Concurrent `AddPlaylistItem` position collision.** Two parallel appends compute `MAX(position)+1` concurrently and may both produce the same value. The UNIQUE constraint on `(playlist_id, library_item_id, episode_id)` prevents collisions for the SAME item, but distinct items could land at the same position. Acceptable — clients tolerate equal positions and break ties by insertion order. A SERIALIZABLE transaction would close the race; not warranted for a low-traffic UX surface.
|
||||
|
||||
## 10. Out-of-scope follow-ups
|
||||
|
||||
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
|
||||
|
||||
- **Smart collections** — sub-project 3 (`abs_smart_collections` + `query_def` JSONB + DSL evaluator).
|
||||
- **RSS feeds** — sub-project 4 (`abs_rss_feeds`).
|
||||
- **Listening stats** — sub-project 4 (aggregations on `abs_playback_sessions`).
|
||||
- **Author / series detail endpoints** — small sub-project, separate.
|
||||
- **Continue-listening toggles** — small sub-project, separate.
|
||||
- **Reorder API for playlists** — compaction + drag-reorder UX. Probably warrants its own design pass.
|
||||
- **Cover-image hydration** — currently the wire emits `coverPath: <content_id>` opaquely. A future enhancement resolves it through `DetailService.PresignURL` to a fully-qualified cover URL.
|
||||
- **Episode hydration in playlists** — when the podcast-playlist sub-project lands.
|
||||
- **Collection socket events** — Phase 2 of the parent spec.
|
||||
|
||||
## 11. References
|
||||
|
||||
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
|
||||
- Sibling sub-project spec: `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md`.
|
||||
- Wire-shape reference: `continuum-plugin-audiobooks/internal/abs/collections_handler.go` and `playlists_handler.go` (canonical, diff against silo's adaptation before flagging response-shape concerns).
|
||||
- Client wire usage: `audiobookshelf-app/components/...` Collections and Playlists modal/page components.
|
||||
@@ -1,384 +0,0 @@
|
||||
# ABS Implementation Fix — Design
|
||||
|
||||
**Status:** Approved (brainstorming complete, awaiting writing-plans handoff)
|
||||
**Date:** 2026-05-26
|
||||
**Scope:** Bring silo-server's Audiobookshelf-compatible API to full parity with the canonical `continuum-plugin-audiobooks` implementation so that official ABS iOS, Android, and 3rd-party (Plappa, AudioBookShelfFully) clients work end-to-end against silo.
|
||||
**Out of scope:** silo's native audiobook surface (`/api/v1/audiobooks/*`), silo-android/silo-apple/silo-plugin-sdk repos, Continuum's plugin-host RPC layer, the "standalone listener" port (silo has no separate process), cover transcoding pipelines, transcribed-audio search.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
silo's ABS-compat layer at `internal/audiobooks/abs/` exposes ~20% of the surface that the canonical Continuum plugin implements (`continuum-plugin-audiobooks` reference). Real ABS mobile clients cannot complete the basic flow today — login is reported broken, and even when it succeeds many subsequent calls (filterdata, resume position, author/series IDs) return data shapes that break the client. Bookmarks, collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats are entirely absent. Socket.io publishes only 3 of the ~30 events real clients subscribe to.
|
||||
|
||||
The user's directive: every feature of ABS clients must function. Source of truth for behavior is the Continuum plugin; the booklore-ng reference docs (`BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`, `src/lib/socket/events.ts`) document specific response-shape bugs and the canonical socket event list.
|
||||
|
||||
## 2. Goals & Non-goals
|
||||
|
||||
### Goals
|
||||
- Official ABS iOS app, ABS Android app, and 3rd-party clients (Plappa, AudioBookShelfFully) work end-to-end: add server → login → browse libraries → play → progress sync → bookmark → collection → playlist.
|
||||
- Token lifecycle is complete: login, refresh, logout, revocation, multi-device tracking.
|
||||
- Socket.io publishes the full event surface real clients subscribe to.
|
||||
- New data lives in ABS-scoped tables that don't entangle silo's existing collection system.
|
||||
|
||||
### Non-goals
|
||||
- silo's native audiobook UI changes.
|
||||
- silo-android / silo-apple client changes.
|
||||
- Importing real audiobook content via ABS-protocol requests (silo has its own `internal/requests/` system that is not being bridged here).
|
||||
- Watch-together, podcast download queues, server-side backup event streams.
|
||||
|
||||
## 3. Architecture & Strategy
|
||||
|
||||
**Source of truth:** `continuum-plugin-audiobooks` (canonical). Port file-by-file, adapt to silo's data model (catalog repos, `auth.Service`, profile model).
|
||||
|
||||
**Topology:** silo is monolithic; the ABS layer reads/writes the local DB directly. The Continuum plugin's `HostClient → backend plugin RPC` layer has no analog and is dropped.
|
||||
|
||||
**Package layout:** keep silo's existing `internal/audiobooks/{abs,abssocket,podcastfeed}` — it already mirrors the Continuum plugin's structure.
|
||||
|
||||
**Listener:** continue using the dedicated `:13378` ABS-compat HTTP server (configured in `internal/config/db_loader.go` as `audiobookshelf_compat.listen`). Routes mounted on a fresh chi router; no SPA fallback collisions.
|
||||
|
||||
**Data isolation:** new ABS surface uses dedicated tables (`abs_bookmarks`, `abs_user_collections`, `abs_collection_items`, `abs_playlists`, `abs_playlist_items`, `abs_smart_collections`, `abs_rss_feeds`). silo's existing `library_collections` / `user_personal_collections` / `user_smart_collections` / collection groups are untouched. Future bridging is possible but out of scope here.
|
||||
|
||||
## 4. Phasing
|
||||
|
||||
Four phases, each independently mergeable and independently verifiable against a real mobile client.
|
||||
|
||||
### Phase 0 — Login + critical bug fixes
|
||||
|
||||
**Goal:** iOS/Android app can `add server → login → browse library → tap book → play` end-to-end. No bookmarks/collections/etc. yet.
|
||||
|
||||
**Concrete changes:**
|
||||
|
||||
| File:line | Change |
|
||||
|---|---|
|
||||
| `internal/audiobooks/abs/login.go:195-234` | Add to login envelope: `user.itemTagsAccessible: []`, `user.itemTagsSelected: []`, `user.lastSeen: <epoch ms>`, `user.createdAt: <epoch ms>`. Enrich `serverSettings` with `coverAspectRatio`, `dateFormat`, `timeFormat`, `storeCoverWithItem`, `scannerDisableWatcher`, `metadataFileFormat`, `chromecastEnabled`. Mirror Continuum's `completeLogin` (`continuum-plugin-audiobooks/internal/abs/handler.go:591-697`) verbatim. |
|
||||
| `internal/audiobooks/abs/login.go:274-315` (`handleABSAuthorize`) | Return the identical envelope as `/login`, including `accessToken` and `refreshToken`. Currently omits them, which breaks resume-on-launch. |
|
||||
| `internal/audiobooks/abs/handler.go:353-391` (bearerAuth) | Audit JTI lookup against `abs_session_store.go:GetTokenByJTI` to confirm the freshly-minted JTI is found. Add `slog.Debug` lines at each rejection branch so the next 401 is traceable. Confirm `?token=` query-param fallback parses correctly (iOS AVPlayer requirement). |
|
||||
| `internal/audiobooks/abs/libraries_handler.go:499-549` (`siloItemToMetadata`) | Include `id` on every `authors[]` entry (use `item_people.id` UUID). Include `id` on every `series[]` entry (slugified name until a series table lands). Populate `genres` and `tags` from real catalog data instead of empty arrays. Cross-reference `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md` lines 9-100. |
|
||||
| `internal/audiobooks/abs/play_response.go:119` | Replace hardcoded `currentTime: 0` with `ProgressStore.GetItemProgress(userID, profileID, libraryItemID)` lookup; emit the persisted `currentTime` and `progress` fields. |
|
||||
| `internal/audiobooks/abs/libraries_handler.go:56-66` | When `?include=filterdata`, hydrate `authors`, `series`, `narrators`, `genres`, `languages`, `tags` aggregations so iOS filter UI populates. |
|
||||
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /auth/refresh`. Validate refresh token type, check JTI not revoked, mint new access+refresh pair, persist new JTIs. Port from `continuum-plugin-audiobooks/internal/abs/handler.go:handleRefresh`. |
|
||||
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /logout`. Marks caller's JTIs revoked in `abs_sessions`. Port from Continuum's `handleLogout`. |
|
||||
| `internal/audiobooks/abs/handler.go:225` (`mountRoutes`) | Mount the new `/auth/refresh` and `/logout` routes (the latter inside `bearerAuth`). |
|
||||
|
||||
**Deliverable:** silo build where official ABS iOS app smoke test passes the core flow.
|
||||
|
||||
**Size:** ~600-800 lines across ~6 files + 2 new endpoints.
|
||||
|
||||
### Phase 1 — Feature surface completion
|
||||
|
||||
**Goal:** Bookmarks, manual collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats functional in mobile clients.
|
||||
|
||||
**Endpoints to add** (all under `bearerAuth` unless noted; mounted at both `/abs/api/*` and `/api/*`):
|
||||
|
||||
**Bookmarks** (port `continuum-plugin-audiobooks/internal/abs/bookmarks_handler.go`)
|
||||
- `POST /api/me/item/{itemId}/bookmark` — body `{title, time}` → array of all bookmarks for the item. Fires socket `user_updated` (`reason: "bookmark_created"`).
|
||||
- `PATCH /api/me/item/{itemId}/bookmark` — upsert at time position. Fires `user_updated` (`reason: "bookmark_updated"`).
|
||||
- `DELETE /api/me/item/{itemId}/bookmark/{time}` — fires `user_updated` (`reason: "bookmark_deleted"`).
|
||||
|
||||
**Manual Collections** (port `collections_handler.go`)
|
||||
- `GET /api/collections` — list with embedded `libraryItems[]`.
|
||||
- `POST /api/collections` — `{name, description}` → single collection.
|
||||
- `GET/PATCH/DELETE /api/collections/{id}`.
|
||||
- `POST /api/collections/{id}/book/{bookId}` — add item.
|
||||
- `DELETE /api/collections/{id}/book/{bookId}` — remove item.
|
||||
|
||||
**Playlists** (port `playlists_handler.go`)
|
||||
- `GET /api/playlists` → `{playlists: [...]}`.
|
||||
- `POST /api/playlists` — `{name, description, cover_item, is_public}`.
|
||||
- `GET/PATCH/DELETE /api/playlists/{id}`.
|
||||
- `POST /api/playlists/{id}/item` — `{libraryItemId, episodeId?}`.
|
||||
- `POST /api/playlists/{id}/batch/add` — `{items: [...]}`.
|
||||
- `POST /api/playlists/{id}/batch/remove`.
|
||||
- `DELETE /api/playlists/{id}/item/{libraryItemId}[/{episodeId}]`.
|
||||
|
||||
**Smart Collections** (port `smart_collection_handler.go` + the entire `internal/smartcoll/` DSL package; adapt SQL to silo's `media_items` schema)
|
||||
- `GET /api/me/smart-collections` → `{items: [...]}`.
|
||||
- `POST /api/me/smart-collections` — `{name, description, color, is_public, is_pinned, query_def}`.
|
||||
- `GET /api/me/smart-collections/{id}`.
|
||||
- `GET /api/me/smart-collections/{id}/items` — evaluates `query_def` against the catalog.
|
||||
- `PATCH/DELETE /api/me/smart-collections/{id}`.
|
||||
|
||||
**Author detail**
|
||||
- `GET /api/authors/{id}` — single author with `books[]`.
|
||||
- `GET /api/authors/{id}/image` — wire to `item_people.poster_path` via `DetailService.PresignURL` (currently 404s).
|
||||
|
||||
**Series detail**
|
||||
- `GET /api/series/{id}` — single series with `books[]` sorted by `series_sequence`.
|
||||
|
||||
**RSS Feeds** (port `rss_feed_handler.go` + `internal/podcastfeed/`)
|
||||
- `POST /api/feeds` — `{itemId, slug, minified}` → `{success, feed: {id, slug, url, ...}}`.
|
||||
- `GET /api/feeds` — caller's feeds.
|
||||
- `DELETE /api/feeds/{id}`.
|
||||
- `GET /abs/public/feed/{slug}` — public RSS XML (no auth, slug is capability token).
|
||||
- `GET /abs/public/feed/{slug}/cover`.
|
||||
- `GET /abs/public/feed/{slug}/item/{itemId}/{fileIdx}`.
|
||||
|
||||
**Listening stats** (port `internal/abs/handler.go:handleListeningStats` + `handleListeningSessions`)
|
||||
- `GET /api/me/listening-stats` → `{totalTime, items, days, dayOfWeek, monthly}` from `abs_playback_sessions` aggregation.
|
||||
- `GET /api/me/listening-sessions?limit=...` — paginated session history.
|
||||
- `GET /api/me/listening-sessions/{sid}` — single session detail.
|
||||
|
||||
**Continue-listening toggles** (port `continue_listening.go`)
|
||||
- `GET /api/me/progress/{itemId}/remove-from-continue-listening`.
|
||||
- `GET /api/me/progress/{itemId}/readd-to-continue-listening`.
|
||||
- Adds `hide_from_continue boolean` column to `user_watch_progress` (the table the ABS layer writes via `abs_progress_store.go`).
|
||||
|
||||
**Migrations** (numbered after silo's current max, paired up/down per `CLAUDE.md`):
|
||||
- `148_abs_bookmarks` — `(id ULID PK, user_id, profile_id, library_item_id, time_seconds, title, created_at, updated_at)` + unique `(user_id, profile_id, library_item_id, time_seconds)`.
|
||||
- `149_abs_user_collections` + `150_abs_collection_items`.
|
||||
- `151_abs_playlists` + `152_abs_playlist_items`.
|
||||
- `153_abs_smart_collections` — includes `query_def jsonb`.
|
||||
- `154_abs_rss_feeds` — slug is unique capability token.
|
||||
- `155_abs_progress_hide_from_continue` — adds `hide_from_continue boolean default false`.
|
||||
|
||||
**Deliverable:** ABS iOS/Android Library, Collections, Playlists, Bookmarks, RSS, Stats tabs all functional.
|
||||
|
||||
**Size:** ~3000-4000 lines across ~15 new files + 7 migration pairs. Splittable into 4 sub-commits (bookmarks, collections+playlists, smart collections, RSS+stats).
|
||||
|
||||
### Phase 2 — Socket.io full event parity
|
||||
|
||||
**Goal:** A logged-in mobile client sees real-time updates when another device on the same account changes progress, when a server scan adds items, when collections/playlists are mutated remotely, and when an admin posts notifications.
|
||||
|
||||
**Event surface** (union of Continuum + booklore-ng — what real ABS iOS source subscribes to):
|
||||
|
||||
**Server → Client** (~30 events):
|
||||
- Lifecycle: `init`, `auth_failed`, `user_online`, `user_offline`, `listener_count`.
|
||||
- Progress/session: `user_item_progress_updated`, `user_session_open`, `user_session_updated`, `user_session_closed`, `user_stream_update`, `user_stream_end`.
|
||||
- User-scoped: `user_updated` (bookmark reasons).
|
||||
- Library: `library_added`, `library_updated`, `library_removed`.
|
||||
- Items: `item_added`, `item_updated`, `item_removed`, `items_added`.
|
||||
- People/series: `author_added`, `author_updated`, `author_removed`, `series_added`, `series_updated`, `series_removed`.
|
||||
- Collections/playlists: `collection_added/updated/removed`, `playlist_added/updated/removed`.
|
||||
- RSS: `rss_feed_open`, `rss_feed_closed`.
|
||||
- Tasks: `scan_start`, `scan_progress`, `scan_complete`, `task_started`, `task_progress`, `task_finished`.
|
||||
- Misc: `notification`.
|
||||
|
||||
**Client → Server** (10 events):
|
||||
- `auth` (exists), `ping`, `join_library`, `leave_library`, `playback_start`, `playback_sync`, `playback_end`, `stream_open`, `stream_close`, `sync_progress`.
|
||||
|
||||
**Architecture changes to `internal/audiobooks/abssocket/`:**
|
||||
- Add room types: existing `user:<userID>`, new `lib:<libraryID>` (joined via `join_library`), new `admin:*`.
|
||||
- New file `abssocket/publisher.go` exposing `Publisher` interface (`PublishUser`, `PublishLibrary`, `Broadcast`). Replaces ad-hoc `h.publish/h.broadcast` helpers on the Handler struct (currently silent no-ops when `SocketIO == nil`). Tests pass a recording Publisher.
|
||||
|
||||
**Cross-package event-source hooks** (via a new `audiobooks.EventBroadcaster` interface in `api.Dependencies` so the consumer packages don't import `abssocket` directly):
|
||||
- `internal/scanner/` — emit `scan_*`, `item_*`, `items_added` on audiobook-library scan operations.
|
||||
- `internal/taskmanager/` — emit `task_*` for audiobook-scoped tasks.
|
||||
- `internal/api/handlers/libraries.go` (or wherever library CRUD lives) — emit `library_*`.
|
||||
|
||||
**Auth/lifecycle improvements:**
|
||||
- Richer `init` payload: `{userId, connectedAt, libraries[], itemTagsAccessible[]}`.
|
||||
- Heartbeat: respond to `ping` with `pong`.
|
||||
- On disconnect, decrement listener count and broadcast `user_offline` if this was the user's last device.
|
||||
- Reconnect replay (last 30s of user-scoped events) is out of scope for v1; leave a hook.
|
||||
|
||||
**Migrations:** none — sockets are stateless except for in-memory connection registry.
|
||||
|
||||
**Size:** ~1500-2000 lines: ~600 in `abssocket/`, ~400 publisher wiring across audiobook handlers, ~500 cross-package hooks.
|
||||
|
||||
### Phase 3 — Hardening
|
||||
|
||||
**Media token signing layer** (port `continuum-plugin-audiobooks/internal/mediatoken/`):
|
||||
- Separate HS256 secret (`audiobooks.abs.media_signing_secret`), 15-min TTL, claims bound to `(user_id, profile_id, book_id, file_idx)`.
|
||||
- `file_handler.go` mints when building stream URLs; validates on file request.
|
||||
- Defense-in-depth: a leaked stream URL grants access to ONE file for 15 minutes instead of full account.
|
||||
- Secret stored in `server_settings`, auto-generated on first read.
|
||||
|
||||
**Device tracking** (currently `abs_sessions.device_id = JTI`, useless for UI):
|
||||
- Parse `User-Agent` on `/login` → derive `device_name`, `client_name`, `client_version`, store on `abs_sessions` insert.
|
||||
- `GET /api/me/sessions` — list user's sessions (so they see "iPhone 15 Pro — Sep 1").
|
||||
- `DELETE /api/me/sessions/{jti}` — selective revocation.
|
||||
|
||||
**Audit log:**
|
||||
- New table `abs_audit_log`: `(id, user_id, action, ip, user_agent, metadata jsonb, created_at)`.
|
||||
- Logged events: login success/failure, logout, token refresh, session revoke, smart-collection-rule changes.
|
||||
- Optional `GET /api/admin/audit-log` for ops visibility.
|
||||
|
||||
**Rate limiting beyond login:**
|
||||
- Per-user limit on `POST /me/progress` (1/sec — clients sometimes spam this).
|
||||
- Per-user limit on `POST /auth/refresh` (5/hour — prevent JTI accumulation).
|
||||
|
||||
**Size:** ~800-1200 lines + 2 migration pairs.
|
||||
|
||||
## 5. Data Model
|
||||
|
||||
### New tables (all in Phase 1 unless noted)
|
||||
|
||||
```
|
||||
abs_bookmarks (
|
||||
id text PRIMARY KEY, -- ULID
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid, -- nullable: primary profile
|
||||
library_item_id text NOT NULL,
|
||||
time_seconds double precision NOT NULL,
|
||||
title text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
UNIQUE (user_id, profile_id, library_item_id, time_seconds)
|
||||
)
|
||||
|
||||
abs_user_collections (
|
||||
id text PRIMARY KEY, -- ULID
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
is_public boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
abs_collection_items (
|
||||
collection_id text NOT NULL REFERENCES abs_user_collections(id) ON DELETE CASCADE,
|
||||
library_item_id text NOT NULL,
|
||||
added_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (collection_id, library_item_id)
|
||||
)
|
||||
|
||||
abs_playlists (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
cover_item text, -- library_item_id for cover
|
||||
is_public boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
abs_playlist_items (
|
||||
playlist_id text NOT NULL REFERENCES abs_playlists(id) ON DELETE CASCADE,
|
||||
position integer NOT NULL,
|
||||
library_item_id text NOT NULL,
|
||||
episode_id text, -- nullable: book item vs podcast episode
|
||||
added_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (playlist_id, position)
|
||||
)
|
||||
|
||||
abs_smart_collections (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
color text,
|
||||
is_public boolean DEFAULT false,
|
||||
is_pinned boolean DEFAULT false,
|
||||
query_def jsonb NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
abs_rss_feeds (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
slug text UNIQUE NOT NULL, -- capability token in URL
|
||||
item_id text NOT NULL,
|
||||
minified boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)
|
||||
|
||||
-- Phase 3
|
||||
abs_audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id integer,
|
||||
action text NOT NULL,
|
||||
ip inet,
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)
|
||||
```
|
||||
|
||||
### Modifications to existing tables
|
||||
|
||||
```
|
||||
-- Phase 1: hide-from-continue toggle
|
||||
ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean DEFAULT false;
|
||||
|
||||
-- Phase 3: real device tracking
|
||||
ALTER TABLE abs_sessions
|
||||
ALTER COLUMN device_id DROP DEFAULT,
|
||||
ADD COLUMN parsed_user_agent text;
|
||||
```
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
**Unit tests** (Go, in-package):
|
||||
- Table-driven per handler in `internal/audiobooks/abs/`: happy path, missing field, wrong field type, IDOR (other user's session/bookmark/collection).
|
||||
- `abssocket/` `Publisher` recording mock — verify events fire on the right operations with the right payload shape.
|
||||
- Smart collection DSL evaluator — port Continuum's `smartcoll_test.go`.
|
||||
|
||||
**Integration tests** (against real Postgres + Redis via silo's `internal/audiobooks/testutil` harness):
|
||||
- Full login → browse → play → progress → close-session flow.
|
||||
- Token refresh — old token rejected, new token accepted.
|
||||
- Bookmark CRUD round-trip with socket event capture.
|
||||
- Smart collection rule evaluation against seeded catalog.
|
||||
|
||||
**End-to-end (manual)**:
|
||||
- iOS official ABS app: add server → login → browse → play offline downloads → progress sync across devices.
|
||||
- Plappa: same flow. Strictest client for response shape; if Plappa works, everything works.
|
||||
- Android official: same.
|
||||
|
||||
**Migration safety:**
|
||||
- Each migration includes `.down.sql`.
|
||||
- New helper `scripts/test-abs-migrations.sh` runs up → down → up against a fresh DB.
|
||||
|
||||
## 7. Rollout
|
||||
|
||||
Single-shot per phase, no human review gate. **Each phase gets its own implementation plan** (separate `writing-plans` cycle) since a combined plan would be unwieldy at this size. Start with Phase 0; subsequent phases planned only after the prior phase merges and verifies.
|
||||
|
||||
| Phase | PR | Verify |
|
||||
|---|---|---|
|
||||
| Phase 0 | One PR | `make build` → restart silo → curl `/ping` → iOS app smoke test |
|
||||
| Phase 1 | One PR (or 4 sub-PRs if reviewable size matters) | iOS app: Bookmarks, Collections, Playlists, Stats tabs functional |
|
||||
| Phase 2 | One PR | Two devices on same account: progress sync visible in real time |
|
||||
| Phase 3 | One PR | `/api/me/sessions` shows real device names; leaked stream URL expires |
|
||||
|
||||
**Cross-repo coordination:** silo-android and silo-apple are NOT touched. The ABS-compat surface targets OFFICIAL ABS clients; silo's own clients use silo's native API.
|
||||
|
||||
**Rollback:** every migration has a working `.down.sql`. New tables don't break existing functionality; silo's native audiobook API (`/api/v1/audiobooks/*`) is untouched.
|
||||
|
||||
## 8. Open Follow-ups (Explicitly Out of Scope)
|
||||
|
||||
- ABS server-side import requests (`/api/me/request`) — Continuum had this; silo has its own `internal/requests/` system. Bridging is a future decision.
|
||||
- Podcast episode-download queue events (`episode_download_*`) — only relevant if silo gains podcast download capability beyond RSS refresh.
|
||||
- Backup events — silo's backup story is separate.
|
||||
- Watch-Together over the ABS socket — silo has `internal/watchtogether/`, deferred.
|
||||
- Reconnect replay buffer for socket events — Phase 2 leaves a hook but does not implement.
|
||||
|
||||
## 9. References
|
||||
|
||||
- Source-of-truth implementation: `continuum-plugin-audiobooks` (sibling worktree). Key files: `internal/abs/handler.go`, `bookmarks_handler.go`, `collections_handler.go`, `playlists_handler.go`, `smart_collection_handler.go`, `rss_feed_handler.go`, `continue_listening.go`, `abssocket/server.go`, `mediatoken/`.
|
||||
- Bug catalog for response shape: `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`.
|
||||
- Canonical socket event list: `booklore-ng/src/lib/socket/events.ts`.
|
||||
- ABS API documentation: `booklore-ng/AUDIOBOOKSHELF_API_DOCUMENTATION.md`.
|
||||
|
||||
## 10. Phase 0 — Status
|
||||
|
||||
**Implemented:** 2026-05-26. Plan: `docs/superpowers/plans/2026-05-26-abs-phase-0-login-and-critical-fixes.md`. All 11 plan tasks committed on `feat/audiobooks` and deployed to the production silo container. Endpoint smoke tests confirm correct mount + status codes for `/ping`, `/login`, `/auth/refresh`, `/logout`, `/me`, `/authorize` at both root and `/api`/`/abs/api` prefixes. End-to-end iOS/Plappa app validation pending operator hand-off.
|
||||
|
||||
**Phase 0 commits (13 total — implementation + review-feedback fixes):**
|
||||
|
||||
| # | SHA | Subject |
|
||||
|---|-----|---------|
|
||||
| 1 | `6c9c721` | feat(audiobooks): diagnostic logging to ABS bearer auth and login |
|
||||
| 1+ | `d5edb24` | chore(audiobooks): normalize slog "err" key + add path to secret-fetch log |
|
||||
| 2 | `877120f` | fix(audiobooks): enrich ABS login envelope |
|
||||
| 2+ | `47c04f1` | chore(audiobooks): reuse existing now var in login envelope |
|
||||
| 3 | `045984c` | fix(audiobooks): /authorize returns identical envelope to /login |
|
||||
| 3+ | `0306cfe` | docs(audiobooks): restore x-return-tokens and displayName fallback comments |
|
||||
| 4 | `e163858` | fix(audiobooks): emit IDs on authors/series and stable genres/tags arrays |
|
||||
| 4+ | `470a483` | fix(audiobooks): proper pagination total + tags key in play session |
|
||||
| 5 | `7f600b2` | fix(audiobooks): hydrate filterdata authors and series in library detail |
|
||||
| 5+ | `2e2b411` | chore(audiobooks): rename const cap to fetchCap in buildFilterData |
|
||||
| 6 | `7e956f1` | fix(audiobooks): seed currentTime from ProgressStore so resume works |
|
||||
| 7 | `93e321f` | feat(audiobooks): POST /auth/refresh for ABS token rotation |
|
||||
| 8 | `278f815` | feat(audiobooks): POST /logout for ABS sign-out |
|
||||
|
||||
**Tests added:** 15 (6 metadata + 4 resume + 5 refresh + 4 logout — note 4 logout overlaps with refresh's memTokenStore). `go test ./internal/audiobooks/... -count=1` all pass.
|
||||
|
||||
**Next:** Phase 1 (bookmarks, collections, playlists, smart collections, RSS, author/series detail, listening stats) will start with its own brainstorming → writing-plans cycle after operator confirms Phase 0 works against real clients.
|
||||
@@ -1,344 +0,0 @@
|
||||
# ABS Phase 1 Close-out — Sub-Project 4
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** Final sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`). Closes out the four remaining feature surfaces in one combined spec/plan to ship in a single push.
|
||||
**Predecessor specs:** bookmarks, collections+playlists, smart collections. Established conventions (anti-enumeration 404, `io.LimitReader(1<<20)`, `errors.Is(err, pgx.ErrNoRows)`, profile-scoped + cross-user-public, 200-on-POST, in-package test fakes, `dispatchABSWithParams`) carry over without re-justification.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Land the four remaining Phase 1 surfaces:
|
||||
- **Listening stats** — totalTime / days / dayOfWeek / monthly aggregations + paginated session history + per-session detail.
|
||||
- **Author detail** — `GET /authors/{id}` and `GET /authors/{id}/image` (the latter currently 404s; wire to people poster image).
|
||||
- **Series detail** — `GET /series/{id}` with embedded books sorted by series_sequence.
|
||||
- **Continue-listening toggles** — `/me/progress/{itemId}/remove-from-continue-listening` + `/readd-to-continue-listening`, backed by a new `hide_from_continue` column on `user_watch_progress`.
|
||||
- **RSS feeds** — minimal viable surface: open a feed for a library item, list caller's feeds, close a feed, and serve the public RSS XML to podcast clients. Series/collection feed variants deferred.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- **No socket events.** Continue-listening toggles, stats, author/series, and RSS feed mutations fire no events (Phase 2 covers full event parity).
|
||||
- **No series/collection RSS feeds.** Only single-item feeds in v1. The continuum `/api/feeds/series/{id}/open` and `/api/feeds/collection/{id}/open` variants are explicitly deferred.
|
||||
- **No RSS cover / per-track endpoints.** `GET /feed/{slug}/cover` and `GET /feed/{slug}/item/{itemId}/{idx}` are deferred — podcast clients can fetch covers from the enclosure URL embedded in the RSS XML.
|
||||
- **No stable series IDs.** silo stores series denormalised on `audiobook_series.content_id` (one row per book); a "series" is identified by `LOWER(series_name)`. The detail handler accepts URL-decoded series names; URL slugs use the lowercased name. Continuum's stable series-row schema is out of scope.
|
||||
- **No author write surface.** Author detail is read-only.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Five surfaces, sharing the established structure. Three new HTTP handler files; one new migration; minimal extensions to existing stores; one new pgx store for RSS.
|
||||
|
||||
- **HTTP layer (`internal/audiobooks/abs/`):**
|
||||
- `listening_stats_handler.go` — three handlers (stats / list / detail).
|
||||
- `author_series_handler.go` — three handlers (author / author-image / series).
|
||||
- `continue_listening_handler.go` — two handlers.
|
||||
- `rss_feeds_handler.go` — four authenticated handlers + one public XML handler.
|
||||
- **Store extensions:**
|
||||
- `ABSPlaybackSessionStore.AggregateStats(ctx, userID, profileID) (Stats, error)` — single SQL with grouped aggregates.
|
||||
- `ABSPlaybackSessionStore.ListClosedSessions(ctx, userID, profileID, limit, offset) ([]ABSPlaybackSession, int, error)` — paginated history.
|
||||
- `ProgressStore.SetHideFromContinue(ctx, userID, profileID, contentID string, hide bool) error` — toggle column.
|
||||
- `MediaStore.GetAuthorByID(ctx, authorID string) (Author, error)` — author detail + books list.
|
||||
- `MediaStore.GetSeriesByName(ctx, seriesName string) (Series, error)` — series detail + books list ordered by series_index.
|
||||
- **New store interface + concrete:**
|
||||
- `RSSFeedStore` interface (CRUD + lookup-by-slug) in `internal/audiobooks/abs/rss_feeds.go`.
|
||||
- `ABSRSSFeedStore` (pgx) in `internal/audiobooks/abs_rss_feed_store.go`.
|
||||
- **Migration:**
|
||||
- `154_user_watch_progress_hide_from_continue.up.sql` — `ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean NOT NULL DEFAULT false`.
|
||||
- `155_abs_rss_feeds.up.sql` — feed rows (id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at).
|
||||
- **Service wiring + route registration:** new fields on `abs.Dependencies` + new routes in `mountRoutes` (auth-gated group plus a new unauth public group for `/feed/{slug}.xml`).
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All authenticated routes mount under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** except where the `Returns` column says otherwise.
|
||||
|
||||
### 4.1 Listening stats
|
||||
|
||||
| Verb | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/me/listening-stats` | `{totalTime, items, days, dayOfWeek, monthly}` aggregated from `abs_playback_sessions` |
|
||||
| GET | `/me/listening-sessions` (?limit=, ?page=) | `pagedEnvelope` of session rows (most-recent-first) |
|
||||
| GET | `/me/listening-sessions/{sid}` | single session detail (404 when not found or not owned) |
|
||||
|
||||
Stats wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"totalTime": 36000,
|
||||
"items": 12,
|
||||
"days": [{"date": "2026-05-26", "seconds": 1800}, ...],
|
||||
"dayOfWeek": {"0": 1200, "1": 3600, ..., "6": 900},
|
||||
"monthly": [{"month": "2026-05", "seconds": 18000}, ...]
|
||||
}
|
||||
```
|
||||
|
||||
Session row shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HSESS...",
|
||||
"libraryItemId": "126887...",
|
||||
"userId": "1",
|
||||
"startedAt": 1779786284823,
|
||||
"lastUpdate": 1779786884823,
|
||||
"duration": 1500,
|
||||
"currentTime": 1234.5,
|
||||
"timeListening": 600
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Author + Series detail
|
||||
|
||||
| Verb | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/authors/{id}` | `{id, name, numBooks, books: [LibraryItem]}` |
|
||||
| GET | `/authors/{id}/image` | redirect to PresignURL for `item_people.poster_path` when set; otherwise 404 |
|
||||
| GET | `/series/{id}` | `{id, name, numBooks, books: [LibraryItem]}` ordered by series_index ASC |
|
||||
|
||||
`{id}` for authors is the stringified `people.id`. `{id}` for series is the URL-decoded series name (case-insensitive lookup). The author-image route was already mounted unauthenticated in handler.go but currently returns 404 — this spec wires the body.
|
||||
|
||||
### 4.3 Continue-listening toggles
|
||||
|
||||
| Verb | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/me/progress/{itemId}/remove-from-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = true` |
|
||||
| GET | `/me/progress/{itemId}/readd-to-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = false` |
|
||||
|
||||
Both endpoints are idempotent. Item validation via `MediaStore.GetAudiobookByID` (404 on unknown item). The existing `MediaStore.ListContinueListening` SQL is updated to add `AND uwp.hide_from_continue = false` to the WHERE clause.
|
||||
|
||||
### 4.4 RSS feeds
|
||||
|
||||
| Verb | Path | Auth | Body | Returns |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/api/feeds` | bearer | — | `{feeds: [Feed]}` for caller (owner + profile scope, only open feeds) |
|
||||
| POST | `/api/feeds/item/{itemId}/open` | bearer | `{slug?, minified?}` | created Feed (full shape) |
|
||||
| POST | `/api/feeds/{id}/close` | bearer | — | 204 (sets closed_at) |
|
||||
| GET | `/feed/{slug}.xml` | none | — | RSS 2.0 XML; 404 on unknown / closed |
|
||||
| GET | `/feed/{slug}` | none | — | same as above (some podcast clients omit the `.xml`) |
|
||||
|
||||
Feed wire shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HFEED...",
|
||||
"userId": "1",
|
||||
"libraryItemId": "126887...",
|
||||
"slug": "abc-randomsluggenerated",
|
||||
"minified": false,
|
||||
"createdAt": 1779786284823,
|
||||
"url": "https://silo.example.com/feed/abc-randomsluggenerated.xml"
|
||||
}
|
||||
```
|
||||
|
||||
`slug` is a 16-char URL-safe random string when caller omits it from the body. Manual slugs accepted as long as they match `^[a-z0-9-]{4,64}$`. Slugs are globally unique (UNIQUE index).
|
||||
|
||||
RSS XML uses standard RSS 2.0 + iTunes namespace; each `<item>` is one media_file (chapter) of the underlying audiobook. URLs are absolute (`absBaseURL` + `/public/session/...` style — but RSS feeds need a stable public path, so v1 uses a new public file route below). For v1 simplicity, the `<enclosure url>` points at the existing `/abs/public/session/{sid}/track/{idx}` style URL — but feeds don't have an associated session. So we use a new public route mounted in tandem:
|
||||
|
||||
**Additional public RSS support route:**
|
||||
|
||||
| Verb | Path | Auth | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/feed/{slug}/file/{ino}` | none | streams the media file for the feed's library_item_id (404 if ino doesn't belong to the item) |
|
||||
|
||||
This is a feed-scoped public file route — slug is the capability token, so no Bearer needed. Mirrors the bookmarked-session pattern from sub-project 1.
|
||||
|
||||
## 5. Data model
|
||||
|
||||
### 5.1 Migration 154 — `hide_from_continue` column
|
||||
|
||||
`migrations/154_user_watch_progress_hide_from_continue.up.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.user_watch_progress
|
||||
ADD COLUMN IF NOT EXISTS hide_from_continue boolean NOT NULL DEFAULT false;
|
||||
```
|
||||
|
||||
`migrations/154_user_watch_progress_hide_from_continue.down.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE public.user_watch_progress
|
||||
DROP COLUMN IF EXISTS hide_from_continue;
|
||||
```
|
||||
|
||||
### 5.2 Migration 155 — `abs_rss_feeds`
|
||||
|
||||
`migrations/155_abs_rss_feeds.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_rss_feeds (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
minified boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS abs_rss_feeds_slug_uniq
|
||||
ON public.abs_rss_feeds (slug);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_rss_feeds_user_profile_idx
|
||||
ON public.abs_rss_feeds (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/155_abs_rss_feeds.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_rss_feeds_user_profile_idx;
|
||||
DROP INDEX IF EXISTS public.abs_rss_feeds_slug_uniq;
|
||||
DROP TABLE IF EXISTS public.abs_rss_feeds;
|
||||
```
|
||||
|
||||
### 5.3 Go models
|
||||
|
||||
```go
|
||||
// Stats aggregates per spec §4.1.
|
||||
type Stats struct {
|
||||
TotalTime int // seconds
|
||||
Items int // distinct content_ids listened to
|
||||
Days []DayStat // last 30 days
|
||||
DayOfWeek [7]int // index 0=Sunday
|
||||
Monthly []MonthStat // last 12 months
|
||||
}
|
||||
type DayStat struct{ Date string; Seconds int } // "2026-05-26"
|
||||
type MonthStat struct{ Month string; Seconds int } // "2026-05"
|
||||
|
||||
// Author is the detail-shape row.
|
||||
type Author struct {
|
||||
ID string
|
||||
Name string
|
||||
PosterPath string // empty when unset; resolved via CoverResolver on emit
|
||||
Books []*models.MediaItem
|
||||
}
|
||||
|
||||
// Series is the detail-shape row.
|
||||
type Series struct {
|
||||
ID string // lowercased series_name
|
||||
Name string // canonical series_name
|
||||
Books []*models.MediaItem // ordered by series_index ASC
|
||||
}
|
||||
|
||||
// RSSFeed mirrors an abs_rss_feeds row.
|
||||
type RSSFeed struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
LibraryItemID string
|
||||
Slug string
|
||||
Minified bool
|
||||
CreatedAt time.Time
|
||||
ClosedAt *time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Storage contracts
|
||||
|
||||
```go
|
||||
// Extensions to existing interfaces:
|
||||
|
||||
// On ABSPlaybackSessionStore (existing):
|
||||
AggregateStats(ctx context.Context, userID, profileID string) (Stats, error)
|
||||
ListClosedSessions(ctx context.Context, userID, profileID string, limit, offset int) ([]ABSPlaybackSession, int, error)
|
||||
|
||||
// On ProgressStore (existing):
|
||||
SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error
|
||||
|
||||
// On MediaStore (existing):
|
||||
GetAuthorByID(ctx context.Context, authorID string) (Author, error)
|
||||
GetSeriesByName(ctx context.Context, seriesName string) (Series, error)
|
||||
|
||||
// New store interface — RSSFeedStore (defined in internal/audiobooks/abs/rss_feeds.go):
|
||||
type RSSFeedStore interface {
|
||||
ListUserFeeds(ctx context.Context, userID, profileID string) ([]RSSFeed, error)
|
||||
GetFeedBySlug(ctx context.Context, slug string) (RSSFeed, error)
|
||||
GetFeed(ctx context.Context, id string) (RSSFeed, error)
|
||||
CreateFeed(ctx context.Context, f RSSFeed) error
|
||||
CloseFeed(ctx context.Context, id string) error
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- `AggregateStats` runs one SQL with three CTE subqueries (days / day_of_week / monthly aggregates) plus a `SUM(time_listening_seconds)` and `COUNT(DISTINCT content_id)`.
|
||||
- `SetHideFromContinue` writes `UPDATE user_watch_progress SET hide_from_continue = $4 WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`. Returns `nil` even when no row matched (idempotent — no-progress means nothing to hide; client can readd later).
|
||||
- `GetAuthorByID` parses `authorID` as int (`strconv.Atoi`), then `SELECT name FROM people WHERE id = $1` for the author row + `SELECT mi.* FROM item_people ip JOIN media_items mi ON mi.content_id = ip.content_id WHERE ip.person_id = $1 AND ip.kind = 7 AND mi.type = 'audiobook' ORDER BY mi.title`. Returns `ErrNotFound` when the people row is missing.
|
||||
- `GetSeriesByName` does case-insensitive lookup on `audiobook_series.series_name`: `SELECT DISTINCT series_name FROM audiobook_series WHERE LOWER(series_name) = LOWER($1) LIMIT 1` → name; then `SELECT mi.* FROM audiobook_series s JOIN media_items mi ON mi.content_id = s.content_id WHERE LOWER(s.series_name) = LOWER($1) AND mi.type = 'audiobook' ORDER BY s.series_index NULLS LAST`. Returns `ErrNotFound` when no rows.
|
||||
- `RSSFeedStore.CreateFeed` inserts with the slug as-is; UNIQUE constraint on slug surfaces as a 409 client-side. `GetFeedBySlug` only returns rows where `closed_at IS NULL` (closed feeds 404 to podcast clients). `CloseFeed` sets `closed_at = now()` — idempotent on already-closed rows.
|
||||
|
||||
## 7. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| 401 missing/invalid bearer | 401 | bearerAuth |
|
||||
| Body decode (POST `/feeds/.../open`) | 400 | `invalid body` |
|
||||
| Slug fails `^[a-z0-9-]{4,64}$` | 400 | `invalid slug` |
|
||||
| Item not found on author/series/feed POST | 404 | `<type> not found` |
|
||||
| Author/Series unknown ID | 404 | `<author\|series> not found` |
|
||||
| RSS slug collision on POST | 409 | `slug taken` |
|
||||
| Non-owner DELETE/close feed | 404 | `feed not found` (anti-enumeration) |
|
||||
| Public `/feed/{slug}` unknown or closed slug | 404 | `feed not found` (plain text body — podcast clients ignore it) |
|
||||
| Store mutate fails | 500 | sanitized; err logged |
|
||||
|
||||
Cross-cutting:
|
||||
- Same `io.LimitReader(1<<20)` on POST/PATCH bodies.
|
||||
- Continue-listening toggles are idempotent and return 200 even on no-row-matched.
|
||||
- The public RSS routes (`/feed/{slug}.xml`, `/feed/{slug}`, `/feed/{slug}/file/{ino}`) mount OUTSIDE the bearerAuth group — they're capability-gated by the slug. Mount alongside the existing public-session routes in `mountRoutes`.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Listening stats
|
||||
|
||||
- `TestStats_TotalAggregation` — seed 3 closed sessions, assert `totalTime == sum(time_listening_seconds)` and `items == 2` (3 sessions across 2 distinct items).
|
||||
- `TestStats_DayOfWeekBucketing` — sessions on a Monday + a Wednesday + another Monday, assert `dayOfWeek[1] == 2 sessions worth, dayOfWeek[3] == 1`.
|
||||
- `TestSessions_List_PaginatedAndScoped` — paged response shape, other-user sessions excluded.
|
||||
- `TestSession_Detail_Owner_OK` + `TestSession_Detail_NonOwner_404`.
|
||||
|
||||
### 8.2 Author + Series
|
||||
|
||||
- `TestAuthor_Detail_ReturnsBooks` — seed author with 2 books; GET returns both, name matches.
|
||||
- `TestAuthor_Unknown_404`.
|
||||
- `TestAuthor_Image_PresignWired` — when CoverResolver returns a URL, image route 302s; when poster_path is empty, 404.
|
||||
- `TestSeries_Detail_OrderedByIndex` — books returned in series_index ASC.
|
||||
- `TestSeries_Unknown_404` (name doesn't match any audiobook_series row).
|
||||
|
||||
### 8.3 Continue-listening
|
||||
|
||||
- `TestContinue_Remove_SetsHideTrue` — seed progress row, GET remove-from-continue, assert column becomes true.
|
||||
- `TestContinue_Readd_SetsHideFalse` — inverse.
|
||||
- `TestContinue_UnknownItem_404`.
|
||||
- `TestContinue_NoProgressRow_OK_Idempotent` — no progress row exists; remove-from-continue still returns 200 (idempotent no-op).
|
||||
|
||||
### 8.4 RSS
|
||||
|
||||
- `TestFeed_Open_Item_GeneratesSlug` — POST `/api/feeds/item/{id}/open` returns a feed with a random 16-char slug.
|
||||
- `TestFeed_Open_AcceptsCustomSlug`.
|
||||
- `TestFeed_Open_RejectsInvalidSlug_400`.
|
||||
- `TestFeed_Open_RejectsCollision_409`.
|
||||
- `TestFeed_List_OwnerOnly` — only caller's open feeds, profile-scoped.
|
||||
- `TestFeed_Close_Owner_204`.
|
||||
- `TestFeed_Close_NonOwner_404`.
|
||||
- `TestPublicFeed_UnknownSlug_404`.
|
||||
- `TestPublicFeed_ClosedSlug_404`.
|
||||
- `TestPublicFeed_HappyPath_RSSXML` — open a feed with a known item; fetch `/feed/{slug}.xml`; assert Content-Type is `application/rss+xml`, body contains `<rss`, `<channel>`, `<item>`, `<enclosure url=`.
|
||||
|
||||
### 8.5 Out of scope
|
||||
|
||||
- No real-Postgres SQL test.
|
||||
- No live smoke for the public RSS endpoints (operator can hit them; v1 doesn't include the curl snippet).
|
||||
- No RSS validator test against a third-party tool.
|
||||
|
||||
## 9. References
|
||||
|
||||
- Continuum reference: `continuum-plugin-audiobooks/internal/abs/rss_feed_handler.go` (port routing names + slug generation; adapt store/auth to silo).
|
||||
- silo's existing public-route precedent: `handlePublicTrack` in `internal/audiobooks/abs/file_handler.go` — same slug-as-capability pattern.
|
||||
- Item-people kinds: 7 = author per `media_store.go:ListLibraryAuthors`. Narrator kind not used here.
|
||||
|
||||
## 10. Out-of-scope follow-ups
|
||||
|
||||
- RSS feed variants: series feeds, collection feeds, cover endpoint, per-track public route is included but is the minimum.
|
||||
- Listening-stats `/all-time` vs `/last-N-days` filters — current spec returns a single aggregate.
|
||||
- Author edit / merge surface.
|
||||
- Series metadata edit.
|
||||
- Stats charts beyond the three buckets (per-genre, per-author, per-narrator) — defer.
|
||||
@@ -1,377 +0,0 @@
|
||||
# ABS Smart Collections — Phase 1 Sub-Project 3
|
||||
|
||||
**Status:** Approved 2026-05-26. Ready for implementation plan.
|
||||
**Scope:** Third sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
|
||||
**Predecessor specs:** `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`. Conventions established there (anti-enumeration 404, `io.LimitReader(1<<20)` body cap, `errors.Is(err, pgx.ErrNoRows)` store pattern, profile-scoped + cross-user-public read, 200 OK on POST, in-package test fakes + `dispatchABSWithParams`) are reused without re-justification.
|
||||
|
||||
Commands in this document assume the repository root is the cwd.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Land rule-based "smart" audiobook collections on the silo ABS surface so the official Audiobookshelf clients can define dynamic groupings ("books from 2020-2024 by Brandon Sanderson", "audiobooks I've started but haven't finished", "5-star fantasy under 12 hours"). Rules are stored as a JSON DSL; the items endpoint evaluates the rules against the catalog at request time and returns paginated `LibraryItem` results.
|
||||
|
||||
## 2. Non-goals
|
||||
|
||||
- **No SQL pushdown.** Evaluation walks the candidate list in Go. silo's audiobook library is small enough (hundreds of items) that linear scan per request is fine. SQL pushdown is a Phase 4 follow-up if the library grows past ~5000 items.
|
||||
- **No rule-builder UI.** Clients ship their own; silo just stores and evaluates `query_def`.
|
||||
- **No podcast smart collections.** The DSL catalog is audiobook-domain only (title/author/narrator/series/genre/...). Podcast smart collections, if ever needed, get their own DSL.
|
||||
- **No socket events.** Matches the manual-collections decision in sub-project 2. Phase 2 covers full event parity.
|
||||
- **No saved-search aliases.** A smart collection is fully described by its `query_def`; there's no separate "saved query" entity.
|
||||
- **No background materialisation.** Each `/items` call re-evaluates. If perf matters later, add a 30s in-memory result cache keyed on `(collectionID, userID)`.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Three layers, sharing the established silo ABS structure:
|
||||
|
||||
- **DSL layer (new package `internal/audiobooks/smartcoll/`):**
|
||||
- `query.go` — `QueryDefinition`, `QueryGroup`, `QueryRule`, `QuerySort` types + field/sort catalogs + `Normalize`/`Validate`/`MarshalJSON`. Ported from continuum's `internal/smartcoll/query.go` verbatim except for the import path.
|
||||
- `evaluator.go` — `Candidate`, `EvaluateOptions`, `Evaluate(ctx, qd, candidates, opts) []Candidate`. Pure function, no I/O. Ported from continuum.
|
||||
- `query_test.go` + `evaluator_test.go` — port continuum's test suites; remove any references to continuum-specific backend types.
|
||||
- **HTTP layer (`internal/audiobooks/abs/`):**
|
||||
- `smart_collections_handler.go` — six handlers: `handleListSmartCollections`, `handleCreateSmartCollection`, `handleGetSmartCollection`, `handleSmartCollectionItems`, `handleUpdateSmartCollection`, `handleDeleteSmartCollection`.
|
||||
- `smart_collections_handler_test.go` — in-memory `memSmartCollectionStore` + handler tests reusing `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher` from prior sub-projects' test files.
|
||||
- `smart_collections_envelope_test.go` — wire-shape marshal test.
|
||||
- **Storage layer:**
|
||||
- `internal/audiobooks/abs/smart_collections.go` — `SmartCollectionStore` interface + `SmartCollection` Go model + `smartCollectionToABS` serialiser.
|
||||
- `internal/audiobooks/abs_smart_collection_store.go` — pgx-backed `ABSSmartCollectionStore` (parallel to `abs_collection_store.go`).
|
||||
- **Migration:** `153_abs_smart_collections.up.sql` + `.down.sql`.
|
||||
- **Bookmark store extension:**
|
||||
- `BookmarkStore.CountByUser(ctx, userID, profileID) (map[string]int, error)` returning `{libraryItemID: count}` for batch hydration of the personalized `bookmark_count` rule. Single SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2, sentinel) GROUP BY library_item_id`.
|
||||
- **Service wiring:** `BuildABSHandler` constructs the new store; field lands on `abs.Dependencies` next to `PlaylistStore`.
|
||||
|
||||
## 4. Endpoint surface
|
||||
|
||||
All routes mounted under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** unless the `Returns` column says otherwise.
|
||||
|
||||
| Verb | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/me/smart-collections` | — | `{"items": [SmartCollection list-shape]}` |
|
||||
| POST | `/me/smart-collections` | `{name, description?, color?, isPublic?, isPinned?, query_def}` | SmartCollection full-shape |
|
||||
| GET | `/me/smart-collections/{id}` | — | SmartCollection full-shape when owner OR `isPublic=true`; otherwise 404 |
|
||||
| GET | `/me/smart-collections/{id}/items` | (query: `?limit=`, `?page=`) | Paged envelope of `LibraryItem` results from rule evaluation |
|
||||
| PATCH | `/me/smart-collections/{id}` | `{name?, description?, color?, isPublic?, isPinned?, query_def?}` | SmartCollection full-shape |
|
||||
| DELETE | `/me/smart-collections/{id}` | — | 204 No Content |
|
||||
|
||||
### 4.1 Wire shape — SmartCollection
|
||||
|
||||
**List-shape and full-shape are identical** (unlike manual collections / playlists where the list-shape omits child items). Smart collections have no stored items; the items are computed by the separate `/items` route. So one shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HSC...",
|
||||
"userId": "1",
|
||||
"name": "Recent Fantasy",
|
||||
"description": "fantasy added in the last 6 months",
|
||||
"color": "#3b82f6",
|
||||
"isPublic": false,
|
||||
"isPinned": true,
|
||||
"queryDef": {
|
||||
"library_ids": [9],
|
||||
"match": "all",
|
||||
"groups": [{
|
||||
"match": "all",
|
||||
"rules": [
|
||||
{"field": "genre", "op": "contains", "value": "Fantasy"},
|
||||
{"field": "added_at", "op": "in_last", "value": "180d"}
|
||||
]
|
||||
}],
|
||||
"sort": {"field": "added_at", "order": "desc"},
|
||||
"limit": null
|
||||
},
|
||||
"createdAt": 1779786284823,
|
||||
"updatedAt": 1779786284823
|
||||
}
|
||||
```
|
||||
|
||||
All ten top-level keys always present (`description`, `color` default to `""`; `isPublic`, `isPinned` default to `false`; `queryDef` is the parsed JSON, never raw bytes on the wire). Timestamps are JS-epoch milliseconds.
|
||||
|
||||
### 4.2 Wire shape — `/items` envelope
|
||||
|
||||
Standard ABS paged envelope (`pagedEnvelope` helper already in `handler.go`):
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [ /* hydrated LibraryItem objects */ ],
|
||||
"total": 42,
|
||||
"limit": 30,
|
||||
"page": 0,
|
||||
"sortBy": "added_at",
|
||||
"sortDesc": true,
|
||||
"filterBy": "",
|
||||
"minified": false,
|
||||
"include": ""
|
||||
}
|
||||
```
|
||||
|
||||
`results` items use the same `siloItemToLibraryItem` shape as `/api/libraries/{id}/items`. Pagination is post-eval slice (eval order is preserved; pages are stable for a given `(collection, user)` as long as the underlying catalog doesn't change).
|
||||
|
||||
### 4.3 List-envelope wrap key
|
||||
|
||||
Smart-collection LIST uses `{"items": [...]}` — matches continuum's wrap key (which differs from manual collections' `{"collections": [...]}`). Clients pattern-match on the wrap key when distinguishing surfaces; don't override.
|
||||
|
||||
## 5. DSL surface (full vocabulary)
|
||||
|
||||
### 5.1 Fields (15 total)
|
||||
|
||||
**Non-personalized (10):**
|
||||
|
||||
| Field | Type | Valid ops | Source |
|
||||
|---|---|---|---|
|
||||
| `title` | scalar string | `is`, `is_not`, `contains` | `media_items.title` |
|
||||
| `author` | array of strings | `is`, `is_not`, `contains` | item people: role=author |
|
||||
| `narrator` | array of strings | `is`, `is_not`, `contains` | item people: role=narrator |
|
||||
| `series` | array of strings | `is`, `is_not`, `contains` | `audiobook_series` |
|
||||
| `genre` | array of strings | `is`, `is_not`, `contains` | `media_items.genres` |
|
||||
| `year` | int | `is`, `is_not`, `gt`, `gte`, `lt`, `lte`, `between` | `media_items.release_year` |
|
||||
| `rating` | float | `gt`, `gte`, `lt`, `lte`, `between` | `media_items.rating_imdb` (or whichever rating silo populates) |
|
||||
| `language` | scalar string | `is`, `is_not` | `media_items.original_language` |
|
||||
| `publisher` | scalar string | `is`, `is_not`, `contains` | `media_items.publisher` |
|
||||
| `added_at` | timestamp | `gt`, `lt`, `between`, `in_last` | `media_items.added_at` |
|
||||
| `duration_seconds` | int | `gt`, `gte`, `lt`, `lte`, `between` | sum of media_files.duration for the item |
|
||||
|
||||
**Personalized (5)** — require `AllowPersonalized: true` (only when caller is the collection's owner):
|
||||
|
||||
| Field | Valid ops | Source |
|
||||
|---|---|---|
|
||||
| `finished` | `is` | `user_watch_progress.is_finished` |
|
||||
| `in_progress` | `is` | progress exists AND `is_finished == false` AND `current_seconds > 0` |
|
||||
| `last_played` | `gt`, `gte`, `lt`, `lte`, `between`, `in_last` | `user_watch_progress.updated_at` |
|
||||
| `abandoned` | `is` | `in_progress == true` AND `last_played < now() - 60d` (configurable via `EvaluateOptions.AbandonedAfter`) |
|
||||
| `bookmark_count` | `gt`, `gte`, `lt`, `lte`, `between` | count of `abs_bookmarks` rows for (user, profile, item) |
|
||||
|
||||
### 5.2 Sort fields
|
||||
|
||||
`title` (asc), `added_at` (desc), `year` (desc), `duration_seconds` (desc), `rating` (desc), `random` (deterministic shuffle seeded by `userID + ":" + collectionID`), and personalized: `progress` (desc), `last_played` (desc), `plays` (desc).
|
||||
|
||||
### 5.3 Operators
|
||||
|
||||
- `is` / `is_not` — equality (case-insensitive for strings; numeric otherwise). For array fields, `is` matches when ANY element equals the value.
|
||||
- `contains` — substring (case-insensitive) for scalar strings; element-substring for array fields.
|
||||
- `gt` / `gte` / `lt` / `lte` — numeric or timestamp comparison.
|
||||
- `between` — `value` is a 2-element array `[low, high]`, inclusive bounds.
|
||||
- `in_last` — relative window. `value` is a duration string (`"7d"`, `"180d"`, `"24h"`, `"4w"`). Evaluator parses to `time.Duration`; field must be ≥ `opts.Now - duration`.
|
||||
|
||||
### 5.4 Aliases
|
||||
|
||||
`authors` → `author`, `narrators` → `narrator`, `genres` → `genre` for fields. `sort_title` → `title`, `recently_added` → `added_at`, `duration` → `duration_seconds` for sorts. Applied in `Normalize()`; older clients with plural / verbose names keep working.
|
||||
|
||||
## 6. Data model
|
||||
|
||||
### 6.1 Migration 153
|
||||
|
||||
`migrations/153_abs_smart_collections.up.sql`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS public.abs_smart_collections (
|
||||
id text PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
profile_id uuid,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
color text NOT NULL DEFAULT '',
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
is_pinned boolean NOT NULL DEFAULT false,
|
||||
query_def jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx
|
||||
ON public.abs_smart_collections (
|
||||
user_id,
|
||||
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
);
|
||||
```
|
||||
|
||||
`migrations/153_abs_smart_collections.down.sql`:
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx;
|
||||
DROP TABLE IF EXISTS public.abs_smart_collections;
|
||||
```
|
||||
|
||||
### 6.2 Schema rationale
|
||||
|
||||
- **`query_def jsonb`** — JSONB so future GIN-indexed JSON path probes are an option; default `'{}'::jsonb` so rows with no rules are queryable without NULL guards.
|
||||
- **No items table** — smart collections have no stored children; items are computed at eval time.
|
||||
- **`color`, `is_pinned`** — UI decorations. silo doesn't validate the color format.
|
||||
- **No `library_ids` column** — that list is part of the DSL, lives inside `query_def`.
|
||||
|
||||
### 6.3 Go model
|
||||
|
||||
```go
|
||||
type SmartCollection struct {
|
||||
ID string
|
||||
UserID string
|
||||
ProfileID string
|
||||
Name string
|
||||
Description string
|
||||
Color string
|
||||
IsPublic bool
|
||||
IsPinned bool
|
||||
QueryDef []byte // raw JSONB bytes; decoded on the items path
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Storage contract
|
||||
|
||||
```go
|
||||
type SmartCollectionStore interface {
|
||||
ListUserSmartCollections(ctx context.Context, userID, profileID string) ([]SmartCollection, error)
|
||||
GetSmartCollection(ctx context.Context, id string) (SmartCollection, error)
|
||||
CreateSmartCollection(ctx context.Context, c SmartCollection) error
|
||||
UpdateSmartCollection(ctx context.Context, c SmartCollection) error
|
||||
DeleteSmartCollection(ctx context.Context, id string) error
|
||||
}
|
||||
```
|
||||
|
||||
Behavior follows the established silo pattern: ordered by `created_at DESC`, empty slice never nil, `GetSmartCollection` returns `ErrNotFound` without owner check (handler authorizes), `Update` sets `updated_at = now()`, `Delete` returns nil even on no-match.
|
||||
|
||||
**Extension to existing `BookmarkStore`:**
|
||||
|
||||
```go
|
||||
// CountByUser returns a map of library_item_id → bookmark count for
|
||||
// the given (user, profile). Empty map (never nil) when none.
|
||||
// Used by the smart-collection items evaluator to hydrate the
|
||||
// personalized `bookmark_count` rule in one SQL pass.
|
||||
CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error)
|
||||
```
|
||||
|
||||
SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2::uuid, sentinel) GROUP BY library_item_id`.
|
||||
|
||||
## 8. Items evaluation flow
|
||||
|
||||
`handleSmartCollectionItems` runs:
|
||||
|
||||
1. Auth + store-nil + URL `{id}` checks (same boilerplate as other handlers).
|
||||
2. `GetSmartCollection(ctx, id)` → 404 on ErrNotFound OR `(non-owner AND !isPublic)`.
|
||||
3. Decode `c.QueryDef` → `smartcoll.QueryDefinition`. 500 on malformed (shouldn't happen — `Validate` is called on persist; defensive guard).
|
||||
4. Resolve target libraries: `qd.LibraryIDs` if non-empty, else every audiobook library from `MediaStore.ListAudiobookLibraries`.
|
||||
5. For each target library, fetch the audiobook list via `MediaStore.ListAudiobooks(ctx, libID, 5000, 0)`. (5000 cap matches continuum's; silo's libraries are smaller.)
|
||||
6. **Build Candidates.** For each item:
|
||||
- `Candidate.Item` = the `*models.MediaItem`.
|
||||
- If `c.UserID == a.UserID` (owner): hydrate per-user state.
|
||||
- Fetch all progress rows: `ProgressStore.ListProgressForAudiobooks(ctx, a.UserID, a.ProfileID, 10000)`. Build `map[contentID]ProgressRow`.
|
||||
- Fetch bookmark counts: `BookmarkStore.CountByUser(ctx, a.UserID, a.ProfileID)`. Map `contentID → count`.
|
||||
- Populate `IsFinished`, `ProgressPct`, `CurrentSeconds`, `LastPlayedAt`, `BookmarkCount`.
|
||||
- Non-owner viewing public collection: skip hydration; personalized rules eval against zero-values.
|
||||
7. `smartcoll.Evaluate(ctx, qd, candidates, EvaluateOptions{AllowPersonalized: c.UserID == a.UserID, UserSeed: a.UserID + ":" + c.ID, Now: time.Now(), AbandonedAfter: 60*24*time.Hour})` → matched + sorted.
|
||||
8. Paginate: `limit, page := readPagedQuery(r, 30)`. Override default limit with `qd.Limit` when `?limit=` query param is absent. Slice `matched[page*limit : (page+1)*limit]`.
|
||||
9. Hydrate each result via the existing `siloItemToLibraryItem` helper (same as `/api/libraries/{id}/items`).
|
||||
10. Wrap in `pagedEnvelope`. Write 200.
|
||||
|
||||
### 8.1 Performance budget
|
||||
|
||||
- Library fetch: one SQL query per library (~hundreds of rows).
|
||||
- Progress hydration: one SQL query for the user's full progress list.
|
||||
- Bookmark hydration: one SQL query for the aggregated counts.
|
||||
- Total: 2 + N (libraries) queries, all paginated/limited at the store layer. silo's current 1-library config means 3 total queries per `/items` request. Fast enough.
|
||||
|
||||
## 9. Error model
|
||||
|
||||
| Condition | Status | Body |
|
||||
|---|---|---|
|
||||
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
|
||||
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
|
||||
| `name` missing on POST | 400 | `name required` |
|
||||
| `query_def` fails `Validate(allowPersonalized=true)` | 400 | `invalid query_def: <reason>` (reason from validator — safe to surface) |
|
||||
| Unknown collection on GET (owner or not) | 404 | `smart collection not found` |
|
||||
| Non-owner GET on private | 404 | same body — no leak |
|
||||
| Non-owner PATCH/DELETE/items | 404 | same body |
|
||||
| Store mutate fails | 500 | `smart collection persist failed` (delete: `smart collection delete failed`); err logged via `slog.Error` |
|
||||
| `/items` eval — `MediaStore.ListAudiobooks` fails for a library | log `slog.Warn` and skip that library | partial results beat 500 |
|
||||
| Personalized rule on non-owner-public eval | silently dropped at `smartcoll.Evaluate` via `AllowPersonalized: false` | no error to the client |
|
||||
|
||||
**Cross-cutting:** Body size limit `io.LimitReader(r.Body, 1<<20)` on all POST/PATCH. `validate(allowPersonalized=true)` runs on every persist call; on read, the items handler passes `AllowPersonalized = (c.UserID == a.UserID)` so saved personalized rules are non-fatally dropped when viewed by non-owners.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 DSL unit tests (`internal/audiobooks/smartcoll/`)
|
||||
|
||||
Port continuum's `query_test.go` and `evaluator_test.go` verbatim. The continuum reference covers:
|
||||
|
||||
- `Normalize`: aliasing, lowercase, dedupe library_ids, default-match `"all"`, default-sort `"added_at"`.
|
||||
- `Validate`: unknown field, invalid op for field, personalized rule without scope, invalid sort field, invalid sort order, negative limit.
|
||||
- `Evaluate`: each operator for each field type, AND/OR combinator for groups and definition, personalized rules drop when `AllowPersonalized: false`, `random` sort stability per `UserSeed`, `Limit` honored, empty rules match-everything.
|
||||
|
||||
### 10.2 Handler tests (`smart_collections_handler_test.go`)
|
||||
|
||||
In-memory `memSmartCollectionStore` (parallel to `memCollectionStore`); reuses `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher`. The bookmark store extension (`CountByUser`) needs a matching method on `memBookmarkStore` from sub-project 1's test file — extend that fake.
|
||||
|
||||
| Test | Asserts |
|
||||
|---|---|
|
||||
| `SmartCollection_Create_ReturnsFullShape` | POST → 200, 10 top-level keys present, ULID, queryDef round-tripped as a nested object (not raw bytes) |
|
||||
| `SmartCollection_Create_NameRequired_400` | empty name → 400 |
|
||||
| `SmartCollection_Create_InvalidBody_400` | malformed JSON → 400 |
|
||||
| `SmartCollection_Create_InvalidQueryDef_400` | `{"groups":[{"rules":[{"field":"nonsense","op":"is","value":1}]}]}` → 400 with reason in body |
|
||||
| `SmartCollection_Create_PersonalizedRuleAllowed` | rule with `field:"finished"` accepted (persist always runs with `allowPersonalized=true`) |
|
||||
| `SmartCollection_List_WrappedAsItems` | GET → `{"items": [...]}` envelope key (NOT `"collections"`) |
|
||||
| `SmartCollection_List_DoesNotLeakOtherUsers` | user 1 creates; user 2 lists → empty |
|
||||
| `SmartCollection_List_ProfileIsolation` | profile A creates; profile B lists same user → empty |
|
||||
| `SmartCollection_Get_Owner_ReturnsFullShape` | owner GET → 200 + full-shape |
|
||||
| `SmartCollection_Get_NonOwner_Public_OK` | public GET by other user → 200 |
|
||||
| `SmartCollection_Get_NonOwner_Private_404` | private GET by other user → 404 (anti-enumeration) |
|
||||
| `SmartCollection_Get_Unknown_404` | unknown ID → 404 |
|
||||
| `SmartCollection_Patch_Owner_UpdatesFields` | partial PATCH updates only present fields |
|
||||
| `SmartCollection_Patch_NonOwner_404` | non-owner PATCH → 404, original untouched |
|
||||
| `SmartCollection_Patch_InvalidQueryDef_400` | PATCH with bad query_def → 400 |
|
||||
| `SmartCollection_Delete_Owner_204` | DELETE → 204; subsequent GET → 404 |
|
||||
| `SmartCollection_Delete_NonOwner_404` | non-owner DELETE → 404, original still present |
|
||||
| `SmartCollection_Items_Owner_EvaluatesRules` | seed 3 audiobooks (two match the rule, one doesn't); GET /items returns 2 results |
|
||||
| `SmartCollection_Items_PersonalizedDroppedForNonOwner` | public collection with `field:"finished"` rule — non-owner sees ALL matching books (personalized rule silently dropped); owner sees only finished |
|
||||
| `SmartCollection_Items_PaginatedEnvelope` | GET /items → standard pagedEnvelope with 9 fields |
|
||||
| `SmartCollection_Items_RespectsQueryDefLimit` | qd.Limit=5; GET without ?limit returns 5; GET with ?limit=10 returns 10 (request overrides) |
|
||||
| `SmartCollection_Items_NonOwner_Private_404` | private collection /items by non-owner → 404 |
|
||||
| `SmartCollection_Envelope_HasRequiredKeys` | marshal-test for 10 top-level keys with empty defaults |
|
||||
|
||||
### 10.3 Live integration smoke (operator)
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl ... /login | jq -r .accessToken)
|
||||
|
||||
# Create a smart collection: "fantasy added in the last 6 months"
|
||||
SC=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"recent fantasy","query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"genre","op":"contains","value":"Fantasy"},{"field":"added_at","op":"in_last","value":"180d"}]}],"sort":{"field":"added_at","order":"desc"}}}' \
|
||||
http://127.0.0.1:13378/api/me/smart-collections | jq -r .id)
|
||||
|
||||
# Evaluate
|
||||
curl ... /api/me/smart-collections/$SC/items | jq '.results | length'
|
||||
|
||||
# Patch name
|
||||
curl -X PATCH ... -d '{"name":"renamed"}' /api/me/smart-collections/$SC | jq .name
|
||||
|
||||
# Delete
|
||||
curl -X DELETE ... /api/me/smart-collections/$SC # 204
|
||||
```
|
||||
|
||||
### 10.4 Out of scope
|
||||
|
||||
- No real-Postgres SQL test (same posture as prior sub-projects).
|
||||
- No GIN-index on `query_def`. Phase 4 follow-up if rule-querying becomes a feature.
|
||||
- No performance test. Library is small; linear scan is fine.
|
||||
|
||||
## 11. Risks & open questions
|
||||
|
||||
- **5000-item per-library cap.** If a library exceeds this, the over-fetch silently truncates. silo's libraries are well below this today; if it changes, switch to paged ListAudiobooks + accumulate.
|
||||
- **Personalized rule eval against non-owners is silent.** A non-owner viewing a public collection sees results that don't honor the owner's personalized rules. This is the privacy-correct behavior, but a confused user might wonder why "books I haven't finished" shows everything when viewed by their friend. Document in the parent spec's UX notes.
|
||||
- **`random` sort + pagination.** Two requests with the same `(user, collection)` produce the same shuffle (seeded). Browser-back / page-2 stays consistent. If the catalog mutates between requests, the seed still works but new items land in unpredictable positions — acceptable.
|
||||
- **`query_def` migration on field-catalog changes.** If silo renames a field or removes an op in the future, existing rows persist the old vocab. `Validate` would reject them, but Normalize won't drop them — we'd surface stale rules as a runtime warning in the items handler. Acceptable for v1.
|
||||
- **`description` length.** No cap; relies on the 1 MiB body limit. Same posture as other surfaces.
|
||||
|
||||
## 12. Out-of-scope follow-ups
|
||||
|
||||
- SQL-pushdown evaluator for large libraries.
|
||||
- GIN-indexed JSON path queries on `query_def`.
|
||||
- Background materialisation cache for hot smart collections.
|
||||
- Cross-collection composition ("collection A + collection B").
|
||||
- Result count badges on `/me/smart-collections` (would require running eval on every list call — expensive).
|
||||
- Audiobook recommender hooks (the `relevance` sort placeholder in continuum).
|
||||
|
||||
## 13. References
|
||||
|
||||
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
|
||||
- Sibling sub-project specs: `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`.
|
||||
- DSL reference: `continuum-plugin-audiobooks/internal/smartcoll/{query,evaluator}.go` + matching tests (port verbatim).
|
||||
- HTTP reference: `continuum-plugin-audiobooks/internal/abs/smart_collection_handler.go`.
|
||||
@@ -1,295 +0,0 @@
|
||||
# Unified User Collections (Audiobooks + Movies + TV) Design
|
||||
|
||||
**Status:** design — pending implementation plan.
|
||||
|
||||
**Source brainstorm:** in-session, 2026-05-27. Driven by audiobook coverage gaps in `page_sections`, `library_collections`, and `user_personal_collections`, plus a parallel `abs_*` collection/playlist/smart-collection stack that doesn't intersect with the silo-canonical tables.
|
||||
|
||||
**Commands assume the repository root is the cwd.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Audiobooks today live in a parallel collections world from movies and TV:
|
||||
|
||||
| Surface | Audiobook coverage today |
|
||||
|---|---|
|
||||
| `page_sections` (homepage rails) | None. Recipes reference only `movie`/`series`. |
|
||||
| `library_collections` (admin-owned, library-scoped) | Schema supports `MediaAudiobook` kind but nothing surfaces audiobook-typed admin collections in practice. |
|
||||
| `user_personal_collections` (user-owned) | Zero references to audiobooks anywhere in `internal/usercollections/`. |
|
||||
| `abs_user_collections` + `abs_collection_items` | Full audiobook-only user-curated collections behind the ABS-compat API. |
|
||||
| `abs_smart_collections` | Audiobook-only smart collections with a rule DSL (port of continuum-plugin-audiobooks). |
|
||||
| `abs_playlists` + `abs_playlist_items` | Audiobook + podcast-episode ordered playlists. |
|
||||
|
||||
Net effect: audiobooks don't appear in any of silo's first-party discovery surfaces, the ABS-app's collection/playlist features don't intersect with silo's native ones, and we maintain two parallel storage stacks.
|
||||
|
||||
The data inside the `abs_*` tables today is essentially empty (1 playlist, 0 user collections, 0 smart collections), so unification can land via hard cutover with minimal data migration cost.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- One canonical storage for user-owned lists across all media types.
|
||||
- Audiobook items eligible for `page_sections` recipes, `library_collections`, and `user_personal_collections`.
|
||||
- ABS-compat endpoints (`/api/collections`, `/api/playlists`, `/api/smart-collections`) continue to work and return identical wire shapes, but their persistence collapses into the canonical store.
|
||||
- One smart-collection engine evaluated against all media types.
|
||||
- Podcast-episode-level granularity preserved (ABS playlists can name a specific episode within a library item).
|
||||
|
||||
## 3. Non-goals
|
||||
|
||||
- Admin-curated audiobook collections via the ABS API surface. `library_collections` already supports audiobooks; whether ABS clients should see those admin lists is a separate question deferred for a later design.
|
||||
- Frontend changes. Audiobook detail/library pages already exist (per `2026-05-24-audiobook-ui-redesign`) and will consume the unified endpoints when they ship. An admin section-builder media-type selector and any UI surfacing of audiobook sections/collections are follow-up plans.
|
||||
- Cross-user shared collections, public sharing, social features. Out of scope.
|
||||
- Episode-level granularity for movies/TV. The new `sub_item_id` column is nullable and only populated by podcast playlists.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### 4.1 Storage model
|
||||
|
||||
One canonical user-owned-lists table — `user_personal_collections` — extended to discriminate four flavors via `collection_type`:
|
||||
|
||||
| `collection_type` | Semantics |
|
||||
|---|---|
|
||||
| `manual` | Existing. Hand-curated list of items. Order optional. |
|
||||
| `synced` | Existing. External sync (Trakt etc.). Items derived from external source. |
|
||||
| `playlist` | **New.** Ordered sequence; `position` column on items is meaningful. |
|
||||
| `smart` | **New.** Rule-based; items materialized at read time from `query_definition` via `internal/smartcoll/`. |
|
||||
|
||||
`user_personal_collection_items` gains one nullable column:
|
||||
|
||||
```sql
|
||||
ALTER TABLE user_personal_collection_items ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
`sub_item_id` is populated only when the entry refers to a sub-item of a library item (today: podcast episode within a podcast library item). All other entries leave it empty. Audiobook entries always use empty `sub_item_id` because an audiobook is a single `media_items` row.
|
||||
|
||||
The CHECK constraint on `collection_type` is widened to admit the new values:
|
||||
|
||||
```sql
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
|
||||
```
|
||||
|
||||
Admin-curated lists (`library_collections`, `library_collection_items`, etc.) are unchanged. They already support audiobooks through the existing `MediaKind` enum.
|
||||
|
||||
### 4.2 Smart-collection engine
|
||||
|
||||
The current audiobook-only smart-collection engine in `internal/audiobooks/smartcoll/` is promoted to `internal/smartcoll/`:
|
||||
|
||||
```
|
||||
internal/audiobooks/smartcoll/ → internal/smartcoll/
|
||||
```
|
||||
|
||||
Rationale: smart collections are no longer audiobook-only. The DSL operates on `media_items` regardless of type; audiobook-specific rule kinds (e.g. `narrator`, `series_position`) stay registered but evaluate to no-op predicates on non-audiobook items.
|
||||
|
||||
The existing simple-shape `query_definition` on `user_personal_collections` (mostly `library_ids` filter) is a strict subset of the smartcoll DSL — existing rows continue to evaluate correctly because their library-id filter maps to the new DSL's library-id predicate.
|
||||
|
||||
### 4.3 ABS-compat handler mapping
|
||||
|
||||
The three ABS store adapter files in `internal/audiobooks/` get their bodies rewritten to query the canonical tables. Method signatures stay the same so the ABS HTTP handlers above them don't change.
|
||||
|
||||
| ABS endpoint | Backing query after cutover |
|
||||
|---|---|
|
||||
| `GET /api/libraries/{id}/collections` | `SELECT … FROM user_personal_collections WHERE collection_type='manual'` joined to items in this library |
|
||||
| `POST /api/collections` | INSERT `user_personal_collections` with `collection_type='manual'`, scoping via `query_definition.library_ids` |
|
||||
| `GET /api/libraries/{id}/playlists` | `SELECT … FROM user_personal_collections WHERE collection_type='playlist'` joined to items in this library |
|
||||
| `POST /api/playlists` | INSERT `user_personal_collections` with `collection_type='playlist'` |
|
||||
| `GET /api/libraries/{id}/smart-collections` | `SELECT … FROM user_personal_collections WHERE collection_type='smart'`; items materialized via `internal/smartcoll` |
|
||||
| `episode_id` in playlist items wire shape | maps to `sub_item_id` column |
|
||||
|
||||
### 4.4 Page sections (homepage rails)
|
||||
|
||||
`page_sections` rows gain a media-types filter. The cleanest carrier is a typed column:
|
||||
|
||||
```sql
|
||||
ALTER TABLE page_sections
|
||||
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
|
||||
```
|
||||
|
||||
Default preserves the current "movies and TV" behavior on existing rows so the migration doesn't change any user-visible rails.
|
||||
|
||||
Each recipe declaration in `internal/sections/recipes/` gains a `SupportedMediaTypes` field. Fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes set `SupportedMediaTypes = ['movie','series']`; they keep their current behavior. Recipes that work for audiobooks declare `['movie','series','audiobook']` (or `['audiobook']` for audiobook-only ones).
|
||||
|
||||
Two new recipes ship at the same time:
|
||||
|
||||
- **`continue_listening`** — analog of `continue_watching`; audiobook items with non-zero progress and not finished.
|
||||
- **`by_audiobook_series`** — book series rail, drawing from the `audiobook_series` table; mirrors `by_show`.
|
||||
|
||||
More audiobook-flavored recipes (top narrators, by genre, new from followed authors) are deferred until the baseline two are exercised.
|
||||
|
||||
### 4.5 ABS app contract preservation
|
||||
|
||||
Wire-shape compatibility is part of the contract — the ABS Android/iOS apps cannot break. The store-adapter rewrites must produce byte-for-byte identical JSON for the existing endpoints. Concretely:
|
||||
|
||||
- IDs remain stable for the moved row(s). The migration preserves `abs_playlists.id` as the new `user_personal_collections.id`.
|
||||
- `episode_id` field is emitted from `sub_item_id` (empty string when null/empty).
|
||||
- `is_public` field on playlists/smart collections is emitted from `is_shared`.
|
||||
- `cover_item` on playlists — needs decision (see §6).
|
||||
|
||||
### 4.6 Module structure after the change
|
||||
|
||||
```
|
||||
internal/
|
||||
smartcoll/ -- NEW (promoted from audiobooks/smartcoll/)
|
||||
types.go
|
||||
evaluator.go
|
||||
rule_registry.go
|
||||
usercollections/ -- existing; extended for playlist/smart kinds
|
||||
listing.go -- gains Kind filter
|
||||
types.go -- adds CollectionKind = playlist|smart
|
||||
smartfetch.go -- new; materializes smart-collection items via internal/smartcoll
|
||||
sections/
|
||||
recipes/
|
||||
*.go -- each gains SupportedMediaTypes
|
||||
continue_listening.go -- new
|
||||
by_audiobook_series.go -- new
|
||||
audiobooks/
|
||||
abs_collection_store.go -- rewritten: canonical SQL
|
||||
abs_playlist_store.go -- rewritten: canonical SQL
|
||||
abs_smart_collection_store.go -- rewritten: canonical SQL + smartcoll
|
||||
smartcoll/ -- REMOVED (lifted to internal/smartcoll/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration
|
||||
|
||||
Single pair: `migrations/156_unify_user_collections.up.sql` / `.down.sql`. Hard cutover.
|
||||
|
||||
### 5.1 Up
|
||||
|
||||
```sql
|
||||
-- 1. Extend canonical items table with sub-item granularity.
|
||||
ALTER TABLE user_personal_collection_items
|
||||
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
|
||||
|
||||
-- 2. Widen the collection_type enum.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
|
||||
|
||||
-- 3. Move the single existing abs_playlists row.
|
||||
INSERT INTO user_personal_collections
|
||||
(id, user_id, profile_id, name, description, collection_type,
|
||||
is_shared, created_at, updated_at, creator_profile_id)
|
||||
SELECT
|
||||
id, user_id, COALESCE(profile_id::text, ''), name, description, 'playlist',
|
||||
is_public, created_at, updated_at, COALESCE(profile_id::text, '')
|
||||
FROM abs_playlists;
|
||||
|
||||
INSERT INTO user_personal_collection_items
|
||||
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
|
||||
SELECT p.user_id, i.playlist_id, i.library_item_id, i.episode_id, i.position, i.added_at
|
||||
FROM abs_playlist_items i
|
||||
JOIN abs_playlists p ON p.id = i.playlist_id;
|
||||
|
||||
-- 4. Drop the abs_* collection tables.
|
||||
DROP TABLE abs_playlist_items;
|
||||
DROP TABLE abs_playlists;
|
||||
DROP TABLE abs_collection_items;
|
||||
DROP TABLE abs_user_collections;
|
||||
DROP TABLE abs_smart_collections;
|
||||
|
||||
-- 5. Extend page_sections with media-type filter.
|
||||
ALTER TABLE page_sections
|
||||
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
|
||||
```
|
||||
|
||||
### 5.2 Down
|
||||
|
||||
```sql
|
||||
-- Reverse the page_sections column.
|
||||
ALTER TABLE page_sections DROP COLUMN media_types;
|
||||
|
||||
-- Recreate the abs_* tables empty by re-applying their CREATE TABLE
|
||||
-- statements verbatim from the original migration files (no data restored):
|
||||
-- migrations/149_abs_user_collections.up.sql → abs_user_collections
|
||||
-- migrations/150_abs_collection_items.up.sql → abs_collection_items
|
||||
-- migrations/151_abs_playlists.up.sql → abs_playlists
|
||||
-- migrations/152_abs_playlist_items.up.sql → abs_playlist_items
|
||||
-- migrations/153_abs_smart_collections.up.sql → abs_smart_collections
|
||||
-- The implementer copies the CREATE TABLE bodies (and indexes) into this
|
||||
-- down migration; constraint/index names must match the originals so a
|
||||
-- subsequent up-down-up cycle is idempotent.
|
||||
|
||||
-- Remove rows we promoted.
|
||||
DELETE FROM user_personal_collection_items
|
||||
WHERE collection_id IN (
|
||||
SELECT id FROM user_personal_collections WHERE collection_type IN ('playlist','smart')
|
||||
);
|
||||
DELETE FROM user_personal_collections WHERE collection_type IN ('playlist','smart');
|
||||
|
||||
-- Restore narrow CHECK constraint.
|
||||
ALTER TABLE user_personal_collections
|
||||
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
|
||||
ALTER TABLE user_personal_collections
|
||||
ADD CONSTRAINT user_personal_collections_type_check
|
||||
CHECK (collection_type IN ('manual', 'synced'));
|
||||
|
||||
-- Drop the sub_item_id column.
|
||||
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
|
||||
```
|
||||
|
||||
The down migration is symmetrically structured but lossy in reverse: rolling back loses any playlist/smart rows created after the cutover. That's acceptable because the abs_* tables were near-empty before the up.
|
||||
|
||||
### 5.3 Risk
|
||||
|
||||
| Risk | Probability | Mitigation |
|
||||
|---|---|---|
|
||||
| Down-migration loses real user data | Low (one row today; grows over time) | Snapshot the table before running down in any prod context |
|
||||
| ABS app sees wire-shape regressions | Medium | Snapshot tests on JSON output of each ABS endpoint before/after |
|
||||
| smartcoll DSL gap (silo-native query_definition row that doesn't fit DSL subset) | Low | Pre-migration query enumerates non-conforming rows; spec asserts subset compat |
|
||||
| `library_collections` UI gains audiobook rows it can't render | Low | UI changes deferred; admin doesn't currently create audiobook-typed library collections |
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions
|
||||
|
||||
1. **Playlist `cover_item` field.** `abs_playlists.cover_item` is a FK to `media_items.content_id`. `user_personal_collections.poster_url` is a plain text URL. Two options for the migration: (a) drop `cover_item`, regenerate poster URLs from the first item in the playlist; or (b) add a `cover_content_id` column to `user_personal_collections`. Recommend (a) for simplicity — the existing playlist row has no `cover_item` set.
|
||||
|
||||
2. **`is_pinned` on smart collections.** `abs_smart_collections.is_pinned` has no analog in `user_personal_collections`. Likely needs a new boolean column; defer until smart-collection UI work names a use case.
|
||||
|
||||
3. **`color` on smart collections.** `abs_smart_collections.color` has no analog. Same call as `is_pinned` — defer; the existing 0 rows means no data to preserve.
|
||||
|
||||
4. **Admin-curated audiobook collections in the ABS API.** Out of scope per §3, but flagged for a future design.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- **Migration round-trip test.** Run `up.sql` → `down.sql` → `up.sql` on a fresh test DB seeded with one of each `abs_*` row. Assert canonical-table contents are identical before and after.
|
||||
- **ABS endpoint wire-shape snapshots.** Generate JSON for `GET /api/libraries/{id}/collections|playlists|smart-collections` before the cutover; assert byte-equal after.
|
||||
- **smartcoll engine portability.** Move the existing `internal/audiobooks/smartcoll/*_test.go` into `internal/smartcoll/` and add three new tests:
|
||||
- audiobook-specific rule (`narrator` filter) evaluated against an audiobook → expected items.
|
||||
- audiobook-specific rule evaluated against a movie → empty set (no-op predicate).
|
||||
- simple library-id filter (silo-native query_definition shape) evaluated under the unified DSL → expected items.
|
||||
- **Section recipe regression.** Existing recipes with default `media_types=['movie','series']` return identical results to today. New `continue_listening` and `by_audiobook_series` recipes covered with focused unit tests.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sub-projects
|
||||
|
||||
This design is decomposed for implementation:
|
||||
|
||||
1. **Schema migration + canonical storage extensions** — migration 156, CHECK/column additions, no behavior changes yet.
|
||||
2. **Smart-collection engine lift** — move `internal/audiobooks/smartcoll/` to `internal/smartcoll/`, update imports, no logic changes.
|
||||
3. **ABS store adapter rewrites** — collection/playlist/smart-collection stores query canonical tables.
|
||||
4. **Section recipes for audiobooks** — recipe `SupportedMediaTypes` field, two new recipes, fetcher type filter.
|
||||
5. **Documentation + follow-up tickets** — open-question call-outs (cover_item, is_pinned, color, admin collections in ABS).
|
||||
|
||||
Each sub-project has a single-MR scope and ships independently of the others. Sub-project 1 lands first; sub-projects 2–4 can land in any order after it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Source references
|
||||
|
||||
- Existing canonical tables: `internal/usercollections/`, `internal/collections/templates/templates.go`
|
||||
- Existing smart-collection engine: `internal/audiobooks/smartcoll/`
|
||||
- Existing section recipes: `internal/sections/recipes/`, `internal/api/handlers/sections_preview.go`, `internal/api/handlers/sections_bulk.go`, `internal/api/handlers/recipes.go`
|
||||
- Existing ABS store adapters: `internal/audiobooks/abs_collection_store.go`, `internal/audiobooks/abs_playlist_store.go`, `internal/audiobooks/abs_smart_collection_store.go`
|
||||
- Related shipped specs: `docs/superpowers/specs/2026-05-26-abs-smart-collections-design.md`, `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
|
||||
@@ -1,311 +0,0 @@
|
||||
# AI services: shared core, metadata translation, Whisper ASR — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Draft, pending review
|
||||
**Scope:** Extract the AI subtitle translation engine into a shared AI core, add AI translation of movie/series/season/episode descriptions into the existing localization tables, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). One PR.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
`internal/subtitles/ai` ships an on-demand LLM subtitle translator. The LLM plumbing inside
|
||||
it (OpenAI-compatible chat client with retry/backoff, batched indexed-JSON translation,
|
||||
job lifecycle with idempotency/heartbeat/reaper/bounded concurrency) is generic, but it is
|
||||
buried in a subtitle-specific package, so nothing else can use it.
|
||||
|
||||
Two features want exactly that plumbing:
|
||||
|
||||
1. **Metadata translation.** Libraries whose `metadata_language` the providers cannot fully
|
||||
serve end up with overviews/taglines in the item's default language (usually English).
|
||||
The localization tables (`media_item_localizations`, `season_localizations`,
|
||||
`episode_localizations`) and the serving path (`LocalizeItemModel` and friends in
|
||||
`internal/catalog/detail.go`) already exist — the gap is producing rows for languages the
|
||||
providers don't have.
|
||||
2. **Whisper ASR.** `subtitle_ai_jobs.kind` already reserves `transcribe` and
|
||||
`transcribe_translate`; media with no usable text subtitle track (foreign audio with only
|
||||
bitmap subs, or no subs at all) currently has no AI path to a subtitle track.
|
||||
|
||||
## Goals
|
||||
|
||||
- One shared AI core: a single OpenAI-compatible endpoint configuration (chat + audio
|
||||
transcription), one retry/backoff implementation, one generic batched text translator,
|
||||
one job-lifecycle runner — consumed by subtitle translation, metadata translation, and ASR.
|
||||
- AI-translated overviews and taglines stored in the **existing** localization tables, so
|
||||
web, Android, Apple, and jellycompat clients get them through the current serving path
|
||||
with **zero client changes**.
|
||||
- Provenance on AI-writable localization fields so refreshes never regress data quality:
|
||||
`manual` beats `provider` beats `ai`. A later provider localization overwrites an AI
|
||||
translation; the reverse never happens.
|
||||
- Two metadata-translation triggers: a manual admin action, and an opt-in per-library
|
||||
fallback ("provider had no localization for the library language → AI-translate") wired
|
||||
into metadata ingestion.
|
||||
- ASR jobs that produce ordinary `downloaded_subtitles` rows (provider `transcribed`),
|
||||
with `transcribe_translate` chaining the existing LLM translator for a target-language
|
||||
track; live cue streaming to the requesting playback session, same as translation today.
|
||||
- Total AI load on the configured endpoint bounded by **one** shared semaphore.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Episode/movie/series title translation.** Titles often have official localized forms
|
||||
that are not literal translations; provider data only.
|
||||
- **Collection names or descriptions.** Deferred: collections have no localization table
|
||||
yet. The metadata translation service is written against content-ID + field-list inputs,
|
||||
so a `collection_localizations` table can be added in a follow-up without reshaping it.
|
||||
- **Genre translation.** If ever wanted, that is a static mapping, not an LLM call.
|
||||
- **Per-profile metadata language.** Presentation language stays library-scoped
|
||||
(`media_folders.metadata_language`); changing that is an orthogonal feature.
|
||||
- **A localization editor UI.** The `manual` provenance value is honored by the write
|
||||
rules but no UI sets it yet (the existing metadata editor edits base fields, not
|
||||
localizations).
|
||||
- **Whisper word-level timing, diarization, VAD-aligned chunking.** v1 uses fixed-length
|
||||
chunks; boundary-word artifacts are an accepted limitation noted for follow-up.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package layout
|
||||
|
||||
```
|
||||
internal/ai/llm Client: OpenAI-compatible /v1/chat/completions (moved from
|
||||
internal/subtitles/ai/client.go) + new /v1/audio/transcriptions.
|
||||
Shared retry/backoff/429 handling. Config for the endpoint(s).
|
||||
internal/ai/translate Generic batched text translation: []Segment{ID, Text} in,
|
||||
translated segments out. Indexed-JSON protocol, context
|
||||
neighbors, malformed-response retries — moved from
|
||||
internal/subtitles/ai/translator.go and de-subtitled.
|
||||
internal/ai/jobrunner Generic job lifecycle: bounded dispatch off a shared semaphore,
|
||||
heartbeat loop, stale-job reaper, cancel registry — extracted from
|
||||
internal/subtitles/ai/service.go behind a small repo interface.
|
||||
internal/subtitles/ai Stays: subtitle job semantics. Translate path becomes a thin
|
||||
adapter (cues ↔ segments); gains the transcribe paths.
|
||||
internal/metadata/translation
|
||||
New: metadata translation job service + repo + provenance-aware
|
||||
writes into the localization repos.
|
||||
```
|
||||
|
||||
Package names: `llm`, `aitranslate`, `jobrunner` (import paths above; `aitranslate` avoids
|
||||
a name collision when imported from package `ai`).
|
||||
|
||||
### Shared semaphore
|
||||
|
||||
`cmd/silo` builds one `chan struct{}` of size `ai.max_concurrent_jobs` and hands it to both
|
||||
job services via `jobrunner`. Subtitle, metadata, and ASR jobs all draw from it, so the
|
||||
operator's endpoint sees a bounded number of concurrent jobs regardless of job mix.
|
||||
(An ASR job holds one slot for its whole pipeline: extract → transcribe → translate.)
|
||||
|
||||
## Settings
|
||||
|
||||
Connection settings move to a shared `ai.*` namespace. Because encrypted values are
|
||||
GCM-bound to their setting key (see `internal/catalog/encrypted_settings_repo.go`), the
|
||||
legacy rows are **not renamed**; the loader falls back to them, following the precedent of
|
||||
`recommendations.embedding_auth_token` → `recommendations.openai_api_key` in
|
||||
`internal/config/db_loader.go`.
|
||||
|
||||
| Key | Default | Fallback | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `ai.base_url` | `https://api.openai.com` | `subtitle_ai.base_url` | |
|
||||
| `ai.api_key` | `""` | `subtitle_ai.api_key` | sensitive |
|
||||
| `ai.chat_model` | `gpt-4o-mini` | `subtitle_ai.chat_model` | |
|
||||
| `ai.max_concurrent_jobs` | `2` | `subtitle_ai.max_concurrent_jobs` | shared semaphore size |
|
||||
| `ai.asr_model` | `whisper-1` | — | |
|
||||
| `ai.asr_base_url` | `""` (= `ai.base_url`) | — | separate local Whisper server |
|
||||
| `ai.asr_api_key` | `""` (= `ai.api_key`) | — | sensitive |
|
||||
| `subtitle_ai.enabled` | `false` | — | existing; subtitle translation toggle |
|
||||
| `subtitle_ai.transcribe_enabled` | `false` | — | new; ASR toggle |
|
||||
| `subtitle_ai.batch_size` | `40` | — | existing; cues per chat request |
|
||||
| `subtitle_ai.context_neighbors` | `2` | — | existing |
|
||||
| `metadata_ai.enabled` | `false` | — | new; metadata translation toggle |
|
||||
|
||||
`ai.api_key` and `ai.asr_api_key` join `sensitiveSettingKeys`. The admin UI reads through
|
||||
the same fallback (effective values come from the loaded config) and writes the new keys.
|
||||
|
||||
Config structs: `AIConfig` (connection + concurrency), `SubtitleAIConfig` (slims down to
|
||||
toggles + batch tuning), new `MetadataAIConfig`. Readiness: subtitle translate requires
|
||||
`subtitle_ai.enabled` + chat config; ASR requires `subtitle_ai.transcribe_enabled` + ASR
|
||||
config; metadata requires `metadata_ai.enabled` + chat config.
|
||||
|
||||
## Metadata translation
|
||||
|
||||
### What gets translated
|
||||
|
||||
| Target | Fields |
|
||||
| --- | --- |
|
||||
| movie / series (`media_item_localizations`) | `overview`, `tagline` |
|
||||
| season (`season_localizations`) | `overview` |
|
||||
| episode (`episode_localizations`) | `overview` |
|
||||
|
||||
Titles, sort titles, and artwork columns in those rows are never written by AI.
|
||||
|
||||
### Provenance
|
||||
|
||||
New columns (default `'provider'`, check-constrained to `provider|ai|manual`):
|
||||
|
||||
- `media_item_localizations.overview_source`, `media_item_localizations.tagline_source`
|
||||
- `season_localizations.overview_source`
|
||||
- `episode_localizations.overview_source`
|
||||
|
||||
Write rules, enforced in the localization repos (single-statement upserts with CASE):
|
||||
|
||||
| Writer | May overwrite | Sets source | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| provider ingestion | `provider`, `ai` | `provider` | a non-empty provider value always replaces an AI value; an **empty** provider value never blanks an existing one |
|
||||
| AI translation | `ai`, empty | `ai` | with `force`, may also overwrite `provider` (admin explicitly re-translating) — never `manual` |
|
||||
| manual (future editor) | anything | `manual` | |
|
||||
|
||||
Serving hardening: `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel`
|
||||
must only override base fields with **non-empty** localization values, since AI-created
|
||||
rows legitimately carry empty titles. (Verify current behavior; fix if it clobbers.)
|
||||
|
||||
### Job model
|
||||
|
||||
New table `metadata_translation_jobs`, mirroring `subtitle_ai_jobs` conventions
|
||||
(status enum, progress, idempotency partial-unique index, heartbeat):
|
||||
|
||||
- `target_kind` (`item` | `season` | `episode`), `content_id`, `include_children`
|
||||
- `target_language`, `source_language` (default language of the base row)
|
||||
- `engine`, `model`, `status`, `progress`, `progress_message`, `fields_done`, `fields_total`
|
||||
- `error_message`, `idempotency_key`, `requested_by`, `force`, timestamps, `heartbeat_at`
|
||||
|
||||
A job for a series item with `include_children` expands to: item overview + tagline, every
|
||||
season overview, every episode overview. All fields become segments
|
||||
(`item:overview`, `season:<content_id>:overview`, `episode:<content_id>:overview`, …) fed
|
||||
to the generic translator in batches (metadata batch size constant, smaller than the
|
||||
subtitle one — paragraphs, not cue lines), so episode descriptions share terminology in one
|
||||
prompt context. The system prompt names the series/movie and year for grounding.
|
||||
|
||||
Skip logic in the run loop (not at enqueue): a field whose localization value is already
|
||||
non-empty is skipped unless `force` — so the auto trigger costs zero LLM calls on repeat
|
||||
refreshes. If every field is skipped the job completes immediately.
|
||||
|
||||
Idempotency key: `content_id | target_kind | target_language | model`, active-only partial
|
||||
unique index (completed/failed rows never block a retry).
|
||||
|
||||
### Triggers
|
||||
|
||||
1. **Manual.** Admin action on the item detail metadata editor: pick target language
|
||||
(defaults to the library's `metadata_language`), `include_children`, `force`. Gated by
|
||||
the same permission as metadata editing.
|
||||
2. **Auto fallback.** New `media_folders.auto_translate_metadata boolean NOT NULL DEFAULT
|
||||
false`. At the end of a metadata refresh for an item (after provider localizations are
|
||||
persisted in `internal/metadata/service.go`), if the folder has the flag, has a
|
||||
`metadata_language`, that language differs from the item's default metadata language,
|
||||
and the localization row for it is missing a translated field — enqueue a non-force job
|
||||
for the item (children included for series). Enqueue is fire-and-forget; refresh latency
|
||||
is unaffected.
|
||||
|
||||
### API
|
||||
|
||||
Admin-gated, mirroring the subtitle AI handler shapes:
|
||||
|
||||
- `GET /api/v1/metadata/ai/status`
|
||||
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}`
|
||||
- `GET /api/v1/metadata/ai/jobs/{job_id}` / `POST …/cancel`
|
||||
- `GET /api/v1/metadata/ai/jobs?content_id=…`
|
||||
|
||||
Progress UI polls the job endpoint (jobs are short; no websocket events in v1).
|
||||
|
||||
## Whisper ASR
|
||||
|
||||
### Kinds
|
||||
|
||||
`subtitle_ai_jobs.kind` gains its reserved values:
|
||||
|
||||
- `transcribe` — audio track → subtitle track in the spoken language.
|
||||
- `transcribe_translate` — `transcribe`, then the existing LLM translation chained to
|
||||
`target_language`. Both the transcript track and the translated track are stored (the
|
||||
transcript is a cache: a second target language skips ASR entirely because the transcript
|
||||
is now a selectable text source for a plain `translate` job). `result_subtitle_id`
|
||||
points at the translated track.
|
||||
|
||||
For ASR kinds, `source_index` holds the **audio** track index (`-1` = default/first audio
|
||||
track); `source_language` is an optional hint passed to Whisper, else taken from the
|
||||
detected `verbose_json.language`.
|
||||
|
||||
### Pipeline
|
||||
|
||||
1. Resolve the audio track from `media_files`; reject files with no audio.
|
||||
2. Extract once with ffmpeg into fixed chunks in a job temp dir
|
||||
(`-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time 600`):
|
||||
16 kHz mono WAV, 10-minute chunks (~19 MB, under typical 25 MB API limits). Temp dir
|
||||
removed on every exit path.
|
||||
3. Per chunk: `POST /v1/audio/transcriptions` (multipart: file, model,
|
||||
`response_format=verbose_json`, optional `language`, `temperature=0`) via the shared
|
||||
`llm.Client` with the shared retry/backoff. Segment timestamps are offset by the chunk
|
||||
start and merged.
|
||||
4. Cue building: one cue per Whisper segment; text wrapped to at most two lines
|
||||
(~42 chars/line). Empty/whitespace segments dropped.
|
||||
5. Store as `downloaded_subtitles` (provider `transcribed`, SRT, release name
|
||||
`"<Language> (AI transcribed)"`), notify `SubtitleReady`.
|
||||
6. `transcribe_translate`: feed the cues to the existing `Translator`; store the translated
|
||||
track (provider `translated`) and complete.
|
||||
|
||||
Chunks are processed starting from the chunk containing the viewer's playhead, then
|
||||
wrapping — same UX as translation — and cues stream live to the requesting session through
|
||||
the existing notifier. Progress bands: extract 0–10 %, transcribe 10–70 % (per chunk),
|
||||
translate 70–95 %.
|
||||
|
||||
Known v1 limitation: fixed chunk boundaries can clip a word at the seam; acceptable, noted
|
||||
for a silence-aligned follow-up.
|
||||
|
||||
### API / UI
|
||||
|
||||
The existing enqueue endpoint `POST /api/v1/subtitles/ai/translate` gains an optional
|
||||
`kind` (default `translate`; backward compatible). `GET /api/v1/subtitles/ai/status` gains
|
||||
`transcribe_enabled`. The player's translate modal gains a "Generate from audio" mode that
|
||||
lists audio tracks and a target language — the natural path when the only subtitle tracks
|
||||
are bitmap (today's hard error) or none exist.
|
||||
|
||||
## Frontend
|
||||
|
||||
- **AI Services settings page** (`web/src/pages/admin-settings/`): connection card
|
||||
(base URL, API key, chat model, ASR model, optional ASR base URL/key), features card
|
||||
(subtitle translation, ASR, metadata translation toggles; concurrency; batch tuning).
|
||||
The AI card currently inside `SubtitlesSettings.tsx` moves here; subtitle settings keep
|
||||
the download-provider config.
|
||||
- **Metadata editor**: "Translate with AI" action in `EditMetadataDialog.tsx` (language,
|
||||
include children, force), with job-poll progress and query invalidation on completion.
|
||||
- **Library settings**: `auto_translate_metadata` toggle next to the metadata-language
|
||||
field.
|
||||
- **Player**: "Generate from audio" mode in `SubtitleTranslateModal.tsx`.
|
||||
|
||||
## Client (Android / Apple) impact
|
||||
|
||||
None required. Translated overviews/taglines arrive through the existing localized detail
|
||||
responses; ASR/translated tracks arrive as ordinary downloaded subtitles. Optional
|
||||
follow-ups in the client repos: surface "Generate from audio" in their subtitle menus
|
||||
(the jobs API is shared) and the library auto-translate toggle in their admin surfaces.
|
||||
|
||||
## Reliability
|
||||
|
||||
- All jobs: idempotency dedup of in-flight work, heartbeat + stale reaper (crash-safe,
|
||||
multi-instance safe), terminal-state handling on cancel/shutdown — one implementation in
|
||||
`jobrunner`, no copy-paste between the two services.
|
||||
- Provider-wins/manual-wins provenance is enforced in SQL (single-statement upserts), not
|
||||
in racy read-modify-write Go code.
|
||||
- The shared semaphore bounds total endpoint load; ASR temp space is cleaned on success,
|
||||
failure, cancel, and shutdown.
|
||||
- Auto-translate re-runs are free: skip-if-filled happens before any LLM call.
|
||||
|
||||
## Migrations
|
||||
|
||||
One Goose migration (`make migrate-create NAME=ai_metadata_translation_and_asr`):
|
||||
|
||||
1. `metadata_translation_jobs` table + active-idempotency / content / status indexes.
|
||||
2. Provenance columns on the three localization tables (+ check constraints).
|
||||
3. `media_folders.auto_translate_metadata`.
|
||||
|
||||
Down: drop the table, columns, and constraint.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Whisper-endpoint compatibility.** OpenAI, Groq, faster-whisper-server/speaches all
|
||||
speak `verbose_json`, but segment quality varies; the cue builder must tolerate missing
|
||||
segment arrays (fall back to one cue per chunk worth of `text` is *not* acceptable —
|
||||
fail the job with a clear message instead).
|
||||
- **`LocalizeItemModel` empty-field semantics** must be verified before AI rows (empty
|
||||
titles) ship; this is called out as an explicit plan task.
|
||||
- **Long-running ASR on slow local Whisper servers** can exceed the 10-minute HTTP client
|
||||
timeout per chunk; the transcription call uses a per-request timeout sized for chunk
|
||||
duration rather than the client default.
|
||||
- **Settings sprawl.** The `ai.*`/legacy fallback leaves old `subtitle_ai.*` connection
|
||||
rows in place indefinitely; harmless, but a future settings-GC could prune them once the
|
||||
new keys are written.
|
||||
@@ -1,78 +0,0 @@
|
||||
# On-view description translation + per-profile metadata language — design
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Status:** Approved direction (per-profile preference; auto with button fallback; any user behind an opt-in setting)
|
||||
**Builds on:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md`
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
Metadata translation currently triggers from the admin editor or the per-library
|
||||
refresh fallback, always targeting the library's metadata language. A viewer whose
|
||||
language differs from the library's (or whose library was never auto-translated) has
|
||||
no way to get descriptions in their language, and there is no per-person language
|
||||
preference at all — presentation language is library-scoped.
|
||||
|
||||
## Design
|
||||
|
||||
### Per-profile preferred metadata language
|
||||
|
||||
- New column `user_profiles.preferred_metadata_language text NOT NULL DEFAULT ''`
|
||||
(empty = inherit the library's metadata language). Threaded through the profile
|
||||
model/repo/API exactly like the existing `language` (UI language) field, plus a
|
||||
picker in the profile settings UI with an "Inherit library default" option.
|
||||
- `access.Scope` gains `PreferredMetadataLanguage`, populated when the access
|
||||
resolver loads the profile, so every request already carries it.
|
||||
- `catalog.AccessFilter` gains `ProfilePreferredLanguage`, and presentation language
|
||||
resolution becomes: explicit `PresentationLanguage` (request param) →
|
||||
**profile preference** → library `metadata_language`. Applied wherever the scope
|
||||
is available (native API and jellycompat), so all clients get profile-localized
|
||||
metadata server-side.
|
||||
|
||||
### "Not in your language" signal
|
||||
|
||||
`ItemDetail` gains `pending_translation_language` (omitted when empty): set when the
|
||||
resolved presentation language differs from the item's default metadata language,
|
||||
the base overview is non-empty, and no localization overview exists for that
|
||||
language. It is pure data — clients combine it with the AI feature status to decide
|
||||
what to render.
|
||||
|
||||
### On-view trigger
|
||||
|
||||
- New setting `metadata_ai.on_view`: `off` (default) | `button` | `auto`, surfaced
|
||||
on `GET /api/v1/metadata/ai/status` as `on_view`.
|
||||
- New authenticated endpoint `POST /api/v1/items/{id}/translate-description`
|
||||
`{target_language}` — any profile, gated by `metadata_ai` readiness +
|
||||
`on_view != off`, item access enforced with the same `EnsureAccessible` check the
|
||||
watched-state endpoints use. The client echoes back the language the server
|
||||
reported in `pending_translation_language`.
|
||||
- The endpoint goes through a new `Service.RequestOnView`, which adds one guard on
|
||||
top of the existing pipeline: if the latest job for (content, language) failed
|
||||
within a cooldown window, return it instead of enqueuing — a broken endpoint must
|
||||
not be retried on every page view. Existing guarantees do the rest: in-flight
|
||||
idempotency collapses concurrent viewers onto one job, skip-if-filled makes
|
||||
repeats free, and the shared semaphore caps endpoint load.
|
||||
|
||||
### Web UX
|
||||
|
||||
On the detail page, when `pending_translation_language` is set:
|
||||
|
||||
- `on_view=auto`: fire the request on view (once per item), pulse-animate the
|
||||
description while waiting, and poll the detail query until the flag clears (the
|
||||
job's first batch translates the item's own overview, so this lands in seconds
|
||||
even when a series job continues episodes in the background). Time out after ~45 s
|
||||
and show the original text — no error states in the viewer's face.
|
||||
- `on_view=button`: a small "Translate" chip near the description triggers the same
|
||||
flow on click.
|
||||
|
||||
No job-status endpoint for viewers: completion is observed as the flag clearing on
|
||||
refetch; failures surface only as the shimmer timing out (the cooldown stops
|
||||
re-triggering).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-profile language in the Android/Apple UIs (server-side serving means they
|
||||
already *receive* localized metadata; surfacing the trigger there is a client
|
||||
follow-up using the same endpoint).
|
||||
- Translating titles, or any admin-side behavior changes.
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Silo-Server/silo-server
|
||||
|
||||
go 1.26.4
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
@@ -14,69 +14,46 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sys v0.43.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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
|
||||
github.com/h2non/bimg v1.1.9
|
||||
github.com/hashicorp/go-hclog v1.6.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
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/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
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/image v0.39.0
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.10.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||
github.com/zishang520/engine.io-go-parser v1.3.2 // indirect
|
||||
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
|
||||
github.com/zishang520/webtransport-go v0.9.1 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0
|
||||
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
|
||||
@@ -101,10 +78,8 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
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/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.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.5.0/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=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
@@ -46,6 +38,7 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -53,8 +46,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
@@ -71,19 +62,14 @@ 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=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
|
||||
@@ -112,8 +98,6 @@ github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
@@ -137,15 +121,6 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
@@ -169,14 +144,6 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
|
||||
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -207,31 +174,16 @@ github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibgg
|
||||
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
|
||||
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
|
||||
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=
|
||||
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=
|
||||
github.com/zishang520/engine.io-go-parser v1.3.2/go.mod h1:fg/R4V7aytYwUTu4lGcPdjenDSXFWLlkDAGewWVOo3o=
|
||||
github.com/zishang520/engine.io/v2 v2.5.0 h1:0ayZCt51c8lntxG5AWoM2mX40ryZlvRodAULXB1XK/s=
|
||||
github.com/zishang520/engine.io/v2 v2.5.0/go.mod h1:ohfMsnzOCA9NEklEGiQ5Y9j6cWvzLNeVFaB+Bkn1KcQ=
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+qUJZo9beZLK8ZXOwKYo=
|
||||
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
|
||||
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
|
||||
go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
|
||||
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -250,96 +202,29 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
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/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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=
|
||||
|
||||
@@ -73,7 +73,6 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
|
||||
|
||||
scope.MaxContentRating = profile.MaxContentRating
|
||||
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
|
||||
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
|
||||
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
|
||||
|
||||
|
||||
@@ -9,11 +9,8 @@ type Scope struct {
|
||||
LibrariesRestricted bool
|
||||
MaxContentRating string
|
||||
MaxPlaybackQuality string
|
||||
// PreferredMetadataLanguage is the profile's metadata (presentation)
|
||||
// language; "" inherits the library's metadata language.
|
||||
PreferredMetadataLanguage string
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
}
|
||||
|
||||
// ResolveInput is the request input for resolving a viewer access scope.
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
// Package jobrunner owns the lifecycle mechanics shared by Silo's AI job
|
||||
// services (subtitle translation/ASR, metadata translation): bounded dispatch
|
||||
// off a semaphore shared across services, a heartbeat loop that keeps a row
|
||||
// alive even while the job is queued, a stale-job reaper for rows orphaned by
|
||||
// a crashed worker, and a per-job cancel registry. The services keep their own
|
||||
// job tables, validation, and run logic; this package keeps them honest about
|
||||
// concurrency and crash recovery without copy-pasting the trickiest code.
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// A running job refreshes its heartbeat every HeartbeatInterval; one whose
|
||||
// heartbeat has not advanced for StaleJobThreshold is treated as orphaned
|
||||
// by a crashed worker and reaped. The margin over HeartbeatInterval avoids
|
||||
// reaping a job that is merely mid–LLM-call.
|
||||
HeartbeatInterval = 30 * time.Second
|
||||
StaleJobThreshold = 2 * time.Minute
|
||||
// How often the background reaper scans for orphaned jobs.
|
||||
ReaperInterval = time.Minute
|
||||
)
|
||||
|
||||
// Store is the minimal persistence surface the runner needs. Both AI job
|
||||
// repositories satisfy it.
|
||||
type Store interface {
|
||||
Heartbeat(ctx context.Context, id int64) error
|
||||
// ResetStaleJobs marks pending/running jobs whose heartbeat predates
|
||||
// `before` as failed with the given message. Returns rows reset.
|
||||
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
|
||||
}
|
||||
|
||||
// NewSemaphore builds the dispatch semaphore shared across runners, so the
|
||||
// configured endpoint sees one global bound regardless of job mix. size <= 0
|
||||
// falls back to 2.
|
||||
func NewSemaphore(size int) chan struct{} {
|
||||
if size <= 0 {
|
||||
size = 2
|
||||
}
|
||||
return make(chan struct{}, size)
|
||||
}
|
||||
|
||||
// Runner executes jobs with bounded concurrency, heartbeats, cancellation,
|
||||
// and crash recovery. One Runner per job table; the semaphore may be shared
|
||||
// across Runners.
|
||||
type Runner struct {
|
||||
// baseCtx is the application context; dispatched jobs and the reaper
|
||||
// derive from it so they stop when the server shuts down.
|
||||
baseCtx context.Context
|
||||
sem chan struct{}
|
||||
store Store
|
||||
// label names the job family in log lines ("subtitle ai", "metadata translation").
|
||||
label string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cancels map[int64]context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New wires a runner. A nil appCtx falls back to context.Background(); a nil
|
||||
// sem gets a private default-size semaphore (used by tests; production passes
|
||||
// the shared one).
|
||||
func New(appCtx context.Context, sem chan struct{}, store Store, label string, logger *slog.Logger) *Runner {
|
||||
if appCtx == nil {
|
||||
appCtx = context.Background()
|
||||
}
|
||||
if sem == nil {
|
||||
sem = NewSemaphore(0)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Runner{
|
||||
baseCtx: appCtx,
|
||||
sem: sem,
|
||||
store: store,
|
||||
label: label,
|
||||
logger: logger,
|
||||
cancels: make(map[int64]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Recover clears jobs orphaned by a crashed worker and starts a background
|
||||
// reaper that keeps doing so. Reaping is heartbeat-based (not "every active
|
||||
// job"), so it is safe when multiple instances share one database: a job
|
||||
// still being heartbeat-updated by a live worker is never reset. Call once at
|
||||
// startup; jobs and the reaper derive from the application context, so they
|
||||
// stop on shutdown.
|
||||
func (r *Runner) Recover() {
|
||||
r.reapStaleJobs()
|
||||
go r.reaperLoop()
|
||||
}
|
||||
|
||||
func (r *Runner) reaperLoop() {
|
||||
ticker := time.NewTicker(ReaperInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.baseCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.reapStaleJobs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) reapStaleJobs() {
|
||||
before := time.Now().Add(-StaleJobThreshold)
|
||||
n, err := r.store.ResetStaleJobs(context.WithoutCancel(r.baseCtx), before, "interrupted by server restart")
|
||||
if err != nil {
|
||||
r.logger.Warn("failed to reset stale jobs", "jobs", r.label, "error", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
r.logger.Info("reset stale jobs", "jobs", r.label, "count", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch launches a bounded background goroutine for job id. run executes
|
||||
// once a semaphore slot is acquired; onAbort runs instead if the job is
|
||||
// cancelled (user cancel or shutdown) while still waiting for a slot. Both
|
||||
// receive a context derived from the application context that is cancelled by
|
||||
// Cancel(id) or server shutdown.
|
||||
func (r *Runner) Dispatch(id int64, run func(ctx context.Context), onAbort func(ctx context.Context)) {
|
||||
runCtx, cancel := context.WithCancel(r.baseCtx)
|
||||
r.mu.Lock()
|
||||
r.cancels[id] = cancel
|
||||
r.mu.Unlock()
|
||||
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.cancels, id)
|
||||
r.mu.Unlock()
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Heartbeat for the whole lifetime — crucially including while queued
|
||||
// behind the semaphore — so the stale-job reaper never reaps a job that
|
||||
// is alive but merely waiting for a slot (which would otherwise mark it
|
||||
// failed and let it resurrect itself on acquire, or admit a duplicate).
|
||||
stopHeartbeat := make(chan struct{})
|
||||
defer close(stopHeartbeat)
|
||||
go r.heartbeatLoop(runCtx, id, stopHeartbeat)
|
||||
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
case <-runCtx.Done():
|
||||
if onAbort != nil {
|
||||
onAbort(context.WithoutCancel(runCtx))
|
||||
}
|
||||
return
|
||||
}
|
||||
defer func() { <-r.sem }()
|
||||
|
||||
run(runCtx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Cancel cancels the in-flight goroutine for id, returning false when none is
|
||||
// registered (job on another node, or never dispatched) so the caller can
|
||||
// fall back to a best-effort terminal transition in the database.
|
||||
func (r *Runner) Cancel(id int64) bool {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancels[id]
|
||||
r.mu.Unlock()
|
||||
if cancel == nil {
|
||||
return false
|
||||
}
|
||||
cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the
|
||||
// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned
|
||||
// by a crashed worker.
|
||||
func (r *Runner) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = r.store.Heartbeat(context.WithoutCancel(ctx), jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package jobrunner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
heartbeats int
|
||||
resets int
|
||||
lastBefore time.Time
|
||||
}
|
||||
|
||||
func (s *fakeStore) Heartbeat(context.Context, int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.heartbeats++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) ResetStaleJobs(_ context.Context, before time.Time, _ string) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.resets++
|
||||
s.lastBefore = before
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) snapshot() (int, time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.resets, s.lastBefore
|
||||
}
|
||||
|
||||
func TestRecoverReapsImmediatelyWithStaleCutoff(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
approxNow := time.Now()
|
||||
New(ctx, nil, store, "test", nil).Recover()
|
||||
|
||||
resets, before := store.snapshot()
|
||||
if resets < 1 {
|
||||
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
|
||||
}
|
||||
want := approxNow.Add(-StaleJobThreshold)
|
||||
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
|
||||
t.Errorf("stale cutoff = %v, want ~%v", before, want)
|
||||
}
|
||||
}
|
||||
|
||||
// One shared semaphore bounds jobs across BOTH runners: with size 1, the
|
||||
// second runner's job must not start until the first runner's job finishes.
|
||||
func TestSharedSemaphoreBoundsAcrossRunners(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r1 := New(ctx, sem, &fakeStore{}, "one", nil)
|
||||
r2 := New(ctx, sem, &fakeStore{}, "two", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
aStarted := make(chan struct{})
|
||||
var bStarted atomic.Bool
|
||||
bDone := make(chan struct{})
|
||||
|
||||
r1.Dispatch(1, func(context.Context) {
|
||||
close(aStarted)
|
||||
<-release
|
||||
}, nil)
|
||||
<-aStarted
|
||||
|
||||
r2.Dispatch(2, func(context.Context) {
|
||||
bStarted.Store(true)
|
||||
close(bDone)
|
||||
}, nil)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if bStarted.Load() {
|
||||
t.Fatal("job B ran while job A held the only slot")
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-bDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("job B never ran after the slot freed")
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling a job that is queued behind the semaphore aborts it without
|
||||
// running it, via the onAbort callback.
|
||||
func TestCancelWhileQueuedCallsOnAbort(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sem := NewSemaphore(1)
|
||||
r := New(ctx, sem, &fakeStore{}, "test", nil)
|
||||
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
started := make(chan struct{})
|
||||
r.Dispatch(1, func(context.Context) {
|
||||
close(started)
|
||||
<-release
|
||||
}, nil)
|
||||
<-started
|
||||
|
||||
var ran atomic.Bool
|
||||
aborted := make(chan struct{})
|
||||
r.Dispatch(2, func(context.Context) {
|
||||
ran.Store(true)
|
||||
}, func(context.Context) {
|
||||
close(aborted)
|
||||
})
|
||||
|
||||
if !r.Cancel(2) {
|
||||
t.Fatal("Cancel(2) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-aborted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("onAbort never ran")
|
||||
}
|
||||
if ran.Load() {
|
||||
t.Fatal("cancelled queued job still ran")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelUnknownJobReturnsFalse(t *testing.T) {
|
||||
r := New(context.Background(), nil, &fakeStore{}, "test", nil)
|
||||
if r.Cancel(99) {
|
||||
t.Fatal("Cancel(99) = true for unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
// The run context is cancelled by Cancel(id) so an in-flight job can stop.
|
||||
func TestCancelRunningJobCancelsContext(t *testing.T) {
|
||||
r := New(context.Background(), NewSemaphore(1), &fakeStore{}, "test", nil)
|
||||
started := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
r.Dispatch(7, func(ctx context.Context) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
close(stopped)
|
||||
}, nil)
|
||||
<-started
|
||||
if !r.Cancel(7) {
|
||||
t.Fatal("Cancel(7) found no in-flight goroutine")
|
||||
}
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("running job did not observe cancellation")
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package jobrunner
|
||||
|
||||
// Status is the lifecycle state shared by every AI job table.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusRunning Status = "running"
|
||||
StatusCompleted Status = "completed"
|
||||
StatusFailed Status = "failed"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// Terminal reports whether a status is final.
|
||||
func (s Status) Terminal() bool {
|
||||
switch s {
|
||||
case StatusCompleted, StatusFailed, StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
// Package llm provides the shared OpenAI-compatible API client used by every
|
||||
// AI feature in Silo (subtitle translation, metadata translation, Whisper ASR).
|
||||
// One endpoint configuration, one retry/backoff implementation; the operator
|
||||
// can point it at OpenAI, Groq, a local Ollama/llama.cpp/faster-whisper
|
||||
// server, etc.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the connection settings for the shared OpenAI-compatible
|
||||
// endpoints. The ASR fields are optional overrides for operators who run a
|
||||
// separate Whisper-compatible server next to their chat endpoint; when empty,
|
||||
// transcription uses the chat endpoint's base URL and key.
|
||||
type Config struct {
|
||||
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
|
||||
APIKey string // empty for keyless local servers
|
||||
ChatModel string // chat-completions model used for translation
|
||||
|
||||
ASRBaseURL string // optional; empty = BaseURL
|
||||
ASRAPIKey string // optional; empty = APIKey
|
||||
ASRModel string // audio-transcription model, e.g. "whisper-1"
|
||||
}
|
||||
|
||||
func (c Config) asrBaseURL() string {
|
||||
if c.ASRBaseURL != "" {
|
||||
return c.ASRBaseURL
|
||||
}
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
func (c Config) asrAPIKey() string {
|
||||
if c.ASRAPIKey != "" {
|
||||
return c.ASRAPIKey
|
||||
}
|
||||
return c.APIKey
|
||||
}
|
||||
|
||||
// ChatConfigured reports whether chat completions are minimally configured.
|
||||
func (c Config) ChatConfigured() bool { return c.BaseURL != "" && c.ChatModel != "" }
|
||||
|
||||
// ASRConfigured reports whether audio transcription is minimally configured.
|
||||
func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel != "" }
|
||||
|
||||
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
|
||||
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
|
||||
// with embedded error object" responses are retried; other 4xx fail fast).
|
||||
// The config is held behind an atomic pointer so admin settings changes apply
|
||||
// to subsequent requests without rebuilding the client; each request snapshots
|
||||
// the config once so its URL, key, and model stay consistent.
|
||||
type Client struct {
|
||||
cfg atomic.Pointer[Config]
|
||||
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
|
||||
// client-level timeout: a transcription upload's deadline is set per request
|
||||
// (sized to the chunk duration), which a client timeout would silently cap.
|
||||
chatHTTP *http.Client
|
||||
asrHTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a client from the endpoint config.
|
||||
func NewClient(cfg Config) *Client {
|
||||
c := &Client{
|
||||
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
|
||||
asrHTTP: &http.Client{},
|
||||
}
|
||||
c.UpdateConfig(cfg)
|
||||
return c
|
||||
}
|
||||
|
||||
// UpdateConfig swaps the endpoint config. Safe for concurrent use; in-flight
|
||||
// requests finish with the config they started with.
|
||||
func (c *Client) UpdateConfig(cfg Config) {
|
||||
c.cfg.Store(&cfg)
|
||||
}
|
||||
|
||||
// Config returns a snapshot of the current endpoint config.
|
||||
func (c *Client) Config() Config {
|
||||
return *c.cfg.Load()
|
||||
}
|
||||
|
||||
// Message is one chat-completions message.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature float32 `json:"temperature"`
|
||||
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatCompletionResponse struct {
|
||||
Choices []struct {
|
||||
Message Message `json:"message"`
|
||||
} `json:"choices"`
|
||||
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
|
||||
// error object instead of an HTTP error status when an upstream provider
|
||||
// fails. We surface and retry on it.
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Chat performs one chat completion and returns the first choice's content.
|
||||
// When jsonObject is true it requests response_format=json_object; providers
|
||||
// that ignore the field still work because the prompt itself demands JSON.
|
||||
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
|
||||
cfg := c.Config()
|
||||
reqBody := chatCompletionRequest{
|
||||
Model: cfg.ChatModel,
|
||||
Messages: messages,
|
||||
Temperature: 0.2,
|
||||
}
|
||||
if jsonObject {
|
||||
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal chat request: %w", err)
|
||||
}
|
||||
|
||||
url := endpointURL(cfg.BaseURL, "chat/completions")
|
||||
|
||||
var content string
|
||||
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
|
||||
func() (*http.Request, error) {
|
||||
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if reqErr != nil {
|
||||
return nil, fmt.Errorf("create request: %w", reqErr)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if cfg.APIKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed chatCompletionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode chat response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
// 200 with an upstream error object — transient on gateways.
|
||||
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
|
||||
slog.Warn("AI chat returned no choices, retrying",
|
||||
"model", cfg.ChatModel, "response_bytes", len(respBody))
|
||||
return fmt.Errorf("chat API returned no choices")
|
||||
}
|
||||
content = parsed.Choices[0].Message.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// SystemUserChat performs one chat completion from a system + user prompt
|
||||
// pair, requesting a JSON object response. Its signature matches
|
||||
// aitranslate.ChatFn so a client method reference wires straight in.
|
||||
func (c *Client) SystemUserChat(ctx context.Context, system, user string) (string, error) {
|
||||
return c.Chat(ctx, []Message{
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
}, true)
|
||||
}
|
||||
|
||||
// permanentError marks a parse failure that retrying cannot fix (e.g. an
|
||||
// endpoint that structurally does not support the requested response format).
|
||||
type permanentError struct{ err error }
|
||||
|
||||
func (e *permanentError) Error() string { return e.err.Error() }
|
||||
func (e *permanentError) Unwrap() error { return e.err }
|
||||
|
||||
// doWithRetry runs the shared request/retry loop: build constructs a fresh
|
||||
// request per attempt, parse consumes a 200 body. Transport errors, read
|
||||
// errors, 429 (honoring Retry-After), 5xx, and retryable parse errors back
|
||||
// off and retry; other 4xx and permanentError parse failures return at once.
|
||||
func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label string,
|
||||
build func() (*http.Request, error), parse func(body []byte) error) error {
|
||||
const maxAttempts = 6
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
httpReq, err := build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, doErr := httpClient.Do(httpReq)
|
||||
if doErr != nil {
|
||||
lastErr = fmt.Errorf("%s request failed: %w", label, doErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
// A truncated/failed read could otherwise be misparsed as a valid
|
||||
// (empty) response; treat it as a retryable transport error.
|
||||
lastErr = fmt.Errorf("read %s response: %w", label, readErr)
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
wait := rateLimitBackoff(resp, attempt)
|
||||
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
|
||||
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode >= 500:
|
||||
lastErr = fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
continue
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
// 4xx other than 429: not retryable.
|
||||
return fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
|
||||
}
|
||||
|
||||
parseErr := parse(respBody)
|
||||
if parseErr == nil {
|
||||
return nil
|
||||
}
|
||||
var perm *permanentError
|
||||
if errors.As(parseErr, &perm) {
|
||||
return perm.err
|
||||
}
|
||||
lastErr = parseErr
|
||||
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("%s: retries exhausted", label)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// endpointURL joins a configured base URL with an OpenAI API path,
|
||||
// tolerating bases that already include the version segment (e.g.
|
||||
// DeepInfra's https://api.deepinfra.com/v1/openai) alongside bare hosts
|
||||
// (https://api.openai.com) and prefixed hosts (https://api.groq.com/openai).
|
||||
func endpointURL(base, path string) string {
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.Contains(base, "/v1") {
|
||||
return base + "/" + path
|
||||
}
|
||||
return base + "/v1/" + path
|
||||
}
|
||||
|
||||
// Truncate caps a string for inclusion in an error or log line.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sleepCtx waits for d or until ctx is cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(d):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
|
||||
// and otherwise backing off exponentially (capped at 60s).
|
||||
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
|
||||
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
}
|
||||
wait := 10 * time.Second * (1 << attempt)
|
||||
if wait > 60*time.Second {
|
||||
wait = 60 * time.Second
|
||||
}
|
||||
return wait
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func chatConfig(baseURL string) Config {
|
||||
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
|
||||
}
|
||||
|
||||
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
|
||||
|
||||
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
|
||||
var gotAuth, gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := r.Body.Read(buf)
|
||||
gotBody = string(buf[:n])
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" {
|
||||
t.Errorf("content = %q, want hello", out)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q", gotAuth)
|
||||
}
|
||||
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
|
||||
t.Errorf("request body missing model/response_format: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(chatOK))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
start := time.Now()
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("calls = %d, want 2", calls.Load())
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < time.Second {
|
||||
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
|
||||
responses := []func(w http.ResponseWriter){
|
||||
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
|
||||
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
|
||||
}
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
responses[calls.Add(1)-1](w)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if out != "hello" || calls.Load() != 4 {
|
||||
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(chatConfig(srv.URL))
|
||||
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
|
||||
|
||||
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
|
||||
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
|
||||
var gotGranularities []string
|
||||
var gotFile []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
t.Errorf("parse multipart: %v", err)
|
||||
}
|
||||
gotModel = r.FormValue("model")
|
||||
gotFormat = r.FormValue("response_format")
|
||||
gotLang = r.FormValue("language")
|
||||
gotVAD = r.FormValue("vad_filter")
|
||||
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
|
||||
f, _, err := r.FormFile("file")
|
||||
if err == nil {
|
||||
buf := make([]byte, 64)
|
||||
n, _ := f.Read(buf)
|
||||
gotFile = buf[:n]
|
||||
f.Close()
|
||||
}
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
c := NewClient(cfg)
|
||||
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
|
||||
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
|
||||
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
|
||||
}
|
||||
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
|
||||
// segment timestamps stretch wall-to-wall across silence.
|
||||
if gotVAD != "true" {
|
||||
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
|
||||
}
|
||||
if fmt.Sprint(gotGranularities) != "[segment word]" {
|
||||
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
|
||||
}
|
||||
if string(gotFile) != "RIFFfake" {
|
||||
t.Errorf("file payload = %q", gotFile)
|
||||
}
|
||||
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
|
||||
t.Errorf("unexpected transcription: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
|
||||
for url, wantVAD := range map[string]bool{
|
||||
"https://api.openai.com": false,
|
||||
"https://myorg.openai.azure.com": false,
|
||||
"https://api.groq.com/openai": false,
|
||||
"http://192.168.1.10:8000": true,
|
||||
"https://whisper.example.com": true,
|
||||
"https://api.deepinfra.com/v1/openai": true,
|
||||
} {
|
||||
got := !hostMatchesAny(url, strictHostedASRHosts)
|
||||
if got != wantVAD {
|
||||
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
|
||||
// speaches/faster-whisper shape: words nested inside each segment.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
|
||||
{"start":0.0,"end":3.0,"text":" hi there","words":[
|
||||
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
words := tr.Segments[0].Words
|
||||
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
|
||||
t.Errorf("segment words = %+v", words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
|
||||
// OpenAI shape: words in a top-level array, segments without words.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
|
||||
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
|
||||
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
|
||||
{"start":1.8,"end":2.2,"word":"friend"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
|
||||
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
|
||||
}
|
||||
if tr.Segments[1].Words[0].Text != "friend" {
|
||||
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if len(tr.Segments) != 0 {
|
||||
t.Errorf("segments = %v, want empty", tr.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
w.Write([]byte(`{"text":"plain response without segments"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
|
||||
t.Fatalf("err = %v, want verbose_json complaint", err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeUsesASROverrides(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Write([]byte(verboseJSON))
|
||||
}))
|
||||
defer asrSrv.Close()
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
|
||||
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
|
||||
}
|
||||
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if gotAuth.Load() != "Bearer asr-key" {
|
||||
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRequiresConfig(t *testing.T) {
|
||||
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
|
||||
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
|
||||
t.Fatal("expected not-configured error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := chatConfig(srv.URL)
|
||||
cfg.ASRModel = "whisper-test"
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
|
||||
t.Fatalf("err = %v, want configuration hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsChatOnlyGateway(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://openrouter.ai/api": true,
|
||||
"https://openrouter.ai": true,
|
||||
"openrouter.ai/api/v1": true,
|
||||
"https://gateway.openrouter.ai": true,
|
||||
"https://api.groq.com/openai": false,
|
||||
"https://api.openai.com": false,
|
||||
"http://localhost:8000": false,
|
||||
"https://my-openrouter.ai.example.io": false,
|
||||
"": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsChatOnlyGateway(in); got != want {
|
||||
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
|
||||
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
|
||||
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
|
||||
t.Fatalf("err = %v, want chat-only refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
|
||||
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
|
||||
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
|
||||
}
|
||||
for base, want := range cases {
|
||||
if got := endpointURL(base, "chat/completions"); got != want {
|
||||
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// defaultTranscribeTimeout bounds a single transcription request when the
|
||||
// caller does not size one to its chunk duration. Generous on purpose: local
|
||||
// Whisper servers on modest hardware can run well below realtime.
|
||||
const defaultTranscribeTimeout = 20 * time.Minute
|
||||
|
||||
// TranscribeRequest is one audio-transcription call. Audio is held in memory
|
||||
// so the request can be rebuilt across retries; callers chunk long files
|
||||
// (a 10-minute 16 kHz mono WAV is ~19 MB).
|
||||
type TranscribeRequest struct {
|
||||
Filename string // e.g. "chunk00001.wav"; the extension hints the container
|
||||
Audio []byte
|
||||
Language string // optional ISO-639-1 hint; empty lets the model detect
|
||||
Timeout time.Duration // per-request deadline; 0 uses defaultTranscribeTimeout
|
||||
}
|
||||
|
||||
// TranscriptionSegment is one timed segment of recognized speech, in seconds
|
||||
// relative to the start of the submitted audio.
|
||||
type TranscriptionSegment struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
// Words carries per-word timings when the endpoint honors
|
||||
// timestamp_granularities[]=word; empty otherwise. Word-level times let
|
||||
// cue building split paragraph-length segments and end cues when speech
|
||||
// actually stops instead of when the next segment begins.
|
||||
Words []TranscriptionWord
|
||||
}
|
||||
|
||||
// TranscriptionWord is one recognized word with timing, in seconds relative
|
||||
// to the start of the submitted audio.
|
||||
type TranscriptionWord struct {
|
||||
Start float64
|
||||
End float64
|
||||
Text string
|
||||
}
|
||||
|
||||
// Transcription is the parsed verbose_json transcription result.
|
||||
type Transcription struct {
|
||||
// Language is the detected (or hinted) language as reported by the
|
||||
// endpoint. OpenAI returns an English language name ("english"); other
|
||||
// servers return ISO codes. Callers must normalize.
|
||||
Language string
|
||||
// Segments may legitimately be empty for speech-free audio (silence,
|
||||
// music-only chunks).
|
||||
Segments []TranscriptionSegment
|
||||
}
|
||||
|
||||
type transcriptionWordJSON struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Word string `json:"word"`
|
||||
}
|
||||
|
||||
type transcriptionResponse struct {
|
||||
Language string `json:"language"`
|
||||
Text string `json:"text"`
|
||||
// Segments distinguishes "verbose_json honored, no speech" (empty array)
|
||||
// from "endpoint ignored verbose_json" (field absent) — the latter cannot
|
||||
// produce timed cues and must fail rather than silently emit nothing.
|
||||
Segments *[]struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
} `json:"segments"`
|
||||
// Words is where OpenAI puts word timings; faster-whisper servers
|
||||
// (speaches) nest them inside each segment instead.
|
||||
Words []transcriptionWordJSON `json:"words"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// chatOnlyGatewayHosts lists OpenAI-compatible gateways that cannot serve
|
||||
// Silo's transcription needs: they either lack /v1/audio/transcriptions
|
||||
// entirely or (OpenRouter) return plain text with no segment timestamps,
|
||||
// which subtitle cues require. Matched by host suffix.
|
||||
var chatOnlyGatewayHosts = []string{
|
||||
"openrouter.ai",
|
||||
}
|
||||
|
||||
// strictHostedASRHosts lists hosted transcription providers that reject
|
||||
// multipart fields outside the OpenAI spec, so the faster-whisper-only
|
||||
// vad_filter field must be omitted for them. Nothing is lost: hosted Whisper
|
||||
// runs voice-activity detection server-side. Self-hosted servers (speaches,
|
||||
// faster-whisper) need the explicit field — without it segment timestamps
|
||||
// stretch wall-to-wall across silence. Matched by host suffix.
|
||||
var strictHostedASRHosts = []string{
|
||||
"api.openai.com",
|
||||
"openai.azure.com",
|
||||
"api.groq.com",
|
||||
"api.mistral.ai",
|
||||
}
|
||||
|
||||
// IsChatOnlyGateway reports whether baseURL points at a known chat-only
|
||||
// gateway that cannot produce timestamped transcriptions. Used to validate
|
||||
// the transcription settings and to disable ASR rather than fail jobs at
|
||||
// runtime.
|
||||
func IsChatOnlyGateway(baseURL string) bool {
|
||||
return hostMatchesAny(baseURL, chatOnlyGatewayHosts)
|
||||
}
|
||||
|
||||
// hostMatchesAny reports whether baseURL's hostname equals or is a subdomain
|
||||
// of any of the given host suffixes.
|
||||
func hostMatchesAny(baseURL string, hosts []string) bool {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
if baseURL == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(baseURL, "://") {
|
||||
baseURL = "https://" + baseURL
|
||||
}
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, h := range hosts {
|
||||
if host == h || strings.HasSuffix(host, "."+h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Transcribe performs one audio transcription against the ASR endpoint
|
||||
// (falling back to the chat endpoint's base URL/key when no ASR override is
|
||||
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
|
||||
// response_format=verbose_json for segment timestamps.
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
|
||||
cfg := c.Config()
|
||||
if !cfg.ASRConfigured() {
|
||||
return nil, fmt.Errorf("transcription endpoint is not configured")
|
||||
}
|
||||
if IsChatOnlyGateway(cfg.asrBaseURL()) {
|
||||
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
|
||||
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", cfg.asrBaseURL())
|
||||
}
|
||||
if len(req.Audio) == 0 {
|
||||
return nil, fmt.Errorf("no audio data to transcribe")
|
||||
}
|
||||
|
||||
timeout := req.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTranscribeTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
url := endpointURL(cfg.asrBaseURL(), "audio/transcriptions")
|
||||
|
||||
var result *Transcription
|
||||
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
|
||||
func() (*http.Request, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", req.Filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create multipart file: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(req.Audio); err != nil {
|
||||
return nil, fmt.Errorf("write multipart audio: %w", err)
|
||||
}
|
||||
fields := [][2]string{
|
||||
{"model", cfg.ASRModel},
|
||||
{"response_format", "verbose_json"},
|
||||
{"temperature", "0"},
|
||||
// Word timings let cue building split paragraph-length
|
||||
// segments and end cues when speech actually stops; segment
|
||||
// granularity must be requested alongside or OpenAI omits it.
|
||||
{"timestamp_granularities[]", "segment"},
|
||||
{"timestamp_granularities[]", "word"},
|
||||
}
|
||||
if req.Language != "" {
|
||||
fields = append(fields, [2]string{"language", req.Language})
|
||||
}
|
||||
if !hostMatchesAny(cfg.asrBaseURL(), strictHostedASRHosts) {
|
||||
fields = append(fields, [2]string{"vad_filter", "true"})
|
||||
}
|
||||
for _, f := range fields {
|
||||
if err := w.WriteField(f[0], f[1]); err != nil {
|
||||
return nil, fmt.Errorf("write multipart field %s: %w", f[0], err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalize multipart body: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if key := cfg.asrAPIKey(); key != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
return httpReq, nil
|
||||
},
|
||||
func(respBody []byte) error {
|
||||
var parsed transcriptionResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("decode transcription response: %w", err)
|
||||
}
|
||||
if parsed.Error != nil && parsed.Error.Message != "" {
|
||||
return fmt.Errorf("transcription API error: %s", parsed.Error.Message)
|
||||
}
|
||||
if parsed.Segments == nil {
|
||||
return &permanentError{err: fmt.Errorf(
|
||||
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", cfg.ASRModel)}
|
||||
}
|
||||
out := &Transcription{Language: parsed.Language}
|
||||
for _, s := range *parsed.Segments {
|
||||
out.Segments = append(out.Segments, TranscriptionSegment{
|
||||
Start: s.Start, End: s.End, Text: s.Text, Words: wordsFromJSON(s.Words),
|
||||
})
|
||||
}
|
||||
attachTopLevelWords(out.Segments, wordsFromJSON(parsed.Words))
|
||||
result = out
|
||||
return nil
|
||||
})
|
||||
if doErr != nil {
|
||||
return nil, decorateTranscribeError(doErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func wordsFromJSON(words []transcriptionWordJSON) []TranscriptionWord {
|
||||
out := make([]TranscriptionWord, 0, len(words))
|
||||
for _, w := range words {
|
||||
out = append(out, TranscriptionWord{Start: w.Start, End: w.End, Text: w.Word})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attachTopLevelWords distributes an OpenAI-style top-level word list onto
|
||||
// segments by word midpoint, for endpoints that report words separately from
|
||||
// segments. Segments that already carry their own words are left untouched.
|
||||
func attachTopLevelWords(segments []TranscriptionSegment, words []TranscriptionWord) {
|
||||
if len(segments) == 0 || len(words) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range segments {
|
||||
if len(s.Words) > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
si := 0
|
||||
for _, w := range words {
|
||||
mid := (w.Start + w.End) / 2
|
||||
for si < len(segments)-1 && mid >= segments[si].End {
|
||||
si++
|
||||
}
|
||||
segments[si].Words = append(segments[si].Words, w)
|
||||
}
|
||||
}
|
||||
|
||||
// decorateTranscribeError appends a configuration hint to the errors a
|
||||
// chat-only gateway produces when it receives a transcription upload (no
|
||||
// /v1/audio/transcriptions route, or multipart rejected). Operators routinely
|
||||
// point the shared base URL at a chat-only provider; without the hint the raw
|
||||
// 400/404 reads like a pipeline bug instead of "set a Whisper endpoint".
|
||||
func decorateTranscribeError(err error) error {
|
||||
msg := err.Error()
|
||||
likelyUnsupported := strings.Contains(msg, "returned 400") ||
|
||||
strings.Contains(msg, "returned 404") ||
|
||||
strings.Contains(msg, "returned 405")
|
||||
if !likelyUnsupported {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w — the configured endpoint likely does not support audio transcription; "+
|
||||
"set a Whisper-compatible Transcription base URL (and model) under Admin Settings → AI Services "+
|
||||
"(e.g. a self-hosted faster-whisper/speaches server, api.groq.com/openai with whisper-large-v3-turbo, or api.openai.com with whisper-1)", err)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package aitranslate
|
||||
|
||||
import "strings"
|
||||
|
||||
// languageNames maps common ISO 639-1 codes to English names. The model handles
|
||||
// bare codes acceptably, but full names noticeably improve translation quality,
|
||||
// so we resolve the common cases and fall back to the raw code otherwise.
|
||||
var languageNames = map[string]string{
|
||||
"ar": "Arabic", "bg": "Bulgarian", "bn": "Bengali", "cs": "Czech",
|
||||
"da": "Danish", "de": "German", "el": "Greek", "en": "English",
|
||||
"es": "Spanish", "et": "Estonian", "fa": "Persian", "fi": "Finnish",
|
||||
"fr": "French", "he": "Hebrew", "hi": "Hindi", "hr": "Croatian",
|
||||
"hu": "Hungarian", "id": "Indonesian", "it": "Italian", "ja": "Japanese",
|
||||
"ko": "Korean", "lt": "Lithuanian", "lv": "Latvian", "ms": "Malay",
|
||||
"nl": "Dutch", "no": "Norwegian", "pl": "Polish", "pt": "Portuguese",
|
||||
"ro": "Romanian", "ru": "Russian", "sk": "Slovak", "sl": "Slovenian",
|
||||
"sr": "Serbian", "sv": "Swedish", "ta": "Tamil", "th": "Thai",
|
||||
"tr": "Turkish", "uk": "Ukrainian", "vi": "Vietnamese", "zh": "Chinese",
|
||||
}
|
||||
|
||||
// LanguageCodeFromName maps an English language name back to its ISO 639-1
|
||||
// code — Whisper endpoints report detected languages as names ("english")
|
||||
// while local servers report codes. Returns "" when unknown.
|
||||
func LanguageCodeFromName(name string) string {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for code, display := range languageNames {
|
||||
if strings.ToLower(display) == name {
|
||||
return code
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LanguageDisplayName returns a human-readable language name for a code, or
|
||||
// the trimmed code itself when unknown. An empty code yields an empty string.
|
||||
func LanguageDisplayName(code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return ""
|
||||
}
|
||||
base := strings.ToLower(code)
|
||||
if i := strings.IndexAny(base, "-_"); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
if name, ok := languageNames[base]; ok {
|
||||
return name
|
||||
}
|
||||
return code
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
// Package aitranslate implements generic batched text translation over an
|
||||
// OpenAI-compatible chat model. Callers hand it ordered text segments and a
|
||||
// domain-specific system prompt; it owns the wire protocol (indexed-JSON
|
||||
// batches the model must echo back key-for-key), context carryover between
|
||||
// batches, and retries on malformed model output. Subtitle cues and metadata
|
||||
// descriptions both translate through this one implementation.
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Segment is one unit of translatable text. ID is the caller's identifier and
|
||||
// is never sent to the model (batches are keyed by position), so any stable
|
||||
// string works.
|
||||
type Segment struct {
|
||||
ID string
|
||||
Text string
|
||||
}
|
||||
|
||||
// ChatFn performs one chat completion. Wiring this as a function keeps the
|
||||
// package independent of the client and trivially testable.
|
||||
type ChatFn func(ctx context.Context, system, user string) (string, error)
|
||||
|
||||
// Request describes one translation run.
|
||||
type Request struct {
|
||||
Segments []Segment
|
||||
SystemPrompt string // domain-specific; must demand same-keys JSON output
|
||||
// TargetName is the human-readable target language name used in the
|
||||
// per-batch user message.
|
||||
TargetName string
|
||||
// EntryNoun names the units in the user message ("cues", "descriptions");
|
||||
// empty defaults to "entries".
|
||||
EntryNoun string
|
||||
// BatchSize is the number of segments per chat request (required, > 0).
|
||||
BatchSize int
|
||||
// ContextNeighbors is how many preceding source segments are included,
|
||||
// untranslated, for continuity across batch boundaries.
|
||||
ContextNeighbors int
|
||||
}
|
||||
|
||||
// maxFormatRetries is how many times a batch is re-asked after the model
|
||||
// returns unparsable or incomplete JSON. Transport/API errors are retried
|
||||
// inside the chat client and are not part of this count.
|
||||
const maxFormatRetries = 2
|
||||
|
||||
// Translate translates req.Segments in order, preserving count and IDs and
|
||||
// only rewriting Text. onBatch, when non-nil, is called after each batch with
|
||||
// that batch's translated segments and overall progress (done/total
|
||||
// segments). Implementations must honor ctx cancellation between batches.
|
||||
func Translate(ctx context.Context, chat ChatFn, req Request, onBatch func(batch []Segment, done, total int)) ([]Segment, error) {
|
||||
if chat == nil {
|
||||
return nil, fmt.Errorf("chat function is nil")
|
||||
}
|
||||
if req.BatchSize <= 0 {
|
||||
return nil, fmt.Errorf("invalid batch size: %d", req.BatchSize)
|
||||
}
|
||||
total := len(req.Segments)
|
||||
if total == 0 {
|
||||
return nil, fmt.Errorf("no segments to translate")
|
||||
}
|
||||
noun := req.EntryNoun
|
||||
if noun == "" {
|
||||
noun = "entries"
|
||||
}
|
||||
|
||||
// Preserve IDs by copying input segments and only replacing Text.
|
||||
out := make([]Segment, total)
|
||||
copy(out, req.Segments)
|
||||
|
||||
for start := 0; start < total; start += req.BatchSize {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end := min(start+req.BatchSize, total)
|
||||
|
||||
contextStart := max(0, start-req.ContextNeighbors)
|
||||
translated, err := translateBatch(ctx, chat, req, noun, req.Segments[contextStart:start], req.Segments[start:end])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("translate %s %d-%d: %w", noun, start+1, end, err)
|
||||
}
|
||||
for i, text := range translated {
|
||||
out[start+i].Text = text
|
||||
}
|
||||
if onBatch != nil {
|
||||
onBatch(out[start:end], end, total)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func translateBatch(ctx context.Context, chat ChatFn, req Request, noun string, contextSegs, batch []Segment) ([]string, error) {
|
||||
texts := make([]string, len(batch))
|
||||
for i, s := range batch {
|
||||
texts[i] = s.Text
|
||||
}
|
||||
payload, err := buildIndexedJSON(texts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user strings.Builder
|
||||
if len(contextSegs) > 0 {
|
||||
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
|
||||
for _, s := range contextSegs {
|
||||
user.WriteString(strings.ReplaceAll(s.Text, "\n", " "))
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
user.WriteByte('\n')
|
||||
}
|
||||
target := req.TargetName
|
||||
if target == "" {
|
||||
target = "the target language"
|
||||
}
|
||||
fmt.Fprintf(&user, "Translate these %d %s into %s. Respond with only the JSON object:\n%s", len(batch), noun, target, payload)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxFormatRetries; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := chat(ctx, req.SystemPrompt, user.String())
|
||||
if err != nil {
|
||||
return nil, err // transport/API errors are already retried inside the client
|
||||
}
|
||||
|
||||
obj, err := extractJSONObject(content)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
lastErr = fmt.Errorf("decode translation JSON: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
out := make([]string, len(batch))
|
||||
complete := true
|
||||
for i := range batch {
|
||||
v, ok := m[strconv.Itoa(i+1)]
|
||||
if !ok {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
if !complete {
|
||||
lastErr = fmt.Errorf("model omitted one or more %s", noun)
|
||||
continue
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid model response after %d attempts: %w", maxFormatRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
|
||||
// 1-based position, escaping each value safely. It is built by hand rather
|
||||
// than json.Marshal'ing a map so the keys stay in numeric order — that reads
|
||||
// more naturally for the model than the lexicographic order Go emits for maps
|
||||
// ("1","10","11",...,"2"). Correctness doesn't depend on order (results are
|
||||
// mapped back by key), but ordered input gives the model better context.
|
||||
func buildIndexedJSON(texts []string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteByte('{')
|
||||
for i, text := range texts {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(strconv.Itoa(i + 1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
val, err := json.Marshal(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(val)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// extractJSONObject pulls the first balanced-looking JSON object out of a
|
||||
// model reply, tolerating ``` code fences and surrounding prose.
|
||||
func extractJSONObject(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "```") {
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if nl := strings.IndexByte(s, '\n'); nl >= 0 {
|
||||
s = s[nl+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(s, "```"); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
start := strings.IndexByte(s, '{')
|
||||
end := strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end < 0 || end < start {
|
||||
return "", fmt.Errorf("no JSON object found in model response")
|
||||
}
|
||||
return s[start : end+1], nil
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package aitranslate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// echoChat translates by upper-casing every value of the indexed JSON payload
|
||||
// in the user message, mimicking a well-behaved model.
|
||||
func echoChat(t *testing.T) ChatFn {
|
||||
t.Helper()
|
||||
return func(_ context.Context, _ string, user string) (string, error) {
|
||||
obj, err := extractJSONObject(user)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("test chat: %w", err)
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal([]byte(obj), &m); err != nil {
|
||||
return "", fmt.Errorf("test chat decode: %w", err)
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = strings.ToUpper(v)
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return string(b), nil
|
||||
}
|
||||
}
|
||||
|
||||
func segments(n int) []Segment {
|
||||
out := make([]Segment, n)
|
||||
for i := range out {
|
||||
out[i] = Segment{ID: strconv.Itoa(i + 1), Text: fmt.Sprintf("line %d", i+1)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTranslateBatchesAndPreservesIDs(t *testing.T) {
|
||||
var batches []int
|
||||
out, err := Translate(context.Background(), echoChat(t), Request{
|
||||
Segments: segments(7),
|
||||
BatchSize: 3,
|
||||
}, func(batch []Segment, done, total int) {
|
||||
batches = append(batches, len(batch))
|
||||
if total != 7 {
|
||||
t.Errorf("total = %d, want 7", total)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if len(out) != 7 {
|
||||
t.Fatalf("len(out) = %d", len(out))
|
||||
}
|
||||
for i, s := range out {
|
||||
if s.ID != strconv.Itoa(i+1) {
|
||||
t.Errorf("ID[%d] = %q, want %d", i, s.ID, i+1)
|
||||
}
|
||||
if want := strings.ToUpper(fmt.Sprintf("line %d", i+1)); s.Text != want {
|
||||
t.Errorf("Text[%d] = %q, want %q", i, s.Text, want)
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(batches) != "[3 3 1]" {
|
||||
t.Errorf("batch sizes = %v, want [3 3 1]", batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateSendsContextNeighborsUntranslated(t *testing.T) {
|
||||
var secondBatchMsg string
|
||||
call := 0
|
||||
chat := func(ctx context.Context, system, user string) (string, error) {
|
||||
call++
|
||||
if call == 2 {
|
||||
secondBatchMsg = user
|
||||
}
|
||||
return echoChat(t)(ctx, system, user)
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(4),
|
||||
BatchSize: 2,
|
||||
ContextNeighbors: 2,
|
||||
TargetName: "French",
|
||||
EntryNoun: "cues",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "context only") ||
|
||||
!strings.Contains(secondBatchMsg, "line 1\nline 2") {
|
||||
t.Errorf("second batch missing context block:\n%s", secondBatchMsg)
|
||||
}
|
||||
if !strings.Contains(secondBatchMsg, "Translate these 2 cues into French.") {
|
||||
t.Errorf("second batch missing instruction:\n%s", secondBatchMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateToleratesCodeFences(t *testing.T) {
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
return "Sure! Here you go:\n```json\n{\"1\":\"ok\"}\n```", nil
|
||||
}
|
||||
out, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(1),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Translate: %v", err)
|
||||
}
|
||||
if out[0].Text != "ok" {
|
||||
t.Errorf("Text = %q", out[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateRetriesOmittedKeysThenFails(t *testing.T) {
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
return `{"1":"only one"}`, nil // always omits key "2"
|
||||
}
|
||||
_, err := Translate(context.Background(), chat, Request{
|
||||
Segments: segments(2),
|
||||
BatchSize: 5,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "omitted") {
|
||||
t.Fatalf("err = %v, want omission failure", err)
|
||||
}
|
||||
if calls != maxFormatRetries+1 {
|
||||
t.Errorf("calls = %d, want %d", calls, maxFormatRetries+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateStopsOnCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
chat := func(_ context.Context, _, _ string) (string, error) {
|
||||
calls++
|
||||
cancel() // cancel after the first batch completes
|
||||
return `{"1":"a"}`, nil
|
||||
}
|
||||
_, err := Translate(ctx, chat, Request{
|
||||
Segments: segments(3),
|
||||
BatchSize: 1,
|
||||
}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("err = %v, want context cancellation", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,12 @@ type autoscanQueuer = autoscan.Queuer
|
||||
|
||||
// RequestIntegrationLookup adapts the Requests repository to the autoscan
|
||||
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
|
||||
// Requests integration to its base URL and api key (the repo decrypts the key
|
||||
// on read, so this returns plaintext).
|
||||
// Requests integration to its base URL and (write-only) api-key ref.
|
||||
type RequestIntegrationLookup struct {
|
||||
Repo *mediarequests.Repository
|
||||
}
|
||||
|
||||
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
|
||||
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) {
|
||||
integration, err := l.Repo.GetIntegration(ctx, integrationID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -65,8 +64,8 @@ type PluginScanSourceAdapter struct {
|
||||
Svc *plugins.Service
|
||||
}
|
||||
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
|
||||
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
|
||||
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
|
||||
}
|
||||
|
||||
// scanSourceCapabilityType is the plugin capability type autoscan discovery
|
||||
@@ -76,6 +75,15 @@ const scanSourceCapabilityType = "scan_source.v1"
|
||||
// PluginScanSourceLister adapts the plugin installation store to
|
||||
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
|
||||
// capability across ALL installed plugins, regardless of enabled state.
|
||||
//
|
||||
// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily
|
||||
// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped
|
||||
// disabled plugins here, PollOnce would prune their sources and skip them with no
|
||||
// last_error — they'd vanish silently. Keeping them present means PollOnce still
|
||||
// attempts them; the plugin client fails to load (not running) and the source
|
||||
// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from
|
||||
// List entirely) is a true orphan. The Add-source picker shares this all-installed
|
||||
// set, which is fine — operators can bind sources against an installed capability.
|
||||
type PluginScanSourceLister struct {
|
||||
Store *plugins.InstallationStore
|
||||
}
|
||||
@@ -96,9 +104,10 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
|
||||
continue
|
||||
}
|
||||
out = append(out, autoscan.DiscoveredSource{
|
||||
PluginID: inst.PluginID,
|
||||
CapabilityID: c.ID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
InstallationID: c.InstallationID,
|
||||
CapabilityID: c.ID,
|
||||
PluginID: inst.PluginID,
|
||||
DisplayName: scanSourceDisplayName(inst.PluginID, c),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,22 +135,27 @@ func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
|
||||
}
|
||||
}
|
||||
|
||||
// AutoscanSecretResolver resolves an encrypted api-key reference to plaintext.
|
||||
// It is satisfied by the server settings repo (Get(ctx, key) (string, error)).
|
||||
type AutoscanSecretResolver interface {
|
||||
Get(ctx context.Context, ref string) (string, error)
|
||||
}
|
||||
|
||||
// BuildAutoscanService wires the v2 autoscan engine from its concrete
|
||||
// dependencies. Both the HTTP router (manual trigger) and the background poll
|
||||
// task share this constructor so the adapter wiring lives in exactly one place.
|
||||
// Credentials are now decrypted inline by the autoscan/requests repos, so there
|
||||
// is no separate secret resolver to thread.
|
||||
func BuildAutoscanService(
|
||||
repo *autoscan.Repository,
|
||||
pluginService *plugins.Service,
|
||||
installationStore *plugins.InstallationStore,
|
||||
requestsRepo *mediarequests.Repository,
|
||||
secrets AutoscanSecretResolver,
|
||||
folderRepo *catalog.FolderRepository,
|
||||
queue autoscanQueuer,
|
||||
redisClient *redis.Client,
|
||||
) *autoscan.Service {
|
||||
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
|
||||
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
|
||||
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo}, secrets)
|
||||
svc := autoscan.NewService(
|
||||
repo,
|
||||
provider,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
|
||||
// connection is rejected when the integration is disabled or has no base_url, so
|
||||
@@ -26,31 +22,3 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
|
||||
// locks the autoscan→requests credential-reuse behavior after the
|
||||
// request_integrations table was generalized into the two-tier connection
|
||||
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
|
||||
// rows via the same enabled + base_url gate, regardless of the added
|
||||
// capability/installation columns.
|
||||
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
|
||||
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
|
||||
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
|
||||
}
|
||||
// Disabled -> error (autoscan must not poll a disabled connection).
|
||||
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
|
||||
t.Fatal("disabled integration must be rejected")
|
||||
}
|
||||
// Blank base_url -> error.
|
||||
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
|
||||
t.Fatal("blank base_url must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
|
||||
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
|
||||
// fail the build here rather than silently at runtime.
|
||||
var _ = func(in mediarequests.Integration) (string, string, bool) {
|
||||
return in.BaseURL, in.APIKeyRef, in.Enabled
|
||||
}
|
||||
|
||||
@@ -21,18 +21,14 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/adminjob"
|
||||
"github.com/Silo-Server/silo-server/internal/ai/llm"
|
||||
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/cache"
|
||||
"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"
|
||||
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -86,9 +82,7 @@ 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
|
||||
@@ -98,7 +92,6 @@ type AdminHandler struct {
|
||||
BootstrapSensitiveValues map[string]string
|
||||
OnUserSessionsRevoked func(ctx context.Context, userID int)
|
||||
OnServerSettingUpdated func(ctx context.Context, key, value string)
|
||||
RestartStatus *ServerRestartStatusTracker
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler backed by the given
|
||||
@@ -1144,10 +1137,29 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
|
||||
|
||||
// --- Server Settings endpoints ---
|
||||
|
||||
// sensitiveSettingKeys is the audited allowlist of secret-bearing settings
|
||||
// keys, shared with the at-rest encryption decorator so redaction and
|
||||
// encryption can never drift apart. See catalog.SensitiveSettingKeys.
|
||||
var sensitiveSettingKeys = catalog.SensitiveSettingKeys
|
||||
var sensitiveSettingKeys = map[string]bool{
|
||||
"auth.jwt_secret": true,
|
||||
"s3.public_access_key": true,
|
||||
"s3.public_secret_key": true,
|
||||
"s3.public_token_secret": true,
|
||||
"s3.private_access_key": true,
|
||||
"s3.private_secret_key": true,
|
||||
"s3.user_db_access_key": true,
|
||||
"s3.user_db_secret_key": true,
|
||||
"redis.url": true,
|
||||
"recommendations.openai_api_key": true,
|
||||
"recommendations.embedding_auth_token": true,
|
||||
"tmdb.api_key": true,
|
||||
"introdb.api_key": true,
|
||||
"mdblist.api_key": true,
|
||||
"requests.radarr.api_key": true,
|
||||
"requests.sonarr.api_key": true,
|
||||
"subtitle_ai.api_key": true,
|
||||
"watchsync.trakt.client_id": true,
|
||||
"watchsync.trakt.client_secret": true,
|
||||
"watchsync.simkl.client_id": true,
|
||||
"watchsync.simkl.client_secret": true,
|
||||
}
|
||||
|
||||
// HandleGetSettings handles GET /admin/settings.
|
||||
func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1222,9 +1234,6 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R
|
||||
type adminSettingResponse struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
// RestartRequired reports whether the saved value only takes effect
|
||||
// after a server restart (set on update responses only).
|
||||
RestartRequired bool `json:"restart_required,omitempty"`
|
||||
}
|
||||
|
||||
type adminSettingsListResponse struct {
|
||||
@@ -2111,33 +2120,6 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
|
||||
} else {
|
||||
req.Value = normalized
|
||||
}
|
||||
case "ai.asr_base_url":
|
||||
if llm.IsChatOnlyGateway(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
|
||||
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
|
||||
return
|
||||
}
|
||||
case "metadata_ai.on_view":
|
||||
switch req.Value {
|
||||
case "off", "button", "auto":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"metadata_ai.on_view must be off, button, or auto")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_jobs":
|
||||
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
|
||||
return
|
||||
}
|
||||
case "subtitle_ai.transcribe_quota_period":
|
||||
if !subtitleai.ValidQuotaPeriod(req.Value) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"subtitle_ai.transcribe_quota_period must be day, week, or month")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
|
||||
@@ -2152,13 +2134,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})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
|
||||
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/cache"
|
||||
"github.com/Silo-Server/silo-server/internal/markers"
|
||||
)
|
||||
@@ -120,11 +122,7 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
existing, ok := h.Config.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Unknown marker provider")
|
||||
@@ -181,11 +179,7 @@ func (h *AdminMarkerProvidersHandler) HandleValidateProvider(w http.ResponseWrit
|
||||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
|
||||
return
|
||||
}
|
||||
provider, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
|
||||
return
|
||||
}
|
||||
provider := chi.URLParam(r, "provider")
|
||||
var submitter markers.Submitter
|
||||
for _, p := range h.Registry.Providers() {
|
||||
if p.ID() == provider {
|
||||
|
||||
@@ -49,34 +49,6 @@ func TestValidateMarkerProviderUsesSnakeCaseStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fakePluginMarkerSubmitter struct{ fakeMarkerStatsSubmitter }
|
||||
|
||||
func (fakePluginMarkerSubmitter) ID() string { return "plugin:6:introdb" }
|
||||
|
||||
// Browsers send encodeURIComponent-escaped provider IDs (plugin%3A6%3Aintrodb),
|
||||
// and chi matches the raw path, so the handler must decode the route param.
|
||||
func TestValidateMarkerProviderDecodesEncodedID(t *testing.T) {
|
||||
reg := markers.NewRegistry(nil)
|
||||
if err := reg.Register(fakePluginMarkerSubmitter{}); err != nil {
|
||||
t.Fatalf("register provider: %v", err)
|
||||
}
|
||||
h := NewAdminMarkerProvidersHandler(reg, nil, nil, nil)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post("/admin/markers/providers/{provider}/validate", h.HandleValidateProvider)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/markers/providers/plugin%3A6%3Aintrodb/validate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"valid":true`) {
|
||||
t.Fatalf("unexpected response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerProviderResponseIncludesPluginMetadata(t *testing.T) {
|
||||
resp := toProviderConfigResponse(
|
||||
markers.ProviderConfig{Provider: "plugin:4:markers", FetchEnabled: true, FetchPriority: 25},
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -326,56 +326,6 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
value string
|
||||
restartRequired bool
|
||||
}{
|
||||
// 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{}
|
||||
restartStatus := NewServerRestartStatusTracker()
|
||||
handler := &AdminHandler{SettingsRepo: settings, RestartStatus: restartStatus}
|
||||
|
||||
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())
|
||||
}
|
||||
var resp adminSettingResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
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 performSettingsCheckRequest(
|
||||
t *testing.T,
|
||||
handler *AdminHandler,
|
||||
|
||||
@@ -22,9 +22,7 @@ type AdminStats struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalUsers int `json:"total_users"`
|
||||
TotalMovies int `json:"total_movies"`
|
||||
TotalMovieFiles int `json:"total_movie_files"`
|
||||
TotalShows int `json:"total_shows"`
|
||||
TotalShowFiles int `json:"total_show_files"`
|
||||
ActiveStreams int `json:"active_streams"`
|
||||
TotalStorageBytes int64 `json:"total_storage_bytes"`
|
||||
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
|
||||
@@ -129,15 +127,13 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
}
|
||||
|
||||
var (
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalMovieFiles int64
|
||||
totalShows int64
|
||||
totalShowFiles int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
totalUsers int64
|
||||
totalItems int64
|
||||
totalFiles int64
|
||||
totalMovies int64
|
||||
totalShows int64
|
||||
activeStreams int64
|
||||
totalStorage int64
|
||||
)
|
||||
|
||||
row := pool.QueryRow(ctx, `
|
||||
@@ -155,34 +151,8 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
file_stats AS (
|
||||
SELECT
|
||||
COUNT(*)::bigint AS total_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
|
||||
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
|
||||
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
|
||||
FROM (
|
||||
SELECT
|
||||
media_files.file_size,
|
||||
CASE
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
|
||||
THEN 'series'
|
||||
WHEN episodes.content_id IS NOT NULL
|
||||
THEN 'series'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
|
||||
THEN 'movie'
|
||||
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
|
||||
THEN 'series'
|
||||
ELSE ''
|
||||
END AS file_kind
|
||||
FROM media_files
|
||||
JOIN media_folders ON media_folders.id = media_files.media_folder_id
|
||||
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
|
||||
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
|
||||
) classified_files
|
||||
FROM media_files
|
||||
),
|
||||
session_stats AS (
|
||||
SELECT COUNT(*)::bigint AS active_streams
|
||||
@@ -193,9 +163,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
item_stats.total_items,
|
||||
file_stats.total_files,
|
||||
item_stats.total_movies,
|
||||
file_stats.total_movie_files,
|
||||
item_stats.total_shows,
|
||||
file_stats.total_show_files,
|
||||
session_stats.active_streams,
|
||||
file_stats.total_storage_bytes
|
||||
FROM user_stats
|
||||
@@ -208,9 +176,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
&totalItems,
|
||||
&totalFiles,
|
||||
&totalMovies,
|
||||
&totalMovieFiles,
|
||||
&totalShows,
|
||||
&totalShowFiles,
|
||||
&activeStreams,
|
||||
&totalStorage,
|
||||
); err != nil {
|
||||
@@ -228,9 +194,7 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
|
||||
TotalItems: int(totalItems),
|
||||
TotalFiles: int(totalFiles),
|
||||
TotalMovies: int(totalMovies),
|
||||
TotalMovieFiles: int(totalMovieFiles),
|
||||
TotalShows: int(totalShows),
|
||||
TotalShowFiles: int(totalShowFiles),
|
||||
ActiveStreams: int(activeStreams),
|
||||
TotalStorageBytes: totalStorage,
|
||||
WatchProviderActivity: activity,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -56,11 +57,7 @@ func (h *AdminSubtitleHandler) HandleListProviders(w http.ResponseWriter, r *htt
|
||||
|
||||
// HandleUpdateProvider handles PUT /api/v1/admin/subtitle-providers/{provider}
|
||||
func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
|
||||
var req updateSubtitleProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -86,11 +83,7 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht
|
||||
|
||||
// HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test
|
||||
func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) {
|
||||
providerName, err := decodedURLParam(r, "provider")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
|
||||
return
|
||||
}
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
|
||||
cfg, err := h.repo.GetProviderConfig(r.Context(), providerName)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
)
|
||||
|
||||
type audiobookGroupResponse struct {
|
||||
Name string `json:"name"`
|
||||
ItemCount int `json:"item_count"`
|
||||
TotalDurationSeconds int64 `json:"total_duration_seconds"`
|
||||
InProgressCount int `json:"in_progress_count"`
|
||||
FinishedCount int `json:"finished_count"`
|
||||
PosterURLs []string `json:"poster_urls"`
|
||||
}
|
||||
|
||||
type audiobookGroupsResponse struct {
|
||||
Total int `json:"total"`
|
||||
Groups []audiobookGroupResponse `json:"groups"`
|
||||
}
|
||||
|
||||
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
|
||||
//
|
||||
// Grouped browse for audiobook libraries: authors, narrators, or series with
|
||||
// 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.
|
||||
//
|
||||
// Group names round-trip into the corresponding catalog filter fields
|
||||
// (author / narrator / series), which match case-insensitively.
|
||||
func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
|
||||
if err != nil || libraryID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
|
||||
return
|
||||
}
|
||||
|
||||
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
|
||||
return
|
||||
}
|
||||
|
||||
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
|
||||
switch sort {
|
||||
case "", "name", "count", "duration":
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 200
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
n, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
|
||||
|
||||
groups, total, err := catalog.ListAudiobookGroups(
|
||||
r.Context(),
|
||||
h.itemsH.browseRepo.Pool(),
|
||||
catalog.AudiobookGroupsQuery{
|
||||
LibraryID: libraryID,
|
||||
GroupBy: groupBy,
|
||||
Sort: sort,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
},
|
||||
h.itemsH.accessFilter(r),
|
||||
)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
|
||||
return
|
||||
}
|
||||
|
||||
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
|
||||
for _, g := range 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)
|
||||
}
|
||||
}
|
||||
resp.Groups = append(resp.Groups, audiobookGroupResponse{
|
||||
Name: g.Name,
|
||||
ItemCount: g.ItemCount,
|
||||
TotalDurationSeconds: g.TotalDurationSeconds,
|
||||
InProgressCount: g.InProgressCount,
|
||||
FinishedCount: g.FinishedCount,
|
||||
PosterURLs: posterURLs,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -36,7 +36,6 @@ type autoscanStore interface {
|
||||
CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error)
|
||||
ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error)
|
||||
ListRunningEvents(ctx context.Context) ([]autoscan.Event, error)
|
||||
GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error)
|
||||
LatestEventAt(ctx context.Context) (*time.Time, error)
|
||||
}
|
||||
@@ -310,7 +309,7 @@ func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.
|
||||
// credentials: the connection link is by id only.
|
||||
type autoscanSourceResponse struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -332,7 +331,7 @@ func sourceResponse(s autoscan.Source) autoscanSourceResponse {
|
||||
config := normalizeSourceConfig(s.SourceConfig)
|
||||
return autoscanSourceResponse{
|
||||
ID: s.ID,
|
||||
PluginID: s.PluginID,
|
||||
InstallationID: s.InstallationID,
|
||||
CapabilityID: s.CapabilityID,
|
||||
ConnectionID: s.ConnectionID,
|
||||
Enabled: s.Enabled,
|
||||
@@ -365,9 +364,10 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
|
||||
// --- Available scan-source plugins (Add-source picker) ---
|
||||
|
||||
type autoscanScanSourcePluginResponse struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// HandleListAvailableScanSources returns every installed scan_source capability
|
||||
@@ -381,9 +381,10 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
|
||||
for _, a := range available {
|
||||
out = append(out, autoscanScanSourcePluginResponse{
|
||||
PluginID: a.PluginID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
DisplayName: a.DisplayName,
|
||||
InstallationID: a.InstallationID,
|
||||
CapabilityID: a.CapabilityID,
|
||||
PluginID: a.PluginID,
|
||||
DisplayName: a.DisplayName,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, struct {
|
||||
@@ -393,12 +394,12 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
|
||||
|
||||
// --- Create source ---
|
||||
|
||||
// autoscanCreateSourceInput is the create payload: it names the scan_source
|
||||
// capability to bind (plugin_id + capability_id) plus the initial
|
||||
// binding/scheduling fields. Unlike the update payload, identity is supplied
|
||||
// here rather than read from an existing row.
|
||||
// autoscanCreateSourceInput is the create payload: it names the installed
|
||||
// scan_source capability to bind (installation_id + capability_id) plus the
|
||||
// initial binding/scheduling fields. Unlike the update payload, identity is
|
||||
// supplied here rather than read from an existing row.
|
||||
type autoscanCreateSourceInput struct {
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -419,11 +420,6 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
capID := strings.TrimSpace(in.CapabilityID)
|
||||
pluginID := strings.TrimSpace(in.PluginID)
|
||||
if pluginID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id is required")
|
||||
return
|
||||
}
|
||||
if capID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
|
||||
return
|
||||
@@ -436,7 +432,7 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
// Validate the (plugin_id, capability_id) is a currently-installed
|
||||
// Validate the (installation_id, capability_id) is a currently-installed
|
||||
// scan_source capability — a source may only be created against an installed
|
||||
// plugin capability.
|
||||
available, err := h.svc.ListAvailableScanSources(r.Context())
|
||||
@@ -444,13 +440,13 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
if !scanSourceInstalled(available, pluginID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id + capability_id is not a currently-installed scan_source capability")
|
||||
if !scanSourceInstalled(available, in.InstallationID, capID) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability")
|
||||
return
|
||||
}
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
created, err := h.repo.CreateSource(r.Context(), autoscan.Source{
|
||||
PluginID: pluginID,
|
||||
InstallationID: in.InstallationID,
|
||||
CapabilityID: capID,
|
||||
ConnectionID: connArg,
|
||||
Enabled: in.Enabled,
|
||||
@@ -466,18 +462,18 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusCreated, sourceResponse(created))
|
||||
}
|
||||
|
||||
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
|
||||
// scanSourceInstalled reports whether (installationID, capabilityID) is in the
|
||||
// set of currently-installed scan_source capabilities.
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
|
||||
func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool {
|
||||
for _, a := range available {
|
||||
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
|
||||
if a.InstallationID == installationID && a.CapabilityID == capabilityID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// autoscanSourceInput is the source write payload. The (plugin_id,
|
||||
// autoscanSourceInput is the source write payload. The (installation_id,
|
||||
// capability_id) identity is read from the existing source row, so only the
|
||||
// schedulable/binding fields are accepted.
|
||||
//
|
||||
@@ -552,7 +548,7 @@ func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Requ
|
||||
// connection_id is a full-state field: nil means unbind, a UUID string
|
||||
// means bind. A whitespace-only string is normalised to nil (unbound).
|
||||
connArg := normalizeConnectionID(in.ConnectionID)
|
||||
// Update by id; identity (plugin_id, capability_id) is immutable and
|
||||
// Update by id; identity (installation_id, capability_id) is immutable and
|
||||
// preserved by the repo. A missing source maps to 404.
|
||||
updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{
|
||||
ID: id,
|
||||
@@ -585,8 +581,9 @@ func normalizeConnectionID(in *string) *string {
|
||||
}
|
||||
|
||||
// HandleDeleteSource removes a source row. This is the operator's escape hatch
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled or no
|
||||
// longer resolves cleanly). An unknown id maps to 404 via autoscan.ErrNotFound.
|
||||
// for an orphaned source (one whose scan_source plugin was uninstalled): the
|
||||
// poll loop skips such rows quietly, and this endpoint clears them. An unknown
|
||||
// id maps to 404 via autoscan.ErrNotFound.
|
||||
func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if err := h.repo.DeleteSource(r.Context(), id); err != nil {
|
||||
@@ -713,7 +710,7 @@ type autoscanEventScanRunResponse struct {
|
||||
type autoscanEventResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
@@ -756,7 +753,7 @@ func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
|
||||
return autoscanEventResponse{
|
||||
ID: e.ID,
|
||||
SourceID: e.SourceID,
|
||||
PluginID: e.PluginID,
|
||||
InstallationID: e.InstallationID,
|
||||
CapabilityID: e.CapabilityID,
|
||||
StartedAt: e.StartedAt,
|
||||
CompletedAt: e.CompletedAt,
|
||||
@@ -797,9 +794,9 @@ func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Reques
|
||||
filter.Offset = offset
|
||||
}
|
||||
switch filter.Status {
|
||||
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -838,7 +835,7 @@ type autoscanScanResponse struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
|
||||
SourceID *string `json:"source_id,omitempty"`
|
||||
PluginID string `json:"plugin_id,omitempty"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
CapabilityID string `json:"capability_id,omitempty"`
|
||||
EventStatus string `json:"event_status,omitempty"`
|
||||
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
|
||||
@@ -865,7 +862,7 @@ func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
|
||||
CompletedAt: scan.CompletedAt,
|
||||
AutoscanEventID: scan.AutoscanEventID,
|
||||
SourceID: scan.SourceID,
|
||||
PluginID: scan.PluginID,
|
||||
InstallationID: scan.InstallationID,
|
||||
CapabilityID: scan.CapabilityID,
|
||||
EventStatus: string(scan.EventStatus),
|
||||
EventCompletedAt: scan.EventCompletedAt,
|
||||
@@ -926,35 +923,24 @@ func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request
|
||||
// --- Status ---
|
||||
|
||||
type autoscanStatusSource struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanRunningPollResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceID *string `json:"source_id"`
|
||||
PluginID string `json:"plugin_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ElapsedMS int64 `json:"elapsed_ms"`
|
||||
MarkerBefore *string `json:"marker_before,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
CapabilityID string `json:"capability_id"`
|
||||
ConnectionID *string `json:"connection_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Label string `json:"label"`
|
||||
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
LastError *string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type autoscanStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
RunningPolls []autoscanRunningPollResponse `json:"running_polls"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sources []autoscanStatusSource `json:"sources"`
|
||||
ActiveScans int `json:"active_scans"`
|
||||
AcceptedScans int `json:"accepted_scans"`
|
||||
RunningScans int `json:"running_scans"`
|
||||
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -974,11 +960,6 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
runningEvents, err := h.repo.ListRunningEvents(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
return
|
||||
}
|
||||
latestEventAt, err := h.repo.LatestEventAt(ctx)
|
||||
if err != nil {
|
||||
writeAutoscanError(w, err)
|
||||
@@ -991,38 +972,20 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
rewrites = []autoscan.PathRewrite{}
|
||||
}
|
||||
trimmed = append(trimmed, autoscanStatusSource{
|
||||
ID: src.ID,
|
||||
PluginID: src.PluginID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
})
|
||||
}
|
||||
now := time.Now()
|
||||
runningPolls := make([]autoscanRunningPollResponse, 0, len(runningEvents))
|
||||
for _, event := range runningEvents {
|
||||
elapsed := now.Sub(event.StartedAt)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
runningPolls = append(runningPolls, autoscanRunningPollResponse{
|
||||
ID: event.ID,
|
||||
SourceID: event.SourceID,
|
||||
PluginID: event.PluginID,
|
||||
CapabilityID: event.CapabilityID,
|
||||
StartedAt: event.StartedAt,
|
||||
ElapsedMS: elapsed.Milliseconds(),
|
||||
MarkerBefore: event.MarkerBefore,
|
||||
ID: src.ID,
|
||||
InstallationID: src.InstallationID,
|
||||
CapabilityID: src.CapabilityID,
|
||||
ConnectionID: src.ConnectionID,
|
||||
Enabled: src.Enabled,
|
||||
Label: src.Label,
|
||||
PathRewrites: rewrites,
|
||||
LastRunAt: src.LastRunAt,
|
||||
LastError: src.LastError,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, autoscanStatusResponse{
|
||||
Enabled: s.Enabled,
|
||||
Sources: trimmed,
|
||||
RunningPolls: runningPolls,
|
||||
ActiveScans: queue.Active,
|
||||
AcceptedScans: queue.Accepted,
|
||||
RunningScans: queue.Running,
|
||||
|
||||
@@ -31,7 +31,6 @@ type fakeAutoscanStore struct {
|
||||
countScansFn func(autoscan.ScanListFilter) (int, error)
|
||||
listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
|
||||
countEventsFn func(autoscan.EventListFilter) (int, error)
|
||||
listRunningFn func() ([]autoscan.Event, error)
|
||||
queueSummaryFn func() (autoscan.QueueSummary, error)
|
||||
latestEventAtFn func() (*time.Time, error)
|
||||
}
|
||||
@@ -141,13 +140,6 @@ func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.Event
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) ListRunningEvents(context.Context) ([]autoscan.Event, error) {
|
||||
if f.listRunningFn != nil {
|
||||
return f.listRunningFn()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) {
|
||||
if f.queueSummaryFn != nil {
|
||||
return f.queueSummaryFn()
|
||||
@@ -281,7 +273,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
listed = true
|
||||
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
|
||||
return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil
|
||||
},
|
||||
}
|
||||
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
|
||||
@@ -308,7 +300,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
|
||||
{InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
|
||||
|
||||
@@ -339,19 +331,19 @@ func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
if got.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
|
||||
t.Fatalf("unexpected created source: %+v", got)
|
||||
}
|
||||
var body autoscanSourceResponse
|
||||
@@ -371,14 +363,14 @@ func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
|
||||
return s, nil
|
||||
},
|
||||
}
|
||||
// Installed set does NOT include ("silo.autoscan.arr", "arr").
|
||||
// Installed set does NOT include (1, "arr").
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
|
||||
{InstallationID: 2, CapabilityID: "other"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":false}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -402,12 +394,12 @@ func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
},
|
||||
}
|
||||
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
|
||||
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
|
||||
{InstallationID: 1, CapabilityID: "arr"},
|
||||
}}
|
||||
h := NewAutoscanHandler(store, trig)
|
||||
|
||||
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
|
||||
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
|
||||
`{"installation_id":1,"capability_id":"arr","enabled":true}`, "")
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleCreateSource(rec, req)
|
||||
|
||||
@@ -623,7 +615,7 @@ func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T)
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -649,7 +641,7 @@ func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -677,7 +669,7 @@ func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with no connection.
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -705,7 +697,7 @@ func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) {
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
// Source starts with a connection already bound.
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -735,7 +727,7 @@ func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -933,7 +925,7 @@ func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
|
||||
var got autoscan.Source
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
got = s
|
||||
@@ -998,7 +990,7 @@ func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
|
||||
upserted := false
|
||||
store := &fakeAutoscanStore{
|
||||
getSourceFn: func(id string) (autoscan.Source, error) {
|
||||
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
|
||||
},
|
||||
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
|
||||
upserted = true
|
||||
@@ -1028,7 +1020,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
eventCompleted := completed.Add(-1 * time.Second)
|
||||
eventID := int64(42)
|
||||
sourceID := "src-1"
|
||||
pluginID := "silo.autoscan.cephfs"
|
||||
installationID := 7
|
||||
var gotFilter autoscan.ScanListFilter
|
||||
var gotCountFilter autoscan.ScanListFilter
|
||||
store := &fakeAutoscanStore{
|
||||
@@ -1048,7 +1040,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
},
|
||||
AutoscanEventID: &eventID,
|
||||
SourceID: &sourceID,
|
||||
PluginID: pluginID,
|
||||
InstallationID: &installationID,
|
||||
CapabilityID: "cephfs",
|
||||
EventStatus: autoscan.EventStatusSuccess,
|
||||
EventCompletedAt: &eventCompleted,
|
||||
@@ -1092,7 +1084,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
|
||||
if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" {
|
||||
t.Fatalf("event context = %+v", scan)
|
||||
}
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
|
||||
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" {
|
||||
t.Fatalf("source context = %+v", scan)
|
||||
}
|
||||
}
|
||||
@@ -1136,7 +1128,7 @@ func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
|
||||
Event: autoscan.Event{
|
||||
ID: 42,
|
||||
SourceID: &sourceID,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
InstallationID: 7,
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
@@ -1266,30 +1258,18 @@ func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) {
|
||||
|
||||
func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC)
|
||||
runningStartedAt := time.Now().Add(-90 * time.Second)
|
||||
sourceID := "src-1"
|
||||
store := &fakeAutoscanStore{
|
||||
getSettingsFn: func() (autoscan.Settings, error) {
|
||||
return autoscan.Settings{Enabled: true}, nil
|
||||
},
|
||||
listSourcesFn: func() ([]autoscan.Source, error) {
|
||||
return []autoscan.Source{
|
||||
{ID: "src-1", PluginID: "silo.autoscan.cephfs", CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
{ID: "src-1", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
|
||||
}, nil
|
||||
},
|
||||
queueSummaryFn: func() (autoscan.QueueSummary, error) {
|
||||
return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil
|
||||
},
|
||||
listRunningFn: func() ([]autoscan.Event, error) {
|
||||
return []autoscan.Event{{
|
||||
ID: 44,
|
||||
SourceID: &sourceID,
|
||||
PluginID: "silo.autoscan.cephfs",
|
||||
CapabilityID: "cephfs",
|
||||
StartedAt: runningStartedAt,
|
||||
Status: autoscan.EventStatusRunning,
|
||||
}}, nil
|
||||
},
|
||||
latestEventAtFn: func() (*time.Time, error) {
|
||||
return &latestEventAt, nil
|
||||
},
|
||||
@@ -1316,16 +1296,6 @@ func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
|
||||
if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 {
|
||||
t.Errorf("queue summary = %+v", body)
|
||||
}
|
||||
if len(body.RunningPolls) != 1 {
|
||||
t.Fatalf("running_polls = %+v", body.RunningPolls)
|
||||
}
|
||||
poll := body.RunningPolls[0]
|
||||
if poll.ID != 44 || poll.SourceID == nil || *poll.SourceID != sourceID || poll.PluginID != "silo.autoscan.cephfs" || poll.CapabilityID != "cephfs" {
|
||||
t.Fatalf("running poll identity = %+v", poll)
|
||||
}
|
||||
if poll.ElapsedMS <= 0 {
|
||||
t.Fatalf("running poll elapsed_ms = %d, want > 0", poll.ElapsedMS)
|
||||
}
|
||||
if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) {
|
||||
t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt)
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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/models"
|
||||
"github.com/Silo-Server/silo-server/internal/sections"
|
||||
@@ -43,9 +42,6 @@ type catalogFiltersResponse struct {
|
||||
Countries []string `json:"countries"`
|
||||
OriginalLanguages []string `json:"original_languages"`
|
||||
ContentRatings []string `json:"content_ratings"`
|
||||
Authors []string `json:"authors"`
|
||||
Narrators []string `json:"narrators"`
|
||||
Series []string `json:"series"`
|
||||
Resolutions *[]string `json:"resolutions,omitempty"`
|
||||
AudioLanguages *[]string `json:"audio_languages,omitempty"`
|
||||
SubtitleLanguages *[]string `json:"subtitle_languages,omitempty"`
|
||||
@@ -90,7 +86,6 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
|
||||
h.itemsH.accessFilter(r),
|
||||
overlaySummaries,
|
||||
store,
|
||||
apimw.GetUserID(r.Context()),
|
||||
profileID,
|
||||
)
|
||||
items := make([]itemListResponse, 0, len(result.Items))
|
||||
@@ -168,94 +163,12 @@ func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.
|
||||
Countries: filters.Countries,
|
||||
OriginalLanguages: filters.OriginalLanguages,
|
||||
ContentRatings: filters.ContentRatings,
|
||||
Authors: filters.Authors,
|
||||
Narrators: filters.Narrators,
|
||||
Series: filters.Series,
|
||||
Resolutions: resolutions,
|
||||
AudioLanguages: audioLanguages,
|
||||
SubtitleLanguages: subtitleLanguages,
|
||||
})
|
||||
}
|
||||
|
||||
// catalogFacetSearchResponse mirrors catalog.CatalogFacetSearchResult on
|
||||
// the wire. matches[] is always present (empty when no hits); has_more
|
||||
// is true when the underlying result set held more entries than the
|
||||
// requested limit.
|
||||
type catalogFacetSearchResponse struct {
|
||||
Matches []string `json:"matches"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// HandleGetCatalogFacetSearch — GET /api/v1/catalog/filters/search
|
||||
//
|
||||
// Prefix-typeahead for the high-cardinality filter facets (authors /
|
||||
// narrators / series, plus genre / studio / network / country /
|
||||
// original_language / content_rating for consistency). Query
|
||||
// parameters: same as /api/v1/catalog/filters for scope (source,
|
||||
// library_id, etc.), plus facet=<name>, q=<prefix>, limit=<N>.
|
||||
//
|
||||
// The bulk /api/v1/catalog/filters endpoint stays as the source for
|
||||
// the initial dropdown render (top 1000 alphabetical); this endpoint
|
||||
// takes over once the user starts typing.
|
||||
func (h *CatalogHandler) HandleGetCatalogFacetSearch(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")
|
||||
return
|
||||
}
|
||||
|
||||
req, err := catalog.ParseCatalogRequest(r.URL.Query())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
facet := strings.TrimSpace(r.URL.Query().Get("facet"))
|
||||
if facet == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "facet parameter is required")
|
||||
return
|
||||
}
|
||||
prefix := r.URL.Query().Get("q")
|
||||
|
||||
limit := 20
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
n, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || n <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
|
||||
return
|
||||
}
|
||||
if n > 100 {
|
||||
n = 100
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
|
||||
result, err := h.resolver.SearchFacet(
|
||||
r.Context(),
|
||||
req,
|
||||
h.itemsH.accessFilter(r),
|
||||
facet,
|
||||
prefix,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search catalog facet")
|
||||
return
|
||||
}
|
||||
|
||||
matches := result.Matches
|
||||
if matches == nil {
|
||||
matches = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, catalogFacetSearchResponse{
|
||||
Matches: matches,
|
||||
HasMore: result.HasMore,
|
||||
})
|
||||
}
|
||||
|
||||
func parseIncludeTechnical(raw string) bool {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return true
|
||||
|
||||
@@ -161,7 +161,35 @@ func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
|
||||
h.writeEpisodeResponses(w, r, episodes)
|
||||
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
|
||||
for _, ep := range episodes {
|
||||
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
|
||||
if h.items.fileRepo != nil {
|
||||
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
|
||||
if fileErr == nil {
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
epResp.Files = append(epResp.Files, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
|
||||
resp = append(resp, epResp)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -411,7 +439,34 @@ func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID
|
||||
}
|
||||
|
||||
func (h *CatalogResourceHandler) writeEpisodeResponses(w http.ResponseWriter, r *http.Request, episodes []*models.Episode) {
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: h.items.buildEpisodeResponses(r, episodes)})
|
||||
filter := h.items.accessFilter(r)
|
||||
resp := make([]episodeResponse, 0, len(episodes))
|
||||
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
|
||||
for _, ep := range episodes {
|
||||
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
|
||||
if h.items.fileRepo != nil {
|
||||
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
|
||||
if fileErr == nil {
|
||||
for _, f := range files {
|
||||
if !catalog.FileAllowedByAccess(f, filter) {
|
||||
continue
|
||||
}
|
||||
epResp.Files = append(epResp.Files, episodeFileResponse{
|
||||
FileID: f.ID,
|
||||
Resolution: f.Resolution,
|
||||
CodecVideo: f.CodecVideo,
|
||||
HDR: f.HDR,
|
||||
AudioChannels: f.AudioChannels,
|
||||
Container: f.Container,
|
||||
FileSize: f.FileSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
|
||||
resp = append(resp, epResp)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
|
||||
}
|
||||
|
||||
func parseSyntheticSeasonID(contentID string) (string, int, bool) {
|
||||
@@ -442,8 +497,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
|
||||
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
|
||||
}
|
||||
}
|
||||
case "movie", "episode", "audiobook", "ebook":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
|
||||
case "movie", "episode":
|
||||
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
|
||||
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
// Ebook read state lives exclusively in ebook_reader_progress: every consumer
|
||||
// (user-state Played, catalog watched/in_progress filters, Continue Reading,
|
||||
// sort metrics, recommendations) keys off progress crossing
|
||||
// models.EbookFinishedProgressThreshold. The native watched endpoints therefore
|
||||
// write that table instead of user_watch_progress/user_watch_history. The
|
||||
// helpers live in this package — next to the ebook progress store and types —
|
||||
// because the handler layer is the only producer today: the webhook-import
|
||||
// matcher (internal/historyimport) only matches movie/series/episode kinds, so
|
||||
// imported mark-played events can never resolve to an ebook content ID.
|
||||
|
||||
// EbookReadStateStore is the ebook_reader_progress surface needed to set or
|
||||
// clear an ebook's read state from the watched endpoints.
|
||||
// *PGEbookReaderProgressStore implements it.
|
||||
type EbookReadStateStore interface {
|
||||
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
|
||||
Upsert(ctx context.Context, progress EbookReaderProgress) error
|
||||
Delete(ctx context.Context, userID int, profileID string, contentID string) error
|
||||
}
|
||||
|
||||
// EbookReaderProgressReadWriter is the full progress-store surface the items
|
||||
// handler needs: batch reads for list/user-state responses plus read-state
|
||||
// writes for the watched endpoints.
|
||||
type EbookReaderProgressReadWriter interface {
|
||||
EbookReaderProgressLister
|
||||
EbookReadStateStore
|
||||
}
|
||||
|
||||
// markEbookRead marks an ebook as finished by upserting its reader-progress
|
||||
// row with progress = 1.0 (>= models.EbookFinishedProgressThreshold). An
|
||||
// existing row keeps its file_id and location so the reader still reopens at
|
||||
// the user's last position even though the book is marked read; a missing row
|
||||
// references the item's preferred reader file (resolved lazily via
|
||||
// defaultFileID, since ebook_reader_progress.file_id is NOT NULL) with an
|
||||
// empty location meaning "start at the beginning". A fresh updated_at also
|
||||
// resurfaces a previously hidden book, mirroring how re-watching resurfaces
|
||||
// hidden video history.
|
||||
func markEbookRead(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
now time.Time,
|
||||
defaultFileID func(ctx context.Context) (int, error),
|
||||
) error {
|
||||
existing, err := store.Get(ctx, userID, profileID, contentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress := EbookReaderProgress{
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
ContentID: contentID,
|
||||
Progress: 1.0,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if existing != nil {
|
||||
progress.FileID = existing.FileID
|
||||
progress.Location = existing.Location
|
||||
} else {
|
||||
fileID, err := defaultFileID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
progress.FileID = fileID
|
||||
}
|
||||
return store.Upsert(ctx, progress)
|
||||
}
|
||||
|
||||
// markEbookUnread clears an ebook's read state by deleting its reader-progress
|
||||
// row — the direct analog of the video unwatch path, where
|
||||
// userstore.ClearProgress DELETEs the user_watch_progress row (dropping the
|
||||
// resume position along with the watched flag).
|
||||
func markEbookUnread(
|
||||
ctx context.Context,
|
||||
store EbookReadStateStore,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
) error {
|
||||
return store.Delete(ctx, userID, profileID, contentID)
|
||||
}
|
||||
|
||||
// setEbookReadState applies the watched toggle for an ebook target resolved by
|
||||
// resolveWatchedTargets, which has already enforced the access filter on the
|
||||
// item. Scoping matches the rest of the ebook progress code: user_id +
|
||||
// profile_id.
|
||||
func (h *ItemsHandler) setEbookReadState(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
profileID, contentID string,
|
||||
read bool,
|
||||
filter catalog.AccessFilter,
|
||||
) error {
|
||||
if h.ebookReadStateStore == nil {
|
||||
return fmt.Errorf("ebook reader progress store is not configured")
|
||||
}
|
||||
if !read {
|
||||
return markEbookUnread(ctx, h.ebookReadStateStore, userID, profileID, contentID)
|
||||
}
|
||||
return markEbookRead(ctx, h.ebookReadStateStore, userID, profileID, contentID, time.Now().UTC(), func(ctx context.Context) (int, error) {
|
||||
return h.defaultEbookFileID(ctx, contentID, filter)
|
||||
})
|
||||
}
|
||||
|
||||
// defaultEbookFileID resolves the file a fresh mark-read progress row should
|
||||
// reference, respecting the caller's access filter.
|
||||
func (h *ItemsHandler) defaultEbookFileID(ctx context.Context, contentID string, filter catalog.AccessFilter) (int, error) {
|
||||
if h.fileRepo == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
files, err := h.fileRepo.GetByContentID(ctx, contentID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file := preferredEbookReadFile(catalog.FilterMediaFilesByAccess(files, filter))
|
||||
if file == nil {
|
||||
return 0, catalog.ErrItemNotFound
|
||||
}
|
||||
return file.ID, nil
|
||||
}
|
||||
|
||||
// preferredEbookReadFile mirrors the web reader's default file choice
|
||||
// (web/src/pages/ItemDetail/EbookContent.tsx preferredReadVersion): prefer an
|
||||
// EPUB version, otherwise the first reader-supported ebook file. Callers must
|
||||
// pass access-filtered files.
|
||||
func preferredEbookReadFile(files []*models.MediaFile) *models.MediaFile {
|
||||
var first *models.MediaFile
|
||||
for _, file := range files {
|
||||
if !isEbookFile(file) {
|
||||
continue
|
||||
}
|
||||
if ebookReaderFormat(file.FilePath, file.Container) == "epub" {
|
||||
return file
|
||||
}
|
||||
if first == nil {
|
||||
first = file
|
||||
}
|
||||
}
|
||||
return first
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user