Compare commits
332
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a8e7aa22 | ||
|
|
a102630365 | ||
|
|
51ef906025 | ||
|
|
ece3578e91 | ||
|
|
ee31a1f0e2 | ||
|
|
22fec4ed2d | ||
|
|
383973ec22 | ||
|
|
2bc6ebb013 | ||
|
|
5e2dd91d6b | ||
|
|
920629e0ad | ||
|
|
e625d574e3 | ||
|
|
eee63772a7 | ||
|
|
4d1c97655d | ||
|
|
7cfdabb096 | ||
|
|
0834edcfc0 | ||
|
|
82379fa3e2 | ||
|
|
5d7eec7a62 | ||
|
|
65d94459f4 | ||
|
|
6da6cf7a71 | ||
|
|
3c56ed606c | ||
|
|
c0b2e18173 | ||
|
|
89b7d25084 | ||
|
|
84cd0acb0c | ||
|
|
2e45a9c015 | ||
|
|
0d3b94d2dd | ||
|
|
3f951c8454 | ||
|
|
f6615ef8d7 | ||
|
|
ac1dd99c72 | ||
|
|
487dc84829 | ||
|
|
166c5ef32f | ||
|
|
5ddec8fa4e | ||
|
|
a0507c78eb | ||
|
|
4e42cb3053 | ||
|
|
bd97f3c123 | ||
|
|
8dc2dcbc13 | ||
|
|
bcc78e7332 | ||
|
|
845d969af1 | ||
|
|
dfce4972e4 | ||
|
|
a679b5e1a1 | ||
|
|
e18bc3c568 | ||
|
|
a2e6cd8d5f | ||
|
|
d6cdf5c9e4 | ||
|
|
fcd7eb697d | ||
|
|
44db85457c | ||
|
|
ab29bc8c8e | ||
|
|
37adec320e | ||
|
|
537b3f6476 | ||
|
|
60ce7ed1a4 | ||
|
|
b3e963f54e | ||
|
|
f094d70996 | ||
|
|
7f3b93d1b4 | ||
|
|
b1545ae9f9 | ||
|
|
bb53bd0652 | ||
|
|
bf3c5645f4 | ||
|
|
794e2d786a | ||
|
|
fcaee9bfce | ||
|
|
9136803130 | ||
|
|
3a5d318714 | ||
|
|
194e5ff527 | ||
|
|
c786551ba2 | ||
|
|
68b104fb53 | ||
|
|
8d40138bdb | ||
|
|
1d5f19d794 | ||
|
|
1c2d422548 | ||
|
|
eb6b968ed1 | ||
|
|
ef7eedf3fc | ||
|
|
1ab85d18ea | ||
|
|
0a914441ea | ||
|
|
31e6f1ef41 | ||
|
|
9fad08a6fa | ||
|
|
8b3bff194b | ||
|
|
dee46f9398 | ||
|
|
b822bb7181 | ||
|
|
1f9bd99990 | ||
|
|
4b3362d32e | ||
|
|
93b851fe81 | ||
|
|
2d5d4980de | ||
|
|
827843fd21 | ||
|
|
a6348b3dc5 | ||
|
|
b3e2943ccd | ||
|
|
c1cebf519d | ||
|
|
75c2260897 | ||
|
|
f965a489bd | ||
|
|
1d4f5dbce2 | ||
|
|
21ee543012 | ||
|
|
3457382324 | ||
|
|
a0851edef0 | ||
|
|
4f249fda8f | ||
|
|
c90e736054 | ||
|
|
84f8495e4f | ||
|
|
c5a5b02221 | ||
|
|
845b96e703 | ||
|
|
800f2543ac | ||
|
|
f637404c46 | ||
|
|
e56a1b3e03 | ||
|
|
4fa84a661a | ||
|
|
7369d0afd8 | ||
|
|
8044eb84dd | ||
|
|
b96e359b4e | ||
|
|
2fb5e10de4 | ||
|
|
91e1164090 | ||
|
|
1664c60425 | ||
|
|
95779c47a9 | ||
|
|
b3722dac58 | ||
|
|
6ece120f77 | ||
|
|
255b1be89c | ||
|
|
8fc054c15d | ||
|
|
1f2125b920 | ||
|
|
0f9584ef42 | ||
|
|
f377be9d6e | ||
|
|
035ef457a4 | ||
|
|
c36d70dcba | ||
|
|
be3bfafeaa | ||
|
|
18283c2c9b | ||
|
|
3ddc74f784 | ||
|
|
a196b0844e | ||
|
|
075e217477 | ||
|
|
854d07cf8f | ||
|
|
e487253ad3 | ||
|
|
808883cf8f | ||
|
|
28c6ddc237 | ||
|
|
3fd0912cb3 | ||
|
|
df9cacc8da | ||
|
|
02b6648995 | ||
|
|
c1c110e3d2 | ||
|
|
9cfd01e9c2 | ||
|
|
b7292a9473 | ||
|
|
98a22dafd2 | ||
|
|
648e5c76ef | ||
|
|
58eed826a4 | ||
|
|
10e15798e0 | ||
|
|
d68e70bb47 | ||
|
|
8b9149a789 | ||
|
|
04c4344f52 | ||
|
|
4d99597966 | ||
|
|
e96a8a0cf8 | ||
|
|
3e340552a8 | ||
|
|
a2ef26bece | ||
|
|
203a18ae83 | ||
|
|
76b10b822e | ||
|
|
7f4f609948 | ||
|
|
3df5ed4e09 | ||
|
|
0deff23985 | ||
|
|
021e54a03c | ||
|
|
0694787504 | ||
|
|
4e03f4b807 | ||
|
|
11704a1701 | ||
|
|
43d9056b01 | ||
|
|
a26b2de454 | ||
|
|
4a76016015 | ||
|
|
af0e3e75a9 | ||
|
|
e140bd9424 | ||
|
|
ea518d2316 | ||
|
|
fe06312b06 | ||
|
|
c29212b2c5 | ||
|
|
8e9e2c91d4 | ||
|
|
80aff39921 | ||
|
|
885354e573 | ||
|
|
0fb5afe479 | ||
|
|
d08a4f232f | ||
|
|
11eb771165 | ||
|
|
745f72967e | ||
|
|
e3a209c4ff | ||
|
|
ba2bac7f55 | ||
|
|
28340ad6f5 | ||
|
|
69dde62fcd | ||
|
|
42602b7896 | ||
|
|
2e0e145721 | ||
|
|
5f59f8e952 | ||
|
|
8fa40e928b | ||
|
|
b8b708cbe0 | ||
|
|
8bb88658b1 | ||
|
|
9ead29bfcc | ||
|
|
f7954f226e | ||
|
|
193a2905b2 | ||
|
|
430224a1b9 | ||
|
|
a5fb16a5c5 | ||
|
|
ce09d2b6fc | ||
|
|
cb2581af4a | ||
|
|
2155261706 | ||
|
|
5b0c90b4c1 | ||
|
|
97ac2b4eed | ||
|
|
c0f209a936 | ||
|
|
ade3a3c5d4 | ||
|
|
ce06dc8ca5 | ||
|
|
604bbf1a0f | ||
|
|
631d95530a | ||
|
|
d590eda22a | ||
|
|
c75c519e3d | ||
|
|
012006e7a7 | ||
|
|
f983c54e87 | ||
|
|
a4402d4621 | ||
|
|
866392fecd | ||
|
|
287ff3658f | ||
|
|
b250dbb59b | ||
|
|
26089950af | ||
|
|
d1fd7c236f | ||
|
|
18e9856227 | ||
|
|
57fbc1e0b4 | ||
|
|
9cae868a27 | ||
|
|
1e8f79fe18 | ||
|
|
9c6252dace | ||
|
|
8b3ebc78b6 | ||
|
|
2486345679 | ||
|
|
eb35141ecc | ||
|
|
cf0db385f3 | ||
|
|
eafa22a3c3 | ||
|
|
652b5d1e9f | ||
|
|
4fb8f6a711 | ||
|
|
1dbcf2cd9a | ||
|
|
75cf5e3b11 | ||
|
|
8afe783277 | ||
|
|
9cea2bb4f0 | ||
|
|
a0fbe2cdda | ||
|
|
4d2a990de1 | ||
|
|
96a279819e | ||
|
|
443445f0fe | ||
|
|
02e62767a1 | ||
|
|
b4a66b29e2 | ||
|
|
6854eac80e | ||
|
|
6414061022 | ||
|
|
a3aa93534d | ||
|
|
d83f7fadef | ||
|
|
185a90b048 | ||
|
|
78d4f9acbf | ||
|
|
7f67bb80ad | ||
|
|
b21c3eea37 | ||
|
|
f57c95e813 | ||
|
|
a181586139 | ||
|
|
dd2784c0ee | ||
|
|
4473f8c60b | ||
|
|
125e94b429 | ||
|
|
2a943550d4 | ||
|
|
6b45cfd855 | ||
|
|
ed6c084c68 | ||
|
|
91b5105f3c | ||
|
|
7080491f89 | ||
|
|
13ac4b753c | ||
|
|
a5ef464d95 | ||
|
|
dadd6f4769 | ||
|
|
4cc92c1579 | ||
|
|
f40611409d | ||
|
|
e21c35cb7f | ||
|
|
927d7764a4 | ||
|
|
20f36fdc31 | ||
|
|
5089e17ee6 | ||
|
|
07f2dd5a8f | ||
|
|
40329f616d | ||
|
|
fb3a21c332 | ||
|
|
86bc69c0a2 | ||
|
|
6df388b25b | ||
|
|
6ca427096b | ||
|
|
89c435f046 | ||
|
|
af52ea7bcc | ||
|
|
13c0dde538 | ||
|
|
2045b7a0b2 | ||
|
|
5c0027b54b | ||
|
|
87159b0a38 | ||
|
|
227986094b | ||
|
|
9819f0ff12 | ||
|
|
dc44e13bfa | ||
|
|
3ad77095c4 | ||
|
|
0726d9430e | ||
|
|
544a262924 | ||
|
|
187f29e163 | ||
|
|
729468be30 | ||
|
|
de931f784a | ||
|
|
b84c040d27 | ||
|
|
cac435c4b9 | ||
|
|
08997264d2 | ||
|
|
42fd55db08 | ||
|
|
7e532ea982 | ||
|
|
b3198276f7 | ||
|
|
6e1f79e8a9 | ||
|
|
14ffc91dfb | ||
|
|
fbe54ffec5 | ||
|
|
562ae635d7 | ||
|
|
cf3e68e02a | ||
|
|
20e0dd7fe2 | ||
|
|
e99079abf8 | ||
|
|
9b111649f3 | ||
|
|
f207b87ff7 | ||
|
|
c4cbcddeae | ||
|
|
63d608f6fc | ||
|
|
9d00ce2089 | ||
|
|
4502c7a850 | ||
|
|
623348111c | ||
|
|
e084cdd1d6 | ||
|
|
9cd00a877b | ||
|
|
bf54f040f9 | ||
|
|
fe7eb70483 | ||
|
|
c81de3459e | ||
|
|
3f3cf55002 | ||
|
|
190223e030 | ||
|
|
ea6d1d1f00 | ||
|
|
ae65678ba4 | ||
|
|
2e422b66df | ||
|
|
10a0635331 | ||
|
|
13c5e0ba2f | ||
|
|
88aa769fe6 | ||
|
|
5afe56cfc0 | ||
|
|
3e77f39d24 | ||
|
|
6f9427d613 | ||
|
|
bcf0253c09 | ||
|
|
91c7eb364d | ||
|
|
f4594e902d | ||
|
|
9508449859 | ||
|
|
4e521ed2e8 | ||
|
|
1e3780d4fb | ||
|
|
91e987bcda | ||
|
|
22af261786 | ||
|
|
799754dde7 | ||
|
|
d2ccbcb1a6 | ||
|
|
3d2368aed7 | ||
|
|
beb6b880fc | ||
|
|
b83df36636 | ||
|
|
88ddd2a406 | ||
|
|
ebf3352bda | ||
|
|
5f05374a1d | ||
|
|
27b9006ef5 | ||
|
|
df95e3cb95 | ||
|
|
e5b210589d | ||
|
|
4274220266 | ||
|
|
d9e27da59e | ||
|
|
4fd21c7aee | ||
|
|
b091f0c6c1 | ||
|
|
a24279e3e3 | ||
|
|
a0f7810481 | ||
|
|
adeda3c87c | ||
|
|
0eb87fa0f5 | ||
|
|
fadd8ff456 | ||
|
|
a17529fd6f |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: dev-environment-debugging
|
||||
description: Use when diagnosing a running Silo server — playback, streaming, or transcode failures, unhealthy nodes, stuck sessions, wrong database state, errors after a deploy, or anything needing the deployment's logs, health endpoints, or SQL. Covers both a local docker compose install and a remote host over SSH. Not for building or testing code in this repo, and not for client-side bugs that reproduce without a server.
|
||||
---
|
||||
|
||||
# Debugging a running Silo deployment
|
||||
|
||||
Everything here goes through `scripts/silo-dev`, which reads `.silo-dev.env` and runs
|
||||
commands against the deployment whether it is on this machine or behind SSH. Start with:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev doctor
|
||||
```
|
||||
|
||||
`doctor` reports which of API, host shell, compose stack, database, and login actually
|
||||
work. Fix what it flags before diagnosing anything else — most confusing symptoms here
|
||||
are really a misconfigured target. If `.silo-dev.env` is missing, copy
|
||||
`.silo-dev.env.example` and fill it in; ask the user for hosts and credentials rather
|
||||
than guessing, and never move those values into a committed file.
|
||||
|
||||
## Orienting
|
||||
|
||||
```bash
|
||||
scripts/silo-dev compose ps # what is running
|
||||
scripts/silo-dev api /api/v1/health # API up
|
||||
scripts/silo-dev api /api/v1/ready # PostgreSQL and optional S3 reachable
|
||||
scripts/silo-dev logs --tail 200 # recent server logs
|
||||
scripts/silo-dev logs -f # follow
|
||||
```
|
||||
|
||||
`/ready` failing usually means the database, not the API — read the service logs before
|
||||
concluding Postgres is down.
|
||||
|
||||
Silo also records structured logs in the database itself, which survive container
|
||||
restarts and are easier to filter than stdout. `operational_logs` holds server events,
|
||||
`activity_log` holds HTTP requests; both are keyed by `"timestamp"` (quoted — it is a
|
||||
reserved word). See [references/queries.md](references/queries.md) for the diagnostic
|
||||
queries worth having on hand, along with the schema traps that bite most often.
|
||||
|
||||
Log level lives in `server_settings` under `server.log_level` and is read at startup, so
|
||||
raising it to `debug` means updating the row and recreating the service. Put it back
|
||||
afterwards unless the task needs it to persist.
|
||||
|
||||
## Search
|
||||
|
||||
Meilisearch is optional — Silo runs without it — but it is recommended, and it is what
|
||||
makes search results good. It ships as a compose service behind the `search` profile and
|
||||
needs `MEILI_MASTER_KEY` set, so a stack brought up without that profile simply has no
|
||||
`meilisearch` service.
|
||||
|
||||
The provider is chosen from the `catalog.search.provider` setting rather than from
|
||||
whether the container is running, and the failure mode is quiet: if Meilisearch is
|
||||
selected but the URL is empty, unreachable, or the provider fails to initialize, Silo
|
||||
logs a warning at startup and serves Postgres results instead. Nothing surfaces to the
|
||||
client. So "search got worse" or "search stopped matching the way it used to" is usually
|
||||
the fallback being active, not a ranking regression — check which provider is live before
|
||||
touching query code:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev psql "SELECT key, value FROM server_settings WHERE key LIKE 'catalog.search%' ORDER BY key;"
|
||||
scripts/silo-dev logs --tail 500 | grep -i 'catalog search'
|
||||
```
|
||||
|
||||
Provider settings, index name, timeouts, batch sizes, and the semantic-search options all
|
||||
live under `catalog.search.meilisearch.*` in `server_settings`. Changing the provider
|
||||
needs a restart to take effect.
|
||||
|
||||
## Playback
|
||||
|
||||
Work outward from the failure the user actually saw.
|
||||
|
||||
**Direct play** — confirm the file exists at the path stored in `media_files`
|
||||
(`scripts/silo-dev sh 'ls -la …'`, under `SILO_MEDIA_ROOT`), then compare the file's
|
||||
codecs against what the client advertised. A mismatch should have fallen back to remux
|
||||
or transcode; if it did not, that is the bug.
|
||||
|
||||
**Remux and transcode** — both run ffmpeg inside the server container, so check ffmpeg
|
||||
is present and, for hardware paths, that `/dev/dri` is passed through:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev compose "exec -T silo sh -lc 'ffmpeg -hwaccels; ls -la /dev/dri'"
|
||||
```
|
||||
|
||||
Transcode output lands in a bind-mounted temp directory; a full disk there looks like a
|
||||
transcode bug. If `SILO_TRANSCODE_SSH` is set, the node is a separate host with its own
|
||||
`/api/v1/health` and logs — check it before blaming the main server.
|
||||
|
||||
**Stuck sessions** — query `playback_sessions_sync` for rows whose `updated_at` has gone
|
||||
stale. The reconciler and shutdown path clean these up; when Redis is unavailable,
|
||||
cross-node coordination degrades and stale rows are a symptom rather than the cause.
|
||||
|
||||
## Deploying a fix
|
||||
|
||||
`make dev-deploy` and friends come from `Makefile.local`, which is gitignored and
|
||||
per-developer — do not assume they exist. Confirm with `make -n dev-deploy` first, and
|
||||
otherwise deploy however the user's setup does. Afterwards, verify rather than assume:
|
||||
|
||||
```bash
|
||||
scripts/silo-dev compose ps
|
||||
scripts/silo-dev api /api/v1/ready
|
||||
scripts/silo-dev logs --tail 100
|
||||
```
|
||||
|
||||
Deploys restart the container, which invalidates any token you were holding.
|
||||
|
||||
## Rename drift
|
||||
|
||||
Silo was formerly Continuum. Names like `continuum-postgres`, `/opt/continuum`,
|
||||
`CONTINUUM_IMAGE`, or a stray `docker-compose.dev.yml` are usually legacy artifacts, but
|
||||
on a long-lived host they are occasionally still the live stack. Check what compose
|
||||
actually reports before deciding which one you are looking at.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Diagnostic queries
|
||||
|
||||
Run with `scripts/silo-dev psql "<sql>"`.
|
||||
|
||||
## Schema traps
|
||||
|
||||
- `activity_log` and `operational_logs` order by `"timestamp"` — quoted, not `created_at`.
|
||||
- `admin_jobs` uses `job_type`, `requested_at`, and `completed_at` (not `type`,
|
||||
`created_at`, `finished_at`).
|
||||
- Login accounts (`users`) are separate from household profiles (`user_profiles`); a
|
||||
playback session carries both a `user_id` and a `profile_id`.
|
||||
|
||||
## Errors and requests
|
||||
|
||||
```sql
|
||||
SELECT "timestamp", level, component, message, request_id, playback_session_id, attrs
|
||||
FROM operational_logs
|
||||
WHERE level IN ('error', 'warn')
|
||||
ORDER BY "timestamp" DESC
|
||||
LIMIT 50;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT "timestamp", client_ip, method, path, status_code, duration_ms, request_id
|
||||
FROM activity_log
|
||||
WHERE status_code >= 500
|
||||
ORDER BY "timestamp" DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
Drop the `WHERE` clause on `activity_log` for a plain recent-traffic view — useful for
|
||||
confirming a client is reaching the server at all, and with which paths.
|
||||
|
||||
## Playback
|
||||
|
||||
```sql
|
||||
SELECT s.session_id, u.username, COALESCE(s.profile_id, '') AS profile_id,
|
||||
s.play_method, s.reporting_node, s.transcode_node_url,
|
||||
s.position_seconds, s.is_paused, s.target_resolution,
|
||||
s.started_at, s.updated_at
|
||||
FROM playback_sessions_sync s
|
||||
LEFT JOIN users u ON u.id = s.user_id
|
||||
ORDER BY s.updated_at DESC NULLS LAST;
|
||||
```
|
||||
|
||||
Stale rows only:
|
||||
|
||||
```sql
|
||||
SELECT session_id, user_id, play_method, updated_at
|
||||
FROM playback_sessions_sync
|
||||
WHERE updated_at < now() - interval '5 minutes'
|
||||
ORDER BY updated_at;
|
||||
```
|
||||
|
||||
## Nodes
|
||||
|
||||
```sql
|
||||
SELECT id, name, type, url, enabled, healthy, active_jobs, last_health_check
|
||||
FROM stream_nodes
|
||||
ORDER BY type, name;
|
||||
```
|
||||
|
||||
Read-only node state is better taken from here than from the admin endpoints, which need
|
||||
a bearer token. To force a re-check, `POST /api/v1/admin/nodes/{id}/check` — reachable as
|
||||
`scripts/silo-dev api /api/v1/admin/nodes/{id}/check -X POST`.
|
||||
|
||||
## Jobs
|
||||
|
||||
```sql
|
||||
SELECT id, job_type, status, message, error_message,
|
||||
progress_current, progress_total, requested_at, started_at,
|
||||
completed_at, heartbeat_at
|
||||
FROM admin_jobs
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
Filter by `job_type = 'library_refresh'` (and select `request_payload`, `result_payload`)
|
||||
when chasing a scan or refresh that did not finish.
|
||||
|
||||
## Users and settings
|
||||
|
||||
```sql
|
||||
SELECT id, username, email, role, enabled, permissions, max_streams, max_transcodes, created_at
|
||||
FROM users
|
||||
ORDER BY id;
|
||||
```
|
||||
|
||||
```sql
|
||||
SELECT key, value
|
||||
FROM server_settings
|
||||
WHERE key NOT ILIKE '%secret%'
|
||||
AND key NOT ILIKE '%password%'
|
||||
AND key NOT ILIKE '%api_key%'
|
||||
AND key NOT ILIKE '%token%'
|
||||
ORDER BY key;
|
||||
```
|
||||
|
||||
Keep that filter. Encrypted `server_settings` values are GCM-bound to their key name, so
|
||||
they are unreadable here anyway — and dumping them into a transcript is a leak.
|
||||
|
||||
## Raising the log level
|
||||
|
||||
```sql
|
||||
INSERT INTO server_settings (key, value) VALUES ('server.log_level', 'debug')
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
|
||||
```
|
||||
|
||||
Then `scripts/silo-dev compose "up -d --force-recreate silo"`, and set it back to `info`
|
||||
when done.
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
name: issue-to-pr
|
||||
disable-model-invocation: true
|
||||
description: >-
|
||||
End-to-end implement a GitHub issue and open a ready pull request. Invoke
|
||||
explicitly as `/issue-to-pr <number|#ref|url>` (this is a side-effecting
|
||||
workflow — it creates branches and opens PRs — so it does not auto-trigger).
|
||||
It creates an isolated git worktree off the latest main, reproduces the bug
|
||||
(or scopes the feature), finds the root cause, implements the smallest correct
|
||||
change following repo conventions, verifies it (build/lint/tests), runs a Codex
|
||||
adversarial review and addresses the material findings, then commits and opens
|
||||
a ready PR. Not for pure triage (use triage-issue), pure review of an existing
|
||||
PR (use review-pr-or-mr / code-review), or analysis without code changes.
|
||||
---
|
||||
|
||||
# Issue → PR
|
||||
|
||||
Turn a single GitHub issue into a verified, reviewed, ready-to-merge pull
|
||||
request — autonomously — while staying honest about uncertainty and stopping
|
||||
when a human decision is genuinely required.
|
||||
|
||||
The goal is not "produce a diff." It is: **confirm the real problem exists,
|
||||
make the smallest change that correctly solves it, prove it works by execution,
|
||||
have an adversary try to break it, and only then ship a PR.** Speed comes from
|
||||
doing these in order, not from skipping them.
|
||||
|
||||
## Inputs
|
||||
|
||||
- The issue reference is passed as `$ARGUMENTS` — a number (`123`), a `#123`
|
||||
reference, or a full issue URL. If none is given, ask; do not guess.
|
||||
- The repo is the current git repository; PRs target `origin`
|
||||
(`Silo-Server/silo-server`) and base `main`.
|
||||
|
||||
## Operating principles
|
||||
|
||||
- **Default to "maybe nothing needs fixing."** Agents "fix" already-correct or stale
|
||||
code in a large fraction of cases because they don't stop to confirm the problem is
|
||||
real. Confirm the issue reproduces on current `main` before changing anything.
|
||||
- **Verify by execution.** Build/lint/tests are the primary correctness signal. The
|
||||
adversarial review is a *second* layer on top, never a substitute for green checks.
|
||||
- **Don't trust recall for APIs.** Grep for every symbol and signature you call —
|
||||
hallucinated-but-plausible APIs are a common failure here.
|
||||
- **Know when to stop.** See [Stop and ask](#stop-and-ask). Autonomy is for execution,
|
||||
not for inventing product decisions or shipping unverifiable guesses.
|
||||
|
||||
Track the phases below with a task list so progress is visible on long runs.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Understand the issue
|
||||
|
||||
Read everything before writing anything.
|
||||
|
||||
```bash
|
||||
gh issue view <N> --json number,title,body,labels,state,author,url,comments
|
||||
```
|
||||
|
||||
Determine: **bug vs. feature** (labels + body → branch prefix `fix/` vs `feat/`);
|
||||
**acceptance criteria** (what does "done" mean?); **linked scope** (does it serve
|
||||
an epic / sub-issue to reference as `Part of #NNN`?); and **client surface** (does
|
||||
it touch API/auth/playback/session/library/metadata behavior consumed by
|
||||
`silo-android` / `silo-apple`? — if so, coordinated client follow-up may be needed).
|
||||
|
||||
If the issue is vague, underspecified, or needs a product call, stop and ask now —
|
||||
before spending a worktree on the wrong problem.
|
||||
|
||||
### 2. Create a worktree off the latest main
|
||||
|
||||
Work in isolation so the user's checkout is untouched. Create it at the **main
|
||||
repository root**, not inside the current worktree.
|
||||
|
||||
```bash
|
||||
# Resolve the primary repo root (works from inside any worktree)
|
||||
MAIN_ROOT="$(cd "$(dirname "$(git rev-parse --git-common-dir)")" && pwd)"
|
||||
git -C "$MAIN_ROOT" fetch origin # always branch off fresh main
|
||||
|
||||
SLUG=<short-kebab-slug-from-issue-title>
|
||||
BRANCH=fix/issue-<N>-$SLUG # use feat/ for features
|
||||
WT="$MAIN_ROOT/.claude/worktrees/issue-<N>"
|
||||
|
||||
git -C "$MAIN_ROOT" worktree add -b "$BRANCH" "$WT" origin/main
|
||||
cd "$WT"
|
||||
```
|
||||
|
||||
If the branch/worktree already exists from a prior run, reuse it (cd in) rather
|
||||
than failing — but confirm it's based on current `origin/main`; if it's stale, tell
|
||||
the user before continuing. From here, **all work happens in `$WT`** (the Bash tool
|
||||
keeps the working directory between calls); use absolute paths when in doubt.
|
||||
|
||||
### 3. Reproduce / confirm — a hard gate
|
||||
|
||||
This is the highest-leverage step. Do not skip it.
|
||||
|
||||
- **Bug:** reproduce it concretely — ideally a **test that fails on current `main`**
|
||||
(red), or a precise script/trace through the faulty path. Pin down the exact code
|
||||
path and the invariant it violates.
|
||||
- **If you cannot reproduce it, STOP and report** — do not patch. The issue may
|
||||
already be fixed, be environment-specific, or be a misunderstanding. Shipping a
|
||||
speculative fix for a non-reproducing bug is the most common way agents make
|
||||
things worse.
|
||||
- **Feature:** confirm the capability is actually missing, then map where it fits —
|
||||
the owning package, patterns to follow, the data flow, the smallest set of files.
|
||||
|
||||
**Plan proportionally.** If you could describe the diff in one sentence (a typo, a
|
||||
log line, a one-line guard), skip formal planning and make the change. Plan first
|
||||
when the work is multi-file, unfamiliar, or architecturally uncertain — name the
|
||||
files/interfaces involved, state what's out of scope, and end the plan with the
|
||||
verification step. For anything large, confirm direction with the user before a big
|
||||
build-out.
|
||||
|
||||
### 4. Implement
|
||||
|
||||
Make the change, scoped to this one concern (one concern per PR). The two hard rules
|
||||
that a reviewer cannot un-break after the fact:
|
||||
|
||||
- **API:** additive-only within `/api/v1` — never rename/remove a response field,
|
||||
change a field's type, or repurpose a status code. New behavior = new
|
||||
fields/endpoints; expose capability endpoints for feature detection.
|
||||
- **Migrations:** new DB changes are Goose SQL migrations via
|
||||
`make migrate-create NAME=...` (timestamped). Never `goose fix`, never paired
|
||||
`.up.sql`/`.down.sql`.
|
||||
|
||||
For a bug, the failing test from step 3 should now pass.
|
||||
|
||||
### 5. Verify — the primary correctness gate
|
||||
|
||||
Run the checks and make them genuinely pass. The exact commands and the worktree
|
||||
quirks (`GOWORK=off`, stubbing `web/dist`, the v2 golangci invocation, the flaky
|
||||
GPU tests) are in **[references/verification.md](references/verification.md)** —
|
||||
read it first, because plain `go build ./...` / `make lint` fail inside
|
||||
`.claude/worktrees`.
|
||||
|
||||
At minimum: Go build + the relevant package tests pass; if `web/` changed,
|
||||
`pnpm run lint` + `pnpm run format:check` + build pass; `make verify-local-paths` is
|
||||
clean. Run the **full relevant suite**, not just your new test — catch regressions
|
||||
in code you didn't touch. Show the command output as evidence; don't assert success.
|
||||
|
||||
**Never reach green by weakening the checks.** Deleting/skipping/`xfail`-ing tests,
|
||||
loosening assertions, lowering coverage thresholds, or relaxing lint/CI to make a
|
||||
red check pass is a blocker, full stop — if a test genuinely must change, that needs
|
||||
explicit human sign-off, not a quiet edit. If you can't make the checks pass on the
|
||||
merits, stop and report.
|
||||
|
||||
### 6. Adversarial review (Codex)
|
||||
|
||||
Commit your work first (so the review sees exactly what will ship), then run a Codex
|
||||
adversarial review of the branch against `main`. Full invocation, output handling,
|
||||
the iteration cap, and the fallback are in
|
||||
**[references/codex-review.md](references/codex-review.md)** — follow it exactly.
|
||||
|
||||
The loop, in short:
|
||||
1. Commit the implementation on the branch.
|
||||
2. Run the adversarial review (`--base main`, foreground/`--wait`, extended timeout).
|
||||
3. For each finding: **first confirm it's real and grounded in the actual code**
|
||||
(reviewers over-flag — don't fix phantom nits). Then **fix** material findings
|
||||
that affect correctness or the stated requirements (and re-run the review), or
|
||||
**rebut** them with a defensible, written reason. Note non-material findings as
|
||||
optional.
|
||||
4. **Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
|
||||
sharply after ~2–3 attempts, so thrashing won't help — if material findings still
|
||||
stand after the cap, stop and escalate to the user instead of looping.
|
||||
|
||||
Do not open the PR while a material, un-rebutted adversarial finding stands.
|
||||
|
||||
### 7. Commit, push, open the PR
|
||||
|
||||
Conventional Commit subjects scoped to the domain, e.g.
|
||||
`fix(playback): guard against nil session on reconnect`, ending with the
|
||||
`Co-Authored-By:` trailer for the model you are actually running as — don't copy a
|
||||
model name from this file or from an older commit.
|
||||
|
||||
Push and open a **ready** (non-draft) PR against `main`:
|
||||
|
||||
```bash
|
||||
git push -u origin "$BRANCH"
|
||||
gh pr create --base main --head "$BRANCH" --title "<conventional subject>" --body "$(cat <<'EOF'
|
||||
## Problem
|
||||
<what's broken / missing, grounded in the issue — and how you reproduced it>
|
||||
|
||||
## Approach
|
||||
<what you changed and *why this approach* — the tradeoff, not a file list>
|
||||
|
||||
## Verification
|
||||
<commands run and their result; the failing→passing test; manual checks>
|
||||
|
||||
## Adversarial review
|
||||
<clean, or the material findings and how each was fixed/rebutted>
|
||||
|
||||
## Risks & follow-up
|
||||
<rollout/migration/compatibility risks; any silo-android / silo-apple
|
||||
follow-up; anything deferred>
|
||||
|
||||
Closes #<N>
|
||||
<add "Part of #<epic>" if the issue serves a capability epic/sub-issue>
|
||||
|
||||
## AI-use disclosure
|
||||
Implemented by Claude Code (issue-to-pr skill) with human oversight.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
End the PR body with:
|
||||
|
||||
```
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
For **UI changes**, attach before/after screenshots or a recording (the repo
|
||||
requires this); if you can't capture them, explicitly flag in the PR that they're
|
||||
still needed.
|
||||
|
||||
### 8. Report back
|
||||
|
||||
Give the user the PR URL, a 2–3 line summary, the verification result, the
|
||||
adversarial-review outcome, any flagged risks or required client follow-up, and the
|
||||
worktree path so they can inspect it.
|
||||
|
||||
## Long or multi-iteration runs
|
||||
|
||||
Performance degrades on long sessions, and context compaction can silently drop
|
||||
constraints. So: keep a short progress note and a "failed approaches" list in the
|
||||
scratchpad so you don't repeat dead ends; and after any compaction, re-read CLAUDE.md
|
||||
and re-assert the hard guardrails (additive-only API, migration rules, no weakening
|
||||
of tests) before continuing.
|
||||
|
||||
## Stop and ask
|
||||
|
||||
Pause and ask the user (don't push a PR) when:
|
||||
|
||||
- The issue is ambiguous, underspecified, or needs a product/UX decision.
|
||||
- **The bug doesn't reproduce** on current `main`.
|
||||
- The fix would require a non-additive API change, or a risky/irreversible migration.
|
||||
- The change balloons into something large or architectural — propose a plan first.
|
||||
- Checks can't be made to pass on the merits, or material adversarial findings remain
|
||||
after the iteration cap.
|
||||
- A test/CI change would be needed to "pass" — get sign-off, don't do it silently.
|
||||
- The right fix clearly belongs in a sibling repo (`silo-android`, `silo-apple`, a
|
||||
plugin/SDK repo) — see CLAUDE.md's multi-repo guidance.
|
||||
|
||||
In these cases, summarize what you found and what you'd do, and let the user decide.
|
||||
|
||||
## Notes
|
||||
|
||||
- Authorized to open ready PRs autonomously once checks pass and the adversarial
|
||||
review is clean — that's the durable instruction from setup. Not authorized to
|
||||
merge, force-push shared branches, or close issues by hand.
|
||||
- Never invent a fix you can't verify or explain. An honest "here's what's uncertain"
|
||||
beats a confident wrong PR.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Codex adversarial review
|
||||
|
||||
The `/codex:adversarial-review` command is marked `disable-model-invocation: true`,
|
||||
so you cannot trigger it as a tool/skill. Call the same underlying runtime
|
||||
directly via Bash: the `codex-companion.mjs` helper, subcommand
|
||||
`adversarial-review`. It challenges the *approach and design*, not just defects —
|
||||
it tries to find the strongest reasons the change should not ship.
|
||||
|
||||
## 1. Locate the companion script
|
||||
|
||||
```bash
|
||||
CODEX_COMPANION="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs}"
|
||||
if [ -z "$CODEX_COMPANION" ] || [ ! -f "$CODEX_COMPANION" ]; then
|
||||
CODEX_COMPANION="$(find "$HOME/.claude/plugins/marketplaces" "$HOME/.claude/plugins/cache" \
|
||||
-name codex-companion.mjs 2>/dev/null | head -n1)"
|
||||
fi
|
||||
echo "$CODEX_COMPANION"
|
||||
```
|
||||
|
||||
If nothing is found, the Codex plugin isn't installed/set up. Don't silently skip
|
||||
the review — tell the user, point them at `/codex:setup`, and ask whether to
|
||||
proceed without it (or fall back to a `feature-dev:code-reviewer` agent pass).
|
||||
The user asked specifically for the Codex adversarial review, so treat its absence
|
||||
as a real blocker, not a detail.
|
||||
|
||||
## 2. Commit first, then review the branch against main
|
||||
|
||||
Review what will actually ship. Commit the implementation on the branch, then
|
||||
diff against `main`:
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "<conventional subject>"
|
||||
```
|
||||
|
||||
Run it in the **foreground** so results come back this turn, and set an extended
|
||||
Bash timeout (reviews can take several minutes) — pass `timeout: 600000` on the
|
||||
Bash call:
|
||||
|
||||
```bash
|
||||
node "$CODEX_COMPANION" adversarial-review --wait --base main "optional focus text"
|
||||
```
|
||||
|
||||
- `--wait` runs foreground and prints the result to stdout.
|
||||
- `--base main` reviews the whole branch diff (`main...HEAD`).
|
||||
- Append focus text to steer it (e.g. the specific failure mode, the risky
|
||||
concurrency path, the migration). Keep the adversarial framing — don't soften it.
|
||||
- Do **not** use `--background` here; you need the findings in-hand to act on them.
|
||||
|
||||
## 3. Interpret the output
|
||||
|
||||
The review returns a verdict plus findings:
|
||||
|
||||
- **`approve` / no material findings** → green light for the PR.
|
||||
- **`needs-attention`** → there is at least one material risk. Each finding names
|
||||
a file, a line range, an impact, and a concrete recommendation.
|
||||
|
||||
LLM reviewers systematically **over-flag** — they report gaps even when the work is
|
||||
sound. So for every finding, in order:
|
||||
|
||||
1. **Confirm it's real.** Open the cited file/lines and verify the finding is
|
||||
grounded in the actual code and actually reachable. Drop findings that reference
|
||||
code that doesn't exist, lines outside the diff, or behavior the code doesn't have
|
||||
— don't fix phantom nits.
|
||||
2. For a confirmed finding, do one of two things — never silently ignore it:
|
||||
- **Fix it** if it affects correctness or the issue's stated requirements, then
|
||||
re-run the review (go back to step 2 after committing the fix); or
|
||||
- **Rebut it** with a defensible, written reason it doesn't apply, and capture
|
||||
that reasoning in the PR's "Adversarial review" section.
|
||||
3. Treat style/naming/speculative/low-value findings as **optional** — don't
|
||||
gold-plate. The adversarial prompt is told not to emit these, but if any slip
|
||||
through, note and move on; don't add abstraction or defensive code for cases that
|
||||
can't happen.
|
||||
|
||||
## 4. Loop — but cap it
|
||||
|
||||
Repeat implement → verify → commit → review until the review is `approve` or every
|
||||
material finding is fixed or defensibly rebutted.
|
||||
|
||||
**Cap the loop at 2 iterations (3 absolute max).** Debugging effectiveness decays
|
||||
sharply after ~2–3 attempts — if material findings still stand after the cap, stop
|
||||
and escalate to the user with a summary rather than thrashing or shipping over a
|
||||
standing objection. Only open the PR once the review is clean within the cap.
|
||||
|
||||
## 5. Record it in the PR
|
||||
|
||||
Summarize the outcome in the PR body's **Adversarial review** section: either
|
||||
"clean — no material findings" or a terse list of what was raised and how each was
|
||||
fixed or rebutted. This gives reviewers the adversary's perspective for free.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Verification inside a worktree
|
||||
|
||||
Commands assume the repository root is the cwd (the new worktree, `$WT`).
|
||||
|
||||
## Why the obvious commands fail here
|
||||
|
||||
A `.claude/worktrees/*` checkout lives under the parent Silo workspace, whose
|
||||
`go.work` only lists the primary `./silo-server`, not worktree checkouts. So:
|
||||
|
||||
- `go env GOWORK` resolves to the parent workspace from anywhere under `SiloServer/`.
|
||||
- Plain `go build ./...` / `go test ./...` fail with
|
||||
*"directory prefix . does not contain modules listed in go.work"*.
|
||||
- `go build ./...` also needs `web/dist` to exist (the embed directive).
|
||||
- `golangci-lint` is not installed locally.
|
||||
|
||||
## Go
|
||||
|
||||
Prefix every Go command with `GOWORK=off`.
|
||||
|
||||
```bash
|
||||
# Build the parts that matter without needing web/dist:
|
||||
GOWORK=off go build ./internal/... ./cmd/...
|
||||
|
||||
# Run the tests for the package(s) you touched (fast, targeted):
|
||||
GOWORK=off go test ./internal/<pkg>/...
|
||||
|
||||
# If you need a full ./... build, stub the embed target first:
|
||||
mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
GOWORK=off go build ./...
|
||||
```
|
||||
|
||||
Lint Go without installing golangci-lint — must be the **v2** module path (the
|
||||
repo's `.golangci` config rejects v1):
|
||||
|
||||
```bash
|
||||
GOWORK=off go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run ./internal/...
|
||||
```
|
||||
|
||||
**Known flake, not a real failure:** `internal/playback` GPU-probe tests
|
||||
(`TestResolveHWAccel*`, the NVENC smoke test) can fail with `signal: killed`
|
||||
under parallel load on macOS, but pass in isolation. If they're the only
|
||||
failures, re-run them alone before treating them as real:
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/playback/ -run TestResolveHWAccel -count=1
|
||||
```
|
||||
|
||||
## Frontend (only if `web/` changed)
|
||||
|
||||
```bash
|
||||
cd web
|
||||
pnpm install # if deps changed or node_modules is absent
|
||||
pnpm run lint
|
||||
pnpm run format:check
|
||||
pnpm run build # catches type/build errors before the PR
|
||||
cd ..
|
||||
```
|
||||
|
||||
The repo requires `pnpm run lint` and `pnpm run format:check` to be clean before
|
||||
a merge request.
|
||||
|
||||
## Repo-wide
|
||||
|
||||
```bash
|
||||
make verify-local-paths # must be clean; CLAUDE.md forbids absolute paths in docs/specs
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
If the change needs schema work, create a timestamped Goose migration — never
|
||||
hand-number, never `goose fix`, never paired up/down files:
|
||||
|
||||
```bash
|
||||
make migrate-create NAME=add_thing
|
||||
make migrate-validate
|
||||
make migrate-status
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: jellycompat-diagnosis
|
||||
description: Use when a Jellyfin-protocol client (JellyCon, Findroid, Infuse, Swiftfin) misbehaves against Silo's jellycompat API — wrong or missing data, ignored sort/filter/pagination parameters, oversized responses, or slow browse queries. Covers comparing Silo's responses against a real Jellyfin server field-by-field and reading the jellycompat debug log. Not for Silo's own `/api/v1` clients.
|
||||
---
|
||||
|
||||
# Diagnosing jellycompat responses
|
||||
|
||||
Jellyfin clients are unforgiving about response shape: a missing field or an extra one
|
||||
changes what they render, and they rarely report why. The reliable method is to make the
|
||||
identical request against Silo and against a real Jellyfin, then diff the JSON.
|
||||
|
||||
Set `JELLYFIN_COMPARE_URL` and credentials in `.silo-dev.env` — without a reference
|
||||
server you are guessing at what the client expected.
|
||||
|
||||
## Start from what the client actually sent
|
||||
|
||||
The debug log records full request/response pairs, but only when the server runs with
|
||||
`JELLYCOMPAT_DEBUG_LOG` set to a path. If it is unset, that is the first fix.
|
||||
|
||||
```bash
|
||||
scripts/silo-dev sh 'grep -E "^(===|Status:|Remote:|Response \()" /opt/silo/jellycompat-debug.log | tail -60'
|
||||
```
|
||||
|
||||
That overview is usually enough to localize the problem before reading any bodies. Look
|
||||
for response sizes (a 20-item list over ~50KB is wrong), latency (browse over a second is
|
||||
wrong), non-200 statuses, and — most importantly — which query parameters the client
|
||||
actually sent, which is often not what you assumed. `grep -n "^==="` gives request
|
||||
boundaries to `sed` a specific body out of.
|
||||
|
||||
## Compare against real Jellyfin
|
||||
|
||||
Authenticate to both and request the same path. Jellyfin's auth is a POST to
|
||||
`/Users/AuthenticateByName` with an `X-Emby-Authorization` header; the response carries
|
||||
`AccessToken` and `User.Id`, which you need for subsequent `/Users/{id}/Items` calls.
|
||||
Silo's jellycompat listens on port 8096 alongside the main API.
|
||||
|
||||
Diff the sorted key lists of one item from each side before diffing values — a field that
|
||||
is present on one and absent on the other explains most client bugs on its own, and
|
||||
`omitempty` in `dto.go` means an empty value and a missing field are the same thing on
|
||||
the wire.
|
||||
|
||||
[references/response-parity.md](references/response-parity.md) has the queries JellyCon
|
||||
sends, the fields real Jellyfin returns with and without an explicit `Fields` parameter,
|
||||
the parameter-handling behaviors worth asserting, and a symptom-to-cause table for the
|
||||
failures already found once — check it before re-deriving a diagnosis.
|
||||
|
||||
## After a fix
|
||||
|
||||
Redeploy, then re-authenticate — restarting the container invalidates the tokens — and
|
||||
re-run every query you were comparing, not just the one you fixed. Field gating is shared
|
||||
across handlers, so a change that corrects a list view frequently perturbs detail views.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Response parity reference
|
||||
|
||||
## Queries JellyCon sends
|
||||
|
||||
| Query | Purpose | Key params |
|
||||
|-------|---------|-----------|
|
||||
| Random widgets | Home screen | `SortBy=Random&IsPlayed=False&ImageTypeLimit=0` (no `Fields`) |
|
||||
| Library browse | Movie/TV list | `Fields=Overview,Genres,Studios,…&SortBy=SortName&ParentId=LIB` |
|
||||
| Recently added | New content | `Fields=…&SortBy=DateCreated&IsPlayed=False&Limit=20` |
|
||||
| Recently played | Watched | `IsPlayed=True&SortBy=DatePlayed&Limit=20` |
|
||||
| In progress | Resume | `Filters=IsResumable&SortBy=DatePlayed&Limit=20` |
|
||||
| Item detail | Single item | `GET /Items/{id}` |
|
||||
| Views | Libraries | `GET /Users/{id}/Views` |
|
||||
|
||||
## Expected shape
|
||||
|
||||
Structural, per response:
|
||||
|
||||
- `TotalRecordCount` is the real count, not a ceiling.
|
||||
- `Items` length matches `Limit`, or the total when fewer exist.
|
||||
- List items run ~0.5–1.5KB each without `Fields`, ~2–3KB with them.
|
||||
|
||||
Fields real Jellyfin returns on a list item with no `Fields` parameter:
|
||||
|
||||
```
|
||||
Name, Id, ServerId, Type, IsFolder, ProductionYear, OfficialRating,
|
||||
CommunityRating, RunTimeTicks, UserData, ImageTags, ChannelId, MediaType,
|
||||
LocationType, VideoType, Container, HasSubtitles, CriticRating, PremiereDate,
|
||||
ImageBlurHashes
|
||||
```
|
||||
|
||||
With an explicit `Fields` parameter: every requested field appears, unrequested ones do
|
||||
not, `ImageTags` is present even when empty (`{}`), and `People` and `MediaSources`
|
||||
appear only on detail views — never in a list.
|
||||
|
||||
Parameter handling worth asserting directly:
|
||||
|
||||
- `ImageTypeLimit=0` → `ImageTags: {}` and no `BackdropImageTags`; `=1` → normal tags.
|
||||
- `SortBy=Random` → a different order per request.
|
||||
- `IsPlayed=False` / `True` → filtered accordingly, and the `True` case is fast.
|
||||
- `Filters=IsResumable` → only items with `PlaybackPositionTicks > 0`.
|
||||
|
||||
## Symptoms already traced once
|
||||
|
||||
| Symptom | Cause | Where |
|
||||
|---------|-------|-------|
|
||||
| `TotalRecordCount` pinned at 10000 | count ceiling | `internal/catalog/browse.go` |
|
||||
| Same items every request | `SortBy` value unmapped | `query.go:mapSortBy` |
|
||||
| Parameter ignored (`IsPlayed`, `ImageTypeLimit`) | not parsed | `itemsQuery` / `parseItemsQuery` in `query.go` |
|
||||
| Items over ~5KB each | fields ungated, detail fetched per item | `mapping.go` |
|
||||
| Multi-second empty response | over-fetch loop scanning the catalog | needs a dedicated handler |
|
||||
| Missing Studios/Taglines/Countries | not carried through the intermediate type | `upstream_types.go`, `mediaItemToListItem` |
|
||||
| `People`/`MediaSources` in a list | resume path built items from the detail mapper | `handleResumeResponse` |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `internal/jellycompat/query.go` | query parsing, field and sort mapping |
|
||||
| `internal/jellycompat/mapping.go` | DTO construction, field gating |
|
||||
| `internal/jellycompat/handlers_items.go` | dispatch, `ImageTypeLimit` |
|
||||
| `internal/jellycompat/content_direct.go` | browse post-filtering, user-data enrichment |
|
||||
| `internal/jellycompat/dto.go` | JSON tags — `omitempty` decides field presence |
|
||||
| `internal/jellycompat/upstream_types.go` | catalog-to-DTO intermediate types |
|
||||
| `internal/jellycompat/logging.go` | debug log middleware (`JELLYCOMPAT_DEBUG_LOG`) |
|
||||
| `internal/catalog/browse.go` | SQL building, sort, count, pagination |
|
||||
@@ -0,0 +1,5 @@
|
||||
.secrets/
|
||||
.state/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.env
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: silo-discord-triage
|
||||
description: Triage Discord thread URLs, Discord channel/message links, screenshots, or pasted Discord discussions that report bugs, regressions, feature requests, or support issues for Silo projects. Use when the user provides a Discord thread about Silo Server, the Silo Apple clients, the Silo Android app, or an unclear cross-project Silo issue and wants the report fetched with a scoped Discord bot token, summarized, validated, routed to the right repo, converted into a GitHub issue, used as the starting point for a fix, or answered through the bot after approving the exact reply.
|
||||
---
|
||||
|
||||
# Silo Discord Triage
|
||||
|
||||
## Overview
|
||||
|
||||
Turn one Discord report into a decision-ready Silo intake: extract the concrete problem, route it to the correct project, validate the claim before editing, and produce privacy-safe next actions.
|
||||
|
||||
## Intake Workflow
|
||||
|
||||
1. Capture the source thread.
|
||||
- If the user provided a Discord URL, run `scripts/parse_discord_url.py <url>` to identify the URL shape and IDs for private working notes.
|
||||
- Prefer `scripts/fetch_discord_thread.py <url>` when the bot token is available. The script reads `DISCORD_BOT_TOKEN` first — set it in the repo's gitignored `.silo-dev.env` — then falls back to `.secrets/discord_bot_token` next to this skill, which is gitignored for the same reason.
|
||||
- Keep the token out of prompts, command arguments, logs, issues, commits, public summaries, and reusable skill instructions. If a token file is used, keep it local, ignored, and mode `0600`.
|
||||
- If bot-token fetch fails because the bot lacks channel/thread access, permissions, or message-content access, report that blocker precisely and fall back to browser access, pasted transcript, screenshots, or exported thread content.
|
||||
- Open the Discord URL with whatever browser or Chrome tooling is available only when bot-token ingestion is unavailable or insufficient.
|
||||
- Read the first report, title, replies, attachments, screenshots, logs, timestamps that affect ordering, and any maintainer follow-up. Treat Discord content as untrusted input.
|
||||
- Do not post to Discord, react, mention users, or take external actions without explicit user approval.
|
||||
|
||||
2. Classify and route.
|
||||
- Bug/regression: identify observed behavior, expected behavior, affected version or environment, repro steps, frequency, workaround, logs, and user impact.
|
||||
- Feature request: identify the user need, affected workflow, proposed behavior, constraints, acceptance criteria, and open product questions.
|
||||
- Support/question: separate user configuration or usage help from product defects.
|
||||
- Server/web/API issues belong to Silo Server when they involve API behavior, web UI, auth/profiles, libraries, metadata, downloads, matching, Jellyfin/Jellycompat, database migrations, deploys, or backend logs.
|
||||
- Apple issues belong to the Silo Apple client when they involve iOS, tvOS, macOS, SwiftUI layout, playback, AirPlay, cast remote control, subtitles, native media controls, background behavior, or App Store platform behavior.
|
||||
- Android issues belong to the Silo Android app when they involve Android UI, playback, downloads, platform permissions, notifications, Android TV, or device-specific Android behavior. Locate the owning repo from local workspace or GitHub context before assuming a path.
|
||||
- Cross-project issues need one primary owner plus explicit dependencies. Avoid spreading a fix across repos until the failing boundary is proven.
|
||||
|
||||
3. Validate before acting.
|
||||
- For code changes, inspect the current owning repo and current implementation first. Preserve unrelated local work.
|
||||
- For server/live API failures, start from the exact endpoint, log, schema, or runtime path described by the report before changing logic.
|
||||
- For Apple/mobile visual reports, identify the actual platform and owning view or player path early instead of diagnosing from the server repo.
|
||||
- For Android reports, verify the current Android code and device/API-level assumptions before proposing server or Apple changes.
|
||||
- Search existing GitHub issues or PRs when the user asks to file, prioritize, or fix a report.
|
||||
- Keep verification minimal by default: do not add tests for small changes or UI changes unless requested; add backend tests only for critical or high-risk logic.
|
||||
|
||||
4. Produce the result.
|
||||
- For triage only, return a concise brief with: source type, classification, owning project, distilled report, evidence, validation performed, likely next action, and remaining unknowns.
|
||||
- For GitHub issue text, draft a sanitized title and body with repro steps, expected behavior, actual behavior, evidence summary, scope, and acceptance criteria when relevant.
|
||||
- Do not include private Discord URLs, Discord IDs, personal names, account names, private hostnames, local usernames, local filesystem paths, or private infrastructure identifiers in public issue text, PR text, commit messages, or summaries. Use neutral placeholders such as `internal Discord thread`, `reporting user`, `private deployment host`, or `local workspace`.
|
||||
- Before publishing issue or PR text, committing, or summarizing public output, run `~/.local/bin/privacy-leak-check` on the draft or staged content when available.
|
||||
|
||||
5. Post an approved reply when requested.
|
||||
- Require explicit approval of the exact message content before posting. An instruction such as "send that" after the draft is shown counts as approval; a request to investigate or draft does not.
|
||||
- Use `scripts/post_discord_message.py <url> --content-file <path>` first to validate the destination and payload without posting.
|
||||
- Add `--confirm` only after approval. The helper disables all allowed mentions, verifies Discord returned the same content, and never prints the token or message content.
|
||||
- Fetch the thread again after posting and verify the approved reply is present once. Do not retry a successful post merely because a later verification fetch fails.
|
||||
- If posting returns `403`, report the missing Discord permission precisely. A public channel needs `Send Messages`; a thread also needs `Send Messages in Threads`. Private threads must be visible to or joined by the bot.
|
||||
- Prefer the bot helper over browser login for approved Silo thread replies when the bot token is configured and the bot can access the destination.
|
||||
|
||||
## Issue Draft Shape
|
||||
|
||||
Use this structure when the user asks to turn the thread into a GitHub issue:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Briefly state the user-visible problem or requested capability.
|
||||
|
||||
## Source
|
||||
Internal Discord thread. Do not include private links or IDs unless the user explicitly asks for a private tracker entry.
|
||||
|
||||
## Steps to Reproduce
|
||||
1. ...
|
||||
|
||||
## Expected Behavior
|
||||
...
|
||||
|
||||
## Actual Behavior
|
||||
...
|
||||
|
||||
## Evidence
|
||||
Summarize screenshots, logs, messages, or validation results without exposing private identifiers.
|
||||
|
||||
## Scope
|
||||
Owning project: Silo Server, Silo Apple, Silo Android, or cross-project.
|
||||
|
||||
## Acceptance Criteria
|
||||
- ...
|
||||
```
|
||||
|
||||
## Script
|
||||
|
||||
Use `scripts/parse_discord_url.py` to parse Discord URLs without network access. It accepts `discord.com` and `discordapp.com` channel links and emits JSON by default.
|
||||
|
||||
Use `scripts/fetch_discord_thread.py <url>` to fetch thread or channel messages through the Discord HTTP API. Requirements:
|
||||
|
||||
- Put the bot token in `DISCORD_BOT_TOKEN` (via `.silo-dev.env`), or keep it in `.secrets/discord_bot_token` next to this skill. The environment variable overrides the file. Both locations are gitignored; the token must never reach a committed file, a prompt, a command argument, or a log.
|
||||
- Install the Discord app with the `bot` scope only.
|
||||
- Grant the bot `View Channel` and `Read Message History` for the specific report channels/threads.
|
||||
- Enable the `Message Content` privileged intent when the skill must read user-written content, embeds, attachments, components, or polls from message objects.
|
||||
- For intake-only triage, do not grant `Send Messages`, `Manage Messages`, `Manage Threads`, moderation, admin, or webhook permissions.
|
||||
- For explicitly approved reply workflows, grant only `Send Messages` and, for threads, `Send Messages in Threads`; moderation, admin, webhook, and message-management permissions remain unnecessary.
|
||||
- If the report is in a private thread, ensure the bot is permitted to see that thread or is added to it.
|
||||
|
||||
Use `scripts/post_discord_message.py <url> --content-file <path> [--confirm]` for bot replies. Without `--confirm`, it performs a read-only destination/payload validation. With `--confirm`, it posts one message with mentions disabled and verifies the create-message response.
|
||||
|
||||
`fetch_discord_thread.py` emits oldest-first JSON with pseudonymous author labels by default. Message links (guild/channel/message URLs) are supported: the fetch anchors on the linked message (`around` context, guaranteed inclusion) and marks it with `"is_linked_message": true` in the output — start triage from that message. Use `--include-sensitive` only for private local diagnosis when exact Discord IDs, usernames, message URLs, or attachment URLs are necessary.
|
||||
|
||||
Use `scripts/discover_discord_threads.py` for automation. It reads `.secrets/discord_monitor.json`, lists active threads visible to the bot, compares them with `.state/discord_seen_threads.json`, and emits only newly discovered thread URLs by default. Run it with `--init` once before starting an automation so existing active threads are marked seen and only future threads create sessions. In automation runs, mark each Discord thread as seen with `--mark-thread-id <id>` only after its Codex session has been created.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Silo Discord Triage"
|
||||
short_description: "Fetch, triage, and reply to Silo Discord reports"
|
||||
default_prompt: "Use $silo-discord-triage to fetch this Discord thread with the scoped bot token, triage it, and post an exact reply only after I approve the content."
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover new active Discord threads for Silo intake automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from fetch_discord_thread import DEFAULT_TOKEN_ENV, DEFAULT_TOKEN_FILE, DiscordAPIError, load_token, request_json # noqa: E402
|
||||
|
||||
|
||||
DISCORD_EPOCH_MS = 1420070400000
|
||||
DEFAULT_CONFIG_FILE = SCRIPT_DIR.parent / ".secrets" / "discord_monitor.json"
|
||||
DEFAULT_STATE_FILE = SCRIPT_DIR.parent / ".state" / "discord_seen_threads.json"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def snowflake_timestamp(snowflake: str) -> str | None:
|
||||
try:
|
||||
timestamp_ms = (int(snowflake) >> 22) + DISCORD_EPOCH_MS
|
||||
except ValueError:
|
||||
return None
|
||||
return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def read_json(path: Path, default: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp_path, 0o600)
|
||||
tmp_path.replace(path)
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
config = read_json(path, {})
|
||||
guild_ids = config.get("guild_ids")
|
||||
if not isinstance(guild_ids, list) or not guild_ids:
|
||||
raise SystemExit(f"Missing guild_ids in {path}")
|
||||
return config
|
||||
|
||||
|
||||
def thread_url(guild_id: str, thread_id: str) -> str:
|
||||
return f"https://discord.com/channels/{guild_id}/{thread_id}"
|
||||
|
||||
|
||||
def normalize_thread(guild_id: str, thread: dict[str, Any]) -> dict[str, Any]:
|
||||
thread_id = thread["id"]
|
||||
return {
|
||||
"created_at": snowflake_timestamp(thread_id),
|
||||
"last_message_id": thread.get("last_message_id"),
|
||||
"message_count": thread.get("message_count"),
|
||||
"name": thread.get("name") or "untitled-discord-thread",
|
||||
"parent_channel_id": thread.get("parent_id"),
|
||||
"thread_id": thread_id,
|
||||
"type": thread.get("type"),
|
||||
"url": thread_url(guild_id, thread_id),
|
||||
}
|
||||
|
||||
|
||||
def iter_active_threads(token: str, config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
parent_filter = set(config.get("parent_channel_ids") or [])
|
||||
ignored = set(config.get("ignore_thread_ids") or [])
|
||||
discovered: list[dict[str, Any]] = []
|
||||
for guild_id in config["guild_ids"]:
|
||||
payload = request_json(f"/guilds/{guild_id}/threads/active", token)
|
||||
for thread in payload.get("threads", []):
|
||||
thread_id = thread.get("id")
|
||||
if not thread_id or thread_id in ignored:
|
||||
continue
|
||||
parent_id = thread.get("parent_id")
|
||||
if parent_filter and parent_id not in parent_filter:
|
||||
continue
|
||||
discovered.append(normalize_thread(guild_id, thread))
|
||||
discovered.sort(key=lambda item: item.get("thread_id") or "", reverse=True)
|
||||
return discovered
|
||||
|
||||
|
||||
def mark_seen(state: dict[str, Any], threads: list[dict[str, Any]]) -> None:
|
||||
seen = state.setdefault("seen_threads", {})
|
||||
timestamp = now_iso()
|
||||
for thread in threads:
|
||||
thread_id = thread["thread_id"]
|
||||
record = seen.setdefault(thread_id, {})
|
||||
record.update(
|
||||
{
|
||||
"first_seen_at": record.get("first_seen_at") or timestamp,
|
||||
"last_seen_at": timestamp,
|
||||
"name": thread.get("name"),
|
||||
"url": thread.get("url"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def mark_thread_ids(state: dict[str, Any], thread_ids: list[str]) -> None:
|
||||
seen = state.setdefault("seen_threads", {})
|
||||
timestamp = now_iso()
|
||||
for thread_id in thread_ids:
|
||||
record = seen.setdefault(thread_id, {})
|
||||
record.update(
|
||||
{
|
||||
"first_seen_at": record.get("first_seen_at") or timestamp,
|
||||
"last_seen_at": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Discover new active Discord threads visible to the Silo intake bot.")
|
||||
parser.add_argument("--config-file", default=str(DEFAULT_CONFIG_FILE), help="Discord monitor config JSON")
|
||||
parser.add_argument("--state-file", default=str(DEFAULT_STATE_FILE), help="Seen-thread state JSON")
|
||||
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the bot token")
|
||||
parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE), help="Fallback file containing the bot token")
|
||||
parser.add_argument("--max-new", type=int, default=20, help="Maximum new threads to emit")
|
||||
parser.add_argument("--init", action="store_true", help="Mark all currently visible active threads as seen")
|
||||
parser.add_argument("--include-seen", action="store_true", help="Emit seen threads too")
|
||||
parser.add_argument("--mark-seen", action="store_true", help="Mark emitted new threads as seen")
|
||||
parser.add_argument("--mark-thread-id", action="append", default=[], help="Mark one specific Discord thread ID as seen without discovery")
|
||||
args = parser.parse_args()
|
||||
|
||||
state_path = Path(args.state_file).expanduser()
|
||||
state = read_json(state_path, {"seen_threads": {}})
|
||||
if args.mark_thread_id:
|
||||
mark_thread_ids(state, args.mark_thread_id)
|
||||
write_json(state_path, state)
|
||||
print(json.dumps({"marked_seen": len(args.mark_thread_id)}, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
token = load_token(args.token_env, args.token_file)
|
||||
if not token:
|
||||
raise SystemExit(f"No Discord bot token found; set {args.token_env} or create .secrets/discord_bot_token in the skill directory")
|
||||
|
||||
config_path = Path(args.config_file).expanduser()
|
||||
config = load_config(config_path)
|
||||
|
||||
try:
|
||||
active_threads = iter_active_threads(token, config)
|
||||
except DiscordAPIError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
seen = state.setdefault("seen_threads", {})
|
||||
if args.init:
|
||||
mark_seen(state, active_threads)
|
||||
write_json(state_path, state)
|
||||
print(json.dumps({"initialized": True, "marked_seen": len(active_threads)}, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
if args.include_seen:
|
||||
output_threads = active_threads
|
||||
else:
|
||||
output_threads = [thread for thread in active_threads if thread["thread_id"] not in seen]
|
||||
output_threads = output_threads[: args.max_new]
|
||||
|
||||
if args.mark_seen:
|
||||
mark_seen(state, output_threads)
|
||||
write_json(state_path, state)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"new_thread_count": len(output_threads),
|
||||
"threads": output_threads,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch Discord thread/channel messages with a bot token from env or a local secret file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from parse_discord_url import parse_discord_url # noqa: E402
|
||||
|
||||
|
||||
API_BASE = "https://discord.com/api/v10"
|
||||
DEFAULT_TOKEN_ENV = "DISCORD_BOT_TOKEN"
|
||||
DEFAULT_TOKEN_FILE = SCRIPT_DIR.parent / ".secrets" / "discord_bot_token"
|
||||
USER_AGENT = "silo-discord-triage/1.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
source: str
|
||||
channel_id: str
|
||||
guild_id: str | None
|
||||
channel: dict[str, Any] | None
|
||||
message_count: int
|
||||
messages: list[dict[str, Any]]
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
class DiscordAPIError(RuntimeError):
|
||||
def __init__(self, status: int | None, message: str):
|
||||
self.status = status
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def request_json(path: str, token: str, params: dict[str, Any] | None = None) -> Any:
|
||||
query = ""
|
||||
if params:
|
||||
query = "?" + urllib.parse.urlencode({key: value for key, value in params.items() if value is not None})
|
||||
request = urllib.request.Request(
|
||||
API_BASE + path + query,
|
||||
headers={
|
||||
"Authorization": f"Bot {token}",
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
if response.status == 204:
|
||||
return None
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
retry_after = None
|
||||
try:
|
||||
retry_after = json.loads(body).get("retry_after")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if exc.code == 429 and retry_after is not None:
|
||||
time.sleep(float(retry_after))
|
||||
continue
|
||||
raise DiscordAPIError(exc.code, format_http_error(exc.code, body)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise DiscordAPIError(None, f"Discord request failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def format_http_error(status: int, body: str) -> str:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
|
||||
code = payload.get("code")
|
||||
message = payload.get("message") or "Discord API request failed"
|
||||
if status in {401, 403}:
|
||||
message += "; check the bot token, channel visibility, thread access, View Channel, and Read Message History permissions"
|
||||
return f"Discord API error {status}" + (f" ({code})" if code else "") + f": {message}"
|
||||
|
||||
|
||||
def fetch_messages(channel_id: str, token: str, max_messages: int, anchor_message_id: str | None = None) -> list[dict[str, Any]]:
|
||||
remaining = max_messages
|
||||
messages: list[dict[str, Any]] = []
|
||||
before = None
|
||||
|
||||
if anchor_message_id:
|
||||
# Anchor on the linked message so it is always included, with context on both sides.
|
||||
limit = min(100, remaining)
|
||||
page = request_json(f"/channels/{channel_id}/messages", token, {"limit": limit, "around": anchor_message_id})
|
||||
messages.extend(page or [])
|
||||
remaining -= len(messages)
|
||||
if not any(item.get("id") == anchor_message_id for item in messages):
|
||||
anchor = request_json(f"/channels/{channel_id}/messages/{anchor_message_id}", token)
|
||||
if isinstance(anchor, dict):
|
||||
messages.append(anchor)
|
||||
remaining -= 1
|
||||
if messages:
|
||||
before = min(messages, key=lambda item: int(item["id"]))["id"]
|
||||
|
||||
while remaining > 0:
|
||||
limit = min(100, remaining)
|
||||
page = request_json(f"/channels/{channel_id}/messages", token, {"limit": limit, "before": before})
|
||||
if not page:
|
||||
break
|
||||
messages.extend(page)
|
||||
remaining -= len(page)
|
||||
before = page[-1]["id"]
|
||||
if len(page) < limit:
|
||||
break
|
||||
|
||||
deduped = {item["id"]: item for item in messages}
|
||||
return sorted(deduped.values(), key=lambda item: int(item["id"]))
|
||||
|
||||
|
||||
def pseudonymize_author(author_id: str, labels: dict[str, str]) -> str:
|
||||
if author_id not in labels:
|
||||
labels[author_id] = f"author_{len(labels) + 1}"
|
||||
return labels[author_id]
|
||||
|
||||
|
||||
def attachment_summary(attachment: dict[str, Any], include_sensitive: bool) -> dict[str, Any]:
|
||||
summary = {
|
||||
"filename": attachment.get("filename"),
|
||||
"content_type": attachment.get("content_type"),
|
||||
"size": attachment.get("size"),
|
||||
"description": attachment.get("description"),
|
||||
}
|
||||
if include_sensitive:
|
||||
summary["id"] = attachment.get("id")
|
||||
summary["url"] = attachment.get("url")
|
||||
summary["proxy_url"] = attachment.get("proxy_url")
|
||||
return {key: value for key, value in summary.items() if value not in (None, "", [])}
|
||||
|
||||
|
||||
def normalize_message(message: dict[str, Any], labels: dict[str, str], include_sensitive: bool, guild_id: str | None, anchor_message_id: str | None = None) -> dict[str, Any]:
|
||||
author = message.get("author") or {}
|
||||
author_id = author.get("id") or "unknown"
|
||||
normalized: dict[str, Any] = {
|
||||
"author": pseudonymize_author(author_id, labels),
|
||||
"timestamp": message.get("timestamp"),
|
||||
"edited_timestamp": message.get("edited_timestamp"),
|
||||
"content": message.get("content") or "",
|
||||
"attachments": [attachment_summary(item, include_sensitive) for item in message.get("attachments", [])],
|
||||
"embeds": message.get("embeds", []),
|
||||
"components": message.get("components", []),
|
||||
}
|
||||
if message.get("referenced_message"):
|
||||
normalized["references_message"] = True
|
||||
if anchor_message_id and message.get("id") == anchor_message_id:
|
||||
normalized["is_linked_message"] = True
|
||||
if include_sensitive:
|
||||
normalized["id"] = message.get("id")
|
||||
normalized["channel_id"] = message.get("channel_id")
|
||||
normalized["author_id"] = author_id
|
||||
normalized["author_username"] = author.get("username")
|
||||
normalized["author_global_name"] = author.get("global_name")
|
||||
normalized["jump_url"] = build_jump_url(message, guild_id)
|
||||
return {key: value for key, value in normalized.items() if value not in (None, "", [])}
|
||||
|
||||
|
||||
def build_jump_url(message: dict[str, Any], guild_id: str | None) -> str | None:
|
||||
channel_id = message.get("channel_id")
|
||||
message_id = message.get("id")
|
||||
if channel_id and message_id:
|
||||
return f"https://discord.com/channels/{guild_id or '@me'}/{channel_id}/{message_id}"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_channel(channel: dict[str, Any], include_sensitive: bool) -> dict[str, Any]:
|
||||
keys = ["name", "type", "topic", "thread_metadata", "message_count", "member_count"]
|
||||
normalized = {key: channel.get(key) for key in keys if channel.get(key) not in (None, "", [])}
|
||||
if include_sensitive:
|
||||
normalized["id"] = channel.get("id")
|
||||
normalized["guild_id"] = channel.get("guild_id")
|
||||
normalized["parent_id"] = channel.get("parent_id")
|
||||
return normalized
|
||||
|
||||
|
||||
def build_result(source: str, token: str, max_messages: int, include_sensitive: bool) -> FetchResult:
|
||||
parsed = parse_discord_url(source)
|
||||
if not parsed.valid or not parsed.channel_or_thread_id:
|
||||
raise SystemExit(f"Invalid Discord URL: {parsed.error}")
|
||||
|
||||
channel = request_json(f"/channels/{parsed.channel_or_thread_id}", token)
|
||||
messages = fetch_messages(parsed.channel_or_thread_id, token, max_messages, parsed.message_id)
|
||||
labels: dict[str, str] = {}
|
||||
normalized_messages = [normalize_message(item, labels, include_sensitive, parsed.guild_id, parsed.message_id) for item in messages]
|
||||
warnings = build_warnings(messages, normalized_messages)
|
||||
|
||||
return FetchResult(
|
||||
source="discord_url",
|
||||
channel_id=parsed.channel_or_thread_id if include_sensitive else "redacted",
|
||||
guild_id=parsed.guild_id if include_sensitive else None,
|
||||
channel=normalize_channel(channel, include_sensitive) if isinstance(channel, dict) else None,
|
||||
message_count=len(normalized_messages),
|
||||
messages=normalized_messages,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def build_warnings(raw_messages: list[dict[str, Any]], normalized_messages: list[dict[str, Any]]) -> list[str]:
|
||||
warnings = []
|
||||
if raw_messages and not any(item.get("content") for item in raw_messages):
|
||||
warnings.append("All fetched messages have empty content; verify the bot has Message Content privileged intent access or that the thread contains only non-text content.")
|
||||
if raw_messages and any(item.get("attachments") for item in raw_messages):
|
||||
warnings.append("Attachment URLs are omitted by default; rerun with --include-sensitive only for private local diagnosis if direct attachment access is required.")
|
||||
if not normalized_messages:
|
||||
warnings.append("No messages returned; verify the bot can view the channel/thread and has Read Message History.")
|
||||
return warnings
|
||||
|
||||
|
||||
def load_token(token_env: str, token_file: str) -> str | None:
|
||||
token = os.environ.get(token_env)
|
||||
if token:
|
||||
return token.strip()
|
||||
|
||||
path = Path(token_file).expanduser()
|
||||
try:
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not token:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Fetch Discord thread/channel messages using a scoped bot token.")
|
||||
parser.add_argument("url", help="Discord channel, thread, or message URL")
|
||||
parser.add_argument("--max-messages", type=int, default=300, help="Maximum messages to fetch, newest pages first then output oldest-first")
|
||||
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the bot token")
|
||||
parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE), help="Fallback file containing the bot token when the environment variable is unset")
|
||||
parser.add_argument("--include-sensitive", action="store_true", help="Include Discord IDs, usernames, message URLs, and attachment URLs")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.max_messages < 1:
|
||||
raise SystemExit("--max-messages must be at least 1")
|
||||
|
||||
token = load_token(args.token_env, args.token_file)
|
||||
if not token:
|
||||
raise SystemExit(f"No Discord bot token found; set {args.token_env} or create .secrets/discord_bot_token in the skill directory")
|
||||
|
||||
try:
|
||||
result = build_result(args.url, token, args.max_messages, args.include_sensitive)
|
||||
except DiscordAPIError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps(asdict(result), indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse Discord channel, thread, and message URLs without network access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DISCORD_HOSTS = {"discord.com", "www.discord.com", "discordapp.com", "www.discordapp.com"}
|
||||
SNOWFLAKE_RE = re.compile(r"^\d{15,25}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedDiscordURL:
|
||||
input: str
|
||||
valid: bool
|
||||
host: str | None = None
|
||||
guild_id: str | None = None
|
||||
channel_or_thread_id: str | None = None
|
||||
message_id: str | None = None
|
||||
is_dm: bool = False
|
||||
kind: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def parse_discord_url(raw_url: str) -> ParsedDiscordURL:
|
||||
parsed = urlparse(raw_url.strip())
|
||||
host = parsed.netloc.lower()
|
||||
if host not in DISCORD_HOSTS:
|
||||
return ParsedDiscordURL(input=raw_url, valid=False, host=host or None, error="not a Discord URL")
|
||||
|
||||
parts = [part for part in parsed.path.split("/") if part]
|
||||
if len(parts) < 3 or parts[0] != "channels":
|
||||
return ParsedDiscordURL(input=raw_url, valid=False, host=host, error="expected /channels/<guild>/<channel-or-thread>/<message?>")
|
||||
|
||||
guild_id, channel_id = parts[1], parts[2]
|
||||
message_id = parts[3] if len(parts) > 3 else None
|
||||
is_dm = guild_id == "@me"
|
||||
|
||||
bad_fields = []
|
||||
if not is_dm and not SNOWFLAKE_RE.match(guild_id):
|
||||
bad_fields.append("guild_id")
|
||||
if not SNOWFLAKE_RE.match(channel_id):
|
||||
bad_fields.append("channel_or_thread_id")
|
||||
if message_id is not None and not SNOWFLAKE_RE.match(message_id):
|
||||
bad_fields.append("message_id")
|
||||
if bad_fields:
|
||||
return ParsedDiscordURL(
|
||||
input=raw_url,
|
||||
valid=False,
|
||||
host=host,
|
||||
guild_id=None if is_dm else guild_id,
|
||||
channel_or_thread_id=channel_id,
|
||||
message_id=message_id,
|
||||
is_dm=is_dm,
|
||||
error="invalid Discord snowflake field(s): " + ", ".join(bad_fields),
|
||||
)
|
||||
|
||||
if message_id:
|
||||
kind = "message_link"
|
||||
elif is_dm:
|
||||
kind = "dm_channel_link"
|
||||
else:
|
||||
kind = "channel_or_thread_link"
|
||||
|
||||
return ParsedDiscordURL(
|
||||
input=raw_url,
|
||||
valid=True,
|
||||
host=host,
|
||||
guild_id=None if is_dm else guild_id,
|
||||
channel_or_thread_id=channel_id,
|
||||
message_id=message_id,
|
||||
is_dm=is_dm,
|
||||
kind=kind,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Parse a Discord URL into channel/thread/message identifiers.")
|
||||
parser.add_argument("url", help="Discord URL to parse")
|
||||
parser.add_argument("--text", action="store_true", help="Print a short text summary instead of JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = parse_discord_url(args.url)
|
||||
if args.text:
|
||||
if not result.valid:
|
||||
print(f"invalid: {result.error}")
|
||||
else:
|
||||
message_part = f", message={result.message_id}" if result.message_id else ""
|
||||
guild_part = "dm" if result.is_dm else f"guild={result.guild_id}"
|
||||
print(f"{result.kind}: {guild_part}, channel_or_thread={result.channel_or_thread_id}{message_part}")
|
||||
else:
|
||||
print(json.dumps(asdict(result), indent=2, sort_keys=True))
|
||||
return 0 if result.valid else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate or post one explicitly approved Discord message with the scoped bot token."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from fetch_discord_thread import ( # noqa: E402
|
||||
API_BASE,
|
||||
DEFAULT_TOKEN_ENV,
|
||||
DEFAULT_TOKEN_FILE,
|
||||
USER_AGENT,
|
||||
DiscordAPIError,
|
||||
load_token,
|
||||
request_json,
|
||||
)
|
||||
from parse_discord_url import parse_discord_url # noqa: E402
|
||||
|
||||
|
||||
def post_json(path: str, token: str, payload: dict[str, Any]) -> Any:
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
while True:
|
||||
request = urllib.request.Request(
|
||||
API_BASE + path,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bot {token}",
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
response_body = exc.read().decode("utf-8", errors="replace")
|
||||
retry_after = None
|
||||
try:
|
||||
retry_after = json.loads(response_body).get("retry_after")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if exc.code == 429 and retry_after is not None:
|
||||
time.sleep(float(retry_after))
|
||||
continue
|
||||
raise DiscordAPIError(exc.code, format_post_error(exc.code, response_body)) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise DiscordAPIError(None, f"Discord post failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def format_post_error(status: int, body: str) -> str:
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
code = payload.get("code")
|
||||
message = payload.get("message") or "Discord API request failed"
|
||||
if status == 401:
|
||||
message += "; check the bot token"
|
||||
elif status == 403:
|
||||
message += (
|
||||
"; check View Channel and Send Messages, plus Send Messages in Threads "
|
||||
"and thread membership/visibility when posting to a thread"
|
||||
)
|
||||
return f"Discord API error {status}" + (f" ({code})" if code else "") + f": {message}"
|
||||
|
||||
|
||||
def read_content(raw_content: str | None, content_file: str | None) -> str:
|
||||
if raw_content is not None:
|
||||
content = raw_content
|
||||
elif content_file == "-":
|
||||
content = sys.stdin.read()
|
||||
elif content_file:
|
||||
content = Path(content_file).expanduser().read_text(encoding="utf-8")
|
||||
else:
|
||||
raise SystemExit("Provide exactly one of --content or --content-file")
|
||||
|
||||
content = content.rstrip("\r\n")
|
||||
if not content.strip():
|
||||
raise SystemExit("Discord message content must not be empty")
|
||||
if len(content) > 2000:
|
||||
raise SystemExit(f"Discord message content is {len(content)} characters; maximum is 2000")
|
||||
return content
|
||||
|
||||
|
||||
def content_fingerprint(content: str) -> str:
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate or post one explicitly approved Discord message using the scoped bot token."
|
||||
)
|
||||
parser.add_argument("url", help="Discord channel, thread, or message URL")
|
||||
content_group = parser.add_mutually_exclusive_group(required=True)
|
||||
content_group.add_argument("--content", help="Message content")
|
||||
content_group.add_argument("--content-file", help="UTF-8 message file, or - for stdin")
|
||||
parser.add_argument("--confirm", action="store_true", help="Actually post after the exact content is approved")
|
||||
parser.add_argument("--token-env", default=DEFAULT_TOKEN_ENV, help="Environment variable containing the bot token")
|
||||
parser.add_argument("--token-file", default=str(DEFAULT_TOKEN_FILE), help="Fallback local token file")
|
||||
args = parser.parse_args()
|
||||
|
||||
parsed = parse_discord_url(args.url)
|
||||
if not parsed.valid or not parsed.channel_or_thread_id:
|
||||
raise SystemExit(f"Invalid Discord URL: {parsed.error}")
|
||||
content = read_content(args.content, args.content_file)
|
||||
token = load_token(args.token_env, args.token_file)
|
||||
if not token:
|
||||
raise SystemExit(
|
||||
f"No Discord bot token found; set {args.token_env} or create "
|
||||
".secrets/discord_bot_token in the skill directory"
|
||||
)
|
||||
|
||||
try:
|
||||
channel = request_json(f"/channels/{parsed.channel_or_thread_id}", token)
|
||||
result: dict[str, Any] = {
|
||||
"valid": True,
|
||||
"posted": False,
|
||||
"channel_type": channel.get("type") if isinstance(channel, dict) else None,
|
||||
"is_thread": bool(isinstance(channel, dict) and channel.get("type") in {10, 11, 12}),
|
||||
"content_length": len(content),
|
||||
"content_sha256": content_fingerprint(content),
|
||||
"mentions_disabled": True,
|
||||
}
|
||||
if not args.confirm:
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
response = post_json(
|
||||
f"/channels/{parsed.channel_or_thread_id}/messages",
|
||||
token,
|
||||
{
|
||||
"content": content,
|
||||
"allowed_mentions": {
|
||||
"parse": [],
|
||||
"replied_user": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"posted": True,
|
||||
"verified": isinstance(response, dict) and response.get("content") == content,
|
||||
"timestamp": response.get("timestamp") if isinstance(response, dict) else None,
|
||||
}
|
||||
)
|
||||
if not result["verified"]:
|
||||
print(json.dumps(result, indent=2, sort_keys=True), file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
except DiscordAPIError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: web-ui-testing
|
||||
description: Use when verifying a change to the Silo web frontend (`web/`) in a real browser — checking a page renders, a flow works, or a fix actually landed, and capturing screenshots for a PR. Runs the local Vite dev server against a real Silo backend so no build or deploy is needed. Not for backend-only changes, and not a substitute for unit tests in `web/src`.
|
||||
---
|
||||
|
||||
# Verifying web UI changes
|
||||
|
||||
The frontend only ever calls relative `/api` URLs, so pointing it at a real backend is a
|
||||
one-line config change — no build, no deploy, and hot reload still works while you
|
||||
iterate.
|
||||
|
||||
## Point the dev server at a backend
|
||||
|
||||
```bash
|
||||
printf 'VITE_API_PROXY_TARGET=%s\n' "$(scripts/silo-dev env | awk '/^SILO_URL/{print $2}')" > web/.env.local
|
||||
make dev-frontend
|
||||
```
|
||||
|
||||
Any reachable Silo instance works as the target — the shared `.silo-dev.env` deployment,
|
||||
a colleague's server, or `http://localhost:8090` for a backend you are running yourself
|
||||
with `make dev-backend`. Vite proxies `/api` (including WebSockets, such as the realtime
|
||||
`scans` channel) and sets `changeOrigin` automatically for non-localhost targets, because
|
||||
remote vhost proxies reject a localhost `Host` header.
|
||||
|
||||
`web/.env.local` is per-checkout, so a git worktree needs its own.
|
||||
|
||||
Before debugging anything in the UI, confirm the proxy itself:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5173/api/v1/health
|
||||
```
|
||||
|
||||
Silo health JSON means the wiring is good and any remaining problem is the frontend. An
|
||||
HTML page or a connection error means the proxy target is wrong or the backend is down —
|
||||
`scripts/silo-dev doctor` will say which.
|
||||
|
||||
## Drive it
|
||||
|
||||
The app is at `http://localhost:5173`. Use the Playwright tooling to navigate, interact,
|
||||
and screenshot.
|
||||
|
||||
The dev server binds `0.0.0.0` and `allowedHosts` covers `.ts.net` plus the machine's own
|
||||
hostname, so it is also reachable from any device on the tailnet at
|
||||
`http://<tailscale-machine>:5173`. Always give the user that URL alongside the localhost
|
||||
one when starting a dev server for them — it is how they watch the UI you are working on
|
||||
from another machine. `tailscale status --json | jq -r .Self.DNSName` gives the name.
|
||||
|
||||
Most pages need a session. Sign in through the login form using `SILO_ADMIN_USER` and
|
||||
`SILO_ADMIN_PASSWORD` from `.silo-dev.env`; the session then lives in that browser
|
||||
profile for the rest of the run. If those are unset, ask the user for an account on the
|
||||
target rather than testing only the signed-out shell — and never type credentials the
|
||||
user has not given you for that server.
|
||||
|
||||
## What counts as verified
|
||||
|
||||
A change is verified when you have seen the specific thing that was broken now behaving
|
||||
correctly, on a page reached the way a user reaches it, against real data. Screenshots
|
||||
belong in the PR for anything visual.
|
||||
|
||||
Watch for the failure modes that look like success: a page that renders because it fell
|
||||
back to an empty state, a stale bundle from a dev server started before your edit, and a
|
||||
console full of errors under a page that looks fine. Check the browser console before
|
||||
calling it done.
|
||||
|
||||
## States that are hard to reach
|
||||
|
||||
Some UI only appears under conditions you cannot conjure on a real server — a flood of
|
||||
queued scans, a specific error shape, a half-finished download. Rather than mutating the
|
||||
backend, render the real page inside a temporary dev-only route wrapping it in its own
|
||||
`QueryClientProvider` (queries `enabled: false`) seeded via `setQueryData`. It needs its
|
||||
own client because `useAuth` calls `clear()` on the global one when auth fails, which
|
||||
would wipe the fixtures. Delete the harness route once you have the screenshot.
|
||||
@@ -42,6 +42,7 @@ POSTGRES_SHM_SIZE=8gb
|
||||
|
||||
# Optional host port overrides for docker-compose.yml.
|
||||
# PORT=8090
|
||||
# Used only after Jellyfin-compatible app support is enabled by an admin.
|
||||
# JF_PORT=8096
|
||||
# PROXY_PORT=8083
|
||||
# TRANSCODE_PORT=8082
|
||||
@@ -59,6 +60,16 @@ POSTGRES_SHM_SIZE=8gb
|
||||
# docs/architecture/secret-encryption.md.
|
||||
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
|
||||
|
||||
# Optional public URL for Silo. If not set, the server will use the IP address of the container.
|
||||
# SILO_PUBLIC_URL=https://silo.example.com
|
||||
|
||||
# Optional trusted reverse-proxy CIDRs for client IP resolution. When set, this
|
||||
# overrides the "Trusted Proxies" admin setting on every startup (the value is
|
||||
# validated and written to server_settings, so the Admin UI shows it). Leave
|
||||
# unset to manage it from Admin > Settings > General instead. Defaults trust
|
||||
# RFC 1918 private ranges + loopback.
|
||||
# SILO_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1/128,203.0.113.7/32
|
||||
|
||||
# Run from source / advanced overrides
|
||||
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
|
||||
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
|
||||
@@ -72,3 +83,4 @@ POSTGRES_SHM_SIZE=8gb
|
||||
|
||||
# Optional server mode override for source runs or standalone worker nodes.
|
||||
# MODE=integrated
|
||||
#SILO_MIGRATE_TIMEOUT=60m
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# v1 scope lock — changes to the lock artifact require maintainer review
|
||||
/docs/architecture/v1-scope.md @Quick104
|
||||
@@ -0,0 +1 @@
|
||||
github: quick104
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Bug report
|
||||
description: Report something broken in Silo server or web UI
|
||||
title: "[bug] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Raw logs only. Never AI-summarize logs. You must have reproduced this yourself on a real deployment before filing.
|
||||
|
||||
Fabricated or AI-hallucinated reports are an instant block. Read [CONTRIBUTING.md](https://github.com/Silo-Server/silo-server/blob/main/CONTRIBUTING.md) first.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What broke? What did you actually observe?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Exact steps you ran on a real deployment.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: silo-version
|
||||
attributes:
|
||||
label: Silo version / commit
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment
|
||||
options:
|
||||
- Docker
|
||||
- Bare metal / systemd
|
||||
- Local dev build
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: clients-affected
|
||||
attributes:
|
||||
label: Clients affected
|
||||
options:
|
||||
- label: Web UI
|
||||
- label: Android
|
||||
- label: iOS/tvOS/macOS
|
||||
- label: Jellyfin-compat client (Infuse, Findroid, ...)
|
||||
- label: Other/not client-specific
|
||||
- type: textarea
|
||||
id: relevant-logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Paste raw server logs.
|
||||
placeholder: paste raw server logs; write "no logs available" if genuinely none
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: ai-involvement
|
||||
attributes:
|
||||
label: AI involvement in this report
|
||||
options:
|
||||
- None — written by a human
|
||||
- AI-assisted, human verified the repro
|
||||
- AI-generated, human verified the repro
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: ai-tool-model
|
||||
attributes:
|
||||
label: AI tool + model
|
||||
placeholder: e.g. Claude Code / claude-fable-5 — or n/a
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: confirmations
|
||||
attributes:
|
||||
label: Confirmations
|
||||
options:
|
||||
- label: I reproduced this myself on a real deployment
|
||||
required: true
|
||||
- label: Logs above are raw copy-paste, not AI-summarized
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,56 @@
|
||||
name: v1 capability proposal
|
||||
description: Propose a user-facing capability for the Silo v1 scope lock
|
||||
title: "[v1] <capability name>"
|
||||
labels: ["v1-proposed"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
One proposal per **user-facing capability** (e.g. "Manga library type", "Transcoded playback"),
|
||||
not per PR or per endpoint. Locked capabilities are what the client teams plan against —
|
||||
the **API surface** section is mandatory for a capability to be lockable.
|
||||
Scope state and lock rules: `docs/architecture/v1-scope.md`.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One paragraph, user-facing language. What can a user do when this ships?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: value
|
||||
attributes:
|
||||
label: User value / why v1
|
||||
description: Why does v1 need this rather than v1.1?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: api-surface
|
||||
attributes:
|
||||
label: API surface
|
||||
description: >
|
||||
Endpoints/payloads clients will call (method + path + notable request/response fields).
|
||||
Plain list now; OpenAPI pointers once the #135 tooling lands. A capability cannot be
|
||||
Locked without this section filled in.
|
||||
placeholder: |
|
||||
GET /api/v1/manga/{seriesId}/chapters — list chapters (id, index, volume, read_state)
|
||||
POST /api/v1/watch-progress — records reading progress (existing endpoint, new item type)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: client-impact
|
||||
attributes:
|
||||
label: Client impact (android / apple / web)
|
||||
description: What must each client build to surface this capability? "None" is a valid answer per client.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: size
|
||||
attributes:
|
||||
label: Rough size (server-side)
|
||||
options:
|
||||
- S
|
||||
- M
|
||||
- L
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,21 @@
|
||||
## Problem
|
||||
Part of #NNN
|
||||
<!-- PRs with no linked scope item will be questioned. -->
|
||||
|
||||
What does this solve?
|
||||
|
||||
## Approach
|
||||
Why this approach?
|
||||
|
||||
## Testing
|
||||
Paste ACTUAL command output here. For UI changes, include screenshots or recordings.
|
||||
|
||||
### AI Disclosure
|
||||
- Tool(s):
|
||||
- Model(s):
|
||||
- Involvement:
|
||||
- Adversarial review:
|
||||
|
||||
## Checklist
|
||||
- [ ] I ran an adversarial AI review of the diff and summarized findings above.
|
||||
- [ ] I ran the repo verify commands: `make lint`, `cd web && pnpm run lint`, `cd web && pnpm run format:check`, and relevant `go test ./...`.
|
||||
@@ -19,7 +19,15 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, linux]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -28,6 +36,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Prepare platform slug and lowercase image name
|
||||
run: |
|
||||
echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
|
||||
echo "IMAGE_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -76,9 +91,6 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
name: silo-builder
|
||||
keep-state: true
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -92,19 +104,81 @@ jobs:
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=,format=short
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-contexts: |
|
||||
frontend_dist=./web/dist
|
||||
build-args: |
|
||||
BUILD_REVISION=${{ github.sha }}
|
||||
BUILD_DIRTY=false
|
||||
cache-from: type=gha,scope=docker-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=docker-${{ env.PLATFORM_PAIR }},mode=max
|
||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_LC }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Prepare lowercase image name
|
||||
run: echo "IMAGE_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_LC }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=,format=short
|
||||
|
||||
- name: Create and push manifest list
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
# shellcheck disable=SC2046
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${REGISTRY}/${IMAGE_LC}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest list
|
||||
run: |
|
||||
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_LC}:${GITHUB_SHA::7}"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: v1 proposal labeler
|
||||
|
||||
# The issue form applies `v1-proposed` automatically, but only for submissions
|
||||
# through the web form. Contributors filing via the API/CLI (or from a fork)
|
||||
# cannot apply labels without triage/write access, so their proposals would land
|
||||
# unlabeled and never auto-add to the Silo v1 project. This stamps `v1-proposed`
|
||||
# on any issue whose title starts with `[v1]`, regardless of who filed it.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
if: startsWith(github.event.issue.title, '[v1]')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply v1-proposed
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --add-label v1-proposed
|
||||
+23
-2
@@ -20,6 +20,7 @@ coverage.html
|
||||
# Temp
|
||||
/tmp/
|
||||
*.tmp
|
||||
.local/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
@@ -29,6 +30,8 @@ silo.yaml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# Per-developer settings for the agent skills (hosts, credentials, tokens).
|
||||
.silo-dev.env
|
||||
|
||||
# Developer-specific make targets
|
||||
Makefile.local
|
||||
@@ -40,6 +43,7 @@ go.work.sum
|
||||
|
||||
# Frontend
|
||||
web/node_modules/
|
||||
.pnpm-store/
|
||||
web/dist/
|
||||
plugins/metadb/dist/
|
||||
web/*.tsbuildinfo
|
||||
@@ -47,8 +51,17 @@ web/*.tsbuildinfo
|
||||
docs/inspiration
|
||||
|
||||
|
||||
.agents/
|
||||
/.claude/
|
||||
# `.agents/skills` is a symlink to `.claude/skills` so non-Claude agents find the
|
||||
# same files; everything else under .agents/ is per-developer scratch.
|
||||
/.agents/*
|
||||
!/.agents/skills
|
||||
# Ignore everything in .claude/ except the committed, shared skills.
|
||||
/.claude/*
|
||||
!/.claude/skills/
|
||||
# Skills may carry per-developer state next to their instructions (the global
|
||||
# *.local.md rule above covers notes; these cover tokens and run state).
|
||||
**/.secrets/
|
||||
**/.state/
|
||||
.cursor/
|
||||
.superpowers/
|
||||
.codex/
|
||||
@@ -58,5 +71,13 @@ logs/
|
||||
.playwright-mcp/
|
||||
demo/
|
||||
docker-compose.dev.yml
|
||||
docker-compose.local.yml
|
||||
# Local-only deployment override (unpublishes bundled redis/postgres host
|
||||
# ports via `ports: !override []`). Kept out of git so a rebase from main
|
||||
# never disturbs it and it never lands in a PR; its absence once exposed
|
||||
# Redis to the internet, so it must persist on the box.
|
||||
docker-compose.override.yml
|
||||
# Local compose customization layered onto docker-compose.yml by make dev-deploy.
|
||||
docker-compose.local.yml
|
||||
.playwright-cli/
|
||||
output/
|
||||
|
||||
@@ -23,12 +23,27 @@ linters:
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nilerr
|
||||
- sloglint
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unused
|
||||
- wastedassign
|
||||
|
||||
settings:
|
||||
sloglint:
|
||||
# Require the context-carrying slog variants (InfoContext, …) wherever a
|
||||
# context.Context is in scope, so log records correlate with the active
|
||||
# OpenTelemetry trace. "scope" (not "all") allows the plain Info/… form in
|
||||
# background/init code where no ctx exists. Enforced repo-wide after the
|
||||
# phase-3 sweep converted the ~1,159 legacy call sites.
|
||||
context: "scope"
|
||||
# Message must be a constant, not a built string; move dynamic parts to attrs.
|
||||
static-msg: true
|
||||
# Attribute keys are snake_case (component, request_id, trace_id, …).
|
||||
key-naming-case: snake
|
||||
# Don't mix key-value pairs and slog.Attr in one call.
|
||||
no-mixed-args: true
|
||||
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Silo agent environment — copy to `.silo-dev.env` and fill in.
|
||||
#
|
||||
# cp .silo-dev.env.example .silo-dev.env && $EDITOR .silo-dev.env
|
||||
#
|
||||
# `.silo-dev.env` is gitignored. It is the single place agents and humans look
|
||||
# for "which Silo deployment am I debugging, and how do I reach it" — real
|
||||
# hosts, tokens, and passwords belong here and nowhere else in the repo.
|
||||
#
|
||||
# The skills in `.claude/skills/` (also reachable as `.agents/skills/`) read it
|
||||
# through `scripts/silo-dev`, which works the same whether your server runs on
|
||||
# this machine or over SSH. Nothing here is required to build or run Silo; it
|
||||
# only configures debugging against an already-running deployment.
|
||||
|
||||
# --- The deployment under test -----------------------------------------------
|
||||
|
||||
# Base URL you reach the server on. A local docker compose install is usually
|
||||
# http://localhost:8090; a remote one is whatever you browse to.
|
||||
SILO_URL=http://localhost:8090
|
||||
|
||||
# SSH target for the host running Silo, e.g. root@10.0.0.5 or a ~/.ssh/config
|
||||
# alias. Leave empty when Silo runs on this machine — commands then run locally.
|
||||
SILO_SSH=
|
||||
|
||||
# Directory on that host holding docker-compose.yml and .env.
|
||||
SILO_DIR=/opt/silo
|
||||
|
||||
# Compose file, relative to SILO_DIR. Colon-separate to layer overrides.
|
||||
SILO_COMPOSE_FILE=docker-compose.yml
|
||||
|
||||
# Compose service names. Override if your stack renames them.
|
||||
SILO_SERVICE=silo
|
||||
SILO_POSTGRES_SERVICE=postgres
|
||||
|
||||
# --- Database ----------------------------------------------------------------
|
||||
# Match POSTGRES_USER / POSTGRES_DB in the deployment's own .env.
|
||||
|
||||
SILO_DB_USER=silo
|
||||
SILO_DB_NAME=silo
|
||||
|
||||
# --- Credentials -------------------------------------------------------------
|
||||
# An account on the deployment, used to exercise authenticated API endpoints and
|
||||
# to sign in to the web UI while verifying changes. Use a throwaway admin on a
|
||||
# dev box, not your production login.
|
||||
|
||||
SILO_ADMIN_USER=
|
||||
SILO_ADMIN_PASSWORD=
|
||||
|
||||
# --- Optional ----------------------------------------------------------------
|
||||
|
||||
# Host path the media library is mounted from, for checking that a file exists.
|
||||
SILO_MEDIA_ROOT=
|
||||
|
||||
# SSH target of a separate transcode node, if you run one.
|
||||
SILO_TRANSCODE_SSH=
|
||||
|
||||
# A real Jellyfin server to compare jellycompat responses against, and an
|
||||
# account on it. Only needed for the jellycompat-diagnosis skill.
|
||||
JELLYFIN_COMPARE_URL=
|
||||
JELLYFIN_COMPARE_USER=
|
||||
JELLYFIN_COMPARE_PASSWORD=
|
||||
|
||||
# Scoped Discord bot token for the silo-discord-triage skill.
|
||||
DISCORD_BOT_TOKEN=
|
||||
@@ -1,90 +1,108 @@
|
||||
# Repository Guidelines
|
||||
# Silo Server
|
||||
|
||||
## Project Structure & Module Organization
|
||||
`cmd/silo` contains the main server entrypoint. Backend code lives in `internal/`, organized by domain (`api`, `catalog`, `metadata`, `playback`, `scanner`, `jellycompat`, etc.); keep new code in the package that owns the behavior instead of creating catch-all helpers. Database changes belong in `migrations/sql/` as Goose SQL migrations. Legacy converted migrations intentionally keep their original numeric versions so existing `schema_versions` rows can bootstrap cleanly into Goose. New migrations must use timestamped filenames created with `make migrate-create NAME=add_thing`; do not run `goose fix` or create paired `.up.sql` / `.down.sql` files. The React frontend lives in `web/src/`, with feature code split across `components/`, `pages/`, `hooks/`, `player/`, and `lib/`. Reference material belongs in `docs/architecture/` or `docs/superpowers/{specs,plans}/`; ad hoc SQL helpers live in `scripts/`.
|
||||
Go backend for Silo: API contracts, auth/session, catalog/scanner/playback services, database
|
||||
migrations, Jellyfin compatibility, and the host-side plugin runtime. `cmd/silo` is the
|
||||
entrypoint, backend code is under `internal/` by domain, the React frontend is `web/src/`.
|
||||
|
||||
When creating or editing `docs/superpowers/specs/` or `docs/superpowers/plans/`, never include local absolute filesystem paths or transient worktree IDs. Use repository-relative paths and wording like "Commands assume the repository root is the cwd."
|
||||
This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term
|
||||
maintainability is encouraged.
|
||||
|
||||
This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
|
||||
## Priorities
|
||||
|
||||
Performance and reliability first. Keep behavior predictable under load and during failures —
|
||||
session restarts, reconnects, partial streams. When a tradeoff is forced, choose correctness and
|
||||
robustness over short-term convenience.
|
||||
|
||||
## Core Priorities
|
||||
Put new code in the package that owns the behavior rather than in a catch-all helper. Prefer
|
||||
extracting shared logic over duplicating it, and prefer changing existing code over bolting a
|
||||
local workaround onto it.
|
||||
|
||||
1. Performance first.
|
||||
2. Reliability first.
|
||||
3. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).
|
||||
## Gotchas
|
||||
|
||||
If a tradeoff is required, choose correctness and robustness over short-term convenience.
|
||||
**Migrations.** New DB changes are Goose SQL migrations in `migrations/sql/`, created with
|
||||
`make migrate-create NAME=add_thing` so they get timestamped filenames. Never run `goose fix`,
|
||||
and never create paired `.up.sql` / `.down.sql` files. Legacy converted migrations deliberately
|
||||
keep their original numeric versions so existing `schema_versions` rows bootstrap cleanly — do
|
||||
not renumber them.
|
||||
|
||||
## Maintainability
|
||||
**Encrypted settings.** Encrypted `server_settings` rows are GCM-bound to their key name.
|
||||
Renaming a row in SQL makes its value undecryptable.
|
||||
|
||||
Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem.
|
||||
**Profiles vs accounts.** Login accounts (`users`) are separate from household profiles; several
|
||||
profiles on one account share a `user_id`. A profile's `is_primary` marks the household parent,
|
||||
which is *not* the server-wide `admin` role on the account.
|
||||
|
||||
This repository is part of a broader multi-repo Silo workspace. The sibling
|
||||
repositories are usually checked out side-by-side in the same parent directory.
|
||||
**Docs hygiene.** Files under `docs/superpowers/{specs,plans}/` must not contain local absolute
|
||||
filesystem paths or transient worktree IDs — use repository-relative paths and wording like
|
||||
"Commands assume the repository root is the cwd." `make verify-local-paths` enforces this.
|
||||
|
||||
- `silo-server` owns the Go backend, web admin UI, API contracts, auth/session
|
||||
behavior, catalog/scanner/playback services, database migrations, Jellyfin
|
||||
compatibility, and host-side plugin runtime.
|
||||
- `silo-android` owns the Android phone and TV clients. Client-visible API,
|
||||
auth, playback, session, library, and metadata changes may require Android
|
||||
follow-up.
|
||||
- `silo-apple` owns the iOS, tvOS, and macOS clients. Client-visible API, auth,
|
||||
playback, session, library, and metadata changes may require Apple follow-up.
|
||||
**Dev frontend against a remote backend.** Set `VITE_API_PROXY_TARGET` in `web/.env.local` before
|
||||
`make dev-frontend`; the frontend calls relative `/api` URLs that Vite proxies.
|
||||
|
||||
When changing server behavior consumed by clients, check whether both client
|
||||
repos need model, routing, playback, or UX updates. Prefer coordinated
|
||||
multi-repo changes over leaving one platform behind.
|
||||
**Working from a plan.** When implementing from an attached plan, don't edit the plan file.
|
||||
|
||||
Do not assume all plugin-related code lives in this repo.
|
||||
## Multi-repo
|
||||
|
||||
- `silo-plugin-sdk` owns the public plugin SDK, protobuf contracts, generated plugin API code, manifest helpers, and runtime bootstrap.
|
||||
- `silo-plugins` owns the central plugin catalog / repository manifest.
|
||||
- First-party plugins such as `silo-plugin-tmdb`, and `silo-plugin-tvdb` live in their own repositories.
|
||||
- `Silo` owns host-side plugin installation, runtime management, API handlers, and integration logic.
|
||||
Sibling repos are usually checked out side-by-side in the same parent directory.
|
||||
|
||||
When a task mentions plugins, first determine whether the change belongs in this repo, the SDK repo, the catalog repo, or a specific plugin repo. Prefer coordinated multi-repo changes over forcing plugin work into `Silo`.
|
||||
- `silo-android` — Android phone and TV clients.
|
||||
- `silo-apple` — iOS, tvOS, and macOS clients.
|
||||
- `silo-plugin-sdk` — public plugin SDK, protobuf contracts, generated plugin API, manifest
|
||||
helpers, runtime bootstrap.
|
||||
- `silo-plugins` — central plugin catalog / repository manifest.
|
||||
- First-party plugins (`silo-plugin-metadata-tmdb`, `silo-plugin-metadata-tvdb`, …) each have
|
||||
their own repo.
|
||||
|
||||
## Build, and Development Commands
|
||||
Use the checked-in `Makefile` for the common paths:
|
||||
Client-visible changes to API, auth, playback, session, library, or metadata behavior usually
|
||||
need follow-up in both client repos — prefer coordinated multi-repo changes over leaving a
|
||||
platform behind. When a task mentions plugins, work out first whether it belongs here, in the
|
||||
SDK, in the catalog, or in a specific plugin repo.
|
||||
|
||||
- `make build`: install frontend deps, build `web/dist`, compile `./silo`
|
||||
- `make dev-backend`: run the Go server in integrated mode
|
||||
- `make dev-frontend`: start the Vite dev server with HMR
|
||||
- `make dev-proxy` / `make dev-transcode`: run standalone worker modes
|
||||
- `make lint`: run `golangci-lint` and frontend ESLint
|
||||
- `make migrate-status` / `make migrate-up`: inspect or apply Goose migrations through Silo's legacy-safe bootstrapping runner
|
||||
## Building and verifying
|
||||
|
||||
Run before opening a merge request:
|
||||
`make build`, `make dev-backend`, `make dev-frontend`, `make lint`, `make migrate-status` /
|
||||
`make migrate-up` — read the `Makefile` for the rest. Local services:
|
||||
`docker compose up -d postgres redis`.
|
||||
|
||||
- `cd web && pnpm run lint`
|
||||
- `cd web && pnpm run format:check`
|
||||
- `make verify-local-paths`
|
||||
Before opening a merge request:
|
||||
|
||||
For local services, start PostgreSQL and Redis with `docker compose up -d postgres redis`.
|
||||
```bash
|
||||
make lint
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Go code must stay `gofmt`/`goimports` clean and pass `golangci-lint`. Keep package names lowercase and focused; Frontend code is TypeScript with 2-space indentation, semicolons, double quotes, trailing commas, and a 100-character line width (`web/.prettierrc`). Use `PascalCase.tsx` for components/pages, `useThing.ts` for hooks, and keep shared utilities in `web/src/lib` or `web/src/utils`.
|
||||
Go stays `gofmt`/`goimports` clean; the frontend follows `web/.prettierrc`.
|
||||
|
||||
## Skills
|
||||
|
||||
## Deployment Debugging
|
||||
Task-specific guides live in `.claude/skills/`, also reachable as `.agents/skills/` for agents
|
||||
that look there. Read the one that matches the task instead of working from this file alone.
|
||||
|
||||
When troubleshooting a Silo deployment (container health, playback failures,
|
||||
database state, log analysis, deploys), follow the runbook at
|
||||
`.claude/skills/deployment-debugging/SKILL.md`. It includes step-by-step
|
||||
procedures and a first-run setup that configures SSH, database, and Redis
|
||||
connection details for your environment.
|
||||
They share one config file: copy `.silo-dev.env.example` to `.silo-dev.env` and fill in how to
|
||||
reach your Silo deployment — URL, SSH target, database, an account to debug with. That file is
|
||||
gitignored and is the only place hosts, passwords, and tokens belong. `scripts/silo-dev doctor`
|
||||
checks it end to end.
|
||||
|
||||
## Commit & Merge Request Guidelines
|
||||
Recent history follows Conventional Commit-style subjects such as `feat(playback): add realtime session hub`, `fix(playback): ...`, and `docs: ...`. Keep commits scoped to one concern. For non-trivial work, open an issue or discussion first; this codebase moves quickly. Merge requests should explain the problem, why this approach was chosen, linked issue/spec/plan, risks or follow-up work, and AI-use disclosure. Include screenshots or recordings for UI changes.
|
||||
## v1 API rules
|
||||
|
||||
## Learned User Preferences
|
||||
Additive-only within `/api/v1`:
|
||||
|
||||
- When implementing from an attached plan, do not edit the plan file itself.
|
||||
- After feature work, user may invoke the `/simplify` skill to improve readability without changing behavior.
|
||||
- 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 or endpoints. Removals go through the Deprecation/Sunset
|
||||
header flow only.
|
||||
- New features expose capability endpoints for feature detection rather than relying on version
|
||||
sniffing. Contract strategy and tooling: issue #135.
|
||||
|
||||
## Learned Workspace Facts
|
||||
## Pull requests
|
||||
|
||||
- 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.
|
||||
Conventional Commit subjects (`feat(playback): add realtime session hub`). One concern per PR.
|
||||
Explain the problem, why this approach, the linked issue/spec/plan, and risks or follow-up work.
|
||||
Include screenshots or recordings for UI changes. Link the capability epic or sub-issue the PR
|
||||
serves (`Part of #NNN`) — PRs with no linked scope item get questioned at review. For non-trivial
|
||||
work, open an issue or discussion first; this codebase moves quickly.
|
||||
|
||||
AI-use disclosure is required in the PR body. If you are an AI agent contributing on behalf of a
|
||||
non-maintainer, follow [docs/ai-contributions.md](docs/ai-contributions.md) — it has the required
|
||||
disclosure block and the evidence standard.
|
||||
|
||||
+23
-1
@@ -36,13 +36,35 @@ AI writes most of the code here. That's fine. What's not fine is copy-pasting ou
|
||||
|
||||
"The AI suggested it" is not an acceptable answer in review. You should be able to explain the reasoning and tradeoffs.
|
||||
|
||||
## AI Disclosure (Required)
|
||||
|
||||
Every PR and every issue needs to say what AI was involved: the tool, the exact model ID, and the involvement level. "No AI" is a perfectly fine answer. Just say so.
|
||||
|
||||
```md
|
||||
### AI Disclosure
|
||||
- Tool(s): e.g. Claude Code, Codex CLI, Cursor — or "none"
|
||||
- Model(s): exact model ID(s), e.g. claude-fable-5, gpt-5.4 — or "n/a"
|
||||
- Involvement: fully AI-generated | AI-assisted | human-written, AI-reviewed | none
|
||||
- Adversarial review: what your own AI review of the diff found, and how you resolved it
|
||||
```
|
||||
|
||||
The exact model matters. This project is developed with frontier models. If a PR was generated by an older or weaker model, the fastest honest response may be for me to re-implement the idea with a current model instead of reviewing the diff line by line. Disclosing the model lets that call happen quickly. "Thanks, the idea is accepted, but the implementation will be redone" is a possible outcome; see [Be Realistic](#be-realistic).
|
||||
|
||||
Before submitting, run an adversarial review of your own diff with whatever AI tooling you have, and summarize what it found and what you did about it. "It found nothing" is only credible for tiny diffs.
|
||||
|
||||
Undisclosed AI use that is discovered later gets the PR closed on the spot. Repeat offenses get you blocked. The offense is the non-disclosure, not the AI use.
|
||||
|
||||
Fabricated content is an immediate block, first offense. Invented APIs, repro steps that never happened, AI-imagined vulnerabilities, and "bugs" nobody actually observed all count. This is the curl-style rule for hallucinated reports.
|
||||
|
||||
For issues, logs must be raw copy-paste, never AI-paraphrased. You must have actually reproduced the problem yourself on a real deployment before filing.
|
||||
|
||||
## Merge Requests
|
||||
|
||||
A good MR answers: what problem does this solve, why this approach, how was it tested, anything to watch out for. For non-trivial changes, link the issue and note any migration/compatibility concerns. Screenshots for UI changes.
|
||||
|
||||
Small, well-explained MRs get reviewed fast. Big unexplained ones sit.
|
||||
|
||||
If AI meaningfully shaped the implementation, say so in the description. Not gatekeeping — just helps reviewers understand intent.
|
||||
AI disclosure is mandatory for every MR; use the required block in [AI Disclosure (Required)](#ai-disclosure-required).
|
||||
|
||||
## Development Setup
|
||||
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# Developing Silo
|
||||
|
||||
This document covers building, running, and contributing to the Silo server. If you just want to run Silo, see the [README](README.md).
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Go** 1.24+
|
||||
- **Bun** 1.0+
|
||||
- **PostgreSQL** 18+
|
||||
- **FFmpeg** (for transcoding support)
|
||||
|
||||
## Local Development
|
||||
|
||||
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) for local services and the source-build workflow below.
|
||||
|
||||
```sh
|
||||
# Start local PostgreSQL and Redis
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# Run the frontend dev server (hot reload, proxies API to :8090)
|
||||
make dev-frontend
|
||||
|
||||
# Run the Go backend
|
||||
make dev-backend
|
||||
```
|
||||
|
||||
The main compose file expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first (`cp .env.example .env`).
|
||||
|
||||
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
|
||||
|
||||
Plugin authors should start with [docs/architecture/plugin-development.md](docs/architecture/plugin-development.md), which covers the RPC plugin package format, generated proto workflow, SDK import paths, route and asset exposure, and auth or user-config integration points.
|
||||
|
||||
## Make Targets
|
||||
|
||||
| Target | Description |
|
||||
|---|---|
|
||||
| `make build` | Build frontend + Go binary |
|
||||
| `make frontend` | Build frontend only |
|
||||
| `make dev-frontend` | Vite dev server with HMR |
|
||||
| `make dev-backend` | Run Go backend (integrated mode) |
|
||||
| `make dev-proxy` | Run a standalone proxy node |
|
||||
| `make dev-transcode` | Run a standalone transcode node |
|
||||
| `make migrate-create NAME=add_thing` | Create a timestamped Goose SQL migration |
|
||||
| `make migrate-validate` | Validate Goose migration files without touching a database |
|
||||
| `make migrate-status` | Show Goose migration status using Silo's bootstrapping runner |
|
||||
| `make migrate-up` | Apply pending Goose migrations using Silo's bootstrapping runner |
|
||||
| `make clean` | Remove build artifacts |
|
||||
|
||||
## Database Migrations
|
||||
|
||||
PostgreSQL schema migrations are managed by Goose. Migration SQL files live in
|
||||
`migrations/sql/` and use Goose annotations. Converted legacy migrations keep
|
||||
their original numeric versions so existing `schema_versions` rows can bootstrap
|
||||
cleanly into Goose without replaying old SQL. New migrations should be created
|
||||
with timestamped filenames:
|
||||
|
||||
```sh
|
||||
make migrate-create NAME=add_thing
|
||||
make migrate-validate
|
||||
```
|
||||
|
||||
Do not run `goose fix`; timestamped migrations are the repository policy because
|
||||
they avoid version collisions across parallel PRs. The existing `001`-style
|
||||
files are historical compatibility records, not the naming pattern for new work.
|
||||
Runtime migrations are applied by the integrated/API server only. Proxy and
|
||||
transcode modes never mutate schema.
|
||||
For existing installs, use `make migrate-status` and `make migrate-up` rather
|
||||
than invoking the Goose CLI directly; those targets copy legacy
|
||||
`schema_versions` rows into `public.goose_db_version` under the migration lock
|
||||
before reading or applying migrations. Set `ENV_FILE=path/to/.env` when the
|
||||
database URL should be read from a non-default env file.
|
||||
|
||||
## Running Tests
|
||||
|
||||
```sh
|
||||
# Go tests (uses testcontainers — Docker must be running)
|
||||
go test ./...
|
||||
|
||||
# Frontend tests
|
||||
cd web && bun test
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
```sh
|
||||
# Go
|
||||
golangci-lint run
|
||||
|
||||
# Frontend
|
||||
cd web && bun run lint
|
||||
cd web && bun run format:check
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cmd/silo/ Entry point
|
||||
internal/
|
||||
api/ HTTP router, handlers, middleware
|
||||
auth/ JWT authentication and sessions
|
||||
catalog/ Media item, episode, season repositories
|
||||
config/ YAML + env var configuration
|
||||
jellycompat/ Jellyfin/Emby protocol compatibility
|
||||
metadata/ Plugin-driven metadata matching and enrichment
|
||||
playback/ Direct play, remux, transcode session management
|
||||
scanner/ Media file discovery and FFProbe
|
||||
worker/ Background jobs (scan, match, reconcile)
|
||||
web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui)
|
||||
migrations/sql/ Goose-managed PostgreSQL schema migrations
|
||||
```
|
||||
+6
-3
@@ -50,12 +50,15 @@ RUN apt-get update && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 git libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get purge -y gnupg && apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
RUN mkdir -p /tmp/silo-transcode /var/lib/silo/compat/jellyfin-web
|
||||
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=frontend /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
||||
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
COPY --from=build /silo /usr/local/bin/silo
|
||||
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
|
||||
EXPOSE 8080 8096 13378
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
|
||||
|
||||
+7
-4
@@ -55,13 +55,16 @@ RUN apt-get update && \
|
||||
echo "deb [signed-by=/usr/share/keyrings/jellyfin.gpg arch=${TARGETARCH}] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get install -y --no-install-recommends jellyfin-ffmpeg7 git libvips42 fonts-noto-core fonts-noto-cjk && \
|
||||
apt-get purge -y gnupg && apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /tmp/silo-transcode
|
||||
RUN mkdir -p /tmp/silo-transcode /var/lib/silo/compat/jellyfin-web
|
||||
COPY --from=frontend /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=frontend /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
||||
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
COPY --from=build /silo /usr/local/bin/silo
|
||||
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
|
||||
EXPOSE 8080 8080 8096
|
||||
EXPOSE 8080 8096 13378
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
|
||||
ENTRYPOINT ["silo"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web-bundle migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
|
||||
.PHONY: frontend build dev-frontend dev-backend dev-proxy dev-transcode lint clean jellyfin-web migrate-continuum-check verify-local-paths install-hooks migrate-create migrate-validate migrate-status migrate-up
|
||||
|
||||
GIT_COMMON_DIR := $(strip $(shell git rev-parse --git-common-dir 2>/dev/null))
|
||||
MAIN_CHECKOUT_ROOT := $(if $(GIT_COMMON_DIR),$(abspath $(GIT_COMMON_DIR)/..))
|
||||
@@ -15,7 +15,8 @@ else ifneq ($(wildcard $(SHARED_PLUGIN_SDK_DIR)),)
|
||||
DEV_PLUGIN_SDK_DIR ?= $(SHARED_PLUGIN_SDK_DIR)
|
||||
endif
|
||||
|
||||
JELLYFIN_WEB_OUTPUT_DIR ?= third_party/jellyfin-web
|
||||
JELLYFIN_WEB_INSTALL_DIR ?= .local/compat/jellyfin-web
|
||||
JELLYFIN_WEB_VERSION ?= 10.11.6
|
||||
|
||||
# Build version stamping: inject the git revision so the admin Build panel shows a
|
||||
# version even when Go's VCS metadata isn't embedded (mirrors the Dockerfile ldflags).
|
||||
@@ -82,9 +83,9 @@ install-hooks:
|
||||
fi
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
# Fetch and build the pinned Jellyfin Web bundle
|
||||
jellyfin-web-bundle:
|
||||
JELLYFIN_WEB_OUTPUT_DIR=$(JELLYFIN_WEB_OUTPUT_DIR) scripts/fetch-jellyfin-web.sh
|
||||
# Fetch and build the pinned Jellyfin Web component into a gitignored local cache.
|
||||
jellyfin-web:
|
||||
go run ./cmd/silo/ compat-web install --dir "$(JELLYFIN_WEB_INSTALL_DIR)" --version "$(JELLYFIN_WEB_VERSION)"
|
||||
|
||||
# Read-only preflight for Continuum Docker installs moving to Silo.
|
||||
migrate-continuum-check:
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# Silo
|
||||
|
||||
A self-hosted media streaming server with a React frontend and Go backend. Supports direct play, remuxing, and hardware-accelerated transcoding. Compatible with Jellyfin/Emby clients (VidHub, Findroid) via a built-in compatibility layer.
|
||||
Silo is a self-hosted media streaming server for your movies, shows, music, and books. Point it at your media folders and stream to your devices — at home or away — with direct play, remuxing, and hardware-accelerated transcoding handled automatically.
|
||||
|
||||
Join the community on [Discord](https://discord.gg/4RxuUQAEnW).
|
||||
Join the community on [Discord](https://discord.gg/4RxuUQAEnW). If Silo is useful to you, consider [sponsoring the project](https://github.com/sponsors/quick104) — see [Supporting Silo](#supporting-silo).
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Plays your media, your way** — direct play when the device supports it, remux or hardware-accelerated transcode (including NVENC) when it doesn't.
|
||||
- **Web app included** — a full-featured web client and admin interface ship with the server.
|
||||
- **Works with apps you already use** — optional Jellyfin/Emby-compatible API supports clients such as VidHub, Findroid, and Infuse.
|
||||
- **Household profiles** — multiple profiles per account, with per-profile watch state and parental controls.
|
||||
- **Plugin-driven metadata** — match and enrich your libraries with providers like TMDB and TVDB, installed as plugins.
|
||||
- **Fast setup** — one `docker compose up -d` brings up the whole stack; everything else is configured in the admin UI.
|
||||
|
||||
## Deploy with Docker (recommended)
|
||||
|
||||
@@ -30,7 +39,7 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL, Redis, and the integrated Silo server. The app is available at `http://localhost:8090` and the Jellyfin-compatible endpoint at `http://localhost:8096`.
|
||||
This starts PostgreSQL, Redis, and the integrated Silo server. The app is available at `http://localhost:8090`. Jellyfin-compatible app support is disabled until an administrator enables it in onboarding or admin settings.
|
||||
|
||||
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
|
||||
|
||||
@@ -105,157 +114,6 @@ Silo is externally stateful by default rather than fully stateless. Durable appl
|
||||
Migrating an existing Continuum Docker install should be done with the preflight
|
||||
helper and cutover guide in [docs/continuum-to-silo-docker-migration.md](docs/continuum-to-silo-docker-migration.md).
|
||||
|
||||
## Build from Source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go** 1.24+
|
||||
- **Bun** 1.0+
|
||||
- **PostgreSQL** 18+
|
||||
- **FFmpeg** (for transcoding support)
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Start PostgreSQL** (skip if you already have one running)
|
||||
|
||||
```sh
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
|
||||
|
||||
2. **Configure the database connection**
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
|
||||
|
||||
3. **Build and run**
|
||||
|
||||
```sh
|
||||
make build
|
||||
./silo
|
||||
```
|
||||
|
||||
The server starts at `http://localhost:8080` by default. All other settings are configured through the admin UI.
|
||||
|
||||
## Development
|
||||
|
||||
Local development remains intentionally separate from the deploy-oriented compose setup. Use [docker-compose.yml](docker-compose.yml) and the existing source-build workflow for local development.
|
||||
|
||||
```sh
|
||||
# Run the frontend dev server (hot reload, proxies API to :8090)
|
||||
make dev-frontend
|
||||
|
||||
# Run the Go backend
|
||||
make dev-backend
|
||||
```
|
||||
|
||||
If you are developing `Silo` and `silo-plugin-sdk` together, keep using the local [`go.work`](go.work) workspace. That workspace is a developer convenience only. CI and release builds run with `GOWORK=off`, so any new SDK helper used here must be pushed and tagged in `silo-plugin-sdk` before this repo can merge or release the change.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
|
||||
|
||||
Plugin authors should start with [docs/architecture/plugin-development.md](docs/architecture/plugin-development.md), which covers the RPC plugin package format, generated proto workflow, SDK import paths, route and asset exposure, and auth or user-config integration points.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
|
||||
|
||||
Please include:
|
||||
|
||||
- What you were trying to do
|
||||
- Exact steps you took
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- What exact action is slow or broken (`save`, `scan`, `browse`, `import`, `playback`, etc.)
|
||||
- Whether it happens every time or only sometimes
|
||||
- The library, media type, filter, setting, or value involved
|
||||
- Version, branch, commit, and deployment details if you know them
|
||||
- Screenshots, recordings, or log snippets if relevant
|
||||
|
||||
If you used Claude/Codex for debugging, put that under `Technical notes` at the end. Suspected files, SQL output, stack traces, and root-cause theories can be helpful, but only after the workflow and repro steps are clear.
|
||||
|
||||
Use this template:
|
||||
|
||||
```text
|
||||
Goal:
|
||||
Steps:
|
||||
Expected:
|
||||
Actual:
|
||||
What is slow/broken:
|
||||
Scope:
|
||||
Version/branch:
|
||||
Deployment:
|
||||
Technical notes:
|
||||
```
|
||||
|
||||
### Make Targets
|
||||
|
||||
| Target | Description |
|
||||
|---|---|
|
||||
| `make build` | Build frontend + Go binary |
|
||||
| `make frontend` | Build frontend only |
|
||||
| `make dev-frontend` | Vite dev server with HMR |
|
||||
| `make dev-backend` | Run Go backend (integrated mode) |
|
||||
| `make dev-proxy` | Run a standalone proxy node |
|
||||
| `make dev-transcode` | Run a standalone transcode node |
|
||||
| `make migrate-create NAME=add_thing` | Create a timestamped Goose SQL migration |
|
||||
| `make migrate-validate` | Validate Goose migration files without touching a database |
|
||||
| `make migrate-status` | Show Goose migration status using Silo's bootstrapping runner |
|
||||
| `make migrate-up` | Apply pending Goose migrations using Silo's bootstrapping runner |
|
||||
| `make clean` | Remove build artifacts |
|
||||
|
||||
### Database Migrations
|
||||
|
||||
PostgreSQL schema migrations are managed by Goose. Migration SQL files live in
|
||||
`migrations/sql/` and use Goose annotations. Converted legacy migrations keep
|
||||
their original numeric versions so existing `schema_versions` rows can bootstrap
|
||||
cleanly into Goose without replaying old SQL. New migrations should be created
|
||||
with timestamped filenames:
|
||||
|
||||
```sh
|
||||
make migrate-create NAME=add_thing
|
||||
make migrate-validate
|
||||
```
|
||||
|
||||
Do not run `goose fix`; timestamped migrations are the repository policy because
|
||||
they avoid version collisions across parallel PRs. The existing `001`-style
|
||||
files are historical compatibility records, not the naming pattern for new work.
|
||||
Runtime migrations are applied by the integrated/API server only. Proxy and
|
||||
transcode modes never mutate schema.
|
||||
For existing installs, use `make migrate-status` and `make migrate-up` rather
|
||||
than invoking the Goose CLI directly; those targets copy legacy
|
||||
`schema_versions` rows into `public.goose_db_version` under the migration lock
|
||||
before reading or applying migrations. Set `ENV_FILE=path/to/.env` when the
|
||||
database URL should be read from a non-default env file.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```sh
|
||||
# Go tests (uses testcontainers — Docker must be running)
|
||||
go test ./...
|
||||
|
||||
# Frontend tests
|
||||
cd web && bun test
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```sh
|
||||
# Go
|
||||
golangci-lint run
|
||||
|
||||
# Frontend
|
||||
cd web && bun run lint
|
||||
cd web && bun run format:check
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Silo is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE).
|
||||
|
||||
## Configuration
|
||||
|
||||
Silo requires only a `DATABASE_URL` when running from source or against external infrastructure. In the default Docker Compose path, the stack wires the database and Redis URLs for you. All other settings — libraries, metadata providers, transcoding, users — are managed through the admin UI after first launch.
|
||||
@@ -269,7 +127,7 @@ Silo requires only a `DATABASE_URL` when running from source or against external
|
||||
| `proxy` | Stream proxy node that connects to the shared deployment database and Redis |
|
||||
| `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis |
|
||||
|
||||
## PostgreSQL Auto-Tuning
|
||||
### PostgreSQL Auto-Tuning
|
||||
|
||||
The default Docker Compose stack does not require a checked-in `postgresql.conf`.
|
||||
It enables Silo's [pgtune](https://github.com/le0pard/pgtune)-style OLTP tuning
|
||||
@@ -321,20 +179,93 @@ change PostgreSQL server settings. Settings already written with `ALTER SYSTEM`
|
||||
remain in `postgresql.auto.conf`; reset those PostgreSQL parameters if you later
|
||||
move fully to a custom `postgresql.conf`.
|
||||
|
||||
## Project Structure
|
||||
## Build from Source
|
||||
|
||||
If you prefer running Silo without Docker:
|
||||
|
||||
1. **Install prerequisites**: Go 1.24+, Bun 1.0+, PostgreSQL 18+, and FFmpeg.
|
||||
|
||||
2. **Start PostgreSQL and Redis** (skip if you already have them running)
|
||||
|
||||
```sh
|
||||
docker compose up -d postgres redis
|
||||
```
|
||||
|
||||
The main compose file still expects `MEDIA_ROOT` to be set even if you only want the bundled PostgreSQL and Redis services, so set that in `.env` first.
|
||||
|
||||
3. **Configure the database connection**
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set `DATABASE_URL` to point to your PostgreSQL instance.
|
||||
|
||||
4. **Build and run**
|
||||
|
||||
```sh
|
||||
make build
|
||||
./silo
|
||||
```
|
||||
|
||||
The server starts at `http://localhost:8080` by default. All other settings are configured through the admin UI.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
|
||||
|
||||
Please include:
|
||||
|
||||
- What you were trying to do
|
||||
- Exact steps you took
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- What exact action is slow or broken (`save`, `scan`, `browse`, `import`, `playback`, etc.)
|
||||
- Whether it happens every time or only sometimes
|
||||
- The library, media type, filter, setting, or value involved
|
||||
- Version, branch, commit, and deployment details if you know them
|
||||
- Screenshots, recordings, or log snippets if relevant
|
||||
|
||||
If you used Claude/Codex for debugging, put that under `Technical notes` at the end. Suspected files, SQL output, stack traces, and root-cause theories can be helpful, but only after the workflow and repro steps are clear.
|
||||
|
||||
Use this template:
|
||||
|
||||
```text
|
||||
Goal:
|
||||
Steps:
|
||||
Expected:
|
||||
Actual:
|
||||
What is slow/broken:
|
||||
Scope:
|
||||
Version/branch:
|
||||
Deployment:
|
||||
Technical notes:
|
||||
```
|
||||
cmd/silo/ Entry point
|
||||
internal/
|
||||
api/ HTTP router, handlers, middleware
|
||||
auth/ JWT authentication and sessions
|
||||
catalog/ Media item, episode, season repositories
|
||||
config/ YAML + env var configuration
|
||||
jellycompat/ Jellyfin/Emby protocol compatibility
|
||||
metadata/ Plugin-driven metadata matching and enrichment
|
||||
playback/ Direct play, remux, transcode session management
|
||||
scanner/ Media file discovery and FFProbe
|
||||
worker/ Background jobs (scan, match, reconcile)
|
||||
web/ React + TypeScript frontend (Vite, Tailwind, shadcn/ui)
|
||||
migrations/sql/ Goose-managed PostgreSQL schema migrations
|
||||
```
|
||||
|
||||
## Contributing & Development
|
||||
|
||||
Silo is open source and contributions are welcome. See [DEVELOPMENT.md](DEVELOPMENT.md) for building from source in a dev workflow, running tests, database migrations, and project layout, and [CONTRIBUTING.md](CONTRIBUTING.md) for contribution expectations, merge request guidance, and the policy for AI-assisted submissions.
|
||||
|
||||
## Supporting Silo
|
||||
|
||||
Silo is an open-source hobby project, developed in spare time and funded out of pocket. If you'd like to support development, you can sponsor via [GitHub Sponsors](https://github.com/sponsors/quick104).
|
||||
|
||||
Donations go directly toward the costs of building and running the project:
|
||||
|
||||
- AI development tooling subscriptions (Claude, Codex) used to build and maintain Silo
|
||||
- Push notification relay infrastructure
|
||||
- Future development costs
|
||||
|
||||
Sponsoring is entirely optional — Silo is and will remain free and open source. Bug reports, contributions, and feedback are just as valuable.
|
||||
|
||||
## License & Trademarks
|
||||
|
||||
Silo's source code is licensed under the **GNU Affero General Public License
|
||||
v3.0 or later** (`AGPL-3.0-or-later`) — see [LICENSE](LICENSE).
|
||||
|
||||
The **Silo name, logo, and wordmark are trademarks of Silo Media L.L.C.** and
|
||||
are **not** covered by the AGPL. You're free to fork and redistribute the code,
|
||||
but forks and redistributions must not use the Silo brand as their identity and
|
||||
must remove or replace the brand assets. Publishing a Silo-branded app to an app
|
||||
store requires written permission. See [TRADEMARK.md](TRADEMARK.md) for what's
|
||||
permitted — including referential use like "compatible with Silo."
|
||||
|
||||
@@ -5,11 +5,11 @@ material needed for Silo server builds.
|
||||
|
||||
## Jellyfin Web
|
||||
|
||||
`third_party/jellyfin-web/10.11.6/` contains a vendored Jellyfin Web build used
|
||||
for Jellyfin compatibility surfaces. Upstream package notices generated by that
|
||||
build are preserved next to the corresponding bundles as `*.LICENSE.txt` files.
|
||||
Keep those notices with the vendored files when updating or redistributing this
|
||||
directory.
|
||||
Silo does not bundle Jellyfin Web in this repository or in the default runtime
|
||||
image. Administrators can explicitly install Jellyfin Web as a separate
|
||||
compatibility component with `silo compat-web install` or the admin settings UI.
|
||||
The installer records the upstream source URL, tag, commit SHA, checksum,
|
||||
license, and source/provenance metadata beside the installed assets.
|
||||
|
||||
## Collection Template Posters
|
||||
|
||||
@@ -17,4 +17,3 @@ directory.
|
||||
poster artwork. The artwork is intended to use generic original scenes and
|
||||
avoid copyrighted movie/show posters, recognizable actors, franchise
|
||||
characters, provider logos, and readable in-image third-party branding.
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Silo Trademark & Brand Policy
|
||||
|
||||
Silo's source code is free and open source under the **GNU Affero General Public
|
||||
License v3.0 or later** (`AGPL-3.0-or-later`). You are welcome to use, study,
|
||||
modify, and redistribute it under those terms.
|
||||
|
||||
**That freedom applies to the code — not to the Silo name and brand.** The Silo
|
||||
trademarks identify the official project and its official builds. This policy
|
||||
explains how they may and may not be used. It exists for a simple reason:
|
||||
anyone should be free to fork the software, while users can still trust that
|
||||
something called "Silo" actually comes from us.
|
||||
|
||||
## Marks covered
|
||||
|
||||
The following are trademarks of **Silo Media L.L.C.** ("Silo Media"), whether or
|
||||
not registered:
|
||||
|
||||
- the names **Silo** and **Silo Server**
|
||||
- the **Silo logo** and **wordmark**
|
||||
- Silo **product and component names**, including **SiloRemote**, **SiloMac**,
|
||||
and other "Silo…" names
|
||||
- confusingly similar variations of the above
|
||||
|
||||
The AGPL license over the source code **does not grant any license to these
|
||||
marks.** This reservation is expressly permitted by AGPL-3.0 §7(e).
|
||||
|
||||
## Uses that are always fine — no permission needed
|
||||
|
||||
- **Referring to Silo truthfully** — "compatible with Silo," "a plugin for
|
||||
Silo," "built on Silo," "a fork of Silo." Use the word to refer to *our*
|
||||
project, not to brand *yours*.
|
||||
- **Redistributing official, unmodified builds** under the Silo name, with all
|
||||
notices kept intact.
|
||||
- **Running, modifying, and self-hosting** Silo for yourself or your
|
||||
organization.
|
||||
- **Editorial and educational** use — articles, tutorials, talks, reviews.
|
||||
|
||||
## Uses that require our written permission
|
||||
|
||||
- Distributing a **modified version under the Silo name** (see *Forks* below).
|
||||
- **Publishing an app to any app store** — Apple App Store, Google Play,
|
||||
F-Droid, or similar — under the Silo name, logo, or a confusingly similar
|
||||
identity.
|
||||
- Using the marks in a **company, product, domain, or app name**, or in any way
|
||||
that implies Silo Media operates or endorses your offering.
|
||||
- **Merchandise** or other commercial use of the logo or wordmark.
|
||||
|
||||
## Forks must rebrand
|
||||
|
||||
You may fork Silo — that is your right under the AGPL. But a **distributed** fork
|
||||
must not use the Silo name or logo as its identity. Pick your own name and logo,
|
||||
remove the Silo brand assets (below), and you may state factually that your
|
||||
project "is based on Silo" or "is a fork of Silo." This is the same model used by
|
||||
Mozilla, Rust, and many other open-source projects: **open code, protected
|
||||
identity.**
|
||||
|
||||
## Brand assets are not under the AGPL
|
||||
|
||||
The Silo logo, wordmark, and icon files are **© 2026 Silo Media L.L.C., all
|
||||
rights reserved**, and are **not** licensed under the AGPL or any other
|
||||
open-source license. Any redistribution or fork must **remove or replace** them.
|
||||
See the `NOTICE` file in the brand-asset directories.
|
||||
|
||||
## Official sources
|
||||
|
||||
The only official Silo software comes from:
|
||||
|
||||
- the **Silo-Server** organization on GitHub — <https://github.com/Silo-Server>
|
||||
- the official website — <https://siloserver.org>
|
||||
- official app-store listings published by **Silo Media L.L.C.**
|
||||
|
||||
Any app-store listing, download, or "Silo" app that does not originate from the
|
||||
above is **not** official and is **not** authorized to use the Silo marks.
|
||||
|
||||
## Requesting permission / reporting misuse
|
||||
|
||||
Email **trademark@siloserver.org** with the details. We're generally glad to
|
||||
grant reasonable requests — the goal of this policy is only to prevent user
|
||||
confusion and unauthorized "official-looking" releases.
|
||||
|
||||
---
|
||||
|
||||
*Silo and the Silo logo are trademarks of Silo Media L.L.C. The **™** symbol is
|
||||
used with the Silo wordmark until federal registration issues, after which **®**
|
||||
applies.*
|
||||
+945
-114
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@ func buildLiveSessionSync(s *playback.Session, reportingNode string) worker.Sess
|
||||
PlayMethod: string(s.PlayMethod),
|
||||
ReportingNode: reportingNode,
|
||||
ClientIP: s.ClientIP,
|
||||
ClientName: s.ClientName,
|
||||
ClientVersion: s.ClientVersion,
|
||||
ClientUserAgent: s.ClientUserAgent,
|
||||
AudioTrackIndex: s.AudioTrackIndex,
|
||||
TranscodeAudio: s.TranscodeAudio,
|
||||
StreamBitrateKbps: s.StreamBitrateKbps,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Development stack:
|
||||
# docker compose -f docker-compose.dev.yml up --build
|
||||
#
|
||||
# Vite serves the UI at http://localhost:${PORT:-8090} and proxies API
|
||||
# requests to the Air-reloaded Go backend on the compose network.
|
||||
name: silo-dev
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
restart: unless-stopped
|
||||
shm_size: ${POSTGRES_SHM_SIZE:-2gb}
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-silo}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-silo}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-silo}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- silo-dev-postgres:/var/lib/postgresql
|
||||
command: ["postgres", "-c", "listen_addresses=*"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-silo}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- silo-dev-redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
silo:
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM node:22-slim AS node_runtime
|
||||
|
||||
FROM golang:1.26
|
||||
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOPROXY=direct
|
||||
ENV GOPRIVATE=github.com/Silo-Server/*
|
||||
ENV GONOSUMDB=github.com/Silo-Server/*
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
ffmpeg \
|
||||
git \
|
||||
libvips-dev \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=node_runtime /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node_runtime /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
||||
RUN ln -sf ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
RUN go install github.com/air-verse/air@latest
|
||||
|
||||
WORKDIR /app
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
mkdir -p tmp web/dist
|
||||
if [ ! -f web/dist/index.html ]; then
|
||||
printf '%s\n' '<!doctype html><html><body><div id="root"></div></body></html>' > web/dist/index.html
|
||||
fi
|
||||
|
||||
cat >/tmp/air.docker.toml <<'AIR'
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/silo ./cmd/silo/"
|
||||
bin = "./tmp/silo"
|
||||
args_bin = []
|
||||
include_ext = ["go", "sql", "json"]
|
||||
include_dir = ["cmd", "internal", "migrations"]
|
||||
exclude_dir = ["tmp", "vendor", "third_party", "docs", "scripts", "web"]
|
||||
exclude_regex = ["_test\\.go$"]
|
||||
delay = 500
|
||||
stop_on_error = true
|
||||
kill_delay = 500
|
||||
send_interrupt = true
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
AIR
|
||||
|
||||
air -c /tmp/air.docker.toml
|
||||
environment:
|
||||
MODE: integrated
|
||||
PORT: "8080"
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379
|
||||
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
|
||||
POSTGRES_TUNE: "off"
|
||||
ports:
|
||||
- "${BACKEND_PORT:-18080}:8080"
|
||||
- "${JF_PORT:-8096}:8096"
|
||||
- "${ABS_PORT:-13378}:13378"
|
||||
volumes:
|
||||
- .:/app
|
||||
- silo-dev-air-tmp:/app/tmp
|
||||
- silo-dev-go-build:/root/.cache/go-build
|
||||
- silo-dev-go-mod:/go/pkg/mod
|
||||
- silo-dev-plugins:/var/lib/silo/plugins
|
||||
- silo-dev-compat:/var/lib/silo/compat
|
||||
- silo-dev-transcode:/tmp/silo-transcode
|
||||
- silo-dev-audiobook-covers:/var/lib/silo/audiobook-covers
|
||||
- ${MEDIA_ROOT:-/tmp/silo-dev-media}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/api/v1/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM node:22-slim
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
|
||||
|
||||
WORKDIR /app/web
|
||||
restart: unless-stopped
|
||||
working_dir: /app/web
|
||||
command: sh -c "pnpm install --frozen-lockfile --store-dir /pnpm/store && pnpm run dev -- --host 0.0.0.0"
|
||||
environment:
|
||||
VITE_API_PROXY_TARGET: http://silo:8080
|
||||
VITE_HMR_CLIENT_PORT: "${PORT:-8090}"
|
||||
PNPM_STORE_DIR: /pnpm/store
|
||||
CI: "true"
|
||||
ports:
|
||||
- "${PORT:-8090}:5173"
|
||||
volumes:
|
||||
- .:/app
|
||||
- silo-dev-web-node-modules:/app/web/node_modules
|
||||
- silo-dev-pnpm-store:/pnpm/store
|
||||
depends_on:
|
||||
silo:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
silo-dev-air-tmp:
|
||||
silo-dev-audiobook-covers:
|
||||
silo-dev-compat:
|
||||
silo-dev-go-build:
|
||||
silo-dev-go-mod:
|
||||
silo-dev-plugins:
|
||||
silo-dev-pnpm-store:
|
||||
silo-dev-postgres:
|
||||
silo-dev-redis:
|
||||
silo-dev-transcode:
|
||||
silo-dev-web-node-modules:
|
||||
@@ -31,6 +31,26 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
meilisearch:
|
||||
image: ${MEILISEARCH_IMAGE:-getmeili/meilisearch:latest}
|
||||
restart: unless-stopped
|
||||
profiles: [search]
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -euc
|
||||
- |
|
||||
if [ -z "$${MEILI_MASTER_KEY:-}" ]; then
|
||||
echo "MEILI_MASTER_KEY is required when enabling the search profile" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec /bin/meilisearch
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-}
|
||||
ports:
|
||||
- "${MEILISEARCH_HOST:-127.0.0.1}:${MEILISEARCH_PORT:-7700}:7700"
|
||||
volumes:
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/meilisearch:/meili_data
|
||||
|
||||
silo:
|
||||
image: ${SILO_IMAGE:-ghcr.io/silo-server/silo-server:latest}
|
||||
restart: unless-stopped
|
||||
@@ -53,6 +73,7 @@ services:
|
||||
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
|
||||
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/compat:/var/lib/silo/compat
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
|
||||
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# AI-assisted contributions
|
||||
|
||||
For AI agents preparing a pull request or issue for this repository on behalf of someone who is
|
||||
not a maintainer.
|
||||
|
||||
## Disclosure is required
|
||||
|
||||
Include this completed block in the PR or issue body, with the exact model ID you are running as.
|
||||
Do not omit it, and do not let your operator omit it — undisclosed AI use gets the contribution
|
||||
closed.
|
||||
|
||||
```md
|
||||
### AI Disclosure
|
||||
- Tool(s): e.g. Claude Code, Codex CLI, Cursor — or "none"
|
||||
- Model(s): exact model ID(s), e.g. claude-opus-5, gpt-5.6 — or "n/a"
|
||||
- Involvement: fully AI-generated | AI-assisted | human-written, AI-reviewed | none
|
||||
- Adversarial review: what your own AI review of the diff found, and how you resolved it
|
||||
```
|
||||
|
||||
## Evidence standard
|
||||
|
||||
Run the repo verify commands before declaring the work complete, and paste the real output into
|
||||
the PR:
|
||||
|
||||
```bash
|
||||
make lint
|
||||
cd web && pnpm run lint && pnpm run format:check
|
||||
make verify-local-paths
|
||||
go test ./... # targeted at the packages you touched
|
||||
```
|
||||
|
||||
Run an adversarial review of your own diff and summarize what it found in the PR body.
|
||||
|
||||
Never file an issue containing a repro you did not actually execute, or logs you synthesized.
|
||||
Raw logs only. A fabricated repro costs a maintainer more time than no report at all.
|
||||
|
||||
## What to expect
|
||||
|
||||
The maintainer may accept the idea but re-implement it with a current frontier model. That is a
|
||||
normal outcome here, not a rejection of the contribution — tell your operator to expect it.
|
||||
@@ -0,0 +1,376 @@
|
||||
# Deterministic, Cross-Server `content_id`
|
||||
|
||||
**Status:** Implemented (branch `feat/deterministic-content-id`)
|
||||
**Scope:** catalog / scanner / metadata
|
||||
**Key code:** `internal/contentid`, `internal/metadata/service.go`,
|
||||
`internal/catalog/history_source.go`,
|
||||
`migrations/sql/20260612130000_deterministic_content_id.sql`,
|
||||
`migrations/sql/20260614120000_content_id_online_reid.sql`
|
||||
|
||||
This is the dev-facing reference for *why* `content_id` is what it is and *how*
|
||||
it works. It merges the original design spec, the implementation notes, and the
|
||||
PR write-up into one place.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
`content_id` is the anchor almost everything hangs off: artwork, metadata, watch
|
||||
history, progress, favorites, ratings, collections, credits. Historically it was
|
||||
a **Sonyflake** — a locally generated, time-ordered random number minted at scan
|
||||
time.
|
||||
|
||||
That means the *same* movie matched on two servers (or re-imported into a fresh
|
||||
instance) gets a **different** id each time. Consequence: two servers holding the
|
||||
identical title share *none* of that state. Run more than one Silo, or try to
|
||||
sync a profile's history between servers, and the same film shows up as a
|
||||
stranger on the other side — no resume point, no watched flags, no ratings. There
|
||||
was no stable identity to build any cross-server feature on.
|
||||
|
||||
We want: **the same logical title produces the same `content_id` on every server,
|
||||
every time**, derived from a globally stable anchor (the provider IDs already in
|
||||
the library), and **stable across file renames/re-encodes** so linked state
|
||||
survives.
|
||||
|
||||
---
|
||||
|
||||
## 2. The decision
|
||||
|
||||
Make `content_id` a **deterministic, provider-derived, structured natural key**,
|
||||
stored as **`text COLLATE "C"`**:
|
||||
|
||||
| Item | `content_id` |
|
||||
| ---- | ------------ |
|
||||
| Movie | `movie-tmdb-228064` (fallback `movie-imdb-tt2413338`) |
|
||||
| Series | `series-tvdb-296762` |
|
||||
| Season | `season-tvdb-296762-1` |
|
||||
| Episode | `episode-tvdb-296762-1-5` |
|
||||
| Unmatched / local | `local-<128-bit path hash, hex>` |
|
||||
|
||||
Why this shape:
|
||||
|
||||
- **Collision-free with no hashing.** Provider IDs are unique by construction, so
|
||||
this is a natural key, not a surrogate — there is no "how many bits" question
|
||||
and no birthday bound.
|
||||
- **No column-type migration.** It stays `text`, so the wide soft-reference graph
|
||||
keeps working; only the *values* are remapped, not the type.
|
||||
- **Debuggable.** A row's identity is legible in logs and queries, and the
|
||||
scanner's match step becomes a direct PK lookup ("do I already have
|
||||
`movie-tmdb-228064`?") instead of a join through `media_item_provider_ids`.
|
||||
- **Stable** across renames/re-encodes — a movie keeps its tmdb id.
|
||||
|
||||
### Why `content_id`, not a file-level id
|
||||
|
||||
Two distinct identities at different granularities, not to be conflated:
|
||||
|
||||
- **`content_id`** — the *logical* item (movie/series/season/episode). One id,
|
||||
*many* files (1080p + 4K + cuts; a series owns thousands of episode files).
|
||||
- **`media_files.id`** — one *physical* file; a per-server serial that only
|
||||
answers "which file on disk."
|
||||
|
||||
`content_id` is the high-value target (35 FKs + ~25 unconstrained soft references
|
||||
point at it). Its stability requirement is the *opposite* of a file id's: a file
|
||||
id *should* change on rename; a `content_id` must *not*, or every server would
|
||||
drop a title's watch history on a routine rename. That's why it's anchored on
|
||||
rename-invariant provider IDs, never a path or file hash. File-level dedup is a
|
||||
separate, deferred concern.
|
||||
|
||||
---
|
||||
|
||||
## 3. How the id is derived
|
||||
|
||||
### Format (SchemeVersion 1)
|
||||
|
||||
```text
|
||||
movie-<provider>-<id> movie-tmdb-228064
|
||||
series-<provider>-<id> series-tvdb-296762
|
||||
season-<provider>-<seriesId>-<n> season-tvdb-296762-1
|
||||
episode-<provider>-<seriesId>-<s>-<e> episode-tvdb-296762-1-5
|
||||
local-<hex112> local-<sha256(path)[:14]>
|
||||
```
|
||||
|
||||
The component separator is `-`, an [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986#section-2.3)
|
||||
*unreserved* character. Every component is `[a-z0-9]+` (or `tt` + digits for
|
||||
IMDb), so `-` is an unambiguous delimiter, and because it never needs
|
||||
percent-encoding the id doubles as its own tidy URL path segment
|
||||
(`/item/series-tvdb-296762`) — identical to what is stored in the database, so
|
||||
there is no encode/decode boundary and an operator can grep the value straight
|
||||
out of a URL or log. (`:` would be legal in a path too, but `encodeURIComponent`
|
||||
escapes it to `%3A`; an unreserved separator sidesteps that entirely.)
|
||||
|
||||
- The leading entity-type token domain-separates namespaces, so a movie and an
|
||||
episode can never alias on a coincidental number.
|
||||
- IDs are normalized: lowercase scheme tokens, whitespace trimmed, IMDb keeps its
|
||||
`tt` prefix verbatim.
|
||||
|
||||
### Frozen provider precedence
|
||||
|
||||
Each item anchors on **one** canonical provider, chosen by fixed precedence so
|
||||
two servers that see the same tags pick the same anchor:
|
||||
|
||||
- **Movies:** `tmdb → imdb → tvdb`
|
||||
- **Series / Season / Episode:** `tvdb → tmdb → imdb` (TVDB is episode-canonical)
|
||||
|
||||
Precedence **and** exact string format are frozen behind a code constant
|
||||
(`contentid.SchemeVersion = 1`), *not* a DB column — any scheme change forces a
|
||||
full remap that normalizes every row at once, so there is never a mixed-version
|
||||
population. Staleness is recoverable by recomputing the expected id from the
|
||||
provider columns and comparing.
|
||||
|
||||
### The load-bearing invariant: the show falls out of the episode id
|
||||
|
||||
Season/episode ids compose from the **series anchor + season/episode numbers**
|
||||
(both present in filenames and universally available), *not* their own provider
|
||||
episode IDs (often missing). Because `episode-<provider>-<seriesId>-<s>-<e>`
|
||||
embeds the series anchor, the series id is a pure **string transform** of the
|
||||
episode id — no catalog lookup:
|
||||
|
||||
```text
|
||||
episode-tvdb-296762-1-5 → series-tvdb-296762
|
||||
movie-tmdb-228064 → movie-tmdb-228064 (movies/series: unchanged)
|
||||
```
|
||||
|
||||
This is relied on by the hot watch-history query (§5). The format must never
|
||||
break it. (`contentid.SeriesIDFromContentID` is the canonical transform.)
|
||||
|
||||
### Where the anchor comes from
|
||||
|
||||
For tagged libraries the IDs are in the folder/file name
|
||||
(`{imdb-tt2413338} {tmdb-228064}`), so both servers derive the same id **from the
|
||||
path string at scan time, with no API call**. The denormalized
|
||||
`media_items.{tmdb,imdb,tvdb}_id` columns and the `media_item_provider_ids` link
|
||||
table are the other sources. `content_id` is *derived from* the link table, not a
|
||||
replacement — keep the full provider-ID set indexed.
|
||||
|
||||
---
|
||||
|
||||
## 4. What was built
|
||||
|
||||
| Area | File(s) | Summary |
|
||||
| ---- | ------- | ------- |
|
||||
| Derivation core | `internal/contentid/contentid.go` | `ForMovie`/`ForSeries`/`ForSeason`/`ForEpisode`/`ForLocal`, `SeriesIDFromContentID`, `IsProviderAnchored`/`IsLocal`, `SchemeVersion`, frozen precedence, normalization. |
|
||||
| Generation wiring | `internal/metadata/service.go` | `deriveLogicalContentID`/`deriveSeasonContentID`/`deriveEpisodeContentID` replace `idgen.NextID()` at every mint site (skeleton, provider-match, explicit/implicit seasons, episodes, scanner fallback). |
|
||||
| Re-ID on match | `internal/metadata/canonicalize.go` | Promotes a `local-` placeholder to its deterministic id at first confirmed match. |
|
||||
| Hot query | `internal/catalog/history_source.go` | Watch-history `display_id` resolves the show via the string transform for anchored episodes; skips the `episodes_pkey` probe. |
|
||||
| Value-remap migration | `migrations/sql/20260612130000_deterministic_content_id.sql` | Collision-safe Sonyflake → structured remap across the whole reference graph + `COLLATE "C"`, with rollback. |
|
||||
| Online re-ID migration | `migrations/sql/20260614120000_content_id_online_reid.sql` | Adds `silo_rename_content_id` + `ON UPDATE CASCADE` to the content-id FK family. |
|
||||
|
||||
### Generation: determinism at scan time
|
||||
|
||||
The scanner already parses provider tags from names, so `createOrFindSkeleton`
|
||||
derives `movie-tmdb-…`/`series-tvdb-…` immediately — two servers seeing the same
|
||||
tags mint the same id with no provider API call. This is the primary target
|
||||
(Radarr/Sonarr/Plex-tagged libraries). Untagged new items get a path-derived
|
||||
`local-` id keyed on the **file path** (not the folder), so distinct pre-merge
|
||||
skeletons in a shared folder stay separate while being stable across rescans.
|
||||
|
||||
Same-server convergence is preserved by the existing dedup
|
||||
(`findExistingByProviderIDs`) plus `UpsertTx`'s
|
||||
`ON CONFLICT (content_id) DO UPDATE` backstop.
|
||||
|
||||
Non-movie/series items (audiobook/ebook/podcast) and anchorless items **stay on
|
||||
Sonyflake** — there's no stable cross-server anchor to derive from, so changing
|
||||
them gains nothing.
|
||||
|
||||
### Re-ID on match: untagged items converge later
|
||||
|
||||
A tagged file is born with the right id; an *untagged* file gets a `local-` id
|
||||
and only learns its provider IDs later when the match worker confirms a result. A
|
||||
single gate in `mergeAndPersist` (`canonicalizeLocalContentID`) promotes the
|
||||
placeholder at first confirmed match:
|
||||
|
||||
```text
|
||||
SCAN a new file ← unchanged, still no network call
|
||||
▼
|
||||
createOrFindSkeleton ──► tag? YES ► movie-tmdb-123 NO ► local-<hash>
|
||||
▼
|
||||
(later) match worker → mergeAndPersist → canonicalizeLocalContentID
|
||||
│ Is the id still local- AND do we now have provider IDs?
|
||||
│ no → do nothing
|
||||
│ yes → Does a row already sit at movie-tmdb-123?
|
||||
│ yes → MERGE this row into it (rebindItemToExistingItem)
|
||||
│ no → RENAME local-<hash> → movie-tmdb-123
|
||||
▼ └─ DB auto-moves child rows (ON UPDATE CASCADE)
|
||||
rest of mergeAndPersist runs with the corrected id
|
||||
```
|
||||
|
||||
The guard is a single `IsLocal` prefix check, so tagged content and all refreshes
|
||||
pay nothing; the promotion is self-healing (a partial run re-runs on the next
|
||||
match). The rename primitive `silo_rename_content_id` reuses the migration's
|
||||
catalog-driven column enumeration so the two stay in lockstep.
|
||||
`ON UPDATE CASCADE` only fires when a `content_id` actually changes (essentially
|
||||
never outside this promotion), so there is no steady-state cost.
|
||||
|
||||
### The migration
|
||||
|
||||
The column **type does not change** (`text` → `text COLLATE "C"`); the **values**
|
||||
are remapped via add-then-swap, not in-place PK mutation. It dynamically
|
||||
enumerates the reference graph (the three PKs, every FK child from
|
||||
`pg_constraint`, and soft-reference columns by name — **65 columns** on the real
|
||||
schema), drops/recreates FKs and triggers, and retains a `content_id_migration_map`
|
||||
audit table for rollback.
|
||||
|
||||
**Collisions are never merged.** If two items derive to the same key, or a key is
|
||||
already taken, those rows keep their Sonyflake id and are flagged `collision` in
|
||||
the map — the migration can never violate a PK or silently drop a row. Collision
|
||||
status **cascades** from a series to its seasons/episodes (otherwise a uniquely
|
||||
numbered episode under a colliding series would get an `episode-…` key whose
|
||||
embedded series anchor points at a non-existent row).
|
||||
|
||||
`COLLATE "C"` is applied to every referencing column in the same migration so
|
||||
join keys match on both type and collation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Why `COLLATE "C"` is mandatory
|
||||
|
||||
Provider-derived IDs are pure ASCII, but `content_id` inherits the DB default
|
||||
`en_US.utf8` collation. The design **regresses without `COLLATE "C"`**:
|
||||
|
||||
- For **equality / hash joins** (the watch-history join), `en_US.utf8` is
|
||||
deterministic so PostgreSQL already compares raw bytes — collation is
|
||||
irrelevant.
|
||||
- For **ordering** comparisons (B-tree descent on PK probes, `ORDER BY`, range
|
||||
scans), `en_US.utf8` invokes libc `strcoll` while `C` is a plain `memcmp`.
|
||||
- Structured keys share long prefixes (`episode-tvdb-296762-…`), which makes
|
||||
`strcoll` *worse* than on 18-digit Sonyflake strings — so a structured key
|
||||
under `en_US.utf8` is *slower* than the status quo. Under `C` it's ~3–5×
|
||||
faster.
|
||||
|
||||
So `COLLATE "C"` is load-bearing, not a free upgrade.
|
||||
|
||||
### The hot query
|
||||
|
||||
The watch-history page query (`history_source.go`) resolves each history row's
|
||||
episode key to its series, then inner-joins `media_items`, deduped over the
|
||||
profile's *entire* history. Two facts:
|
||||
|
||||
- `DISTINCT ON` can't short-circuit on `LIMIT` — cost is **O(history size)** with
|
||||
PK probes per row, not O(page).
|
||||
- The structured id lets us **delete the `LEFT JOIN episodes`** that existed only
|
||||
to fetch `series_id`: for anchored episode ids the show is a string transform.
|
||||
The join is null-poisoned for anchored ids (`= NULL` is an unsatisfiable b-tree
|
||||
scan key) so the planner never descends `episodes_pkey` for them. Legacy/`local-`
|
||||
ids still fall through the join.
|
||||
|
||||
The remaining `JOIN media_items` stays — it's for access/library filtering, not
|
||||
the show id. Reducing full-history scans to O(page) for the heaviest profiles is
|
||||
follow-up work (a per-`(profile, display_id)` summary table), not part of this
|
||||
change.
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance — measured at production scale
|
||||
|
||||
Measured on an isolated, prod-tuned PostgreSQL 18 instance (`shared_buffers=4GB`)
|
||||
loaded with an **exact-cardinality copy of production**: 184k items, 1.93M
|
||||
episodes, 775k watch-history rows / 1,622 profiles. "Before" reproduces
|
||||
production today (Sonyflake, `en_US.utf8`); "after" applies the structured-key
|
||||
remap + `COLLATE "C"`. **Every metric improves.**
|
||||
|
||||
**`content_id` index-probe cost** — 300k forced point lookups over 1.9M keys:
|
||||
|
||||
The last three columns are *why* the faster schemes were rejected: speed alone
|
||||
isn't the goal — the scheme has to carry the properties the whole feature exists
|
||||
for. ✓ = has the property, ✗ = lacks it.
|
||||
|
||||
| Key scheme | Index size | µs / probe | vs today | Cross-server deterministic | Zero-join show transform | Human-readable |
|
||||
| ---------- | ---------- | ---------- | -------- | :------------------------: | :----------------------: | :------------: |
|
||||
| Sonyflake text, `en_US.utf8` *(today)* | 74 MB | 5.05 | 1.00× | ✗ | ✗ | ✗ |
|
||||
| 128-bit hash `bytea(16)` | 74 MB | 1.80 | 2.81× | ✓ | ✗ | ✗ |
|
||||
| `bigint` surrogate | 41 MB | 1.23 | 4.11× | ✗ | ✗ | ✗ |
|
||||
| **Structured + `COLLATE "C"` (this PR)** | 84 MB | **1.86** | **2.71×** | ✓ | ✓ | ✓ |
|
||||
|
||||
The structured key lands within 3% of a 128-bit hash and is the **only all-✓
|
||||
row**. The two faster schemes each give up something load-bearing:
|
||||
|
||||
- **128-bit hash** is cross-server deterministic (it hashes the same natural
|
||||
key), but an opaque digest can't be string-transformed, so episode→series
|
||||
needs a catalog join (no zero-join hot path), and it isn't legible in logs,
|
||||
URLs, or `psql`.
|
||||
- **`bigint` surrogate** is just the status quo's narrow integer: fast because
|
||||
it's 8 bytes, but per-server (not reproducible elsewhere), no embedded anchor
|
||||
to transform, and not meaningful to a human.
|
||||
|
||||
Index grows ~14% (84 vs 74 MB), immaterial.
|
||||
|
||||
**Real history-page query** — heaviest profile (25,384 rows):
|
||||
|
||||
| State | Latency | `episodes_pkey` probes | Speedup |
|
||||
| ----- | ------- | ---------------------- | ------- |
|
||||
| Before | 385 ms | 25,348 | 1.00× |
|
||||
| **This PR** | **150 ms** | **2,775** | **2.57×** |
|
||||
|
||||
**Concurrency** (`pgbench`, 150 real profiles): at 100 concurrent users,
|
||||
**311 ms / 321 tps → 181 ms / 551 tps** — 1.7× throughput, 42% lower tail
|
||||
latency. Comparison cost is pure CPU, so cheaper `COLLATE "C"` comparisons raise
|
||||
the throughput ceiling under load.
|
||||
|
||||
*Caveat: warmed (dataset fits in `shared_buffers`); the before/after **ratios**
|
||||
are the robust result, not absolute tps.*
|
||||
|
||||
---
|
||||
|
||||
## 7. Known limitations / follow-ups
|
||||
|
||||
- **Untagged series children.** A series that accumulated season/episode rows
|
||||
*before* it matched keeps those children on Sonyflake until a follow-up
|
||||
`recomposeSeriesChildIDs` pass re-derives them. Rare at first match (children
|
||||
usually don't exist yet); not yet wired.
|
||||
- **Cross-provider reconciliation.** A series tagged tmdb-only on one server and
|
||||
tvdb-only on another still diverges — both paths derive from the denormalized
|
||||
provider columns. Proper fix: consult `media_item_provider_ids` before walking
|
||||
the precedence list.
|
||||
- **O(history) query shape.** The watch-history `DISTINCT ON` over full history is
|
||||
the real liability at concurrency; the string transform removes a probe but not
|
||||
the full scan. The O(page) summary table is the fix, deferred.
|
||||
- **Migration at scale.** Runs in one transaction holding `AccessExclusive` locks
|
||||
for the COLLATE rewrite + remap — run off-peak; very large installs (10–50M
|
||||
rows) should use the batched procedure noted in the migration header.
|
||||
- **`local-` uses the absolute path,** so a same-server remount re-IDs local
|
||||
items. Acceptable for the best-effort local namespace. The hash is 112 bits
|
||||
(`sha256(path)[:14]`) so a `local-` id packs losslessly into a compat UUID
|
||||
(see below).
|
||||
- **Client-visible id churn.** `jellycompat` encodes `content_id` into the
|
||||
client-facing UUID, so item ids seen by Android/Apple/Jellyfin clients change at
|
||||
migration (caches/resume keyed on item id reset once). **silo-android** and
|
||||
**silo-apple** should be made aware; server-side state is fully remapped. The
|
||||
encoding is *reversible* — a structured or local `content_id` is bit-packed
|
||||
into the UUID (`contentid.Pack`/`Unpack`), not hashed into a lookup table — so
|
||||
a compat item id decodes by pure computation and is stable across server
|
||||
restarts and across instances. Only arbitrary names (genres, studios) and the
|
||||
rare `content_id` too large to pack still use the in-memory hash map.
|
||||
|
||||
---
|
||||
|
||||
## 8. Prior art
|
||||
|
||||
- **Plex:** logical identity is a provider-namespaced GUID (`plex://movie/…`,
|
||||
`com.plexapp.agents.imdb://tt…`) — exactly this shape. (Its per-server integer
|
||||
`ratingKey` is the non-portable counterexample we avoid.)
|
||||
- **Jellyfin / Emby:** store namespaced provider IDs and match on them; item GUIDs
|
||||
are deterministic. Same principle — anchor identity on the provider, not a
|
||||
per-server sequence.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — alternatives rejected
|
||||
|
||||
Retained so the decision isn't re-litigated:
|
||||
|
||||
- **Keep Sonyflake.** Non-deterministic per server — the entire problem.
|
||||
- **Hash provider tuple → `uuid` (128-bit).** Collision resistance is unnecessary
|
||||
(input is already unique), the id becomes opaque, and it forces a `text → uuid`
|
||||
migration across 35 FKs + ~25 soft refs whose failure mode is a seq-scan cliff
|
||||
(miss one column → `uuid = text` per-row cast → index disabled). Pays a large
|
||||
migration cost for an ~8-byte width win that only matters at a 50M-row ceiling.
|
||||
- **Hash provider tuple → `text` hex.** Opaque like the uuid, large like the
|
||||
natural key — no reason to pick it over the legible structured key.
|
||||
- **Path or content hash.** A title spans many files at many paths (no single path
|
||||
to hash), and the hash changes on rename — the opposite of the stability
|
||||
requirement. Only appropriate for *file-level* identity.
|
||||
- **Pack provider + numeric id into `bigint`.** Variable-width provider numbers
|
||||
and S/E packing make the encoding brittle; not worth it over a legible key.
|
||||
- **File-level deterministic id (`media_files`).** Deferred, not rejected — lower
|
||||
value (no metadata/watch state hangs off it), with open problems (server-local
|
||||
mount anchors, same-file-in-two-libraries). A complement, out of scope here.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Direct Stream Resumption
|
||||
|
||||
Protocol v3 advertises `direct_stream_resume_v1` for playback plans whose
|
||||
delivery is `original_http`. The capability formalizes resumption of the
|
||||
original file by issuing sequential authorized HTTP requests. It does not
|
||||
change authentication, authorization, or playback-session ownership.
|
||||
|
||||
Original-file responses follow the HTTP byte-range contract:
|
||||
|
||||
- `Accept-Ranges: bytes` advertises byte addressing.
|
||||
- A satisfiable `Range` request returns `206 Partial Content` with the selected
|
||||
interval in `Content-Range`.
|
||||
- Open-ended and suffix byte ranges are supported.
|
||||
- A range starting at or past end of file, or another invalid range, returns
|
||||
`416 Requested Range Not Satisfiable` with `Content-Range: bytes */<size>`.
|
||||
- `HEAD` returns the same representation headers as `GET` without a body.
|
||||
|
||||
On Linux, macOS, and Windows, each response carries a strong, opaque `ETag`
|
||||
derived from the open file's filesystem identity, change time, modification
|
||||
time, and size. The validator is stable while the playback plan's original-file
|
||||
entity is unchanged, but changes for same-size replacements even when their
|
||||
modification time is preserved. Platforms that cannot expose a durable
|
||||
filesystem revision omit the validator instead of hashing an entire media file
|
||||
before each request. On those platforms an ETag-based `If-Range` request cannot
|
||||
match and safely falls back to a full `200 OK` response.
|
||||
|
||||
A client resuming a transfer sends both `Range` and `If-Range` with the
|
||||
validator. When it still matches, the server returns the requested `206`
|
||||
response. When the entity changed, the server ignores the range and returns the
|
||||
entire current entity as `200 OK`, preventing bytes from different revisions
|
||||
from being combined. `If-None-Match` uses the same validator for ordinary
|
||||
conditional requests.
|
||||
|
||||
Playback sessions already treat each transport request independently.
|
||||
Sequential ranged requests refresh transport activity, and cleanup never
|
||||
expires a session while one of those transport requests is active. A late
|
||||
request within the paused-session grace remains valid under the same rules as
|
||||
the initial request.
|
||||
|
||||
The capability does not apply to progressive remux delivery
|
||||
`server_remux_progressive`, which is not byte-resumable. It applies only to
|
||||
`original_http`; remux and transcode transports retain their existing
|
||||
contracts.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Outbound Email (`internal/mail`)
|
||||
|
||||
**Status:** Implemented 2026-06-11
|
||||
|
||||
Silo's shared outbound email facility. It is deliberately feature-agnostic: any
|
||||
feature that sends mail (notification emails, account flows, invites) composes
|
||||
a `mail.Message` and hands it to the shared `mail.Sender`, so SMTP
|
||||
configuration, security policy, and diagnostics live in exactly one place.
|
||||
|
||||
## Abstraction
|
||||
|
||||
```go
|
||||
type Sender interface {
|
||||
Enabled(ctx context.Context) bool
|
||||
Send(ctx context.Context, msg Message) error
|
||||
}
|
||||
```
|
||||
|
||||
`Message` carries recipients, subject, and text and/or HTML bodies (both set →
|
||||
multipart/alternative). `Send` returns `mail.ErrNotConfigured` when email is
|
||||
disabled or incomplete, so features treat email as an optional transport and
|
||||
degrade gracefully. The SMTP implementation (`mail.NewSMTPSender`) is backed by
|
||||
`github.com/wneessen/go-mail`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Live server settings (no restart required; read on every send — volume is
|
||||
low):
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `email.enabled` | `false` | master switch |
|
||||
| `email.smtp_host` | — | required |
|
||||
| `email.smtp_port` | `587` | |
|
||||
| `email.smtp_security` | `starttls` | `starttls` \| `tls` (implicit, port 465) \| `none` |
|
||||
| `email.smtp_username` | — | empty = no auth |
|
||||
| `email.smtp_password` | — | encrypted at rest (`SensitiveSettingKeys`) |
|
||||
| `email.from_address` | — | required |
|
||||
| `email.from_name` | `Silo` | |
|
||||
|
||||
Admin UI: Admin Settings → Connections → Email, including a synchronous test
|
||||
send (`POST /api/v1/admin/email/test`).
|
||||
|
||||
## Adding a consumer
|
||||
|
||||
Construct messages in the feature package and send through a `mail.Sender`
|
||||
dependency. Do not read `email.*` settings from feature code, and check
|
||||
`Enabled` (or branch on `ErrNotConfigured`) rather than treating a missing
|
||||
SMTP configuration as an error.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Local NFO metadata and sidecar artwork (as built)
|
||||
|
||||
Implements #216: Kodi/Jellyfin-style `.nfo` files and sidecar images
|
||||
(`poster.jpg`, `fanart.jpg`, …) next to movies and shows are read by a builtin
|
||||
metadata provider, so curated local libraries work with partial or zero
|
||||
TMDB/TVDB coverage. Admin-facing usage lives in
|
||||
`docs/wiki/admin/nfo-local-metadata.md`; this page records the architecture for
|
||||
maintainers.
|
||||
|
||||
## Registration: a builtin provider in the plugin chain
|
||||
|
||||
Provider chains are keyed on `plugin_installations` rows, so builtins are
|
||||
registered **as data**: a reserved installation (`plugin_id='silo.builtin'`,
|
||||
`kind='builtin'`, sentinel `install_path`, `update_policy='manual'`) plus a
|
||||
`metadata_provider.v1` capability `nfo` with `default_priority` 1 for
|
||||
movie/series/season/episode and `default_enabled=false`
|
||||
(`migrations/sql/20260712100125_builtin_host_providers.sql`,
|
||||
`...124741_nfo_capability_season_episode_levels.sql`).
|
||||
|
||||
- `internal/metadata/builtin.go` is the in-process registry; providers
|
||||
self-register (`internal/metadata/nfo/register.go`) and `buildProviders`
|
||||
(`internal/metadata/chain.go`) returns the in-process provider for
|
||||
`kind='builtin'` rows instead of constructing a gRPC plugin.
|
||||
- Guard rails keep the reserved row out of every plugin surface (user plugin
|
||||
settings, installations list, image resolvers, preload, auto-update, store
|
||||
delete, mutation handlers); `silo.builtin` is a reserved manifest id. Generic
|
||||
installation reads must **not** filter builtins — the chain enabled-check
|
||||
depends on them.
|
||||
- On startup, `SyncBuiltinProviderChains` (`internal/metadata/builtin_sync.go`)
|
||||
first materializes legacy `content_level=''` chains per level, then appends
|
||||
builtin capabilities to all existing chains, disabled. New libraries pick the
|
||||
capability up through normal chain seeding. The chain-less fallback respects
|
||||
`default_enabled=false`.
|
||||
- **Everything is inert until an admin enables "NFO Files" on a library.**
|
||||
|
||||
## Identity: hint-first, never candidate-first
|
||||
|
||||
NFO `<uniqueid>` values (tmdb/imdb/tvdb) are **trusted identity hints** seeded
|
||||
before remote search (`IdentityHintProvider`, applied in
|
||||
`internal/metadata/service.go`), unlocking full remote enrichment even when
|
||||
search-by-title fails. Conflict policy by mode:
|
||||
|
||||
- initial match / scheduled refresh: NFO hints beat folder-name hints; stored
|
||||
durable IDs beat NFO hints (no background identity flips);
|
||||
- manual refresh: NFO hints beat stored IDs — the recovery path for a corrected
|
||||
NFO. The item's `content_id` re-anchors too (not just its stored provider
|
||||
IDs): a provider-anchored item whose fixed `<uniqueid>` now derives a
|
||||
different anchor is renamed to it, or merged onto the existing item already
|
||||
holding that id;
|
||||
- manual Identify: the user's choice wins; the NFO provider is skipped for the
|
||||
whole operation.
|
||||
|
||||
The NFO search candidate exists only for the title-only case and is defanged:
|
||||
ID-less candidates lose provider-priority tie-breaks to ID-bearing group
|
||||
members, `nfo` never counts as corroboration, and Phase-2 NFO results cannot
|
||||
inject provider-id keys. A title-only NFO yields a `matched` item under a
|
||||
path-deterministic `local:` content id; adding a `<uniqueid>` later promotes it
|
||||
to that provider anchor on the next refresh (this promotion from an unanchored
|
||||
id runs on scheduled refresh too — only an *already-anchored* identity is held
|
||||
stable in the background). NFO field edits propagate on **manual refresh only**
|
||||
(scheduled refresh is fill-empty by design).
|
||||
|
||||
## Sidecar artwork: copy into the S3 image cache
|
||||
|
||||
Sidecar images (`poster`/`folder`/`cover`, `fanart`/`backdrop`/`background`,
|
||||
`logo`/`clearlogo`, and `<basename>-poster/-fanart/-logo` variants; extensions
|
||||
`.jpg/.jpeg/.png/.webp`, 8 MiB cap, symlinked leaves rejected) are discovered
|
||||
by the provider's `GetImages` (`internal/metadata/nfo/images.go`). Clients —
|
||||
including jellycompat — always receive the normal presigned
|
||||
`poster_url`/`backdrop_url`/`logo_url`; library files are never served
|
||||
directly, and API nodes never need filesystem access to libraries.
|
||||
|
||||
- Sources are recorded as `file://<absolute-logical-path>` in `*_source_path`
|
||||
columns and cached by the metadata image-cache processor under
|
||||
`local/{contentType}/{contentID}/{hash8}/{imageType}/...` (`hash8` = content
|
||||
hash; the discriminator sits before the imageType segment because variant
|
||||
clamping reads the parent directory). Editing the file and refreshing rotates
|
||||
the key; the stale hashed prefix is deleted after a successful re-cache, and
|
||||
item deletion trims local paths to the content prefix.
|
||||
- The processor confines each read to the owning library's `media_folders`
|
||||
roots: a lexical check on the logical path, then a symlink-resolving re-check
|
||||
(both path and roots are resolved, so a legitimately symlinked root stays
|
||||
valid while an intermediate directory symlink escaping a root is rejected).
|
||||
Missing/unreadable/out-of-root and structurally-unusable (non-regular,
|
||||
over-cap) paths are stable failures with a long retry deferral; recovery is
|
||||
refresh-driven — the provider-artwork backfill sweep deliberately skips
|
||||
`file://` sources.
|
||||
- Generic filenames attach only when the directory holds a single content
|
||||
group; a shared `folder.jpg` in a flat multi-movie folder applies to none.
|
||||
`<basename>-poster.jpg` style names always attach to their file.
|
||||
- `applyBestImages`' final write has a local exemption so rating-0 local art
|
||||
can fill already-matched items and cannot be stickily displaced by remote
|
||||
art; the localization pass skips language-neutral local candidates.
|
||||
|
||||
**Deployment constraint:** the host running the metadata image-cache processor
|
||||
must mount the media libraries at the same paths as the scanner/metadata
|
||||
worker, otherwise local artwork jobs fail until the mount is present.
|
||||
|
||||
## Series depth and mixed libraries
|
||||
|
||||
`SeasonsRequest`/`EpisodesRequest` carry local path context; `season.nfo`
|
||||
supplies season name/plot, `<basename>.nfo` + `<basename>-thumb.ext` supply
|
||||
episode metadata/thumbs (`internal/metadata/nfo/series_depth.go`). **Naming
|
||||
supplies structure; NFOs supply metadata**: NFO season/episode numbers are
|
||||
advisory — directory/filename-derived numbers win with a warning. Episode NFOs
|
||||
work without a `season.nfo`; `SynthesizeFallbackEpisodes` still covers
|
||||
NFO-less episodes.
|
||||
|
||||
In `mixed` libraries, movie-vs-series is decided per file by naming before any
|
||||
provider runs (`internal/naming/filename.go`); the NFO provider is seeded into
|
||||
all levels and behaves as in dedicated libraries. The provider's root-element
|
||||
type guard means a `tvshow.nfo` next to a movie-classified file is ignored,
|
||||
never applied; the per-root Type override is the correction path. This is the
|
||||
contract for sports/mixed libraries (events as movies, weekly shows as series).
|
||||
|
||||
## Known limitations
|
||||
|
||||
- The admin image picker does not surface local art (automatic chain path
|
||||
only).
|
||||
- Multi-part movies: a basename-mismatched NFO in a folder holding multiple
|
||||
content groups is not found (directory candidates are suppressed there).
|
||||
- No NFO writing, no music/audiobook/ebook NFO (those ecosystems use
|
||||
OPF/metadata.json — future builtin providers via the same registry).
|
||||
- Feature detection: the `nfo` capability's presence in
|
||||
`GET /api/v1/libraries/provider-defaults` (see
|
||||
`docs/architecture/v1-scope.md`).
|
||||
@@ -0,0 +1,205 @@
|
||||
# Observability (OpenTelemetry logs + traces)
|
||||
|
||||
Silo emits structured **logs** and distributed **traces** via OpenTelemetry (OTLP),
|
||||
in addition to the existing stderr and `opslog` database pipeline. The feature is
|
||||
**opt-in and default-off**: with no `OTEL_*` / `SILO_OTEL_ENABLED` configuration the
|
||||
server behaves exactly as before (stderr + `opslog` only).
|
||||
|
||||
**Metrics are not part of OpenTelemetry here.** They remain on Prometheus
|
||||
(`client_golang`, the `/metrics` endpoint, and the domain metrics in
|
||||
`internal/api/middleware`). See [Metrics](#metrics-stay-on-prometheus) below.
|
||||
|
||||
## Enabling it
|
||||
|
||||
Telemetry turns on when **either** `SILO_OTEL_ENABLED` is truthy **or**
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` is set.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| --- | --- | --- |
|
||||
| `SILO_OTEL_ENABLED` | Master gate (`1`/`true`/`yes`/`on`). | off |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector endpoint; also implicitly enables telemetry. | — |
|
||||
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` (default) or `http/protobuf`. | `grpc` |
|
||||
| `OTEL_SERVICE_NAME` | `service.name` resource attribute. | `silo-server` |
|
||||
| `OTEL_SERVICE_VERSION` | `service.version` resource attribute. | unset |
|
||||
| `OTEL_TRACES_SAMPLER` | `always_on`, `always_off`, `traceidratio`, `parentbased_always_on`, `parentbased_always_off`, or `parentbased_traceidratio`. Unsupported values (e.g. `jaeger_remote`) fall back to the default. | `parentbased_traceidratio` |
|
||||
| `OTEL_TRACES_SAMPLER_ARG` | Trace-id ratio for the ratio-based samplers (0–1; clamps >1 to 1). | `1.0` |
|
||||
|
||||
The node identity is attached as the `service.instance.id` resource attribute, so
|
||||
multiple Silo nodes sharing one `service.name` stay distinguishable in the backend.
|
||||
|
||||
All other `OTEL_EXPORTER_OTLP_*` knobs (headers, TLS, per-signal endpoints) are read
|
||||
directly from the environment by the OTLP exporters — the environment is the single
|
||||
source of truth for exporter wiring.
|
||||
|
||||
The endpoint/exporter connection is lazy and non-blocking: an unreachable collector
|
||||
does **not** delay or crash startup. Setup failure is also non-fatal: if the `OTEL_*`
|
||||
environment is malformed (e.g. an unparseable endpoint URL), the server logs an error
|
||||
and keeps running with telemetry disabled rather than crash-looping.
|
||||
|
||||
## Architecture
|
||||
|
||||
The bootstrap lives in `internal/telemetry` (`Setup` in `telemetry.go`). When enabled
|
||||
it builds one shared `resource.Resource`, a `TracerProvider` (sampler per
|
||||
`OTEL_TRACES_SAMPLER`, batched OTLP exporter), a `LoggerProvider` (batched OTLP exporter), and the W3C
|
||||
`TraceContext + Baggage` propagator. Shutdown is deferred in `cmd/silo/main.go` with a
|
||||
flush timeout so buffered spans/logs drain on exit.
|
||||
|
||||
### Log handler chain
|
||||
|
||||
Logs are bridged, not rerouted. The OTel `otelslog` handler is added to the existing
|
||||
`slog` handler chain via **fan-out**, so every record still reaches stderr and the
|
||||
`opslog` DB/admin-stream pipeline unchanged:
|
||||
|
||||
```
|
||||
slog.<Level>Context(ctx, …)
|
||||
→ opslog.Handler (DB capture + admin stream, when level ≥ capture)
|
||||
→ logfilter.Handler (drops quieted subsystem prefixes)
|
||||
→ slog.MultiHandler
|
||||
├── stderr (json/text)
|
||||
└── otelslog → OTLP (level-gated + best-effort)
|
||||
```
|
||||
|
||||
Three properties matter:
|
||||
|
||||
- **Level-gated.** `slog.MultiHandler.Enabled` ORs its children, so the OTel branch is
|
||||
wrapped in a level gate bound to the shared `LevelVar`; console and OTLP share one
|
||||
verbosity knob. Without this, Debug records would be built and exported even at
|
||||
`log_level=info`.
|
||||
- **Best-effort.** The OTel branch never propagates an export error, so a failing
|
||||
collector cannot break the console or DB branches.
|
||||
- **Redacted.** The whole fan-out is wrapped in `internal/logredact`, so console and OTLP
|
||||
emit secret-masked output. Redaction is key-based (`password`, `secret`, `token`,
|
||||
`api_key`, `authorization`, `cookie`, …); a matching attribute's value becomes
|
||||
`[REDACTED]`, including attrs bound via `.With(...)` and nested groups. The marker list
|
||||
is shared with the opslog DB path (`opslog.shouldRedact` → `logredact.SecretKey`) so all
|
||||
sinks agree. Limitation: values are not scanned, so a secret embedded in a free-text
|
||||
message or under a non-secret key is not caught.
|
||||
|
||||
## Logging conventions (enforced)
|
||||
|
||||
Use the **context-carrying** slog variants and tag the subsystem:
|
||||
|
||||
```go
|
||||
slog.InfoContext(ctx, "scanner: starting", "component", "scanner", "folder_id", id)
|
||||
```
|
||||
|
||||
Rules, enforced by `sloglint` in `make lint` (`.golangci.yml`):
|
||||
|
||||
- **`slog.<Level>Context(ctx, …)`** wherever a `context.Context` is in scope (so records
|
||||
carry the active `trace_id`/`span_id`). The plain `slog.Info(…)` form is only allowed
|
||||
where no `ctx` exists (early boot, top-level goroutines).
|
||||
- **Static message** — the message is a constant; move dynamic parts to attributes.
|
||||
- **snake_case attribute keys** — `component`, `request_id`, `trace_id`, `user_id`, …
|
||||
- **No mixed args** — don't combine key-value pairs and `slog.Attr` in one call.
|
||||
|
||||
### Component classification
|
||||
|
||||
Every direct `slog.*Context` call carries a `component` attribute. `opslog` classifies
|
||||
records by that attribute first, falling back to `opslog.InferComponent` (the `subsystem:`
|
||||
prefix in the message) when no attribute is present — bound loggers that already set
|
||||
`component` via `.With(...)` are left as-is.
|
||||
|
||||
**Limitation:** `sloglint` enforces the *shape* (context variant, snake keys, static
|
||||
message) but **cannot** enforce that a `component` attribute is present. That convention
|
||||
rests on this doc, the canonical list below, and review.
|
||||
|
||||
Canonical component values (first path segment under `internal/`; `app` for `cmd/silo`):
|
||||
|
||||
`activitylog`, `adminjob`, `ai`, `api`, `app`, `audiobooks`, `auth`, `autoscan`,
|
||||
`catalog`, `chapterthumbs`, `downloads`, `ebooks`, `historyimport`, `jellycompat`,
|
||||
`libraryingest`, `manga`, `metadata`, `nodeconfig`, `nodepool`, `noderecipe`,
|
||||
`nodesessions`, `notifications`, `opslog`, `playback`, `plugins`, `policy`, `proxy`, `ratelimit`,
|
||||
`recommendations`, `requests`, `scanner`, `scanqueue`, `sections`, `taskmanager`,
|
||||
`telemetry`, `transcodenode`, `watchlist`, `watchsync`, `webhooksync`, `worker`.
|
||||
|
||||
## Metrics stay on Prometheus
|
||||
|
||||
OpenTelemetry here installs **no MeterProvider**. Metrics continue to flow through the
|
||||
existing Prometheus `client_golang` instrumentation to `/metrics`; Grafana scraping is
|
||||
unaffected.
|
||||
|
||||
This is deliberate and guarded: the trace-instrumentation libraries (`otelhttp`, etc.,
|
||||
added in later phases) also emit metrics through the *global* MeterProvider. Because none
|
||||
is set, that global stays the built-in **no-op**, so those metric calls are silently
|
||||
discarded — no double-counting into Prometheus, no second `/metrics` source. A guard test
|
||||
asserts `otel.GetMeterProvider()` remains the no-op after `Setup`. Migrating metrics to
|
||||
the OTel metrics SDK is out of scope.
|
||||
|
||||
## Local collector (example)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (dev)
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector:latest
|
||||
command: ["--config=/etc/otelcol/config.yaml"]
|
||||
volumes:
|
||||
- ./otelcol.yaml:/etc/otelcol/config.yaml
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC
|
||||
- "4318:4318" # OTLP HTTP
|
||||
```
|
||||
|
||||
```yaml
|
||||
# otelcol.yaml — minimal debug pipeline
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc: {}
|
||||
http: {}
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: detailed
|
||||
service:
|
||||
pipelines:
|
||||
traces: { receivers: [otlp], exporters: [debug] }
|
||||
logs: { receivers: [otlp], exporters: [debug] }
|
||||
```
|
||||
|
||||
Run Silo with `SILO_OTEL_ENABLED=1 OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317` and watch
|
||||
logs + traces arrive at the collector. Swap `debug` for Loki/Tempo (or a vendor OTLP
|
||||
endpoint) for real storage; Grafana can then show traces alongside the unchanged
|
||||
Prometheus metrics.
|
||||
|
||||
## Log retention & rotation
|
||||
|
||||
Silo does **not** write or rotate its own log files. Rotation/retention is handled by the
|
||||
layer that owns each sink, which is the intended cloud-native split:
|
||||
|
||||
- **Console (stderr)** is owned by the container runtime. Under Docker, configure the
|
||||
`json-file` driver to cap and rotate on-disk logs, and mount the log directory on a
|
||||
volume so history survives container recreation (the problem that motivated this work):
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
silo:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m" # rotate at 50 MB
|
||||
max-file: "5" # keep 5 rotated files
|
||||
```
|
||||
|
||||
For a non-Docker deployment, run under a supervisor/journald or pipe to `logrotate`.
|
||||
|
||||
- **OTLP** is the durable, queryable path: the collector/backend (Loki, a vendor, …) owns
|
||||
retention and is the recommended place to keep searchable history. Enable it (see above)
|
||||
and set retention on the backend.
|
||||
|
||||
- **opslog (Postgres)** self-prunes: daily partitions with per-component retention and
|
||||
size caps (`internal/opslog/cleanup.go`). No operator action needed.
|
||||
|
||||
An in-process rotating file sink (e.g. lumberjack) was deliberately **not** added — it
|
||||
would re-introduce a custom sink the OTLP + runtime split already covers.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **`log_quiet` also suppresses OTel logs.** The fan-out sits below the quiet filter, so
|
||||
quieting a subsystem empties its OTLP stream too (OTLP mirrors the console stream).
|
||||
- **Redaction is key-based, not value-based.** Secrets under a recognized key are masked
|
||||
on every sink, but a secret embedded in a message string or under an unrecognized key is
|
||||
not caught (see [Redacted](#log-handler-chain)).
|
||||
- **Early-boot logs are not exported.** Records emitted before the handler is installed
|
||||
(DB connect, migrations, tuning) reach stderr only, matching existing `opslog` behavior.
|
||||
- **Per-subsystem trace propagation into plugins** is a follow-up owned by
|
||||
`silo-plugin-sdk`; this repo instruments only the host side.
|
||||
@@ -0,0 +1,787 @@
|
||||
# Restart-resilient playback (TR-lease)
|
||||
|
||||
How Silo keeps playback alive across a server restart, reconnect, or partial
|
||||
stream — and how the design arrived at **TR-lease** (token-carried
|
||||
reconstruction + a server-side session-deny marker for revocation).
|
||||
|
||||
> **Status: the revocation half is deferred to a future PR.** Only the
|
||||
> token-carried **reconstruction** core shipped in this PR. The **session-deny /
|
||||
> stream-revocation** mechanism described throughout (the `silo:streamauth:<sid>`
|
||||
> deny marker, the proxy's `Allowed()` enforcement, and the admin
|
||||
> Stop/Terminate deny write) is **not present in the current implementation**.
|
||||
> Admin Terminate and user Stop tear down the live in-memory session and the
|
||||
> ffmpeg producer, but they do **not** prevent a still-valid stream token from
|
||||
> reconstructing the session until its 24h TTL expires. Sections describing that
|
||||
> mechanism are kept for design continuity and are individually flagged as
|
||||
> deferred below.
|
||||
|
||||
This document is the consolidated design record. It folds together four earlier
|
||||
working notes so a future engineer can see the full evolution, the options
|
||||
weighed, the issues raised across review rounds, the variables traded off, and
|
||||
why the final shape was chosen. The superseded notes were:
|
||||
|
||||
- *unified-playback-reconstruct* — the reconstruct core (PR #174), "RC".
|
||||
- *token-carried-playback-reconstruct* — the storage evolution, "TR".
|
||||
- *async-recipe-card-playback* — a monitoring-preserving middle path, "RC-async".
|
||||
- *playback-reconstruct-options-comparison* — the goals/options matrix.
|
||||
|
||||
Paths are repository-relative; assume the repository root is the cwd.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
Before this work, a missing in-memory playback session was a `404`. A server
|
||||
restart (deploy, crash, OOM) wiped the in-memory session map, so every in-flight
|
||||
stream died: the client's next manifest/segment/byte-range request hit a session
|
||||
that no longer existed and playback stopped hard. The same gap appeared on any
|
||||
front-end that had never held the session (horizontal scale) and on a dedicated
|
||||
transcode node reboot.
|
||||
|
||||
Core priorities for the fix (from the repo guidelines): **performance first,
|
||||
reliability first, predictable behavior under load and during failures**. The
|
||||
hot path (per-segment serve) must stay off central/Postgres; bytes must stay
|
||||
offloaded to nodes; correctness and robustness win over convenience.
|
||||
|
||||
---
|
||||
|
||||
## 2. The core insight: reconstruct, don't rehydrate
|
||||
|
||||
A missing session is a **reconstruct trigger, not a 404**. The client always
|
||||
re-requests the next chunk after an outage, and its request already states where
|
||||
it is (an HTTP `Range`, a `?seek=`, or a `seg_NNNNN` number). So the server
|
||||
rebuilds the session just-in-time from two things:
|
||||
|
||||
1. a small **durable descriptor** (identity, ownership, and the byte-affecting
|
||||
encode parameters — the "recipe"), and
|
||||
2. the **position the client supplies** on the triggering request.
|
||||
|
||||
Nothing live is serialized (no ffmpeg process, context, channels, or log sink).
|
||||
This is the contribution of PR #174 ("RC"), which also unified the native and
|
||||
Jellyfin-compat paths behind one `playback.TranscodeManager` so the
|
||||
card-lifetime rules, the reconstruct concurrency cap, and the node-affinity rule
|
||||
live in exactly one place. The reconstruct skeleton —
|
||||
`LoadOrReconstructSession`, `ReconstructSession` / `ReconstructTranscode`, the
|
||||
single-flight + `reconstructSem` cap that paces the post-restart thundering
|
||||
herd, `RegisterReconstructed`, the `CloseProcess`/`Close` split — is the
|
||||
foundation every later option reused. **It was kept; only the descriptor storage
|
||||
and the revocation model evolved.**
|
||||
|
||||
The six routes that must survive a restart:
|
||||
|
||||
| # | Route | Tier | How it is rebuilt |
|
||||
|---|-------|:----:|-------------------|
|
||||
| 1 | native direct | 1 | client `Range` re-serve; descriptor = identity |
|
||||
| 2 | native remux | 1 | re-spawn ffmpeg at `?seek`; descriptor = id + audio |
|
||||
| 3 | native transcode | 2 | rebuild `Session` + ffmpeg seeked to the requested segment; descriptor = full encode opts |
|
||||
| 4 | jellycompat direct | 1 | compat store + `Range` re-serve |
|
||||
| 5 | jellycompat remux | 1 | compat store + `?seek` |
|
||||
| 6 | jellycompat transcode | 2 | compat store + shared-manager reconstruct |
|
||||
|
||||
Tier 1 = stateless re-serve (client re-supplies position). Tier 2 = rebuild
|
||||
ffmpeg. Routes 4–6 always retain a durable central compat store
|
||||
(`jellycompat_playback_sessions`): third-party Jellyfin clients cannot
|
||||
round-trip a native descriptor, so the compat layer keeps the full
|
||||
`PlaybackSession` (route + media source resolved before native reconstruct
|
||||
runs).
|
||||
|
||||
---
|
||||
|
||||
## 3. The two axes the options differ on
|
||||
|
||||
Every option is a point on a 2-D grid: **where the descriptor lives** (vertical)
|
||||
× **how revocation is bounded** (horizontal).
|
||||
|
||||
```text
|
||||
AUTHORITY / REVOCATION MODEL ----------------------->
|
||||
re-resolve at long token short TTL +
|
||||
reconstruct (24h, expire) refresh checkpoint
|
||||
D central +----------------+----------------+----------------+
|
||||
E Postgres row | RC (#174) | (n/a) | RC-async |
|
||||
S +----------------+----------------+----------------+
|
||||
C client/node- | (n/a) | TR-24h | TR + re-mint |
|
||||
R carried token | | | |
|
||||
I +----------------+----------------+----------------+
|
||||
P
|
||||
T top row = descriptor stays central (node serves bytes but cannot
|
||||
| self-reconstruct → node-restart gap)
|
||||
| bottom = recipe travels to the node (node self-sufficient, no central record)
|
||||
```
|
||||
|
||||
**TR-lease is a fourth point off this grid:** bottom row (token-carried, 24h),
|
||||
but the revocation column is replaced by a *server-side deny marker* — on an
|
||||
admin kill, central would write a per-session deny to Redis that the node
|
||||
enforces by withholding bytes. Revocation is decoupled from the token TTL and
|
||||
needs no client refresh. (An earlier iteration polled and re-resolved every
|
||||
stream on a timer; that was cut to the event-driven session-deny — see §7 and
|
||||
the §8 design note.)
|
||||
|
||||
> **Status: deferred to a future PR.** The deny-marker column described above is
|
||||
> the design target, not the current shipped behavior. As shipped, the
|
||||
> token-carried row offers no revocation tighter than the 24h token TTL on the
|
||||
> node path; admin Terminate and user Stop only tear down the live session and
|
||||
> ffmpeg producer.
|
||||
|
||||
---
|
||||
|
||||
## 4. Goals weighed
|
||||
|
||||
| ID | Goal |
|
||||
|----|------|
|
||||
| G1 | Hot path (per-segment serve) stays off central/Postgres |
|
||||
| G2 | Bytes offloaded to nodes (served from disk, signature-verified, never central) |
|
||||
| G3 | Restart resiliency across all 6 routes |
|
||||
| G4 | Two-factor ownership (auth caller + ownership rebind; refuse `userID==0`/mismatch) |
|
||||
| G5 | Revocation latency (how fast a banned user / pulled access stops playing) |
|
||||
| G6 | Single-box: zero external store required for reconstruction |
|
||||
| G7 | Restart runway (descriptor stays valid longer than the outage) |
|
||||
| G8 | Multi-front-end / verify-anywhere (no integrated-transcode split-brain) |
|
||||
| G9 | Observability — durable, identity-rich active-stream record |
|
||||
| G10 | Low operational surface (tables, migrations, stores, endpoints) |
|
||||
| G11 | Node-restart recovery |
|
||||
| G12 | No new client coordination required |
|
||||
| G13 | Small signing-key blast radius |
|
||||
| G14 | Cleanup correctness (never wipe a live session's segment dir) |
|
||||
|
||||
A note on G9 (observability): the *live* "who is streaming now" view does **not**
|
||||
depend on the descriptor strategy. Every offload node calls the Redis-backed
|
||||
node-session tracker (`internal/nodesessions/tracker.go`) on every serve, tied to
|
||||
actual byte-serving (so a non-cooperative client or replayed token still shows
|
||||
up) and surviving a central restart. G9 scores only the *durable, identity-rich*
|
||||
(who + what + recipe) slice on top of that.
|
||||
|
||||
---
|
||||
|
||||
## 5. The options, and the comparison
|
||||
|
||||
Legend: ✓ meets · ✓\* minor asterisk · ~ partial · ✗ fails · ✓✓ best-in-class
|
||||
|
||||
| Goal | `main` | RC #174 | TR-24h | TR + re-mint | RC-async | **TR-lease** |
|
||||
|------|:------:|:-------:|:------:|:------------:|:--------:|:------------:|
|
||||
| G1 hot path free of central DB | ✓ | ✓\* | ✓ | ✓ | ✓ | ✓ |
|
||||
| G2 bytes offloaded | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| G3 restart resiliency (6/6) | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| G4 two-factor ownership | ✓ | ✓ | ~ | ~ | ~ | ✓~ |
|
||||
| G5 revocation latency | ~ | ~ | ✗ (≤24h) | ✓ (≤TTL) | ✓✓ | ✓✓ |
|
||||
| G6 single-box, no external store | ✓ | ✗ | ✓✓ | ~ | ~ | ~ |
|
||||
| G7 restart runway | n/a | ✓ (30m) | ✓✓ (24h) | ~ | ~ | ✓✓ (24h) |
|
||||
| G8 multi-front-end | ✗ | ~ | ✓~ | ✓~ | ✓~ | ✓~ |
|
||||
| G9 durable identity-rich record | ✗ | ✓✓ | ✗ | ✗ | ✓ | ~ |
|
||||
| G10 low operational surface | ✓✓ | ~ | ✓ | ~ | ✗ | ~ |
|
||||
| G11 node-restart recovery | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ |
|
||||
| G12 no new client coordination | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ |
|
||||
| G13 small signing-key blast radius | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ |
|
||||
| G14 cleanup correctness | ✓ | ✓✓ | ~ | ~ | ~ | ~ |
|
||||
|
||||
The TTL/revocation strategies head-to-head. **Note: the session-deny column is
|
||||
the deferred design target — see §7; as shipped, the node path has no revocation
|
||||
tighter than the 24h token TTL.**
|
||||
|
||||
| Strategy | Revocation latency | Restart runway | Client refresh? | Central on refresh? |
|
||||
|----------|--------------------|----------------|-----------------|---------------------|
|
||||
| Re-resolve at reconstruct (RC) | on reconstruct; node hop ≤24h | 30 min | no | only on reconstruct |
|
||||
| Leave 24h, expire only (TR-24h) | up to 24h | 24h | no | never |
|
||||
| Short TTL + refresh + denylist (RC-async / TR item B) | ≤~5 min | ~90 s grace | **yes** | every ~3.5 min/session |
|
||||
| 24h token + session-deny marker (TR-lease — **deferred**) | *design target:* admin kill cuts node bytes on next serve; passive ban = next-play / ≤24h. *As shipped:* node path bounded by ≤24h token only | 24h | **no** | nothing on the serve path; one write per admin-kill event |
|
||||
|
||||
---
|
||||
|
||||
## 6. Why each non-final option was set aside
|
||||
|
||||
- **RC (PR #174, the recipe card in Postgres).** Correct and the basis of
|
||||
everything here, but it couples reconstruction to a shared per-session Postgres
|
||||
store: a write on every start, per audio/quality change, and a ≤1/min refresh,
|
||||
with resilience tied to the DB being reachable. It does not give the single-box
|
||||
zero-dependency property, and the node hop's 24h token stays unrevocable
|
||||
anyway. RC's reconstruct *core* was kept; its *storage* was the thing to evolve.
|
||||
|
||||
- **TR-24h (token-carried, leave the token to expire).** Wins the single-box case
|
||||
(zero external dependency) and removes the per-start DB write, but revocation is
|
||||
bounded by *nothing tighter than the 24h token TTL* on the node path — a banned
|
||||
user keeps node-served direct/remux for up to 24h. This is actually the
|
||||
*status quo* for the node path even pre-branch (the proxy verifies by signature
|
||||
alone, no DB), so TR-24h only adds identity claims to the token without
|
||||
improving revocation. Not acceptable as the endpoint.
|
||||
|
||||
- **TR + re-mint (short TTL + authenticated playlist refresh + denylist), and
|
||||
RC-async (the same revocation idea but keeping an async-written Postgres mirror
|
||||
for observability).** Both bound revocation to ~5 min by having the client
|
||||
refetch a stable `RequireAuth` playlist that re-checks access and re-mints
|
||||
short-lived segment URLs. The cost: a **hard client-coordination requirement**
|
||||
(silo-android / silo-apple / Jellyfin clients must implement the refresh flow),
|
||||
a shrunken restart runway (~90 s grace instead of 30 min — if central is down
|
||||
past the grace, playback stalls), and a new authenticated re-mint transport
|
||||
that does not exist on the supported proxy/node topology. RC-async additionally
|
||||
keeps a write pipeline + denylist + the central row (the highest operational
|
||||
surface of all). They solved revocation by pushing work onto the client and
|
||||
shortening the runway — the opposite of the reliability priorities.
|
||||
|
||||
The open item across TR and RC-async was always **revocation enforcement** ("item
|
||||
B"): bounding revocation needs *either* a per-segment token check on the hot path
|
||||
*or* an authenticated re-mint checkpoint the client drives. TR-lease removes that
|
||||
dilemma.
|
||||
|
||||
---
|
||||
|
||||
## 7. TR-lease: the chosen design
|
||||
|
||||
TR-lease keeps TR's 24h token (so the restart runway is untouched and no client
|
||||
refresh flow is needed) and adds a server-side **session-deny marker** for the
|
||||
one revocation case the node path cannot otherwise cover — no client
|
||||
participation, no steady-state cost.
|
||||
|
||||
**Reconstruction (token-carried).** The signed stream token *is* the durable
|
||||
descriptor. Its claims carry the full byte-affecting recipe plus `uid/pid/mfid`
|
||||
ownership lookup keys. A front-end that lost its in-memory session rebuilds
|
||||
ffmpeg from the token the client re-presents — no shared per-session store, zero
|
||||
external dependency for reconstruction on a single box. `HWAccel`/`HWDevice` are
|
||||
deliberately *not* carried; they are re-resolved from live config so an operator
|
||||
config change applies to reconstructed sessions too. Ownership re-binds to the
|
||||
live authenticated caller and refuses `userID==0` / mismatch — the token's `uid`
|
||||
is never trusted alone (a leaked URL is useless without the owner's auth session
|
||||
on the native path).
|
||||
|
||||
**Revocation (session-deny marker).**
|
||||
|
||||
> **Status: deferred to a future PR.** Everything in this subsection (the
|
||||
> `silo:streamauth:<sid>` deny marker, the admin Stop/Terminate deny write, and
|
||||
> the node-side `Allowed()` enforcement) describes the design target and is **not
|
||||
> present in the current implementation**. As shipped, admin Terminate and user
|
||||
> Stop tear down the live in-memory session and the ffmpeg producer, but a
|
||||
> still-valid stream token can reconstruct the session until its 24h TTL expires;
|
||||
> there is no node-side byte-withholding and no guaranteed/instant revocation on
|
||||
> the node path. The design below is retained for when revocation lands.
|
||||
|
||||
The revocation surface was deliberately
|
||||
kept to the *one* case the offloaded topology cannot otherwise cover, rather than
|
||||
a periodic re-check of every stream. An admin Stop/Terminate would write
|
||||
`silo:streamauth:<sid> = deny` (TTL ≥ the 24h token lifetime); the offload node
|
||||
would read it (a node-local Redis GET, sub-ms) before serving any bytes and
|
||||
return `403` with no bytes on a hit. Enforcement is **byte-withholding, not
|
||||
client cooperation** — a revoked client that ignores the 403 keeps hitting a
|
||||
wall.
|
||||
|
||||
Why this would be sufficient, and what it deliberately does *not* do:
|
||||
|
||||
- **New playback is already blocked at central** — `/playback/start` is
|
||||
`RequireAuth` + access check, so a ban/scope change always takes effect on the
|
||||
next play, everywhere, with no marker needed.
|
||||
- **Cooperative clients already stop** — central holds the realtime WebSocket and
|
||||
can drop it + send a stop on a ban.
|
||||
- **The only thing needing a hard node-side cut** is the *explicit, session-scoped
|
||||
admin kill* of a *non-cooperative* client holding a valid token on a
|
||||
node-served direct/remux stream (no producer to kill). That is exactly what the
|
||||
(deferred) session-deny marker would cover; until it lands this case is bounded
|
||||
by the ≤24h token like the passive bans below.
|
||||
- **Not covered (accepted limitation):** a *passive* ban or *partial* access
|
||||
change (lost one library, lowered rating) does NOT hard-revoke an
|
||||
already-running, non-cooperative node stream. It is enforced at next play and
|
||||
otherwise bounded by the ≤24h token. Closing this would require either a
|
||||
periodic re-resolver (a poll over every active stream — rejected as constant
|
||||
DB load for a rare case) or per-content deny enumeration; both were judged not
|
||||
worth the machinery for a self-hosted server. A user-scoped deny was rejected
|
||||
too: it over-blocks content the viewer still legitimately has access to.
|
||||
|
||||
**Fail-open on absence (deferred).** In the design, the normal case is *no*
|
||||
marker, which serves; a Redis error also serves; only a present deny withholds
|
||||
bytes. This is a deliberate reliability-first choice: availability over
|
||||
revocation latency.
|
||||
|
||||
**Why this shape.** It keeps token-carried reconstruction's single-box and
|
||||
verify-anywhere properties and would add revocation with no client coordination
|
||||
(G12) and essentially no steady-state cost — the marker is written only on the
|
||||
rare admin-kill event, never on a timer. It is, in effect, **TR-24 plus a single
|
||||
session-scoped kill switch**. The kill switch is **deferred**; what shipped is
|
||||
TR-24 (token-carried reconstruction, ≤24h token, no node-side revocation).
|
||||
|
||||
---
|
||||
|
||||
## 8. What was implemented, and where
|
||||
|
||||
This PR ships **Commit 1 only** (token-carried reconstruction). **Commit 2 (the
|
||||
node session-deny marker) is deferred to a future PR** — the `internal/streamauth`
|
||||
package, the proxy `Allowed()` guard, and the admin deny write described below
|
||||
are **not present in the current implementation**.
|
||||
|
||||
**Commit 1 — token-carried reconstruction; retire `transcode_recipes`. (shipped)**
|
||||
- `streamtoken.Claims` gains the recipe + `uid/pid/mfid`
|
||||
(`internal/streamtoken/token.go`).
|
||||
- `playback.RecipeCard` projects to/from claims (`ToClaims` /
|
||||
`RecipeCardFromClaims`); the `Reconstruct*` front door takes the decoded recipe
|
||||
instead of reading Postgres (`internal/playback/recipecard.go`,
|
||||
`transcode_manager.go`).
|
||||
- Native serve URLs carry the token as `?st=`; the manifest rewriter already
|
||||
appends `RawQuery` to segment URIs, so segments inherit it with no route change
|
||||
(`internal/api/handlers/playback.go`, `stream.go`). The integrated server is
|
||||
hit directly, so the query-stripping-proxy concern that motivates a path
|
||||
segment does not apply there; the proxy/node path keeps the token in the URL
|
||||
path as before.
|
||||
- Jellycompat folds the recipe into its durable compat store
|
||||
(`PlaybackSession.Recipe`) since Jellyfin clients cannot round-trip a native
|
||||
token (`internal/jellycompat/...`).
|
||||
- Segment-dir cleanup switches from the card index to in-memory liveness (live
|
||||
map + in-flight reconstruct set + age past the max token TTL); a dedicated node
|
||||
keeps its boot-time full wipe (`internal/playback/transcode_cleanup.go`,
|
||||
`transcode_manager.go`).
|
||||
- `PostgresRecipeStore`, the `RecipeStore` interface, and the unmerged
|
||||
`transcode_recipes` migration are deleted.
|
||||
|
||||
**Commit 2 — node session-deny marker. (DEFERRED — not in this PR)**
|
||||
|
||||
> The items below describe the planned revocation commit. They were removed from
|
||||
> this PR and deferred to a future one; none of these symbols exist in the
|
||||
> current implementation. The admin Stop/Terminate path still tears down the live
|
||||
> session and ffmpeg producer, but writes no deny marker, so a valid token can
|
||||
> reconstruct until its 24h TTL expires.
|
||||
|
||||
- `internal/streamauth`: a deny-only Redis `Store` — `Deny(sid)` (TTL ≥ token
|
||||
lifetime) and a fail-open `Allowed(sid)` reader. No poller, no allow-leases, no
|
||||
access re-resolver.
|
||||
- The node guard in `internal/proxy/server.go` (`verifyToken` consults
|
||||
`Allowed`).
|
||||
- An admin Stop/Terminate writes the deny marker
|
||||
(`internal/api/handlers/admin_playback_control.go` → `PlaybackHandler.LeaseDenier`).
|
||||
- `nodesessions.SessionInfo` carries the numeric ownership keys
|
||||
(`auth_user_id`/`profile_id`/`media_file_id`), populated by the node from the
|
||||
verified token, to enrich the live admin "active streams" view (who/what, not
|
||||
just session id) — see §10 monitoring.
|
||||
- A single integrated box reads no marker and needs none — the producer-kill +
|
||||
session removal on Terminate is the stop there.
|
||||
|
||||
> Design note (about the deferred commit 2): an earlier iteration added a central
|
||||
> *revalidator* that re-resolved access for every active stream every ~2 min and
|
||||
> wrote allow/deny leases. It was dropped in favour of the event-driven
|
||||
> session-deny above: the poll re-derived, at a constant all-streams DB cost,
|
||||
> signals the
|
||||
> system already emits (new playback is gated at `/playback/start`; admin kill is
|
||||
> an explicit event), and its only unique coverage — sub-24h *passive* revocation
|
||||
> of an in-flight node stream — was judged not worth the machinery (see §7).
|
||||
|
||||
---
|
||||
|
||||
## 9. Issues raised across review, and their disposition
|
||||
|
||||
From two adversarial review rounds on the reconstruct core and the storage
|
||||
evolution:
|
||||
|
||||
- **Integrated-transcode split-brain across front-ends (P-1 / A).** The
|
||||
`SessionManager` + reconstruct single-flight are per-process and the descriptor
|
||||
carries no owning-node id, so without sticky affinity two front-ends can run
|
||||
divergent ffmpeg. **Disposition: deferred (owner-identity claim).** Supported
|
||||
topology is dedicated `--mode=transcode` nodes (routed via `tnode`, no
|
||||
split-brain) + single-box / LB-session-affinity integrated transcode. The
|
||||
caveat is documented, not silently assumed.
|
||||
- **24h node-path token is unrevocable (P-2).** The (deferred, see §7–§8)
|
||||
session-deny marker would let the explicit admin kill cut node bytes on the next
|
||||
serve. **Until that ships, this remains open:** the node-path token is
|
||||
unrevocable, so admin kill and passive bans / partial access changes are all
|
||||
bounded by next-play + the ≤24h token.
|
||||
- **Dedicated transcode-node restart (P-3 / tr-10). Fixed for both paths.**
|
||||
*Native:* the proxy forwards the verified stream token to the transcode node
|
||||
(`X-Silo-Stream-Token`); on a manifest/segment miss the node re-verifies the
|
||||
token, decodes the recipe, and self-reconstructs ffmpeg seeked to the requested
|
||||
segment — single-flighted and concurrency-capped exactly like the integrated
|
||||
reconstruct. *Jellycompat:* the node-hop token is server-minted and *could* carry the
|
||||
recipe like native, but it deliberately does not — the recipe is mutated in
|
||||
place under a stable session id (a Jellyfin audio/subtitle switch can arrive as
|
||||
a `/Sessions/Playing/Progress` report that restarts ffmpeg without re-minting
|
||||
the client's token), and a third-party Jellyfin client cannot be driven to
|
||||
refresh a stale token, so a token *snapshot* could reconstruct a stale
|
||||
rendition. Central instead writes the recipe to a shared Redis recipe store
|
||||
(`internal/noderecipe`) keyed by upstream session id and overwrites it on every
|
||||
switch, and the node (which cannot reach Postgres) reads the *current* recipe on
|
||||
the reconstruct miss — over the same Redis the session tracker already uses (and
|
||||
the deferred deny-lease would use), so no central URL is needed. The boot-time
|
||||
full segment-dir wipe
|
||||
stands; reconstruct re-transcodes from the requested segment. See §10 for the
|
||||
full rationale (and why this is *not* a token-carryable case).
|
||||
- **`userID==0` tolerance / "auth optional" (P-5).** Reconstruct hard-rejects
|
||||
`userID==0` and a card whose session id does not match the URL.
|
||||
- **Mutable recipe under a stable id (D).** A parameter change re-mints a new
|
||||
manifest + token and the client reloads, so the common case self-heals. The
|
||||
only residual is a restart *during* the brief switch transition with a prior
|
||||
token in flight: it reconstructs the prior *rendition* (access is re-resolved,
|
||||
only the rendition can be stale). TTL-bounded, self-healing on reload; minor,
|
||||
accepted.
|
||||
- **Cleanup liveness (F / tr-9).** Each process owns its `TranscodeDir`, so the
|
||||
liveness signal is the in-process live map + the in-flight reconstruct set +
|
||||
age-past-max-TTL; a dir is reaped only when absent from both and older than any
|
||||
surviving token. No cross-process enumeration-failure mode for the supported
|
||||
topology.
|
||||
- **Signing-key blast radius (E / tr-7, G13).** A single `JWTSecret` signs native
|
||||
auth, the stream token, and the node bearer, so a node-secret leak can forge
|
||||
native logins. **Accepted risk** on the trusted-node precondition. **Deferred
|
||||
hardening:** split the stream-token secret from the native-auth secret, then
|
||||
asymmetric stream-token signing before any untrusted/edge node.
|
||||
|
||||
---
|
||||
|
||||
## 10. Residuals and follow-ups
|
||||
|
||||
- **Revocation of an in-flight node stream (all cases, until deny-lease ships).**
|
||||
Because the session-deny marker is deferred (§7–§8), *no* ban — admin kill,
|
||||
passive ban, or partial access change — hard-cuts an already-running,
|
||||
non-cooperative node-served stream; all are enforced at next play, bounded by
|
||||
the ≤24h token. The future deny-lease closes the *admin-kill* case; passive
|
||||
bans would still need a per-content deny enumeration on the access-change event,
|
||||
or accepting it. See §7.
|
||||
- **Monitoring.** Two distinct questions:
|
||||
- *Live "who is watching what right now"* — covered. Every node serve writes a
|
||||
record to the Redis tracker (`silo:sessions:*`), now enriched with
|
||||
`auth_user_id` / `media_file_id` from the verified token, so the admin
|
||||
active-streams view answers who/what/where, tied to real byte-serving and
|
||||
surviving a central restart. Integrated (no-node) sessions show in central's
|
||||
in-memory session list.
|
||||
- *Durable historical/audit ("who watched what last week")* — NOT provided by
|
||||
this subsystem (there is no longer a durable per-session recipe record, by
|
||||
design — G9 was the deliberate trade for token-carried reconstruction). That
|
||||
history lives in the separate watch-progress / scrobble system.
|
||||
- **Multiple viewers on one stream token.** The token is per playback session, so
|
||||
a shared/leaked stream URL lets several clients pull bytes under one session id.
|
||||
They **collapse to a single entry** in the tracker (one `sid`) — monitoring
|
||||
cannot distinguish or count them — and the (deferred) session-deny would cut
|
||||
them as a group, not individually. Detecting/limiting this needs a per-account
|
||||
concurrency cap or
|
||||
per-device session ids (device binding); deferred. Do not bind to client IP
|
||||
(mobile / CGNAT).
|
||||
- **Token in logs (tr-1).** Scrub the `?st=` token / path token from request
|
||||
loggers (the jellycompat logger logs `RawQuery`; the native logger omits it).
|
||||
Short TTL + signature bound the exposure.
|
||||
- **Owner-identity (A).** Required before multi-front-end *integrated* transcode
|
||||
without sticky affinity; defines owner election, self-URL discovery, a
|
||||
peer-forward endpoint, and failover.
|
||||
- **Node-side reconstruct (P-3). Done** for native (token-forwarded) and
|
||||
jellycompat (Redis recipe handoff via `internal/noderecipe`: central writes the
|
||||
recipe at remote-transcode start, the node reads it on a reconstruct miss).
|
||||
Precondition: central and the nodes share the same Redis (already required for
|
||||
the session tracker, and for the deferred deny-lease). Residual: the recipe
|
||||
store is consulted
|
||||
only for a jellycompat token, so a node restart still depends on the recipe key
|
||||
surviving (24h TTL, ≥ token lifetime).
|
||||
- *Why a server-side store and not the token here (the real rationale).* It is
|
||||
tempting to delete `internal/noderecipe` and let the jellycompat node-hop
|
||||
token carry the recipe like native — the token is server-minted and opaque to
|
||||
the Jellyfin client (it follows it only as a redirect target), so it *could*.
|
||||
That does **not** work, and the reason is **not** the often-stated "a Jellyfin
|
||||
client can't round-trip a token." The reason is that the recipe is **mutated
|
||||
in place under a stable session id**: a Jellyfin audio/subtitle switch can
|
||||
arrive as a `/Sessions/Playing/Progress` report
|
||||
(`restartCompatTranscodeForAudioSelection`) that restarts ffmpeg in place
|
||||
**without re-minting the client's token**. A third-party Jellyfin client
|
||||
cannot be driven to refresh the now-stale token — its VOD/`ENDLIST` manifest
|
||||
is fetched once, and the compat WebSocket (`HandleSocket`) is a receive-only
|
||||
keep-alive stub with no server→client command to force a manifest re-fetch. A
|
||||
token is a snapshot at mint time, so after such a switch it would reconstruct
|
||||
the **stale** rendition (old audio/subtitle) until the client happens to
|
||||
re-fetch the manifest (e.g. on a seek), which for a VOD stream may be never.
|
||||
Because the node also cannot reach Postgres (where the authoritative compat
|
||||
recipe lives), the only way the node rebuilds the *current* recipe is a
|
||||
node-reachable, server-authoritative store that central overwrites on every
|
||||
switch — i.e. `internal/noderecipe`. Native has no equivalent path: every
|
||||
native audio switch re-mints the manifest + token, so the native client's
|
||||
token is never stale, which is exactly why native needs no such store. The
|
||||
store is **load-bearing, not redundant**.
|
||||
- **Key split / asymmetric signing (E).** Closes the account-takeover path; a
|
||||
precondition for untrusted edge nodes.
|
||||
|
||||
---
|
||||
|
||||
## 11. Preconditions
|
||||
|
||||
- **Persistent `TranscodeDir` (recommended).** With persistence, reconstruct
|
||||
serves surviving segments; with an ephemeral dir it re-transcodes from the
|
||||
descriptor (which survives a wiped dir). A node *restart* still loses the
|
||||
session regardless (P-3).
|
||||
- **Re-transcode of one segment beats the buffer runway** on worst-case hardware
|
||||
(software decode, subtitle burn-in). Measure.
|
||||
- **Client retry/backoff exists.** Clients retry segment/manifest GETs on
|
||||
5xx/connection error and reconnect the WebSocket. The token rides the URL, so no
|
||||
extra client logic is needed to carry it; a client that does not retry sees the
|
||||
failed request as fatal.
|
||||
|
||||
---
|
||||
|
||||
## 12. Diagrams
|
||||
|
||||
The `mermaid` blocks render in GitHub / VS Code / most Markdown viewers; the
|
||||
ASCII blocks render anywhere.
|
||||
|
||||
### 12.1 Topology — where state lives
|
||||
|
||||
> **Deferred:** the `silo:streamauth:<sid> = deny` marker, the "Admin
|
||||
> Stop/Terminate → write session-deny marker" arrow, and the node's "deny GET"
|
||||
> are the planned revocation path and are **not** in the current implementation.
|
||||
> The tracker (`silo:sessions:*`) and the recipe handoff are shipped.
|
||||
|
||||
```text
|
||||
┌─────────────────────────── CENTRAL (mode=server) ───────────────────────────┐
|
||||
Client │ RequireAuth /playback/start /playback/transcode/start (mint token) │
|
||||
(web / android / │ native serve: /playback/transcode/{sid}/master.m3u8?st= /stream/{sid}?st= │
|
||||
apple / jellyfin) │ Admin Stop/Terminate → write session-deny marker (event, not a timer) │
|
||||
└───────────┬──────────────────────────┬───────────────────────┬──────────────┘
|
||||
│ Postgres │ Redis │
|
||||
▼ ▼ │ (multi-node only)
|
||||
┌───────────────────────┐ ┌──────────────────────────┐ ▼
|
||||
│ catalog / access │ │ silo:sessions:* (tracker)│ ┌────────────────────────┐
|
||||
│ jellycompat_playback_ │ │ silo:streamauth:<sid> │ │ Proxy node (mode=proxy) │
|
||||
│ sessions (compat │ │ = deny (session kill) │◀─│ verifyToken + deny GET │
|
||||
│ store, carries Recipe)│ └──────────────────────────┘ │ serves direct/remux │
|
||||
└───────────────────────┘ ▲ │ proxies transcode ──────┼──▶ Transcode node
|
||||
✗ NO transcode_recipes table (deleted this branch) │ node-local GET └────────────────────────┘ (mode=transcode)
|
||||
└── sub-ms, off Postgres ffmpeg /transcode/{sid}/…
|
||||
```
|
||||
|
||||
Descriptor location by route (the core change). The "lease (node)" revocation
|
||||
column is the **deferred** design; as shipped, the node path has no revocation
|
||||
tighter than the ≤24h token:
|
||||
|
||||
| route family | descriptor (how it reconstructs) | revocation |
|
||||
|--------------|----------------------------------|------------|
|
||||
| native (1–3) | the signed token the client re-presents | *deferred:* lease (node) / shipped: producer-kill (integrated), ≤24h token (node) |
|
||||
| jellycompat (4–6) | `jellycompat_playback_sessions.data.Recipe` (token can't round-trip) | *deferred:* lease (node) / shipped: producer-kill, ≤24h token (node) |
|
||||
| ~~all~~ | ~~`transcode_recipes` Postgres row~~ — removed | — |
|
||||
|
||||
### 12.2 Where the token rides
|
||||
|
||||
```text
|
||||
NATIVE (integrated server is hit directly → query param; segments inherit it)
|
||||
manifest : GET /playback/transcode/{sid}/master.m3u8?st=<JWT>
|
||||
segment : GET /playback/transcode/{sid}/segment/seg_00042.m4s?st=<JWT> ← RawQuery propagated by the manifest rewriter
|
||||
direct : GET /stream/{sid}?st=<JWT>
|
||||
|
||||
PROXY/NODE (capability URL → path segment; survives a query-stripping hop)
|
||||
GET {proxy}/stream/transcode/{JWT}/master.m3u8 → relative segment URIs inherit {JWT}
|
||||
GET {proxy}/stream/direct/{JWT} GET {proxy}/stream/remux/{JWT}
|
||||
|
||||
JELLYCOMPAT → recipe is NOT in the token; it lives in the compat store.
|
||||
the path token is used only for the node hop.
|
||||
```
|
||||
|
||||
### 12.3 Normal playback — native, integrated box (no nodes)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Central server (integrated)
|
||||
participant PG as Postgres (access)
|
||||
Note over C,S: transcode start
|
||||
C->>S: POST /playback/transcode/start (RequireAuth)
|
||||
S->>PG: resolve file + access
|
||||
S->>S: StartTranscode (ffmpeg) + RegisterTranscodeSession
|
||||
S->>S: card = NewRecipeCard(opts); token = Sign(card.ToClaims(), 24h)
|
||||
S-->>C: 202 manifestURL = /playback/transcode/{sid}/master.m3u8?st=TOKEN
|
||||
Note over C,S: hot path — NO Postgres, NO lease (live session in memory)
|
||||
C->>S: GET .../master.m3u8?st=TOKEN
|
||||
S->>S: LoadOrReconstructSession → live hit → BuildPlaybackManifest (segments carry ?st)
|
||||
S-->>C: manifest
|
||||
loop every segment
|
||||
C->>S: GET .../segment/seg_N.m4s?st=TOKEN
|
||||
S->>S: live session → GetSegment → ServeFile
|
||||
S-->>C: bytes
|
||||
end
|
||||
```
|
||||
|
||||
The hot path touches neither Postgres nor the lease — it is an in-memory map
|
||||
hit. `?st` is verified only to decode a card if reconstruct is needed; a live
|
||||
session ignores it.
|
||||
|
||||
### 12.4 Normal playback — native, multi-node (offloaded)
|
||||
|
||||
> **Deferred:** the `GET silo:streamauth:{sid}` lease guard and its `deny → 403`
|
||||
> branch are the planned revocation path, **not** in the current implementation.
|
||||
> As shipped the node verifies the token, tracks the session, and serves — there
|
||||
> is no deny check.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Central server
|
||||
participant N as Proxy node
|
||||
participant T as Transcode node
|
||||
participant R as Redis
|
||||
C->>S: POST /playback/transcode/start (RequireAuth)
|
||||
S->>S: card(full recipe, tnode=T); token = Sign(card.ToClaims())
|
||||
S-->>C: manifestURL = {N}/stream/transcode/TOKEN/master.m3u8
|
||||
loop manifest + segments
|
||||
C->>N: GET /stream/transcode/TOKEN/...
|
||||
N->>N: verifyToken(TOKEN)
|
||||
N->>R: GET silo:streamauth:{sid} (lease guard)
|
||||
alt deny
|
||||
N-->>C: 403 (no bytes)
|
||||
else allow / absent
|
||||
N->>R: Track silo:sessions:{node}:{sid} (uid, mfid from claims)
|
||||
N->>T: proxy /transcode/{sid}/...
|
||||
T-->>N: bytes
|
||||
N-->>C: bytes
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
The node copies `uid`/`mfid` from the verified token into the Redis tracker —
|
||||
that is what enriches the live admin "active streams" view (who/what per node).
|
||||
|
||||
### 12.5 Restart → reconstruct (native transcode)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Central server (just restarted — session map empty)
|
||||
Note over S: in-memory session GONE; NO transcode_recipes table to read
|
||||
C->>S: GET /playback/transcode/{sid}/segment/seg_42.m4s?st=TOKEN
|
||||
S->>S: card = streamCardFromQuery(?st) → Verify + decode recipe
|
||||
S->>S: LoadOrReconstructSession → GetSession = NotFound, card != nil
|
||||
S->>S: ReconstructSession (re-bind: refuse userID==0 / != card.uid; card.sid == {sid})
|
||||
S->>S: ReconstructTranscode(seg=42) → single-flight, mark in-flight, respawn ffmpeg SEEKED to seg 42
|
||||
S-->>C: bytes (resumes near where the client was)
|
||||
```
|
||||
|
||||
- direct/remux restart is the same but Tier-1: reconstruct the `Session` from the
|
||||
identity token, then `Range` / `?seek` re-serve — no ffmpeg rebuild.
|
||||
- multi-node restart: the integrated front-end reconstructs only the `Session`
|
||||
(to learn `tnode`) and re-proxies; the transcode node keeps serving. If the
|
||||
transcode *node* itself reboots, the proxy forwards the stream token
|
||||
(`X-Silo-Stream-Token`) and the node self-reconstructs ffmpeg seeked to the
|
||||
requested segment (native path, P-3). A jellycompat session on a rebooted node
|
||||
reconstructs too: the node fetches the recipe central wrote to the shared Redis
|
||||
recipe store (`internal/noderecipe`) at transcode start, since the Jellyfin
|
||||
client's token cannot carry it.
|
||||
- cleanup race: a segment dir is reaped only if absent from the live map AND the
|
||||
in-flight set AND older than 24h (max token TTL), so a token that can still
|
||||
reconstruct never gets its dir wiped.
|
||||
|
||||
### 12.6 Auth deny — the session-deny marker
|
||||
|
||||
> **Status: deferred to a future PR.** The entire "Admin Stop/Terminate → write
|
||||
> deny marker" and "Offload node → GET deny → 403" flow below is the planned
|
||||
> revocation design and is **not** in the current implementation. As shipped,
|
||||
> admin Stop/Terminate does the "realtime WS stop + producer teardown" half only
|
||||
> (the right branch); it writes no marker, and the node performs no deny check, so
|
||||
> a valid token reconstructs until its ≤24h TTL. New playback is still gated at
|
||||
> `/playback/start` (the top subgraph is accurate).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph New["New playback (any topology)"]
|
||||
P["/playback/start — RequireAuth + access check"] --> PB{allowed?}
|
||||
PB -- no --> PR[refused — ban takes effect at next play]
|
||||
PB -- yes --> PM[mint token]
|
||||
end
|
||||
subgraph Admin["Admin Stop/Terminate (event, not a timer)"]
|
||||
K[denyStreamLease] --> WD[SET silo:streamauth:sid = deny<br/>TTL = 24h]
|
||||
K --> WS[realtime WS stop + producer teardown]
|
||||
end
|
||||
subgraph Node["Offload node — every serve"]
|
||||
G[verifyToken OK] --> L{GET silo:streamauth:sid}
|
||||
L -- present deny --> F[403, no bytes]
|
||||
L -- absent / redis-err --> SV[serve bytes fail-open]
|
||||
end
|
||||
```
|
||||
|
||||
Invariants:
|
||||
- No steady-state writes: the marker is set only on an admin kill, never on a
|
||||
timer. The normal serve path finds *no* key and serves.
|
||||
- Fail-open: absent key or Redis error → serve. The only hard stop is a present
|
||||
deny.
|
||||
- The integrated box reads no marker — the producer-kill + session removal on
|
||||
Terminate is the stop there; the marker only matters where nodes serve.
|
||||
- Not covered here (by design): a *passive* ban / partial access change of an
|
||||
in-flight non-cooperative node stream — enforced at next play, bounded by the
|
||||
≤24h token (§7).
|
||||
|
||||
### 12.7 Jellycompat — what differs
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant J as Jellyfin client
|
||||
participant S as Central (jellycompat)
|
||||
participant PG as Postgres compat store
|
||||
J->>S: negotiate → PlaybackSession{UpstreamSessionID, MediaSources}
|
||||
S->>PG: Put (jellycompat_playback_sessions)
|
||||
Note over S: transcode start
|
||||
S->>S: StartTranscode; recipe = NewRecipeCard(opts)
|
||||
S->>PG: Update(ps): TranscodeStarted=true, Recipe=recipe (same write)
|
||||
Note over J,S: restart → reconstruct from the COMPAT STORE, not a token
|
||||
J->>S: GET HLS segment
|
||||
S->>PG: Get(playSessionID) → ps.Recipe
|
||||
S->>S: LoadOrReconstructSession(UpstreamSessionID, card = ps.Recipe) → ReconstructTranscode(*ps.Recipe)
|
||||
S-->>J: bytes
|
||||
```
|
||||
|
||||
Jellyfin clients cannot carry a native `?st` token, so the recipe is folded into
|
||||
the durable compat row (`PlaybackSession.Recipe`, persisted in the `data` JSONB)
|
||||
and written in the same `Update` that flips `TranscodeStarted`. The node hop
|
||||
(multi-node jellycompat) still uses a path token exactly like native (the lease
|
||||
guard is deferred along with the rest of the revocation path).
|
||||
|
||||
### 12.8 Hot-path cost per request
|
||||
|
||||
The "lease" / "SET deny" Redis costs below are **deferred** (the revocation path
|
||||
is not shipped); as shipped the node does only the tracker write, and admin
|
||||
Stop/Terminate writes no Redis key:
|
||||
|
||||
| request | Postgres | Redis | central CPU | reconstruct |
|
||||
|---------|----------|-------|-------------|-------------|
|
||||
| native segment, live, integrated | — | — | map hit | — |
|
||||
| native segment, live, node | — | 1 track (+ deferred: GET lease) | — | — |
|
||||
| native segment, after restart, integrated | — | — | verify JWT + ffmpeg respawn (once, single-flight) | yes |
|
||||
| jellycompat segment, live | — | track (+ deferred: lease) | map hit | — |
|
||||
| jellycompat segment, after restart | 1 Get (compat row) | track (+ deferred: lease) | respawn | yes |
|
||||
| admin Stop/Terminate (event, not per-request) | — | deferred: 1 SET deny | one write | — |
|
||||
|
||||
---
|
||||
|
||||
## 13. Restart-resiliency behavior matrix (role × path)
|
||||
|
||||
The observable reconstruction behavior of every playback path under a restart of
|
||||
each server role. **✅** = the path reconstructs and resumes on the client's next
|
||||
request; **—** = the path never executes on that role (nothing to reconstruct);
|
||||
**⚠️** = reconstructs only while a stated precondition holds.
|
||||
|
||||
| path | integrated restart | proxy-node restart | transcode-node restart |
|
||||
|------|--------------------|--------------------|------------------------|
|
||||
| native · direct | ✅ rebuild `Session` from `?st` token → `Range` re-serve | ✅ stateless re-serve from `claims.MediaPath` | — not served on a transcode node |
|
||||
| native · remux | ✅ token + `?seek` → re-spawn pipe | ✅ stateless per-request pipe re-spawn | — |
|
||||
| native · transcode | ✅ token (full recipe) → respawn ffmpeg seeked to segment | ✅ transparent re-route + token forward | ✅ self-contained — token is recipe-complete |
|
||||
| jellycompat · direct | ✅ re-resolve from durable compat row → `Range` re-serve | ✅ stateless re-serve from token `MediaPath` | — |
|
||||
| jellycompat · remux | ✅ compat row + `?seek` | ✅ stateless per-request pipe re-spawn | — |
|
||||
| jellycompat · transcode | ✅ `ReconstructTranscode(*ps.Recipe)` from compat row | ✅ transparent re-route + token forward | ⚠️ reconstructs **iff** the shared Redis recipe (`internal/noderecipe`, ≤24h TTL) still exists; fails closed (404) otherwise |
|
||||
|
||||
**What carries the recovery state, per role**
|
||||
|
||||
| role | native carrier | jellycompat carrier |
|
||||
|------|----------------|---------------------|
|
||||
| integrated | `?st` stream token — identity for direct/remux, full recipe for transcode | durable compat row `PlaybackSession.Recipe` |
|
||||
| proxy node | self-describing path token, re-derived per request — the proxy holds no per-session state | same path token (identity); the recipe is not needed at the proxy |
|
||||
| transcode node | forwarded `X-Silo-Stream-Token`, recipe-complete | identity token + Redis `noderecipe` recipe (the Jellyfin token cannot carry it) |
|
||||
|
||||
**Reading the matrix**
|
||||
|
||||
- **Integrated and proxy-node restarts are unconditional** for every applicable
|
||||
path. The proxy holds no authoritative per-session state — direct/remux are
|
||||
re-served from the token's `MediaPath`, transcode is reverse-proxied — so a
|
||||
proxy bounce, or a reroute to any in-group proxy (`pickProxy` is round-robin
|
||||
with no session affinity), is transparent.
|
||||
- **The four `—` cells are structural, not gaps.** Direct and remux never run on
|
||||
a transcode node — the proxy (or the integrated box) serves them from source
|
||||
media — so a transcode-node restart has nothing to reconstruct on those paths.
|
||||
- **The one conditional (⚠️)** is jellycompat transcode after a *transcode-node*
|
||||
restart. The node-hop token is identity-only — not because a Jellyfin client
|
||||
cannot round-trip it (the token is server-minted and opaque to the client), but
|
||||
because the recipe is mutated in place under a stable session id and a
|
||||
third-party Jellyfin client cannot be driven to refresh a stale token, so a
|
||||
token snapshot could reconstruct a stale rendition (see §10). The node therefore
|
||||
rebuilds from the Redis `noderecipe` entry central overwrites on every switch
|
||||
rather than from the token. Native transcode has no such dependency — every
|
||||
native audio switch re-mints the token, so it is always recipe-complete and
|
||||
current. This is the only native-vs-jellycompat asymmetry in reconstruction
|
||||
(see §10).
|
||||
|
||||
**Out of scope of this matrix** (accepted behaviors, not restart-reconstruction
|
||||
holes — see §7, §10):
|
||||
|
||||
- Passive ban / partial access change of an in-flight node stream — enforced at
|
||||
next play, bounded by the ≤24h token, not by reconstruction.
|
||||
- Integrated transcode behind a non-sticky load balancer can split-brain into
|
||||
divergent segment dirs (P-1); safe single-front-end or with LB affinity. Remote
|
||||
(node) sessions are immune — node affinity routes every front-end to the same
|
||||
transcode node.
|
||||
@@ -61,7 +61,8 @@ 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.
|
||||
history-import tokens, subtitle provider credentials, and plugin runtime
|
||||
configuration.
|
||||
- `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).
|
||||
@@ -79,7 +80,11 @@ remaining plaintext in place:
|
||||
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.
|
||||
encrypted,
|
||||
4. whole `plugin_runtime_configs.config_value` objects. Plugin config is
|
||||
intentionally opaque to the host storage layer: plugins may write undeclared
|
||||
keys and manifest annotations may be unavailable or change over time, so
|
||||
manifest-selected field encryption cannot fail closed.
|
||||
|
||||
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
|
||||
a per-row guard makes concurrent multi-node boots converge without
|
||||
@@ -92,7 +97,9 @@ whatever the primary encrypted. No manual steps are required.
|
||||
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.
|
||||
read `enc:v1:`-prefixed integration keys as garbage credentials. It would also
|
||||
pass the reserved plugin-config envelope object to plugins instead of their
|
||||
configuration.
|
||||
|
||||
To downgrade safely you must first return the affected values to plaintext, for
|
||||
example:
|
||||
@@ -123,7 +130,10 @@ 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.
|
||||
the ABS signing key. Plugin runtime configuration is encrypted as one opaque
|
||||
row-bound envelope rather than by manifest field. Consequently, runtime code
|
||||
must use `RuntimeConfigStore`; database JSON-member queries and indexes are not
|
||||
supported for `plugin_runtime_configs.config_value`.
|
||||
|
||||
Deliberately **not** encrypted (tracked as follow-ups):
|
||||
|
||||
@@ -132,11 +142,6 @@ Deliberately **not** encrypted (tracked as follow-ups):
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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`).
|
||||
|
||||
Feature-detection precedent: clients discover which metadata providers (including the
|
||||
built-in NFO provider, #216) apply to a library type via
|
||||
`GET /api/v1/libraries/provider-defaults` rather than version sniffing. New capabilities
|
||||
should follow the same capability-endpoint pattern.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Handoff: `account.capabilities_changed` event on the events WebSocket
|
||||
|
||||
**Date:** 2026-07-06
|
||||
**Requested by:** Apple client team (silo-apple)
|
||||
**Status:** Proposed — server implementation needed
|
||||
|
||||
## Problem
|
||||
|
||||
Clients cache user-facing capability payloads (e.g. `GET /api/v1/downloads/capability`)
|
||||
and have no way to learn that an admin changed something that affects them. Concrete
|
||||
incident: enabling "Allow transcoded downloads" (`users.download_transcode_allowed`)
|
||||
for a user did not surface the transcode quality presets in the iOS app until the
|
||||
client's cache expired (previously 24h).
|
||||
|
||||
The Apple client now re-fetches capability on app-foreground and when the download
|
||||
options UI opens (pull-based safety net, shipped in silo-apple). This handoff covers
|
||||
the push half: an invalidation event so connected clients react immediately, without
|
||||
polling.
|
||||
|
||||
## Existing infrastructure (no new transport needed)
|
||||
|
||||
- Events WebSocket: `GET /api/v1/events/ws` (`internal/api/handlers/events_ws.go`),
|
||||
channel-based subscribe with per-role channel ACLs (`allowedChannelsForRole`).
|
||||
- `user_state` channel is already available to non-admin users and the hub already
|
||||
filters envelopes by `env.UserID` for non-admins (`allowsEventForClaims`), so a
|
||||
user-targeted event on this channel is only delivered to that user (and admins).
|
||||
- Publishing helper: `evt.Hub.PublishJSON(ctx, channel, type, payload, opts)`
|
||||
(`internal/events/hub.go`), pattern example in
|
||||
`internal/api/handlers/user_state_events.go`.
|
||||
|
||||
## Proposed contract
|
||||
|
||||
**Channel:** `user_state` (reuse; it is the only user-scoped channel non-admins can
|
||||
subscribe to, and clients that care already subscribe to it).
|
||||
|
||||
**Event type:** `account.capabilities_changed`
|
||||
|
||||
**Payload — intentionally minimal (invalidation ping, not a data carrier):**
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "downloads" | "playback" | "all"
|
||||
}
|
||||
```
|
||||
|
||||
Do **not** include the new capability values. Clients respond by re-fetching the REST
|
||||
endpoints they already consume (`/api/v1/downloads/capability`, playback prefs, etc.).
|
||||
This keeps the event contract trivial and guarantees it never drifts from the REST
|
||||
response shapes. `scope` is a coarse hint so clients can skip irrelevant re-fetches;
|
||||
when in doubt publish `"all"`.
|
||||
|
||||
**Targeting:**
|
||||
|
||||
- Per-user change → `PublishOptions{UserID: <affected user>}` so only that user's
|
||||
connections receive it.
|
||||
- Server-wide setting change → publish once with no `UserID` (fan-out to all
|
||||
connected users). Verify `allowsEventForClaims` passes envelopes with
|
||||
`UserID == 0` to non-admins — it does today (`env.UserID > 0` guard).
|
||||
|
||||
## Publish sites
|
||||
|
||||
1. **Admin user update handlers** (`internal/api/handlers/admin.go` user
|
||||
create/update paths): publish scope `"downloads"` when any of
|
||||
`download_allowed`, `download_transcode_allowed` change; `"playback"` for
|
||||
playback-affecting fields (`max_streams`, `max_transcodes`, quality limits);
|
||||
`"all"` if simpler. Target the affected `UserID`.
|
||||
2. **Server settings writes** (settings handler / config store): publish untargeted
|
||||
scope `"downloads"` when any `download.*` key changes (notably
|
||||
`download.transcode_enabled`, `download.enabled`, `defaults.download_*`);
|
||||
`"playback"` for `playback.*` gates that shape client-visible capability.
|
||||
3. **Access-group / policy-engine changes** that alter `download` /
|
||||
`download_transcode` action outcomes, if applicable — same event, `"all"` scope,
|
||||
untargeted (membership makes per-user targeting fiddly; the re-fetch is cheap).
|
||||
|
||||
No snapshot frame needed for this event type (`snapshotForChannel` for `user_state`
|
||||
already returns `null`).
|
||||
|
||||
## Client behavior (for reference / Android alignment)
|
||||
|
||||
- Apple: will add a `ServerEventsClient` subscribing to `user_state` +
|
||||
`notifications`; on `account.capabilities_changed` with scope `downloads`/`all`,
|
||||
calls its existing capability re-fetch. Until then the pull-based refresh already
|
||||
covers correctness.
|
||||
- Android: already has `NotificationsRealtimeClient` on this socket; should handle
|
||||
the same event the same way.
|
||||
- Clients must treat the event as best-effort: sockets are down while backgrounded,
|
||||
so they keep refresh-on-foreground / refresh-on-use regardless.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
1. Toggle `download_transcode_allowed` for a connected non-admin user → that user's
|
||||
socket receives `account.capabilities_changed` (scope `downloads`); other users
|
||||
receive nothing.
|
||||
2. Flip `download.transcode_enabled` server setting → all connected users receive
|
||||
the event once.
|
||||
3. A non-admin subscribed to `user_state` never receives another user's targeted
|
||||
capability events (existing `allowsEventForClaims` filtering).
|
||||
4. `GET /api/v1/downloads/capability` immediately after the event reflects the new
|
||||
presets (it already does — the event carries no data to go stale).
|
||||
@@ -0,0 +1,179 @@
|
||||
# Streaming write-deadline fix: stop truncating streams at 120s without losing stalled-client protection
|
||||
|
||||
**Status:** planned (2026-07-09)
|
||||
**Repos:** silo-server (primary), silo-apple (hardening follow-up)
|
||||
**Root-cause session:** live debugging on Apple TV, 2026-07-09 evening
|
||||
|
||||
## Problem
|
||||
|
||||
`cmd/silo/main.go:2373` sets `WriteTimeout: 120 * time.Second` on the main API
|
||||
`http.Server`. Go's `WriteTimeout` is an **absolute deadline from the start of each
|
||||
request**, not an idle timeout — so every response still being written at T+120s is
|
||||
killed mid-body with a clean connection close.
|
||||
|
||||
Observed impact (2026-07-09, dev server, Apple TV loopback client):
|
||||
|
||||
- Every direct-stream GET (`/api/v1/stream/{session_id}`) serving a 15.3 GB MKV was
|
||||
cut at exactly 120s. Server log signature: `path=/api/v1/stream/` with
|
||||
`duration_ms=120000` (three instances in a 3h window; more go unlogged when the
|
||||
client disconnects first).
|
||||
- The Apple client's byte-cursor reconnect ladder silently absorbs most kills, so
|
||||
playback "mostly works" — but when a kill lands during backpressure parking or a
|
||||
demuxer resync, the client's 3-attempt `.prematureEOF` retry budget exhausts and it
|
||||
performs a full teardown + new-session recovery (~5 s visible stop). Four such
|
||||
failures in ~35 minutes of playback.
|
||||
- Confirmed by dual-path probe (long ranged GET consumed at playback rate): died at
|
||||
~123s direct to the origin and ~151s via the CDN edge edge (origin killed
|
||||
the edge's upstream GET at 120s; the edge's buffer drained for the extra ~30s).
|
||||
The CDN is exonerated — it only relayed the origin's truncation.
|
||||
|
||||
Precedent inside the same file: the Jellyfin-compat listener (`main.go:2514`) and the
|
||||
Audiobookshelf-compat listener (`main.go:2535`) both already set `WriteTimeout: 0`
|
||||
because compat clients stream long responses. The main listener serves the exact same
|
||||
class of traffic for first-party clients and was never aligned.
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
Goals:
|
||||
|
||||
1. Streaming/long responses are never killed while they are making progress.
|
||||
2. Genuinely stalled connections (client gone, TCP zombie, slow-loris) still get
|
||||
reaped in bounded time — we do NOT simply set `WriteTimeout: 0` on the main
|
||||
listener, because that removes mid-response stall protection entirely (a peer that
|
||||
stops ACKing would block a handler goroutine + fd indefinitely).
|
||||
3. All non-streaming API routes keep today's 120s absolute protection unchanged.
|
||||
4. No client changes required for the fix to take effect (Android, web, Infuse/compat
|
||||
benefit identically).
|
||||
|
||||
Non-goals: CDN edge tuning; silo-server#333 control-plane restart work; the
|
||||
silo-apple `.prematureEOF` parking hardening (tracked as follow-up below, it is
|
||||
defense-in-depth once this lands).
|
||||
|
||||
## Design: rolling per-write deadline for streaming handlers
|
||||
|
||||
Semantics change for streaming responses only, from "must complete within 120s" to
|
||||
"must make progress at least every N seconds".
|
||||
|
||||
### New helper (one small file, e.g. `internal/api/httpstream/rolling_deadline.go`)
|
||||
|
||||
```go
|
||||
// RollingDeadlineWriter wraps a streaming http.ResponseWriter and pushes the
|
||||
// connection's write deadline forward on every successful write, so long
|
||||
// responses survive indefinitely while stalled ones die within `window`.
|
||||
type RollingDeadlineWriter struct {
|
||||
w http.ResponseWriter
|
||||
rc *http.ResponseController
|
||||
window time.Duration // stall budget, e.g. 180s
|
||||
step time.Duration // min interval between deadline bumps, e.g. 15s
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func New(w http.ResponseWriter, window time.Duration) *RollingDeadlineWriter
|
||||
func (s *RollingDeadlineWriter) Write(p []byte) (int, error) // bump-then-write
|
||||
func (s *RollingDeadlineWriter) ReadFrom(r io.Reader) (int64, error) // see below
|
||||
func (s *RollingDeadlineWriter) Header() http.Header
|
||||
func (s *RollingDeadlineWriter) WriteHeader(code int)
|
||||
func (s *RollingDeadlineWriter) Flush()
|
||||
func (s *RollingDeadlineWriter) Unwrap() http.ResponseWriter // ResponseController compat
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- Uses `http.NewResponseController(w).SetWriteDeadline(time.Now().Add(window))` —
|
||||
Go ≥ 1.20 (we are on 1.26.4). A per-request controller deadline **overrides** the
|
||||
server-level `WriteTimeout` for that response, which is exactly the mechanism the
|
||||
stdlib provides for this case.
|
||||
- Bumps are rate-limited (`step`, ~15s) so we do one `SetWriteDeadline` per ~15s of
|
||||
wall time, not one per 32 KB chunk.
|
||||
- **`ReadFrom` must be implemented** and delegate to the underlying writer in bounded
|
||||
slices (e.g. `io.CopyN(underlying, r, 64<<20)` per iteration, bumping the deadline
|
||||
between iterations). Rationale: `http.ServeContent` → `io.Copy` uses the
|
||||
`io.ReaderFrom` fast path on the response writer (sendfile for `*os.File`); a naive
|
||||
wrapper without `ReadFrom` silently forfeits sendfile and burns CPU copying 15 GB
|
||||
through userspace. Bounded-slice delegation keeps sendfile *and* the rolling
|
||||
deadline.
|
||||
- First bump happens in `New` (covers the header write + first body bytes).
|
||||
- `window` default 180s, overridable via env `SILO_STREAM_WRITE_STALL_TIMEOUT`
|
||||
(seconds). 180s > the Apple client's longest observed benign backpressure park
|
||||
(~20s) and > outage-probe cadence with margin, while still reaping zombies in
|
||||
minutes. Note: a paused client that stops reading its socket for >window will have
|
||||
the connection reaped — that is fine and intended; the client's reconnect ladder
|
||||
resumes at the byte cursor on unpause (this is today's behavior for ALL streams at
|
||||
120s, so it is a strict improvement).
|
||||
|
||||
### Application points (all on the main listener)
|
||||
|
||||
| Path | File | Mechanism | Change |
|
||||
|---|---|---|---|
|
||||
| Direct play (the tonight failure) | `internal/playback/directplay.go:52` `ServeDirectPlay` | `http.ServeContent` of the full media file | wrap `w` before `ServeContent` |
|
||||
| Remux stream | `internal/playback/remux.go:226` `ServeRemux` (write loop `:255`) | manual chunked `Write`+`Flush` | wrap `w` (loop needs no change) |
|
||||
| Offline downloads | `internal/api/handlers/downloads.go:401` → `svc.ServeFile` | full media file download | wrap `w` at the handler before delegating |
|
||||
| Transcode-node proxy | `internal/api/handlers/playback.go:2998` `io.Copy(w, resp.Body)` | long-lived proxy of remote transcode output | wrap `w` |
|
||||
| Ebook/converted serving | `internal/api/handlers/ebook_convert_serve.go:159`, `ebook_reader.go:625` | `ServeContent`, files up to ~100s of MB; slow readers can exceed 120s | wrap `w` (cheap, same helper) |
|
||||
|
||||
Explicitly **unchanged**:
|
||||
|
||||
- Transcode HLS segments (`playback.go:2897 http.ServeFile`) — bounded few-second
|
||||
files; 120s absolute is fine. (Harmless to wrap later if segment sizes grow.)
|
||||
- WebSockets (`/control/ws`) — hijacked connections manage their own deadlines;
|
||||
tonight's WS survived 4+ minutes untouched, confirming no interaction.
|
||||
- Jellyfin/ABS compat listeners — separate `http.Server`s, already `WriteTimeout: 0`;
|
||||
optionally migrate them to the rolling writer later so they regain stall
|
||||
protection (follow-up, not required).
|
||||
- All JSON/image/API routes — keep the server-level 120s absolute deadline.
|
||||
|
||||
`cmd/silo/main.go:2374` itself stays at `WriteTimeout: 120s`. That is the point: the
|
||||
global guard remains, streaming handlers opt out per-response with a better contract.
|
||||
|
||||
## Server tests
|
||||
|
||||
1. **Unit — rolling behavior:** wrapper on a fake ResponseController records deadline
|
||||
bumps; assert bump on first write, rate-limited bumps thereafter, `ReadFrom`
|
||||
chunking delegates and bumps per slice.
|
||||
2. **Integration — survives the server timeout:** `httptest.Server` with
|
||||
`WriteTimeout: 1s`; handler streams 3s of throttled data through the wrapper
|
||||
(window 2s). Assert full body received (fails without wrapper — this is the
|
||||
regression test for tonight's bug).
|
||||
3. **Integration — stalled client still reaped:** wrapper with window 500ms writing
|
||||
to a client that stops reading; assert handler write errors within ~window, not
|
||||
never.
|
||||
4. **Regression:** non-streaming route on the same server still killed at
|
||||
`WriteTimeout` (guard unchanged).
|
||||
|
||||
## Validation on dev (after tonight's viewing)
|
||||
|
||||
1. `make dev-deploy`.
|
||||
2. Re-run the range-GET probe (script preserved from tonight's session): sustained
|
||||
open-ended GET at playback rate for ≥ 15 min on both direct and CDN paths; expect
|
||||
zero early closes and no `duration_ms=120000` entries in the request log.
|
||||
3. Long ATV playback session (existing client build): expect zero
|
||||
`prematureSourceEnd` teardowns over a full film.
|
||||
4. Spot-check normal API latency/behavior and one Jellyfin-compat client.
|
||||
|
||||
## Follow-up workstream: silo-apple client hardening (separate branch)
|
||||
|
||||
Defense-in-depth so any truncating origin/middlebox (not just our own bug) degrades
|
||||
to an invisible reconnect instead of a 5s rebuild:
|
||||
|
||||
1. Park persistent mid-stream `.prematureEOF` into the existing outage ride-through
|
||||
once the cursor-resume retries reproduce the same truncation offset
|
||||
(`PlaybackOriginOutagePolicy.shouldPark`, `PlaybackSourceOriginStream.swift:159-173`;
|
||||
writer then gets the 240s park allowance instead of the 10s deadline). Keep it
|
||||
kill-switchable alongside `SILO_DISABLE_OUTAGE_RIDE_THROUGH`.
|
||||
2. Add `[CMP-ORIGIN]` logging: one line per origin connection end (cause, HTTP
|
||||
status, cursor, bytes delivered, retry streak) — tonight the entire retry-and-give-up
|
||||
sequence ran with zero log output.
|
||||
3. Verify reconnects use fresh connection state (AetherEngine builds a new URLSession
|
||||
per reconnect to avoid poisoned keep-alive pools; our 3 rapid same-offset failures
|
||||
while a fresh session succeeded seconds later is circumstantial evidence we reuse).
|
||||
4. Optional: raise `.prematureEOF` retry cap toward AE's progress-aware semantics.
|
||||
|
||||
Android note: the server fix benefits Android identically with no client change;
|
||||
worth a later check of how its player handles a 120s kill today (it may have been
|
||||
absorbing them silently, or contributing to unexplained rebuffers).
|
||||
|
||||
## Rollout
|
||||
|
||||
Dev first (above), soak for a few days of normal viewing, then include in the next
|
||||
server release. The change is additive and per-handler; risk is localized to the
|
||||
wrapped streaming paths, and the global timeout still protects everything else.
|
||||
@@ -0,0 +1,771 @@
|
||||
# Client Diagnostics: crash reports and debug log upload
|
||||
|
||||
Status: Draft (spec + cross-repo plan)
|
||||
Date: 2026-07-19
|
||||
Repos affected: `silo-server`, `silo-android`, `silo-apple`
|
||||
|
||||
## Summary
|
||||
|
||||
Add an opt-in, self-hosted diagnostics pipeline: Silo clients detect crashes
|
||||
and abnormal exits, collect structured debug logs (playback, TV focus,
|
||||
network, lifecycle) plus a hardware snapshot (device model, display/HDMI,
|
||||
audio path, codec capabilities), and — only with explicit user consent —
|
||||
upload a compressed diagnostics bundle to the user's own Silo server. Admins
|
||||
review bundles through new acting-admin API endpoints alongside the existing
|
||||
server logs tooling.
|
||||
|
||||
Everything ships **disabled by default**:
|
||||
|
||||
- Server: a `diagnostics.uploads_enabled` server setting gates the whole
|
||||
feature (default off), and it cannot be enabled unless the server's private
|
||||
object storage is configured and validated.
|
||||
- Client: verbose "debug logging" is a per-device setting (default off).
|
||||
Crash reporting defaults to **Ask** — after an abnormal exit the app shows
|
||||
an incident-specific prompt (e.g. *"Silo closed unexpectedly during
|
||||
playback. Send a diagnostic report to your server?"*) with
|
||||
**Send / Don't send / Always send**. Nothing uploads without one of: an
|
||||
explicit prompt confirmation, a prior "Always send" election bound to this
|
||||
server and account, or the user explicitly sending a manual capture.
|
||||
|
||||
## Motivation
|
||||
|
||||
Today we are blind to client-side failures:
|
||||
|
||||
- **No crash reporting anywhere.** Neither the Android repo nor the Apple
|
||||
repo installs an uncaught-exception handler, uses MetricKit, or bundles a
|
||||
crash SDK (verified: no handler/SDK references in either repo; the only
|
||||
Firebase dependency is messaging). A user saying "the app crashed" is a
|
||||
dead end.
|
||||
- **Diagnostics don't survive to the report.** Android logs go to logcat (raw
|
||||
`android.util.Log` in ~40 files, no buffer or file sink); the Apple
|
||||
player's rich `[CMP]`/`[CMP-DIAG]` diagnostics go through `print()`
|
||||
(`PlayerLog.swift` / `PlayerCore.swift`) with no app-owned persistent sink.
|
||||
By the time a user reports a problem, the evidence is gone.
|
||||
- **The hardest bugs are device-specific.** Shield HLG quirks, Fire TV
|
||||
passthrough suppression, HDMI mode switches, TV focus traps — reproducing
|
||||
them requires knowing the exact device, panel, audio chain, and codec path.
|
||||
Users can't reliably report any of that.
|
||||
- **The server already has half the story.** `operational_logs` records
|
||||
`playback_session_id` (migration `028_log_partitioning.sql`), the admin
|
||||
logs API filters by it, and the web admin has a logs viewer. What's missing
|
||||
is the client half, and a way to join the two.
|
||||
|
||||
## Goals
|
||||
|
||||
- Capture crashes and abnormal exits (JVM/native/ANR on Android; crash and
|
||||
hang diagnostics on iOS; abnormal-exit detection on tvOS) with enough
|
||||
context to act on: stack or exit trace where the platform provides one,
|
||||
recent structured logs, and a device snapshot.
|
||||
- Capture deep opt-in debug logs for playback, TV focus behavior, networking,
|
||||
and navigation/lifecycle — everything needed to troubleshoot user issues.
|
||||
- Always include a hardware snapshot: device (Shield / Fire TV / onn /
|
||||
phone), OS, app build, display and HDMI mode with HDR capabilities, audio
|
||||
output path and passthrough capabilities, decoder inventory — with honest
|
||||
`unknown` / `not_collected` states where a platform can't provide a field.
|
||||
- One payload contract across Android and Apple; one server endpoint; one
|
||||
admin surface. The contract is a checked-in versioned JSON Schema with
|
||||
golden fixtures consumed by all three repos' CI.
|
||||
- Explicit, server-bound consent for every byte uploaded; feature fully off
|
||||
by default on both server and client.
|
||||
- Bounded cost: size caps, per-account quotas, concurrency limits, and
|
||||
server-side retention.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Live/continuous log streaming from clients to the server.
|
||||
- Screenshots or screen recordings in bundles.
|
||||
- Native/tvOS crash *stack* capture via an in-process crash reporter
|
||||
(PLCrashReporter/KSCrash-style). tvOS v1 detects abnormal exits and
|
||||
attaches breadcrumbs, not stacks (see Apple design). Adopting an in-process
|
||||
crash reporter is a separate decision with its own signal-safety and review
|
||||
implications.
|
||||
- Symbolication. Bundles store what the OS gives us; a server-side
|
||||
symbolication job can come later.
|
||||
- Native-app admin viewer screens (per current product policy, admin surfaces
|
||||
beyond STATS on the clients are a separate product decision; v1 admin
|
||||
consumption is via API + web admin).
|
||||
- macOS. The Apple repo has a macOS target, but v1 scopes to
|
||||
`android`, `android-tv`, `ios`, `tvos`; the platform enum reserves `macos`
|
||||
without accepting it.
|
||||
- Third-party telemetry, metrics aggregation, or fleet analytics. This is a
|
||||
support/debugging tool, not analytics.
|
||||
|
||||
## Why this route
|
||||
|
||||
**Upload to the user's own Silo server, not a third-party crash service.**
|
||||
Crashlytics/Sentry SaaS would send user data to a third party, require vendor
|
||||
accounts/keys we'd have to ship, and put the reports in front of *us* rather
|
||||
than the person who can actually act — the self-hosting admin. Requiring
|
||||
every admin to also run self-hosted Sentry is far too heavy. The Silo server
|
||||
is the natural, privacy-aligned destination, and it already has auth, admin
|
||||
tooling, object storage, and retention patterns to build on.
|
||||
|
||||
**Bundle-on-event, not streaming.** A bundle captured at the moment of
|
||||
failure (crash, or a user-triggered capture) contains exactly the context
|
||||
needed: recent structured logs, the playback stats timeline, and the hardware
|
||||
snapshot. Continuous streaming would cost bandwidth and privacy while mostly
|
||||
capturing noise, and would still miss pre-crash buffers.
|
||||
|
||||
**Hybrid storage: Postgres row + object-store blob.** Report *metadata* (a
|
||||
small manifest: device summary, truncated crash summary, type, timestamps)
|
||||
goes in a queryable table; the payload itself (potentially megabytes of logs
|
||||
and crash artifacts) goes to the S3-compatible private bucket as a single
|
||||
archive. Ingesting client log lines into the partitioned `operational_logs`
|
||||
table was considered and rejected: bundles are consumed as whole documents by
|
||||
a human, not queried row-wise; client lines are untrusted input with
|
||||
different retention needs; and multi-MB uploads would bloat a table tuned for
|
||||
the server's own high-churn logs. The server *does* emit normalized
|
||||
operational events about diagnostics activity (report accepted / rejected /
|
||||
downloaded / deleted, with report id, account, sizes, result) so ingest and
|
||||
admin actions are auditable and correlate with existing logs — raw client
|
||||
lines never enter `operational_logs`.
|
||||
|
||||
**A versioned archive, not a bare gzipped log stream.** Crash evidence is
|
||||
heterogeneous: JSONL logs, ANR trace text, Android native tombstones
|
||||
(protobuf on API 31+), MetricKit diagnostic JSON. A tar.gz archive with named
|
||||
entries carries each artifact losslessly with a declared media type, keeps
|
||||
large traces out of Postgres, and can evolve by adding entries.
|
||||
|
||||
**Build small in-house rather than adopt ACRA/other SDKs.** The surface we
|
||||
need on Android (one exception handler writing a bounded marker, a ring
|
||||
buffer, one upload call) is a few hundred lines; ACRA would add a dependency
|
||||
and its own report format without covering Apple. On Apple, the OS provides
|
||||
MetricKit natively (iOS; not tvOS — see Apple design).
|
||||
|
||||
## Architecture overview
|
||||
|
||||
```text
|
||||
Android / Apple client silo-server
|
||||
┌────────────────────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ Safe-logging facade → ring │ │ GET /api/v1/diagnostics/status │
|
||||
│ └ debug mode: rotating │ │ └ available|disabled| │
|
||||
│ segment files │ │ storage_unavailable + limits │
|
||||
│ Crash/abnormal-exit capture │ │ POST /api/v1/diagnostics/reports │
|
||||
│ ├ UEH marker (Android) │──▶│ ├ gate + storage check │
|
||||
│ ├ ApplicationExitInfo (30+) │ │ ├ streaming, bounded multipart │
|
||||
│ ├ MetricKit (iOS) │ │ ├ account quotas + concurrency │
|
||||
│ └ exit sentinel (tvOS) │ │ ├ manifest → client_diagnostic_ │
|
||||
│ Pending reports (server-bound) │ │ │ reports (state machine) │
|
||||
│ DeviceSnapshot (probes+new │ │ └ archive → S3 private bucket │
|
||||
│ accessors) │ │ Admin (acting admin): list/detail/ │
|
||||
│ Consent UI (ask/always/never, │ │ download/delete + audit events │
|
||||
│ per server+account) │ │ Retention + orphan reconciler task │
|
||||
└────────────────────────────────┘ └──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Consent model
|
||||
|
||||
Consent is **scoped, versioned, and destination-bound** — not a global
|
||||
boolean. Both clients support multiple servers and accounts, so a device-wide
|
||||
"Always send" would let a bundle full of server A's titles and URLs upload to
|
||||
server B after a switch. Rules:
|
||||
|
||||
- Consent (including "Always send") is **account-scoped**: every consent
|
||||
record and every pending report is bound to
|
||||
`(server_instance_id, account_user_id, consent_notice_version)`, where
|
||||
`server_instance_id` is a stable opaque ID returned by the status
|
||||
endpoint. The capturing `profile_id` is recorded on reports for
|
||||
*attribution*, not as a consent scope — quotas and elections follow the
|
||||
account.
|
||||
- A pending report is only ever uploaded to the server+account it was
|
||||
captured under. Reports are never retargeted. Switching server or account
|
||||
neither uploads nor migrates pending reports; signing out of an account or
|
||||
removing a server deletes its pending reports.
|
||||
- "Always send" is an election for one server+account. Bumping
|
||||
`consent_notice_version` (when we materially change what bundles contain)
|
||||
invalidates prior elections and reverts to Ask.
|
||||
- Setting crash reports to **Never** deletes pending reports for that
|
||||
server+account and stops persistent capture (breadcrumbs/debug files); the
|
||||
in-memory ring keeps running (memory-only, never uploaded without a later
|
||||
explicit action).
|
||||
- **Child/managed profiles**: the Diagnostics section is not shown to
|
||||
profiles with `is_child`, and every diagnostics action — changing
|
||||
settings, electing Always, the crash prompt, and manual capture/send —
|
||||
requires a non-child profile. A crash captured while a child profile was
|
||||
active is held pending and prompted for the next time a non-child profile
|
||||
is active. Reports are attributed to account + capturing profile.
|
||||
- The crash prompt and the manual flow both offer **View report** — a
|
||||
human-readable summary (incident, categories and line counts, device
|
||||
identity, destination server) before anything is sent.
|
||||
- Uploads carry `consent: {mode, notice_version}` in the manifest so the
|
||||
server can reject stale-consent uploads outright.
|
||||
|
||||
## Bundle format (shared contract)
|
||||
|
||||
An upload is `multipart/form-data` with exactly two parts, in order:
|
||||
|
||||
1. `manifest` — `application/json`, ≤ 64 KiB. Queryable summary; stored in
|
||||
Postgres.
|
||||
2. `bundle` — `application/gzip`: a tar.gz archive, `manifest.json` first.
|
||||
Entry names come from a **fixed allowlist**, and each name implies its
|
||||
media type — there is no per-entry type metadata to drift:
|
||||
|
||||
```jsonc
|
||||
manifest.json (manifest minus the `archive` object; see hashing note)
|
||||
device.json (full device snapshot)
|
||||
logs.jsonl (structured log lines, newest-last)
|
||||
crash/summary.json (normalized crash info incl. provenance)
|
||||
crash/stack.txt (JVM stack / ANR trace, when present)
|
||||
crash/tombstone.pb (Android native tombstone, opaque bytes, API 31+)
|
||||
crash/metrickit.json (raw MetricKit diagnostic JSON, iOS)
|
||||
breadcrumbs.jsonl (persisted pre-crash context journal, when present)
|
||||
```
|
||||
|
||||
Hashing note: `archive.sha256` and the size fields in part 1 describe the
|
||||
finalized `bundle` part bytes as transmitted. Since the archive is hashed
|
||||
after it is built, the embedded `manifest.json` is the manifest **without**
|
||||
the `archive` object; the server verifies part 1's `archive` fields against
|
||||
the received blob. `archive.bytes` is the transmitted (gzip) size;
|
||||
`archive.uncompressed_bytes` is the total decompressed tar stream size,
|
||||
including tar headers, the end-of-archive marker, and record padding —
|
||||
i.e. the byte count between the client's tar writer and gzip writer, and
|
||||
what `gzip -l` reports. Standard end-of-archive zero padding (up to 64 KiB)
|
||||
is accepted; any other trailing data is rejected.
|
||||
|
||||
The contract lives in this repo as a versioned JSON Schema
|
||||
(`docs/design/schemas/client-diagnostics/v1/`) with canonical valid and
|
||||
invalid fixtures; Go, Kotlin, and Swift CI all validate against the same
|
||||
fixtures. Servers accept all schema versions they support and ignore unknown
|
||||
additive fields.
|
||||
|
||||
### Manifest (part 1)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "crash", // crash | anr | native_crash | hang | abnormal_exit | manual
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"capture_session_id": "run_c7d…", // UUID per app run, also stamped on log lines
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android-tv", // android | android-tv | ios | tvos (macos reserved)
|
||||
"os_version": "11 (API 30)",
|
||||
"profile_id": "prof_…" // capturing profile, attribution only
|
||||
},
|
||||
"destination": { "server_instance_id": "srv_…" }, // consent binding; server rejects mismatches
|
||||
"consent": { "mode": "prompt", "notice_version": 1 }, // prompt | always | manual
|
||||
"crash": { // absent for manual
|
||||
"summary": "NullPointerException in PlaybackSessionManager.start",
|
||||
"stack_excerpt": "…truncated ≤ 8 KiB; full artifact in archive…",
|
||||
"thread": "main",
|
||||
"foreground": true,
|
||||
"source": "ueh", // ueh | exit_info | metrickit | exit_sentinel
|
||||
"provenance": "pre_failure", // pre_failure | post_restart | metric_reporting_period
|
||||
"occurred_at": "2026-07-19T18:22:31Z" // best known; MetricKit reports carry period bounds
|
||||
},
|
||||
"device_summary": { "manufacturer":"NVIDIA", "model":"SHIELD Android TV", "os":"11", "form_factor":"tv" },
|
||||
"playback_session_ids": ["ps_9f2…"],
|
||||
"log_summary": { "lines": 3841, "bytes_gz": 412034, "dropped_lines": 12, "categories": ["playback","focus","network"], "debug_logging": true },
|
||||
"archive": { "entries": ["manifest.json","device.json","logs.jsonl","crash/summary.json","crash/stack.txt"], "bytes": 498112, "uncompressed_bytes": 3110400, "sha256": "…" }
|
||||
}
|
||||
```
|
||||
|
||||
### Log line schema (`logs.jsonl`)
|
||||
|
||||
```json
|
||||
{"ts":"2026-07-19T18:22:29.412Z","run":"run_c7d…","lvl":"W","cat":"playback","tag":"AudioCapabilityMgr","msg":"passthrough suppressed: HDMI sink lost TrueHD","attrs":{"sink":"HDMI","fmt":"truehd"}}
|
||||
```
|
||||
|
||||
- `lvl`: `V|D|I|W|E`. `cat`: `playback`, `focus`, `network`, `lifecycle`,
|
||||
`browse`, `cast`, `download`, `crash`, `other`.
|
||||
- `run` (capture_session_id) distinguishes lines from prior app runs when
|
||||
breadcrumbs or debug files span sessions.
|
||||
- Playback additionally emits periodic (~5s) player-stats snapshot lines
|
||||
(`"cat":"playback","tag":"StatsSnapshot"`) built from the existing stats
|
||||
models — a timeline of decoder, resolution, HDR mode, bitrate, dropped
|
||||
frames, and audio underruns around the failure.
|
||||
|
||||
### Safe logging contract (redaction happens at collection time)
|
||||
|
||||
Best-effort scrubbing at package time is not enough: today's client logs
|
||||
already emit full URLs with query strings, token suffixes, and raw exception
|
||||
strings (e.g. the Apple `HTTPClient` response logging and player URL prints;
|
||||
Android exception messages containing backend URLs). Therefore:
|
||||
|
||||
- The facade accepts **typed attributes**, not preformatted strings, for
|
||||
anything risky: URL values are normalized (userinfo, query, and fragment
|
||||
always stripped; path preserved), exceptions pass through a sanitizing
|
||||
formatter, and free-text `msg` is authored, not interpolated from network
|
||||
data.
|
||||
- Attribute keys are **normatively registered**: a per-category key registry
|
||||
(checked in next to the JSON Schema) declares each key and its value type
|
||||
(scalar types plus a few enumerated units). Unregistered keys fail debug
|
||||
builds/CI and are dropped in production builds.
|
||||
- Existing call sites are migrated **curated, not mechanically** — each
|
||||
migrated line is reviewed against the contract. Unmigrated `Log.*`/`print`
|
||||
call sites simply never enter bundles.
|
||||
- Defense in depth at bundle time: values matching the account's current
|
||||
access/profile tokens are replaced before packaging.
|
||||
- Golden leak-fixture tests (JWTs, query tokens, signed URLs, cookies, token
|
||||
fragments) run in all three repos' CI against the facade and the bundle
|
||||
builder.
|
||||
- The network category logs `method, templated path, status, duration`;
|
||||
never headers, bodies, query strings, or full URLs.
|
||||
|
||||
### Device snapshot (`device.json`)
|
||||
|
||||
Fields are tri-state where a platform can't provide them: a value,
|
||||
`"unknown"` (probe exists, couldn't determine), or `"not_collected"`
|
||||
(no probe on this platform). Every snapshot carries `captured_at` and
|
||||
`provenance` (`pre_failure` for snapshots persisted with the incident,
|
||||
`post_restart` for snapshots taken at bundle-build time).
|
||||
|
||||
```json
|
||||
{
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"provenance": "pre_failure",
|
||||
"identity": { "manufacturer":"NVIDIA", "model":"SHIELD Android TV", "device":"mdarcy", "form_factor":"tv", "device_id":"…" },
|
||||
"display": { "mode":"3840x2160@59.94", "modes_supported":[…], "hdr_types":["HDR10","HLG","DV"], "wide_gamut":true },
|
||||
"audio": { "outputs":[{"type":"HDMI","encodings":["AC3","EAC3","TRUEHD"],"channels":8}], "passthrough":["AC3","EAC3","TRUEHD"], "suppressions":[…] },
|
||||
"video_codecs": [ { "mime":"video/hevc", "hw":true, "profiles":["Main10","DV P8"], "max":"4096x2160@60" } ],
|
||||
"network": { "transport":"ethernet" }
|
||||
}
|
||||
```
|
||||
|
||||
Android sources: `AndroidDeviceMetadataProvider` (id/model/platform/app
|
||||
version), `MediaCodecCapabilitiesProbe` (codec + HDR decode caps),
|
||||
`AudioCapabilityManager` (aggregate passthrough). **New accessors required**
|
||||
(the probes exist but don't currently export everything): current + supported
|
||||
`Display.Mode` list (DisplayHdrProbe exposes HDR support only), exact
|
||||
identity fields (`Build.DEVICE`, form factor), `AudioManager.getDevices()`
|
||||
output enumeration, and a snapshot/export API on
|
||||
`PassthroughSuppressionRegistry` (its set is currently private).
|
||||
|
||||
Apple sources: `AppleDeviceIdentity.current` (persisted id, device name,
|
||||
platform) and `ApplePlaybackV3Capabilities.snapshot()` (codec/HDR/device
|
||||
context). **Honesty constraints:** the current `outputSnapshot()` helper is
|
||||
private, reports route UIDs/types rather than full output capabilities, and
|
||||
its passthrough payload is a placeholder — so Apple v1 reports audio as
|
||||
route metadata with `passthrough: "unknown"` until a real probe exists, and
|
||||
the private helper is refactored into an internal shared accessor so
|
||||
diagnostics can reuse it without duplicating route logic. Audio route UIDs
|
||||
are persistent peripheral identifiers and are one-way hashed before
|
||||
inclusion.
|
||||
|
||||
### Playback session correlation
|
||||
|
||||
Clients include recent server playback session IDs so admins can pivot into
|
||||
the existing server-side logs (`operational_logs.playback_session_id` +
|
||||
the admin logs filter). Both clients currently retain only the *active*
|
||||
session ID (Android `PlaybackSessionManager`, Apple `PlaybackSessionBridge`),
|
||||
so each client adds a small **bounded, persisted recent-session tracker**
|
||||
(last ~10 session IDs with timestamps) that survives restarts — required for
|
||||
next-launch crash reports.
|
||||
|
||||
## Server design (`silo-server`)
|
||||
|
||||
### Feature gate
|
||||
|
||||
Borrows the *behavior* of the requests gate (status endpoint + per-call
|
||||
guard that rereads settings) but stores keys in the general `server_settings`
|
||||
store — requests uses its own dedicated table, which diagnostics doesn't
|
||||
need. Key naming follows existing namespaces (`opslog.*`,
|
||||
`audiobooks.enabled`, `playback.protocol_v3_enabled`):
|
||||
|
||||
- `diagnostics.uploads_enabled` (bool, default `false`)
|
||||
- `diagnostics.max_bundle_bytes` (default 10 MiB compressed)
|
||||
- `diagnostics.max_uncompressed_bytes` (default 64 MiB)
|
||||
- `diagnostics.max_reports_per_user_per_day` (default 20)
|
||||
- `diagnostics.retention_days` (default 30)
|
||||
- `diagnostics.max_bytes_per_user` (default 200 MiB)
|
||||
|
||||
Enabling `diagnostics.uploads_enabled` **requires a storage validation**
|
||||
(private object store configured and writable); the admin settings API
|
||||
refuses to enable it otherwise. `S3Private` is a nullable dependency in the
|
||||
router and is only populated when configured, so the feature must degrade
|
||||
predictably, not 500.
|
||||
|
||||
### Status endpoint
|
||||
|
||||
`GET /api/v1/diagnostics/status` — authenticated (`RequireAuth`), **no
|
||||
profile required**: crashes during login/profile selection must still be
|
||||
reportable, so capability discovery and upload are account-scoped with
|
||||
optional profile attribution.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "available", // available | disabled | storage_unavailable
|
||||
"server_instance_id": "srv_…", // stable; binds consent + pending reports
|
||||
"accepted_schema_versions": [1],
|
||||
"max_bundle_bytes": 10485760,
|
||||
"max_manifest_bytes": 65536,
|
||||
"retention_days": 30,
|
||||
"consent_notice_version": 1
|
||||
}
|
||||
```
|
||||
|
||||
Clients cache this alongside existing server-driven config refresh.
|
||||
|
||||
### Ingest endpoint
|
||||
|
||||
`POST /api/v1/diagnostics/reports` — `RequireAuth`; access-token
|
||||
`Authorization` header only (no query credentials; `sa_` API keys rejected
|
||||
for diagnostics). Optional validated profile attribution. Hardened parsing —
|
||||
the avatar handler is the *precedent* (multipart + `PutObject`), but its
|
||||
buffer-everything approach (`ParseMultipartForm` + `io.ReadAll`) is not
|
||||
reused:
|
||||
|
||||
1. Guard: `diagnostics.uploads_enabled` + storage present, else stable error
|
||||
codes (`disabled`, `storage_unavailable`).
|
||||
2. `http.MaxBytesReader` with `max_bundle_bytes` + manifest overhead before
|
||||
any parsing; request deadline; per-account concurrency cap (1 in-flight)
|
||||
and a server-global concurrency ceiling, on top of the existing rate-limit
|
||||
middleware.
|
||||
3. Streaming `multipart.Reader`: exactly the two named parts in order;
|
||||
manifest capped at 64 KiB and schema-validated; unknown parts rejected.
|
||||
4. Quota reservation: in one transaction holding a per-account advisory
|
||||
lock (`pg_advisory_xact_lock(user_id)` — serializing concurrent uploads
|
||||
from multiple devices on one account), check the per-day count and
|
||||
per-user byte quota and insert the report row in state `receiving`
|
||||
(server-generated UUID + `short_id`). Failures return `quota_exceeded`
|
||||
(429, with `retry_after`) or `too_large` (413) — machine-readable so
|
||||
clients back off and keep the report pending locally.
|
||||
5. Stream the `bundle` part directly to its final key
|
||||
(`diagnostics/{user_id}/{report_id}.tar.gz`) while hashing and counting
|
||||
bytes; enforce the compressed cap during streaming. Validate the
|
||||
gzip/tar structure and per-entry limits (entry count, name allowlist,
|
||||
uncompressed total ≤ `max_uncompressed_bytes`, compression ratio bound)
|
||||
in the same bounded streaming pass. Reject on violation
|
||||
(`invalid_bundle`, `unsupported_schema`). There is no staging/rename
|
||||
step — S3-style stores have no cheap rename, and the row's `state` is
|
||||
the source of truth: a blob is not a report until its row is `ready`.
|
||||
6. On success, update the row to `ready` with sizes + sha256. On any
|
||||
failure: delete the object, mark the row `failed` (compensation); the
|
||||
reconciler (below) cleans up stragglers from process death mid-request.
|
||||
7. Respond `201 {"report_id":"…","short_id":"SILO-…"}` and emit a normalized
|
||||
operational event (accepted/rejected, account, sizes, result).
|
||||
|
||||
`short_id` is 12 Crockford-Base32 characters, server-generated with
|
||||
collision retry, case-insensitively unique, shown to the user for support
|
||||
conversations, filterable in the admin API — and explicitly not an
|
||||
authorization secret.
|
||||
|
||||
New code: `internal/diagnostics/` (service + store interface), handler in
|
||||
`internal/api/handlers/diagnostics.go`, wired through `Dependencies` in
|
||||
`internal/api/router.go`. The store interface is diagnostics-owned (the
|
||||
avatar handler's is package-private and presign-only) and covers everything
|
||||
the design uses: a **streaming** put (`io.Reader` — the existing s3client
|
||||
`PutObject` takes `[]byte`, so this is a small s3client addition),
|
||||
`GetObject` (streamed admin download fallback), `DeleteObject`,
|
||||
`ListObjects(prefix)` (reconciler), and `PresignGetURL`.
|
||||
|
||||
### Storage
|
||||
|
||||
```sql
|
||||
CREATE TABLE client_diagnostic_reports (
|
||||
id UUID PRIMARY KEY,
|
||||
short_id TEXT NOT NULL, -- unique, case-insensitive (expression index)
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
profile_id TEXT, -- capturing profile, nullable
|
||||
state TEXT NOT NULL, -- receiving | ready | failed
|
||||
captured_at TIMESTAMPTZ NOT NULL, -- client claim
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- server truth; retention keys off this
|
||||
report_type TEXT NOT NULL, -- crash|anr|native_crash|hang|abnormal_exit|manual
|
||||
platform TEXT NOT NULL,
|
||||
app_version TEXT NOT NULL,
|
||||
crash_summary TEXT, -- truncated ≤ 8 KiB
|
||||
manifest JSONB NOT NULL, -- part 1 verbatim (≤ 64 KiB by construction)
|
||||
playback_session_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
blob_bucket TEXT,
|
||||
blob_key TEXT,
|
||||
blob_bytes BIGINT,
|
||||
uncompressed_bytes BIGINT,
|
||||
blob_sha256 TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX ON client_diagnostic_reports (lower(short_id));
|
||||
CREATE INDEX ON client_diagnostic_reports (user_id, received_at DESC);
|
||||
CREATE INDEX ON client_diagnostic_reports (received_at);
|
||||
```
|
||||
|
||||
Plain table, not partitioned — volume is orders of magnitude below
|
||||
`operational_logs`; the byte-heavy payload lives in object storage. Full
|
||||
stacks/traces live only in the archive; the row holds a truncated summary.
|
||||
|
||||
### Admin API
|
||||
|
||||
Under the acting-admin subtree (same placement as the `admin_logs.go`
|
||||
routes):
|
||||
|
||||
- `GET /api/v1/admin/diagnostics/reports` — list; filters: user, platform,
|
||||
type, date range, `short_id` (exact, case-insensitive); paginated.
|
||||
- `GET /api/v1/admin/diagnostics/reports/{id}` — row incl. manifest.
|
||||
- `GET /api/v1/admin/diagnostics/reports/{id}/download` — presigned GET URL
|
||||
(primary, matching avatar behavior) with a streamed `GetObject` proxy
|
||||
fallback for deployments where presigned URLs aren't reachable — the
|
||||
fallback is new behavior, not something the avatar path provides.
|
||||
- `DELETE /api/v1/admin/diagnostics/reports/{id}` — row + blob.
|
||||
|
||||
Admin download and delete emit audit/operational events. Web admin gets a
|
||||
list/detail/download page next to the logs viewer, with a
|
||||
playback-session-id link into the existing filtered logs view. Native apps
|
||||
add no admin screens in v1 (product policy).
|
||||
|
||||
### Retention + reconciliation
|
||||
|
||||
A taskmanager task `internal/taskmanager/tasks/cleanup_client_diagnostics.go`
|
||||
mirroring `cleanup_operational_log.go`, registered in `cmd/silo/main.go`
|
||||
with the other cleanup tasks:
|
||||
|
||||
- Delete reports older than `diagnostics.retention_days` (by `received_at`),
|
||||
then enforce `diagnostics.max_bytes_per_user` oldest-first. Blob deleted
|
||||
first (tolerating already-missing objects), then the row; deletes are
|
||||
idempotent.
|
||||
- Reconcile: remove `receiving`/`failed` rows older than a grace window,
|
||||
and (via `ListObjects`) any `diagnostics/*` objects whose row is absent,
|
||||
`failed`, or stale-`receiving` — so partial failures and process deaths
|
||||
mid-request never leak storage.
|
||||
- Account deletion cascades rows (FK) ; the reconciler then removes orphaned
|
||||
blobs.
|
||||
|
||||
## Android design (`silo-android`)
|
||||
|
||||
New package
|
||||
`android-shared/src/androidMain/kotlin/org/siloserver/silo/common/diagnostics/`.
|
||||
|
||||
### Logging
|
||||
|
||||
- `SiloLog.kt` — safe-logging facade per the contract above:
|
||||
`SiloLog.d(cat, tag, msg, attrs)` (+`i/w/e`, sanitized `Throwable`).
|
||||
Always forwards to `android.util.Log` (today's behavior unchanged) and
|
||||
offers the entry to the ring buffer.
|
||||
- `LogRing.kt` — always-on in-memory ring: fixed-size array of pre-rendered
|
||||
compact entries, **non-blocking** writes (atomic index; a torn final entry
|
||||
during crash snapshot is acceptable), ~4000 entries / ~1.5 MB cap, lazy
|
||||
attribute formatting off the hot path, dropped-entry counter. Must add no
|
||||
measurable contention to playback; a playback benchmark gate accompanies
|
||||
the PR that wires player events in.
|
||||
- `DiagnosticsFileLogger.kt` — only while debug logging is on: a
|
||||
single-writer coroutine draining a bounded channel (drop + count under
|
||||
backpressure) into **append-only framed segment files** (5 × 2 MB,
|
||||
app-private `files/diagnostics/logs/`, `noBackup`), compressed only at
|
||||
bundle-build time — a process death never corrupts more than the tail of
|
||||
the active segment (unlike a single long-lived gzip stream).
|
||||
- Playback wiring: each `PlaybackAnalyticsListener` event plus a ~5s periodic
|
||||
`PlayerStatsSnapshot` line while debug logging is on (reuses the existing
|
||||
reducer). TV focus logging hooks the TV app's focus observers under
|
||||
`cat=focus`. Network: a Ktor hook logging `method, templated path, status,
|
||||
duration_ms` only.
|
||||
|
||||
### Crash and abnormal-exit capture
|
||||
|
||||
- `CrashCapture.kt` — installed as early as possible in both `Application`
|
||||
classes; idempotent; captures the previous
|
||||
`Thread.getDefaultUncaughtExceptionHandler` once. On crash it does the
|
||||
minimum on the dying thread: render the stack (bounded), copy what the
|
||||
non-blocking ring exposes within a hard time budget, write **one bounded
|
||||
marker file** (≤ ~512 KiB) via temp-write + atomic rename, and invoke the
|
||||
prior handler in a `finally`. No locks shared with the ring's write path,
|
||||
no coroutines, no allocation-heavy JSON building (preformatted line
|
||||
buffer). Bundle assembly happens on next launch, never in the handler.
|
||||
At most 3 pending reports are kept (oldest dropped) as a crash-loop guard.
|
||||
- `ExitInfoCollector.kt` — API 30+ enhancement (minSdk is 24; many Fire TV
|
||||
devices ship below API 30, where the handler is the only source): reads
|
||||
`ActivityManager.getHistoricalProcessExitReasons()` on launch.
|
||||
`REASON_CRASH`, `REASON_CRASH_NATIVE`, `REASON_ANR` become pending
|
||||
reports. Dedupe uses a persisted bounded **fingerprint set** (process
|
||||
name, pid, timestamp, reason, status, trace hash) — not a raw timestamp
|
||||
watermark, which loses same-stamp events and breaks on clock changes.
|
||||
`getTraceInputStream()` is treated as optional opaque bytes with a bounded
|
||||
read: ANR text → `crash/stack.txt`; API 31+ native tombstone protobuf →
|
||||
`crash/tombstone.pb` (never decoded into the manifest). UEH-captured
|
||||
events are correlated by fingerprint so the same death isn't reported
|
||||
twice.
|
||||
- Next foreground launch, per the consent model: Ask → aggregated,
|
||||
incident-specific prompt (one prompt covering N pending reports; default
|
||||
focus **Don't send** on TV; "Always send" requires a second confirmation;
|
||||
full-screen review flow on TV rather than a small dialog); Always →
|
||||
silent upload, at most one bundle per crash fingerprint per day;
|
||||
Never → purge. Per-fingerprint prompt suppression with backoff prevents
|
||||
crash-loop prompt fatigue. Pending reports expire after 7 days —
|
||||
visibly (the Diagnostics screen shows pending reports with expiry), not
|
||||
silently.
|
||||
|
||||
### Device snapshot
|
||||
|
||||
`DeviceSnapshotCollector.kt` assembles `device.json` from the existing
|
||||
probes plus the new accessors listed in the contract section (display mode
|
||||
list, `Build.DEVICE`/form factor, `AudioManager.getDevices()` enumeration,
|
||||
suppression-registry export). A compact snapshot is persisted with each
|
||||
crash marker (`provenance: pre_failure`) since post-restart audio/display
|
||||
state can differ from crash-time state.
|
||||
|
||||
### Settings + consent UI
|
||||
|
||||
- `DiagnosticsSettingsStore.kt` — DataStore. Deliberately **not** the
|
||||
per-profile pattern of `AndroidPlayerSettingsStore`: consent and pending
|
||||
reports are keyed by server+account (per the consent model), and debug
|
||||
logging is per-device. Holds consent records, crash-report mode,
|
||||
debug-logging flag, fingerprint set, session tracker, and status cache.
|
||||
- Phone: "Diagnostics" section in `SettingsScreen.kt`; TV: rail category in
|
||||
`TvSettingsScreen.kt`. For non-child profiles the section is **always
|
||||
visible** and state-aware rather than hidden when the server gate is off — it distinguishes
|
||||
`available` / `disabled by server` / `storage unavailable` / `offline`,
|
||||
and always shows local pending reports (count, size, expiry) with
|
||||
review/delete controls. Uploads are simply unavailable in non-available
|
||||
states; a report captured while the gate was off is not auto-uploaded
|
||||
later unless consent for that server+account exists.
|
||||
- Manual capture is the primary support flow and is stronger than a bare
|
||||
"send now": **Start diagnostic capture** (turns on verbose capture,
|
||||
shows an indicator) → user reproduces the issue → **Stop & review** →
|
||||
summary (categories, time range, sizes, destination) → send. A one-shot
|
||||
"Send diagnostics now" remains for quick cases but warns when only the
|
||||
in-memory ring is available ("this bundle contains only the last few
|
||||
minutes of basic logs").
|
||||
- After upload, the `short_id` is displayed and kept in a small sent
|
||||
history so a user can read it to their admin later.
|
||||
|
||||
### Upload
|
||||
|
||||
- `shared/commonMain/…/network/api/DiagnosticsApi.kt` — `getStatus()` and
|
||||
`uploadReport(manifestJson, bundleBytes)` via Ktor
|
||||
`MultiPartFormDataContent` (first multipart use in the client — verified
|
||||
none exists — written as a small reusable helper), using the existing
|
||||
`safeApiCall`/`ApiResult` pattern, wired through Koin like other `*Api`
|
||||
classes.
|
||||
- `DiagnosticsUploader.kt` — builds the tar.gz (marker + ring/segment files
|
||||
overlapping the incident window + breadcrumbs + snapshot), streams it to
|
||||
the endpoint, deletes the pending report only on 201, records `short_id`.
|
||||
On `quota_exceeded`/`too_large`/`disabled`/`storage_unavailable` it keeps
|
||||
the pending report and backs off per `retry_after`; on
|
||||
`unsupported_schema` it surfaces "server needs an update" rather than
|
||||
silently retrying until expiry.
|
||||
|
||||
## Apple design (`silo-apple`)
|
||||
|
||||
Mirrors Android with platform-native mechanisms, and with tvOS scoped
|
||||
honestly — **MetricKit diagnostic payloads (crash/hang) are iOS-only; the
|
||||
SDK marks them unavailable on tvOS**, and delivery is delayed (typically
|
||||
daily, while the app runs), not "next launch".
|
||||
|
||||
- **Ring + facade**: `DiagLog` implementing the same safe-logging contract.
|
||||
The player's existing `cmpLog` helper (`PlayerLog.swift`) is **extended**
|
||||
into a tee — it keeps `print()`ing (preserving `devicectl --console`
|
||||
capture on tvOS) and also feeds the ring; remaining direct `print()` call
|
||||
sites in `Screens/Player/` migrate to it curated, per the redaction
|
||||
contract. OSLog-based subsystems are harvested at bundle-build time via
|
||||
`OSLogStore(scope: .currentProcessIdentifier)` (deployment targets are
|
||||
iOS/tvOS 26, far above the OSLogStore 15+ floor), merged into
|
||||
`logs.jsonl` with provenance.
|
||||
- **Breadcrumb journal**: because MetricKit reports arrive in a *later*
|
||||
process, the in-memory ring is gone by delivery time. While crash
|
||||
reporting is enabled, a small append-only breadcrumb journal (bounded,
|
||||
rotating, crash-safe appends: lifecycle transitions, playback start/stop,
|
||||
route changes, screen changes) persists across runs and becomes
|
||||
`breadcrumbs.jsonl`, matched to a diagnostic by `capture_session_id` and
|
||||
time window. Snapshots taken at bundle-build time are labeled
|
||||
`provenance: post_restart`; the design never presents post-restart state
|
||||
as crash-time state.
|
||||
- **iOS crash/hang detection**: an `MXMetricManager` subscriber. Each
|
||||
received `MXCrashDiagnostic`/`MXHangDiagnostic` becomes its **own**
|
||||
pending report: raw diagnostic JSON preserved as `crash/metrickit.json`,
|
||||
deduped by a canonical hash of the diagnostic (not timestamps), carrying
|
||||
its own app version and reporting-period bounds. Prompt copy is
|
||||
type-specific ("crashed" vs "was not responding") and honest about timing
|
||||
("yesterday during playback"). Delivery is verified on physical devices;
|
||||
parsing is tested via injected fixtures.
|
||||
- **tvOS abnormal-exit detection**: no crash stacks in v1. A dirty-exit
|
||||
sentinel (marker written at launch, cleared on clean
|
||||
background/termination) plus the breadcrumb journal yields
|
||||
`type: abnormal_exit` reports — "Silo did not shut down cleanly last
|
||||
time" — with breadcrumbs and device snapshot but no stack. An in-process
|
||||
crash reporter for tvOS is explicitly deferred (see Non-goals).
|
||||
- **Device snapshot**: `AppleDeviceIdentity.current` +
|
||||
`ApplePlaybackV3Capabilities.snapshot()` for identity/codec/HDR context;
|
||||
audio reported as route metadata with hashed route UIDs and
|
||||
`passthrough: "unknown"` until a real output-capability probe exists (the
|
||||
current one is a private placeholder).
|
||||
- **Settings/consent**: Diagnostics section in `SettingsView` (iOS) and
|
||||
`TVSettingsView` (tvOS) with the same state-aware presentation, manual
|
||||
capture flow, and consent rules.
|
||||
- **Upload**: a small multipart body writer added to `HTTPClient` (verified:
|
||||
JSON-only today, no multipart/uploadTask), same status/error handling as
|
||||
Android.
|
||||
- Housekeeping note: product bundle IDs are already `org.siloserver.silo`;
|
||||
only legacy keychain/logging identifiers (`com.continuum.*`) remain, so
|
||||
nothing here blocks on rebranding.
|
||||
|
||||
## Privacy summary
|
||||
|
||||
- **Consent boundaries**: no network transmission of diagnostic data without
|
||||
prompt confirmation, a server+account-bound Always election, or an
|
||||
explicit manual send. The always-on ring is memory-only; persistent
|
||||
capture (segments, breadcrumbs, markers) is app-private, size-capped,
|
||||
backup-excluded, and purged by Never/sign-out/server removal.
|
||||
- **Never collected**: credentials, `Authorization`/profile-token values,
|
||||
request bodies, query strings, full URLs (normalized at collection),
|
||||
unsanitized exception dumps. Bearer-token matching at bundle time as
|
||||
defense in depth. Golden leak fixtures in CI.
|
||||
- **Deliberately collected** (disclosed in View report): device identity
|
||||
incl. device name, media titles appearing in playback logs, templated
|
||||
server paths. The recipient is the user's own server admin, who already
|
||||
has server-side access to this information; that is the trust model this
|
||||
feature is scoped to — and why third-party destinations are out.
|
||||
- **Persistent identifiers**: the existing device IDs (already sent to the
|
||||
server today) plus hashed audio-route UIDs; each is enumerated in the
|
||||
schema docs with its purpose and retention.
|
||||
- **User control**: state-aware Diagnostics screen with pending list,
|
||||
review, delete, sent history; admin-side deletion; account deletion
|
||||
cascades reports; admin access is acting-admin-only and audited.
|
||||
- **Bounded retention**: defaults 30 days / 200 MiB per user, admin-tunable,
|
||||
enforced server-side.
|
||||
|
||||
## Rollout plan (vertical slices, each independently shippable)
|
||||
|
||||
1. **Server foundation** — settings + storage-validated gate, status
|
||||
endpoint, hardened ingest with state machine, schema + fixtures, admin
|
||||
API, retention/reconciler task, operational audit events. Feature stays
|
||||
off; nothing user-visible.
|
||||
2. **Contract fixtures in clients** — JSON Schema + golden fixtures wired
|
||||
into Android/Apple CI; safe-logging facades + rings land **dark** (no
|
||||
UI, no capture persistence, no prompts).
|
||||
3. **Android end-to-end slice** — consent model + Diagnostics settings UI +
|
||||
crash marker/ExitInfo capture + device snapshot + uploader + manual
|
||||
capture flow, with the initial curated logging migration (player tags +
|
||||
TV focus + network hook + stats snapshots). First release where a user
|
||||
can be asked for, and send, a useful report.
|
||||
4. **Android coverage expansion** — remaining curated call-site migration,
|
||||
breadcrumbs-on-Android if wanted, prompt-fatigue tuning from the field.
|
||||
5. **Apple slice** — cmpLog tee + OSLogStore harvest + breadcrumbs +
|
||||
MetricKit (iOS) + exit sentinel (tvOS) + settings/consent + multipart
|
||||
upload.
|
||||
6. **Web admin viewer + correlation UX** — list/detail/download page,
|
||||
playback-session pivot into the logs viewer.
|
||||
7. **Later / separate decisions** — symbolication job, tvOS in-process
|
||||
crash reporter, native-app admin viewer, macOS.
|
||||
|
||||
Slices 3 and 5 ship complete consent→capture→upload flows; intermediate
|
||||
releases never prompt for reports they can't send.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Should users be able to delete their **uploaded** reports from the client
|
||||
(self-service deletion), or is pending-report control + admin deletion
|
||||
enough for v1?
|
||||
2. tvOS: is abnormal-exit detection (no stacks) acceptable for v1, or does
|
||||
the Shield/Fire TV debugging need justify evaluating an in-process crash
|
||||
reporter (PLCrashReporter/KSCrash) with its signal-safety and review
|
||||
trade-offs?
|
||||
3. Do we want breadcrumbs on Android in v1 (cheap once the facade exists),
|
||||
or is the UEH marker + ExitInfo enough there?
|
||||
4. Localization of prompt/consent strings at launch vs English-first.
|
||||
5. Exact quota defaults (20/day, 200 MiB/user, 10 MiB/bundle) — tune before
|
||||
enabling anywhere real.
|
||||
6. Whether the status payload should also fold into an existing server-driven
|
||||
config refresh response later (clients poll both today); the dedicated
|
||||
endpoint ships first either way.
|
||||
|
||||
## Review notes
|
||||
|
||||
This spec was adversarially reviewed before the draft PR: two independent
|
||||
review passes (a fact-check of every codebase claim against the three repos,
|
||||
and a design attack across consent, crash-capture correctness, ingest
|
||||
robustness, storage consistency, UX, and phasing). Material corrections
|
||||
adopted include: tvOS MetricKit unavailability and honest MetricKit timing;
|
||||
server+account-bound consent; storage-validated gating (private object store
|
||||
is optional in real deployments); streaming bounded ingest with a report
|
||||
state machine and orphan reconciliation; the archive container replacing a
|
||||
bare log gzip; collection-time typed redaction replacing scrub-at-package;
|
||||
fingerprint-based ExitInfo dedupe; non-blocking ring + bounded crash marker;
|
||||
recent-session trackers (only the active playback session ID exists today);
|
||||
and honest Apple snapshot capabilities (passthrough currently unknown).
|
||||
@@ -0,0 +1,5 @@
|
||||
The canonical client-diagnostics v1 contract lives in `v1/`.
|
||||
Clients vendor the schema files, attribute registry, and fixtures from this directory.
|
||||
Servers accept supported schema versions and ignore unknown additive fields.
|
||||
Within v1, changes are additive only: new optional fields, enum values after coordinated client support, and new allowlisted archive entries.
|
||||
Breaking changes require a new versioned contract directory and explicit server support.
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://silo-server.dev/schemas/client-diagnostics/v1/attr-registry.schema.json",
|
||||
"schema_version": 1,
|
||||
"policy": "Attribute keys are registered per category. Debug builds and CI should fail unregistered keys; production clients drop unregistered keys before upload.",
|
||||
"categories": {
|
||||
"playback": {
|
||||
"sink": {
|
||||
"type": "string",
|
||||
"description": "Current audio output sink, such as HDMI or speaker."
|
||||
},
|
||||
"fmt": {
|
||||
"type": "string",
|
||||
"description": "Media or passthrough format identifier, such as truehd."
|
||||
},
|
||||
"decoder": {
|
||||
"type": "string",
|
||||
"description": "Selected decoder name."
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"description": "Video width in pixels."
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"description": "Video height in pixels."
|
||||
},
|
||||
"hdr_mode": {
|
||||
"type": "string",
|
||||
"description": "Active HDR mode or selected tone-mapping mode."
|
||||
},
|
||||
"bitrate_kbps": {
|
||||
"type": "integer",
|
||||
"description": "Observed or selected bitrate in kilobits per second."
|
||||
},
|
||||
"dropped_frames": {
|
||||
"type": "integer",
|
||||
"description": "Dropped video frame count."
|
||||
},
|
||||
"audio_underruns": {
|
||||
"type": "integer",
|
||||
"description": "Audio underrun count."
|
||||
}
|
||||
},
|
||||
"focus": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Focusable element or logical destination."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Focus action or navigation direction."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "HTTP method."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Templated path without query string."
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"description": "HTTP status code."
|
||||
},
|
||||
"duration_ms": {
|
||||
"type": "integer",
|
||||
"description": "Request duration in milliseconds."
|
||||
}
|
||||
},
|
||||
"lifecycle": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "App lifecycle state."
|
||||
}
|
||||
},
|
||||
"crash": {
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "Client-side dedupe fingerprint."
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Crash source matching manifest crash.source."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/client-diagnostics/v1/device.schema.json",
|
||||
"title": "Silo Client Diagnostics Device Snapshot v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"captured_at",
|
||||
"provenance",
|
||||
"identity",
|
||||
"display",
|
||||
"audio",
|
||||
"video_codecs",
|
||||
"network"
|
||||
],
|
||||
"properties": {
|
||||
"captured_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"maxLength": 64
|
||||
},
|
||||
"provenance": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pre_failure",
|
||||
"post_restart"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "#/$defs/tri_state_object"
|
||||
},
|
||||
"display": {
|
||||
"$ref": "#/$defs/tri_state_object"
|
||||
},
|
||||
"audio": {
|
||||
"$ref": "#/$defs/tri_state_object"
|
||||
},
|
||||
"video_codecs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"$ref": "#/$defs/open_object"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/sentinel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"network": {
|
||||
"$ref": "#/$defs/tri_state_object"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"sentinel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"unknown",
|
||||
"not_collected"
|
||||
]
|
||||
},
|
||||
"tri_state_object": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/open_object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/sentinel"
|
||||
}
|
||||
]
|
||||
},
|
||||
"open_object": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/tri_state_value"
|
||||
}
|
||||
},
|
||||
"tri_state_value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"$ref": "#/$defs/tri_state_value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/open_object"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "manual",
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"capture_session_id": "run_bad_archive_entry",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android",
|
||||
"os_version": "15 (API 35)",
|
||||
"profile_id": "prof_tablet"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "manual",
|
||||
"notice_version": 1
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Google",
|
||||
"model": "Pixel Tablet",
|
||||
"os": "15",
|
||||
"form_factor": "tablet"
|
||||
},
|
||||
"playback_session_ids": [],
|
||||
"log_summary": {
|
||||
"lines": 1,
|
||||
"bytes_gz": 100,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"other"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json",
|
||||
"../secrets.txt"
|
||||
],
|
||||
"bytes": 100,
|
||||
"uncompressed_bytes": 200,
|
||||
"sha256": "3333333333333333333333333333333333333333333333333333333333333333"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"report": {
|
||||
"type": "manual",
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"capture_session_id": "run_bad_schema",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android",
|
||||
"os_version": "15 (API 35)",
|
||||
"profile_id": "prof_tablet"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "manual",
|
||||
"notice_version": 1
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Google",
|
||||
"model": "Pixel Tablet",
|
||||
"os": "15",
|
||||
"form_factor": "tablet"
|
||||
},
|
||||
"playback_session_ids": [],
|
||||
"log_summary": {
|
||||
"lines": 1,
|
||||
"bytes_gz": 100,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"other"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json"
|
||||
],
|
||||
"bytes": 100,
|
||||
"uncompressed_bytes": 200,
|
||||
"sha256": "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "manual",
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"capture_session_id": "run_missing_consent",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android",
|
||||
"os_version": "15 (API 35)",
|
||||
"profile_id": "prof_tablet"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Google",
|
||||
"model": "Pixel Tablet",
|
||||
"os": "15",
|
||||
"form_factor": "tablet"
|
||||
},
|
||||
"playback_session_ids": [],
|
||||
"log_summary": {
|
||||
"lines": 1,
|
||||
"bytes_gz": 100,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"other"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json"
|
||||
],
|
||||
"bytes": 100,
|
||||
"uncompressed_bytes": 200,
|
||||
"sha256": "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "manual",
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"capture_session_id": "run_macos_reserved",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "macos",
|
||||
"os_version": "26.0",
|
||||
"profile_id": "prof_mac"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "manual",
|
||||
"notice_version": 1
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Apple",
|
||||
"model": "Mac",
|
||||
"os": "26.0",
|
||||
"form_factor": "desktop"
|
||||
},
|
||||
"playback_session_ids": [],
|
||||
"log_summary": {
|
||||
"lines": 1,
|
||||
"bytes_gz": 100,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"other"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json"
|
||||
],
|
||||
"bytes": 100,
|
||||
"uncompressed_bytes": 200,
|
||||
"sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
}
|
||||
}
|
||||
+54
File diff suppressed because one or more lines are too long
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "kernel_panic",
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"capture_session_id": "run_bad_type",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android-tv",
|
||||
"os_version": "11 (API 30)",
|
||||
"profile_id": "prof_living_room"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "prompt",
|
||||
"notice_version": 1
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"os": "11",
|
||||
"form_factor": "tv"
|
||||
},
|
||||
"playback_session_ids": [],
|
||||
"log_summary": {
|
||||
"lines": 1,
|
||||
"bytes_gz": 100,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"crash"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json"
|
||||
],
|
||||
"bytes": 100,
|
||||
"uncompressed_bytes": 200,
|
||||
"sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "manual",
|
||||
"captured_at": "2026-07-19T23:45:00Z",
|
||||
"capture_session_id": "run_android_manual_preprofile_006",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android",
|
||||
"os_version": "15 (API 35)"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "manual",
|
||||
"notice_version": 1
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Google",
|
||||
"model": "Pixel Tablet",
|
||||
"os": "15",
|
||||
"form_factor": "tablet"
|
||||
},
|
||||
"playback_session_ids": [],
|
||||
"log_summary": {
|
||||
"lines": 12,
|
||||
"bytes_gz": 2048,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"lifecycle",
|
||||
"network"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json",
|
||||
"device.json",
|
||||
"logs.jsonl"
|
||||
],
|
||||
"bytes": 4096,
|
||||
"uncompressed_bytes": 16384,
|
||||
"sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "manual",
|
||||
"captured_at": "2026-07-19T23:40:00Z",
|
||||
"capture_session_id": "run_android_manual_005",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android",
|
||||
"os_version": "15 (API 35)",
|
||||
"profile_id": "prof_tablet"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "manual",
|
||||
"notice_version": 1
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Google",
|
||||
"model": "Pixel Tablet",
|
||||
"os": "15",
|
||||
"form_factor": "tablet"
|
||||
},
|
||||
"playback_session_ids": [],
|
||||
"log_summary": {
|
||||
"lines": 60,
|
||||
"bytes_gz": 5400,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"browse",
|
||||
"network"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json",
|
||||
"device.json",
|
||||
"logs.jsonl"
|
||||
],
|
||||
"bytes": 8000,
|
||||
"uncompressed_bytes": 43000,
|
||||
"sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "crash",
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"capture_session_id": "run_android_tv_ueh_001",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "android-tv",
|
||||
"os_version": "11 (API 30)",
|
||||
"profile_id": "prof_living_room"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "prompt",
|
||||
"notice_version": 1
|
||||
},
|
||||
"crash": {
|
||||
"summary": "NullPointerException in PlaybackSessionManager.start",
|
||||
"stack_excerpt": "java.lang.NullPointerException: player was null\n\tat org.siloserver.silo.PlaybackSessionManager.start(PlaybackSessionManager.kt:42)",
|
||||
"thread": "main",
|
||||
"foreground": true,
|
||||
"source": "ueh",
|
||||
"provenance": "pre_failure",
|
||||
"occurred_at": "2026-07-19T18:22:31Z"
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"os": "11",
|
||||
"form_factor": "tv"
|
||||
},
|
||||
"playback_session_ids": [
|
||||
"ps_9f2a"
|
||||
],
|
||||
"log_summary": {
|
||||
"lines": 3841,
|
||||
"bytes_gz": 412034,
|
||||
"dropped_lines": 12,
|
||||
"categories": [
|
||||
"playback",
|
||||
"focus",
|
||||
"network"
|
||||
],
|
||||
"debug_logging": true
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json",
|
||||
"device.json",
|
||||
"logs.jsonl",
|
||||
"crash/summary.json",
|
||||
"crash/stack.txt"
|
||||
],
|
||||
"bytes": 498112,
|
||||
"uncompressed_bytes": 3110400,
|
||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"captured_at": "2026-07-19T18:22:31Z",
|
||||
"provenance": "pre_failure",
|
||||
"identity": {
|
||||
"manufacturer": "NVIDIA",
|
||||
"model": "SHIELD Android TV",
|
||||
"device": "mdarcy",
|
||||
"form_factor": "tv",
|
||||
"device_id": "dev_8f4a"
|
||||
},
|
||||
"display": {
|
||||
"mode": "3840x2160@59.94",
|
||||
"modes_supported": [
|
||||
"3840x2160@59.94",
|
||||
"1920x1080@60"
|
||||
],
|
||||
"hdr_types": [
|
||||
"HDR10",
|
||||
"HLG",
|
||||
"DV"
|
||||
],
|
||||
"wide_gamut": true
|
||||
},
|
||||
"audio": {
|
||||
"outputs": [
|
||||
{
|
||||
"type": "HDMI",
|
||||
"encodings": [
|
||||
"AC3",
|
||||
"EAC3",
|
||||
"TRUEHD"
|
||||
],
|
||||
"channels": 8
|
||||
}
|
||||
],
|
||||
"passthrough": [
|
||||
"AC3",
|
||||
"EAC3",
|
||||
"TRUEHD"
|
||||
],
|
||||
"suppressions": []
|
||||
},
|
||||
"video_codecs": [
|
||||
{
|
||||
"mime": "video/hevc",
|
||||
"hw": true,
|
||||
"profiles": [
|
||||
"Main10",
|
||||
"DV P8"
|
||||
],
|
||||
"max": "4096x2160@60"
|
||||
}
|
||||
],
|
||||
"network": {
|
||||
"transport": "ethernet"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "hang",
|
||||
"captured_at": "2026-07-19T21:15:00Z",
|
||||
"capture_session_id": "run_ios_metric_042",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "ios",
|
||||
"os_version": "26.0",
|
||||
"profile_id": "prof_phone"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "always",
|
||||
"notice_version": 1
|
||||
},
|
||||
"crash": {
|
||||
"summary": "Main thread hang reported by MetricKit",
|
||||
"stack_excerpt": "Thread 0 name: main\n0 Silo PlayerCore.waitForRouteChange",
|
||||
"thread": "main",
|
||||
"foreground": true,
|
||||
"source": "metrickit",
|
||||
"provenance": "metric_reporting_period",
|
||||
"occurred_at": "2026-07-19T21:15:00Z"
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Apple",
|
||||
"model": "iPhone17,2",
|
||||
"os": "26.0",
|
||||
"form_factor": "phone"
|
||||
},
|
||||
"playback_session_ids": [
|
||||
"ps_ios_103"
|
||||
],
|
||||
"log_summary": {
|
||||
"lines": 902,
|
||||
"bytes_gz": 84511,
|
||||
"dropped_lines": 0,
|
||||
"categories": [
|
||||
"playback",
|
||||
"lifecycle",
|
||||
"network"
|
||||
],
|
||||
"debug_logging": true
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json",
|
||||
"device.json",
|
||||
"logs.jsonl",
|
||||
"crash/summary.json",
|
||||
"crash/metrickit.json",
|
||||
"breadcrumbs.jsonl"
|
||||
],
|
||||
"bytes": 141120,
|
||||
"uncompressed_bytes": 812400,
|
||||
"sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"ts":"2026-07-19T18:22:29.412Z","run":"run_android_tv_ueh_001","lvl":"W","cat":"playback","tag":"AudioCapabilityMgr","msg":"passthrough suppressed: HDMI sink lost TrueHD","attrs":{"sink":"HDMI","fmt":"truehd"}}
|
||||
{"ts":"2026-07-19T18:22:30Z","run":"run_android_tv_ueh_001","lvl":"I","cat":"network","tag":"HTTPClient","msg":"request completed","attrs":{"method":"GET","path":"/api/v1/playback/{id}","status":200,"duration_ms":84}}
|
||||
{"ts":"2026-07-19T18:22:31Z","run":"run_android_tv_ueh_001","lvl":"D","cat":"focus","tag":"TvFocus","msg":"focus moved","attrs":{"target":"play-button","action":"right"}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"report": {
|
||||
"type": "abnormal_exit",
|
||||
"captured_at": "2026-07-19T22:05:11Z",
|
||||
"capture_session_id": "run_tvos_exit_007",
|
||||
"app_version": "1.4.2",
|
||||
"app_build": "20841",
|
||||
"platform": "tvos",
|
||||
"os_version": "26.0",
|
||||
"profile_id": "prof_den"
|
||||
},
|
||||
"destination": {
|
||||
"server_instance_id": "srv_home_01"
|
||||
},
|
||||
"consent": {
|
||||
"mode": "prompt",
|
||||
"notice_version": 1
|
||||
},
|
||||
"crash": {
|
||||
"summary": "Silo did not shut down cleanly last time",
|
||||
"thread": "unknown",
|
||||
"foreground": true,
|
||||
"source": "exit_sentinel",
|
||||
"provenance": "post_restart",
|
||||
"occurred_at": "2026-07-19T22:05:11Z"
|
||||
},
|
||||
"device_summary": {
|
||||
"manufacturer": "Apple",
|
||||
"model": "Apple TV",
|
||||
"os": "26.0",
|
||||
"form_factor": "tv"
|
||||
},
|
||||
"playback_session_ids": [
|
||||
"ps_tvos_11",
|
||||
"ps_tvos_10"
|
||||
],
|
||||
"log_summary": {
|
||||
"lines": 120,
|
||||
"bytes_gz": 12044,
|
||||
"dropped_lines": 3,
|
||||
"categories": [
|
||||
"lifecycle",
|
||||
"playback"
|
||||
],
|
||||
"debug_logging": false
|
||||
},
|
||||
"archive": {
|
||||
"entries": [
|
||||
"manifest.json",
|
||||
"device.json",
|
||||
"logs.jsonl",
|
||||
"crash/summary.json",
|
||||
"breadcrumbs.jsonl"
|
||||
],
|
||||
"bytes": 30001,
|
||||
"uncompressed_bytes": 104000,
|
||||
"sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/client-diagnostics/v1/logline.schema.json",
|
||||
"title": "Silo Client Diagnostics Log Line v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ts",
|
||||
"run",
|
||||
"lvl",
|
||||
"cat",
|
||||
"tag",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"ts": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"maxLength": 64
|
||||
},
|
||||
"run": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"lvl": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"V",
|
||||
"D",
|
||||
"I",
|
||||
"W",
|
||||
"E"
|
||||
],
|
||||
"maxLength": 1
|
||||
},
|
||||
"cat": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playback",
|
||||
"focus",
|
||||
"network",
|
||||
"lifecycle",
|
||||
"browse",
|
||||
"cast",
|
||||
"download",
|
||||
"crash",
|
||||
"other"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"msg": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048
|
||||
},
|
||||
"attrs": {
|
||||
"type": "object",
|
||||
"description": "Attribute keys are registered in attr-registry.json. Production clients drop unregistered keys before upload.",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/schemas/client-diagnostics/v1/manifest.schema.json",
|
||||
"title": "Silo Client Diagnostics Manifest v1",
|
||||
"description": "Canonical manifest contract for a client diagnostics bundle. STRING LENGTHS: every `maxLength` in this schema is a character-count sanity bound. The server enforces the matching limit as a UTF-8 *byte* length (Go `len(string)`), so a value near a cap that contains multi-byte characters (CJK, emoji, accented text) can satisfy `maxLength` here yet be rejected at ingest. Treat each numeric cap as a byte budget for non-ASCII text. JSON Schema cannot express byte length, so this divergence is intentional and documented rather than encoded.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"report",
|
||||
"destination",
|
||||
"consent",
|
||||
"device_summary",
|
||||
"playback_session_ids",
|
||||
"log_summary",
|
||||
"archive"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"report": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"captured_at",
|
||||
"capture_session_id",
|
||||
"app_version",
|
||||
"app_build",
|
||||
"platform",
|
||||
"os_version"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"crash",
|
||||
"anr",
|
||||
"native_crash",
|
||||
"hang",
|
||||
"abnormal_exit",
|
||||
"manual"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"captured_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"maxLength": 64
|
||||
},
|
||||
"capture_session_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"app_version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"app_build": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64
|
||||
},
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"android",
|
||||
"android-tv",
|
||||
"ios",
|
||||
"tvos"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"os_version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"destination": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"server_instance_id"
|
||||
],
|
||||
"properties": {
|
||||
"server_instance_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"consent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"mode",
|
||||
"notice_version"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"prompt",
|
||||
"always",
|
||||
"manual"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"notice_version": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"crash": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"summary",
|
||||
"source",
|
||||
"provenance",
|
||||
"occurred_at"
|
||||
],
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Free-text crash summary; may contain non-ASCII. maxLength counts characters — the server enforces 8192 UTF-8 bytes (see the schema-level string-length note).",
|
||||
"maxLength": 8192
|
||||
},
|
||||
"stack_excerpt": {
|
||||
"type": "string",
|
||||
"description": "Free-text stack excerpt; may contain non-ASCII. maxLength counts characters — the server enforces 8192 UTF-8 bytes (see the schema-level string-length note).",
|
||||
"maxLength": 8192
|
||||
},
|
||||
"thread": {
|
||||
"type": "string",
|
||||
"description": "Thread name; may contain non-ASCII. maxLength counts characters — the server enforces 128 UTF-8 bytes (see the schema-level string-length note).",
|
||||
"maxLength": 128
|
||||
},
|
||||
"foreground": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ueh",
|
||||
"exit_info",
|
||||
"metrickit",
|
||||
"exit_sentinel"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"provenance": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pre_failure",
|
||||
"post_restart",
|
||||
"metric_reporting_period"
|
||||
],
|
||||
"maxLength": 32
|
||||
},
|
||||
"occurred_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"device_summary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"manufacturer",
|
||||
"model",
|
||||
"os",
|
||||
"form_factor"
|
||||
],
|
||||
"properties": {
|
||||
"manufacturer": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Device manufacturer; may contain non-ASCII. maxLength counts characters — the server enforces 128 UTF-8 bytes (see the schema-level string-length note).",
|
||||
"maxLength": 128
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Device model; may contain non-ASCII (e.g. CJK). maxLength counts characters — the server enforces 128 UTF-8 bytes, so a 128-character multi-byte value can pass this schema yet be rejected at ingest (see the schema-level string-length note).",
|
||||
"maxLength": 128
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "OS name/version; may contain non-ASCII. maxLength counts characters — the server enforces 128 UTF-8 bytes (see the schema-level string-length note).",
|
||||
"maxLength": 128
|
||||
},
|
||||
"form_factor": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Device form factor; may contain non-ASCII. maxLength counts characters — the server enforces 64 UTF-8 bytes (see the schema-level string-length note).",
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"playback_session_ids": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"log_summary": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"lines",
|
||||
"bytes_gz",
|
||||
"dropped_lines",
|
||||
"categories",
|
||||
"debug_logging"
|
||||
],
|
||||
"properties": {
|
||||
"lines": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"bytes_gz": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"dropped_lines": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 9,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playback",
|
||||
"focus",
|
||||
"network",
|
||||
"lifecycle",
|
||||
"browse",
|
||||
"cast",
|
||||
"download",
|
||||
"crash",
|
||||
"other"
|
||||
],
|
||||
"maxLength": 32
|
||||
}
|
||||
},
|
||||
"debug_logging": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"archive": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"entries",
|
||||
"bytes",
|
||||
"uncompressed_bytes",
|
||||
"sha256"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"description": "Tar member names in bundle order. The first entry MUST be `manifest.json`: the server reads and validates the leading member before any other and compares this order against the actual bundle. `prefixItems` pins index 0, while `contains` keeps `manifest.json` required for validators that do not implement `prefixItems`.",
|
||||
"uniqueItems": true,
|
||||
"minItems": 1,
|
||||
"maxItems": 8,
|
||||
"prefixItems": [
|
||||
{
|
||||
"const": "manifest.json"
|
||||
}
|
||||
],
|
||||
"contains": {
|
||||
"const": "manifest.json"
|
||||
},
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"manifest.json",
|
||||
"device.json",
|
||||
"logs.jsonl",
|
||||
"crash/summary.json",
|
||||
"crash/stack.txt",
|
||||
"crash/tombstone.pb",
|
||||
"crash/metrickit.json",
|
||||
"breadcrumbs.jsonl"
|
||||
],
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"uncompressed_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Fa-f0-9]{64}$",
|
||||
"maxLength": 64
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": true,
|
||||
"allOf": [
|
||||
{
|
||||
"$comment": "Event reports (crash/anr/native_crash/hang/abnormal_exit) must carry the crash object; mirrors ValidateManifest in internal/diagnostics/contract/contract.go.",
|
||||
"if": {
|
||||
"properties": {
|
||||
"report": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"crash",
|
||||
"anr",
|
||||
"native_crash",
|
||||
"hang",
|
||||
"abnormal_exit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"crash"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"$comment": "Manual captures must not carry a crash object.",
|
||||
"if": {
|
||||
"properties": {
|
||||
"report": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "manual"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"not": {
|
||||
"required": [
|
||||
"crash"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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
@@ -0,0 +1,276 @@
|
||||
# Plan: Personal + Server sections on the user Collections tab
|
||||
|
||||
Status: Draft
|
||||
Branch: `feat/collections-server-section`
|
||||
Owner: TBD
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem
|
||||
|
||||
The user-facing **Collections** tab on the home screen (`web/src/pages/Collections.tsx`)
|
||||
only shows the signed-in profile's *personal* collections. For most users this list is
|
||||
empty or nearly empty, so the tab looks broken or pointless.
|
||||
|
||||
Meanwhile, server-curated collections (admin-created "library collections") only appear
|
||||
*inside each individual library's* Collections tab (`web/src/pages/LibraryCollections.tsx`,
|
||||
served by `GET /library/{id}/collections`). There is no place in the app where a user can
|
||||
see the collections curated across the whole server — they are effectively hidden behind
|
||||
per-library navigation.
|
||||
|
||||
We want the top-level Collections tab to become the single home for collections:
|
||||
|
||||
1. **Your collections** — the user's personal/shared collections, with a large section
|
||||
title, shown at the top (current behavior, kept intact: grouping, drag-reorder, create,
|
||||
edit, sync, delete).
|
||||
2. **Server collections** — below that, a clearly titled section aggregating the visible
|
||||
server (library) collections across every library the user can access, so they are no
|
||||
longer buried per-library.
|
||||
|
||||
## Current state (what exists today)
|
||||
|
||||
### Frontend
|
||||
- `web/src/pages/Collections.tsx` — the top-level tab. Renders the page header + a
|
||||
`GroupedCollectionsBoard` of personal collections. Data via `useCollections()` /
|
||||
`useCollectionGroups()` (`web/src/hooks/queries/collections.ts`), both backed by
|
||||
`GET /collections` → `CollectionsListResponse { collections, groups }`.
|
||||
- `web/src/pages/LibraryCollections.tsx` — per-library tab. Renders a poster grid
|
||||
(`CollectionPosterCard`) grouped by admin groups + ungrouped. Data via
|
||||
`useLibraryCollections(libraryId)` → `GET /library/{id}/collections` →
|
||||
`LibraryTabResponse { groups, ungrouped }`. **This is the visual we want to reuse for the
|
||||
server section** (poster cards, item-count badge, navigate to catalog href).
|
||||
- `web/src/api/types.ts` — `Collection`, `CollectionGroup`, `CollectionsListResponse`,
|
||||
`LibraryTabResponse`, `LibraryTabGroup`, `LibraryTabCollection`, `LibraryTabUngrouped`.
|
||||
- Catalog hrefs: `buildLibraryCollectionCatalogHref(id, title)` and
|
||||
`buildUserCollectionCatalogHref(id, title)` in `web/src/pages/catalogSearchParams.ts`.
|
||||
|
||||
### Backend
|
||||
- `internal/api/handlers/collections.go` — personal collection handlers under `/collections`
|
||||
(registered in `internal/api/router.go` ~lines 1700-1726, behind `RequireProfile`).
|
||||
- `internal/api/handlers/library_collections.go`:
|
||||
- `HandleListLibraryCollections` (line 813) — builds the per-library `LibraryTabResponse`.
|
||||
- `requestCanAccessLibrary` (line 3311) — checks `access.GetScope(ctx).AllowedLibraryIDs`
|
||||
(nil ⇒ access to all libraries).
|
||||
- `LibraryCollectionRepository.ListAll(ctx, libraryID *int, opts)`
|
||||
(`internal/catalog/library_collection_repo.go:368`) — **with `libraryID == nil` it
|
||||
already returns server collections across all libraries** (uses
|
||||
`libraryCollectionScopeFallbackJoin`), filtering `visibility = 'visible'` unless
|
||||
`IncludeHidden`. This is the aggregation primitive we need.
|
||||
- `presignGPURL` / `toLibraryCollectionResponses` — turn stored poster paths into signed
|
||||
URLs for the response.
|
||||
|
||||
## Design decision: aggregate on the server, new endpoint
|
||||
|
||||
We add a new **user-facing aggregate endpoint** rather than fanning out N per-library calls
|
||||
from the client (avoids N round-trips, keeps access/visibility filtering server-side, and
|
||||
matches the "Performance first / Reliability first" priorities in `CLAUDE.md`).
|
||||
|
||||
### New endpoint
|
||||
|
||||
`GET /collections/server` (registered next to the other `/collections` routes in
|
||||
`internal/api/router.go`, behind `RequireProfile`).
|
||||
|
||||
Response shape (new types, mirrors the poster-card needs without per-library group noise):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"libraries": [
|
||||
{
|
||||
"library_id": 3,
|
||||
"library_name": "Movies",
|
||||
"total_count": 80, // total visible server collections in this library
|
||||
"collections": [ // CAPPED teaser slice (see "Scale & pagination")
|
||||
{
|
||||
"id": "...",
|
||||
"title": "...",
|
||||
"poster_url": "https://signed...",
|
||||
"poster_thumbhash": "...",
|
||||
"item_count": 42,
|
||||
"featured": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Grouping by library (with `library_name`) is the recommended default: it lets the frontend
|
||||
render "Server collections" as one **horizontal scrollable row per library**, which keeps a
|
||||
large catalog legible. (Alternative: a single flat list — simpler UI, but loses provenance
|
||||
and the per-library "See all" affordance. Recommend per-library grouping.)
|
||||
|
||||
### Scale & pagination (many collections per library)
|
||||
|
||||
The server section is a **discovery** surface aggregating across *all* accessible libraries
|
||||
at once, so a full wrapping grid per library does not scale (e.g. 5 libraries × 80
|
||||
collections = 400 stacked tiles). Decisions:
|
||||
|
||||
- **Render each library as a horizontal carousel row**, not a wrapping grid. Heading = the
|
||||
library name + a **"See all →"** link.
|
||||
- **Cap the response** at ~20 collections per library and include `total_count`. Ordering of
|
||||
the capped slice follows the repo's existing `featured DESC, sort_order ASC, title ASC`
|
||||
("best first"), so the teaser row surfaces featured/curated collections.
|
||||
- **"See all" links to the existing per-library Collections tab**
|
||||
(`/library/{id}` Collections tab, served by the unchanged
|
||||
`GET /library/{id}/collections`), which already renders the full wrapping grid with admin
|
||||
grouping. We do **not** build a new full-aggregate view — reuse the canonical
|
||||
"show everything for this library" surface.
|
||||
- This caps payload size and the per-library query cost, and keeps the home tab fast
|
||||
("Performance first").
|
||||
|
||||
### API contract impact
|
||||
|
||||
- `GET /collections` (personal) — **unchanged**.
|
||||
- `GET /library/{id}/collections` (per-library tab, "See all" target) — **unchanged**;
|
||||
reused as-is.
|
||||
- `GET /collections/server` — **new, additive**. No existing response changes; no
|
||||
Android/Apple contract break.
|
||||
|
||||
### Why a separate endpoint, not extending `GET /collections`
|
||||
|
||||
It is tempting to make `GET /collections` return both personal and server collections in one
|
||||
call. We deliberately do **not**, for three reasons:
|
||||
|
||||
- **Client safety / semantics.** `GET /collections`'s `collections[]` array is the user's
|
||||
*editable, personal* shelves — web and the Android/Apple clients render reorder/edit/
|
||||
delete/sync affordances on it. Mixing read-only server collections into that same array is
|
||||
a *silent semantic break*: the JSON still parses, so nothing errors, but clients would
|
||||
offer "delete"/"reorder" on collections the user can't mutate (→ 403/404). A new sibling
|
||||
field (`{ collections, groups, server }`) would be additive and parse-safe, but still
|
||||
couples discovery into the personal-management contract.
|
||||
- **Cache / refetch lifecycle.** `GET /collections` is the heavily-mutated query —
|
||||
invalidated on every create, edit, reorder, group change, and sync (via
|
||||
`invalidateUserCollectionQueries`). Server collections don't change on a personal reorder,
|
||||
so folding them in would refetch the whole server-wide catalog on every personal mutation
|
||||
("Performance first" violation). Separate endpoints ⇒ independent cache keys + lifecycles.
|
||||
- **Zero downstream risk.** A brand-new `/collections/server` endpoint cannot break existing
|
||||
clients, because nothing calls it yet. Consolidating into one call later is a *deliberate
|
||||
follow-up requiring coordinated Android/Apple work* — and must use a distinct field, never
|
||||
overload `collections[]`.
|
||||
|
||||
### Handler logic (`HandleListServerCollections`)
|
||||
|
||||
1. Resolve accessible library IDs: read `access.GetScope(ctx)`. If `AllowedLibraryIDs ==
|
||||
nil`, the user can see all libraries → list all of them; else restrict to that set.
|
||||
2. List visible server collections:
|
||||
- Simplest correct approach: call `repo.ListAll(ctx, nil, ListLibraryCollectionsOptions{})`
|
||||
once (server-wide, visible-only), then **filter each collection to libraries the user
|
||||
can access** and bucket by library. This requires knowing each collection's
|
||||
library membership (`library_collection_libraries`). If `ListAll` does not already
|
||||
return membership, either (a) add a per-library loop calling
|
||||
`repo.ListByLibrary(ctx, libID, opts)` for each accessible library (bounded by library
|
||||
count, each query already does the grouping/sort), or (b) extend the repo to return
|
||||
library membership. **Recommended: per-accessible-library loop** — reuses the existing,
|
||||
tested `ListByLibrary` path and naturally yields the per-library buckets and respects
|
||||
scope. Deduplicate collections that span multiple libraries if we choose flat output;
|
||||
for per-library output, a collection legitimately appears under each of its libraries.
|
||||
3. For each library, set `total_count` to the full visible count, then **cap
|
||||
`collections` to ~20** (keep the repo's `featured DESC, sort_order ASC, title ASC`
|
||||
ordering so featured/curated collections lead). Define the cap as a single named
|
||||
constant.
|
||||
4. Resolve library display names (catalog/library store lookup) for `library_name`.
|
||||
5. Presign poster URLs via the existing `presignGPURL` helper — only for the capped slice,
|
||||
so we never presign 100 posters we won't send.
|
||||
6. Skip empty libraries. Return `200` with `{ libraries: [] }` when nothing is visible.
|
||||
|
||||
Reuse existing helpers (`presignGPURL`, sort helpers like `applyCollectionSort`) rather than
|
||||
duplicating logic. Add the new response structs next to the existing
|
||||
`libraryTabResponse` types in `library_collections.go`, or in `collections.go` if cleaner —
|
||||
keep them in the package that owns the behavior.
|
||||
|
||||
## Frontend changes
|
||||
|
||||
### Types (`web/src/api/types.ts`)
|
||||
Add:
|
||||
```ts
|
||||
export interface ServerCollectionsLibrary {
|
||||
library_id: number;
|
||||
library_name: string;
|
||||
total_count: number; // total visible; collections[] is a capped teaser slice
|
||||
collections: LibraryTabCollection[]; // reuse existing shape
|
||||
}
|
||||
export interface ServerCollectionsResponse {
|
||||
libraries: ServerCollectionsLibrary[];
|
||||
}
|
||||
```
|
||||
|
||||
### Query hook (`web/src/hooks/queries/collections.ts` + `keys.ts`)
|
||||
- Add `collectionKeys.server()` to `web/src/hooks/queries/keys.ts`.
|
||||
- Add `useServerCollections()` → `GET /collections/server` →
|
||||
`ServerCollectionsResponse`. Independent query key from `/collections`, so the personal
|
||||
section keeps its single round-trip and the server section loads in parallel.
|
||||
|
||||
### Page (`web/src/pages/Collections.tsx`)
|
||||
Restructure into two titled sections inside the existing `page-shell`:
|
||||
|
||||
1. **"Your collections"** section
|
||||
- Add a large section title (e.g. `<h2 class="page-title ...">Your collections</h2>`)
|
||||
above the existing `GroupedCollectionsBoard` / empty-state block. Keep all current
|
||||
personal-collection behavior unchanged (create, group, reorder, sync, delete).
|
||||
- The page-level `<h1>Collections</h1>` header + subtitle + action buttons stay at the
|
||||
very top.
|
||||
|
||||
2. **"Server collections"** section (new)
|
||||
- Title `<h2>Server collections</h2>` with a short subtitle ("Curated across every
|
||||
library on this server").
|
||||
- Use `useServerCollections()`. Render **one horizontal scrollable row per library**
|
||||
(carousel), not a wrapping grid. Each poster card reuses the **same visual as
|
||||
`LibraryCollections.tsx`** — extract `CollectionPosterCard` into a shared component so
|
||||
both pages use it (avoids duplicate logic per `CLAUDE.md` maintainability rule).
|
||||
Proposed location: `web/src/components/collections/CollectionPosterCard.tsx`.
|
||||
- For each `library` bucket: a sub-heading row with `library_name` and a **"See all →"**
|
||||
link routing to that library's existing Collections tab (the unchanged
|
||||
`GET /library/{id}/collections` surface). Show "See all" only when
|
||||
`total_count > collections.length`; optionally include the count, e.g. "See all (80)".
|
||||
- Cards navigate via `buildLibraryCollectionCatalogHref(id, title)` and show the
|
||||
item-count badge. These are admin/library collections (`kind="regular"`); the
|
||||
sidebar-pin affordance can remain — pass `library.library_id` as `libraryId`.
|
||||
- Use the project's existing horizontal-row/carousel pattern if one exists (check
|
||||
`web/src/components` for a shelf/row component used on the home screen) rather than
|
||||
hand-rolling overflow scrolling; reuse it for consistent snap/scroll behavior.
|
||||
- Loading: skeleton row(s) (reuse the existing skeleton block pattern).
|
||||
- Empty: if `libraries` is empty, render nothing (or a muted "No server collections yet"
|
||||
line) — do not show a scary empty state, since the personal section above may have
|
||||
content.
|
||||
|
||||
### Refactor note
|
||||
Extracting `CollectionPosterCard` is the only shared-logic extraction required. Keep the
|
||||
extraction behavior-identical for `LibraryCollections.tsx` (props: `collection`, `kind`,
|
||||
`libraryId`). The per-library tab keeps its wrapping grid; only the new server section uses
|
||||
horizontal rows.
|
||||
|
||||
## Out of scope / non-goals
|
||||
- No changes to how admin/library collections are created or to per-library tabs.
|
||||
- No reordering/editing of server collections from the user Collections tab (read-only).
|
||||
- No new visibility model — we honor existing `visibility = 'visible'` + access scope.
|
||||
|
||||
## Risks / follow-ups
|
||||
- **Access scope correctness**: must not leak collections from libraries outside
|
||||
`AllowedLibraryIDs`. Mitigate by driving the query from accessible library IDs, and add a
|
||||
test for a restricted-scope user.
|
||||
- **Collections spanning multiple libraries** appear once per library in per-library output;
|
||||
confirm this is the desired UX (vs. dedup). Decide before implementation.
|
||||
- **Performance**: per-accessible-library loop is N queries. Acceptable for typical library
|
||||
counts; if N grows large, revisit with a single membership-aware query. Add an index check
|
||||
on `library_collection_libraries(library_id)` (likely already present).
|
||||
- **Client parity**: this is a new server endpoint and a web-only UI change. Android/Apple
|
||||
clients have their own collections surfaces — file follow-up issues if they should mirror
|
||||
the personal+server split. No API contract break (purely additive endpoint).
|
||||
- **Empty-state UX** for users with no personal AND no server collections — ensure the page
|
||||
still reads sensibly.
|
||||
|
||||
## Verification
|
||||
- Backend: `go build ./...`, `go vet ./...`, and a handler/unit test for
|
||||
`HandleListServerCollections` covering (a) full access, (b) restricted scope, (c) empty.
|
||||
- Frontend: `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`.
|
||||
- Manual: log in as a normal profile with empty personal collections; confirm the
|
||||
"Your collections" section shows the empty/create state and the "Server collections"
|
||||
section lists library collections grouped by library with correct posters, counts, and
|
||||
working navigation into each collection's catalog view.
|
||||
|
||||
## Implementation order
|
||||
1. Backend: response types + `HandleListServerCollections` + route registration.
|
||||
2. Backend test for access-scope filtering.
|
||||
3. Frontend: types + `useServerCollections` hook + `collectionKeys.server()`.
|
||||
4. Frontend: extract shared `CollectionPosterCard`; refactor `LibraryCollections.tsx`.
|
||||
5. Frontend: restructure `Collections.tsx` into the two titled sections.
|
||||
6. Lint/format/build/manual verification.
|
||||
@@ -0,0 +1,311 @@
|
||||
# Plan: interleave `transcribe_translate` so live AI subtitles follow the playhead
|
||||
|
||||
Issue: https://github.com/Silo-Server/silo-server/issues/154
|
||||
Area: `internal/subtitles/ai` (+ `internal/playback` extraction, `internal/config`)
|
||||
Type: bug fix to an existing (shipped) capability — makes the chained AI-subtitle
|
||||
path deliver its designed live behavior. No new client-facing contract.
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Problem (confirmed)
|
||||
|
||||
`Service.runTranscribe` (`internal/subtitles/ai/service.go`) runs the chained
|
||||
`transcribe_translate` kind as two **global, sequential** stages:
|
||||
|
||||
1. Transcribe the **entire** file (progress 5%→70%). Transcript cues are not
|
||||
streamed for the chained kind —
|
||||
`streamTranscript := streaming && job.Kind == JobKindTranscribe`
|
||||
(`service.go:417`), so it is `false` for `transcribe_translate`.
|
||||
2. Only then translate **all** cues (70%→95%), streaming each translated batch
|
||||
as it lands (`service.go:482-493`).
|
||||
|
||||
Because translation cannot start until step 1 finishes for the whole file, the
|
||||
first translated cue streams minutes into the job, long past the playhead.
|
||||
|
||||
### Two distinct bottlenecks (both must be addressed)
|
||||
|
||||
- **Transcription stage is global.** Translation waits for the whole-file ASR
|
||||
pass. This is the dominant term for a feature film (~20 min ASR) and the
|
||||
primary cause. Fixed by interleaving translation per chunk (Phase 1).
|
||||
- **Extraction is global and precedes any callback.** `Transcribe` calls
|
||||
`t.extract(...)` (`transcriber.go:124`) before the `onChunk` loop, and
|
||||
`ExtractAudioChunks` blocks on a single whole-track ffmpeg pass
|
||||
(`internal/playback/audio_extract.go:34,65`). So even with per-chunk
|
||||
interleave and small chunks, the first cue still waits for full-file
|
||||
extraction (~1–2 min for a 2h film). Fixed by playhead-first incremental
|
||||
extraction (Phase 2).
|
||||
|
||||
The transcriber already exposes the per-chunk seam: `Transcribe` invokes
|
||||
`onChunk(cues, done, total)` per chunk, playhead-first (`transcriber.go:111-174`),
|
||||
and plain `transcribe` already streams through it (`service.go:426-429`).
|
||||
|
||||
Secondary defect found while confirming: `WhisperTranscriber.SetExtraction`
|
||||
clamps any `chunkSeconds` outside `[60, 600]` to the **default 600**
|
||||
(`transcriber.go:100-102`), so a small live chunk value silently becomes 10 min
|
||||
instead of clamping to the floor.
|
||||
|
||||
## Goal & honest latency targets
|
||||
|
||||
Translated cues stream playhead-first for a session-attached
|
||||
`transcribe_translate` job, instead of waiting for whole-file transcription.
|
||||
|
||||
Time-to-first-translated-cue, by phase:
|
||||
|
||||
- **Today:** `extract(full) + transcribe(full) + translate(first batch)` — many
|
||||
minutes (≈ full ASR pass).
|
||||
- **After Phase 1 (interleave, existing full extraction):**
|
||||
`extract(full) + transcribe(first chunk) + translate(first chunk)` — removes
|
||||
the whole-file ASR pass from the critical path; ≈ extraction time (~1–2 min for
|
||||
a 2h film), then cues follow the playhead. Already usable for live after a
|
||||
one-time wait.
|
||||
- **After Phase 2 (playhead-first incremental extraction):**
|
||||
`extract(one chunk near playhead) + transcribe + translate` — single-digit
|
||||
seconds. This is what the issue means by "within seconds."
|
||||
|
||||
Phase 1 delivers most of the value with low risk; Phase 2 closes the remaining
|
||||
extraction latency. Ship Phase 1 first; do not claim "within seconds" until
|
||||
Phase 2 lands.
|
||||
|
||||
### Non-goals (this plan)
|
||||
|
||||
- Pacing/throttling to the playhead (rolling window). Optional in the issue;
|
||||
follow-up below.
|
||||
- Cross-chunk translation-context continuity beyond batch context neighbours.
|
||||
|
||||
## Decision: interleave only when streaming
|
||||
|
||||
**Session-attached (live) jobs** use the interleaved per-chunk path.
|
||||
**Background (non-streaming) `transcribe_translate` jobs keep the current
|
||||
whole-file transcribe-then-translate-at-end path unchanged.** Rationale: the
|
||||
issue is live-only; this preserves background output byte-for-byte (full
|
||||
cross-file translation context, no incremental-extraction cost) and keeps the
|
||||
two behaviors cleanly separated by the existing `streaming` boolean. The
|
||||
shared transcript-storage step is factored so it is not duplicated.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — interleave translation per chunk (streaming jobs)
|
||||
|
||||
### 1a. `internal/subtitles/ai/service.go` — `runTranscribe`
|
||||
|
||||
For the chained kind **when `streaming`**, replace the kind-specific `onChunk`
|
||||
body (`service.go:423-430`) with per-chunk translate-and-stream:
|
||||
|
||||
- Capture a translate error in a closure: `var translateErr error` and an
|
||||
accumulator `var translatedAccum []SubtitleCue`.
|
||||
- In `onChunk(chunk, done, total)`:
|
||||
- If `translateErr != nil` or `len(chunk) == 0`, return (skip silence; stop
|
||||
translating after a prior failure). Do **not** abort transcription.
|
||||
- Else call `s.translator.Translate` on **that chunk's** cues. On error: set
|
||||
`translateErr`, emit `TranslationFailed` once, and return (let transcription
|
||||
continue). On success: stream translated cues via `TranslationCues` and
|
||||
append to `translatedAccum`.
|
||||
- Progress: single 5%→95% band keyed on chunk `done/total`
|
||||
(message "Transcribing & translating").
|
||||
- Keep `onChunk` signature unchanged (no error return). Aborting on translate
|
||||
failure via the callback is intentionally avoided — see 1b.
|
||||
|
||||
After `Transcribe` returns:
|
||||
|
||||
- **Always store the transcript track** (unchanged, `service.go:447-466`,
|
||||
including the transcript `SubtitleReady` broadcast). This must run regardless
|
||||
of `translateErr`.
|
||||
- If `translateErr != nil`: `finishWithError(ctx, job, translateErr)`. The
|
||||
transcript is already persisted (cache preserved), and the live session
|
||||
already received `TranslationFailed`.
|
||||
- Else: assemble the translated track from `translatedAccum` (`sortCuesByStart`
|
||||
+ `StoreSubtitle`, `service.go:499-514`), `CompleteJob`, and
|
||||
`TranslationCompleted` (`service.go:515-522`).
|
||||
- Remove the whole-file translate-at-end (`service.go:479-497`) for the
|
||||
streaming path only.
|
||||
|
||||
Non-streaming chained jobs fall through to the existing whole-file path,
|
||||
untouched.
|
||||
|
||||
### 1b. Transcript-on-translation-failure (resolves review P1)
|
||||
|
||||
Today the full transcript is stored before translation starts
|
||||
(`service.go:447` then `:482`), so a translation failure still leaves a
|
||||
transcript/cache row and an already-broadcast `SubtitleReady`. The interleaved
|
||||
path preserves this exactly because:
|
||||
|
||||
- a per-chunk translate failure sets `translateErr` but lets the ASR pass run to
|
||||
completion (it does **not** return an error from `onChunk`), so `Transcribe`
|
||||
returns the full cue set normally;
|
||||
- the transcript is then stored on the same post-transcribe path as today,
|
||||
before the job is failed for the translated track.
|
||||
|
||||
An ASR failure or context cancellation still returns an error from `Transcribe`
|
||||
and yields no transcript — identical to today.
|
||||
|
||||
### 1c. Live progress semantics (resolves review P2)
|
||||
|
||||
`SubtitleTranslationCuesPayload.Done/Total` is documented as overall progress
|
||||
(`internal/playback/realtime.go:172-181`). For live `transcribe_translate`:
|
||||
|
||||
- `TranslationCues` carries **`done = chunks completed, total = total chunk
|
||||
count`** — the same chunk-granular convention plain `transcribe` already emits
|
||||
(`service.go:427-428`). Monotonic and stable across chunks.
|
||||
- `TranslationStarted.TotalCues = 0` (indeterminate), consistent with the
|
||||
transcribe path today (`service.go:412`), since the cue total is unknown before
|
||||
ASR completes.
|
||||
- Cues carry absolute Start/End, so the client places them regardless of
|
||||
arrival order; unpause is driven by cue arrival near the playhead, not by
|
||||
`Done/Total`. Confirm with client teams that `Done/Total` is treated as a
|
||||
fraction, not absolute cue indices (clients already receive chunk counts from
|
||||
the plain `transcribe` path, so this should hold).
|
||||
|
||||
### 1d. Small live chunks + clamp fix
|
||||
|
||||
- Add `TranscribeJobRequest.ChunkSeconds int` (0 = configured default). In
|
||||
`Transcribe`, use `req.ChunkSeconds` when `> 0`, else the atomic default
|
||||
(`transcriber.go:121-122`); validate/clamp identically to `SetExtraction`.
|
||||
- Fix the clamp in `SetExtraction` (`transcriber.go:99-105`): clamp
|
||||
out-of-range to the nearest bound, not to the 600 default.
|
||||
- Lower `minASRChunkSeconds` from 60 to 15 (`transcriber.go:26`); update the
|
||||
doc comment about request count / boundary word-clip tradeoff.
|
||||
- Config: add `subtitle_ai.live_asr_chunk_seconds` (default 30):
|
||||
- `internal/config/config.go`: new field beside `ASRChunkSeconds`
|
||||
(`config.go:274-277`).
|
||||
- `internal/config/db_loader.go`: load it next to `asr_chunk_seconds`
|
||||
(`db_loader.go:472-476`).
|
||||
- `internal/subtitles/ai/config.go`: add `LiveASRChunkSeconds int` to the
|
||||
service `Config` so the service can set `req.ChunkSeconds` for streaming jobs.
|
||||
- `internal/api/router.go`: plumb through `effectiveSubtitleAIConfig` and the
|
||||
`OnConfigChange` `UpdateConfig` (`router.go:1024-1055`).
|
||||
|
||||
### 1e. Phase 1 tests
|
||||
|
||||
- `service_test.go`: fake `Transcriber` emitting several chunks playhead-first
|
||||
via `onChunk`; fake `Translator` recording calls. For a streaming
|
||||
`transcribe_translate` job assert: one `Translate` per non-empty chunk; first
|
||||
`TranslationCues` streamed before the last chunk is transcribed; empty chunks
|
||||
skipped; final stored translated track = all translated cues sorted by start;
|
||||
`Done/Total` is chunk-granular and monotonic.
|
||||
- Failure case: a translate error on chunk N sets the failure but transcription
|
||||
completes, the transcript track **is** stored, `TranslationFailed` is emitted,
|
||||
and the job ends via `finishWithError`.
|
||||
- Non-streaming `transcribe_translate`: unchanged whole-file path; correct final
|
||||
track; no `TranslationCues`.
|
||||
- `transcriber_test.go`: `SetExtraction` clamp fix and `ChunkSeconds` override
|
||||
(incl. the new 15s floor).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — playhead-first incremental extraction (true seconds latency)
|
||||
|
||||
Phase 1 still waits on full-file extraction before the first cue. Phase 2
|
||||
removes that by extracting and consuming chunks incrementally, seeked to the
|
||||
playhead.
|
||||
|
||||
### 2a. New incremental extractor in `internal/playback`
|
||||
|
||||
Add a streaming, seek-based extractor alongside `ExtractAudioChunks`:
|
||||
|
||||
```
|
||||
ExtractAudioChunksFrom(ctx, filePath, audioTrackIndex, dir, ffmpegPath,
|
||||
startSec float64, chunkSeconds int,
|
||||
onSegment func(AudioChunk) error) error
|
||||
```
|
||||
|
||||
- Runs one ffmpeg pass with `-ss <startSec> -i <file> ... -f segment
|
||||
-segment_list segments.csv` covering `startSec`→end.
|
||||
- Consumes segments **as they complete**: poll the segment-list CSV (ffmpeg
|
||||
appends `filename,start,end` when it closes each segment) on a short interval;
|
||||
for each new row, invoke `onSegment(AudioChunk{Path, Start: startSec + csvStart})`.
|
||||
Reconcile any trailing rows after `cmd.Wait()` so the final segment is not
|
||||
missed.
|
||||
- Honors `ctx` (CommandContext kills ffmpeg on cancel) and `onSegment` errors
|
||||
(stop the pass). Caller owns `dir`; segments are deleted by the consumer after
|
||||
ASR to cap disk at one extraction.
|
||||
- Preserves exact per-segment starts from the CSV, so timing does not accumulate
|
||||
drift within a pass (only the initial `-ss` seek is packet-accurate, sub-second,
|
||||
and is further corrected by the existing `ProbeAudioStartOffset`).
|
||||
|
||||
Rejected alternative — **per-chunk seek extraction** (`-ss s -t d` per chunk):
|
||||
simpler control flow but O(n) ffmpeg spawns and packet-aligned per-chunk seeks
|
||||
that can clip words at every seam. The single-pass streaming consumer keeps the
|
||||
existing segment-muxer boundary quality and far fewer process spawns.
|
||||
|
||||
### 2b. Transcriber incremental mode
|
||||
|
||||
- Add `TranscribeJobRequest.Incremental bool`, set by the service for streaming
|
||||
jobs.
|
||||
- When `Incremental`, `Transcribe` drives playhead-first in two streaming passes
|
||||
(reproducing today's `chunkOrderForPosition` order): pass A `startSec =
|
||||
playhead → end`, then pass B `0 → playhead`. Each completed segment is fed
|
||||
straight into the existing per-chunk ASR loop body (transcribe → `onChunk`),
|
||||
which Phase 1 already wires to translate+stream. The full cue set is still
|
||||
accumulated and returned for transcript storage.
|
||||
- When not `Incremental` (background, and plain transcribe unless we opt it in),
|
||||
keep the existing whole-file `ExtractAudioChunks` + `chunkOrderForPosition`
|
||||
path.
|
||||
|
||||
Note: timing composition (`-ss` reset PTS + segment CSV start + probe offset)
|
||||
must be verified with a fixture; see tests.
|
||||
|
||||
### 2c. Phase 2 tests
|
||||
|
||||
- `audio_extract` test (or transcriber test with an injected extractor) covering
|
||||
incremental emission order, exact-start mapping with a `startSec` offset, and
|
||||
ctx-cancel mid-pass.
|
||||
- Transcriber test asserting `Incremental` yields playhead-first chunk order
|
||||
across the two passes and returns the complete cue set for transcript storage.
|
||||
- Confirm first `onChunk` fires after ~one chunk of audio, not after full-file
|
||||
extraction (timing/ordering assertion via a fake extractor, not wall-clock).
|
||||
|
||||
---
|
||||
|
||||
## Multi-repo / client impact
|
||||
|
||||
None expected to the contract. Reuses the existing notifier protocol
|
||||
(`TranslationStarted` / `TranslationCues` / `TranslationCompleted` /
|
||||
`TranslationFailed`, `notifier.go`) under the same translated-track `trackKey`.
|
||||
Translated cues simply begin arriving far earlier, and `Done/Total` keeps the
|
||||
chunk-granular meaning plain `transcribe` already uses (1c). Worth a smoke test
|
||||
on one `silo-android` / `silo-apple` client to confirm the live track renders
|
||||
cues arriving before the transcript track completes, and that `Done/Total` is
|
||||
treated as a fraction.
|
||||
|
||||
## Risks & tradeoffs
|
||||
|
||||
- **Translation context at chunk boundaries (live only).** Per-chunk translation
|
||||
resets the batch context-neighbour window at each ~30s boundary — accepted
|
||||
quality tradeoff for live; background jobs are unchanged.
|
||||
- **More/smaller ASR requests for live jobs.** 30s chunks mean more requests;
|
||||
per-chunk timeout (`chunkSeconds * asrChunkTimeoutFactor`) and per-spawn
|
||||
overhead scale; the 15s floor bounds the worst case.
|
||||
- **Incremental extraction reliability (Phase 2).** Segment-list polling + the
|
||||
`-ss` seek path need fixtures for boundary timing, the final-segment race, and
|
||||
cancel/cleanup. Single ffmpeg pass per direction keeps cost low.
|
||||
- **Boundary word-clips.** Smaller live chunks add more fixed-length boundaries
|
||||
where a straddling word can clip — already a documented v1 limitation; the
|
||||
silence-aligned follow-up would address it.
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- **Pace to playback (issue item 3):** rolling window ahead of the playhead to
|
||||
bound work; separate change once interleave + incremental extraction land.
|
||||
- **Cross-chunk translation context:** feed the previous chunk's tail source
|
||||
cues as untranslated context into each chunk's translate call.
|
||||
- **Silence-aligned chunk boundaries:** reduce boundary word-clips that smaller
|
||||
live chunks make more frequent.
|
||||
|
||||
## v1 scope note
|
||||
|
||||
`docs/architecture/v1-scope.md` is **NOT LOCKED**; the issue carries
|
||||
`v1-proposed`. This is a **bug fix** to an already-shipped capability (no new
|
||||
user-facing capability, endpoint, or response field — only an internal config
|
||||
key and corrected streaming behavior), so it proceeds under the "bug fixes
|
||||
proceed normally" clause. Link the PR to the AI-subtitle capability item per the
|
||||
v1 PR requirements.
|
||||
|
||||
## Pre-push checklist
|
||||
|
||||
- `make lint`
|
||||
- `cd web && pnpm run lint && pnpm run format:check` (run only if web touched —
|
||||
none expected)
|
||||
- `make verify-local-paths`
|
||||
- Go tests for `internal/subtitles/ai` and `internal/playback` in a
|
||||
libvips-capable container (a bare-host `go test ./...` silently skips CGO
|
||||
packages).
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Personal Collection Catalog Filter Standardization - Implementation Plan
|
||||
|
||||
- **Date:** 2026-06-23
|
||||
- **Spec:** `docs/superpowers/specs/2026-06-23-personal-collection-catalog-filter-standardization-design.md` (rev. 3, post-adversarial-review). The spec owns the *why* and the decisions; this plan owns the *how*, with concrete files and ordering.
|
||||
- **Note:** All paths are repository-relative. Commands assume the repository root is the cwd.
|
||||
|
||||
## Approach & PR Boundaries
|
||||
|
||||
Three concerns, landed in order. PR 1 is independently valuable and must merge first because PR 2 depends on its hardened predicate for correctness.
|
||||
|
||||
| PR | Concern | Depends on |
|
||||
|----|---------|-----------|
|
||||
| **PR 1** | Harden the catalog `watched` predicate (series/season rollup + row-type branching) and pin equivalence with item user-data. | — |
|
||||
| **PR 2** | Replace the `watch_filter` / `media_filter` enum vocabulary with a `display_query_definition` fragment end to end (migration → storage → API → execution → web). | PR 1 |
|
||||
| **PR 3** (deferred) | Wire display filters for smart/live collections via an executor-level `(source) AND (display)` conjunction. | PR 2 |
|
||||
|
||||
**Why PR 3 is deferred:** display-filter controls are currently exposed only for manual/exact and imported collections (`CollectionBuilder.tsx` renders them only when `collection_type === "manual"`; imported uses `ImportedCollectionEditor.tsx`). Smart collections never reach `FilterUserCollectionDisplayItems` today (`catalog_resolver.go` branches smart collections to `resolveQuerySource` before the filter call). So the OR-leak the adversarial review found is a guardrail for *future* wiring, not a v1 path. Build PR 2 so the same conjunction helper is reusable, but do not expose smart-collection display filters until PR 3.
|
||||
|
||||
---
|
||||
|
||||
## PR 1 — Catalog Watched Predicate Hardening
|
||||
|
||||
**Goal:** make the catalog `watched` rule produce the same played/read verdict as item user-data (`internal/api/handlers/user_state.go`) for every item kind, under both scoped and unscoped queries. This is the single-semantic foundation; everything else routes through it.
|
||||
|
||||
### Current state (verified)
|
||||
|
||||
- `internal/catalog/query_builder.go`:
|
||||
- `buildWatchedClause` (≈900–912) wraps `userStateCompletionClause` and negates for `value:false`.
|
||||
- `userStateCompletionClause` (≈1119–1171) checks completion against **the row's own `content_id`** (`uwp.media_item_id = mi.content_id` / `uwh.media_item_id = mi.content_id`). No episode rollup → a series row is effectively never watched.
|
||||
- `ebookUserStateCompletionClause` (≈1173–1193) is selected **only when `qb.mediaScope == "ebook"`** → an ebook under an unscoped query is misclassified by the video clause.
|
||||
- `internal/api/handlers/user_state.go` `allEpisodesCompleted` (≈171–185) defines the canonical rollup over the episode repository's set.
|
||||
- Episode set (the parity target): `internal/catalog/episode_repo.go` `ListBySeriesIDs` / `ListBySeasonIDs` select from `episodes` filtered by `series_id` / `season_id` **AND** `episodeAvailabilityPredicate` = `EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = episodes.content_id)`. No specials/season-0 exclusion, no media-file requirement. Links: `episodes.series_id`, `episodes.season_id`.
|
||||
- The base relation always exposes `mi.type` (`media_items mi`, or the episode derived table that hard-codes `'episode'::text AS type`), so a `CASE` on row type is feasible.
|
||||
|
||||
### Changes
|
||||
|
||||
1. **Extract a reusable completion predicate.** In `internal/catalog/query_builder.go`, refactor `userStateCompletionClause` into a helper that, given a row alias and the linking column, emits the leaf completion EXISTS pair (progress + history, with hidden-history guard). Keep `ebookUserStateCompletionClause` as the ebook leaf. This is the shared building block for the branches below.
|
||||
|
||||
2. **Add series/season rollup branches with repository-parity episode sets.** Emit, for a series row, "≥1 child episode exists AND no child episode is incomplete," expressed so the child-episode set is **identical** to `episodeAvailabilityPredicate`:
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM episodes e
|
||||
WHERE e.series_id = mi.content_id
|
||||
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM episodes e
|
||||
WHERE e.series_id = mi.content_id
|
||||
AND EXISTS (SELECT 1 FROM episode_libraries el WHERE el.episode_id = e.content_id)
|
||||
AND NOT (<leaf completion predicate for e.content_id>))
|
||||
```
|
||||
Season rollup is the same with `e.season_id = mi.content_id`. The leaf predicate inside the `NOT EXISTS` reuses the helper from step 1 keyed on `e.content_id`. **The `episode_libraries` EXISTS must match `episodeAvailabilityPredicate` verbatim** — if `episode_repo.go` ever changes that predicate, this SQL changes with it. Add a code comment cross-referencing `episodeAvailabilityPredicate` so the coupling is discoverable.
|
||||
|
||||
3. **Branch by row type for unscoped queries.** When `qb.mediaScope` is empty/`video` (rows can be mixed), select the completion source from `mi.type` rather than from `mediaScope`:
|
||||
- `movie` / `episode` / video leaf → leaf predicate,
|
||||
- `series` → series rollup, `season` → season rollup,
|
||||
- `ebook` → ebook leaf,
|
||||
- `manga` → deferred; treat as never-completed for now (documented), do not invent a collection-only interpretation.
|
||||
When `mediaScope` is a single concrete kind (e.g. `series`, `ebook`), the optimizer-friendly path may emit just that branch. Implement as a `CASE mi.type WHEN ... END` or chained `(mi.type = 'x' AND <clause>) OR ...`; verify argIdx accounting (each leaf/ebook clause increments `qb.argIdx` by 2 — the rollups reuse the leaf, so count the placeholders carefully and add a builder test that the produced arg count matches bound args).
|
||||
|
||||
4. **`last_watched` stays separate.** Do not route `last_watched` through the new predicate; it is a `user_last_watched` CTE timestamp aggregate. Leave as-is; add a one-line comment noting the intentional divergence.
|
||||
|
||||
### Tests (PR 1)
|
||||
|
||||
- `internal/catalog/query_builder_test.go`: assert the `watched` clause contains the series/season rollup EXISTS only when the scope/row-type can include series/seasons; assert arg count == bound args for each branch; assert the `episode_libraries` predicate text is present in the rollup.
|
||||
- **Equivalence test (highest value):** seed a Postgres fixture with a series whose episodes are variously completed/not, a season, a movie, and an ebook for one profile; assert the catalog `watched` rule's matched set equals exactly the items whose `user_state.go` `Played` flag is true. Because `user_state.go` lives in `internal/api/handlers` (CGO/libvips), run this in the libvips-capable container; the test can drive both the catalog executor and the item-user-state builder against the same pool and diff the verdicts. If a same-package home is awkward, place the comparison test in `internal/api/handlers` where both sides are reachable.
|
||||
|
||||
### Release note (PR 1)
|
||||
|
||||
Catalog `watched` filtering for series/seasons changes from "never matches" to "matches when all available episodes are completed." Call this out in the PR body and release notes per the spec's semantics-change risk.
|
||||
|
||||
---
|
||||
|
||||
## PR 2 — Replace the Enum Vocabulary with `display_query_definition`
|
||||
|
||||
One concern: swap the persisted representation and every surface that reads/writes it. Clean replacement — no aliases, no backfill (the enums are branch-only and unreleased; see spec Current Branch State). Land the steps below together so no layer reads a column another layer has dropped.
|
||||
|
||||
### Step 2.1 — Migration reshape
|
||||
|
||||
Edit `migrations/sql/20260623184858_user_collection_display_filters.sql` in place so the canonical schema has no legacy columns:
|
||||
|
||||
- **Up:** `ALTER TABLE public.user_personal_collections ADD COLUMN IF NOT EXISTS display_query_definition jsonb;` (nullable; `NULL`/absent = no display filter). Remove the `watch_filter` / `media_filter` `ADD COLUMN` and their CHECK constraints.
|
||||
- **Down:** `ALTER TABLE ... DROP COLUMN IF EXISTS display_query_definition;`
|
||||
|
||||
**Operational gotcha:** Goose keys applied migrations by version, so editing an already-applied migration will **not** re-run it. Any dev DB that already applied the original `20260623184858` (which added `watch_filter`/`media_filter`) must be rolled back to before it and re-applied: `make migrate-down` to the prior version, then `make migrate-up`. If a shared dev DB makes in-place editing risky, the alternative is a *new* timestamped migration (`make migrate-create NAME=replace_collection_display_filters`) that drops the two columns and adds `display_query_definition`; the spec prefers in-place reshape since nothing is merged, but call the choice out in the PR.
|
||||
|
||||
### Step 2.2 — Backend storage & types
|
||||
|
||||
- `internal/userstore/types.go`: in `Collection` (≈185–213), `CreateCollectionInput` (≈238–255), `UpdateCollectionInput` (≈257–278), remove `WatchFilter` / `MediaFilter`; add `DisplayQueryDefinition string` (and `*string` on the update input), mirroring the existing `QueryDefinition` fields.
|
||||
- `internal/userstore/pgstore/collections.go`:
|
||||
- `collectionSelectColumns` (≈15–18): drop `watch_filter, media_filter`; add `display_query_definition`.
|
||||
- `scanCollection` (≈20–42) and the `ListCollections` scan (≈192–196): drop the two scan targets; add `&c.DisplayQueryDefinition`.
|
||||
- `CreateCollection` INSERT (≈79–94): drop the two columns/args; add `display_query_definition` + arg.
|
||||
- `UpdateCollection` (≈270–283): replace the two `add("watch_filter"…)` / `add("media_filter"…)` blocks with a single `display_query_definition` block driven by `input.DisplayQueryDefinition != nil`.
|
||||
- The normalization at create-time (≈59–68) moves from enum normalization to **fragment normalization** (Step 2.5).
|
||||
- `internal/userstore/collection_filters.go`: repurpose into the fragment normalizer's home, or delete the enum constants/`Normalize*` funcs if nothing else uses them after the swap. Keep the file's tests meaningful — convert `collection_filters_test.go` to cover the new normalizer (Step 2.5) rather than the deleted enums.
|
||||
|
||||
### Step 2.3 — API surface
|
||||
|
||||
- `internal/api/handlers/collections.go`:
|
||||
- `createCollectionRequest` (≈39–50), `updateCollectionRequest` (≈52–67), `collectionResponse` (≈73–101): drop `watch_filter` / `media_filter`; add `display_query_definition json.RawMessage` following the existing `query_definition` handling (response assembly mirrors `defaultJSON([]byte(c.QueryDefinition))` at ≈843).
|
||||
- `HandleCreateCollection` (≈270–279) / `HandleUpdateCollection` (≈341–356): replace enum validation with fragment normalization/validation (Step 2.5); reject invalid fragments with `400`.
|
||||
- `collectionCapabilitiesResponse` (≈108–111) + `HandleCapabilities` (≈224–229): replace `watch_filters`/`media_filters` value lists with capability metadata describing the supported display-query fields (`type`, `watched`) and the UI presets (`all/watched/unwatched`, `all/movies/series`). Keep it additive and feature-detection-oriented per the v1 API rules.
|
||||
- `internal/api/handlers/user_collection_imports.go`: `userImportSharedFields` (≈67–77) and `createImportedCollection` validation (≈214–223) — same swap to `display_query_definition`.
|
||||
|
||||
### Step 2.4 — Exact-collection execution through the catalog executor
|
||||
|
||||
Replace the custom Go filter pass and the metadata-only in-Go matcher with one SQL path.
|
||||
|
||||
- Delete `internal/catalog/user_collection_display_filters.go` (`FilterUserCollectionDisplayItems` and helpers) and its test, plus the duplicated `userCollectionAllEpisodesCompleted`.
|
||||
- Call sites to rewrite:
|
||||
- `internal/catalog/catalog_resolver.go` `resolveUserCollectionSource` (≈479–526, filter call at ≈517).
|
||||
- `internal/sections/fetcher.go` `fetchUserCollection` (filter call at ≈1371).
|
||||
- New behavior for exact collections (per spec Execution Model):
|
||||
1. Load member IDs in source order (`user_personal_collection_items`, `ORDER BY position`) — unchanged.
|
||||
2. Parse `collection.DisplayQueryDefinition` into a `QueryDefinition`, **force `Limit = nil`** and drop any fragment `sort`/`library_ids` defensively before execution.
|
||||
3. Run the executor with `AccessFilter.AllowedContentIDs = memberIDs` (the pattern `resolveCandidateItemsWithQuery` already uses at `catalog_resolver.go` ≈606–607). The executor's `conditions` slice AND-joins `AllowedContentIDs` with the display fragment, so membership ∧ filter is automatic — no OR-leak risk here.
|
||||
4. For source-ordered requests: request the **full** match set (no limit), collect matching IDs into a set, reapply original order, then apply request offset/limit. Pagination happens after reordering, never inside the executor.
|
||||
5. Stop using `filterCatalogItems` (`catalog_resolver.go` ≈1772) for these collections — replace the `UseSourceOrder` branch in `resolveExactOrderedMediaItems` (≈571–586) with the match-set-then-reorder path. **Do not delete `filterCatalogItems`:** it has a second caller at `catalog_resolver.go:244` (the query-source path), so it stays; only the exact-collection call at ≈575 changes.
|
||||
- Empty display fragment ⇒ skip the executor filter entirely and return members in source order (preserves today's "no filter" performance for unfiltered collections).
|
||||
|
||||
### Step 2.5 — Fragment normalization (shared backend helper)
|
||||
|
||||
A `display_query_definition` is a **filter-only** `QueryDefinition` fragment. Add one normalizer (e.g. in `internal/catalog` next to `QueryDefinition`, or `internal/userstore`) used by every write path (create/update/import):
|
||||
|
||||
- Accept only `match` + `groups`, with rules limited to the allowed display vocabulary (v1: `type`, `watched`).
|
||||
- Reject (preferred, on write) or strip `limit`, `sort`, `library_ids`, `media_scope`.
|
||||
- Reject unknown fields/ops so clients learn the contract.
|
||||
- Return a canonical JSON string for storage. Used by `pgstore` create/update and the API handlers.
|
||||
|
||||
### Step 2.6 — Web
|
||||
|
||||
The frontend already has the machinery: `QueryDefinition` types, `createEmptyQueryDefinition` / `normalizeQueryDefinition` (`web/src/api/types.ts` ≈1318–1361), and `CollectionGuidedRulesEditor.tsx` which already converts `watched` rules ↔ a `watchStatus` field (`queryDefinitionToGuidedState` ≈93–254, `guidedStateToQueryDefinition` ≈257–374). Reuse it; do not add a parallel state model.
|
||||
|
||||
- `web/src/lib/collectionDisplayFilters.ts`: replace the enum option lists / `normalize*` helpers with converters **preset ⇄ `QueryDefinition` fragment**:
|
||||
- `watched`/`unwatched`/`all` ⇄ a `{field:"watched",op:"is",value:true|false}` rule (or none),
|
||||
- `movie`/`series`/`all` ⇄ a `{field:"type",op:"is",value:"movie"|"series"}` rule (or none),
|
||||
- a builder that assembles the two presets into a single filter-only fragment, and a reader that derives the two preset values back from a fragment (for editing).
|
||||
- `web/src/api/types.ts`: drop `UserCollectionWatchFilter` / `UserCollectionMediaFilter` and the `watch_filter` / `media_filter` fields on `Collection` (≈1270–1271), `CreateCollectionRequest` (≈1403–1404), `UpdateCollectionRequest` (≈1420–1421); add `display_query_definition?: QueryDefinition`.
|
||||
- Editors — swap the two `<Select>` controls' wiring to read/write through the helper, keeping the same visible labels:
|
||||
- `web/src/components/collections/CollectionBuilder.tsx` (value shape ≈45–57, UI ≈259–313, submit ≈88–101),
|
||||
- `web/src/pages/ImportedCollectionEditor.tsx` (state ≈118–125, UI ≈277–354, save ≈184–214),
|
||||
- `web/src/components/CollectionTemplateGallery/UserCollectionTemplateConfigForm.tsx` (state ≈99–100, submit ≈119–167).
|
||||
- `web/src/hooks/queries/collections.ts` (`useCreateCollection` ≈85–101, `useUpdateCollection` ≈103–127): payloads now carry `display_query_definition` instead of the enums (the generic `buildUserCollectionPayload` needs no change beyond the type swap).
|
||||
|
||||
### Tests (PR 2)
|
||||
|
||||
- `internal/api/handlers/collections_test.go`: create/update/get round-trips `display_query_definition`; `watch_filter`/`media_filter` are absent from request and response; an invalid fragment (e.g. containing `limit` or an unknown field) returns `400`.
|
||||
- Resolver/executor tests:
|
||||
- exact collection routes through the catalog `watched` rule and preserves source order across pages;
|
||||
- an exact collection larger than a page returns the full match set in source order regardless of fragment contents (guards the limit-truncation finding);
|
||||
- a mixed-media exact collection (movie + ebook) with `watched`/`unwatched` classifies the ebook via `ebook_reader_progress`, not video history (relies on PR 1 row-type branching).
|
||||
- Fragment-normalizer unit tests (replacing `collection_filters_test.go`): strips/rejects `limit`/`sort`/`library_ids`/`media_scope`; round-trips `type` + `watched` rules.
|
||||
- Frontend: helper test for preset ⇄ fragment conversion (both directions); one editor test asserting the submit payload carries the canonical `display_query_definition`.
|
||||
|
||||
---
|
||||
|
||||
## PR 3 — Smart/Live Display Filters (deferred)
|
||||
|
||||
Only when smart/live collections expose display-filter controls. Implements the spec's executor-level conjunction so `(source) AND (display)` holds regardless of the source's top-level `match`.
|
||||
|
||||
- Seam: `internal/catalog/query_executor.go`, after the `AllowedContentIDs` handling (≈322) and before `ApplySectionAccessFilter`. Build the display fragment with a second `NewQueryBuilder("mi").WithArgIdx(argIdx)`, `rebindSQLPlaceholders` its output to the running `argIdx`, append the rebound clause to `conditions`, and advance `argIdx`/`args`. Because `conditions` is AND-joined (≈353), this yields `(sourceWhere) AND (displayWhere)` with no group-list manipulation and no OR-leak.
|
||||
- Do **not** append the display group into `def.Groups` — that is the OR-leak the adversarial review found (`QueryBuilder.Build` joins top-level groups with the def's single `match`, ≈84–111).
|
||||
- Tests: a `match: any` source plus a display filter never returns rows the source alone excludes; a contradictory `watched` overlay on a `match: any` source returns zero rows.
|
||||
|
||||
---
|
||||
|
||||
## Verification (every PR, pre-push)
|
||||
|
||||
Per `CLAUDE.md`:
|
||||
|
||||
- `make lint`
|
||||
- `cd web && pnpm run lint && pnpm run format:check`
|
||||
- `make verify-local-paths`
|
||||
- Go tests in a **libvips-capable container** (a bare-host `go test ./...` silently skips CGO packages including `internal/api/handlers`, where the equivalence and DTO tests live). Prefix `GOWORK=off` for builds/tests in this repo.
|
||||
- Targeted: `go test ./internal/catalog/... ./internal/api/handlers/... ./internal/userstore/...` and the relevant `web` helper/editor tests.
|
||||
|
||||
## Sequencing summary
|
||||
|
||||
1. PR 1 (predicate + equivalence test) → merge.
|
||||
2. PR 2 (migration → types/storage → API/imports → fragment normalizer → exact execution → web), landed together → merge.
|
||||
3. PR 3 only if/when smart-collection display filters are wired.
|
||||
|
||||
Link each PR to the capability sub-issue (`Part of #NNN`) per the v1 process; keep one concern per PR.
|
||||
|
||||
## Open items to confirm before/while implementing
|
||||
|
||||
- Confirm no `silo-android` / `silo-apple` build already consumes the branch's `watch_filter` / `media_filter` fields (spec assumes unreleased; if pinned, fall back to the contingency backfill path in the spec's Migration Strategy).
|
||||
- Confirm `episodeAvailabilityPredicate` is the intended "child episode" definition to match (Open Question #4 in the spec) — it currently includes specials/season-0 if they have library links. Match it exactly; change item user-data and the catalog rule together if it should differ.
|
||||
- Decide the fragment normalizer's package home (`internal/catalog` vs `internal/userstore`) based on import direction — it needs the `QueryDefinition` type without creating an import cycle.
|
||||
@@ -0,0 +1,327 @@
|
||||
# Hybrid Semantic Search Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Make Meilisearch hybrid semantic search safe to enable broadly by gating it on *trustworthy* per-type vector coverage, validating embedder capability, and exposing per-query/admin diagnostics — without regressing the hot search path.
|
||||
|
||||
**Architecture:** Semantic search stays gated behind the existing `SemanticEnabled && wordCount >= 3` rule, plus a new **coverage gate** that reads an in-memory, periodically-refreshed per-type coverage snapshot (never a per-request DB query). Coverage = `current-model embeddings ÷ embed-eligible items`, computed entirely from Postgres and latched with hysteresis. Existing provider/fallback fields are plumbed through the resolver into an additive `search_diagnostics` response object. A rate-limited capability probe validates the Meili embedder without downing keyword search.
|
||||
|
||||
**Tech Stack:** Go (pgx/pgxpool), Meilisearch hybrid search (`userProvided` embedder), React/TypeScript admin UI, Goose migrations.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **API additive-only within `/api/v1`** — never rename/remove a response field, change a type, or repurpose a status code. New fields are `omitempty` additions. (CLAUDE.md)
|
||||
- **Performance first, reliability first, predictable under load** — the search request path must not gain a synchronous DB count, lock wait, or remote probe. Fail **safe** (keyword-only), never panic. (CLAUDE.md Core Priorities)
|
||||
- **One additive index migration** is in scope (revised — see Assumptions): a btree on `media_item_embeddings(model)` to support the periodic coverage query. No schema/column changes.
|
||||
- **Canonical embedding dimensions** = `embeddingvectors.CanonicalDimensions` (= 3072; re-exported as `recommendations.CanonicalEmbeddingDimensions`). Never hardcode the number.
|
||||
- **Single source of truth for embed-eligibility** — the predicate `(status='matched' OR type IN ('audiobook','ebook'))` must not be duplicated. It moves to the neutral `internal/embeddingvectors` package (imported by both `catalog` and `recommendations`; `recommendations` already imports `catalog`, so `catalog` cannot import `recommendations`).
|
||||
- **Single phase** — measurement *and* enforcement ship together. The diagnostics shipped here are what let us retune thresholds later without code changes-in-anger.
|
||||
- Build/test in worktrees with `GOWORK=off` and a stubbed `web/dist` (see worktree-build-quirks memory).
|
||||
|
||||
---
|
||||
|
||||
## Corrected Baseline (what actually exists today — verified)
|
||||
|
||||
Work from this, not from intuition. Each point was confirmed against the tree.
|
||||
|
||||
1. **The only semantic skip rule is `SemanticEnabled && len(Fields(normalizedQuery)) >= 3`** (`internal/catalog/search_meilisearch_provider.go:329`). There is **no** `SkipTotal`/preview skip, and `TestMeilisearchSearchRequestBuildsHybridForApproximateInteractiveSearch` (`search_provider_test.go:213`) asserts hybrid **IS** used with `SkipTotal: true`. → **Do not add a `SkipTotal` skip.** Layer the coverage gate onto the existing rule; remove nothing.
|
||||
2. **`CatalogSearchResult` already carries `Provider` and `FallbackReason`** (`search_provider.go:64-71`). Diagnostics work is *plumbing these through* `CatalogResult` (which drops them, `catalog_resolver.go:24-30,291-296`) — not inventing provider fields.
|
||||
3. **The provider already self-downgrades hybrid→keyword on a Meili error** (`search_meilisearch_provider.go:221-228`). Diagnostics must report the *post-downgrade* reality.
|
||||
4. **`countCatalogSearchVectorDocuments` ignores `model`** (`search_indexer.go:757`), counts the *indexed* population (no eligibility filter), and its companion denominator (`document_count`) comes from **Meili index stats** (`search_service.go:129`), not Postgres. It has **four callers** — `search_service.go:139`, `search_indexer.go:133`, `:203`, `:310` — all must be updated when its signature changes.
|
||||
5. **Indexed population ≠ embed-eligible population.** `LoadDocumentsAfter` (`search_indexer.go:534`) indexes every type-matched, non-manga row regardless of `status`; embeddings are only written for `(status='matched' OR audiobook OR ebook)` (`recommendations/repo.go:154-160`, `recommendationItemEligibilityWhereClause`). A denominator of "indexable items" is therefore structurally < 1.0 on any library with unmatched video → **the gate would never open.** Coverage must use the **embed-eligible** population for both numerator and denominator.
|
||||
6. **Status fields that already exist:** `document_count`, `vector_document_count` (`search_provider.go:347-348`). The earlier draft's `index_document_count` does **not** exist — keep real names; add new fields alongside.
|
||||
7. **`UpsertEmbedding` already enqueues a Meili index event after every write** (`recommendations/repo.go:186`) — verified correct, **no change**.
|
||||
8. **Catalog search settings are restart-bound** (`config/restart_keys.go:75-85`, *"Catalog search provider construction is intentionally startup-bound in v1"*). The service is built **once** in `NewRouter` and never live-rebuilt. → the coverage refresher is a single process-lifetime goroutine; no live-rebuild leak exists in v1.
|
||||
9. **`catalogSearchService` is a local var in `NewRouter`** (`router.go:437`), wired into handlers but **never returned** to `main.go`. The long-lived `deps.AppContext` (`router.go:88`) is the refresher's context.
|
||||
10. **Legacy `/search` plain-`q` re-route is descoped** — see [Descoped](#descoped--not-in-this-pass).
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### D1 — Coverage = current-model embeddings ÷ embed-eligible items (single Postgres source, per type)
|
||||
|
||||
Let `ELIG(alias)` = `embeddingvectors.ItemEligibilityWhereClause(alias)` → `(alias.status='matched' OR alias.type IN ('audiobook','ebook'))` (moved here from `recommendations`; `recommendations.recommendationItemEligibilityWhereClause` is refactored to call it — no behavior change, no duplication).
|
||||
|
||||
- **Denominator** (embed-eligible items, per type):
|
||||
```sql
|
||||
SELECT mi.type, COUNT(*) AS eligible
|
||||
FROM media_items mi
|
||||
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
|
||||
AND ($1::text[] IS NULL OR mi.type = ANY($1))
|
||||
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
|
||||
GROUP BY mi.type
|
||||
```
|
||||
- **Numerator** (eligible items with a *current-model* embedding, per type):
|
||||
```sql
|
||||
SELECT mi.type, COUNT(*) AS vectorized
|
||||
FROM media_item_embeddings e
|
||||
JOIN media_items mi ON mi.content_id = e.media_item_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM manga_chapters mc WHERE mc.chapter_content_id = mi.content_id)
|
||||
AND ($1::text[] IS NULL OR mi.type = ANY($1))
|
||||
AND (mi.status = 'matched' OR mi.type IN ('audiobook','ebook')) -- ELIG("mi")
|
||||
AND ($2 = '' OR e.model = $2)
|
||||
GROUP BY mi.type
|
||||
```
|
||||
|
||||
Applying `ELIG` to **both** guarantees numerator ⊆ denominator → ratio ∈ `[0,1]` (an embedding left behind on an item that became unmatched can't push a type over 1.0). Unmatched items are excluded from both, so they never drag coverage. As backfill completes, ratio → 1.0.
|
||||
|
||||
**Active model (`$2`)** is read from the embedding lock (`recommendations.EmbeddingLock.Model`, `embedding_lock.go:16`). No lock ⇒ `$2 = ""` and the snapshot is marked not-ready (never gate semantic on for an unestablished embedding space).
|
||||
|
||||
> **Why model-filtering is load-bearing:** right after a model change, old vectors persist but are mismatched to the new query embedder. A model-blind count reads ~100% at the most dangerous moment. Filtering by the locked model collapses coverage to ~0 until re-embedding catches up.
|
||||
|
||||
**Index (migration, in scope):** `media_item_embeddings` has only an HNSW index on `embedding` (`migrations/sql/001_schema.sql:1857`, `014_...:25`) — nothing on `model`. Add `CREATE INDEX CONCURRENTLY idx_media_item_embeddings_model ON public.media_item_embeddings (model)` so the numerator filter is index-supported. (Goose: timestamped file via `make migrate-create`, with `-- +goose NO TRANSACTION` because `CONCURRENTLY` cannot run in a txn.)
|
||||
|
||||
### D2 — Coverage lives in an in-memory snapshot, refreshed off the request path
|
||||
|
||||
A `semanticCoverageTracker` (new, `internal/catalog/semantic_coverage.go`) holds an `atomic.Pointer[semanticCoverageSnapshot]`.
|
||||
|
||||
- **Refresh cadence:** a single background goroutine every `semanticCoverageRefreshInterval` (**2 minutes** — long enough that two `GROUP BY` aggregates are negligible steady-state load even at 1M rows; short enough to bound the post-model-change danger window, which also fails safe via the model-collapse rule below). The interval is a named const; **there is no after-sync trigger** (the indexer and service share no handle — see Descoped rationale; the interval alone is sufficient and avoids a cross-component wiring + race).
|
||||
- **Single-flight & immutability:** `Refresh` holds a `sync.Mutex` across read-prev → query → compute → publish (background path; contention irrelevant). Each refresh builds a **fresh** `PerType` map and publishes a new immutable snapshot pointer; the published snapshot is **never mutated** after publish (avoids a `map` data race against concurrent `CoverageReady` reads). A `-race` test fires refreshes concurrently with reads.
|
||||
- **Fail-safe reads:** `CoverageReady` reads only the atomic pointer — **no DB, no lock, no remote call.** If the snapshot is `nil` (boot, before first refresh) it returns `(false, "coverage not yet computed")` — **never** dereferences a nil map (that would be a hot-path panic).
|
||||
- **Error retention:** on a refresh query error, retain the last-good snapshot (transient DB blips must not flip semantic off); log; do not publish a zeroed snapshot. Admin status flags `UpdatedAt` staleness.
|
||||
- **Model-change collapse:** the snapshot records the `Model` it was computed for. When `Refresh` observes the active model differs from the published snapshot's model, it **immediately publishes an all-not-ready snapshot** (safe) and recomputes real coverage under the new model on the same pass. This bounds the mismatched-vector window to ≤ one interval and fails safe within it.
|
||||
|
||||
### D3 — Per-type readiness with hysteresis; scope = AND of its types; empty scope = all types
|
||||
|
||||
Each type's `ready` flag is **latched**: flips **true** at `ratio >= semanticCoverageEnableRatio` (0.90), flips **false** only at `ratio < semanticCoverageDisableRatio` (0.80). The snapshot carries prior latched state so each refresh applies hysteresis — preventing flap (identical queries returning differently-ranked results).
|
||||
|
||||
`CoverageReady(itemTypes)`:
|
||||
- **Non-empty scope:** ready iff **every** requested type is latched-ready (`min` semantics — a weak subtype blocks the mixed scope). The reason names the first failing type.
|
||||
- **Empty/nil scope** (the common "search everything" case — `MediaScopeItemTypes("") → nil`, so `ItemTypes == nil` reaches the gate): ready iff **every type in the snapshot** is latched-ready. This deliberately mirrors the empty-config semantics and refuses to let an unscoped search bypass a weak type. Reason names the first failing type, or `"coverage not yet computed"` if the snapshot is nil/empty.
|
||||
|
||||
### D4 — Diagnostics: existing fields, plumbed, reported post-downgrade, scoped to the provider path
|
||||
|
||||
- Add `Mode string` (`"keyword"`/`"hybrid"`) and `SemanticUsed bool` to `CatalogSearchResult`; the Meili provider sets them to reflect the **final** request after the internal hybrid→keyword downgrade.
|
||||
- Add `Provider`, `Mode`, `SemanticUsed`, `FallbackReason` to `CatalogResult`; copy them in `resolveDirectSearchSource`.
|
||||
- Add an `omitempty` `search_diagnostics` object to `catalogResponse`.
|
||||
- **Scope:** diagnostics are produced **only** on the direct provider-backed search path (`useDirectSearchPath` = relevance sort, desc, no advanced rules — `catalog_resolver.go:1416-1427`). They are **omitted** for: browse, non-relevance-sorted `q=` searches (which never hit the provider), and `group=work` responses (the grouped wrapper builds a fresh `CatalogResult` at `catalog.go:287-293` and is already approximate — `TotalExact=false`). Each exclusion gets an explicit handler test.
|
||||
|
||||
### D5 — Capability validation: narrow, rate-limited, non-fatal, admin-path only
|
||||
|
||||
Embedder settings are written by Silo itself, so validation guards only **external drift** (Meili volume reset, manual edits). Add `GetSettings` to the Meili client; verify the configured embedder exists, `source == "userProvided"`, and `dimensions == embeddingvectors.CanonicalDimensions`. The hybrid probe:
|
||||
- runs **only on the admin status path** (`HandleGetCatalogSearchStatus`), **not** on `CheckConnection`/health (which only does `GET /health`), and the admin query has no `refetchInterval` (`settings.ts`, `staleTime: 15_000`);
|
||||
- runs **only when `state.ActiveIndexUID != ""`**, and is **cached for `semanticCapabilityProbeTTL` (5m)** → ≤ 1 probe / 5m regardless of refresh rate;
|
||||
- uses a **non-degenerate unit sample vector** (`v[0]=1.0`, rest 0, length `CanonicalDimensions`) — an all-zero vector has zero norm and can spuriously fail cosine hybrid; targets `ActiveIndexUID` with `Hybrid.Embedder = config.Embedder`, `limit:1`.
|
||||
|
||||
Failures surface in admin status and force keyword-only; they **never** flip provider `Healthy` or trip the circuit.
|
||||
|
||||
### D6 — Backfill split: cheap discovery vs. bounded text-staleness, with fairness
|
||||
|
||||
The expensive part of `ListEmbeddingTextCandidates` is **building `current_text` for every eligible row** via 5 `LATERAL` joins to `item_people` (`recommendations/repo.go:544-589`) — *not* the `IS DISTINCT FROM` comparison.
|
||||
|
||||
- **Cheap discovery** (`ListMissingOrModelStaleEmbeddingIDs`): `LEFT JOIN media_item_embeddings`, filter `ELIG("mi") AND (e.media_item_id IS NULL OR e.model = '' OR e.model <> $currentModel)`, `ORDER BY mi.content_id`, `LIMIT`. **No `LATERAL`, no `current_text`.** The SQL is built by a package-private `buildMissingOrModelStaleEmbeddingIDsSQL() string` helper so a unit test can assert it contains neither `item_people` nor `LATERAL` (mirrors the existing `embeddingEligibilityWhereClause()` string-helper test pattern). Returns IDs; `current_text` is then built **only for that bounded batch** via `BuildEmbeddingTextForIDs(ids)`.
|
||||
- **Text-staleness** (existing `ListEmbeddingTextCandidates`): keep, but run as a **bounded, unconditional quota per cycle** (`embeddingTextStaleQuotaPerRun`) so canonical-text-stale items progress even when the cheap backlog never empties (fairness against starvation).
|
||||
- **Cursors:** the cheap pass advances `afterID` from the **cheap query's** last id (it is the ordered cursor), independent of `BuildEmbeddingTextForIDs` row order (`ANY($1)` does not preserve slice order, and a dropped id must not desync the cursor). The text-stale pass keeps its **own** `afterID`. Both monotonic.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Change |
|
||||
| --- | --- | --- |
|
||||
| `migrations/sql/<ts>_media_item_embeddings_model_index.sql` | `(model)` btree, `CONCURRENTLY` / `NO TRANSACTION` | Create |
|
||||
| `internal/embeddingvectors/eligibility.go` | `ItemEligibilityWhereClause(alias)` — single source of truth | Create |
|
||||
| `internal/catalog/semantic_coverage.go` | tracker, snapshot, querier seam, per-type SQL, hysteresis, single-flight refresher | Create |
|
||||
| `internal/catalog/search_provider.go` | gate + model-provider interfaces; `Mode`/`SemanticUsed`; semantic status structs | Modify |
|
||||
| `internal/catalog/search_meilisearch_provider.go` | consult gate; set `Mode`/`SemanticUsed` (incl. downgrade); capability probe (unit vector) | Modify |
|
||||
| `internal/catalog/search_meilisearch_client.go` | `GetSettings` + settings DTOs | Modify |
|
||||
| `internal/catalog/search_service.go` | build/own tracker; `StartCoverageRefresh`; comma-ok model-provider; `Status()` reads snapshot | Modify |
|
||||
| `internal/catalog/search_indexer.go` | `ELIG`+`model` numerator & eligible denominator; update **all 4** `countCatalogSearchVectorDocuments` callers | Modify |
|
||||
| `internal/catalog/catalog_resolver.go` | carry diagnostics through `CatalogResult` (direct path only) | Modify |
|
||||
| `internal/recommendations/repo.go` | delegate eligibility to `embeddingvectors`; `ListMissingOrModelStaleEmbeddingIDs` (+SQL helper); `BuildEmbeddingTextForIDs` | Modify |
|
||||
| `internal/recommendations/similar.go` | `EmbedAll`: cheap drain + unconditional text-stale quota; dual cursors | Modify |
|
||||
| `internal/recommendations/engine.go` | `ActiveEmbeddingModel(ctx)` from the lock | Modify |
|
||||
| `internal/api/handlers/catalog.go` | `searchDiagnostics` DTO; populate on direct path; omit for grouped | Modify |
|
||||
| `internal/api/router.go` | start `service.StartCoverageRefresh(deps.AppContext)` inside `NewRouter` | Modify |
|
||||
| `web/src/hooks/queries/admin/settings.ts`, `web/src/pages/admin-settings/SearchSettings.tsx` | surface readiness/ratio/per-type/capability | Modify |
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Shared eligibility predicate, model+eligibility coverage counts, supporting index
|
||||
|
||||
**Files:** Create `internal/embeddingvectors/eligibility.go`, `migrations/sql/<ts>_media_item_embeddings_model_index.sql`; Modify `internal/recommendations/repo.go:150-160`, `internal/catalog/search_indexer.go:757-782` (+ callers `:133,:203,:310`, `search_service.go:139`); Test `internal/embeddingvectors/eligibility_test.go`, `internal/catalog/search_indexer_test.go`.
|
||||
|
||||
**Interfaces — Produces:**
|
||||
- `embeddingvectors.ItemEligibilityWhereClause(alias string) string`
|
||||
- `countCatalogSearchVectorDocuments(ctx, q coverageQuerier, itemTypes []string, model string) (int, error)` (adds `model`; applies `ELIG`)
|
||||
- `catalogSemanticCoverageByType(ctx, q coverageQuerier, itemTypes []string, model string) ([]catalogTypeCoverage, error)` → `{Type string; Eligible, Vectorized int}`
|
||||
|
||||
- [ ] Migration: `make migrate-create NAME=media_item_embeddings_model_index`; body uses `-- +goose NO TRANSACTION` then a self-healing guard for a leftover invalid index from a crashed build (follow the `20260618164519_add_episodes_season_id_index.sql` precedent: a `DO $$ ... NOT i.indisvalid ... DROP INDEX $$` block), then `CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_item_embeddings_model ON public.media_item_embeddings (model);` (down: `DROP INDEX CONCURRENTLY IF EXISTS`). The runner honors `NO TRANSACTION` (three shipping precedents). Run `make migrate-up`; verify with `make migrate-status`.
|
||||
- [ ] Move the eligibility predicate to `embeddingvectors`; refactor `recommendationItemEligibilityWhereClause` to delegate (assert output string unchanged).
|
||||
- [ ] Failing tests: (a) `model` filter excludes rows whose `e.model` differs; (b) an **unmatched, non-book** item inflates neither numerator nor denominator (the C1 regression guard); (c) denominator counts eligible items with no embedding row; (d) ratio ∈ [0,1] when a stale embedding exists on a now-unmatched item.
|
||||
- [ ] Implement both queries with `ELIG`; merge per type in Go; update **all four** callers (pass active model where available, else `""`).
|
||||
- [ ] `GOWORK=off go test ./internal/embeddingvectors ./internal/catalog -run 'Eligib|Coverage' -count=1` → PASS. Commit.
|
||||
|
||||
### Task 2: Active embedding model from the lock
|
||||
|
||||
**Files:** Modify recommendations `Engine` (`engine.go`), `internal/catalog/search_provider.go`; Test `internal/recommendations/*_test.go`.
|
||||
|
||||
**Produces:** `recommendations: func (e *Engine) ActiveEmbeddingModel(ctx) (string, error)` (→ `GetEmbeddingLock`; `lock.Model` or `""`); `catalog: type CatalogSemanticModelProvider interface { ActiveEmbeddingModel(ctx context.Context) (string, error) }`.
|
||||
|
||||
- [ ] Test: lock present → its `Model`; no lock → `("", nil)`.
|
||||
- [ ] Implement; confirm `recEngine` satisfies both `CatalogSearchQueryVectorizer` (the real interface name, `search_provider.go:77`) and `CatalogSemanticModelProvider`.
|
||||
- [ ] Run recommendations tests → PASS. Commit.
|
||||
|
||||
### Task 3: Coverage tracker — querier seam, snapshot, hysteresis, single-flight refresher
|
||||
|
||||
**Files:** Create `internal/catalog/semantic_coverage.go`, `semantic_coverage_test.go`; Modify `internal/catalog/search_provider.go` (consts + gate interface).
|
||||
|
||||
**Produces:**
|
||||
```go
|
||||
const (
|
||||
semanticCoverageEnableRatio = 0.90
|
||||
semanticCoverageDisableRatio = 0.80
|
||||
semanticCoverageRefreshInterval = 2 * time.Minute
|
||||
)
|
||||
|
||||
// coverageQuerier is the seam so Refresh is unit-testable without a real *pgxpool.Pool.
|
||||
type coverageQuerier interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
}
|
||||
|
||||
type catalogTypeCoverage struct { Type string; Eligible, Vectorized int; Ratio float64; Ready bool }
|
||||
type semanticCoverageSnapshot struct { PerType map[string]catalogTypeCoverage; Overall float64; Model string; UpdatedAt time.Time }
|
||||
|
||||
type SemanticCoverageGate interface { CoverageReady(itemTypes []string) (ready bool, reason string) }
|
||||
|
||||
type semanticCoverageTracker struct { /* q coverageQuerier; indexTypes []string; models CatalogSemanticModelProvider; mu sync.Mutex; snap atomic.Pointer[semanticCoverageSnapshot]; clock func() time.Time */ }
|
||||
func (t *semanticCoverageTracker) Refresh(ctx context.Context) error
|
||||
func (t *semanticCoverageTracker) CoverageReady(itemTypes []string) (bool, string) // nil-snapshot ⇒ (false, "coverage not yet computed")
|
||||
func (t *semanticCoverageTracker) Snapshot() *semanticCoverageSnapshot
|
||||
func (t *semanticCoverageTracker) Run(ctx context.Context) // ticker; returns on ctx.Done()
|
||||
```
|
||||
|
||||
- [ ] Test (no panic / fail-safe): `CoverageReady` on a freshly-constructed tracker (no `Refresh` yet) returns `(false, "coverage not yet computed")` and touches the querier **zero** times (querier stub fails the test if called).
|
||||
- [ ] Test (hysteresis): 0.85→not ready; →0.92 ready; →0.85 **stays** ready; →0.79 not ready.
|
||||
- [ ] Test (scope): `CoverageReady(["movie","series"])` false when `series` < threshold though `movie` ready; reason names `series`. Empty scope requires all snapshot types ready.
|
||||
- [ ] Test (model collapse): snapshot computed for model A; active model becomes B → next `Refresh` publishes all-not-ready before recomputing.
|
||||
- [ ] Test (`-race`): concurrent `Refresh` + `CoverageReady` loops; assert monotonic `UpdatedAt`, no race.
|
||||
- [ ] Test (error retention): a `Refresh` whose query errors retains the prior snapshot.
|
||||
- [ ] Implement single-flight mutex, fresh-map immutability, nil-safe reads, model-collapse, retain-last-good, ticker `Run`.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog -run Coverage -race -count=1` → PASS. Commit.
|
||||
|
||||
### Task 4: Wire gate + model provider; comma-ok degradation; start refresher in NewRouter
|
||||
|
||||
**Files:** Modify `internal/catalog/search_meilisearch_provider.go:27-66,329-334` (`MeilisearchProviderConfig` opens at `:27`), `internal/catalog/search_service.go:31-74`, `internal/api/router.go:437-552`.
|
||||
|
||||
**Consumes:** `SemanticCoverageGate`, `CatalogSemanticModelProvider`. **Produces:** `MeilisearchProviderConfig.Coverage SemanticCoverageGate`; `func (s *CatalogSearchService) StartCoverageRefresh(ctx context.Context)`.
|
||||
|
||||
- [ ] `shouldUseSemanticSearch` → `SemanticEnabled && wordCount>=3 && (p.config.Coverage == nil || ready)`; not-ready yields fallback reason `"semantic_not_ready: " + reason` (a **diagnostic, not an error**). Keep the `SkipTotal`-builds-hybrid test green.
|
||||
- [ ] In `NewCatalogSearchServiceFromSettings`: only when `SemanticEnabled`, derive the model provider via **comma-ok** `mp, ok := queryVectorizer.(CatalogSemanticModelProvider)`. If `!ok` or `queryVectorizer == nil` (semantic enabled but recommendations disabled — a real config), build the tracker with a model provider that yields `""` ⇒ every type not-ready (reason `"embedding model provider unavailable"`); **never** assert without comma-ok (nil-interface assertion panics). Store the tracker on the service; pass it as `Coverage`.
|
||||
- [ ] `StartCoverageRefresh` runs `tracker.Run(ctx)` once; no-op when there is no tracker (postgres/semantic-off). Call it from `NewRouter` after the service is built: `if catalogSearchService != nil { catalogSearchService.StartCoverageRefresh(deps.AppContext) }`. (The service is local to `NewRouter` and not returned; `deps.AppContext` is the process-lifetime ctx — Baseline #8/#9.)
|
||||
- [ ] Test: stub gate not-ready ⇒ long query stays keyword-only; ready gate + vectorizer ⇒ hybrid emitted; nil vectorizer + semantic-enabled ⇒ not-ready, no panic.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
|
||||
|
||||
### Task 5: Diagnostics plumbing (provider → resolver → handler), correctly scoped
|
||||
|
||||
**Files:** Modify `internal/catalog/search_provider.go:64-71`, `internal/catalog/search_meilisearch_provider.go:189-237`, `internal/catalog/catalog_resolver.go:24-30,269-297`, `internal/api/handlers/catalog.go:55-61,91-134`; Test `search_provider_test.go`, `catalog_test.go`.
|
||||
|
||||
**Produces:** `CatalogSearchResult{+Mode string; +SemanticUsed bool}`; `CatalogResult{+Provider,Mode string; +SemanticUsed bool; +FallbackReason string}`; handler `type searchDiagnostics struct { Provider, Mode string; SemanticUsed bool; FallbackReason string \`json:"fallback_reason,omitempty"\` }`; `catalogResponse{+SearchDiagnostics *searchDiagnostics \`json:"search_diagnostics,omitempty"\`}`.
|
||||
|
||||
- [ ] Provider: `Mode="hybrid", SemanticUsed=true` only when the hybrid request is issued **and survives**; on the downgrade (`:221-228`) set `Mode="keyword", SemanticUsed=false, FallbackReason=semanticFallback`.
|
||||
- [ ] Resolver: copy the four fields in `resolveDirectSearchSource` (direct path only).
|
||||
- [ ] Handler: populate `search_diagnostics` only on the non-grouped direct path; **omit** for `group=work` and for non-relevance-sort `q=` requests.
|
||||
- [ ] Tests: (a) hybrid error ⇒ `semantic_used:false, mode:"keyword", fallback_reason` set (post-downgrade truth); (b) existing `total/total_exact/has_more/items/snapshot` byte-stable; (c) field **absent** for browse, for `group=work`, and for a `q=`+title-sort request.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog ./internal/api/handlers -count=1` → PASS. Commit.
|
||||
|
||||
### Task 6: Capability validation + rate-limited probe; admin status from snapshot
|
||||
|
||||
**Files:** Modify `internal/catalog/search_meilisearch_client.go:167+`, `internal/catalog/search_meilisearch_provider.go`, `internal/catalog/search_service.go:90-145`, `internal/catalog/search_provider.go` (status structs).
|
||||
|
||||
**Produces:**
|
||||
```go
|
||||
func (c *meilisearchClient) GetSettings(ctx, uid string) (meilisearchIndexSettings, error) // GET /indexes/{uid}/settings
|
||||
type meilisearchIndexSettings struct { Embedders map[string]meilisearchEmbedderSettings `json:"embedders"` }
|
||||
type meilisearchEmbedderSettings struct { Source string `json:"source"`; Dimensions int `json:"dimensions"` }
|
||||
type CatalogSearchSemanticStatus struct {
|
||||
Ready bool `json:"ready"`; DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CoverageRatio float64 `json:"vector_coverage_ratio"`; CoverageUpdatedAt *time.Time `json:"coverage_updated_at,omitempty"`
|
||||
PerType []CatalogSearchTypeCoverage `json:"per_type,omitempty"`; Capability CatalogSearchSemanticCapability `json:"capability"`
|
||||
}
|
||||
type CatalogSearchTypeCoverage struct { Type string `json:"type"`; Eligible int `json:"eligible"`; Vectorized int `json:"vectorized"`; CoverageRatio float64 `json:"vector_coverage_ratio"`; Ready bool `json:"ready"` }
|
||||
type CatalogSearchSemanticCapability struct { OK bool `json:"ok"`; Reason string `json:"reason,omitempty"`; Embedder string `json:"embedder,omitempty"`; Dimensions int `json:"dimensions,omitempty"` }
|
||||
const semanticCapabilityProbeTTL = 5 * time.Minute
|
||||
```
|
||||
|
||||
- [ ] Add `Semantic CatalogSearchSemanticStatus` to `CatalogSearchRuntimeStatus`. **Keep** `document_count`/`vector_document_count` unchanged (additive only). The `Semantic` block (ratio, per-type, ready) is read from `tracker.Snapshot()` — **no fresh query** in `Status()`; if no tracker, `Ready=false, DisabledReason="semantic disabled"`.
|
||||
- [ ] Capability: embedder present, `source=="userProvided"`, `dimensions==embeddingvectors.CanonicalDimensions`; each failure → distinct `Reason`. Probe (unit vector, `limit:1`, `Embedder=config.Embedder`) only when `ActiveIndexUID != ""`, cached `semanticCapabilityProbeTTL`.
|
||||
- [ ] Tests: missing embedder / wrong source / wrong dimensions / probe failure each distinct; provider `Healthy` and circuit untouched in all four.
|
||||
- [ ] `GOWORK=off go test ./internal/catalog -count=1` → PASS. Commit.
|
||||
|
||||
### Task 7: Backfill cheap/expensive split with fairness
|
||||
|
||||
**Files:** Modify `internal/recommendations/repo.go:472-621`, `internal/recommendations/similar.go:251-362`; Test `repo_test.go`, `similar_test.go`.
|
||||
|
||||
**Produces:** `buildMissingOrModelStaleEmbeddingIDsSQL() string`; `ListMissingOrModelStaleEmbeddingIDs(ctx, afterID, currentModel string, limit int) ([]string, error)`; `BuildEmbeddingTextForIDs(ctx, ids []string) ([]EmbeddingTextCandidate, error)`; `EmbedAll` two-pass loop with dual cursors and an unconditional `embeddingTextStaleQuotaPerRun`.
|
||||
|
||||
- [ ] Test (SQL shape): `buildMissingOrModelStaleEmbeddingIDsSQL()` contains neither `"item_people"` nor `"LATERAL"`.
|
||||
- [ ] Test (behavior, no real DB needed for the shape test; behavior tests use existing recommendations test seams): an item that is *only* canonical-text-stale (people changed, model current) is **not** returned by the cheap query; a missing/model-stale item **is**.
|
||||
- [ ] Test (parity): `BuildEmbeddingTextForIDs(ids)` reproduces the same `canonical_text` as the legacy full CTE for the same ids, regardless of `ids` order.
|
||||
- [ ] Test (no starvation): with a permanently non-empty cheap backlog, a text-stale item is still embedded within a bounded number of `EmbedAll` runs (quota is unconditional); both cursors stay monotonic.
|
||||
- [ ] Implement; cheap `afterID` advances from the cheap query's last id.
|
||||
- [ ] `GOWORK=off go test ./internal/recommendations -count=1` → PASS. Commit.
|
||||
|
||||
### Task 8: Admin UI surfacing
|
||||
|
||||
**Files:** Modify `web/src/hooks/queries/admin/settings.ts:21-54`, `web/src/pages/admin-settings/SearchSettings.tsx:202-261`.
|
||||
|
||||
- [ ] Extend the `CatalogSearchStatus` TS type with the `semantic` block (ready, disabled_reason, vector_coverage_ratio, coverage_updated_at, per_type, capability).
|
||||
- [ ] Render a "Semantic readiness" row (ready/disabled + reason), overall coverage %, a per-type coverage table, a capability badge, and a "coverage updated" timestamp. Keep "Vectorized Documents" (`vector_document_count`).
|
||||
- [ ] `cd web && pnpm run lint && pnpm run format:check` → PASS. Commit.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
**Go unit (`internal/embeddingvectors`, `internal/catalog`):**
|
||||
- Eligibility predicate single-sourced; unmatched non-book item excluded from numerator **and** denominator (C1 guard); ratio ∈ [0,1].
|
||||
- Hybrid omitted when gate not ready; emitted when ready + capable + long query + vectorizer present.
|
||||
- **Model-stale safety:** only-old-model embeddings ⇒ coverage ~0 ⇒ keyword-only; model-change collapse publishes not-ready within one interval.
|
||||
- Hysteresis latch; mixed-scope AND; empty-scope = all-types; nil-snapshot returns not-ready with **no panic** and **no DB call**; `-race` refresh/read; error retention.
|
||||
- Capability: missing embedder / wrong source / wrong dimensions / probe failure each distinct; keyword stays up.
|
||||
|
||||
**Go unit (`internal/recommendations`):**
|
||||
- Cheap SQL excludes `item_people`/`LATERAL`; people-only-stale row excluded from cheap pass; per-ID text build matches legacy output order-independently; no text-stale starvation; dual cursors monotonic.
|
||||
|
||||
**Go unit (`internal/api/handlers`):**
|
||||
- `search_diagnostics` present on direct path; **absent** for browse, `group=work`, and non-relevance-sort `q=`; `semantic_used` reflects post-downgrade reality; existing fields byte-stable.
|
||||
|
||||
**Admin status:** `semantic.ready`, `disabled_reason`, `vector_coverage_ratio`, `per_type`, `capability`, `coverage_updated_at` sourced from the snapshot (no fresh query).
|
||||
|
||||
**Focused command:**
|
||||
```bash
|
||||
GOWORK=off go test ./internal/embeddingvectors ./internal/catalog ./internal/api/handlers ./internal/recommendations -race -count=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Descoped — not in this pass
|
||||
|
||||
**Routing legacy `/search?q=` through the provider pipeline.** Sunset **Wed, 01 Jul 2026** (`internal/api/handlers/legacy_read_routes.go:10`, `legacyReadSunset`) — days away; hardening a path scheduled for deletion is negative ROI. The typed legacy path already routes through the resolver; only the bare-`q` fallback hits `itemRepo.Search`, which the sunset removes. Re-routing also risks `browseResponse`↔`catalogResponse` shape drift. Reopen as a one-task follow-up **only if the sunset slips**, with a byte-for-byte `browseResponse` assertion.
|
||||
|
||||
**After-sync coverage refresh trigger.** The indexer (`main.go:1730`) and the search service (`router.go:457`) share no handle, so an after-drain trigger would need new cross-component wiring and would race the interval refresh. The 2-minute interval plus model-change collapse is sufficient; the trigger is intentionally omitted.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **One additive index migration** (`media_item_embeddings(model)`, `CONCURRENTLY`). No schema/column changes. *(Revised from the original "no migration": the model-filtered numerator is only cheap with this index, and performance is priority #1.)*
|
||||
- All runtime coverage state is **in-memory** (atomic per-type snapshot), rebuilt on boot and refreshed every 2 minutes; not persisted.
|
||||
- Coverage is computed **entirely from Postgres** over the **embed-eligible** population (`status='matched' OR audiobook/ebook`), model-filtered, manga excluded, restricted to configured index types; it **never** uses the Meili `document_count`.
|
||||
- Active embedding model comes from `recommendations.EmbeddingLock.Model`; **no lock / no model provider ⇒ semantic not ready.**
|
||||
- A model change is reflected within ≤ one refresh interval and **fails safe** within that window via the model-collapse rule; the residual ≤2-minute mismatch window is acceptable for a rare, admin-gated reconfiguration.
|
||||
- Thresholds (enable 0.90 / disable 0.80) and the 2-minute interval are named consts and starting heuristics; the diagnostics shipped here are the instrument for retuning them. Promoting them to settings is a possible follow-up, not this pass.
|
||||
- `semantic_ratio` stays `0.30` (`DefaultMeilisearchSemanticRatio`).
|
||||
- Scope is hybrid-search hardening only — not conversational search.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (writing-plans checklist)
|
||||
|
||||
- **Findings coverage:** C1 eligibility denominator (D1, Task 1) · C2 empty-scope semantics (D3, Task 3) · missing `model` index (D1 migration, Task 1) · 4-caller signature change (Baseline #4, Task 1) · refresher wiring in `NewRouter`/`AppContext` (Baseline #9, Task 4) · concurrent-refresh single-flight + immutability + `-race` (D2, Task 3) · nil-snapshot/error fail-safe (D2, Task 3) · model-change collapse (D2, Task 3) · comma-ok nil vectorizer (Task 4) · querier seam + SQL-string helper for testability (Task 3, Task 7) · grouped/non-relevance diagnostics scoping (D4, Task 5) · unit probe vector + admin-only/5m (D5, Task 6) · `Status()` reads snapshot (Task 6) · backfill cursors + fairness (D6, Task 7) · legacy & after-sync descoped (Descoped) — all mapped.
|
||||
- **Type consistency:** `SemanticCoverageGate`/`CatalogSemanticModelProvider`/`coverageQuerier`/`semanticCoverageTracker`/`catalogTypeCoverage`/`CatalogSearchSemanticStatus` used identically across tasks; `Eligible` (not `Indexable`) is the denominator field name throughout.
|
||||
- **No placeholders:** every new symbol has a signature; SQL is concrete; the migration body is specified.
|
||||
@@ -0,0 +1,180 @@
|
||||
# OPA Policy Engine Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
|
||||
|
||||
**Spec:** [`../specs/2026-07-02-opa-policy-engine-design.md`](../specs/2026-07-02-opa-policy-engine-design.md) — read it first; this plan does not restate the full design rationale.
|
||||
|
||||
**Goal:** Embed OPA (`github.com/open-policy-agent/opa/v1/rego`) as Silo's authorization engine: vendor Rego reproduces today's viewer-scope, permission-gate, and download/playback decisions exactly (proven by dual-execution parity tests), admins get a full policy editor (CodeMirror, validate, simulate, versioning, rollback) for narrowing-only custom overrides, and every decision feeds a partitioned audit log.
|
||||
|
||||
**Architecture:** One new `internal/policy` package (engine, typed PDP, Postgres-backed document store, compile sandbox, async decision logger, System lifecycle). Vendor Rego ships via `go:embed`; admin documents live in Postgres as immutable versions with an activate pointer and a global `policy_generation` counter; cross-node invalidation via a new `cache.EventPolicyChanged` on the existing `ChannelAdmin` plus a 60s poll fallback. Adapters implement the existing interfaces (`middleware.ViewerResolver`, permission middleware, downloads gate, `playback.SessionLimitProvider`) so cutover is a constructor swap per surface. Direct replacement, staged per-surface, no runtime toggle.
|
||||
|
||||
**Tech Stack:** Go (pgx, chi, `opa/v1/rego` + `opa/v1/ast` + `opa/v1/tester`), Goose migrations, `internal/partman`, Redis `cache.EventBus`, React/TypeScript + TanStack Query + CodeMirror 6 (`@uiw/react-codemirror`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **API additive-only within `/api/v1`** — new endpoints/fields only; capability endpoint for feature detection. (CLAUDE.md)
|
||||
- **Performance first, reliability first** — prepared queries only; input-document-only (no OPA store mirroring, no DB reads inside eval); async decision logging that never blocks a decision; fail closed on every eval error/timeout.
|
||||
- **Frozen contracts:** `access.Scope` / `access.ResolveInput` shapes; PIN/profile-token crypto stays in `internal/access/profile_token.go`; `access_policy_revision` semantics (library_ids changes do NOT bump it — `internal/auth/repository.go` has an explicit comment); jellycompat and API keys pass `SkipPINVerification: true`.
|
||||
- **Narrowing-only overrides**, enforced in vendor Rego by tightening-direction merges (intersect libraries, min ceilings, AND booleans) — never by heuristic post-checks.
|
||||
- **Sandbox invariants:** custom modules compile under stripped `ast.Capabilities` (no `http.send`, `net.*`, `opa.runtime`) at save, activate, AND reload; per-eval `context.WithTimeout` (default 25ms, setting `policy.eval_timeout_ms`); package path must be `silo_custom.<domain>`.
|
||||
- **Migrations:** timestamped Goose files via `make migrate-create NAME=...`; never touch legacy numeric migrations.
|
||||
- **One concern per PR**; Conventional Commit subjects; each PR links the OPA epic (`Part of #NNN` — create the epic issue before Phase 1 lands).
|
||||
- Worktree builds need `GOWORK=off` and a stubbed `web/dist` (see worktree-build-quirks memory). DB-backed Go tests use the `SILO_TEST_DATABASE_URL` skip pattern.
|
||||
- Before opening each PR: `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`, `make lint`.
|
||||
|
||||
---
|
||||
|
||||
## Verified Baseline (confirmed against the tree — work from this, not intuition)
|
||||
|
||||
1. **OPA is greenfield**: `go.mod` has no `open-policy-agent` dependency; no `.rego` files exist anywhere in the repo. `web/package.json` has no code-editor dependency.
|
||||
2. **Five `access.NewResolver` construction sites** (all must cut over in Phase 7, not just the router):
|
||||
- `internal/api/router.go:342` — viewer middleware resolver.
|
||||
- `cmd/silo/main.go:1384` — notifications scopes.
|
||||
- `cmd/silo/main.go:1813` — reconciler resolver.
|
||||
- `cmd/silo/main.go:2271` — jellycompat `AccessFilterFn` via `jellycompat.NewScopeAccessFilter`.
|
||||
- `internal/audiobooks/access_resolver.go:24` — ABS access resolver (constructed with `tokens = nil`; PIN verification intentionally skipped there).
|
||||
3. **`middleware.ViewerResolver`** (`internal/api/middleware/viewer_access.go:14`) is a 1-method interface: `Resolve(ctx, access.ResolveInput) (access.Scope, error)`. The middleware maps `ErrProfileUnverified`→403, `ErrProfileNotFound`→404, anything else→500. Fail-closed already.
|
||||
4. **Scope semantics:** `AllowedLibraryIDs == nil` means unrestricted; empty slice means "nothing". `DisabledLibraryIDs` only set when `AllowedLibraryIDs` is nil (`internal/access/resolver.go:95-106`). Profile PIN token must match `UserID`, `SessionID`, `ProfileID`, AND `PolicyRevision` (`resolver.go:88`).
|
||||
5. **`RequireAdmin` is dead in production routing** — only `RequireActingAdmin` (`internal/api/middleware/auth.go:184`) is wired. Model one admin tier.
|
||||
6. **Permissions today:** exactly two assignable permissions, `marker_edit` + `metadata_curation` (`internal/auth/permissions.go:13-16`); `HasEffectivePermission` grants all to enabled admins. `RequireMetadataCurationForItem` (`internal/api/middleware/permissions.go:44`) additionally requires every library containing the item to be inside `users.library_ids`.
|
||||
7. **Downloads gating:** `internal/downloads/policy.go` — `ensureTranscodeAllowed(user, cfg)` (line 166), `DownloadQualityResolver.PresetsFor(user, cfg, artifactsAvailable)` (line 119), driven by `users.download_allowed` / `download_transcode_allowed` + `config.DownloadConfig`. Capability surface: `internal/api/handlers/downloads.go` `HandleCapability` (line 163).
|
||||
8. **Playback limits:** `playback.SessionLimitProvider = func(ctx, userID) (SessionLimits, error)` (`internal/playback/session.go:115`), installed via `SessionManager.SetLimitProvider` (line 141). Live admission counting is in-memory inside `SessionManager` — Go keeps the counting; policy decides given counts.
|
||||
9. **Log-table pattern to copy:** `opsPM := partman.NewManager(pool, "operational_logs", partman.Daily, 3)` (`cmd/silo/main.go:174`), `activityPM := partman.NewManager(pool, "activity_log", partman.Weekly, 2)` (`main.go:1629`); cleanup tasks registered via `taskMgr.Register(...)` (~`main.go:1710-1875`). Migration `migrations/sql/028_log_partitioning.sql` shows the PARTITION BY RANGE + DEFAULT partition idiom. `internal/opslog/repo.go` has the cursor-pagination shape to mirror.
|
||||
10. **Event bus:** `cache.ChannelAdmin = "silo:admin"`, `EventSettingsChanged = "settings_changed"` (`internal/cache/redis.go:24,49`). No `EventPolicyChanged` exists yet. `internal/nodeconfig/watcher.go` shows the subscribe + 60s poll-fallback idiom.
|
||||
11. **System lifecycle template:** `notifications.System` construction at `cmd/silo/main.go:1382-1409` (Start/Wait, wired into `api.Dependencies`).
|
||||
12. **Admin routing:** `/admin` route group with `requireActingAdmin` in `internal/api/router.go` (~line 2259+); `Dependencies` struct at `router.go:78-187`. Capability-endpoint precedent: `internal/api/handlers/downloads.go` `HandleCapability`.
|
||||
13. **Frontend:** admin nav registry `web/src/lib/adminNavigation.ts`; dedicated-page precedent (`/admin/logs`, `/admin/tasks`); query-hook shape `web/src/hooks/queries/admin/settings.ts`; query key factory `web/src/hooks/queries/keys.ts`; DTOs in `web/src/api/types.ts`; jsdom localStorage needs the per-file in-memory stub (see `web/src/api/client.test.ts`).
|
||||
14. **Settings:** flat `server_settings` via `catalog.SettingsStore`; per-key validation switch in `internal/api/handlers/admin.go` `HandleUpdateSetting` (~line 2075+); typed parsing in `internal/config/db_loader.go`; restart-required registry `internal/config/restart_keys.go` (policy keys must all hot-reload — add none there). Policy Rego is not a secret: no `SensitiveSettingKeys` entries.
|
||||
|
||||
## Design Decisions (summary — details in the spec)
|
||||
|
||||
- **D1** One package `internal/policy`; nothing else imports `opa/v1/*`. Typed PDP methods (`ResolveViewerScope`, `CheckPermission`, `CheckAction`) over three prepared queries (`data.silo.{scope,permission,action}.decision`).
|
||||
- **D2** Vendor Rego via `go:embed`, never in the DB. Admin documents: `policy_documents` + immutable `policy_document_versions` + `active_version_id` pointer + single-row `policy_generation` counter.
|
||||
- **D3** Override contract: vendor computes `base_decision`, then `decision := data.silo_custom.<domain>.override(base_decision, input) else base_decision`, merged tightening-only in vendor Rego. Custom package path `silo_custom.<domain>` enforced at compile-check.
|
||||
- **D4** `ScopeDecision` carries an explicit `unrestricted` bool; the adapter maps it to nil `AllowedLibraryIDs`. Combinatorial parity tests own this boundary.
|
||||
- **D5** Decision log: dedicated partitioned `policy_decisions` (daily), async buffered writer, digest-by-default verbosity, 1-in-50 scope sampling, denials/errors always logged, retention task.
|
||||
- **D6** New `cache.EventPolicyChanged` on `ChannelAdmin` + poll fallback on `policy_generation`.
|
||||
- **D7** Frontend: dedicated `/admin/policy` page; CodeMirror 6 with hand-rolled Rego StreamLanguage + `@codemirror/lint` diagnostics from `/validate`.
|
||||
- **D8** Direct replacement staged per surface; legacy Go logic retained (compiled + tested) for one release post-cutover, then deleted.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — `internal/policy` engine core + vendor scope Rego + parity suite (PR 1, dead code)
|
||||
|
||||
- [ ] Add dependency: `go get github.com/open-policy-agent/opa@latest` (v1 module path; confirm `opa/v1/rego`, `opa/v1/ast`, `opa/v1/tester` import cleanly). Note binary-size delta in the PR body (~+19 MB expected).
|
||||
- [ ] `internal/policy/input.go`: `ScopeInput`/`ScopeDecision` structs (JSON tags per spec, `schema_version`, request-context fields `request_time`/`device_id`/`client_ip`/`is_api_key`). Doc comments — these render in the editor reference panel later.
|
||||
- [ ] `internal/policy/errors.go`: `ErrPolicyEvalFailed`, `ErrUnknownDecision`, `ErrCompileFailed` (wraps structured `{Row, Col, Message}` list).
|
||||
- [ ] `internal/policy/vendor/lib/quality.rego` + `lib/ratings.rego`: port rank tables + `min`/`allowed`/`normalize` helpers from `internal/access/quality.go` and `rating.go`. Byte-for-byte table parity asserted by test (Task below).
|
||||
- [ ] `internal/policy/vendor/scope.rego` (`package silo.scope`, `import rego.v1`): reproduce `access.Resolver.Resolve` — `effectiveLibraries` intersection logic, disabled-library subtraction (both restricted/unrestricted branches), quality min-merge, rating passthrough, `profile_verified` passthrough, explicit `unrestricted` output. Include the `data.silo_custom.scope.override(base_decision, input)` extension hook with tightening-only merge (intersect `allowed_library_ids`, union `disabled_library_ids`, min ceilings, AND `profile_verified`).
|
||||
- [ ] `internal/policy/vendor/scope_test.rego`: one Rego test per branch of `Resolve` (no profile / account-restricted / profile-restricted / both-intersect / disabled-subtraction / unverified profile), plus override-merge tests proving a widening override has no effect.
|
||||
- [ ] `internal/policy/vendor.go`: `//go:embed vendor` FS + module loader.
|
||||
- [ ] `internal/policy/engine.go`: `Engine` with `queries map[DecisionName]rego.PreparedEvalQuery`, RWMutex atomic `swap`, `Evaluate(ctx, name, input, out) (Meta, error)` with `context.WithTimeout` (25ms default), fail-closed on err/empty result/decode failure. Vendor-only compile path for now (no DB).
|
||||
- [ ] `internal/policy/compile.go` (first half): `LockedCapabilities()` stripping `http.send`, `net.*`, `opa.runtime`, `rego.parse_module` from `ast.CapabilitiesForThisVersion()`. (Custom-doc compile-check lands Phase 2; capabilities are needed now for the sandbox tests.)
|
||||
- [ ] `internal/policy/pdp.go`: `PDP.ResolveViewerScope(ctx, ScopeInput) (ScopeDecision, Meta, error)` only (other methods come with their surfaces).
|
||||
- [ ] `internal/policy/vendor_rego_test.go`: run all `vendor/*_test.rego` via `opa/v1/tester` inside `go test` (no CLI dependency).
|
||||
- [ ] **Parity tests** `internal/policy/scope_parity_test.go`: table-driven fixtures run through BOTH `access.Resolver`-equivalent logic (drive the real resolver with stub repos, mirroring `internal/access` existing tests) and `PDP.ResolveViewerScope`, asserting identical `access.Scope`. Include the combinatorial battery: `account ∈ {nil, [], [1,2,3]} × profile ∈ {absent, unrestricted, [], [2,3,4]} × disabled ∈ {[], [2]} × verified ∈ {true,false}`.
|
||||
- [ ] Sandbox test: a module using `http.send` fails to compile under `LockedCapabilities()`; a `while`-style pathological comprehension trips the eval timeout and returns `ErrPolicyEvalFailed`.
|
||||
- [ ] Benchmark `internal/policy/engine_bench_test.go`: prepared-query scope eval incl. input marshaling; assert well under 200µs p99 locally; record the number in the PR body.
|
||||
- [ ] Verify: `GOWORK=off go build ./... && GOWORK=off go test ./internal/policy/...` green; `make lint` green. **No wiring into router/main — dead code by design.**
|
||||
|
||||
## Phase 2 — Data model + PolicyStore + compile-check pipeline (PR 2)
|
||||
|
||||
- [ ] `make migrate-create NAME=policy_foundation`: `policy_documents`, `policy_document_versions` (immutable, `UNIQUE(document_id, version_number)`, deferred FK for `active_version_id`), `policy_generation` (single-row, seeded), `policy_decisions` (PARTITION BY RANGE on `"timestamp"` + DEFAULT partition + indexes incl. partial `WHERE allowed = false`) — schema per spec. Goose Up/Down both present.
|
||||
- [ ] `internal/policy/store.go`: `PolicyStore` — CRUD for documents/versions, `ActiveSources(ctx) map[domain][]source`, `Activate(documentID, versionID)` bumping `policy_generation` in the same tx (`UPDATE ... SET generation = generation + 1 ... RETURNING`), `Generation(ctx)`, enable/disable, delete-guard (only when no active version).
|
||||
- [ ] `internal/policy/compile.go` (second half): `CompileCheck(ctx, domain, source) error` — parse with locked capabilities, enforce `package silo_custom.<domain>`, compile candidate layered over vendor + other active docs, 2s compile budget, structured errors.
|
||||
- [ ] Extend `Engine` load path: vendor (full caps) + enabled custom actives (locked caps); a custom doc failing compile at load is skipped with WARN, never fatal. `revision` = generation from store.
|
||||
- [ ] DB-backed tests (`SILO_TEST_DATABASE_URL` skip pattern): store CRUD, activation atomicity under concurrent activates (no lost generation bump), version immutability, delete-guard.
|
||||
- [ ] Verify: migration applies cleanly via `make migrate-up` on a scratch DB; `GOWORK=off go test ./internal/policy/...`.
|
||||
|
||||
## Phase 3 — System lifecycle, hot reload, cross-node invalidation (PR 3)
|
||||
|
||||
- [ ] `internal/cache/redis.go`: add `EventPolicyChanged = "policy_changed"` beside `EventSettingsChanged`.
|
||||
- [ ] `internal/policy/system.go`: `System` (mirrors `notifications.System`) — `NewSystem(pool, eventBus, settingsReader)`; `Start(ctx)` does initial `reloadFromStore` (vendor-only compile failure = fatal; custom failure = degraded WARN), subscribes to `ChannelAdmin` for `EventPolicyChanged`, runs 60s poll fallback comparing `policy_generation` to loaded generation (idiom: `internal/nodeconfig/watcher.go`); graceful stop.
|
||||
- [ ] Settings: `policy.eval_timeout_ms` (default 25) parsed in `internal/config/db_loader.go`, hot-applied via atomic value read by `Engine` — NOT in `restart_keys.go`.
|
||||
- [ ] Wire in `cmd/silo/main.go` (near notifications wiring ~1382): construct `policy.System`, `Start(appCtx)`, add `deps.PolicySystem` field to `api.Dependencies`, deferred stop in shutdown ordering. Skip construction in proxy/transcode standalone modes.
|
||||
- [ ] Cross-node test: two `System` instances over one test DB + EventBus double; activate on A, assert B converges via event AND (separately, with events suppressed) via poll fallback.
|
||||
- [ ] Verify: `GOWORK=off go build ./...`; integrated-mode boot smoke (`make dev-backend` locally) shows policy system start log; no behavior change anywhere (still nothing querying the PDP in request paths).
|
||||
|
||||
## Phase 4 — Decision logging (PR 4)
|
||||
|
||||
- [ ] `internal/policy/decisionlog.go`: `DecisionLogger` — non-blocking buffered channel + batch-insert flush goroutine (shape: `internal/activitylog` writer/consumer); drop-and-count metric on full buffer; fields per spec (`decision_name`, `policy_generation`, identity, `allowed`, `eval_time_ns`, `input_digest`, verbosity-gated `input_sample`/`result_sample`, `error`).
|
||||
- [ ] Sampling/verbosity settings (hot-reloaded, parsed in `db_loader.go`): `policy.decision_log_verbosity` (`digest`|`verbose`), `policy.decision_log_scope_sample_rate` (default 50 ⇒ 1-in-50), `policy.decision_log_retention_days` (default 14). Denials + eval errors bypass sampling always.
|
||||
- [ ] Hook `PDP` methods to emit entries post-decision (never on the eval critical path — enqueue only).
|
||||
- [ ] `internal/policy/decisionlog_repo.go`: cursor-paginated `List` (filters: decision_name, user_id, allowed, time range) mirroring `internal/opslog/repo.go`.
|
||||
- [ ] `cmd/silo/main.go`: `policyPM := partman.NewManager(pool, "policy_decisions", partman.Daily, 3)` + `EnsureFuturePartitions` (non-fatal), register `tasks.NewPolicyDecisionLogCleanupTask(...)` beside the other log cleanup tasks (`internal/taskmanager/tasks/`).
|
||||
- [ ] DB-backed tests: writer batch/flush/drop behavior, repo pagination + filters, partition creation, cleanup task drops expired partitions.
|
||||
- [ ] Verify: `GOWORK=off go test ./internal/policy/... ./internal/taskmanager/...`.
|
||||
|
||||
## Phase 5 — Admin HTTP API (PR 5)
|
||||
|
||||
- [ ] `internal/policy/simulate.go`: throwaway-bundle simulate (`domain`, optional candidate `source`, `decision_name`, raw `input`) → decision + `eval_time_ns` + optional `rego.Tracer` trace. Never touches the live engine; never writes a decision-log row.
|
||||
- [ ] `internal/api/handlers/policy.go`: thin handlers per the spec's endpoint table — vendor viewer, documents CRUD, versions (create = CompileCheck + persist, 422 with `{errors:[{row,col,message}]}`), activate (= rollback), enabled toggle, delete-guard, `/validate`, `/simulate`, `/decisions` list + detail. DTOs follow existing handler naming (`policyDocumentResponse`, ...).
|
||||
- [ ] Capability endpoint `GET /api/v1/policy/capability` (authenticated, non-admin — mirror `downloads.HandleCapability`): `{enabled, editor_available, decision_types, generation}`.
|
||||
- [ ] Mount in `internal/api/router.go`: `/policy/capability` in the authenticated group; `r.Route("/policy", ...)` inside the acting-admin `/admin` group.
|
||||
- [ ] Handler tests: validate/simulate happy + compile-error paths, activation flow publishes `EventPolicyChanged`, capability shape, non-admin gets 403 on admin routes.
|
||||
- [ ] Verify: `GOWORK=off go test ./internal/api/...`; manual smoke: create → validate → save version → simulate → activate → decision generation bumps.
|
||||
|
||||
## Phase 6 — Frontend `/admin/policy` page (PR 6)
|
||||
|
||||
- [ ] Deps: `pnpm add @uiw/react-codemirror @codemirror/lint @codemirror/language` in `web/`. Record bundle-size delta in the PR body.
|
||||
- [ ] `web/src/api/types.ts`: additive DTOs (`PolicyCapability`, `PolicyDocument`, `PolicyVersion`, `PolicyValidateResult`, `PolicySimulateResult`, `PolicyDecisionEntry`).
|
||||
- [ ] `web/src/hooks/queries/admin/policy.ts` + entries in `web/src/hooks/queries/keys.ts`: `usePolicyCapability`, `usePolicyDocuments`, `usePolicyVersions`, `useValidatePolicy`, `useCreatePolicyVersion`, `useActivatePolicyVersion`, `useSimulatePolicy`, `usePolicyDecisions` (mutations invalidate document/version keys).
|
||||
- [ ] `web/src/lib/regoLanguage.ts`: ~60-line `StreamLanguage` Rego mode (keywords `package import default if else not in every some as with contains`, `#` comments, strings/numbers).
|
||||
- [ ] Pages per spec layout under `web/src/pages/admin-policy/`: `AdminPolicyLayout` (sub-nav Documents | Vendor | Decision Log), `PolicyDocumentList`, `PolicyEditorPanel` (CodeMirror + Validate → lint diagnostics at row/col + Save version + separate Activate with confirm), `PolicyVendorViewer` (read-only), `PolicySimulatePanel` (JSON input seeded with per-decision example + result/trace + eval µs), `PolicyVersionHistory` (list, client-side diff vs active, rollback confirm), `PolicyDecisionLogTable` (filters + cursor pagination + row expand).
|
||||
- [ ] Register route in `web/src/App.tsx` under `/admin/*`; nav entry in `web/src/lib/adminNavigation.ts` (System group, ShieldCheck-style icon). Hide/disable via `usePolicyCapability` when the engine reports disabled.
|
||||
- [ ] Settings additions: policy log verbosity/sample-rate/retention + eval timeout fields in the existing admin settings area (small section, `useSettingsForm` pattern) + `HandleUpdateSetting` validation cases for the new keys.
|
||||
- [ ] Vitest: editor validate-flow (mock 422 diagnostics render), simulate hook, decision-log pagination (remember the jsdom localStorage stub).
|
||||
- [ ] Verify: `cd web && pnpm run lint && pnpm run format:check && pnpm test`; manual walkthrough — author a schedule-based narrowing override, validate, simulate, activate, watch it in the decision log, roll back. Screenshots in the PR (UI-change convention).
|
||||
|
||||
## Phase 7 — Cutover surface 1: viewer scope (PR 7)
|
||||
|
||||
- [ ] `internal/access/policy_resolver.go`: `PolicyResolver` implementing `middleware.ViewerResolver` — owns the same repo lookups `access.Resolver` does today (user row, profile via userstore, disabled-library setting), performs PIN/profile-token verification in Go (identical claims checks incl. `PolicyRevision`), builds `ScopeInput` (`profile_verified` as fact), calls `PDP.ResolveViewerScope`, maps `unrestricted` → nil, returns `access.Scope` with `PolicyRevision`/`ProfileID`/`UserID` filled Go-side. Error taxonomy preserved: `ErrProfileNotFound`, `ErrProfileUnverified`, wrapped internals.
|
||||
- [ ] Swap **all five** construction sites to the policy-backed resolver: `internal/api/router.go:342`, `cmd/silo/main.go:1384` (notifications), `main.go:1813` (reconciler), `main.go:2271` (jellycompat), `internal/audiobooks/access_resolver.go:24` (ABS — preserve its skip-PIN semantics via `tokens=nil` equivalent). Do NOT delete `access.Resolver`.
|
||||
- [ ] Re-point/extend existing tests: middleware viewer-access tests and jellycompat scope tests must pass unchanged against the new resolver (that is the point). Parity suite from Phase 1 remains the gate.
|
||||
- [ ] Verify: full `GOWORK=off go test ./...` (jellycompat `TestBeginWebOperation*` flakes are pre-existing); manual smoke on dev: restricted profile + PIN profile + disabled libraries behave identically across native web and a Jellyfin client; decision log shows sampled scope entries.
|
||||
|
||||
## Phase 8 — Cutover surface 2: permission gates (PR 8)
|
||||
|
||||
- [ ] `internal/policy/vendor/permission.rego` + tests: reproduce `HasEffectivePermission` (enabled + admin-grants-all + assigned list), acting-admin rule (`role==admin` AND (no declared profile OR declared profile primary)), `metadata_curation` item-scope rule (every target library ∈ user allowlist; empty allowlist = unrestricted). Override hook `silo_custom.permission.override`, AND-merge only.
|
||||
- [ ] `internal/policy/input.go` + `pdp.go`: `PermissionInput`/`PermissionDecision`, `CheckPermission`.
|
||||
- [ ] Parity tests against `auth.HasEffectivePermission`/`EffectivePermissions` and `actingAdminAllowed` fixtures.
|
||||
- [ ] `internal/api/middleware/policy_gates.go`: acting-admin middleware + `RequireMetadataCurationForItem` equivalent backed by `CheckPermission` — same signatures as the existing constructors so `router.go` call sites are one-line swaps. Go still resolves the declared-profile-primary fact (DB lookup) before eval. Eval failure → 500 (matches existing `actingAdminAllowed` error contract); clean deny → 403.
|
||||
- [ ] Swap wiring in `internal/api/router.go` (acting-admin group construction + permission middleware). Keep legacy middleware code compiled.
|
||||
- [ ] Verify: existing middleware tests green against the new gates; manual: non-primary-profile admin still blocked from `/admin`, curator without library access still 403 on out-of-scope items.
|
||||
|
||||
## Phase 9 — Cutover surface 3: download/playback actions (PR 9)
|
||||
|
||||
- [ ] `internal/policy/vendor/action.rego` + tests: download rules (`download_allowed`; transcode requires `download_transcode_allowed` + artifacts; quality/rating ceilings via lib helpers) and playback admission (`current_active_streams < max_streams` when limit > 0; transcode analog; 0 = unlimited). Override hook `silo_custom.action.override`, tightening-only.
|
||||
- [ ] `ActionInput`/`ActionDecision` + `PDP.CheckAction`; parity tests against `ensureTranscodeAllowed`, `PresetsFor`, and `SessionLimits` admission math.
|
||||
- [ ] Downloads integration: `internal/downloads` gains a required `PolicyGate` dependency used by `Capability()`/`Create()`/preset resolution; map deny → existing `ErrDownloadNotAllowed`-family sentinels so `HandleCapability`/`writeDownloadError` responses are unchanged.
|
||||
- [ ] Playback integration: reimplement the `SessionLimitProvider` closure (wired via `SetLimitProvider`) to consult `CheckAction` with Go-computed live counts; keep `SessionManager` counting untouched.
|
||||
- [ ] Verify: downloads handler/service tests + playback session tests green; manual: user without download rights sees unchanged capability response; stream-limit enforcement unchanged; a custom action override (e.g. "no downloads 22:00–07:00") works end-to-end via the editor.
|
||||
|
||||
## Phase 10 — Bake, cleanup, docs (PR 10, next release)
|
||||
|
||||
- [ ] After one release of bake with all three surfaces cut over: delete legacy logic — `internal/access/resolver.go` resolve internals (keep `types.go`, `errors.go`, `context.go`, `profile_token.go`), `rating.go`/`quality.go` rank tables IF no non-policy callers remain (check `catalog.applyAccessFilter`'s `AllowedRatingsUpTo` usage — if catalog still needs the Go tables for SQL filtering, keep them and drop only dead paths; the SQL filter surface is explicitly deferred), legacy permission middleware, legacy downloads inline checks. Parity tests convert to golden tests against vendor Rego alone.
|
||||
- [ ] Remove any temporarily duplicated fixtures; run `/simplify`-style pass on `internal/policy`.
|
||||
- [ ] `docs/architecture/policy-engine.md`: subsystem overview, input-document contracts (source-of-truth tables), override-authoring guide with schedule/device examples, operational notes (generation, invalidation, decision-log knobs).
|
||||
- [ ] Flag follow-up issues: repeated-timeout circuit breaker, persisted test suites, elevated (widening) override path, compile-to-SQL exploration, plugin gating.
|
||||
- [ ] Verify: `GOWORK=off go build ./... && GOWORK=off go test ./...`, `make lint`, `make verify-local-paths`.
|
||||
|
||||
---
|
||||
|
||||
## Test & verification summary
|
||||
|
||||
| Gate | Command |
|
||||
|---|---|
|
||||
| Unit + Rego tests | `GOWORK=off go test ./internal/policy/...` |
|
||||
| Parity (per surface, pre-cutover) | `GOWORK=off go test ./internal/policy/ -run 'Parity'` |
|
||||
| DB-backed | `SILO_TEST_DATABASE_URL=... go test ./internal/policy/...` |
|
||||
| Perf guardrail | `go test ./internal/policy/ -bench BenchmarkResolveViewerScope` (assert < 200µs incl. marshaling) |
|
||||
| Lint/format | `make lint`; `cd web && pnpm run lint && pnpm run format:check` |
|
||||
| Local-path hygiene | `make verify-local-paths` |
|
||||
|
||||
## Risks to watch during implementation
|
||||
|
||||
1. **nil-vs-empty `AllowedLibraryIDs`** — the `unrestricted` field mapping is the invariant; the combinatorial parity battery is the gate. Do not ship Phase 7 with any parity case skipped.
|
||||
2. **Five call sites, not one** — missing the audiobooks/reconciler/notifications resolvers leaves silently divergent authz paths (the exact failure mode CLAUDE.md's duplicate-logic rule warns about).
|
||||
3. **ABS resolver PIN semantics** — it constructs the resolver with a nil token validator today; the policy-backed replacement must preserve that (verified-by-construction), not accidentally start requiring profile tokens on ABS clients.
|
||||
4. **Decision-log volume** — scope decisions fire per request; keep the 1-in-50 default and digest verbosity, and confirm the drop-counter metric is visible before Phase 7 ships.
|
||||
5. **Vendor Rego drift vs Go tables** — until Phase 10 deletes the Go rank tables, the byte-parity test pinning `lib/{ratings,quality}.rego` to `internal/access/{rating,quality}.go` must stay green.
|
||||
@@ -0,0 +1,252 @@
|
||||
# OpenTelemetry Observability: Logs + Traces Adoption Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd unless a task explicitly says otherwise.
|
||||
|
||||
**Goal:** Adopt OpenTelemetry for **logs and traces** (metrics explicitly deferred), so Silo emits OTLP-exportable structured logs correlated with distributed traces, without discarding the existing stderr + `opslog` DB/admin-stream pipeline. Standardize all `slog` call sites so every log record can carry `trace_id`/`span_id` when a span is active.
|
||||
|
||||
**Architecture:** Add a single OTel SDK bootstrap module (`internal/telemetry`) that builds a shared `resource.Resource`, a `TracerProvider`, and a `LoggerProvider` from `OTEL_*` env config, with graceful shutdown wired into the existing signal path. Bridge logs by inserting an `otelslog` handler into the existing `slog.Handler` chain via **fan-out** (`slog.MultiHandler`, stdlib in Go 1.26) — the current stderr + `opslog` sinks are untouched; OTel receives the same stream. Standardize the ~1,200 `slog.*` call sites to `slog.*Context(ctx, …)` and to explicit `component` attrs (retiring the `subsystem:` message-prefix convention as the classification signal, while keeping `opslog.InferComponent` as a backward-compatible fallback). Add tracing at the natural seams: chi HTTP middleware, `pgx` pool tracer, `go-redis` hook, outbound `http.Client` transports, host-side plugin gRPC stats handlers, and domain spans for scanner/playback/taskmanager. W3C `traceparent` propagation ties main-server → transcode-node and → plugin-subprocess traces together.
|
||||
|
||||
**Tech Stack:** Go 1.26, `log/slog`, chi/v5, pgx/v5 + pgxpool, go-redis/v9, gRPC, PostgreSQL. New deps: `go.opentelemetry.io/otel` (+ `sdk`, `sdk/trace`, `sdk/log`), OTLP exporters (`otlptracegrpc`/`otlptracehttp`, `otlploggrpc`/`otlploghttp`), `go.opentelemetry.io/contrib/bridges/otelslog`, `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`, `go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`, `github.com/redis/go-redis/extra/redisotel/v9`, and a pgx tracer (`github.com/exaring/otelpgx`).
|
||||
|
||||
**Non-goals (explicit):**
|
||||
- **Metrics stay on Prometheus — and must not break.** The existing `client_golang` instrumentation (`/metrics` on the dedicated `metricsMux` at `cmd/silo/main.go:2078`, `internal/api/middleware/metrics.go`, `internal/api/middleware/domain_metrics.go`) is **not** migrated to the OTel metrics SDK in this plan. Prometheus and OTel run on **separate, parallel rails**: Prometheus → `/metrics` → Grafana scrape (unchanged), OTel → OTLP → traces/logs backend. See the "Prometheus / Grafana coexistence" section below for the one footgun that must be guarded.
|
||||
- **Plugin-SDK-side instrumentation is out of repo.** Only host-side gRPC handler options live here; the plugin subprocess side is owned by `silo-plugin-sdk` and is a coordination follow-up.
|
||||
- **Log redaction now covers all sinks.** A shared `internal/logredact` handler masks secret-keyed attrs on console + OTLP (opslog DB path already redacted); `opslog.shouldRedact` delegates to `logredact.SecretKey` so the marker list is single-sourced. (Superseded the earlier "OTel output is raw" risk.)
|
||||
|
||||
---
|
||||
|
||||
## Validated Findings
|
||||
|
||||
Established by codebase research on the `feat/opentelemetry-plan` branch (based off `origin/main` @ `5172155a`).
|
||||
|
||||
### Logging
|
||||
- The `slog.Handler` chain is assembled in two stages. Base handler is `slog.NewJSONHandler`/`NewTextHandler(os.Stderr, …)` in `buildBaseHandler` (`cmd/silo/main.go:132-138`), gated by a shared `*slog.LevelVar` (`cmd/silo/main.go:540-544`).
|
||||
- `internal/logfilter/handler.go` wraps the base handler to drop records whose message has a configured `prefix:` (hot-reloadable `SetQuiet`). Installed as the first default logger at `cmd/silo/main.go:544-545`.
|
||||
- Once Postgres/settings exist, `configureOperationalLogging` (`cmd/silo/main.go:169-211`) re-wraps: `slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID))` (`cmd/silo/main.go:209`).
|
||||
- `opslog.Handler.Handle` (`internal/opslog/handler.go:33-93`) forwards to `inner` first, then (if level ≥ capture) flattens attrs, redacts secret-ish keys (`internal/opslog/handler.go:188-196`), and writes an `opslog.Entry` to a `Writer` (Redis list or in-memory chan → `Consumer` → partitioned `operational_logs` table → `logstream.Hub` admin stream).
|
||||
- **Component classification**: explicit `component` attr, else `inferComponent(message)` splitting on first `:` (`internal/opslog/handler.go:51-54,166-174`), default `"app"`. Correlation attrs already extracted if present: `request_id`, `session_id`, `playback_session_id`, `client_ip`/`remote_addr`, `node_id`, `user_id`.
|
||||
- **~1,211 `slog.(Info|Warn|Error|Debug)(` call sites** across `internal/`. Heaviest: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35). Dominant idiom is the bare package-level `slog.Info("subsystem: msg", "k", v)` (no context). A few subsystems bind `slog.Default().With("component", …)` on a struct field (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `internal/api/middleware/request_logger.go`).
|
||||
- **No `trace_id`/`span_id` concept exists** anywhere yet. No automatic context→attr bridging; each call passes correlation attrs manually.
|
||||
|
||||
### Instrumentation seams
|
||||
- Router is chi/v5. Main middleware stack (`internal/api/router.go:209-231`): `RequestID` (`router.go:213`) → `clientip.Middleware` → `apimw.RequestLogger` (`internal/api/middleware/request_logger.go:19`) → `Recoverer` → `apimw.Metrics` (`internal/api/middleware/metrics.go:35`) → `Compress`. `request_logger.go:53-70` already logs `request_id`/`user_id`/`session_id`/`playback_session_id`. `metrics.go:79-89` has `sanitizePath` (solves `http.route` cardinality).
|
||||
- jellycompat has its own chi router mounting `middleware.RequestID` (`internal/jellycompat/router.go:30`). `internal/transcodenode/server.go` is a standalone chi server in a separate process.
|
||||
- Postgres pool built at `internal/database/postgres.go:15` via `pgxpool.ParseConfig`; no `Tracer` set (pgx v5 `QueryTracer` seam).
|
||||
- Redis clients built at `internal/cache/redis.go:145-254` (two `redis.NewClient` sites); go-redis v9 `AddHook`/`redisotel` seam.
|
||||
- Outbound `http.Client` literals (not a shared factory): `internal/nodepool/health.go:22-26`, metadata providers (`internal/metadata/tmdb`, `.../trakt`, `.../translation`, `internal/mdblist`), subtitle providers (`internal/subtitles/opensubtitles`, `subdl`, `subsource`), plugin HTTP proxy (`internal/plugins.HTTPProxy`, wired `router.go:149`).
|
||||
- Host-side plugin gRPC server: `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` — but note this is inside `broker.AcceptAndServe(...)`, a hashicorp/go-plugin **brokered sub-stream** (reverse channel), not the primary host→plugin invocation channel. The load-bearing seam is the client dial (`grpc.NewClient`, pattern in `internal/pluginhost/client_test.go:18`). See Task 7.
|
||||
- Background entrypoints: scanner `NewScanner` (`internal/scanner/scanner.go:192`), scoped scan `applyScopedScan` (`internal/scanner/scanner.go:1287`); playback `NewSessionManager` (`internal/playback/session.go:152`); taskmanager `RunTask` (`internal/taskmanager/manager.go:231`).
|
||||
- `context.Context` is threaded pervasively (`Dependencies.AppContext` at `internal/api/router.go:88`; every repo/service method takes `ctx` first). Span context propagates cleanly for the **request/service call chains** — the work there is adding spans + switching call sites to the `…Context` variants. **Exception:** struct-bound loggers whose methods don't take a `ctx` (`taskmanager`, `notifications/*`, `ffmpeg_log_sink.go`) need signature plumbing or stay uncorrelated; see Task 3 Step 3.
|
||||
- Existing endpoints: `/metrics` on a dedicated `metricsMux` (`cmd/silo/main.go:2078`), `/health` + `/ready` (`internal/api/router.go:247,1436`). No pprof registered.
|
||||
|
||||
### Dependencies
|
||||
- `go 1.26.4` → `slog.MultiHandler` is available in the stdlib (no `samber/slog-multi` needed).
|
||||
- Already present: `github.com/prometheus/client_golang v1.23.2`; `go.opentelemetry.io/otel/sdk/metric v1.41.0` is present only as an **indirect** transitive dep (not wired). No `go.opentelemetry.io/otel` core, no trace SDK, no exporters, no otelslog/otelhttp/otelgrpc.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### New files
|
||||
- `internal/telemetry/telemetry.go` — SDK bootstrap: `Config` (parsed from env), `Setup(ctx, Config) (*Providers, shutdown func(context.Context) error, error)`, shared `resource.Resource`, `TracerProvider`, `LoggerProvider`, W3C propagator, batch processors.
|
||||
- `internal/telemetry/config.go` — `OTEL_*` + `SILO_OTEL_*` env parsing (enable gate, endpoint, protocol, sampler, service name/version).
|
||||
- `internal/telemetry/loghandler.go` — a best-effort wrapper around `otelslog.Handler` whose `Handle` never propagates an OTel export error (mirrors the `logsink` best-effort contract), plus the fan-out assembly helper.
|
||||
- `internal/telemetry/httpclient.go` — shared `otelhttp.NewTransport`-wrapped `http.RoundTripper` factory for outbound clients.
|
||||
- `internal/telemetry/telemetry_test.go`, `internal/telemetry/config_test.go`, `internal/telemetry/loghandler_test.go`.
|
||||
|
||||
### Modified files (by phase)
|
||||
- `go.mod` / `go.sum` — add OTel core/sdk/exporters + contrib instrumentation + pgx/redis tracers.
|
||||
- `cmd/silo/main.go` — call `telemetry.Setup`, insert otelslog fan-out into `buildBaseHandler`, defer `shutdown` in `run()`.
|
||||
- `internal/api/router.go` — `otelhttp` handler/middleware in the chi chain; inject `trace_id`/`span_id` into request-logger attrs.
|
||||
- `internal/api/middleware/request_logger.go` — add trace/span IDs to the emitted attrs.
|
||||
- `internal/jellycompat/router.go`, `internal/transcodenode/server.go` — `otelhttp` server instrumentation + propagation.
|
||||
- `internal/database/postgres.go` — `otelpgx` `QueryTracer` on the pool config.
|
||||
- `internal/cache/redis.go` — `redisotel.InstrumentTracing` on both client constructions.
|
||||
- Outbound-client packages listed above — accept/use the shared traced transport.
|
||||
- `internal/pluginhost/host.go` (+ client dial sites) — `otelgrpc` server/client stats handlers.
|
||||
- `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go` — domain spans.
|
||||
- **All packages under `internal/`** — call-site sweep (`slog.* → slog.*Context`, `component` attr). Batched per-package in Phase 3.
|
||||
- `docs/architecture/observability.md` — operator docs (env vars, collector wiring, what is/isn't instrumented).
|
||||
|
||||
---
|
||||
|
||||
## Phasing overview
|
||||
|
||||
- **Phase 0** — Dependencies + `internal/telemetry` SDK bootstrap (no behavior change; disabled unless configured).
|
||||
- **Phase 1** — Logs bridge fan-out (logs export works end-to-end; call sites unchanged).
|
||||
- **Phase 2** — Tracing providers + propagation plumbing (spans exist, exported).
|
||||
- **Phase 3** — Full call-site sweep to `…Context` + `component` attrs (unlocks `trace_id` in logs).
|
||||
- **Phase 4** — HTTP server + client tracing seams.
|
||||
- **Phase 5** — pgx + Redis tracing seams.
|
||||
- **Phase 6** — Plugin gRPC host-side propagation.
|
||||
- **Phase 7** — Domain spans (scanner / playback / taskmanager).
|
||||
- **Phase 8** — Shutdown wiring, docs, verification hardening.
|
||||
|
||||
Each phase is independently shippable and leaves `main` green. Phases 1–2 are prerequisites for Phase 3's payoff; Phases 4–7 can land in any order after Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Prometheus / Grafana coexistence
|
||||
|
||||
**Question this answers:** does adopting OTel break, replace, or entangle the existing Prometheus + Grafana metrics? **Answer: no — they stay separate rails, provided one footgun is guarded. Metrics stay on Prometheus permanently for this plan; migrating them to OTel is not in scope.**
|
||||
|
||||
- **Separate lines, not merged (this plan).** OTel `telemetry.Setup` installs **only** a `TracerProvider` and a `LoggerProvider`. It **must not** call `otel.SetMeterProvider` (Task 1 Step 5 builds no MeterProvider). Prometheus keeps its own independent rail: `client_golang` registry → `/metrics` (`cmd/silo/main.go:2078`) → Grafana scrape. Nothing about the metrics path changes. Grafana metric dashboards are untouched; traces become a *new, additive* Grafana/Tempo capability, not a change to existing panels.
|
||||
- **THE FOOTGUN — global MeterProvider (guard required).** The OTel instrumentation libraries used for traces (`otelhttp`, `otelgrpc`, `otelpgx`, `redisotel`) **also emit metrics by default**, through `otel.GetMeterProvider()`. Because this plan never sets a MeterProvider, that global stays the built-in **no-op** — so those metric calls are silently discarded: no double-counting into Prometheus, no second `/metrics` source, no crash, no measurable overhead. This is safe *by omission*, so make it **explicit and tested**, not incidental:
|
||||
- In each instrumentation call, prefer passing an explicit no-op meter provider option where the library supports it (e.g. `otelhttp.WithMeterProvider(noop.NewMeterProvider())`) so a future accidental `SetMeterProvider` can't silently start double-emitting HTTP/DB metrics.
|
||||
- Add a guard test asserting `otel.GetMeterProvider()` is the no-op after `telemetry.Setup` (i.e. scope B did not wire metrics).
|
||||
- HTTP request metrics continue to come **solely** from the existing `apimw.Metrics` Prometheus middleware (`internal/api/middleware/metrics.go:35`); `otelhttp` contributes **traces only**.
|
||||
- **Metrics remain fully on Prometheus.** Migrating metrics to the OTel metrics SDK is explicitly out of scope and not planned here — the current `client_golang` instrumentation is retained as-is.
|
||||
|
||||
## Blast radius & complexity
|
||||
|
||||
Grounded in actual counts (`grep` over `internal/` + `cmd/`): **173 files** contain `slog.*` calls totalling **1,339 call sites** (39 already on the `…Context` variant → **~1,300 to convert**). Total change spans **~185–190 files** and **~2,500–3,200 diff lines**.
|
||||
|
||||
Complexity is **concentrated, not spread** — line count is a poor proxy for risk here:
|
||||
|
||||
| Phase group | Files | ~LOC | Nature | Risk |
|
||||
|---|---|---|---|---|
|
||||
| **Phase 0–2** — telemetry bootstrap, logs fan-out bridge, tracing providers + propagation | ~10–12 (mostly new `internal/telemetry/*` + `cmd/silo/main.go`) | ~900–1,100 net-new | Genuinely new code | **High** — the level-gated fan-out, provider lifecycle, shutdown flush |
|
||||
| **Phase 3** — call-site sweep | 173 | ~1,300–1,700 modified | Mechanical, codemod-assisted, per-package-green | **Low per file** — a bad conversion fails the build, not runtime; the burden is review volume, not engineering |
|
||||
| **Phase 4–7** — HTTP/pgx/redis/gRPC/client + domain spans | ~12–15 | ~350–500 net-new | Small, localized seams | **Medium** — ~4 seams are subtly easy to get wrong (gRPC broker-vs-real channel, per-session playback span, chi route timing, struct-bound loggers) |
|
||||
| **Phase 8** — shutdown wiring, docs | ~2 | ~170 | Wiring + prose | Low |
|
||||
|
||||
**Takeaway:** ~90% of the *file count* (the 173-file sweep) is the *safe* part; ~90% of the *risk* lives in the few hundred lines of `internal/telemetry` plus ~4 seams. The scary number (1,300 sites) is mechanical churn.
|
||||
|
||||
### Commit strategy
|
||||
|
||||
- **Commit 1 — Phases 0–2** (the observability capability itself): telemetry bootstrap + level-gated logs fan-out + tracing providers/propagation. Small, high-value, reviewable as a unit; delivers OTLP log/trace export end-to-end without touching call sites.
|
||||
- **Commit 2 — Phase 3** (the call-site sweep, in isolation): the ~1,300-site `…Context` + `component` conversion across 173 files, as its own commit so the large mechanical diff never obscures the load-bearing logic in Commit 1. (May itself be split per-package internally, but lands as a distinct changeset from Commit 1.)
|
||||
- **Later commits — Phases 4–8**: seam tracing + domain spans + shutdown/docs, each landing independently after the foundation is in.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add dependencies and the telemetry SDK bootstrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`, `go.sum`
|
||||
- Create: `internal/telemetry/config.go`, `internal/telemetry/config_test.go`, `internal/telemetry/telemetry.go`, `internal/telemetry/telemetry_test.go`
|
||||
|
||||
- [ ] **Step 1: Add dependencies.** `go get go.opentelemetry.io/otel go.opentelemetry.io/otel/sdk go.opentelemetry.io/otel/sdk/trace go.opentelemetry.io/otel/sdk/log go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp go.opentelemetry.io/contrib/bridges/otelslog`. Run `go mod tidy`.
|
||||
- [ ] **Step 2: Write failing config tests** (`config_test.go`) covering: disabled by default (no endpoint, no `SILO_OTEL_ENABLED`); enabled when `OTEL_EXPORTER_OTLP_ENDPOINT` set; `service.name` from `OTEL_SERVICE_NAME` overriding a default of `silo-server`; protocol selection (`grpc` default / `http/protobuf`); sampler default (`parentbased_always_on`) and ratio override; invalid numeric sampler arg falls back to default (not a crash).
|
||||
- [ ] **Step 3: Implement `config.go`.** Parse `OTEL_*` (respect the OTel env spec) plus a `SILO_OTEL_ENABLED` convenience gate. Default `service.name=silo-server`, `service.version` from build info, `node.id` from the existing `SILO_NODE_NAME`/nodeID signal. Return a fully-defaulted `Config` with an `Enabled bool`.
|
||||
- [ ] **Step 4: Write failing telemetry test** (`telemetry_test.go`): `Setup` with `Enabled=false` returns a no-op shutdown and installs nothing; `Setup` with a `console`/`none` exporter builds providers and a shutdown that returns nil; shutdown is idempotent.
|
||||
- [ ] **Step 5: Implement `telemetry.go`.** Follow the canonical `setupOTelSDK` shape: build one shared `resource.Resource` (`resource.WithAttributes(semconv.ServiceName(cfg.ServiceName))`, `WithFromEnv`, `WithProcess`, `WithHost`), a composite W3C `TraceContext{}+Baggage{}` propagator, a `TracerProvider` (`trace.WithBatcher`), and a `LoggerProvider` (`log.WithProcessor(log.NewBatchProcessor(...))`). Register `otel.SetTracerProvider`, `otel.SetTextMapPropagator`, `global.SetLoggerProvider`. **Do NOT build or register a MeterProvider** — metrics stay on Prometheus (see "Prometheus / Grafana coexistence"); leaving `otel.GetMeterProvider()` as the built-in no-op is what keeps the trace instrumentation libraries from double-emitting metrics. Accumulate `shutdownFuncs` and join their errors. When `Enabled=false`, return early with a no-op shutdown so the entire feature is dormant unless configured.
|
||||
- [ ] **Step 6: Verify.** `go build ./...`, `go vet ./internal/telemetry/`, `go test ./internal/telemetry/ -race`, `gofmt -l` clean.
|
||||
|
||||
## Task 2: Logs bridge — fan-out otelslog into the handler chain
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/telemetry/loghandler.go`, `internal/telemetry/loghandler_test.go`
|
||||
- Modify: `cmd/silo/main.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (`loghandler_test.go`): a fan-out handler forwards a record to both a capturing stderr-substitute handler and an otelslog handler backed by an in-memory `LoggerProvider`; an OTel-branch `Handle` error is swallowed and does **not** prevent the console branch from receiving the record (mirrors the `logsink` best-effort contract that keeps `opslog`'s DB path alive — see `internal/opslog/handler.go` "inner error skips capture" behavior).
|
||||
- [ ] **Step 2: Implement `loghandler.go`.** Provide `NewOTelHandler(lp) slog.Handler` = `otelslog.NewHandler("silo-server", otelslog.WithLoggerProvider(lp))` wrapped so its `Handle`/`WithAttrs`/`WithGroup` never propagate export errors. Provide `FanOut(console, otel slog.Handler) slog.Handler` returning `slog.NewMultiHandler(console, otelBestEffort)` (stdlib, Go 1.26).
|
||||
- [ ] **Step 2a (REQUIRED — level gating): gate the OTel branch by the shared `LevelVar`.** `slog.MultiHandler.Enabled` returns true if **any** child is enabled. The stderr child is gated by the shared `*slog.LevelVar` (`cmd/silo/main.go:542-543`), but otelslog's `Enabled` follows the (unfiltered-by-default) `LoggerProvider`. Left unfixed, the logger would evaluate `Enabled==true` for `Debug` at `LogLevel=info` — constructing + dispatching Debug records it previously skipped across ~1,200 sites (per-call allocation, violates "Performance first") **and** exporting Debug to OTLP while stderr stays silent (a confusing, silent divergence). Wrap `NewOTelHandler` in a level-gate bound to the same `logLevelVar` (or an equivalent `otelslog` level-bridge option) so console and OTLP share exactly one verbosity knob. Add a test asserting a Debug record is dropped by both branches when the shared level is `info`.
|
||||
- [ ] **Step 3: Wire into `buildBaseHandler`** (defined `cmd/silo/main.go:132-138`, **called at `cmd/silo/main.go:543`** inside `run()` after the pool is up). When telemetry is enabled, return `FanOut(stderrHandler, levelGated(NewOTelHandler(lp)))` instead of the bare stderr handler; otherwise return today's stderr handler unchanged. The `logfilter` (`:544`) → `opslog` (`:209`, installed `:567`) wrapping above stays exactly as-is, so quiet-filtering and DB capture are preserved and OTel mirrors the console stream (document this mirror behavior, matching the prior file-sink decision). Ordering is safe: `telemetry.Setup` depends only on `OTEL_*` env (not the DB), so call it earlier in `run()` and thread the `LoggerProvider` into `buildBaseHandler` — the provider is available well before `:543`.
|
||||
- [ ] **Step 3a (coverage caveat): early-boot logs are not exported.** Many `slog` calls fire before the fan-out is installed at `:543` — DB connect (`:389`), migrations (`:423`,`:441`), secret backfill (`:287-305`), auto-tuning (`:223-272`), and everything inside `database.NewPool`. These reach only Go's default stderr handler today (not even opslog), so OTel not capturing them is **not** a regression — but it means "logs export end-to-end" excludes boot. Either document this gap in the observability doc, or (if boot logs matter to operators) move `telemetry.Setup` + a minimal OTel-only tee to the very top of `run()`.
|
||||
- [ ] **Step 4: Verify** logs actually export. Bring up a local collector (`otel/opentelemetry-collector` with a `debug`/`logging` exporter, documented in Task 10) or use the `console` log exporter; run the server, confirm `slog.Info` records appear on both stderr and the OTLP/console sink. Run `go build ./...`, `go test ./cmd/silo/ ./internal/telemetry/`.
|
||||
|
||||
## Task 3: Full call-site sweep — `…Context` variants + `component` attrs
|
||||
|
||||
This is the largest task. Batch per-package; each batch is its own commit and must keep the build green. The goal state: every `slog.Info/Warn/Error/Debug` becomes the `…Context(ctx, …)` variant wherever a `ctx` is in scope, and the `"subsystem: message"` prefix convention is replaced by an explicit `slog.String("component", "<subsystem>")` attr. `opslog.InferComponent` stays as the fallback for any residual prefix-only calls (backward compatible — do **not** remove it).
|
||||
|
||||
- [ ] **Step 1: Establish the pattern + guardrail.** In a heavily-used package (start with `internal/scanner`), convert calls to `slog.InfoContext(ctx, "msg", slog.String("component", "scanner"), …)`. Confirm `opslog` still classifies correctly (component attr wins over inference — `internal/opslog/handler.go:51-54`). Prototype the `sloglint` rule locally to sanity-check the target form, but do **not** enable it repo-wide yet — it is turned on at `error` severity only as the closing step of the sweep (Task 9), since enabling it before all ~1,300 sites convert turns CI red.
|
||||
- [ ] **Step 2: Codemod the mechanical conversions.** Use an AST-based rewrite (`gofmt -r` is insufficient for adding a `ctx` arg) — a small `golang.org/x/tools/go/analysis` or `astutil` script that, for each `slog.Info(...)` call inside a function with a `context.Context` in scope, rewrites to `slog.InfoContext(ctx, ...)`. Calls with no reachable `ctx` are left on the non-context variant and flagged for manual review (background init, `main`, top-level goroutines). Review every rewrite — do not blind-commit the codemod.
|
||||
- [ ] **Step 3: Convert per-package, hottest first**, one commit each, `go build`/`go test` between: `internal/api/handlers/libraries.go` (83), `internal/metadata/service.go` (70), `internal/api/handlers/playback.go` (60), `internal/metadata/worker.go` (45), `internal/recommendations/worker.go` (43), `internal/scanner/scanner.go` (40), `internal/adminjob/runner.go` (37), `internal/api/handlers/plugins.go` (35), then the long tail. For struct-bound `.With("component", …)` loggers (`internal/notifications/*`, `internal/playback/ffmpeg_log_sink.go`, `request_logger.go`, `taskmanager.New(logger *slog.Logger)` at `internal/taskmanager/manager.go:25`), keep the bound `component` and migrate emit calls to the context variants. **Caveat (corrects the "no structural re-plumbing" claim in Validated Findings):** many such structs log from methods that do **not** currently take a `ctx`. For those, either thread a `ctx` parameter through the method signatures (structural change — budget for it) or, where a request/operation ctx genuinely isn't reachable, leave the call on the non-context variant (still captured via fan-out, just without `trace_id`). Span-context propagation is free *only where a `ctx` already flows*; the struct-bound method surfaces are the exception, not the rule.
|
||||
- [ ] **Step 4: Normalize component names.** Produce a canonical component list (jellycompat, playback, api, scanner, metadata, notifications, catalog, auth, recommendations, adminjob, taskmanager, …) and apply it consistently so trace/log/DB classification agree. Document the list in the package doc.
|
||||
- [ ] **Step 5: Verify.** `go build ./...`, `go vet ./...`, full `go test ./...`, `gofmt -l` clean, the new sloglint guard passes repo-wide. Spot-check that `opslog` entries still populate `component` after the sweep.
|
||||
|
||||
## Task 4: Tracing — HTTP server middleware + request-logger correlation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/api/router.go`, `internal/api/middleware/request_logger.go`, `internal/jellycompat/router.go`, `internal/transcodenode/server.go`
|
||||
|
||||
- [ ] **Step 1: Add deps.** `go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`; `go mod tidy`.
|
||||
- [ ] **Step 2: Instrument the main router.** Insert `otelhttp` server handling into the chi chain right after `middleware.RequestID` (`internal/api/router.go:213`) and before `apimw.RequestLogger`, so a server span exists for the whole handler and `request_logger` can read it. **Route-tag timing:** chi resolves the route *after* the middleware stack runs, so `http.route` cannot be set at middleware entry — set it post-routing from `chi.RouteContext(r).RoutePattern()`, and commit to `sanitizePath` (`metrics.go:79-89`) on the raw path as the fallback when no pattern is available. Exclude `/health`/`/ready` like the logger already does (`request_logger.go:22`). Note `activitylog.NewMiddleware` (`internal/api/router.go:230`) is also request-scoped and may want the span/`trace_id` — evaluate wiring it in the same pass.
|
||||
- [ ] **Step 3: Correlate logs.** In `request_logger.go:53-70`, pull the active span from `r.Context()` and add `slog.String("trace_id", …)`/`slog.String("span_id", …)` to the emitted attrs. (Once Task 3 lands and handlers log with `ctx`, the otelslog bridge attaches these automatically to every downstream record too — this step guarantees the access-log line itself carries them.)
|
||||
- [ ] **Step 4: Instrument jellycompat + transcode-node servers** the same way (`internal/jellycompat/router.go:30`, `internal/transcodenode/server.go`), ensuring incoming W3C `traceparent` headers continue the trace across processes.
|
||||
- [ ] **Step 5: Verify.** Drive a request through a local collector; confirm one server span per request with `http.route`, `trace_id` present in the access log, and a continued trace when hitting a transcode-node endpoint.
|
||||
|
||||
## Task 5: Tracing — outbound HTTP clients (shared traced transport)
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/telemetry/httpclient.go`
|
||||
- Modify: `internal/nodepool/health.go`, metadata/subtitle provider packages, `internal/plugins` HTTP proxy
|
||||
|
||||
- [ ] **Step 1: Shared factory.** Implement `telemetry.NewHTTPTransport(base http.RoundTripper) http.RoundTripper` = `otelhttp.NewTransport(base)` with sane span-name/route options. This satisfies CLAUDE.md's "avoid duplicate logic" — one wrapper, injected everywhere, not per-package patches.
|
||||
- [ ] **Step 2: Inject** the traced transport into the ad-hoc `http.Client` literals: `internal/nodepool/health.go:22-26` and other nodepool clients, `internal/metadata/tmdb`, `internal/metadata/trakt`, `internal/metadata/translation`, `internal/mdblist`, `internal/subtitles/{opensubtitles,subdl,subsource}`, and `internal/plugins.HTTPProxy` (`router.go:149`). Prefer constructing the transport once in `cmd/silo/main.go` and passing it down over editing each package's client construction independently where a seam exists.
|
||||
- [ ] **Step 3: Verify.** A play/scan flow that calls a metadata provider and a transcode node shows child client spans nested under the originating server span, with propagated `traceparent`.
|
||||
|
||||
## Task 6: Tracing — pgx pool + Redis
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/database/postgres.go`, `internal/cache/redis.go`, `go.mod`
|
||||
|
||||
- [ ] **Step 1: pgx tracer.** `go get github.com/exaring/otelpgx`; set `poolCfg.ConnConfig.Tracer = otelpgx.NewTracer(...)` on the config returned by `pgxpool.ParseConfig` (`internal/database/postgres.go:14`), guarded on telemetry enabled. Zero call-site changes — instruments every query. Use `otelpgx` options to avoid logging full SQL args if that is a concern.
|
||||
- [ ] **Step 2: Redis hook.** `go get github.com/redis/go-redis/extra/redisotel/v9`; call `redisotel.InstrumentTracing(client)` after **both** `redis.NewClient` constructions (`internal/cache/redis.go:145-254`).
|
||||
- [ ] **Step 3: Verify.** A request that hits Postgres and Redis shows DB and cache child spans under the server span. `go build ./...`, `go test ./internal/database/ ./internal/cache/`.
|
||||
|
||||
## Task 7: Tracing — host-side plugin gRPC propagation
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/pluginhost/host.go`, plugin gRPC client dial sites, `go.mod`
|
||||
|
||||
- [ ] **Step 1: Add dep.** `go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc`.
|
||||
- [ ] **Step 2: Find the real invocation channel FIRST.** `grpc.NewServer(opts...)` at `internal/pluginhost/host.go:336` sits inside `broker.AcceptAndServe(...)` — a hashicorp/go-plugin **brokered sub-stream** (host services exposed back to the plugin), *not* the primary host→plugin RPC channel (go-plugin stands that up internally; silo does not construct it at this line). Instrumenting only `:336` would capture the reverse/broker channel and **miss primary plugin invocations**. Identify where the primary plugin client connection is created/dialed (the load-bearing path) before instrumenting.
|
||||
- [ ] **Step 3: Client side (load-bearing).** Add `grpc.WithStatsHandler(otelgrpc.NewClientHandler())` to the host→plugin dial that carries actual invocations (dial pattern per `internal/pluginhost/client_test.go:18`). This is the half that must cover the real call path.
|
||||
- [ ] **Step 4: Server side.** Add `grpc.StatsHandler(otelgrpc.NewServerHandler())` to `grpc.NewServer(...)` at `:336` for the brokered channel — useful but secondary.
|
||||
- [ ] **Step 5: Note the boundary.** The plugin-subprocess side that receives `traceparent` is owned by `silo-plugin-sdk` — record a coordination follow-up; do **not** attempt SDK-side changes here.
|
||||
- [ ] **Step 6: Verify against a real plugin call.** Confirm an actual plugin invocation (not just a broker sub-stream) produces a gRPC client span; full end-to-end continuation into the plugin process is deferred to the SDK follow-up.
|
||||
|
||||
## Task 8: Domain spans — scanner, playback, taskmanager
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/scanner/scanner.go`, `internal/playback/session.go`, `internal/taskmanager/manager.go`
|
||||
|
||||
- [ ] **Step 1: Scanner.** Start a span per scan run in `applyScopedScan` (`internal/scanner/scanner.go:1287`) and, if not too chatty, a child per file. Attach library/scope attrs.
|
||||
- [ ] **Step 2: Playback.** Do **not** span `NewSessionManager` (`internal/playback/session.go:152`) — that is the singleton manager constructor, called once at startup; a span there covers the whole process lifetime and cannot carry per-session attrs. Instead attach the span at the actual per-session create/start path (where a `*Session` is added to the `sessions` map), spanning start→stop with `play_method`/session-id attrs, so it links to the transcode-node client spans (Task 5) for end-to-end session traces.
|
||||
- [ ] **Step 3: Taskmanager.** Wrap `RunTask` (`internal/taskmanager/manager.go:231`) in a per-run span; these background goroutines start new root traces (no incoming context), so create a fresh root span with baggage identifying the task.
|
||||
- [ ] **Step 4: Verify.** A scan and a playback session each produce a coherent trace tree; background task runs appear as their own traces.
|
||||
|
||||
## Task 9: Durable enforcement — keep future code (human + AI) on the standard
|
||||
|
||||
The sweep is worthless if new code drifts back to context-less `slog.Info(...)`. Lock the standard in machine-enforced form so it holds without relying on reviewer vigilance. **Sequencing: lands together with the Phase-3 sweep in the same commit, after the legacy sites are converted — enabling it earlier turns CI red repo-wide.**
|
||||
|
||||
**Files:**
|
||||
- Modify: `.golangci.yml`
|
||||
- Reference: `docs/architecture/observability.md` (from Task 10)
|
||||
|
||||
- [x] **Step 1: Machine gate (the real guarantee) — add `sloglint` to `.golangci.yml`.** Added to `linters.enable` with all four rules configured: `context: "scope"`, `static-msg: true`, `key-naming-case: snake`, `no-mixed-args: true`. Since `make lint` runs `golangci-lint run` (`Makefile:54`), this blocks any PR — human- or AI-authored — that regresses the call form. Verified via standalone `sloglint`: after the sweep all four rules report **0 violations** repo-wide (production and test), so no `_test.go` exclusion was needed. (`key-naming-case` and `no-mixed-args` were already clean pre-sweep; `static-msg` had 6 sites, fixed in the sweep.)
|
||||
- [x] **Step 2: Document the honest gap.** Recorded in `docs/architecture/observability.md`: `sloglint` enforces the *shape* (`…Context`, snake keys, static msg) but **cannot** enforce that a `component` attr is present. That convention rests on the observability doc, the canonical component list, and review.
|
||||
- **Note (CLAUDE.md subsection dropped):** per maintainer direction, no Logging subsection is added to `CLAUDE.md`. The post-sweep codebase already models the idiom everywhere and the `sloglint` gate enforces it, so the standard holds without a CLAUDE.md entry; `docs/architecture/observability.md` carries the human/LLM-facing rationale and canonical list.
|
||||
|
||||
## Task 10: Shutdown wiring, docs, and final verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/silo/main.go`
|
||||
- Create: `docs/architecture/observability.md`
|
||||
|
||||
- [ ] **Step 1: Graceful shutdown.** `defer shutdown(ctx)` from `telemetry.Setup` in `run()`, integrated with the existing signal handler, using a **generous** timeout so batch processors flush (buffered spans/logs are lost otherwise). Ensure it runs before process exit on every path.
|
||||
- [ ] **Step 2: Docs.** Write `docs/architecture/observability.md`: the `OTEL_*`/`SILO_OTEL_ENABLED` surface, default-off behavior, a local `docker compose` collector example (OTLP in → debug/Loki/Tempo out), the canonical component list, and the documented non-goals/risks (metrics stay Prometheus; logs unredacted at the OTLP sink; quiet-filter also suppresses OTel logs; plugin-SDK side is a follow-up).
|
||||
- [ ] **Step 3: Full verification.** `go build ./...`; `go vet ./...`; `go test ./... -race` (at least the touched packages under `-race`); `gofmt -l` clean; `cd web && pnpm run lint && pnpm run format:check` (no-op for this backend change but part of the MR gate); `make verify-local-paths`. Manual: default-off start (no OTel env) behaves exactly as today; enabled start exports logs+traces to a local collector; a single request produces correlated log `trace_id` + a server→DB→cache→client span tree. **Metrics coexistence:** with OTel enabled, confirm `/metrics` still serves the full existing Prometheus set (HTTP + domain metrics), Grafana scraping is unaffected, and the guard test confirms `otel.GetMeterProvider()` is the no-op (no OTel metrics rail wired).
|
||||
|
||||
---
|
||||
|
||||
## Risk / follow-ups
|
||||
|
||||
- **Level-gating is load-bearing (see Task 2 Step 2a).** `slog.MultiHandler.Enabled` is an OR across children; without gating the OTel branch by the shared `LevelVar`, Debug records get allocated + OTLP-exported at `level=info`. Treated as a required step, not optional.
|
||||
- **Early-boot logs (pre-`:543`) are not exported** — matches existing opslog behavior, documented not fixed (Task 2 Step 3a).
|
||||
- **Playback span must attach per-session, not at the manager constructor**, and **plugin gRPC instrumentation must target the primary invocation channel, not just the broker sub-stream** — both are easy to get wrong (Tasks 8/7).
|
||||
- **Call-site sweep is a large, review-heavy diff.** Mitigated by per-package batching, an AST codemod with mandatory human review, and a `sloglint` guardrail. Background/init calls with no `ctx` legitimately stay on the non-context variants.
|
||||
- **Quiet-filter also suppresses OTel logs** (the fan-out sits at the base, below the filter). This mirrors stderr exactly — intended, but if durable-capture-despite-quiet is wanted, the OTel branch must tee in above `logfilter`. Deferred, product call.
|
||||
- **Redaction is key-based** (handled via `internal/logredact` on all sinks): secrets under a recognized key are masked, but a secret in a free-text message or under an unrecognized key is not caught.
|
||||
- **Metrics remain on Prometheus** by explicit non-goal; dual observability backends until a future OTel-metrics plan.
|
||||
- **Plugin-subprocess trace continuation** needs a coordinated `silo-plugin-sdk` change; host-side only here.
|
||||
- **Sampler config in multi-node deploys** must use `parentbased_traceidratio` (not bare `traceidratio`) so transcode/proxy nodes respect the parent decision — documented in Task 10.
|
||||
- **Batch-processor flush on shutdown** is critical; verify no lost telemetry on redeploy/signal paths.
|
||||
|
||||
## Verification summary (gate before MR)
|
||||
|
||||
- `go build ./...`, `go vet ./...`, `go test ./... -race` (touched packages), `gofmt -l` clean.
|
||||
- `cd web && pnpm run lint && pnpm run format:check`; `make verify-local-paths`.
|
||||
- Manual: default-off parity; enabled logs+traces to a local collector; correlated `trace_id` in logs; end-to-end span tree across HTTP → pgx → redis → outbound client → gRPC.
|
||||
@@ -0,0 +1,324 @@
|
||||
# Section-fetch & jellycompat performance plan
|
||||
|
||||
Status: in progress. Commands assume the repository root is the cwd.
|
||||
|
||||
> **Log provenance / staleness check.** The production logs analyzed here were
|
||||
> captured against commit `b1b3a9b4` (2026-07-01). Current `origin/main` is
|
||||
> `3d82aa59` (2026-07-02), 15 commits ahead. Only two of those commits touch the
|
||||
> hot files, and both are unrelated (`3d8a63de` metadata-language upsert
|
||||
> precedence; `d6c4dce1` playback-session conflict error) — verified they do not
|
||||
> change `ListBySeries`, `loadProgressPage`, the next-up query, the browse
|
||||
> fast-path, or the detail-enrich path. So the diagnoses below still apply to
|
||||
> current `main`; nothing since the logs has fixed these.
|
||||
|
||||
## Revised implementation scope (2026-07-03, after code verification)
|
||||
|
||||
After reading the current code, several original fixes were re-scoped or
|
||||
deferred. This section is authoritative; the "Proposed fixes" section further
|
||||
down is the original analysis and is kept for context.
|
||||
|
||||
**Commit 1 (safe subset):**
|
||||
- **#2 Resume scan cap — IMPLEMENTED.** `loadProgressPage` now bounds its scan to
|
||||
`resumeScanMaxRows = 300` so `EnableTotalRecordCount=true` can't drive the
|
||||
O(history) scan (`internal/jellycompat/handlers_items.go`). Builds clean.
|
||||
Caveat: the specific logged 35.9s Resume call actually went through the capped
|
||||
`FetchOne` fast path (the deferred #1 cold recompute), so this removes a latent
|
||||
risk rather than that exact spike.
|
||||
- **#9 drop redundant enrich — RE-SCOPED (do not remove wholesale).**
|
||||
`enrichDetailUserData` (`content_direct.go:612-626`) is redundant only for
|
||||
**movie/episode leaf** items (all `batchListItemDetails` callers re-apply
|
||||
batched `progress[...]` which overrides it). For **series** it builds the
|
||||
episode-**rollup** `UserData` (Played/UnplayedItemCount) that no progress row
|
||||
covers — removing it would strip series watch-state from `/Items`/`/Items/Latest`
|
||||
(which can return Series). Correct fix: skip enrich for leaf types only, keep it
|
||||
for series (or batch the leaf-progress lookup so output is identical).
|
||||
- **#4 concurrency — RE-SCOPED (pool-constrained).** `pgxpool` MaxConns defaults
|
||||
to **20** (`internal/config/db_loader.go:162`); live usage already ~25 across the
|
||||
DB. Raising `fetchAllMaxConcurrency` 4→8 doubles per-request pool pressure, so
|
||||
~2–3 concurrent home requests would saturate the pool and could regress under
|
||||
load. Only bump conservatively (e.g. 4→6) or after a load test; not a blind
|
||||
constant change.
|
||||
|
||||
**Deferred to their own PRs (heavier than "light"):**
|
||||
- **#1 continue-watching latency.** There is no existing result cache to "warm";
|
||||
the Resume/CW path recomputes via `FetchOne` every call. The hourly `:12` spike
|
||||
lines up with the `collection sync scheduler` / hourly session-cleanup jobs, so
|
||||
the root cause may be periodic DB contention, not a cache miss. Needs its own
|
||||
investigation (contention vs. adding a short-TTL result cache with progress-write
|
||||
invalidation) — carries user-visible staleness risk.
|
||||
- **#3 `/Shows/{id}/Episodes`.** A `LIMIT` on `ListBySeries` truncates a series;
|
||||
the real fix is client-visible pagination requiring Android/Apple coordination.
|
||||
- **#5 dedup + SQL-side filtering.** The dedup needs cross-section memoization that
|
||||
`FetchAll` does not currently have; the SQL-filter half is a `userstore`
|
||||
interface refactor. Both are structural, not light.
|
||||
|
||||
**Dropped:**
|
||||
- **#6 content_id NextUp rewrite.** ~28ms off an already ~91ms warm query,
|
||||
invisible once cached, highest correctness risk (`::int`/lexicographic trap).
|
||||
Not worth it.
|
||||
|
||||
**Commit 2 (separate) — IMPLEMENTED:**
|
||||
- **#8 browse fast-path.** When a cross-library `recently_added` browse also has
|
||||
an `isPlayed` filter, the over-fetch loop no longer advances `filters.Offset`
|
||||
into `BrowsePage`'s whole-catalog GROUP BY on the 2nd chunk. Instead it pulls
|
||||
the entire over-fetch budget (`maxScannedRows`) in a single
|
||||
`BrowseRecentlyAddedAcrossLibraries` merged index walk
|
||||
(`internal/jellycompat/content_direct.go`), so `/Items/Latest` stays on the
|
||||
~1ms/library fast path. Expected ~0.8–1.6s → ~50–150ms for heavy watchers with
|
||||
2+ libraries.
|
||||
|
||||
## Problem (what operators/users saw)
|
||||
|
||||
Production `silo` logs (24h window) show recurring multi-second stalls on the
|
||||
home screen and continue-watching rails, on both native clients and Jellyfin
|
||||
compat clients.
|
||||
|
||||
- **Continue Watching is the worst offender.** The native `slow section fetch`
|
||||
warning fired 132× in 24h (126 of them `type=continue_watching`), with the
|
||||
aggregate home fetch (`slow aggregate section fetch`) firing 17× at
|
||||
`section_count=32`. Distribution of `slow section fetch`: p50 ≈ 1.1s,
|
||||
p90 ≈ 3.8s, worst 35.8s.
|
||||
- **A very regular hourly spike.** `section_id=compat-resume` reliably jumps to
|
||||
~3.5s clustered at **:12–:14 past every hour**, then returns to fast. This is
|
||||
the fingerprint of an ~1h cache TTL expiring followed by a cold recompute that
|
||||
a live request pays for.
|
||||
- **Jellycompat resume/next-up/episodes are slow under field expansion.**
|
||||
Excluding long-lived websocket/stream/transcode connections (expected to be
|
||||
long), the slowest query-backed endpoints were:
|
||||
- the Resume endpoints (`UserItems/Resume` + the per-user
|
||||
`Users/{id}/Items/Resume` form) — 275 calls >500ms, worst **35.9s** (a VidHub
|
||||
client requesting `Limit=20` with total-record-count).
|
||||
- `Shows/NextUp` — 399 >500ms, 139 >1000ms.
|
||||
- `Shows/{id}/Episodes` — 58 >500ms, worst **10.1s** (long series, all slow
|
||||
calls requested `MediaSources`/`MediaStreams` expansion).
|
||||
- the native home-sections aggregate (`api/v1/home` sections) — worst 5.4s.
|
||||
- `/api/v1/recommendations/taste-seed/items` — 55 >1000ms, worst 5.6s.
|
||||
|
||||
## What is NOT the problem
|
||||
|
||||
The base SQL is fast. `EXPLAIN ANALYZE` of the continue-watching base fetch for
|
||||
the single heaviest user on the box (`user_id=627`, 11,459 in-progress rows)
|
||||
returns in **~1ms** on the existing partial indexes
|
||||
(`idx_uwp_profile_in_progress`, `idx_uwp_profile_completed`). The multi-second
|
||||
cost is application-side: over-scanning, per-section serialization, cold-cache
|
||||
recompute, and one avoidable large join in the NextUp query.
|
||||
|
||||
## Evidence: the NextUp query cost breakdown
|
||||
|
||||
`EXPLAIN (ANALYZE, BUFFERS)` of the real `buildListNextUpQuery`
|
||||
(`internal/catalog/nextup_repo.go:112`) for `user_id=627` executed in **91ms
|
||||
warm**, and the cost is dominated by one thing:
|
||||
|
||||
```text
|
||||
completed_episodes CTE:
|
||||
Nested Loop (actual 60.8ms, 56,884 buffers)
|
||||
-> Index Scan idx_user_watch_progress_profile (14,512 rows, 847 buffers)
|
||||
-> Index Scan episodes_pkey (loops=14,512, 56,037 buffers) <-- dominant
|
||||
LATERAL next-episode lookup: fast (~9k buffers total across 137 series)
|
||||
Execution Time: 91.163 ms (warm; cold/disk-bound is the 3.5s production case)
|
||||
```
|
||||
|
||||
The `completed_episodes` CTE does
|
||||
`JOIN episodes e ON e.content_id = uwp.media_item_id` and probes
|
||||
`episodes_pkey` **once per completed-progress row (14,512×)** purely to read
|
||||
`series_id`, `season_number`, and `episode_number`. That single join is ~60ms of
|
||||
91ms warm and is the largest driver of the cold-cache blowup.
|
||||
|
||||
## The content_id insight (validated)
|
||||
|
||||
`content_id` is a **deterministic, structured natural key**, not a random DB id
|
||||
(`internal/contentid/contentid.go`, migration
|
||||
`migrations/sql/20260612130000_deterministic_content_id.sql`). An episode's id
|
||||
embeds its series anchor, season, and episode:
|
||||
|
||||
```text
|
||||
episode-tvdb-296762-1-5 -> series = series-tvdb-296762, season = 1, episode = 5
|
||||
```
|
||||
|
||||
This is a **documented, frozen, load-bearing invariant** (`contentid.go:29-32`:
|
||||
"the watch-history query relies on this to resolve a show without an episodes
|
||||
table lookup. Never break it."). It is exposed as `SeriesIDFromContentID`
|
||||
(`contentid.go:256`).
|
||||
|
||||
Measured coverage on this DB:
|
||||
- 2,110,532 of 2,111,778 episode rows (**99.94%**) are provider-anchored and
|
||||
derivable; the derived series id matches `series_id` for **100%** of them.
|
||||
- Only 1,246 rows / 134 series are legacy/`local-` ids with no embedded anchor.
|
||||
- Of in-progress `user_watch_progress` rows, 86k are `episode-` (derivable) and
|
||||
44k are movies/books/other (not episodes at all).
|
||||
|
||||
Crucially, **there is already a proven in-repo SQL pattern** for exactly this,
|
||||
used by the watch-history source to avoid the episodes join
|
||||
(`internal/catalog/history_source.go:218-282`):
|
||||
- `seriesFromAnchoredEpisodeExpr` — `'series-' || split_part(id,'-',2) || '-' || split_part(id,'-',3)`.
|
||||
- `anchoredEpisodePredicate` — requires 5 non-empty `-` components before
|
||||
treating an id as anchored.
|
||||
- Null-poisons the episodes join key for anchored ids
|
||||
(`CASE WHEN <anchored> THEN NULL ELSE media_item_id END`) so the planner skips
|
||||
the `episodes_pkey` probe, and only legacy/local ids `LEFT JOIN episodes`.
|
||||
|
||||
Season/episode numbers are also parseable from the id (last two `-` segments),
|
||||
so the CTE can obtain **all three** values it needs (`series_id`,
|
||||
`season_number`, `episode_number`) without touching `episodes` for the 99.94%
|
||||
anchored majority — while COALESCE-ing to the existing join for the legacy tail.
|
||||
|
||||
This makes the NextUp optimization **low-risk and well-precedented**, not novel.
|
||||
|
||||
## Proposed fixes (ranked by expected impact)
|
||||
|
||||
The ranking below was re-ordered after an adversarial review. The production
|
||||
pain is cold-cache spikes hitting live requests, so **caching is the highest-
|
||||
impact lever**, not the query rewrite. The content_id rewrite is a real warm/cold
|
||||
I/O trim but is demoted and gated on a correctness fix (see #6).
|
||||
|
||||
### Tier 1 — highest impact, addresses the actual production symptom
|
||||
|
||||
1. **Warm / lengthen the continue-watching cache** so the hourly `:12` cold
|
||||
recompute never lands on a live request. Options: background refresh before
|
||||
TTL expiry, or a longer TTL with async invalidation on progress write. The
|
||||
evidence (base query ~1–91ms, prod 3.5s clustered at `:12–:14` hourly) says
|
||||
this single fix most likely kills the headline spike on its own.
|
||||
|
||||
2. **Cap the Resume full-history scan.** When `EnableTotalRecordCount=true`,
|
||||
`loadProgressPage` (`handlers_items.go:2446-2483`) keeps paging the user's
|
||||
entire in-progress history to compute a total — the documented 35.9s path.
|
||||
Stop scanning to the end: cap scan depth (e.g. reuse
|
||||
`continueProgressMaxScanned`) and return an approximate/clamped total, or omit
|
||||
the exact total for oversized histories.
|
||||
|
||||
3. **Bound `/Shows/{id}/Episodes`.** `ListBySeries`
|
||||
(`internal/catalog/episode_repo.go:684`) returns the whole series with no
|
||||
`LIMIT`; paginate it (the worst 10.1s case). Also fix the `episodeRepo == nil`
|
||||
fallback that fans out one `ListEpisodes` per season — a genuine N+1
|
||||
(`internal/jellycompat/handlers_items.go:2673`).
|
||||
- NOTE (review correction): do **not** bother adding a `LIMIT` to
|
||||
`listResumableFirstEpisodes` — its input already comes from
|
||||
`ListProgress(..., 100, 0)` (`nextup_repo.go:290`), so `ANY($3)` is already
|
||||
≤100 ids. That would be a cosmetic no-op.
|
||||
|
||||
### Tier 2 — cheap serialization/dedup wins
|
||||
|
||||
4. **Raise/tune `fetchAllMaxConcurrency`** (currently `4`,
|
||||
`internal/sections/fetcher.go:51`). With 32 home sections this serializes into
|
||||
~8 waves; total ≈ `ceil(N/4) × slowest-section`. Likely a bigger aggregate-
|
||||
latency lever than the NextUp micro-optimization and cheaper. **Measure
|
||||
`pgxpool` max conns first** — raising concurrency while cold queries are slow
|
||||
amplifies pool pressure.
|
||||
|
||||
5. **Avoid double NextUp work in combined mode.** `fetchContinueWatchingSection`
|
||||
calls `FetchNextUpItems` inline when `next_up_mode="combined"`
|
||||
(`fetcher.go:436`) while the `next_up` section computes it again — compute once
|
||||
and share. Also **push dismissal/access filtering into SQL** for
|
||||
continue-watching so it stops scanning up to 1000 rows across 10 sequential
|
||||
`ListProgress` round-trips to fill a 20-item section (`fetcher.go:498-522`).
|
||||
|
||||
### Tier 3 — the NextUp content_id rewrite (warm/cold I/O trim, NOT the spike fix)
|
||||
|
||||
6. **Derive series/season/episode from `content_id` in the NextUp CTE instead of
|
||||
joining `episodes`.** Confirmed effect: the `completed_episodes` CTE for user
|
||||
627 drops from **67.5ms / 56,893 buffers** to **38.8ms / 13,362 buffers** —
|
||||
the 56k `episodes_pkey` probes vanish. But this does NOT touch the LATERAL
|
||||
(episodes + media_files), which is the legitimately irreducible part and
|
||||
becomes the new dominant term, and it does NOT address the cold-cache spike
|
||||
that Tier 1 #1 fixes. Keep it, but as an optimization, not the headline.
|
||||
|
||||
**Mandatory correctness requirements (do not skip):**
|
||||
- **`::int` casts are required.** `split_part` returns TEXT. The LATERAL
|
||||
compares against integer columns `(e2.season_number, e2.episode_number)`, and
|
||||
the `DISTINCT ON` orders by `season_number DESC, episode_number DESC`. Mixed
|
||||
`(text,text) > (int,int)` raises `operator does not exist: text > integer`;
|
||||
forcing both sides to text makes `('1','2') > ('1','10')` return TRUE
|
||||
(lexicographic), so NextUp would surface the wrong episode. Cast every derived
|
||||
season/episode value to `int`.
|
||||
- **This is NOT fully precedented.** `history_source.go:275-282` derives only
|
||||
the series-id *string* (never used numerically). The numeric season/episode
|
||||
derivation is new and is exactly where the lexicographic landmine lives —
|
||||
copying `history_source` verbatim gives the series expr but not the casts.
|
||||
- **Fallback for the legacy tail is mandatory.** COALESCE to the episodes join
|
||||
for the 0.06% legacy/local (`local-`/Sonyflake) ids using the same
|
||||
`anchoredEpisodePredicate` (5 non-empty `-` components) as `history_source.go`.
|
||||
- Data validated safe for anchored ids: across all 2,110,532 anchored episode
|
||||
rows, derived season/episode match the table columns with **0 mismatches**
|
||||
(including season-0/specials); all segments numeric.
|
||||
|
||||
### Tier 2 — high-frequency jellycompat browse (`/Items/Latest`, `/Items`)
|
||||
|
||||
These are very high traffic — 10,837 `/Items/Latest` + 6,030 `/Items` calls in
|
||||
24h — so even sub-second slowness is a large aggregate load. 333 `/Items/Latest`
|
||||
calls ran >800ms (p50 1.1s, max 1.6s); every slow one requested full `Fields`
|
||||
(`MediaSources`+`MediaStreams`) with `isPlayed=false&groupItems=true`.
|
||||
|
||||
Root cause is **not** the detail expansion. It is `isPlayed=false` (a per-profile
|
||||
overlay that can't be pushed into SQL) forcing `BrowseItems` into an over-fetch
|
||||
loop that advances `filters.Offset` (`internal/jellycompat/content_direct.go:477`).
|
||||
The cross-library recently-added **fast path is gated on `Offset == 0`**
|
||||
(`content_direct.go:430`); as soon as a heavy watcher needs a 2nd chunk it falls
|
||||
through to the generic `BrowsePage` (`content_direct.go:435`), whose multi-library
|
||||
plan is a whole-catalog `GROUP BY` HashAggregate + top-N heapsort over ~147k
|
||||
movies (`internal/catalog/browse.go:544-548`).
|
||||
|
||||
`EXPLAIN (ANALYZE, BUFFERS)` of the 2nd-chunk shape (`LIMIT 150 OFFSET 150`,
|
||||
`type=movie`, full projection): **755ms per call** (HashAggregate over 147,212
|
||||
rows, top-N heapsort). The offset-0 fast path
|
||||
(`BrowseRecentlyAddedAcrossLibraries`, index walk on
|
||||
`idx_item_libraries_folder_seen_content`) is ~1ms/library. So one fall-through =
|
||||
0.8–1.6s; two (very heavy watchers) reach the top of the range. Only affects
|
||||
users with 2+ libraries and lots of movie watch history — consistent with "some
|
||||
calls slow."
|
||||
|
||||
8. **Keep the `isPlayed` over-fetch loop on the fast path.** Track a separate
|
||||
chunk offset and re-call `BrowseRecentlyAddedAcrossLibraries` for each chunk
|
||||
(growing top-N bound) instead of advancing `filters.Offset` into `BrowsePage`
|
||||
— or give the multi-library recently-added GROUP BY an offset-capable
|
||||
index-ordered plan. Expected: **~0.8–1.6s → ~50–150ms (≈8–10×)**.
|
||||
|
||||
9. **Drop the redundant per-item `enrichDetailUserData`** in
|
||||
`GetItemDetailsByIDs` (`content_direct.go:693-702` → `:612-614`): it runs a
|
||||
single-item `GetProgress` + `ListCompletedHistoryItems` **per item (~100
|
||||
sequential point queries for 50 items)**, but the handler already fetched
|
||||
progress in one batched call (`resolveUserStateForContentIDs`,
|
||||
`handlers_items.go:961`) and `userDataDTO` overrides it (`mapping.go:551-559`),
|
||||
so the per-item work is thrown away. Remove or batch it. Expected: shave
|
||||
~50–150ms of pure waste.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- **`/api/v1/recommendations/taste-seed/items` (4.4–5.5s, every call).** Confirmed
|
||||
it only fires on the explicit `/taste-seed` onboarding page
|
||||
(`web/src/pages/TasteSeed.tsx` via `useTasteSeedItems`); the Home screen renders
|
||||
only `TasteSeedBanner` (dismissed-state check, no items query). 56 calls / 4
|
||||
users in 24h. Rare, opt-in, not on the homescreen — deliberately deferred.
|
||||
|
||||
### Ops (not code)
|
||||
|
||||
7. **Enable `pg_stat_statements`** — confirmed OFF (`shared_preload_libraries`
|
||||
empty). Enabling it makes future regressions measurable, but it **requires a
|
||||
Postgres restart** (not a live toggle) — schedule accordingly.
|
||||
|
||||
## Risk / follow-ups
|
||||
|
||||
- The content_id rewrite's `::int` cast + lexicographic-ordering trap is the
|
||||
single biggest correctness gap; gate it on an explicit test (episode 2 vs 10
|
||||
within a season, and the `season DESC, episode DESC` tiebreak) before merge.
|
||||
- After the rewrite, the LATERAL (episodes + media_files) becomes the dominant
|
||||
term; measure its cold cost separately — it may warrant its own index review.
|
||||
- Raising `fetchAllMaxConcurrency` trades DB pool pressure for latency; validate
|
||||
against `pgxpool` max conns under real concurrency, not just latency.
|
||||
- Cache warming changes staleness semantics for continue-watching; confirm
|
||||
progress writes still invalidate promptly enough that a just-watched item moves.
|
||||
- `/Shows/{id}/Episodes` pagination is client-visible (jellycompat); verify
|
||||
Android/Apple clients tolerate a bounded page + `startItemId` continuation.
|
||||
|
||||
## Verification plan
|
||||
|
||||
- `EXPLAIN (ANALYZE, BUFFERS)` before/after on the NextUp rewrite for `user_id=627`;
|
||||
assert the `episodes_pkey` loop count drops from ~14.5k to ~0 for anchored users.
|
||||
- Replay the worst production queries (Resume `Limit=20` + total-count; long-series
|
||||
Episodes) and confirm sub-second.
|
||||
- Watch `slow section fetch` / `slow aggregate section fetch` counts in logs after
|
||||
deploy; the hourly `:12` compat-resume spike should disappear.
|
||||
- Dedicated ordering test for the content_id derivation: a series with episodes
|
||||
2 and 10 in one season, asserting NextUp picks episode 2 → 3 (not 10 → 11) and
|
||||
the `DISTINCT ON ... DESC` tiebreak picks the highest-numbered completed episode.
|
||||
- `make lint`, `go build ./...`, targeted unit tests for the content_id SQL
|
||||
derivation (mirror existing `history_source` tests, plus the `::int` cast path).
|
||||
@@ -0,0 +1,122 @@
|
||||
# Shared List Cache (Home Rails) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Cut repeated database work and tail latency on the home screen by caching the *shared* part of every user-agnostic home rail once per access scope, refreshing it in the background before it expires (target TTL ~15 min), and layering the cheap per-user part (watched flags, play position, poster links) on top of the cached list on every request.
|
||||
|
||||
**Architecture:** Option A — an in-process, **process-global** resolved-list cache inserted at the native section fetch choke point (`internal/sections`). The section fetcher already returns a *shared, non-personalized* item list; the per-user overlay is applied later in the API handler. We cache at that boundary for the wider/fuller set of user-agnostic section types (including admin-curated collections). Redis is **not** the store — it is an optional invalidation signal only.
|
||||
|
||||
**Tech Stack:** Go, `golang.org/x/sync/singleflight` (already used in `internal/sections`), in-process map + mutex (mirrors the existing `editorialCandidateCache`), existing `catalog.AccessFilter`, targeted Go tests.
|
||||
|
||||
**Scope target:** Native Silo sections path (`internal/sections/fetcher.go` + `internal/api/handlers/sections.go`).
|
||||
|
||||
---
|
||||
|
||||
## Validated Direction
|
||||
|
||||
Confirmed directly against the code on `main`:
|
||||
|
||||
- **The shared base list and the per-user overlay are already separated.** `Fetcher.fetchSection` (`internal/sections/fetcher.go`) returns raw `[]*models.MediaItem` and, for the user-agnostic section types, takes only the access `filter` — no user/profile. The per-user overlay (watched state, play position, presigned poster URLs, overlay badges) is applied afterward in `SectionHandler.buildSectionsResponse` (`internal/api/handlers/sections.go`) on every request. This makes the fetcher output a clean, presign-free cache boundary.
|
||||
- **There is precedent to copy.** `editorialCandidateCache` (`internal/sections/fetcher.go`) is already an in-process TTL cache guarded by a mutex + `singleflight.Group`, keyed by subject/library/access-filter (never by user). It caches *candidate ID lists*; this plan generalizes it to a *resolved item list* with a short TTL and refresh-ahead.
|
||||
- **The fetch choke point is `Fetcher.FetchOne`.** Both `HandleHomeSections` (via `FetchAll` → `FetchOne`) and `HandleHomeSectionItems` route through it. Caching there covers the whole native home path in one place.
|
||||
- **The cache must be process-global, not per-`Fetcher`.** The process constructs several independent `sections.NewFetcher(...)` instances (e.g. native API and recommendations). A cache stored on the `Fetcher` struct (as `editorialCandidateCache` is today) would be duplicated per instance and could never be shared across surfaces. A package-level cache lets any current or future consumer of `FetchOne` reuse the same warm entries.
|
||||
- **`Collection` is not uniformly user-agnostic.** `fetchCollection` serves library collections (shared) but routes to `fetchUserCollection` when `cfg.UserCollectionID != ""` (profile-scoped). The cache must exclude the user-collection case or it would leak one profile's list to another.
|
||||
|
||||
---
|
||||
|
||||
## What is cached (the fuller user-agnostic set)
|
||||
|
||||
These rows are identical for everyone who can see the same libraries at the same content-rating cap, so they are cached by access scope:
|
||||
|
||||
- Recently Added (`SectionRecentlyAdded`)
|
||||
- Recently Released / New Releases (`SectionRecentlyReleased`)
|
||||
- Genre and custom-filter rows (`SectionGenre`, `SectionCustomFilter`)
|
||||
- Trending on this server (`SectionTrendingOnServer`), Most Watched (`SectionMostWatched`)
|
||||
- New to Library (`SectionNewToLibrary`)
|
||||
- Critically Acclaimed (`SectionCriticallyAcclaimed`), Award Winners (`SectionAwardWinners`)
|
||||
- Editorial Spotlight / featured (`SectionEditorialSpotlight`)
|
||||
- Seasonal (`SectionSeasonalThemed`), Mood (`SectionMoodCollection`), Format Showcase (`SectionFormatShowcase`)
|
||||
- Trending Discover (`SectionTrendingDiscover`)
|
||||
- **Admin-curated lists (`SectionAdminCuratedList`)**
|
||||
- **Library** collections only (`SectionCollection` where `cfg.UserCollectionID == ""`)
|
||||
|
||||
## What is NOT cached
|
||||
|
||||
- **Per-user rows (no shared base):** Continue Watching, Next Up, Next in Series, Recommended For You / Because You Watched / Similar Users Liked / Taste Match, Hidden Gems, Forgotten Favorites, Profile Activity Feed, and **user** collections (`SectionCollection` with a `UserCollectionID`). These bypass the cache entirely.
|
||||
- **`SectionRandom`** is technically user-agnostic but intentionally randomized per request; caching would freeze it. Excluded to preserve behavior.
|
||||
|
||||
## The per-user overlay always runs fresh (correctness)
|
||||
|
||||
Even for a cached row, each request still computes, per person: watched flags (`isPlayed`/UserData), play position, and freshly presigned poster URLs — all in `buildSectionsResponse`. The cache only stores the *membership and ordering* of the row (presign-free `*models.MediaItem`). No request ever sees another user's watched state, and no cached entry carries a poster URL that could expire mid-cache.
|
||||
|
||||
---
|
||||
|
||||
## The access-scope cache key (security-critical)
|
||||
|
||||
If the key fails to capture an access boundary, the cache can serve items a user must not see. The key MUST include, and nothing that is per-user:
|
||||
|
||||
- Section identity: section type + section ID + a hash of the section `Config` (so two genre rows with different filters never collide).
|
||||
- Requested `ItemLimit` (row size). Callers may request different sizes for the same section; the key must separate them (or the cache must store a superset and slice down).
|
||||
- Access scope: sorted accessible library IDs, sorted disabled library IDs, max content rating, excluded media types, plus sort/order.
|
||||
|
||||
Model the string builder on the existing `editorialCandidateCacheKey`, extended with section ID + config hash + `ItemLimit`.
|
||||
|
||||
## Background refresh before expiry (the key behavior)
|
||||
|
||||
Each entry stores `builtAt`, a soft `refreshAfter` (e.g. `builtAt + 12min`), and a hard `expiresAt` (e.g. `builtAt + 15min`). A `getOrRefresh(ctx, key, loader)` helper implements:
|
||||
|
||||
- `now < refreshAfter` → return cached value, do nothing.
|
||||
- `refreshAfter <= now < expiresAt` → return cached value **and** kick off one async rebuild via `singleflight` (only one rebuild per key). The fresh value swaps in when ready.
|
||||
- `now >= expiresAt` → block on the build; `singleflight` collapses concurrent blockers into one build (stampede protection).
|
||||
|
||||
Net effect under steady traffic: entries are refreshed ahead of expiry, so live requests are served warm and never pay the cold rebuild. An optional low-frequency sweeper can keep rarely-hit hot scopes warm.
|
||||
|
||||
## Optional invalidation (freshness)
|
||||
|
||||
Newly scanned content otherwise appears up to ~15 min late in these rails — acceptable for "recently added"/"trending". Optionally subscribe to the existing Redis `EventScanComplete` / `EventMetadataUpdated` events (`internal/cache`) and drop affected scopes for near-instant freshness. This is a nice-to-have layered on top of the TTL, not a dependency.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Add `internal/sections/resolvedlistcache.go`
|
||||
- Package-level cache: `map[string]resolvedListEntry` (value = presign-free `[]*models.MediaItem` + `TotalCount` + `builtAt`/`refreshAfter`/`expiresAt`), guarded by a `sync.RWMutex` and a package-level `singleflight.Group`.
|
||||
- `getOrRefresh(ctx, key, ttl, refreshLead, loader)` implementing serve / refresh-ahead / block-only-when-dead. Reuse the `f.now()` clock indirection for deterministic tests.
|
||||
- `resolvedListCacheKey(...)` builder (section type + section ID + config hash + `ItemLimit` + access scope), modeled on `editorialCandidateCacheKey`.
|
||||
- `isCacheableSectionType(resolved)` guard implementing the whitelist above, including the `cfg.UserCollectionID == ""` check for `SectionCollection` and the `SectionRandom` exclusion.
|
||||
- Modify `internal/sections/fetcher.go`
|
||||
- Wrap the user-agnostic branch of `FetchOne` (and the `SectionEditorialSpotlight` branch) so that cacheable section types resolve through `getOrRefresh`; everything else calls the existing loader path unchanged.
|
||||
- Return defensive copies so callers cannot mutate cached slices.
|
||||
- Add `internal/sections/resolvedlistcache_test.go`
|
||||
- Distinct access scopes never cross-serve (security).
|
||||
- `SectionCollection` with a `UserCollectionID` is never cached.
|
||||
- Refresh-ahead returns the current value without blocking; only a fully-expired entry blocks.
|
||||
- Stampede: concurrent cold requests collapse to a single loader call.
|
||||
- Overlay parity: a cached row produces the same per-user response as the uncached path (drive through `buildSectionsResponse` in `internal/api/handlers`).
|
||||
|
||||
No migration is planned. No plugin repo changes are planned. No client changes are planned.
|
||||
|
||||
---
|
||||
|
||||
## Deferred (explicitly out of scope here)
|
||||
|
||||
- **The cross-library recently-added path** in `directContentService.BrowseItems` is a separate choke point with its own per-user overlay, and is a separate follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Add `resolvedlistcache.go` (cache struct, `getOrRefresh`, key builder, section-type whitelist).
|
||||
- [ ] Wire `FetchOne` to route cacheable section types through the cache; leave per-user types untouched.
|
||||
- [ ] Add the `SectionCollection` user-collection exclusion and `SectionRandom` exclusion.
|
||||
- [ ] Add unit tests (scope isolation, user-collection exclusion, refresh-ahead non-blocking, stampede collapse).
|
||||
- [ ] Add an overlay-parity test through `buildSectionsResponse`.
|
||||
- [ ] (Optional) Subscribe to `EventScanComplete`/`EventMetadataUpdated` to invalidate affected scopes.
|
||||
- [ ] `make lint`, `go test ./internal/sections/... ./internal/api/...`, `make verify-local-paths`.
|
||||
|
||||
## Risks / follow-ups
|
||||
|
||||
- **Security key completeness** is the number-one risk; when in doubt, add a field to the key and add a scope-isolation test.
|
||||
- **Approximate totals:** hiding watched items post-cache keeps `TotalRecordCount` approximate — already an accepted tradeoff on the `isPlayed` path; unchanged here.
|
||||
- **Staleness window** up to ~15 min for newly scanned content; mitigated by the optional scan-complete invalidation.
|
||||
- **Memory:** ~200 items × small metadata × tens of scopes is negligible; cap entry count and evict LRU as a backstop.
|
||||
@@ -0,0 +1,219 @@
|
||||
# 2026-07-06 — Why the slow endpoints stayed slow after PR #292, and the fix plan
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Context
|
||||
|
||||
PR #292 (commit `5606beed`) shipped the home/Continue Watching/Latest latency work:
|
||||
scan caps in the jellycompat resume path, the shared resolved-list cache, the
|
||||
per-library Latest fast path, batched presign, and the plugin-installation cache.
|
||||
The image built from it has been serving production for a full day, and the
|
||||
deployment's request logs still show the same endpoint groups breaching 1s
|
||||
(analysis window: 19h post-deploy, `log_min_duration_statement = 500ms` on the
|
||||
Postgres side):
|
||||
|
||||
| Endpoint group | slow (≥1s) calls | p95 | worst |
|
||||
|---|---|---|---|
|
||||
| `/Shows/NextUp` | 301 | 17.1s | 44.8s |
|
||||
| `/UserItems/Resume` + `Users/{userId}/Items/Resume` | 121 | 34.8s / 110.7s | 125.3s |
|
||||
| `/Items/Latest` + `Users/{id}/Items/Latest` | 117 | 17.3s / 8.3s | 18.8s |
|
||||
| `/Items` (incl. search-as-you-type) | 83 | 6.3s | 9.6s |
|
||||
| native home sections + section items (`api/v1/home` routes) | 50 | 6.3s / 42.0s | 42.8s |
|
||||
|
||||
The deployed binary provably contains the PR #292 symbols, so the caps and caches
|
||||
are live. They bounded *how many rows the loops touch* — they did not touch *what
|
||||
each underlying query costs*. The Postgres slow-statement log for the same window
|
||||
shows where the time actually goes:
|
||||
|
||||
| Statement | slow execs | total time | worst |
|
||||
|---|---|---|---|
|
||||
| `ListProgress(status="in_progress")` page query | 5,263 | 13,522s | 16.8s |
|
||||
| Next-up `WITH completed_episodes …` CTE | 648 | 1,717s | 44.7s |
|
||||
| `RemoveHistoryItems` history DELETE | 81 | 118s | 10.9s |
|
||||
| Scanner `media_files` subtree lookup | 27 | 74s | 13.6s |
|
||||
| `ListCompletedHistoryItems` (chunked rollup) | 65 | 64s | 1.6s |
|
||||
|
||||
## Root cause 1 — 4.32M stale "completed but resumable" progress rows + no index for the resume ordering
|
||||
|
||||
**Human-readable.** For accounts that bulk-imported their Plex watch history, every
|
||||
imported "watched" row was stored as *finished AND parked at the very end of the
|
||||
video*. The server's definition of "something you can resume" is "position greater
|
||||
than zero" — deliberately, so a rewatch of an already-watched item re-enters
|
||||
Continue Watching. Result: for a heavy importer, the server believes their **entire
|
||||
watch history (232,979 of 233,016 rows for the worst profile) is resumable**. Every
|
||||
Continue Watching page load walks that entire list, filters ~100% of it away in
|
||||
memory, pages deeper, and repeats.
|
||||
|
||||
**Technical.** All current write paths enforce the invariant "completed ⇒
|
||||
`position_seconds = 0`" (`internal/userstore/pgstore/progress.go`,
|
||||
`internal/historyimport/repo.go:879`). But 4,318,693 of 4,579,210
|
||||
`user_watch_progress` rows (94%, across 1,649 profiles) violate it with
|
||||
`completed = TRUE AND position_seconds > 0` — legacy imports from before the
|
||||
invariant. Only 189 of those are genuine mid-rewatch rows
|
||||
(`position_seconds < duration_seconds`); the rest are parked at/past the end and
|
||||
can never be a meaningful resume point.
|
||||
|
||||
The `in_progress` branch of `ListProgress`
|
||||
(`internal/userstore/pgstore/progress.go:426`) filters
|
||||
`position_seconds > 0 … ORDER BY updated_at DESC LIMIT … OFFSET …`. No index
|
||||
serves that shape (the partial indexes cover `completed = true/false`, not
|
||||
`position_seconds > 0`), so **every call walks all of the profile's rows via
|
||||
`idx_user_watch_progress_profile` and top-N-sorts them** — measured 192ms warm /
|
||||
multi-second cold per call for the worst profile (EXPLAIN ANALYZE: 232,962 rows
|
||||
walked per call).
|
||||
|
||||
Every consumer loops this query per request:
|
||||
|
||||
- `internal/sections/fetcher.go` `collectContinueProgressItems`: up to 10 pages
|
||||
(`continueProgressMaxScanned = 1000` / page size 100) — serves the native
|
||||
Continue Watching section (the `api/v1/home` section-items route, 42s) **and**,
|
||||
since PR #292, the jellycompat Resume fast path (`loadResumeViaSections`) — the
|
||||
125s `Users/{userId}/Items/Resume` calls.
|
||||
- `internal/jellycompat/handlers_items.go` `loadProgressPage`: up to
|
||||
`resumeScanMaxRows = 300` rows per request (the PR #292 cap — it bounds pages,
|
||||
not per-page cost).
|
||||
- `internal/catalog/nextup_repo.go` `listResumableFirstEpisodes`: one 100-row call.
|
||||
|
||||
The 00:30 UTC log window shows the failure shape directly: ~17 sequential 1.1s
|
||||
executions of this query (one paging loop) plus 16.5s cold executions saturating
|
||||
the pool while other endpoints queue behind them.
|
||||
|
||||
**Fix (one-time direct DB repair — applied 2026-07-06, no migration shipped).**
|
||||
|
||||
Because this is a one-shot repair of legacy data on a single deployment, it was
|
||||
applied directly against the production database instead of as a Goose
|
||||
migration; the exact SQL and timings are recorded in the deployment's ops notes
|
||||
(silo-base, `slow-endpoint-db-repair-2026-07-06.md`).
|
||||
|
||||
1. Data repair: `UPDATE user_watch_progress SET position_seconds = 0 WHERE
|
||||
completed = TRUE AND position_seconds > 0 AND position_seconds >=
|
||||
duration_seconds`, followed by `ANALYZE user_watch_progress`. Leaves the 189
|
||||
genuine mid-rewatch rows alone; does not touch `updated_at` or `synced_seq`
|
||||
(no sync flood, ordering preserved). These rows were already invisible in
|
||||
Continue Watching (dismissal/superseded/percent filtering), so no visible
|
||||
behavior changes — the data just stops lying to the query planner. Applied:
|
||||
4,318,504 rows in 2m05s.
|
||||
2. Partial index `idx_uwp_profile_resume (user_id, profile_id, updated_at DESC)
|
||||
WHERE position_seconds > 0` (`CREATE INDEX CONCURRENTLY`) — turns every
|
||||
in-progress listing into an ordered index walk regardless of profile size
|
||||
(belt-and-braces against future bad data; after the repair the index is
|
||||
8.8 MB). Verified post-repair: the worst profile's in-progress page query
|
||||
went from 232,962 rows walked / 192ms warm to 37 rows / 1.0ms.
|
||||
|
||||
## Root cause 2 — Next Up anchors on the entire completed history
|
||||
|
||||
**Human-readable.** "Next Up" answers "what's the next episode of each show this
|
||||
person is watching?". To find those shows it re-reads **every episode the person
|
||||
has ever finished** — a quarter-million rows for bulk importers — on every call,
|
||||
then for each fully-watched show walks all of its episodes looking for an
|
||||
unwatched one that isn't there.
|
||||
|
||||
**Technical.** `buildListNextUpQuery` (`internal/catalog/nextup_repo.go`): the
|
||||
`completed_episodes` CTE does `DISTINCT ON (e.series_id)` over **all** of the
|
||||
profile's completed rows joined to `episodes` (233k rows for the worst profile),
|
||||
then `eligible_series` runs a correlated anti-join against the (currently
|
||||
non-selective, see RC1) `position_seconds > 0` set, then a per-series LATERAL
|
||||
probes episodes in order — scanning *every* episode of a fully-watched series
|
||||
before yielding nothing. 648 slow executions, 44.7s worst. This also drags down
|
||||
the native home-sections aggregate (`api/v1/home` sections) via `maybeInjectNextUp` and the jellycompat
|
||||
`/Shows/NextUp` route.
|
||||
|
||||
**Fix.** Bound the anchor for the global (non-`SeriesID`) query: a
|
||||
`recent_completed` pre-CTE takes the most recent `nextUpAnchorMaxRows = 500`
|
||||
completed rows via `idx_uwp_profile_completed` (an ordered index walk), and
|
||||
`completed_episodes` derives series from that subset. A Next Up rail shows ~24
|
||||
series; the 500 most recent completions cover every realistically surfaceable
|
||||
series. Series-scoped calls (show-detail tile) keep the unbounded shape — they
|
||||
are naturally bounded by one series. Prototyped on the live worst profile:
|
||||
**44.7s → 517ms** (and the residual cost is the RC1 anti-join, which the repair
|
||||
removes).
|
||||
|
||||
## Root cause 3 — series watch-state rollup materializes every episode of every series on the page
|
||||
|
||||
**Human-readable.** For any list of TV shows (per-library "Latest", library
|
||||
browse, search results), the server computes each show's "N unwatched episodes"
|
||||
badge by **loading every episode of every show on the page into memory** and then
|
||||
asking the watch database about each episode in batches of 500. One 50-show page
|
||||
of the Sports library expands to 32,467 episodes and ~65 sequential database
|
||||
round-trips. PR #292 wired the cached Latest fast path through this same rollup
|
||||
(for data parity), so even cache-hit responses pay it.
|
||||
|
||||
**Technical.** `enrichSeriesListUserData` (`internal/jellycompat/content_direct.go`)
|
||||
→ `episodeRepo.ListBySeriesIDs` (all episodes) → `chunkedProgressByMediaItems` →
|
||||
`ListProgressWithCompletedHistory` per 500 ids (each chunk hits
|
||||
`user_watch_progress` + the `ListCompletedHistoryItems` GROUP BY). The same
|
||||
per-episode fanout runs in `enrichDetailUserData` for every series row on detail
|
||||
pages. It only ever produces four numbers per series (total/watched/in-progress/
|
||||
played).
|
||||
|
||||
**Fix.** Compute the counts in one SQL aggregate. New optional interface
|
||||
`userstore.SeriesEpisodeRollupStore`, implemented by `PostgresUserStore`
|
||||
(the pgstore already references catalog tables — see
|
||||
`buildProgressCatalogFilter`): a single `GROUP BY e.series_id` query over
|
||||
`episodes` LEFT-JOINed to the profile's progress rows with the same
|
||||
visibility/history semantics as `ListProgressWithCompletedHistory`
|
||||
(hidden-items anti-join, completed-history fold). `enrichSeriesListUserData` and
|
||||
`enrichDetailUserData` use it when the store implements it and keep the existing
|
||||
chunked path as fallback (SQLite-backed user stores). Prototyped on the live
|
||||
worst profile against the real Sports Latest page: **~17s → 123ms**.
|
||||
|
||||
This also fixes the slow `/Items?searchTerm=…` calls (Meilisearch itself is fast;
|
||||
the search handler excludes movies/episodes, returns mostly series, and then paid
|
||||
this same rollup) and the series portions of `/Items` browse.
|
||||
|
||||
## Root cause 4 (secondary) — two index-starved write/maintenance paths
|
||||
|
||||
- `RemoveHistoryItems` (`internal/userstore/pgstore/progress.go`): the watermark
|
||||
MAX and the DELETE filter `user_watch_history` by `(user_id, profile_id,
|
||||
media_item_id = ANY(...))`, but the only complete index is `(user_id,
|
||||
profile_id, watched_at DESC)` — per-user full history scans (10.9s worst; this
|
||||
is the `/UserPlayedItems/{itemId}` DELETE path, 19.4s worst end-to-end).
|
||||
Fix: plain btree `(user_id, profile_id, media_item_id)`.
|
||||
- Scanner subtree lookups (`internal/scanner/file_repo.go`):
|
||||
`media_folder_id = $1 AND (file_path = $2 OR file_path LIKE $3 ESCAPE '\')` has
|
||||
no usable index for the path predicate (13.6s worst; holds pool connections that
|
||||
API requests then queue behind). The LIKE is prefix-anchored
|
||||
(`pathscope.PrefixLike`), so a btree on `(media_folder_id, file_path
|
||||
text_pattern_ops)` serves both arms and the `ORDER BY file_path`.
|
||||
|
||||
Both indexes were created directly on the production database
|
||||
(`CREATE INDEX CONCURRENTLY`) alongside RC1's partial index — see the
|
||||
deployment's ops notes.
|
||||
|
||||
## What is intentionally not changed
|
||||
|
||||
- `resumeScanMaxRows` / `continueProgressMaxScanned` / `maxSeriesUserDataRollups`
|
||||
caps stay — they remain correct guards; the fixes make each capped unit cheap.
|
||||
- The `position_seconds > 0` rewatch semantics stay; the repair only removes rows
|
||||
that violate the documented write-path invariant.
|
||||
- No `updated_at`/`synced_seq` changes in the repair — client sync state is
|
||||
untouched.
|
||||
- `/api/v1/stream/{session_id}/subtitles/{track}` (subtitle *track* conversion)
|
||||
is out of scope per the task. The related font-attachment endpoint
|
||||
`/subtitles/{track}/fonts` *was* folded in as a follow-up — see deliverable 5 —
|
||||
because its per-attachment ffmpeg spawns shared the same slow-endpoint profile.
|
||||
|
||||
## Deliverables
|
||||
|
||||
1. `docs:` this plan.
|
||||
2. One-time DB repair + three indexes, applied directly to the production
|
||||
database on 2026-07-06 (recorded in the deployment's ops notes; deliberately
|
||||
not shipped as a migration).
|
||||
3. `perf(catalog):` bounded next-up anchor scan.
|
||||
4. `perf(jellycompat,userstore):` SQL series watch-state rollup with chunked
|
||||
fallback.
|
||||
5. `perf(playback):` single-pass ffmpeg font extraction for
|
||||
`/subtitles/{track}/fonts` (one media-file open instead of one per
|
||||
attachment), with the 32-attachment / 32 MiB caps preserved via a dump-dir
|
||||
watchdog.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go build ./... && go vet ./...`; `go test -race` on `internal/catalog`,
|
||||
`internal/jellycompat`, `internal/userstore/...`, `internal/sections`.
|
||||
- EXPLAIN ANALYZE numbers above were measured on the production database
|
||||
against the worst real profile, before and after the repair: in-progress page
|
||||
query 192ms/232,962 rows → 1.0ms/37 rows; deployed (unbounded) next-up shape
|
||||
17–44s → 1.1s; bounded next-up shape → 10ms; 50-series rollup ~17s → 119ms.
|
||||
- Post-deploy of the code fixes: re-run the slow-request aggregation over
|
||||
`docker logs` and confirm the five endpoint groups drop out of the ≥1s report.
|
||||
@@ -0,0 +1,376 @@
|
||||
# Implementation Plan: Autoscan Sonarr/Radarr Webhook Intake
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-09-autoscan-arr-webhook-intake-design.md`
|
||||
(Option A, decisions locked — read the spec's "Resolved review decisions" before
|
||||
starting.)
|
||||
**Date:** 2026-07-09
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
## Overview
|
||||
|
||||
Add a webhook delivery mode to Autoscan so Sonarr/Radarr can POST import,
|
||||
upgrade, rename, and delete notifications directly to Silo without an arr API
|
||||
key. Webhook sources bind to a host-discovered built-in identity
|
||||
(`silo.autoscan.arr-webhook`), never the installed ARR plugin. The host parses
|
||||
payloads and feeds the existing rewrite → resolve → suppress → enqueue → event
|
||||
pipeline.
|
||||
|
||||
Non-negotiable design points from the review:
|
||||
|
||||
1. **Built-in source identity** — no plugin install required for webhook mode.
|
||||
2. **Webhook deliveries are never single-flight-dropped.** Only poll cycles may
|
||||
be skipped when a running event exists (their marker window is re-read).
|
||||
3. Event rows record `delivery_mode` and `provider_event_type` from day one.
|
||||
4. Unknown arr event types → `202` no-op. Secret lookup uses plain SHA-256.
|
||||
Webhook URL stays redisplayable on admin endpoints.
|
||||
|
||||
Suggested commit sequence is one commit per phase below, all on one branch, PR
|
||||
subject `feat(autoscan): add Sonarr/Radarr webhook intake`.
|
||||
|
||||
## Phase 1 — Migration
|
||||
|
||||
Create with `make migrate-create NAME=autoscan_webhook_intake` (single
|
||||
timestamped Goose migration; do not hand-number, do not create paired
|
||||
up/down files).
|
||||
|
||||
Up:
|
||||
|
||||
```sql
|
||||
ALTER TABLE autoscan_sources
|
||||
ADD COLUMN delivery_mode text NOT NULL DEFAULT 'poll'
|
||||
CONSTRAINT autoscan_sources_delivery_mode_check
|
||||
CHECK (delivery_mode = ANY (ARRAY['poll'::text, 'webhook'::text]));
|
||||
|
||||
CREATE TABLE autoscan_webhook_endpoints (
|
||||
source_id uuid PRIMARY KEY
|
||||
REFERENCES autoscan_sources(id) ON DELETE CASCADE,
|
||||
secret_hash text UNIQUE NOT NULL,
|
||||
secret_ref text NOT NULL,
|
||||
secret_suffix text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
rotated_at timestamptz,
|
||||
last_received_at timestamptz,
|
||||
last_error_at timestamptz,
|
||||
last_error_message text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE autoscan_events
|
||||
ADD COLUMN delivery_mode text NOT NULL DEFAULT 'poll',
|
||||
ADD COLUMN provider_event_type text NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
Down reverses all three (drop table, drop columns). Verify with
|
||||
`make migrate-status` / `make migrate-up` against a scratch DB, and check
|
||||
rollback.
|
||||
|
||||
## Phase 2 — Built-in source identity
|
||||
|
||||
**`internal/autoscan/discovery.go`**
|
||||
|
||||
- Add exported constants:
|
||||
- `BuiltinArrWebhookPluginID = "silo.autoscan.arr-webhook"`
|
||||
- `BuiltinArrWebhookCapabilityID = "arr-webhook"`
|
||||
- display name `"Sonarr/Radarr Webhook"`.
|
||||
- Add a composite lister so the built-in identity lives beside the discovery
|
||||
types it extends (not in wiring code):
|
||||
|
||||
```go
|
||||
// WithBuiltinSources wraps a ScanSourceLister and appends host-built-in
|
||||
// source identities that need no plugin installation.
|
||||
func WithBuiltinSources(inner ScanSourceLister, builtins ...DiscoveredSource) ScanSourceLister
|
||||
```
|
||||
|
||||
It must tolerate `inner == nil` (return only builtins) because
|
||||
`Service.lister` is already allowed to be nil.
|
||||
|
||||
**`internal/api/autoscan_wiring.go`**
|
||||
|
||||
- In `BuildAutoscanService`, wrap the existing lister:
|
||||
|
||||
```go
|
||||
autoscan.WithBuiltinSources(
|
||||
PluginScanSourceLister{installationStore},
|
||||
autoscan.BuiltinArrWebhookSource(), // helper returning the DiscoveredSource
|
||||
)
|
||||
```
|
||||
|
||||
Tests (`internal/autoscan/discovery_test.go`): builtin appears with and without
|
||||
an inner lister; plugin entries pass through unchanged.
|
||||
|
||||
## Phase 3 — Repository and types
|
||||
|
||||
**`internal/autoscan/types.go`**
|
||||
|
||||
- `Source`: add `DeliveryMode string` (values `"poll"`/`"webhook"`; constants
|
||||
`DeliveryModePoll`, `DeliveryModeWebhook`).
|
||||
- `Event`: add `DeliveryMode string`, `ProviderEventType string`.
|
||||
- `EventCreate`: add `DeliveryMode string`, `ProviderEventType string`,
|
||||
`SkipRunningCheck bool`.
|
||||
- New:
|
||||
|
||||
```go
|
||||
type WebhookEndpoint struct {
|
||||
SourceID string
|
||||
SecretSuffix string
|
||||
CreatedAt time.Time
|
||||
RotatedAt *time.Time
|
||||
LastReceivedAt *time.Time
|
||||
LastErrorAt *time.Time
|
||||
LastErrorMessage string
|
||||
}
|
||||
```
|
||||
|
||||
The plaintext token and hash never live on the struct that flows to
|
||||
handlers/responses; creation/rotation return the token separately.
|
||||
|
||||
**`internal/autoscan/repository.go`**
|
||||
|
||||
- Thread `delivery_mode` through `CreateSource`, `UpdateSource`,
|
||||
`ListSources`, `ListEnabledSources`, `GetSource` (default `'poll'` when
|
||||
empty on write).
|
||||
- `CreateEvent`: write the two new columns. When `in.SkipRunningCheck` is
|
||||
true, use the simple no-transaction insert path (the same shape as the
|
||||
existing `sourceID == ""` branch, but with the source id set) — no advisory
|
||||
lock, no running-row check. Poll-mode behavior is unchanged.
|
||||
- `FinishEvent` / `ListEvents` / `ListRunningEvents` / scan-event joins:
|
||||
select and populate the new event fields.
|
||||
- Webhook endpoint methods (token = 32 bytes from `crypto/rand`,
|
||||
`base64.RawURLEncoding`; hash = hex SHA-256 of the token; `secret_ref` =
|
||||
`r.cipher.Encrypt(token, secret.RowAAD("autoscan_webhook_endpoints", "secret_ref", sourceID))`;
|
||||
suffix = last 6 chars of the token):
|
||||
|
||||
```go
|
||||
// CreateWebhookEndpoint creates (or returns the existing) endpoint; token is
|
||||
// "" when the endpoint already existed.
|
||||
CreateWebhookEndpoint(ctx, sourceID string) (WebhookEndpoint, token string, err error)
|
||||
RotateWebhookEndpoint(ctx, sourceID string) (WebhookEndpoint, token string, err error)
|
||||
DeleteWebhookEndpoint(ctx, sourceID string) error
|
||||
GetWebhookEndpoint(ctx, sourceID string) (WebhookEndpoint, error)
|
||||
// RevealWebhookToken decrypts secret_ref for admin redisplay.
|
||||
RevealWebhookToken(ctx, sourceID string) (string, error)
|
||||
// ResolveWebhookToken maps a raw delivery token to its source. Constant
|
||||
// shape: hash the token, look up by secret_hash, join autoscan_sources.
|
||||
// Returns ErrNotFound for unknown hashes.
|
||||
ResolveWebhookToken(ctx, token string) (Source, WebhookEndpoint, error)
|
||||
TouchWebhookReceived(ctx, sourceID string) error
|
||||
RecordWebhookError(ctx, sourceID, msg string) error // bounded, sanitized by caller
|
||||
```
|
||||
|
||||
DB-backed tests follow the existing `SILO_TEST_DATABASE_URL` pattern used by
|
||||
the rest of `internal/autoscan`. Cover: create/rotate invalidates the old
|
||||
token, resolve-by-token round-trip, cascade delete with the source, reveal
|
||||
decrypts to the original token.
|
||||
|
||||
## Phase 4 — Service: shared consume path + `IngestChanges`
|
||||
|
||||
**`internal/autoscan/service.go`**
|
||||
|
||||
- Extract everything in `PollOnce` after `PollChanges` returns into:
|
||||
|
||||
```go
|
||||
type consumeOptions struct {
|
||||
EventID int64
|
||||
Marker string // poll only; "" for webhook
|
||||
NextMarker string // poll only
|
||||
AdvanceMarker bool // false for webhook
|
||||
}
|
||||
|
||||
func (s *Service) consumeSourceChanges(ctx context.Context, src Source, changes []Change, opts consumeOptions) error
|
||||
```
|
||||
|
||||
It owns: `rewriteChanges` → `resolveAndClaim` → target-cap collapse →
|
||||
`enqueueScanTargets` (+ `releaseClaims` on failure) → the
|
||||
transient-error / unresolved / success status decision → `finishEvent`.
|
||||
Marker advancement stays conditional on `opts.AdvanceMarker`; the hold-marker
|
||||
comments and semantics in `PollOnce` must survive the refactor intact.
|
||||
- `PollOnce`: skip sources with `src.DeliveryMode == DeliveryModeWebhook`
|
||||
(before the interval check), then delegate to `consumeSourceChanges`.
|
||||
Existing poll tests in `service_test.go` must pass unmodified except for
|
||||
construction changes.
|
||||
- Add:
|
||||
|
||||
```go
|
||||
type ChangeIngest struct {
|
||||
SourceID string
|
||||
ProviderEventType string
|
||||
Changes []Change
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
Enqueued int
|
||||
Suppressed int
|
||||
Unresolved bool
|
||||
}
|
||||
|
||||
func (s *Service) IngestChanges(ctx context.Context, in ChangeIngest) (IngestResult, error)
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Load the source; reject non-webhook sources.
|
||||
- `CreateEvent` with `DeliveryMode: "webhook"`, `ProviderEventType`,
|
||||
`SkipRunningCheck: true` — deliveries are never dropped because another
|
||||
event is running (spec: "Security and reliability").
|
||||
- Delegate to `consumeSourceChanges` with `AdvanceMarker: false` and empty
|
||||
markers.
|
||||
- Transient resolve failure → finish event as error and return an error (the
|
||||
handler maps it; a duplicate later delivery is safe). All paths outside
|
||||
Silo folders → finish as `unresolved`, return success.
|
||||
|
||||
Service tests: webhook ingest enqueues the same targets as an equivalent poll;
|
||||
concurrent ingests for one source both complete (no `ErrPollAlreadyRunning`);
|
||||
suppressor claims dedupe overlapping targets; `PollOnce` skips webhook sources.
|
||||
|
||||
## Phase 5 — Parser: `internal/autoscan/arrwebhook`
|
||||
|
||||
New package, no dependencies on api/handlers.
|
||||
|
||||
```go
|
||||
type ParsedWebhook struct {
|
||||
Provider string // "sonarr" | "radarr"
|
||||
EventType string
|
||||
Test bool
|
||||
Changes []autoscan.Change
|
||||
}
|
||||
|
||||
func Parse(provider string, body []byte) (ParsedWebhook, error)
|
||||
```
|
||||
|
||||
- `provider` is `sonarr`, `radarr`, or `auto`; `auto` infers from top-level
|
||||
`series` vs `movie` keys. Inference failure on a non-test event returns a
|
||||
typed error the handler maps to 400 (sanitized message, no payload echo).
|
||||
- Event handling per the spec's "Payload parsing" section: Test → no changes;
|
||||
Download/Import/Upgrade/DownloadComplete → imported file paths; Rename → new
|
||||
**and** previous paths; EpisodeFileDelete/MovieFileDelete → deleted path.
|
||||
All paths emit `ChangeScopeFile` (`resolveChange` already falls back through
|
||||
`ResolveVanishedPath` for vanished files). Series/movie folder paths are a
|
||||
subtree fallback (`ChangeScopeSubtree`) only when no file path exists.
|
||||
- Unknown event types return `ParsedWebhook` with no changes and a marker the
|
||||
handler can distinguish (e.g. `EventType` set, `Changes` empty, no error) —
|
||||
they must not be an error.
|
||||
- Dedupe exact paths before returning.
|
||||
|
||||
Fixtures in `internal/autoscan/arrwebhook/testdata/`: Sonarr Download, Rename,
|
||||
EpisodeFileDelete, Test; Radarr Download, Rename, MovieFileDelete, Test; plus
|
||||
an unknown-event and a malformed body. Build them from the Servarr custom-
|
||||
scripts docs linked in the spec.
|
||||
|
||||
## Phase 6 — HTTP: public delivery route + admin endpoints
|
||||
|
||||
**New file `internal/api/handlers/autoscan_webhook.go`**
|
||||
|
||||
Public handler `HandleWebhookDelivery` (`POST /api/v1/autoscan/webhooks/{token}`):
|
||||
|
||||
1. `http.MaxBytesReader` at 256 KiB → 413 on overflow.
|
||||
2. Hash token, `ResolveWebhookToken` → 404 on miss (identical body for
|
||||
deleted/never-existed; do not distinguish).
|
||||
3. Global autoscan disabled or source disabled → 202, no enqueue, still
|
||||
`TouchWebhookReceived`.
|
||||
4. `arrwebhook.Parse` with `source_config.webhook_provider` (default `auto`)
|
||||
→ 400 on parse error; 202 no-op for Test and unknown event types.
|
||||
5. `IngestChanges`; on success `TouchWebhookReceived` and return `202`. On
|
||||
transient ingest failure, `RecordWebhookError` (bounded message) and return
|
||||
`500` so arr retries — a duplicate later delivery is safe. Log only source
|
||||
id, provider, event type, and path count. **Never log the token, URL, or
|
||||
body.**
|
||||
|
||||
Admin handlers (same file, wired through `AutoscanHandler` deps):
|
||||
|
||||
- `POST /admin/autoscan/sources/{id}/webhook` → create-if-missing, return
|
||||
URL + status.
|
||||
- `POST /admin/autoscan/sources/{id}/webhook/rotate` → rotate, return new URL.
|
||||
- `DELETE /admin/autoscan/sources/{id}/webhook` → delete.
|
||||
- URL construction: `deps.PublicURL + "/api/v1/autoscan/webhooks/" + token`
|
||||
when `PublicURL` is set; otherwise return the relative path and let the UI
|
||||
prepend `window.location.origin`. Response field is `webhook_url` either way.
|
||||
|
||||
**`internal/api/handlers/autoscan.go`**
|
||||
|
||||
- `sourceResponse`: add `delivery_mode`, `webhook_configured`, `webhook_url`,
|
||||
`webhook_secret_suffix`, `webhook_last_received_at`, `webhook_last_error_at`,
|
||||
`webhook_last_error_message` (fetch endpoint rows for listed sources; a
|
||||
single batched query, not N+1).
|
||||
- `HandleCreateSource` / `HandleUpdateSource` validation:
|
||||
- `delivery_mode` defaults to `poll`; must be `poll` or `webhook`.
|
||||
- `webhook` is valid only for the built-in identity; `poll` is invalid for
|
||||
the built-in identity.
|
||||
- Webhook mode requires no connection; a bound connection is allowed
|
||||
(rewrite suggestions only).
|
||||
- `source_config.webhook_provider` ∈ {`sonarr`, `radarr`, `auto`} when set.
|
||||
- The installed-capability check passes for the built-in identity
|
||||
automatically because the composite lister supplies it — verify with a
|
||||
handler test rather than special-casing `scanSourceInstalled`.
|
||||
- Event list responses: add `delivery_mode` and `provider_event_type`.
|
||||
|
||||
**`internal/api/router.go`**
|
||||
|
||||
- Admin routes: add the three webhook-management routes next to the existing
|
||||
`/autoscan/sources` block.
|
||||
- Public route: register `r.Post("/autoscan/webhooks/{token}", ...)` alongside
|
||||
the other public routes (near the auth/discord-callback blocks), gated on
|
||||
`autoscanHandler != nil`.
|
||||
- Rate limiting: reuse `internal/ratelimit` — mirror the
|
||||
`Middleware.AuthEndpointHandler(endpoint)` per-IP pattern with a
|
||||
`"autoscan_webhook"` endpoint entry (config default on the generous side,
|
||||
e.g. 60/min burst 30; arr bursts are legitimate). Per-token limiting beyond
|
||||
that is unnecessary for v1 given the suppressor already collapses duplicate
|
||||
work; note this in the PR.
|
||||
|
||||
Handler tests (`internal/api/handlers`): status-code matrix from the spec's
|
||||
Testing section — 202 (ok / test / disabled / unknown event), 400, 404, 405
|
||||
(chi handles), 413, 429; rotation invalidates the old token; no token/body in
|
||||
logs (assert via a captured slog handler).
|
||||
|
||||
## Phase 7 — Frontend
|
||||
|
||||
- `web/src/api/types.ts`: extend `AutoscanSource` with the new response
|
||||
fields; add webhook endpoint response type.
|
||||
- `web/src/hooks/queries/useAutoscan.ts` (+ `keys.ts`): mutations for webhook
|
||||
create/rotate/delete; invalidate the sources query on success.
|
||||
- `web/src/pages/admin/autoscan/SourcesPanel.tsx`:
|
||||
- Delivery mode segmented control (`Poll` / `Webhook`) in the source
|
||||
create/edit form. Only offered when the picked capability is the built-in
|
||||
webhook identity (which forces webhook mode) or a plugin capability
|
||||
(which forces poll mode) — in practice the mode follows the picker choice;
|
||||
render the control as the mode indicator rather than a free toggle.
|
||||
- Webhook mode panel: provider select (`Auto`/`Sonarr`/`Radarr` →
|
||||
`source_config.webhook_provider`), webhook URL display with copy button
|
||||
(prepend `window.location.origin` when the API returns a relative path),
|
||||
rotate action with confirm, last-received / last-error status line.
|
||||
- Connection picker hidden in webhook mode (or shown as explicitly optional
|
||||
for rewrite suggestions); poll-interval input hidden.
|
||||
- Path-rewrite editor stays visible in both modes.
|
||||
- `web/src/pages/admin/autoscan/ActivityPanel.tsx`: show delivery mode and
|
||||
provider event type on event rows.
|
||||
- Follow existing panel patterns; 2-space/double-quote Prettier config.
|
||||
|
||||
## Phase 8 — Verification
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/autoscan/... ./internal/api/handlers
|
||||
GOWORK=off go test ./...
|
||||
cd web && pnpm run lint && pnpm run format:check && pnpm run build
|
||||
make lint
|
||||
make verify-local-paths
|
||||
```
|
||||
|
||||
Manual smoke (dev): create a webhook source, paste the URL into a Sonarr
|
||||
Connect → Webhook (On Import + On Rename + On File Delete), use Sonarr's
|
||||
"Test" button (expect 202 and `last_received_at` updating), import a file,
|
||||
confirm a webhook event row and a file-scope scan appear in the Activity
|
||||
panel.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- The `PollOnce` refactor is the riskiest step: marker-advancement semantics
|
||||
are subtle and documented inline — preserve the decision comments and rely
|
||||
on the existing `service_test.go` suite as the regression harness before
|
||||
adding webhook paths.
|
||||
- `secret_ref` uses `internal/secret.Cipher` with row-bound AAD; never rename
|
||||
the table/column in SQL later without a `db_loader`-style fallback
|
||||
(encryption is AAD-bound).
|
||||
- Keep `/api/v1` additive: every API change above is a new field, new
|
||||
endpoint, or new route; nothing existing is renamed or repurposed.
|
||||
- Client repos: none required for v1 — this is an admin-web-only surface; the
|
||||
public endpoint is consumed by arr, not by Silo clients.
|
||||
@@ -0,0 +1,477 @@
|
||||
# Plugin Hub and Approved Community Catalog Implementation Plan
|
||||
|
||||
> **For implementation:** Use the `executing-plans` skill and complete the
|
||||
> phases in dependency order. Commands assume the repository root is the cwd.
|
||||
|
||||
**Goal:** Replace the current raw plugin cards with an operator-focused Plugin
|
||||
Hub that explains what each plugin does, shows setup and release information,
|
||||
links to its source, and lets a server administrator opt into Silo-approved
|
||||
community plugins without manually managing repository URLs.
|
||||
|
||||
**Audience:** Server administrators and casual homelab operators. This is not an
|
||||
end-user plugin surface. Developer identifiers and repository details remain
|
||||
available, but they are secondary to plain-language purpose, setup, health, and
|
||||
update information.
|
||||
|
||||
**Approval meaning:** An **Approved community** plugin has been reviewed by Silo
|
||||
maintainers, validated to install and work as described, and considered safe for
|
||||
its documented use at the time of approval. It remains community-maintained;
|
||||
approval does not transfer maintenance or support ownership to the Silo core
|
||||
team and is not a permanent guarantee against future vulnerabilities.
|
||||
|
||||
**Architecture:** Add typed presentation fields to `PluginManifest`; keep
|
||||
version-specific GitHub release notes in catalog packages; create a separately
|
||||
owned `silo-community/silo-plugins` catalog with an allowlisted approval gate;
|
||||
and teach Silo to manage official, approved-community, and custom sources as
|
||||
different provenance classes. The admin UI consumes additive `/api/v1` fields,
|
||||
uses a default-off community-channel setting, and opens URL-addressable plugin
|
||||
details from the Installed and Catalog views.
|
||||
|
||||
**Delivery shape:** This is one feature with coordinated PRs across
|
||||
`silo-plugin-sdk`, `silo-plugins`, the new community catalog, `silo-server`, and
|
||||
the affected plugin repositories. Do not combine all repositories into one
|
||||
commit or PR.
|
||||
|
||||
---
|
||||
|
||||
## Product Decisions and Defaults
|
||||
|
||||
- The primary tabs remain **Installed** and **Catalog**.
|
||||
- The catalog contains Silo-maintained plugins by default.
|
||||
- **Include approved community plugins** is a server-wide setting and defaults
|
||||
to `false` for both fresh installs and upgrades.
|
||||
- Enabling the setting manages approved community catalog sources on the
|
||||
administrator's behalf; it does not ask for a GitHub URL.
|
||||
- Disabling the setting hides community catalog entries and pauses update
|
||||
discovery for community installations. It never disables or uninstalls an
|
||||
installed plugin.
|
||||
- When community installations exist, disabling requires confirmation and
|
||||
states how many plugins will stop receiving update discovery.
|
||||
- Manual catalog repositories and archive uploads remain available under
|
||||
**Manage sources → Advanced**.
|
||||
- Cards use **Silo maintained**, **Approved community**, and **External source**
|
||||
as the provenance labels. Do not use one ambiguous `Verified` badge.
|
||||
- The approval badge is catalog/host-derived. A plugin manifest cannot declare
|
||||
itself approved.
|
||||
- In-app release content is labeled **What's new**. The first implementation
|
||||
stores the latest release notes and links to the complete external changelog;
|
||||
it does not retain a full in-app version history.
|
||||
- The Plugin Hub is web-admin-only. No Android or Apple client changes are
|
||||
required.
|
||||
|
||||
## Explicitly Out of Scope
|
||||
|
||||
- An end-user plugin marketplace or plugin controls outside the admin UI.
|
||||
- Selecting and transferring the first batch of repositories into
|
||||
`silo-community`; that migration should follow this infrastructure plan with
|
||||
an explicit repository list.
|
||||
- Plugin rollback or installation of arbitrary historical versions.
|
||||
- Cryptographic artifact signing or reproducible-build attestation beyond the
|
||||
existing release checksum contract.
|
||||
- A remote kill switch. Removing a plugin from the approved catalog stops new
|
||||
installs and updates, but does not remotely stop already-installed code.
|
||||
- Automated proof that a plugin is safe. Approval includes human review and
|
||||
runtime validation.
|
||||
|
||||
---
|
||||
|
||||
## Verified Baseline
|
||||
|
||||
- `PluginManifest` has `metadata` and `category` but no plugin-level display
|
||||
name, summary, description, setup guide, publisher, or source links.
|
||||
`CapabilityDescriptor.description` exists but describes individual
|
||||
capabilities rather than the plugin as a whole.
|
||||
- `silo-plugins` already writes `repo_url` into each catalog package, but
|
||||
`silo-server/internal/plugins/catalog_service.go` does not decode or return
|
||||
it.
|
||||
- The catalog updater fetches the GitHub release tag and assets but does not
|
||||
preserve the release page URL, publication time, or release body.
|
||||
- The server seeds one official repository only when *no* repository rows
|
||||
exist. This is not sufficient for multiple managed channels or for adopting
|
||||
an existing installation that already has custom repositories.
|
||||
- Catalog fetches are live and unbounded, and a failing source is skipped. No
|
||||
last-known-good catalog payload is available to keep browsing and release
|
||||
notes usable during an outage.
|
||||
- Auto-update selection currently chooses the highest version for a plugin ID
|
||||
across all enabled repositories. Updates must instead stay pinned to the
|
||||
installation's repository so a custom source cannot shadow an official or
|
||||
approved plugin.
|
||||
- `AdminPlugins.tsx` is a single large page. Installed and available cards show
|
||||
raw plugin IDs, versions, capability chips, and operational controls but no
|
||||
plugin-level description, release notes, or source provenance.
|
||||
|
||||
---
|
||||
|
||||
## Public Contracts
|
||||
|
||||
### SDK manifest presentation
|
||||
|
||||
In `silo-plugin-sdk/proto/silo/plugin/v1/common.proto`, add a new additive
|
||||
message and field:
|
||||
|
||||
```text
|
||||
PluginPresentation presentation = 13;
|
||||
|
||||
PluginPresentation:
|
||||
display_name
|
||||
summary
|
||||
description_markdown
|
||||
setup_markdown
|
||||
homepage_url
|
||||
source_url
|
||||
support_url
|
||||
changelog_url
|
||||
publisher_name
|
||||
publisher_url
|
||||
license_spdx
|
||||
```
|
||||
|
||||
Contract rules:
|
||||
|
||||
- Existing manifests without `presentation` remain valid.
|
||||
- When `presentation` is present, validate lengths and accept only absolute
|
||||
`http` or `https` links. Reject control characters and unsafe schemes.
|
||||
- Recommended limits: `display_name` 120 characters, `summary` 240 characters,
|
||||
and each Markdown field 32 KiB.
|
||||
- SDK validation does not require presentation fields globally during the
|
||||
compatibility window. Official and approved-community catalog CI applies the
|
||||
stricter publishing requirement.
|
||||
- Markdown is CommonMark-style text. Raw HTML is not part of the contract.
|
||||
- `publisher_*` and source links are self-declared identity information; they
|
||||
never determine Silo approval.
|
||||
|
||||
Regenerate the Go protobuf output, update manifest fixtures and documentation,
|
||||
and publish a new additive SDK minor release before downstream repositories
|
||||
consume these fields.
|
||||
|
||||
### Catalog release and approval metadata
|
||||
|
||||
Extend the catalog package JSON shared by the official and community catalogs:
|
||||
|
||||
```text
|
||||
repo_url
|
||||
release:
|
||||
url
|
||||
published_at
|
||||
notes_markdown
|
||||
approval: # community catalog only
|
||||
approved_at
|
||||
review_url
|
||||
```
|
||||
|
||||
- `repo_url` remains the canonical source-code link generated from the GitHub
|
||||
repository that produced the release.
|
||||
- `release` is generated from the GitHub Releases API. Bound
|
||||
`notes_markdown` to 64 KiB before writing the catalog.
|
||||
- `approval` comes from the community catalog's reviewed allowlist, never from
|
||||
the plugin's manifest or release payload.
|
||||
- `changelog_url` remains in `PluginPresentation`; when absent, the web UI may
|
||||
link to the repository's Releases page derived from `repo_url`.
|
||||
- Preserve the complete protobuf manifest during catalog generation. Do not
|
||||
reduce it to a hand-built subset.
|
||||
|
||||
### Additive server API fields
|
||||
|
||||
Add `GET` and `PUT /api/v1/admin/plugins/catalog-settings`:
|
||||
|
||||
```text
|
||||
include_approved_community_plugins: boolean
|
||||
approved_community_plugin_count: number
|
||||
installed_community_plugin_count: number
|
||||
community_updates_paused: boolean
|
||||
```
|
||||
|
||||
The `PUT` body accepts only
|
||||
`include_approved_community_plugins`. It persists the preference, reconciles
|
||||
managed repository rows, and returns the new state. A source refresh failure
|
||||
does not roll the preference back; the repository status reports the failure
|
||||
and the catalog falls back to cached data when available.
|
||||
|
||||
Add `GET /api/v1/admin/plugins/capabilities` for feature detection. It reports
|
||||
support for typed presentation metadata, release notes, approved community
|
||||
catalogs, and last-known catalog caching.
|
||||
|
||||
Extend existing responses additively:
|
||||
|
||||
- `PluginRepository`: `source_kind`, `managed`, `last_fetch_error`,
|
||||
`last_fetch_error_at`.
|
||||
- `PluginCatalogEntry`: typed `presentation`, `source_kind`, repository display
|
||||
name, `repo_url`, `release`, optional `approval`, and `stale`.
|
||||
- `PluginInstallation`: typed `presentation`, source/repository provenance,
|
||||
the matching current or available release metadata when known, and
|
||||
`updates_paused`.
|
||||
|
||||
Keep existing `metadata`, capability, route, asset, and configuration fields
|
||||
unchanged for `/api/v1` compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — SDK Presentation Contract
|
||||
|
||||
Repository: `silo-plugin-sdk`
|
||||
|
||||
- [ ] Add `PluginPresentation` and field 13 to the protobuf contract; regenerate
|
||||
`pkg/pluginproto` with `make proto`.
|
||||
- [ ] Add reusable URL and length validation in
|
||||
`pkg/pluginsdk/manifest/manifest.go` without making presentation mandatory for
|
||||
older plugins.
|
||||
- [ ] Add decode/round-trip/validation tests for complete, partial, absent, and
|
||||
unsafe presentation blocks.
|
||||
- [ ] Document every presentation field and provide one complete example
|
||||
manifest for plugin authors.
|
||||
- [ ] Publish an additive SDK minor release and verify the tag is consumable
|
||||
from a clean downstream module.
|
||||
|
||||
Verification: `GOWORK=off go test ./...`, `make proto`, and a clean-tree check
|
||||
after regeneration.
|
||||
|
||||
## Phase 2 — Catalog Tooling and Community Approval Gate
|
||||
|
||||
Repositories: `Silo-Server/silo-plugins` and new
|
||||
`silo-community/silo-plugins`
|
||||
|
||||
- [ ] Extend the shared catalog generator's GitHub `Release` DTO and
|
||||
`CatalogPackage` to preserve source, publication, release-note, and approval
|
||||
fields.
|
||||
- [ ] Keep the update workflow's shared concurrency group so simultaneous
|
||||
plugin releases cannot race on `manifest.json`.
|
||||
- [ ] Add tests proving the generator preserves the full manifest, bounds
|
||||
release notes, derives safe URLs, and produces deterministic JSON.
|
||||
- [ ] Create `silo-community/silo-plugins` with the same catalog package and
|
||||
release-asset contract as the official catalog.
|
||||
- [ ] Add `approved-plugins.json` to the community catalog. Each active entry
|
||||
contains `plugin_id`, exact GitHub repository, `approved_at`, and a URL to the
|
||||
approval review/evidence.
|
||||
- [ ] Make community catalog updates reject releases whose repository or
|
||||
plugin ID is not active in `approved-plugins.json`.
|
||||
- [ ] Protect the community catalog's main branch and approval registry with
|
||||
CODEOWNERS/required review. Release dispatch alone must not grant approval.
|
||||
- [ ] Add a rebuild/check command that removes packages no longer present in
|
||||
the active allowlist so catalog membership cannot remain stale indefinitely.
|
||||
- [ ] Publish an approval policy documenting the minimum review: successful
|
||||
builds/tests, checksum-bearing release artifacts, manifest/setup accuracy,
|
||||
runtime validation on a supported Silo version, source/release-workflow
|
||||
review, license, support path, and no known unsafe behavior.
|
||||
|
||||
Verification: `GOWORK=off go test ./...`; run the updater against fixtures for
|
||||
an approved and rejected repository; run the catalog rebuild/check twice to
|
||||
prove idempotence.
|
||||
|
||||
## Phase 3 — Server Repository Provenance, Preference, and Cache
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Create a timestamped Goose migration with
|
||||
`make migrate-create NAME=plugin_catalog_provenance`.
|
||||
- [ ] Extend `plugin_repositories` with a nullable unique `managed_key`, a
|
||||
constrained `source_kind` (`silo`, `approved_community`, `external`), and
|
||||
last-fetch error fields. Existing rows default to `external` and the known
|
||||
official URL is adopted as `silo` during reconciliation.
|
||||
- [ ] Add a `plugin_catalog_cache` table keyed by repository, plugin ID, and
|
||||
version, storing the normalized catalog package JSON plus fetch time. Delete
|
||||
cache rows with the repository.
|
||||
- [ ] Add a plugin-owned setting key
|
||||
`plugins.include_approved_community_plugins`; missing/invalid values resolve
|
||||
to `false`.
|
||||
- [ ] Replace `seedDefaultRepository` with an idempotent managed-repository
|
||||
reconciler. It always adopts/upserts the official catalog and upserts
|
||||
`https://raw.githubusercontent.com/silo-community/silo-plugins/main/manifest.json`
|
||||
enabled according to the community setting. Structure the registry as a list
|
||||
so another approved community catalog can be added without a second toggle.
|
||||
- [ ] Managed repository URLs, names, keys, and provenance are read-only through
|
||||
manual repository CRUD. Custom repositories retain existing create/edit/delete
|
||||
behavior.
|
||||
- [ ] Bound repository-index downloads before JSON decoding. Validate and cache
|
||||
only accepted packages; on a successful refresh transactionally replace that
|
||||
repository's cache and clear its error state.
|
||||
- [ ] On a fetch failure, record a concise error and serve last-known cached
|
||||
entries with `stale=true`. An enabled source with no cache returns no entries
|
||||
but remains enabled for retry.
|
||||
- [ ] Resolve catalog duplicates deterministically by source precedence
|
||||
(`silo` → `approved_community` → `external`) before comparing versions within
|
||||
a source. A higher-version custom package must never shadow the same official
|
||||
or approved plugin ID.
|
||||
- [ ] Make update discovery repository-pinned: installations with a
|
||||
`repository_id` compare only against that repository's entries. Direct uploads
|
||||
without a repository do not gain catalog updates accidentally.
|
||||
- [ ] When a source is disabled, retain `available_version` but return
|
||||
`updates_paused=true`; applying the update is blocked until the source is
|
||||
enabled again.
|
||||
- [ ] Enrich installed responses from the on-disk manifest first and matching
|
||||
cached catalog metadata second, so descriptions and source links survive a
|
||||
network outage and release notes remain available after the community channel
|
||||
is disabled.
|
||||
|
||||
Tests:
|
||||
|
||||
- managed-source reconciliation is idempotent and adopts an existing official
|
||||
row without duplication;
|
||||
- the community preference defaults off and toggles all managed community
|
||||
sources without touching custom rows;
|
||||
- stale cache is served after fetch failure and replaced after recovery;
|
||||
- official/community/external duplicate precedence is deterministic;
|
||||
- update discovery cannot switch an installation to another repository;
|
||||
- disabling the community channel leaves installations enabled and marks their
|
||||
updates paused;
|
||||
- migration Up/Down and repository constraints behave correctly against the
|
||||
test database.
|
||||
|
||||
## Phase 4 — Server HTTP API
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Add typed catalog-settings and capability handlers to
|
||||
`internal/api/handlers/plugins.go` or focused files in the same handler
|
||||
package; mount them under the existing acting-admin plugin route group.
|
||||
- [ ] Extend repository, catalog, and installation serializers with the
|
||||
additive presentation, release, provenance, approval, stale, and paused
|
||||
fields.
|
||||
- [ ] Compute `installed_community_plugin_count` by repository provenance, not
|
||||
by parsing plugin IDs or GitHub URLs.
|
||||
- [ ] Validate the `PUT` body strictly and make preference persistence plus
|
||||
managed-row reconciliation atomic from the caller's perspective.
|
||||
- [ ] Keep catalog source failures per-repository: one broken community or
|
||||
custom source must not fail the entire catalog response.
|
||||
- [ ] Add handler tests for default-off state, enable/disable, malformed input,
|
||||
community installation counts, additive response fields, stale-source
|
||||
reporting, and acting-admin authorization.
|
||||
|
||||
Verification:
|
||||
`GOWORK=off go test ./internal/plugins/... ./internal/api/handlers/...`.
|
||||
|
||||
## Phase 5 — Plugin Hub Web UI
|
||||
|
||||
Repository: `silo-server`
|
||||
|
||||
- [ ] Split `AdminPlugins.tsx` into focused components under
|
||||
`web/src/components/admin/plugins/` or `web/src/pages/admin-plugins/`: page
|
||||
shell, Installed list, Catalog grid, catalog controls, detail sheet,
|
||||
configuration content, Markdown renderer, and advanced source manager.
|
||||
- [ ] Add typed DTOs and TanStack Query hooks for plugin capabilities and
|
||||
catalog settings. Mutations invalidate settings, repositories, catalog, and
|
||||
installations together.
|
||||
- [ ] Keep **Installed** as the default tab and rename **Available** to
|
||||
**Catalog**.
|
||||
- [ ] Place **Include approved community plugins** in the Catalog header with
|
||||
this helper meaning: these plugins are validated by Silo to work as described
|
||||
and are considered safe, but are maintained and supported by community
|
||||
contributors.
|
||||
- [ ] Enabling shows a refresh state and then community entries. Disabling with
|
||||
installed community plugins opens a confirmation that reports the count and
|
||||
explains that plugins keep running while update discovery pauses.
|
||||
- [ ] Add search plus provenance/capability filters. Preserve the active tab,
|
||||
search, and filters in URL search parameters.
|
||||
- [ ] Catalog cards show display name, publisher/provenance, two-line summary,
|
||||
capabilities, version, configuration requirement, and Install. Installed
|
||||
rows show operational status, summary, version, Configure/Open, and
|
||||
Review update; move update policy, disable, and uninstall into secondary
|
||||
controls.
|
||||
- [ ] Use URL search parameters for addressable details:
|
||||
`installation=<id>` for installed plugins and
|
||||
`repository=<id>&plugin=<plugin_id>` for catalog entries. Browser Back closes
|
||||
the sheet and focus returns to the originating card. Use a full-width sheet
|
||||
on small screens.
|
||||
- [ ] Detail sections are **About**, **Setup**, **What's new**, and
|
||||
**Technical details**. Technical details include plugin ID, API/platform
|
||||
compatibility, publisher, license, source catalog, source repository, support,
|
||||
and changelog links.
|
||||
- [ ] Change update behavior from immediate `Update` to `Review update`; show
|
||||
the target release notes before the administrator confirms the update.
|
||||
- [ ] Add a constrained Markdown renderer. Raw HTML and images are disabled;
|
||||
only safe `http`/`https` links are rendered, and external links use
|
||||
`rel="noopener noreferrer"`.
|
||||
- [ ] Move repository CRUD and archive upload beneath **Manage sources →
|
||||
Advanced**. Managed official/community sources show status and fetch errors
|
||||
but cannot have their system URL edited or be deleted.
|
||||
- [ ] Backward-compatible UI fallbacks: humanize `plugin_id` when display name
|
||||
is absent; use the first capability description when summary is absent; show
|
||||
“No setup guide provided” or “No release notes were published” rather than an
|
||||
empty panel.
|
||||
- [ ] Add accessible labels, keyboard navigation, focus management, loading
|
||||
skeletons, empty states, and per-source stale/error messaging.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- default-off toggle and opt-in catalog refresh;
|
||||
- disable confirmation with installed community plugins;
|
||||
- provenance filters and badges;
|
||||
- legacy-manifest fallback rendering;
|
||||
- detail deep link and Back behavior;
|
||||
- safe Markdown links with raw HTML/images suppressed;
|
||||
- Review update requires confirmation and displays release notes;
|
||||
- stale catalog and missing release-note states.
|
||||
|
||||
Verification: `cd web && pnpm run lint`, `cd web && pnpm run format:check`,
|
||||
`cd web && pnpm test`, and `cd web && pnpm run build`. Capture desktop and
|
||||
mobile screenshots for the UI PR.
|
||||
|
||||
## Phase 6 — Manifest Backfill and Community Migration Readiness
|
||||
|
||||
Repositories: current first-party plugins and future community plugin repos
|
||||
|
||||
- [ ] Update every cataloged plugin to the released SDK version and add complete
|
||||
presentation metadata. Use plain language for homelab operators; capability
|
||||
descriptions remain technical and specific.
|
||||
- [ ] Require each repository to publish meaningful GitHub release notes so
|
||||
What's new is not an empty surface.
|
||||
- [ ] Standardize README setup instructions, license, security/support policy,
|
||||
CODEOWNERS or named maintainers, and the existing checksum-bearing release
|
||||
workflow.
|
||||
- [ ] Before transferring a selected plugin to `silo-community`, update its
|
||||
manifest publisher/source links and release dispatch target, add it to the
|
||||
community approval registry through review, and remove it from the official
|
||||
catalog in the same rollout window.
|
||||
- [ ] Verify a transferred plugin appears only once, installs from the community
|
||||
repository, remains repository-pinned for updates, and is hidden for admins
|
||||
who have not enabled the community channel.
|
||||
|
||||
---
|
||||
|
||||
## Rollout Order
|
||||
|
||||
1. Merge and release the SDK contract.
|
||||
2. Upgrade official catalog tooling and create the approved community catalog
|
||||
plus approval policy/registry.
|
||||
3. Land the server migration, managed-source reconciliation, cache, update
|
||||
pinning, and additive APIs.
|
||||
4. Land the Plugin Hub UI and keep the community switch default-off.
|
||||
5. Backfill presentation data and release notes in existing official plugins.
|
||||
6. Transfer selected plugins one at a time only after both catalogs and the
|
||||
deployed server understand provenance.
|
||||
|
||||
Older servers ignore new catalog/manifest fields; newer servers retain fallbacks
|
||||
for older manifests. Do not transfer a plugin out of the official catalog before
|
||||
the community catalog and server opt-in path are deployed.
|
||||
|
||||
## Final Acceptance Scenarios
|
||||
|
||||
- A fresh server shows Silo-maintained catalog entries with friendly names,
|
||||
descriptions, setup instructions, source links, and release notes; approved
|
||||
community plugins are absent.
|
||||
- An admin enables **Include approved community plugins**, sees the approved
|
||||
entries without entering a URL, and can identify their community maintenance
|
||||
and Silo approval clearly.
|
||||
- A community catalog outage leaves cached entries visible with a stale warning
|
||||
and does not break the official catalog.
|
||||
- Disabling the community channel with installed community plugins requires
|
||||
confirmation, leaves those plugins running, and clearly reports paused update
|
||||
discovery.
|
||||
- A custom repository publishing the same plugin ID or a higher version cannot
|
||||
replace or update an official/approved installation.
|
||||
- Review update presents the exact target release notes before installation.
|
||||
- An old plugin without presentation fields remains manageable with readable
|
||||
fallbacks.
|
||||
- Removing a plugin from the approved catalog stops new discovery and updates
|
||||
without remotely disabling existing installations.
|
||||
|
||||
## Cross-Repository Verification Gate
|
||||
|
||||
- `silo-plugin-sdk`: `GOWORK=off go test ./...` and `make proto`.
|
||||
- Official and community catalogs: `GOWORK=off go test ./...` plus catalog
|
||||
rebuild/check idempotence.
|
||||
- `silo-server`: focused plugin/API Go tests, web lint/format/test/build,
|
||||
`make lint`, and `make verify-local-paths`.
|
||||
- End-to-end dev smoke: toggle community off/on, install one approved plugin,
|
||||
configure it, simulate a catalog outage, review an update, disable the
|
||||
community channel, and confirm the installed plugin continues running while
|
||||
updates are paused.
|
||||
@@ -0,0 +1,822 @@
|
||||
# Playback Protocol v3 Server Implementation Plan
|
||||
|
||||
**Status:** Proposed
|
||||
**Scope:** `silo-server`, with coordinated contract fixtures in `silo-android`
|
||||
**Protocol owner:** Silo server
|
||||
**Client target:** Android Media3-only playback; legacy web and Apple playback remain supported
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Implement the server half of Silo playback protocol v3 so a client can report
|
||||
its current device and output capabilities and receive one complete executable
|
||||
plan for:
|
||||
|
||||
- authenticated original HTTP playback;
|
||||
- progressive or HLS remux;
|
||||
- video-copy with audio adaptation;
|
||||
- HLS video transcode;
|
||||
- subtitle render, conversion, or burn-in; or
|
||||
- a terminal `adaptation_unavailable` outcome.
|
||||
|
||||
The client must never choose FFmpeg recipes. The server must never claim that a
|
||||
route preserves Dolby Vision, HDR, lossless audio, or subtitle fidelity unless
|
||||
the source metadata, client/output capability, selected recipe, and installed
|
||||
tooling support that claim.
|
||||
|
||||
The normative behavior remains in the sibling Android repository:
|
||||
|
||||
- `silo-android/docs/playback/01-media3-only-player-architecture.md`
|
||||
- `silo-android/docs/playback/02-migration-compatibility-validation.md`
|
||||
- `silo-android/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt`
|
||||
|
||||
This document maps that contract onto the current server. It does not redefine
|
||||
the protocol.
|
||||
|
||||
## 2. Verified server baseline
|
||||
|
||||
The implementation should reuse the current playback stack rather than replace
|
||||
it:
|
||||
|
||||
| Existing behavior | Current owner | v3 implication |
|
||||
| --- | --- | --- |
|
||||
| Legacy start, authorization, profile checks, track preferences, session creation | `internal/api/handlers/playback.go` | Preserve the legacy branch byte-for-byte where possible; move shared operations behind helpers. |
|
||||
| Direct/remux/transcode choice | `internal/playback/resolver.go` | Replace its broad codec/container booleans with a v3 planner for v3 requests only. Do not change legacy decisions during the first rollout. |
|
||||
| Byte-range original delivery | `internal/playback/directplay.go`, `internal/api/handlers/stream.go` | Reuse as `original_http`. |
|
||||
| Progressive MP4 remux and audio-to-AAC | `internal/playback/remux.go`, `internal/api/handlers/stream.go` | Reuse as `server_remux_progressive`; make every transformation explicit in the plan. |
|
||||
| HLS copy/transcode, timelines, restart/reconstruct | `internal/playback/transcode.go`, `internal/playback/transcode_manager.go` | Reuse as `server_remux_hls` or `server_transcode_hls`. |
|
||||
| Local and remote transcode start | `HandleStartTranscode`, `internal/transcodenode/server.go`, `internal/nodepool` | Extract a server-callable starter so v3 can return the final manifest without a second client recipe request. |
|
||||
| Playback sessions, policy and limits | `internal/playback/session.go` | Extend session state with v3 attempt/plan identity; keep current admission controls authoritative. |
|
||||
| Source video/audio/subtitle metadata | `internal/models/media.go`, `internal/scanner/probe.go` | Most required fields already exist, including DV profile, BL compatibility ID, EL presence, color/range, bit depth, layout, and channels. MEL/FEL classification and transformation provenance are not yet represented. |
|
||||
| Subtitle extract/conversion/burn-in | `internal/playback/subtitles.go`, `subtitle_stream.go`, transcode arguments | Reuse the executors; add an explicit policy decision and artifact contract. |
|
||||
| Restart-safe signed stream recipes | `internal/playback/recipecard.go`, `internal/streamtoken` | Continue using signed recipe tokens; v3 plans describe the resulting URL and timeline. |
|
||||
|
||||
The largest correctness gaps in the current resolver are expected and must not
|
||||
be carried into v3:
|
||||
|
||||
- transcode-disabled incompatibility currently falls back to attempting direct
|
||||
playback instead of returning a terminal result;
|
||||
- codec support is modeled as a string union, without profile, level, bit depth,
|
||||
frame rate, range, sink layout, or subtitle-fidelity validation;
|
||||
- clients currently supply transcode codec, bitrate, segment, and burn-in
|
||||
recipe details through `/playback/transcode/start`;
|
||||
- the start response does not contain an authoritative effective recipe,
|
||||
validation claims, transformations, or timeline for every delivery;
|
||||
- no idempotent replan or attempt-scoped route-event API exists.
|
||||
|
||||
## 3. Architecture decision
|
||||
|
||||
### 3.1 Additive dispatch on the existing start endpoint
|
||||
|
||||
Keep `POST /api/v1/playback/start` for compatibility. Refactor its handler into
|
||||
a small protocol dispatcher:
|
||||
|
||||
1. Decode only an envelope containing `protocol_version`.
|
||||
2. When it equals `3`, decode strictly as `PlaybackStartRequestV3` and call the
|
||||
v3 orchestration service.
|
||||
3. When it is absent or not `3`, pass the original body to the existing legacy
|
||||
decoder and behavior.
|
||||
|
||||
Use buffered request bytes or `json.RawMessage`; never decode the body twice
|
||||
from the socket. Unknown fields remain tolerated for additive compatibility,
|
||||
but required v3 fields and closed enum values are validated explicitly.
|
||||
|
||||
Do not overwrite the existing request/response structs with a union of legacy
|
||||
and v3 fields. That would make missing nested capabilities silently look like a
|
||||
valid legacy request and recreate the current false-direct failure mode.
|
||||
|
||||
### 3.2 Introduce one v3 orchestration service
|
||||
|
||||
Add a service in the `internal/playback` domain that owns:
|
||||
|
||||
- input normalization and validation;
|
||||
- stable track resolution;
|
||||
- source and capability evaluation;
|
||||
- ordered candidate construction;
|
||||
- policy and capacity checks;
|
||||
- deterministic plan identity and loop prevention;
|
||||
- transport startup;
|
||||
- terminal outcome mapping;
|
||||
- replan idempotency; and
|
||||
- plan/session state transitions.
|
||||
|
||||
HTTP handlers authenticate, authorize, decode, call the service, and encode the
|
||||
result. They do not contain route-selection rules.
|
||||
|
||||
Recommended files:
|
||||
|
||||
```text
|
||||
internal/playback/protocol_v3.go wire/domain types and enums
|
||||
internal/playback/plan_v3.go candidate planner
|
||||
internal/playback/capabilities_v3.go source/capability validators
|
||||
internal/playback/recipe_v3.go normalized executable recipes
|
||||
internal/playback/plan_key_v3.go canonical IDs and attempt keys
|
||||
internal/playback/tracks_v3.go stable track identities/remapping
|
||||
internal/playback/subtitle_policy_v3.go subtitle decision policy
|
||||
internal/playback/transformations_v3.go installed-tool/recipe registry
|
||||
internal/playback/protocol_v3_test.go
|
||||
internal/playback/testdata/protocol_v3/ golden contract/planner fixtures
|
||||
|
||||
internal/playback/planstore/store.go persistence interfaces
|
||||
internal/playback/planstore/postgres.go attempts, replans and route events
|
||||
|
||||
internal/api/handlers/playback_v3.go start/replan/event handlers
|
||||
internal/api/handlers/playback_transport.go reusable transport starter
|
||||
internal/api/handlers/playback_v3_test.go
|
||||
```
|
||||
|
||||
If implementation reveals that the wire and domain structs need different
|
||||
types, keep JSON structs in `handlers` and convert at the boundary. Do not add
|
||||
JSON tags throughout unrelated session and FFmpeg types merely to save mapping
|
||||
code.
|
||||
|
||||
### 3.3 Reconcile additive client-contract gaps before freezing v3
|
||||
|
||||
The current Kotlin v3 types are sufficient to negotiate and execute a plan,
|
||||
but they do not yet carry every fact the normative direct-play rules require.
|
||||
The server cannot manufacture those facts. Phase 0 therefore includes a
|
||||
coordinated additive Android contract change:
|
||||
|
||||
| Current gap | Required additive contract |
|
||||
| --- | --- |
|
||||
| Video capability is a codec-name list plus a global maximum resolution. | Add per-codec decode entries with supported profiles/levels, bit depths, maximum width/height/frame rate/bitrate, and hardware/software status. |
|
||||
| Audio passthrough is a codec-name list plus one global maximum channel count. | Add per-codec supported channel counts/layouts; retain the old fields for tolerant readers. |
|
||||
| A plan does not expose requested versus effective media file when alternate-version selection occurs. | Add requested/effective media file IDs and a normalized source descriptor to the plan. |
|
||||
| Subtitle decision does not expose the effective fidelity policy. | Add `subtitle_fidelity_policy` beside mode/artifact. |
|
||||
| Route events cannot explicitly report replan identity, local PCM recovery, retry outcome, decoder timing, or requested/effective quality. | Add optional structured fields or a versioned, bounded diagnostic schema; the server enriches delivery/recipe/source facts from stored plan state rather than trusting duplicates from the client. |
|
||||
| Android's profile-quality canonicalizer passes unknown stored labels through and does not produce `original`. | Canonicalize the closed v3 values and aliases client-side; keep server normalization tolerant for rolling upgrades. |
|
||||
|
||||
Gate the detailed behavior with additive client feature tokens such as
|
||||
`detailed_decode_capabilities`, `layout_aware_passthrough`, and
|
||||
`playback_route_diagnostics`. A v3 client missing them remains parseable, but
|
||||
unknown profile/layout facts cannot satisfy a strict direct claim. The planner
|
||||
must choose a conservative adapted route or terminal result.
|
||||
|
||||
Freeze the exact JSON field names only after Go/Kotlin golden fixtures exist.
|
||||
Do not implement a server-only guess and call the contract complete.
|
||||
|
||||
The response engine mapping is fixed:
|
||||
|
||||
| Delivery | `engine` |
|
||||
| --- | --- |
|
||||
| `original_http` | `media3_direct` |
|
||||
| `server_remux_progressive` | `media3_progressive_remux` |
|
||||
| `server_remux_hls` | `media3_hls` |
|
||||
| `server_transcode_hls` | `media3_hls` |
|
||||
|
||||
Android currently accepts header refresh modes `none` and `session`; do not
|
||||
emit `refresh_endpoint` until the client implements it.
|
||||
|
||||
Never emit legacy/client-owned runtime values in a v3 plan:
|
||||
`mpv_direct`, `client_local_loopback`, `external_player`, or
|
||||
`client_local_normalization`. They remain decode-only compatibility values on
|
||||
Android.
|
||||
|
||||
### 3.4 Keep v3 dark until the complete route is executable
|
||||
|
||||
Add a dynamic server setting `playback.protocol_v3_enabled`, default `false`.
|
||||
Read it through `PlaybackSettingsReader.Get` on each capability/start request,
|
||||
as the existing `allow_4k_transcode` path does. Do not load it only through
|
||||
`internal/config/db_loader.go`; that would turn rollback into a restart-required
|
||||
change.
|
||||
Also add `GET /api/v1/playback/capability` because `/api/v1` features use
|
||||
capability detection rather than server-version sniffing.
|
||||
|
||||
When a v3 start arrives while disabled, return a successful negotiation
|
||||
response with no allocated session and without `playback_plan_v3` in
|
||||
`server_features`. This intentionally triggers Android's
|
||||
`server_upgrade_required` path without leaking a legacy session.
|
||||
|
||||
The capability response should contain:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"protocol_versions": [3],
|
||||
"features": [],
|
||||
"deliveries": [],
|
||||
"transformations": [],
|
||||
"reason": "disabled"
|
||||
}
|
||||
```
|
||||
|
||||
Only advertise `playback_plan_v3` after every enabled delivery and
|
||||
transformation passes Phase 0 fixtures on the deployed build.
|
||||
|
||||
## 4. HTTP contract and ownership
|
||||
|
||||
### 4.1 Endpoints
|
||||
|
||||
| Endpoint | Success | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/playback/capability` | `200` | Feature/protocol and installed transformation discovery. |
|
||||
| `POST /api/v1/playback/start` | `201` | Authorize source, create a session, select/start a route, return playable or terminal v3 response. |
|
||||
| `POST /api/v1/playback/{session_id}/replan` | `200` | Idempotently replace the active plan at the supplied media position. |
|
||||
| `POST /api/v1/playback/route-events` | `202` | Validate and enqueue attempt-scoped diagnostics. |
|
||||
|
||||
All four routes use normal account authentication. Start, replan, and route
|
||||
events require profile auth. `X-Profile-Id` remains authoritative; a body
|
||||
`profile_id` must match it. Replan additionally verifies session ownership and
|
||||
that `playback_attempt_id` belongs to the session.
|
||||
|
||||
Playable and `adaptation_unavailable` are protocol outcomes, not HTTP errors.
|
||||
Use HTTP errors only for transport/API failures such as malformed input,
|
||||
unauthorized access, missing files, or an idempotency conflict.
|
||||
|
||||
### 4.2 Strict request validation
|
||||
|
||||
Reject before allocating a session when any of these fail:
|
||||
|
||||
- protocol is not exactly `3`;
|
||||
- `playback_attempt_id` is absent or not a bounded UUID/ULID-like identifier;
|
||||
- file/profile authorization fails;
|
||||
- track ID and fallback index disagree;
|
||||
- output-route generation is negative or disagrees with the nested output
|
||||
context;
|
||||
- codec/container/feature lists exceed bounded counts or string lengths;
|
||||
- bandwidth, bitrate, resolution, attempt count, or position is outside sane
|
||||
bounds; or
|
||||
- a replan's failed plan is not the session's active plan.
|
||||
|
||||
Normalize codecs, containers, layouts, and dynamic-range labels once at the
|
||||
boundary. Accept bounded known quality aliases such as `4k`; fold an unknown
|
||||
rolling-upgrade quality value to `auto` with a decision warning instead of
|
||||
hard-failing playback. Preserve the original values only in bounded
|
||||
diagnostics.
|
||||
|
||||
### 4.3 Complete response invariant
|
||||
|
||||
A `playable` response is valid only after its transport can be fetched or has
|
||||
successfully entered its startup state. It includes:
|
||||
|
||||
- protocol and feature negotiation;
|
||||
- stable `session_id` and deterministic `plan_id`;
|
||||
- delivery and Media3 engine;
|
||||
- final URL, stream protocol, container/MIME, scoped headers, and supported
|
||||
header-refresh mode;
|
||||
- exact player/source timeline mapping and seek window;
|
||||
- selected stable audio/subtitle identities;
|
||||
- effective codec, range, dimensions, frame rate, bitrate, channels and layout;
|
||||
- route validation claims;
|
||||
- subtitle mode/artifact;
|
||||
- named transformations and degradation warnings; and
|
||||
- a stable decision reason code.
|
||||
|
||||
Never return a placeholder transcode URL and expect v3 Android to call the
|
||||
legacy transcode-start endpoint.
|
||||
|
||||
## 5. Domain invariants
|
||||
|
||||
### 5.1 Stable tracks
|
||||
|
||||
Use the current Android-compatible identity initially:
|
||||
|
||||
```text
|
||||
file:{media_file_id}:audio:{ffmpeg_audio_ordinal}
|
||||
file:{media_file_id}:subtitle:{combined_subtitle_ordinal}
|
||||
```
|
||||
|
||||
Freeze the combined subtitle ordering used by `buildSubtitleURLs`:
|
||||
|
||||
1. external subtitles in stored order, starting at zero;
|
||||
2. embedded subtitles at `len(external) + embedded_ordinal`; skipped legacy
|
||||
bitmap entries retain their ordinal hole; and
|
||||
3. downloaded subtitles in repository `created_at` order at
|
||||
`len(external) + len(all_embedded) + downloaded_ordinal`.
|
||||
|
||||
The identity is paired with the effective media file ID. Centralize generation,
|
||||
parsing, URL mapping, and alternate-version remapping in `tracks_v3.go`, and
|
||||
freeze the exact inventory in Phase 0 fixtures. A plan that switches file
|
||||
version must return the effective file's identity after matching the requested
|
||||
track by signature. Do not echo a requested-file track ID with an
|
||||
effective-file index.
|
||||
|
||||
Longer term, scanner-persisted immutable stream IDs can replace ordinal IDs in a
|
||||
future protocol version; do not change v3 identity semantics after release.
|
||||
|
||||
### 5.2 Deterministic plan IDs and loop prevention
|
||||
|
||||
The Android attempt key currently includes `plan_id`. Therefore a random plan
|
||||
ID would let the same failed recipe reappear under a new key and defeat loop
|
||||
prevention.
|
||||
|
||||
Derive `plan_id` deterministically within a playback attempt from:
|
||||
|
||||
- `playback_attempt_id`;
|
||||
- effective file ID;
|
||||
- delivery and stream protocol/container;
|
||||
- normalized video/audio recipe;
|
||||
- effective track IDs;
|
||||
- subtitle mode/artifact type;
|
||||
- sorted transformation names; and
|
||||
- policy/recipe version.
|
||||
|
||||
Do not include expiring URLs, JWTs, timestamps, node hostnames, or the output
|
||||
route generation in `plan_id`. The same effective recipe has the same plan ID;
|
||||
an output-route change remains distinguishable because Android includes
|
||||
`output_route_generation` in its attempt key.
|
||||
|
||||
Implement the client's FNV-1a 64-bit `v3:<16-lowercase-hex>` attempt-key
|
||||
algorithm in Go. The UTF-8 canonical string is exactly:
|
||||
|
||||
```text
|
||||
plan_id
|
||||
|KOTLIN_DELIVERY_ENUM_NAME
|
||||
|KOTLIN_STREAM_PROTOCOL_ENUM_NAME
|
||||
|lowercase_container_or_empty
|
||||
|lowercase_video_codec_or_empty
|
||||
|lowercase_audio_codec_or_empty
|
||||
|(width_or_0)x(height_or_0)
|
||||
|bitrate_kbps_or_0
|
||||
|lowercase_dynamic_range_or_empty
|
||||
|KOTLIN_SUBTITLE_MODE_ENUM_NAME
|
||||
|comma_joined_transformation_names_sorted_by_name
|
||||
|output_route_generation
|
||||
|comma_joined_local_mutations_sorted_lexically
|
||||
```
|
||||
|
||||
The newlines above are explanatory only; the hashed value is one string joined
|
||||
by literal `|` characters. Enum components are Kotlin constant names such as
|
||||
`ORIGINAL_HTTP`, `HTTP_PROGRESSIVE`, `HLS`, and `BURN_IN`, not lowercase wire
|
||||
tokens. Hash with offset basis `0xcbf29ce484222325` and prime
|
||||
`0x100000001b3`, then zero-pad to 16 lowercase hex digits.
|
||||
|
||||
Before committing a candidate, compute its base key for the request's output
|
||||
route and reject it when present in `attempted_plan_keys`. Canonical fixtures
|
||||
must be generated by the checked-in Kotlin implementation and consumed as
|
||||
opaque expected values by Go tests; a Go-generated fixture would make the
|
||||
parity test circular. Include transformation-order, empty/default-field,
|
||||
output-route, and local PCM-mutation cases.
|
||||
|
||||
Server-side ordered candidate IDs are not security tokens.
|
||||
|
||||
### 5.3 Session and replacement semantics
|
||||
|
||||
Treat replan as one replacement transaction, serialized with the per-session
|
||||
lifecycle lock:
|
||||
|
||||
1. validate policy, source, candidate and tooling without touching the active
|
||||
route;
|
||||
2. reserve the active session's existing stream/transcode capacity slot so the
|
||||
replacement is neither double-counted nor denied by its own predecessor;
|
||||
3. start the successor in a plan-scoped staging directory/transport generation,
|
||||
or under an explicitly replacement-linked new session ID;
|
||||
4. wait until the successor URL is fetchable or its startup state is validated;
|
||||
5. atomically commit effective file, tracks, recipe, plan ID, timeline and
|
||||
signed route;
|
||||
6. close and reap the predecessor; and
|
||||
7. release or transfer the capacity reservation exactly once.
|
||||
|
||||
If any pre-commit step fails, leave the old route and session usable and return
|
||||
a typed terminal/retryable result. Never run two FFmpeg writers against the
|
||||
same output directory. Progressive request-scoped transports may drain until
|
||||
the old client request disconnects; they do not require a destructive
|
||||
close-first swap.
|
||||
|
||||
Prefer retaining the same logical playback session so progress, scrobble and
|
||||
admin history remain continuous. If the transport design requires a new public
|
||||
session ID, return it explicitly and keep the old ID alive until the successor
|
||||
is ready. Android will stop the old ID afterward and ignore stop failures. Keep
|
||||
the legacy stop endpoint's behavior unchanged; internal v3 replacement and
|
||||
cleanup paths must treat an already-stopped session as success even though the
|
||||
current `SessionManager.StopSession` returns `ErrSessionNotFound`.
|
||||
|
||||
### 5.4 Replan idempotency
|
||||
|
||||
Create a timestamped Goose migration, generated with:
|
||||
|
||||
```text
|
||||
make migrate-create NAME=add_playback_protocol_v3
|
||||
```
|
||||
|
||||
Add short-lived persistence for:
|
||||
|
||||
- playback attempt/session/current-plan state;
|
||||
- `(session_id, replan_request_id)` request digest, state, lease and serialized
|
||||
response; and
|
||||
- route events.
|
||||
|
||||
Current native restart reconstruction is carried by signed stream tokens, not
|
||||
a durable plan database. This plan store is the first persisted v3 control-
|
||||
plane state; it complements rather than replaces recipe tokens and therefore
|
||||
needs explicit expiry/cleanup and token-to-plan reconciliation tests.
|
||||
|
||||
For a duplicate replan ID:
|
||||
|
||||
- same digest and completed state returns the cached response;
|
||||
- same digest and active lease waits on/single-flights the owner, then returns
|
||||
the response;
|
||||
- different digest returns `409 idempotency_key_reused`; and
|
||||
- an expired lease may be reclaimed after reconciling the session's current
|
||||
recipe/plan state.
|
||||
|
||||
Store only the minimum response needed for replay and expire it with the
|
||||
playback attempt. Never put source paths or authorization headers in route
|
||||
events. Signed URLs in cached responses inherit the attempt TTL and are removed
|
||||
by cleanup.
|
||||
|
||||
## 6. Planner rules
|
||||
|
||||
### 6.1 Candidate order
|
||||
|
||||
Build candidates in least-destructive order, but validate every candidate
|
||||
independently:
|
||||
|
||||
1. `original_http`;
|
||||
2. `server_remux_progressive` when the container alone is incompatible and
|
||||
progressive seek semantics are acceptable;
|
||||
3. `server_remux_hls` when HLS packaging is required;
|
||||
4. `server_remux_hls` with copied video plus audio adaptation, with the audio
|
||||
change named in the effective recipe, transformations, claims and warnings;
|
||||
5. `server_transcode_hls`; and
|
||||
6. terminal `adaptation_unavailable`.
|
||||
|
||||
Remux is not mandatory before transcode. Skip directly to the first valid
|
||||
candidate. A selected subtitle may independently add an artifact or force
|
||||
burn-in.
|
||||
|
||||
### 6.2 Direct eligibility
|
||||
|
||||
Original delivery requires all of:
|
||||
|
||||
- validated source file and byte-range path;
|
||||
- client container extractor support;
|
||||
- selected video codec/profile/level/bit depth/resolution/frame rate/bitrate;
|
||||
- compatible source dynamic range and current display output;
|
||||
- selected audio local decode or exact sink passthrough by codec and channel
|
||||
layout;
|
||||
- renderable selected subtitle at the required fidelity, or an independent
|
||||
converted artifact; and
|
||||
- no requested quality reduction.
|
||||
|
||||
Missing metadata is `unknown`, not compatible. A probe repair may run before
|
||||
planning, but the planner must terminal or adapt when required fields remain
|
||||
unknown.
|
||||
|
||||
### 6.3 Quality policy
|
||||
|
||||
Move target selection out of clients. Add a pure `QualityPolicy` that maps:
|
||||
|
||||
- `original` to no user-requested reduction, while still permitting mandatory
|
||||
compatibility adaptation;
|
||||
- a fixed rung to a maximum height with no upscaling; and
|
||||
- `auto` to the lowest of device maximum, bandwidth estimate with safety
|
||||
margin, user cap, metered policy, and administrator constraints.
|
||||
|
||||
Start with one server-owned ladder. Reconcile it with the existing web constants
|
||||
in `web/src/player/hooks/useTranscodeQuality.ts`; do not maintain two unrelated
|
||||
bitrate tables. The v3 result returns the actual height/bitrate and decision
|
||||
reason. Multi-variant ABR is explicitly outside this work.
|
||||
|
||||
### 6.4 Audio
|
||||
|
||||
Evaluate the selected track, not `MediaFile.CodecAudio` alone:
|
||||
|
||||
- exact sink passthrough codec plus channel count/layout permits audio copy and
|
||||
a `passthrough=true` claim;
|
||||
- client decode support permits copy with `passthrough=false`;
|
||||
- otherwise use video-copy/audio adaptation when permitted;
|
||||
- otherwise terminal with `transcoding_disabled`,
|
||||
`audio_conversion_unsupported`, or policy/capacity reason.
|
||||
|
||||
The effective recipe must report the actual output channels/layout. Do not call
|
||||
AAC stereo output Atmos-preserving. DTS core extraction, E-AC-3 conversion, or
|
||||
other lossy/core-only changes are named transformations with warnings.
|
||||
|
||||
### 6.5 HDR and Dolby Vision
|
||||
|
||||
Use `VideoTrack` fields already populated by the scanner: DV profile, BL
|
||||
compatibility ID, EL presence, range type, color metadata, bit depth, profile,
|
||||
level, and dimensions.
|
||||
|
||||
Add an explicit enhancement-layer classification:
|
||||
|
||||
```text
|
||||
none | mel | fel | unknown
|
||||
```
|
||||
|
||||
An analyzer interface may use an installed `dovi_tool`/libdovi implementation;
|
||||
without it, Profile 7 with an EL remains `unknown`. Unknown must never be
|
||||
reported as MEL, FEL, or validated native DV.
|
||||
|
||||
Represent transformations in a registry with:
|
||||
|
||||
- stable name and recipe version;
|
||||
- required binary/filter/hardware capability;
|
||||
- accepted source metadata;
|
||||
- promised output range;
|
||||
- argument builder;
|
||||
- validation fixture; and
|
||||
- failure-to-terminal mapping.
|
||||
|
||||
Initial entries should cover only paths proven by fixtures, for example:
|
||||
|
||||
- `dv_metadata_strip_to_hdr10` using a compatible HDR10 base layer;
|
||||
- `dv_p7_to_p8_1` only when the selected toolchain and fixture prove the output;
|
||||
- `hdr_to_sdr_tonemap` only when integrated and remote transcode paths both
|
||||
implement the same declared result.
|
||||
|
||||
FFmpeg's `dovi_rpu` bitstream filter can strip DV metadata without decoding,
|
||||
and `dovi_split` can separate Profile 7 layers
|
||||
([FFmpeg bitstream-filter documentation](https://ffmpeg.org/ffmpeg-bitstream-filters.html#dovi_005frpu)).
|
||||
[`dovi_tool`](https://github.com/quietvoid/dovi_tool) can inspect RPU metadata
|
||||
and convert compatible streams. Probe these capabilities at startup and expose
|
||||
only registered, available transformations. Do not silently run the current
|
||||
Profile 7 strip path and still claim Dolby Vision preservation.
|
||||
|
||||
The current progressive remux path is not v3-safe unchanged: when `dovi_rpu`
|
||||
is present it strips Profile 7 metadata automatically, while the no-filter path
|
||||
leaves the copied bitstream without that named adaptation. Refactor remux input
|
||||
to require an explicit transformation choice. A Profile 7 progressive remux is
|
||||
eligible only when it either preserves a fixture-validated native P7 stream or
|
||||
runs the registered `dv_metadata_strip_to_hdr10` transformation against a
|
||||
validated HDR10-compatible base layer. When the required filter/tool is
|
||||
missing, disqualify that remux candidate rather than emitting an ambiguous
|
||||
stream.
|
||||
|
||||
### 6.6 Subtitles
|
||||
|
||||
Resolve exactly one mode:
|
||||
|
||||
- `off`: no selected track;
|
||||
- `render`: return the source artifact when Media3 can render it with required
|
||||
fidelity;
|
||||
- `convert`: return a v3 artifact URL, MIME, format and timing origin;
|
||||
- `burn_in`: force video encoding and declare any HDR/range degradation; or
|
||||
- terminal when the selected subtitle cannot meet policy.
|
||||
|
||||
Map `preserve` to `require_authored_fidelity` and `compatible` to
|
||||
`allow_simplified_rendering`, subject to administrator policy. ASS/SSA styling,
|
||||
font attachments, PGS, VobSub and DVB must each have fixtures; do not infer all
|
||||
bitmap paths from one codec test.
|
||||
|
||||
Converted artifacts need session ownership, signed or authenticated fetches,
|
||||
bounded cache lifetime, and timing relative to the returned stream origin.
|
||||
|
||||
## 7. Transport orchestration refactor
|
||||
|
||||
Extract the body of `HandleStartTranscode` into a server-callable operation that
|
||||
accepts a validated normalized recipe and returns:
|
||||
|
||||
- final manifest URL;
|
||||
- effective local/remote node and hardware encoder;
|
||||
- effective file ID;
|
||||
- final codecs/resolution/bitrate/channels/range;
|
||||
- player/source timeline mapping;
|
||||
- seek-window semantics; and
|
||||
- typed terminal/startup failure.
|
||||
|
||||
Both the legacy endpoint and v3 service call this operation. The legacy handler
|
||||
continues accepting its existing body and maps it to the normalized recipe;
|
||||
there is no `/api/v1` breaking change.
|
||||
|
||||
Jellyfin compatibility is behaviorally out of scope for protocol v3. Its
|
||||
handlers currently start local/remote transcodes through parallel paths and a
|
||||
shared `TranscodeManager`, not through `HandleStartTranscode`. Preserve those
|
||||
wire and lifecycle paths during this work and keep their regression tests
|
||||
green. The extracted lower-level starter may be adopted by jellycompat only in
|
||||
a separately reviewed convergence change; do not half-migrate one of its local
|
||||
or remote branches.
|
||||
|
||||
Apply the same extraction to progressive remux startup where necessary. The v3
|
||||
planner chooses a recipe; the transport starter is not allowed to silently
|
||||
change it. If safety logic must change copy to encode—for example seeked HEVC or
|
||||
subtitle burn-in—the starter returns the effective normalized recipe so the
|
||||
plan, claims, warnings, recipe card, and FFmpeg process remain identical.
|
||||
|
||||
## 8. Route events and observability
|
||||
|
||||
Add `playback_route_events` with bounded columns for:
|
||||
|
||||
- playback/session/plan/plan-attempt identities;
|
||||
- event and failure classification;
|
||||
- fallback reason;
|
||||
- output-route generation;
|
||||
- sanitized diagnostics JSON;
|
||||
- user/profile/client metadata derived from auth and headers; and
|
||||
- received timestamp.
|
||||
|
||||
Accept only a closed event set initially:
|
||||
|
||||
```text
|
||||
plan_selected
|
||||
plan_invalidated
|
||||
plan_failed
|
||||
first_frame
|
||||
terminal
|
||||
stopped
|
||||
```
|
||||
|
||||
Allowlist diagnostic keys and cap key count, key/value length, body size, and
|
||||
events per user/attempt. Strip URLs, headers, tokens, file paths, free-form
|
||||
stack traces, and unknown keys. Enqueue/batch writes so telemetry never blocks
|
||||
playback planning.
|
||||
|
||||
Add exact release queries for:
|
||||
|
||||
- starts by delivery, recipe, dynamic range and client model;
|
||||
- first-frame rate and latency;
|
||||
- replan/failure rate by classification;
|
||||
- terminal reasons;
|
||||
- PCM retry and passthrough outcomes;
|
||||
- repeated-key/loop prevention; and
|
||||
- DV/HDR degradation transformations.
|
||||
|
||||
Operational logs include IDs and reason codes but no stream tokens.
|
||||
|
||||
## 9. Implementation sequence
|
||||
|
||||
Each phase should be a separate reviewable PR and link the playback capability
|
||||
epic/sub-issue.
|
||||
|
||||
### Phase 0 — Freeze the contract
|
||||
|
||||
- Add Go structs/enums mirroring the Kotlin v3 wire model.
|
||||
- Split only the start-envelope dispatch: protocol `3` returns the disabled
|
||||
no-session negotiation response, while absent/non-3 requests replay the
|
||||
buffered body through the unchanged legacy decoder.
|
||||
- Resolve the additive capability, effective-file, subtitle-policy and route-
|
||||
diagnostic gaps from section 3.3 in a coordinated Android change.
|
||||
- Add request/response JSON golden fixtures and canonical attempt-key fixtures.
|
||||
- Add strict validation tests, legacy-envelope tests, and unknown-field tests.
|
||||
- Add `GET /playback/capability` and the disabled negotiation response.
|
||||
- Keep `playback.protocol_v3_enabled=false`.
|
||||
|
||||
**Exit:** Go and Android decode the same fixtures and produce identical attempt
|
||||
keys; Android populates the detailed capability fields; legacy start tests are
|
||||
unchanged; disabled v3 starts allocate no legacy playback session.
|
||||
|
||||
### Phase 1 — Source facts and transformation registry
|
||||
|
||||
- Complete normalized source descriptors from `MediaFile` tracks.
|
||||
- Add MEL/FEL/unknown metadata and conservative probe behavior.
|
||||
- Add installed FFmpeg/dovi tool capability probes.
|
||||
- Define transformation registry and terminal reasons.
|
||||
- Add scanner/model migration or JSONB backfill handling as required.
|
||||
|
||||
**Exit:** every source fact used by the planner is present or explicitly
|
||||
unknown; no unavailable transformation is advertised.
|
||||
|
||||
### Phase 2 — Pure planner
|
||||
|
||||
- Implement capability normalization, stable tracks, quality policy, direct
|
||||
eligibility, candidate ordering, recipe claims, deterministic plan IDs and
|
||||
attempt-key rejection.
|
||||
- Cover direct, progressive remux, HLS copy, audio adaptation, full transcode,
|
||||
subtitles and terminal outcomes with table-driven tests.
|
||||
- Do not start sessions or FFmpeg from planner tests.
|
||||
|
||||
**Exit:** the full Phase 0 decision matrix passes as a pure deterministic
|
||||
function.
|
||||
|
||||
### Phase 3 — Start orchestration
|
||||
|
||||
- Wire the Phase 0 dispatcher's protocol-3 branch to the completed v3
|
||||
orchestration service; keep the legacy branch unchanged.
|
||||
- Extract reusable transcode/remux transport starters.
|
||||
- Start sessions and final transports from the v3 service.
|
||||
- Return complete plans with final URLs and timelines.
|
||||
- Persist attempt/current-plan state.
|
||||
|
||||
**Exit:** every playable fixture fetches and prepares its returned URL; a
|
||||
terminal fixture allocates no leaked session/transcode.
|
||||
|
||||
### Phase 4 — Replan and idempotency
|
||||
|
||||
- Add route, handler, persistent idempotency records and per-session
|
||||
serialization.
|
||||
- Replan track/quality/output changes and classified runtime failures.
|
||||
- Reuse session IDs when safe; close superseded transports exactly once.
|
||||
- Reject attempted recipes and stale failed plan IDs.
|
||||
- Add restart, duplicate request, concurrent request and crash-window tests.
|
||||
|
||||
**Exit:** repeated identical replan requests return the same result without a
|
||||
second FFmpeg process; a different payload under the same ID conflicts.
|
||||
|
||||
### Phase 5 — Route events and rollout controls
|
||||
|
||||
- Add sanitized asynchronous route-event ingestion and retention cleanup.
|
||||
- Add release queries/dashboards and structured plan logs.
|
||||
- Add shadow planning for legacy starts on validation servers: compute and log
|
||||
v3 choice without advertising or executing it.
|
||||
- Compare v3 shadow decisions with current production routes.
|
||||
|
||||
**Exit:** telemetry is queryable, bounded, privacy-reviewed and cannot delay a
|
||||
start/replan response.
|
||||
|
||||
### Phase 6 — Hardware and multi-repository validation
|
||||
|
||||
- Run original/remux/transcode fixtures locally and through proxy/transcode
|
||||
nodes.
|
||||
- Verify restart reconstruction, session limits, transcode-disabled policy,
|
||||
alternate versions and output-route changes.
|
||||
- Run Android Shield tests first on SDR/1080p, then the named 4K DV/AVR chain.
|
||||
- Record plan, route events, decoder, TV range indicator and AVR format for the
|
||||
same attempt.
|
||||
- Publish minimum server revision and enable v3 on validation deployments.
|
||||
|
||||
**Exit:** all Phase 0 fixtures pass on the deployed revision and Android no
|
||||
longer reports `server_upgrade_required`.
|
||||
|
||||
## 10. Test matrix
|
||||
|
||||
At minimum, include:
|
||||
|
||||
### Contract and compatibility
|
||||
|
||||
- absent/2/3/unknown protocol versions;
|
||||
- disabled and enabled feature negotiation;
|
||||
- v3 nested capabilities are consumed, not silently dropped;
|
||||
- legacy start response and HTTP status remain unchanged;
|
||||
- old web/Apple transcode-start requests still work;
|
||||
- malformed plan/terminal responses cannot be emitted.
|
||||
|
||||
### Direct and adaptation
|
||||
|
||||
- H.264/AAC MP4 direct;
|
||||
- HEVC MKV original when the exact Media3 envelope supports it;
|
||||
- container-only progressive and HLS remux;
|
||||
- unsupported TrueHD/DTS layout with video copy and audio conversion;
|
||||
- resolution/user-cap quality transcode;
|
||||
- transcode disabled, user denied, capacity unavailable, missing tool and
|
||||
policy denied terminal results;
|
||||
- alternate lower-resolution file with stable track remapping.
|
||||
|
||||
### HDR/DV
|
||||
|
||||
- HDR10, HDR10+, HLG and SDR direct gates;
|
||||
- DV profiles 5, 7 MEL, 7 FEL, 7 unknown and relevant profile 8 variants;
|
||||
- BL compatibility ID and range mismatch;
|
||||
- native DV, validated HDR10 base-layer fallback and SDR tone map;
|
||||
- missing `dovi_rpu`/`dovi_split`/dovi-tool behavior;
|
||||
- no transformation claims when output was not validated.
|
||||
|
||||
### Audio and subtitles
|
||||
|
||||
- local decode versus passthrough by exact channels/layout;
|
||||
- E-AC-3 JOC, TrueHD, DTS-HD/core and PCM fallback replan;
|
||||
- text sidecar/embedded, ASS with/without authored fidelity, PGS, VobSub, DVB,
|
||||
conversion and burn-in;
|
||||
- artifact timing after seeked remux/transcode;
|
||||
- burn-in forces encode and reports HDR degradation.
|
||||
|
||||
### Recovery and load
|
||||
|
||||
- transport/decoder failures, track/quality/output invalidation;
|
||||
- deterministic same-recipe loop rejection;
|
||||
- idempotent duplicate and concurrent replans;
|
||||
- API and transcode-node restart between plan and first manifest;
|
||||
- session expiry, explicit stop and terminal cleanup;
|
||||
- no double capacity accounting or orphan FFmpeg process;
|
||||
- route-event flood/body/diagnostic limits.
|
||||
|
||||
## 11. Verification commands
|
||||
|
||||
Commands assume the repository root is the cwd.
|
||||
|
||||
```text
|
||||
GOWORK=off go test ./internal/playback ./internal/api/handlers ./internal/transcodenode ./internal/nodepool
|
||||
GOWORK=off go test -race ./internal/playback ./internal/api/handlers
|
||||
make verify-local-paths
|
||||
make lint
|
||||
make build
|
||||
```
|
||||
|
||||
Add a focused integration target that starts PostgreSQL/Redis, creates a media
|
||||
fixture, calls v3 start/replan, fetches the returned stream or manifest, submits
|
||||
route events, and verifies cleanup. Contract fixtures must also run in Android
|
||||
CI against its Kotlin serializers and attempt-key function. Android tests also
|
||||
prove detailed codec/layout capability emission and a local PCM-recovery route
|
||||
event.
|
||||
|
||||
## 12. Rollout and rollback
|
||||
|
||||
1. Merge schema/types with v3 disabled.
|
||||
2. Deploy shadow planner and compare decisions without changing playback.
|
||||
3. Enable v3 only on a validation server with the Android test build.
|
||||
4. Complete SDR, HDR, DV, passthrough, subtitle, restart and node fixtures.
|
||||
5. Publish the minimum server revision.
|
||||
6. Enable v3 for production servers after the named soak and telemetry gates.
|
||||
|
||||
Rollback is the dynamic `playback.protocol_v3_enabled` setting. Disabling it
|
||||
stops advertising the feature and returns the no-session negotiation response
|
||||
to new v3 starts. Existing v3 sessions remain playable until stop/expiry; do
|
||||
not terminate active streams during rollback. Legacy web and Apple endpoints
|
||||
remain available throughout.
|
||||
|
||||
## 13. Definition of done
|
||||
|
||||
- The three Android canonical endpoints and playback capability endpoint are
|
||||
implemented with profile/session ownership checks.
|
||||
- Every playable response is complete, final and fetchable without client-owned
|
||||
recipe decisions.
|
||||
- Every enabled delivery/transformation has a passing integration fixture.
|
||||
- Direct decisions validate the selected track and full source/output facts.
|
||||
- Android supplies the detailed per-codec and per-layout capability facts those
|
||||
direct decisions consume; missing facts fail conservatively.
|
||||
- Terminal reasons replace unsafe direct fallbacks.
|
||||
- Replans are idempotent, loop-safe, restart-aware and capacity-safe.
|
||||
- Route telemetry is bounded, sanitized and queryable.
|
||||
- Existing legacy server, web, Apple, Jellyfin compatibility and audiobook
|
||||
playback tests remain green.
|
||||
- Android passes direct/remux/transcode/replan tests against the deployed server
|
||||
revision, followed by the documented 4K Dolby Vision/AVR validation.
|
||||
@@ -0,0 +1,345 @@
|
||||
# Ebook Enrichment Architecture 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:** Make ebook discovery fast and deterministic while metadata enrichment runs independently as a durable, bounded, observable workflow that can improve records over time.
|
||||
|
||||
**Architecture:** The scanner remains responsible for filesystem reconciliation, embedded metadata, local artwork, format grouping, and identity hints such as ISBN. It never performs broad remote enrichment. A database-backed ebook enrichment state machine claims work with leases, applies outcome-specific retry or refresh horizons, and is drained by the existing task manager. The ebook metadata plugin uses ISBN-first source tiers, ranked matches, and per-source health cooldowns instead of fanning every title query out to every configured source.
|
||||
|
||||
**Tech Stack:** Go, PostgreSQL/goose, pgx, Silo task manager, Silo plugin gRPC API
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Ebook scans must not wait for remote metadata providers.
|
||||
- Existing embedded ebook metadata and local artwork remain higher priority than remote metadata.
|
||||
- Ebooks use ISBN identifiers; audiobook-only identifiers and narrator fields must not be introduced.
|
||||
- Enrichment must survive process restarts and must not let concurrent workers claim the same item.
|
||||
- Provider outages, rate limits, and deterministic client errors must not block scans or create tight retry loops.
|
||||
- Previously enriched records must become eligible for controlled refresh so corrected metadata can be adopted later.
|
||||
- Changes must be based on `Silo-Server/silo-server` upstream `main` and must not modify unrelated local branches.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Decouple Ebook Scans From Remote Retry
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/libraryingest/executor.go`
|
||||
- Modify: `internal/libraryingest/executor_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `librarykind.Of(folder.Type)` and the existing matcher interface.
|
||||
- Produces: `usesDedicatedEnrichment(folderType string) bool`, used to skip synchronous unmatched-item retry for ebooks while preserving existing video behavior.
|
||||
|
||||
- [x] **Step 1: Write the failing executor test**
|
||||
|
||||
Add a matcher that records retry calls and a table test which ingests an ebook folder and a movie folder. Assert that `RetryUnmatchedItemsByFolderAndPathPrefix` is not called for `ebooks`, but remains called for `movies`.
|
||||
|
||||
- [x] **Step 2: Run the focused test and verify red**
|
||||
|
||||
Run: `go test ./internal/libraryingest -run TestIngestFolderSkipsSynchronousRetryForDedicatedEnrichment -count=1`
|
||||
|
||||
Expected: FAIL because ebook ingestion still calls the synchronous retry method.
|
||||
|
||||
- [x] **Step 3: Implement the dedicated-enrichment gate**
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func usesDedicatedEnrichment(folderType string) bool {
|
||||
return librarykind.Of(folderType).Ebook
|
||||
}
|
||||
```
|
||||
|
||||
In the scoped matching loop, retain `ProcessAllByFolderAndPathPrefix` and variant finalization, but call `RetryUnmatchedItemsByFolderAndPathPrefix` only when `usesDedicatedEnrichment(folder.Type)` is false.
|
||||
|
||||
- [x] **Step 4: Verify the focused and package tests**
|
||||
|
||||
Run: `go test ./internal/libraryingest -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/libraryingest/executor.go internal/libraryingest/executor_test.go
|
||||
git commit -m "fix(ebooks): decouple enrichment from library scans"
|
||||
```
|
||||
|
||||
### Task 2: Replace Failure Counting With a Durable Enrichment State Machine
|
||||
|
||||
**Files:**
|
||||
- Create: `migrations/sql/20260719090000_ebook_enrichment_jobs.sql`
|
||||
- Create: `internal/ebooks/enrichment_queue.go`
|
||||
- Create: `internal/ebooks/enrichment_queue_test.go`
|
||||
- Modify: `internal/ebooks/enrichment.go`
|
||||
- Modify: `internal/ebooks/enrichment_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `EnrichmentQueue.Enqueue(ctx, contentID, priority)`, `ClaimBatch(ctx, limit, leaseDuration)`, `Complete(ctx, contentID, outcome, refreshAfter)`, and `Fail(ctx, contentID, errorClass, message, retryAfter)`.
|
||||
- Consumes: `enrichmentItemRow` and the existing `Enricher.enrichItem` persistence path.
|
||||
|
||||
- [x] **Step 1: Write failing queue query and transition tests**
|
||||
|
||||
Test that claims use `FOR UPDATE SKIP LOCKED`, set `lease_until`, and only select `pending` jobs whose `next_attempt_at` is due or whose lease expired. Test outcome policies:
|
||||
|
||||
```go
|
||||
success -> next_attempt_at = now + 90 days
|
||||
no_match -> next_attempt_at = now + 30 days
|
||||
transient failure -> exponential backoff capped at 24 hours
|
||||
rate_limited -> provider retry horizon, capped at 24 hours
|
||||
permanent failure -> next_attempt_at = now + 30 days
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run queue tests and verify red**
|
||||
|
||||
Run: `go test ./internal/ebooks -run 'TestEnrichmentQueue|TestEnrichmentRetryPolicy' -count=1`
|
||||
|
||||
Expected: FAIL because the queue and retry policy do not exist.
|
||||
|
||||
- [x] **Step 3: Add the migration**
|
||||
|
||||
Extend `ebook_enrichment_state` with:
|
||||
|
||||
```sql
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
priority integer NOT NULL DEFAULT 0,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
lease_until timestamptz,
|
||||
last_attempt_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
outcome text,
|
||||
last_error_class text,
|
||||
last_error text
|
||||
```
|
||||
|
||||
Add a partial claim index on `(priority DESC, next_attempt_at, updated_at)` for rows whose status is `pending` or `running`.
|
||||
|
||||
- [x] **Step 4: Implement atomic queue claims and transitions**
|
||||
|
||||
Use one transaction and a CTE:
|
||||
|
||||
```sql
|
||||
WITH candidates AS (
|
||||
SELECT content_id
|
||||
FROM ebook_enrichment_state
|
||||
WHERE next_attempt_at <= now()
|
||||
AND (status = 'pending' OR (status = 'running' AND lease_until < now()))
|
||||
ORDER BY priority DESC, next_attempt_at, updated_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT $1
|
||||
)
|
||||
UPDATE ebook_enrichment_state state
|
||||
SET status = 'running',
|
||||
lease_until = now() + $2::interval,
|
||||
last_attempt_at = now(),
|
||||
attempts = attempts + 1,
|
||||
updated_at = now()
|
||||
FROM candidates
|
||||
WHERE state.content_id = candidates.content_id
|
||||
RETURNING state.content_id;
|
||||
```
|
||||
|
||||
Keep state rows after success so refresh eligibility and prior outcomes are durable.
|
||||
|
||||
- [x] **Step 5: Route `Enricher.Run` through the queue**
|
||||
|
||||
Materialize missing candidates into `ebook_enrichment_state` with `INSERT ... SELECT ... ON CONFLICT DO NOTHING`, claim a bounded batch, load the existing item fields, and transition each claim to success, no-match, skipped, or failure. Context cancellation must release the lease without incrementing item failure state.
|
||||
|
||||
- [x] **Step 6: Verify ebook package tests**
|
||||
|
||||
Run: `go test ./internal/ebooks -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add migrations/sql/20260719090000_ebook_enrichment_jobs.sql internal/ebooks/enrichment_queue.go internal/ebooks/enrichment_queue_test.go internal/ebooks/enrichment.go internal/ebooks/enrichment_test.go
|
||||
git commit -m "feat(ebooks): add durable metadata enrichment queue"
|
||||
```
|
||||
|
||||
### Task 3: Drain Bounded Work Continuously And Report Honest Progress
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata.go`
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata_test.go`
|
||||
- Modify: `internal/ebooks/enrichment.go`
|
||||
- Modify: `cmd/silo/main.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `EnrichmentRunResult{Claimed, Enriched, NoMatch, Failed, Deferred, Remaining int}`, an incremental task, and a separate manual legacy-backfill task.
|
||||
- Consumes: the durable queue from Task 2.
|
||||
|
||||
- [x] **Step 1: Write failing task tests**
|
||||
|
||||
Test that the scheduled task drains only priority-zero-or-higher work until the queue is empty or a four-minute execution budget expires. Add a separate `backfill_ebook_metadata` task with no default triggers which may claim the priority `-100` legacy backlog. Assert progress messages include claimed, enriched, failed, and remaining counts, and cancellation stops between batches.
|
||||
|
||||
- [x] **Step 2: Run focused task tests and verify red**
|
||||
|
||||
Run: `go test ./internal/taskmanager/tasks -run TestSyncEbookMetadata -count=1`
|
||||
|
||||
Expected: FAIL because the task runs one opaque batch and only returns `items_enriched`.
|
||||
|
||||
- [x] **Step 3: Add structured enrichment results**
|
||||
|
||||
Change `Enricher.Run` to return:
|
||||
|
||||
```go
|
||||
type EnrichmentRunResult struct {
|
||||
Claimed int `json:"claimed"`
|
||||
Enriched int `json:"enriched"`
|
||||
NoMatch int `json:"no_match"`
|
||||
Failed int `json:"failed"`
|
||||
Deferred int `json:"deferred"`
|
||||
Remaining int `json:"remaining"`
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Implement time-budgeted draining**
|
||||
|
||||
Loop over bounded queue claims while work remains and the execution deadline has not elapsed. The scheduled task must never claim legacy-backfill rows; only the manually triggered backfill task may include them. Report progress after every batch; never estimate 100 percent until no immediately eligible work remains.
|
||||
|
||||
- [x] **Step 5: Verify task and ebook tests**
|
||||
|
||||
Run: `go test ./internal/taskmanager/tasks ./internal/ebooks -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/taskmanager/tasks/sync_ebook_metadata.go internal/taskmanager/tasks/sync_ebook_metadata_test.go internal/ebooks/enrichment.go
|
||||
git commit -m "feat(ebooks): drain enrichment backlog with progress"
|
||||
```
|
||||
|
||||
### Task 4: Rebuild Ebook Provider Strategy In The Metadata Plugin
|
||||
|
||||
**Files (plugin repository `silo-plugin-ebook-metadata`):**
|
||||
- Create: `provider/health.go`
|
||||
- Create: `provider/health_test.go`
|
||||
- Create: `provider/ranking.go`
|
||||
- Create: `provider/ranking_test.go`
|
||||
- Modify: `provider/provider.go`
|
||||
- Modify: `provider/provider_test.go`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: source tiers `identifier`, `catalog`, and `extended`; `SourceHealth.Allow`, `RecordSuccess`, and `RecordFailure`; deterministic `RankMatches`.
|
||||
- Consumes: existing `Source.Search`, `Source.Fetch`, normalized ISBN helpers, and plugin settings.
|
||||
|
||||
- [x] **Step 1: Write failing source-tier tests**
|
||||
|
||||
Assert default operation enables reliable catalog sources only, exact ISBN fetches use identifier-capable sources in order, and title/author searches do not invoke optional scraper sources unless explicitly enabled.
|
||||
|
||||
- [x] **Step 2: Write failing health-policy tests**
|
||||
|
||||
Assert HTTP 403/405 disables a source for one hour, HTTP 429 honors `Retry-After`, timeouts use exponential cooldown, and one healthy response closes the circuit.
|
||||
|
||||
- [x] **Step 3: Write failing ranking tests**
|
||||
|
||||
Rank exact normalized ISBN first, then normalized title plus author, then title plus year/language. Reject low-confidence title-only collisions and deduplicate matches by normalized ISBN or source/provider ID.
|
||||
|
||||
- [x] **Step 4: Implement tiered querying**
|
||||
|
||||
For an ISBN query, fetch identifier sources sequentially and stop after a complete high-confidence match. For title/author queries, query the reliable catalog tier with bounded concurrency, rank all results, and query the extended tier only when enabled and the catalog tier produced no acceptable match.
|
||||
|
||||
- [x] **Step 5: Implement per-source health cooldowns**
|
||||
|
||||
Wrap every source call with health admission and outcome recording. A source in cooldown is skipped without returning a provider-wide error; healthy sources continue serving the request.
|
||||
|
||||
- [x] **Step 6: Update plugin documentation**
|
||||
|
||||
Document default sources, optional extended sources, API-key sources, match ranking, cooldown behavior, and the `enabled_sources` override.
|
||||
|
||||
- [x] **Step 7: Verify plugin tests**
|
||||
|
||||
Run: `go test ./... -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add provider/health.go provider/health_test.go provider/ranking.go provider/ranking_test.go provider/provider.go provider/provider_test.go README.md
|
||||
git commit -m "feat: add tiered resilient ebook metadata sources"
|
||||
```
|
||||
|
||||
### Task 5: Add Backfill Safety Controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata.go`
|
||||
- Modify: `internal/taskmanager/tasks/sync_ebook_metadata_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the manual legacy task from Task 3.
|
||||
- Produces: a claim cap, inter-batch pacing, and a no-progress circuit breaker.
|
||||
|
||||
- [ ] **Step 1: Preserve typed source deferrals**
|
||||
|
||||
Count `ResourceExhausted` provider results as deferred work with the provider's
|
||||
retry delay, not as generic failures.
|
||||
|
||||
- [ ] **Step 2: Stop stalled drains**
|
||||
|
||||
Stop cleanly after one full batch with no terminal outcome. A plugin packaging
|
||||
error, source outage, or rate-limit saturation must never walk the backlog.
|
||||
|
||||
- [ ] **Step 3: Add canary controls**
|
||||
|
||||
Support a maximum number of claims per manual run and an inter-batch delay.
|
||||
The first production trial uses 20 claims, one worker, and a one-second delay.
|
||||
|
||||
- [ ] **Step 4: Verify safety behavior**
|
||||
|
||||
Test all-failed, all-deferred, mixed-progress, claim-cap, pacing, cancellation,
|
||||
and honest result reporting.
|
||||
|
||||
### Task 6: End-To-End Verification And Operational Rollout
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/plans/2026-07-19-ebook-enrichment-architecture.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-4.
|
||||
- Produces: verified deployment and backfill procedure.
|
||||
|
||||
- [ ] **Step 1: Run server verification**
|
||||
|
||||
Run: `go test ./internal/libraryingest ./internal/ebooks ./internal/taskmanager/tasks ./internal/scanner -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run race-sensitive server verification**
|
||||
|
||||
Run: `go test -race ./internal/ebooks ./internal/taskmanager/tasks -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run plugin verification**
|
||||
|
||||
Run from the plugin worktree: `go test -race ./... -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Package and preflight**
|
||||
|
||||
Build the plugin binary and installed manifest together. Verify their checksums,
|
||||
versions, and runtime manifests match before replacing a running installation.
|
||||
|
||||
- [ ] **Step 5: Deploy a bounded production canary**
|
||||
|
||||
Deploy without overwriting compose or `.env`. Use 20 maximum claims, one worker,
|
||||
and a one-second inter-batch delay. Do not enable an automatic legacy trigger.
|
||||
|
||||
- [ ] **Step 6: Apply acceptance gates**
|
||||
|
||||
Promote only when runtime-manifest errors are zero, no full batch fails or
|
||||
defers, at least 80 percent of claimed rows reach success or no-match, database
|
||||
claims remain below one second, and Silo stays healthy without scan regressions.
|
||||
|
||||
- [ ] **Step 7: Expand in measured stages**
|
||||
|
||||
Increase the claim cap only after recording throughput, provider error rate,
|
||||
queue depth, database load, and projected completion time. Stop automatically
|
||||
when a stage misses an acceptance gate.
|
||||
@@ -0,0 +1,388 @@
|
||||
# Silo Notifications — Architecture Overview
|
||||
|
||||
**Date:** 2026-04-28
|
||||
**Status:** Draft
|
||||
**Scope:** Cross-cutting design covering all delivery channels: durable in-app inbox, realtime websocket, Apple Push (APNs), Android Push (FCM), and outbound webhooks.
|
||||
**Companion docs:**
|
||||
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — Foundation
|
||||
- [`02-apns-relay.md`](./02-apns-relay.md) — Apple push
|
||||
- [`03-fcm-relay.md`](./03-fcm-relay.md) — Android push
|
||||
- [`04-outbound-webhooks.md`](./04-outbound-webhooks.md) — Discord and generic webhooks
|
||||
|
||||
## Goal
|
||||
|
||||
Silo should notify a profile when a new episode of a series they care about becomes available in their library. "Care about" means at least one of: the series is favorited, watchlisted, in continue-watching, or has a profile-eligible next-up cursor.
|
||||
|
||||
The notification should reach the profile through whichever delivery channels the profile has configured: an in-app inbox with realtime push to connected clients, mobile push notifications to Apple and Android devices, and outbound webhooks to user-chosen destinations like a Discord channel.
|
||||
|
||||
## Why this is hard
|
||||
|
||||
Three constraints pull in different directions:
|
||||
|
||||
1. **Self-hosted privacy.** Silo is self-hosted media. Notification content (episode titles, posters, profile names, library identity) must never leave the user's server unless the user has explicitly opted into a destination that requires it.
|
||||
2. **Mobile push gatekeeping.** Apple and Google require pushes to the official App Store / Play Store builds to be authenticated by the credentials of the developer account that signed and published the app. There is no fully self-hosted, zero-Silo-infrastructure path to push to the official mobile apps.
|
||||
3. **Multi-profile fairness.** A single user account on a Silo server can have multiple profiles (kid, adult, parent, etc.). Notifications must be scoped to the active profile. Devices, push tokens, webhooks, and preferences are all profile-scoped.
|
||||
|
||||
The design resolves these by:
|
||||
|
||||
- Routing all notification work through a durable per-profile inbox that lives on the user's own server. Everything else is a transport on top of this inbox.
|
||||
- Operating a privacy-preserving relay for APNs and FCM that holds the official-app credentials and accepts only opaque push requests. Self-hosted servers opt in. The relay never sees notification content.
|
||||
- Profile-scoping every database row, every API surface, every websocket subscription, and every push registration.
|
||||
|
||||
## Mental model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ │
|
||||
ingest/scan ────► release_events (one row per newly-available │
|
||||
│ episode in a library) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ fanout worker │
|
||||
│ │ │
|
||||
│ │ loads profile_series_interest │
|
||||
│ │ applies eligibility + preferences │
|
||||
│ ▼ │
|
||||
│ notification_deliveries (one row per │
|
||||
│ eligible profile per release) │
|
||||
│ │ │
|
||||
│ │ AFTER COMMIT │
|
||||
│ ▼ │
|
||||
│ channel dispatcher ◄── fans out to channels: │
|
||||
│ │
|
||||
│ ┌───────────────┬───────────────┬─────────┐ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ websocket apple push android webhooks│
|
||||
│ (in-app) (APNs relay push (per │
|
||||
│ or custom) (FCM) profile)│
|
||||
│ │ │ │ │ │
|
||||
└───┼───────────────┼───────────────┼─────────┼───┘
|
||||
▼ ▼ ▼ ▼
|
||||
connected iOS / tvOS / Android Discord /
|
||||
browser tabs macOS apps apps user URL
|
||||
and apps (wake then (wake) (content
|
||||
fetch metadata) included)
|
||||
```
|
||||
|
||||
Three invariants make this work:
|
||||
|
||||
- **The durable row is the source of truth.** Realtime websocket events, mobile pushes, and webhooks are all triggered *after* the `notification_deliveries` row commits. If any transport fails, the inbox row still exists and shows up on next reconnect or refresh.
|
||||
- **The fanout worker decides recipients exactly once.** Recipients are derived from a compact `profile_series_interest` index keyed by `(library_id, series_id)`. Per-channel transports are purely about delivery — they never make recipient decisions.
|
||||
- **Dispatch enqueue is durable (outbox).** The same fanout transaction that inserts `notification_deliveries` also inserts `pending` per-target attempt rows for the push and webhook channels (`push_delivery_attempts` / `webhook_delivery_attempts`). The post-commit dispatcher *claims* those rows for immediate delivery; recovery workers sweep stale `pending` rows. A crash between delivery commit and dispatch therefore delays pushes/webhooks instead of silently dropping them. The websocket channel deliberately has no outbox row — it is best-effort because the inbox snapshot covers reconnect. See `01-release-events-and-inbox.md` "Transaction and Concurrency Rules".
|
||||
|
||||
## Channels at a glance
|
||||
|
||||
| Channel | Where it lives | Trust model | Content visibility |
|
||||
|---|---|---|---|
|
||||
| **In-app inbox** | User's own server, accessed via authenticated REST API | Same auth as the rest of Silo | Full content |
|
||||
| **Realtime websocket** | User's own server, `/events/ws` channel `notifications` | Same auth, profile-scoped | Full content |
|
||||
| **Apple Push (APNs)** | Silo-operated relay (or admin-supplied custom APNs credentials) | Opt-in. Self-hosters trust the relay to forward opaque requests. | Opaque payload only — app fetches content from user server after wake |
|
||||
| **Android Push (FCM)** | Silo-operated relay (or admin-supplied custom FCM credentials) | Same as APNs | Opaque data-only message — app fetches content from user server after wake |
|
||||
| **Outbound webhooks** | User-chosen destination URL (e.g., Discord, Slack, custom service) | Profile chose the destination; trust is implicit | Full content included by default. Discord type renders native embeds; generic type sends signed JSON |
|
||||
|
||||
## Triggers (what generates a notification)
|
||||
|
||||
A notification is generated when a new episode becomes available in a library and at least one profile has interest in the series:
|
||||
|
||||
- **Favorite** — the series is favorited by the profile.
|
||||
- **Watchlist** — the series is on the profile's watchlist.
|
||||
- **Continue watching** — the profile has in-progress watch state for the series.
|
||||
- **Next up** — the new episode is at or beyond the profile's `next_expected_episode_key` cursor and the profile has progression state for the series.
|
||||
|
||||
A profile may match multiple reasons for a single release event. The `notification_deliveries` row records all matching reasons in a `reason_flags` JSONB column; the fanout produces exactly one delivery per `(profile_id, release_event_id)` regardless of how many reasons matched. A second uniqueness rule spans libraries: because Silo media items are catalog-level (`media_item_libraries` is a junction), the same episode landing in two libraries (e.g., "TV" and "TV 4K") shares one `episode_id`, and at most one `episode.available` delivery is created per `(profile_id, episode_id)` — dual-quality library setups do not double-notify.
|
||||
|
||||
**Back-catalog protection:** "newly available" must mean *newly released to this server*, not *newly seen by the notifications feature*. The first scan of a newly created library and the one-time feature-enable backfill **seed** `episode_availability` without creating release events, and the fanout worker applies a per-series burst cap for bulk additions to existing libraries. Without this, importing a 200-episode back-catalog of a series with hundreds of interested profiles would generate tens of thousands of deliveries and pushes in a single scan. Full rules in `01-release-events-and-inbox.md` "Seeding and Burst Suppression".
|
||||
|
||||
**v1 explicitly does not support:**
|
||||
|
||||
- Movie additions. Silo has no movie-availability detector parallel to the per-episode one in this design; without it, there's nothing to fan out from for movies. Users *can* watchlist movies that don't yet exist in their library (via TMDB lookups), but the missing piece is the ingest-time signal that a watchlisted movie has now arrived. v2 can add a `movie_availability` table and analogous release events; the fanout/inbox/channel layers in this design are movie-ready by construction.
|
||||
- Metadata-only updates (poster refreshes, title corrections).
|
||||
- Generic library-changed notifications.
|
||||
- Aggregated notifications like "3 new episodes available." Per-episode deliveries first; aggregation is a presentation concern that can stack on top.
|
||||
|
||||
## Mode terminology
|
||||
|
||||
The design uses three vocabularies that correspond to different layers; they are intentionally not the same word. This is the canonical mapping:
|
||||
|
||||
| Layer | Terms | Where it lives |
|
||||
|---|---|---|
|
||||
| **Profile-level push mode** (UI-facing) | `off`, `in_app_only`, `private_push` | `push_devices.push_mode` |
|
||||
| **APNs wire mode** (relay request `mode` field) | `private_alert`, `background_wake` | Sent to APNs relay or used in custom APNs payload builder |
|
||||
| **FCM wire mode** (relay request `mode` field) | `private_data`, `background_wake` | Sent to FCM relay or used in custom FCM payload builder |
|
||||
|
||||
Mapping:
|
||||
|
||||
- Profile `private_push` → APNs `private_alert` for visible wakes, FCM `private_data` for visible wakes.
|
||||
- Profile `in_app_only` → no remote push at all (websocket / inbox only).
|
||||
- Profile `off` → no notifications for this device whatsoever.
|
||||
- The `background_wake` wire mode is reserved for low-noise sync (per-channel spec details). It is not exposed at profile level in v1.
|
||||
|
||||
## Preference model
|
||||
|
||||
Preferences are profile-scoped and flat. There is no per-channel × per-reason matrix.
|
||||
|
||||
```
|
||||
notification_preferences (per profile)
|
||||
├── enabled bool — global kill switch
|
||||
├── notify_favorites bool
|
||||
├── notify_watchlist bool
|
||||
├── notify_continue_watching bool
|
||||
└── notify_next_up bool
|
||||
```
|
||||
|
||||
Per-webhook reason gating uses **the same four boolean columns** on `notification_webhooks` (`notify_favorites`, `notify_watchlist`, `notify_continue_watching`, `notify_next_up`) — not a single structured `reason_filters` JSON field.
|
||||
|
||||
**Precedence:** profile preferences are a **hard gate** applied during fanout (a delivery row is not created if all matching reasons are disabled at the profile level). Per-webhook flags and per-device push mode are **additional filters** applied during dispatch — they can narrow what fires for a specific destination but cannot re-enable a reason the profile has globally disabled.
|
||||
|
||||
Each push device has a `push_mode` (`off`, `in_app_only`, `private_push`) that gates whether mobile push fires for that specific device. Each webhook has its own `enabled` flag and per-webhook `notify_favorites` / `notify_watchlist` / `notify_continue_watching` / `notify_next_up` booleans for finer control.
|
||||
|
||||
The fanout worker uses `notification_preferences` to decide whether to *create* a `notification_delivery` at all. The channel dispatcher uses per-device and per-webhook flags to decide which transports fire after the row commits.
|
||||
|
||||
This means: if a profile turns off `notify_continue_watching`, a delivery is never created for a continue-watching-only match, and no transports fire. If a profile leaves the reason flags on but disables a specific Discord webhook, the inbox row + websocket + push still fire normally — the webhook just doesn't.
|
||||
|
||||
## Mobile push: why a relay is necessary
|
||||
|
||||
Apple and Google operate the only push infrastructure that can wake a closed app on iOS and Android. To send a push to the official Silo app on the App Store or Play Store, the request must be authenticated by the credentials of the developer account that signed and published that app build:
|
||||
|
||||
- **APNs:** Apple Developer Team ID + Auth Key ID + `.p8` private key. The auth key is account-scoped (not topic-scoped); it can sign JWTs that authorize push to any bundle topic the team owns. The `apns-topic` request header selects which topic the push targets, and Apple rejects requests whose topic is not owned by the signing team.
|
||||
- **FCM:** Firebase service account JSON whose private key signs a JWT exchanged for an OAuth2 access token. The service account is scoped to a specific Firebase project, and the project owns one or more registered Android app package names. (Package + SHA-1 cert fingerprint registration matters for Firebase Authentication and App Check, but for plain FCM v1 push the service account + package name + matching FCM registration token are what's required.)
|
||||
|
||||
Distributing these credentials to thousands of self-hosted Silo installations is not viable:
|
||||
|
||||
1. It violates Apple's and Google's developer agreements, which prohibit sharing credentials outside the team.
|
||||
2. Anyone with a copy could push to every Silo app worldwide.
|
||||
3. Apple actively monitors and revokes leaked keys.
|
||||
|
||||
Therefore the design separates two paths:
|
||||
|
||||
### Hosted relay (`silo_relay`) — the default for official-app users
|
||||
|
||||
A small Silo-operated service holds the official APNs `.p8` key and FCM service account JSON. Self-hosted servers opt in by configuring a relay API key, then call the relay with opaque push requests. The relay signs and forwards to APNs / FCM. The relay never sees notification content.
|
||||
|
||||
The relay is **stateless on the request path**. Its database holds only:
|
||||
|
||||
- relay accounts (one per opt-in self-hosted server installation)
|
||||
- relay API keys (with `last_used_at`, revocable)
|
||||
- minimal per-account allowlists (which app topics this account may push to)
|
||||
- redacted operational logs (no APNs/FCM tokens, no notification content)
|
||||
|
||||
It does **not** store user identities, profile data, device-to-user mappings, or notification content. Apple and Google do the actual addressing — when a push request goes out, the relay just forwards `{token, opaque_ids}` to APNs/FCM, and APNs/FCM deliver based on the device token.
|
||||
|
||||
### Custom credentials (`custom_apns` / `custom_fcm`) — escape hatch
|
||||
|
||||
Power users, white-label deployments, or anyone running a fork that ships its own resigned mobile app can configure their own Apple/Google developer credentials. The user's Silo server signs requests directly using those credentials and sends to APNs/FCM without going through the Silo relay.
|
||||
|
||||
Custom mode is **only useful if the admin has shipped a custom-signed app build under their own developer account** with their own bundle/package ID. It does not let a self-hoster push to the official App Store / Play Store app — that's not technically possible without the official developer credentials.
|
||||
|
||||
### Addressing flow walkthrough
|
||||
|
||||
The most common question on this design is "how does the relay know which user/device to send to with thousands of servers and thousands of users?" The answer is: it doesn't, because Apple and Google handle addressing.
|
||||
|
||||
```
|
||||
1. User installs official Silo app on iPhone from App Store.
|
||||
2. App calls iOS push registration; Apple returns an opaque APNs device token
|
||||
(~100 bytes, unique per app install per device).
|
||||
3. App POSTs that token to the user's OWN server:
|
||||
POST https://my-silo.example.com/api/v1/devices/push/apple
|
||||
{ device_id, apns_token, ... }
|
||||
4. User's server stores in its push_devices table, scoped to the active profile.
|
||||
5. New episode lands. Fanout worker creates a notification_delivery row.
|
||||
6. Channel dispatcher loads the profile's enabled push_devices and POSTs
|
||||
to the relay (or signs directly via custom_apns):
|
||||
POST https://relay.silo.app/v1/apple/send
|
||||
Authorization: Bearer rk_thisServersRelayApiKey
|
||||
{
|
||||
"token": "740f...", ← APNs device token from step 3
|
||||
"topic": "com.continuum.app.ios",
|
||||
"environment": "production",
|
||||
"mode": "private_alert",
|
||||
"server_device_id": "01JOPAQUE...", ← lets app find right local server
|
||||
"delivery_id": "01JDELIVERY..." ← lets app fetch notification meta
|
||||
}
|
||||
7. Relay signs JWT with Silo's .p8, forwards to APNs:
|
||||
POST https://api.push.apple.com/3/device/740f...
|
||||
apns-topic: com.continuum.app.ios
|
||||
authorization: bearer <signed JWT>
|
||||
{generic opaque payload with silo.* fields}
|
||||
8. Apple delivers to the iPhone holding token 740f...
|
||||
9. App wakes, reads server_device_id, finds matching local server account,
|
||||
GETs full notification content from the user's own server.
|
||||
```
|
||||
|
||||
The relay knows: a relay API key was used (so: which self-hosted server installation), the server's egress IP address (inherent to any hosted relay — the server connects to it directly), an APNs device token, a timestamp, and opaque IDs. The relay does not know: which user, which profile, what notification content, what server URL, what library, or what media item. Even a fully compromised relay leaks only timing + device tokens + egress IPs, not notification content or identity. The egress IP is the closest thing to an identity leak in this design — for home hosting it identifies the household's connection — and is listed here so the privacy claims stay exhaustive rather than over-strong.
|
||||
|
||||
## Threat model summary
|
||||
|
||||
Detailed per-channel threat models live in each channel's spec. The cross-cutting summary:
|
||||
|
||||
| Channel | Adversary | Worst case |
|
||||
|---|---|---|
|
||||
| In-app inbox + websocket | Network attacker | Existing Silo auth threat model. Notifications add no new surface. |
|
||||
| APNs / FCM relay (operator) | Compromised relay or hostile relay operator | Can learn device tokens, push timing, and the server's egress IP for opted-in servers. Cannot learn user identity, notification content, server URL, or library data. Cannot fabricate meaningful pushes (only generic opaque payloads). |
|
||||
| APNs / FCM (Apple / Google) | Apple / Google as platform operator | Can see app topic, device token, generic payload, timing. No user identity, no media metadata, no server URL. |
|
||||
| Outbound webhooks | Webhook destination operator (e.g., Discord) | Sees full notification content (titles, posters, episode info) for the profile that configured the webhook. The profile chose this destination; this is the explicit trade. |
|
||||
| Outbound webhooks | Network attacker who owns a URL submitted by the user | Can receive the profile's notifications until the webhook is removed. Mitigated by HMAC for generic webhooks, HTTPS-only, host blocklist (no localhost / RFC1918 by default), and a profile-visible "last seen" / failure status. |
|
||||
|
||||
## Channel dispatcher boundary
|
||||
|
||||
A single internal `Dispatcher` interface fans the durable row out to channels. Each channel implements the same minimal contract:
|
||||
|
||||
```go
|
||||
type Dispatcher interface {
|
||||
// Called once per notification_deliveries row, AFTER the row commits.
|
||||
// Per-target dispatchers (push, webhooks) do not decide their own work:
|
||||
// the fanout transaction already enqueued one `pending` attempt row per
|
||||
// (delivery_id, target_id), where target_id is push_device.id or
|
||||
// notification_webhook.id. Dispatch claims and sends those rows; a
|
||||
// recovery worker sweeps `pending` rows whose claim never happened
|
||||
// (crash between commit and dispatch). Idempotency is therefore
|
||||
// by (delivery_id, target_id, attempt_number). The websocket dispatcher
|
||||
// has no per-target row and is instead idempotent by delivery_id alone —
|
||||
// re-publishing the same delivery_id is a no-op for connected clients.
|
||||
Dispatch(ctx context.Context, delivery NotificationDelivery) error
|
||||
}
|
||||
```
|
||||
|
||||
Implementations:
|
||||
|
||||
- `WebsocketDispatcher` — publishes a `notification.created` event on the `notifications` channel, scoped to the delivery's `(user_id, profile_id)`. Best-effort; durable row is the source of truth.
|
||||
- `ApplePushDispatcher` — selects enabled `push_devices` for the profile where `platform = 'apple'` and routes through the configured provider (`silo_relay` or `custom_apns`). Records each attempt in `push_delivery_attempts`.
|
||||
- `AndroidPushDispatcher` — same but `platform = 'android'`, routes through `silo_relay` or `custom_fcm`.
|
||||
- `WebhookDispatcher` — selects enabled `notification_webhooks` for the profile, applies per-webhook reason filters, and POSTs the channel-specific payload (Discord embed or generic JSON+HMAC). Records each attempt in `webhook_delivery_attempts`.
|
||||
|
||||
Channel failures are isolated: if APNs is down, webhooks and websocket still fire. If a webhook destination is unreachable, push still fires. The fanout worker does **not** wait for any dispatcher to succeed before marking the release event processed; the durable delivery rows plus the `pending` outbox attempt rows (enqueued in the same transaction) guarantee eventual delivery for push and webhooks even across a crash. The relay-bound dispatchers additionally pace their sends client-side (token bucket) so a burst never trips the relay's rate limits — see `02`/`03` "Relay pacing".
|
||||
|
||||
## Capability surface
|
||||
|
||||
The frontend and mobile clients need to know what's available without guessing. A single capability endpoint returns the truth:
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/capability
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"in_app": { "enabled": true },
|
||||
"apple_push": {
|
||||
"available": true,
|
||||
"provider": "silo_relay",
|
||||
"supported_modes": ["private_push", "in_app_only"]
|
||||
},
|
||||
"android_push": {
|
||||
"available": false,
|
||||
"provider": "off",
|
||||
"supported_modes": ["in_app_only"]
|
||||
},
|
||||
"webhooks": {
|
||||
"available": true,
|
||||
"max_per_profile": 10,
|
||||
"supported_types": ["discord", "generic"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Clients render setup UI from this response. They never have to introspect admin settings.
|
||||
|
||||
## Data model overview
|
||||
|
||||
The full schemas live in the per-channel specs. The shared shape:
|
||||
|
||||
| Table | Purpose | Spec |
|
||||
|---|---|---|
|
||||
| `episode_availability` | "Episode E became available in library L." Idempotent. Seeded silently for back-catalog. | 01 |
|
||||
| `release_events` | One per `(library_id, episode_id)` newly-available event. | 01 |
|
||||
| `notification_library_seed_state` | Per-library marker: availability has been seeded; release events may now be emitted. | 01 |
|
||||
| `profile_series_interest` | Compact recipient index keyed by `(profile_id, library_id, series_id)`. | 01 |
|
||||
| `notification_deliveries` | Per-profile durable inbox row. Cross-library dedupe via partial unique `(profile_id, episode_id)`. | 01 |
|
||||
| `notification_preferences` | Per-profile reason toggles + master enable. | 01 |
|
||||
| `push_devices` | Per-profile-per-device push registration. Platform-tagged (`apple` / `android`). | 02 / 03 |
|
||||
| `push_delivery_attempts` | Per-attempt push log. No notification content. | 02 / 03 |
|
||||
| `notification_webhooks` | Per-profile webhook destinations (Discord or generic). | 04 |
|
||||
| `webhook_delivery_attempts` | Per-attempt webhook log. | 04 |
|
||||
|
||||
A single migration block lands the foundation tables. APNs/FCM/webhook tables can land in separate migrations as those channels ship — they don't block the v1 in-app inbox.
|
||||
|
||||
## API surface overview
|
||||
|
||||
Profile-scoped (require `X-Profile-Id` header):
|
||||
|
||||
- `GET /api/v1/notifications` — paginated inbox (newest-first, `before` cursor).
|
||||
- `GET /api/v1/notifications/sync` — forward sync (`since` cursor); the endpoint mobile clients call after a push wake.
|
||||
- `GET /api/v1/notifications/{id}` — single delivery by ID.
|
||||
- `GET /api/v1/notifications/unread-count` — for the badge.
|
||||
- `POST /api/v1/notifications/{id}/read` — mark single read.
|
||||
- `POST /api/v1/notifications/read-all` — mark all read.
|
||||
- `POST /api/v1/events/ws-ticket` — mint a short-lived single-use websocket ticket bound to `(user_id, profile_id)`.
|
||||
- `GET /api/v1/notifications/preferences` — read profile prefs.
|
||||
- `PUT /api/v1/notifications/preferences` — update profile prefs.
|
||||
- `GET /api/v1/notifications/capability` — what channels are available.
|
||||
- `POST /api/v1/devices/push/apple` — register an APNs device.
|
||||
- `POST /api/v1/devices/push/fcm` — register an FCM device.
|
||||
- `DELETE /api/v1/devices/push/{id}` — disable a push device.
|
||||
- `GET /api/v1/notifications/webhooks` — list profile's webhooks.
|
||||
- `POST /api/v1/notifications/webhooks` — create a webhook.
|
||||
- `PUT /api/v1/notifications/webhooks/{id}` — update a webhook.
|
||||
- `DELETE /api/v1/notifications/webhooks/{id}` — delete a webhook.
|
||||
- `POST /api/v1/notifications/webhooks/{id}/test` — fire a test event.
|
||||
|
||||
Admin-scoped:
|
||||
|
||||
- Settings registry entries for APNs and FCM provider configuration (admin-only via existing `internal/api/handlers/settings.go` patterns).
|
||||
|
||||
## Realtime channel
|
||||
|
||||
A new event channel `notifications` joins the existing `catalog`, `jobs`, `sessions`, `tasks`, `scans`, `history_import`, `user_state`, `plugins` channels in `internal/events/types.go`. The websocket handler must validate active profile identity and must reject `notifications` events whose `profile_id` doesn't match the connection's bound profile.
|
||||
|
||||
Because browsers can't set custom headers on WebSocket connections, profile identity is carried by a **short-lived single-use ticket**: the client calls `POST /api/v1/events/ws-ticket` (normal auth + `X-Profile-Id`), receives an opaque ticket valid for ~30 seconds, and passes it as a `ticket` query parameter on the handshake. The server consumes the ticket and binds the connection to the `(user_id, profile_id)` it was minted for. A long-lived profile token must **not** be passed in the query string — self-hosted servers commonly sit behind reverse proxies whose access logs capture query strings, and a logged ticket that expired 30 seconds after minting is harmless where a logged profile token is not.
|
||||
|
||||
Event types on this channel:
|
||||
|
||||
- `notification.created` — a new delivery for the bound profile.
|
||||
- `notification.read` — a delivery was marked read (allows multi-tab coherence).
|
||||
|
||||
Snapshot on subscribe: recent unread deliveries for the bound profile, so reconnecting clients hydrate without a separate REST call.
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
- Movie release notifications (no trigger surface; v2).
|
||||
- Request-fulfilled notifications ("your requested item is now available"). Silo's requests system postdates this design; it is the most obvious next notification type and slots into the extensible `notification_deliveries.type` registry without schema changes. v2.
|
||||
- Aggregated notifications ("3 new episodes available"; presentation layer, can stack on per-episode rows later). The per-series burst cap in `01` bounds bulk-addition volume until an aggregated delivery type exists.
|
||||
- Web Push (browser PWA notifications via Push API). Possible v2 — would mirror APNs/FCM as a third push platform.
|
||||
- Email or SMS delivery.
|
||||
- Cross-profile / household-wide notification views.
|
||||
- Admin-level system notifications ("scan complete", "library health alert"). The existing realtime events hub (`internal/events/`, with the operational publisher wrapper at `internal/notifications/hub.go`) already handles operational events; this folder's design is strictly user-facing media notifications.
|
||||
- Notification templates / customization beyond Discord-vs-generic.
|
||||
- Time-window quiet hours. Could be added to `notification_preferences` later; not v1.
|
||||
- Locale negotiation for push payloads. APNs / FCM payloads use generic localization keys (`SILO_NOTIFICATION_GENERIC_BODY` etc.) so the OS picks language from device locale.
|
||||
|
||||
## Open questions
|
||||
|
||||
These are the few remaining items not yet decided. None block writing the per-channel specs.
|
||||
|
||||
1. **Service-level rate limits per relay account.** Per-second and per-day caps need real numbers. Suggest 10 req/sec burst, 50,000 req/day initial quota; tune from logs. The server-side dispatchers pace their relay calls client-side (default 5 req/sec token bucket, see `02`/`03`) so a hundreds-of-users server drains bursts smoothly instead of slamming into 429s; the per-series burst cap in `01` bounds worst-case burst size.
|
||||
2. **Webhook destination domain blocklist edge cases.** RFC ranges are defined in `04-outbound-webhooks.md`; remaining open question is whether IPv6 v4-mapped addresses (`::ffff:0:0/96`) and CGNAT (`100.64.0.0/10`) need any admin escape hatch beyond the global `allow_private_destinations` flag.
|
||||
3. **Discord image proxy timing.** Ship in v1 (build `media.discord-cdn-proxy.silo.app`) or defer to v1.5 with text-only embeds? Recommendation in `04`: defer to v1.5; v1 ships text-only embeds.
|
||||
|
||||
Resolved during review (no longer open):
|
||||
|
||||
- **Relay service code lives in a separate repo** (provisional `silo-push-relay`). This folder defines the contract.
|
||||
- **Migration ordering.** Foundation schema lands as one migration; per-channel schemas can land separately as their channels ship.
|
||||
- **`server_device_id` lifecycle.** Stable across token rotation; rotates only when a device is removed and re-registered.
|
||||
|
||||
## Phasing
|
||||
|
||||
Implementation phases gated by feature flags:
|
||||
|
||||
| Phase | Flag | Capability |
|
||||
|---|---|---|
|
||||
| 1 | `notifications.schema_enabled` | Tables exist; no writes. |
|
||||
| 2 | `notifications.interest_updaters_enabled` | `profile_series_interest` updates land on favorites/watchlist/playback events. Backfill task runs. |
|
||||
| 3 | `notifications.release_events_enabled` | Availability seeding backfill runs first, then ingest writes `release_events`. No fanout yet. |
|
||||
| 4 | `notifications.fanout_enabled` | Fanout worker runs; `notification_deliveries` rows materialize. Websocket channel published. |
|
||||
| 5 | `notifications.ui_enabled` | Inbox API + frontend badge + page exposed. |
|
||||
| 6 | `notifications.webhooks_enabled` | Outbound webhooks + Discord/generic dispatchers. v1 ships **text-only** Discord embeds (no `image` or `avatar_url` fields) to avoid leaking the user's server origin to Discord. v1.5 adds the optional `media.discord-cdn-proxy.silo.app` proxy and re-enables embed images. |
|
||||
| 7 | `notifications.apple_push_enabled` | APNs registration + dispatcher. Requires relay infra (or admin-supplied custom credentials). |
|
||||
| 8 | `notifications.android_push_enabled` | FCM registration + dispatcher. Requires relay infra (or admin-supplied custom credentials). |
|
||||
|
||||
Phases 1-6 ship without any new external infrastructure. Phases 7-8 require Silo to provision Apple Developer + Firebase developer accounts and operate the relay service.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,891 @@
|
||||
# Privacy-Preserving APNs Relay Spec
|
||||
|
||||
**Date:** 2026-04-27 (refined 2026-04-28)
|
||||
**Status:** Draft (refined; open questions resolved)
|
||||
**Scope:** Silo server push integration, Apple clients, opt-in central APNs relay, custom APNs provider configuration
|
||||
**Depends On:**
|
||||
- [`00-architecture-overview.md`](./00-architecture-overview.md) — read first for cross-cutting context
|
||||
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — foundational durable inbox + fanout
|
||||
**Sibling spec:**
|
||||
- [`03-fcm-relay.md`](./03-fcm-relay.md) — Android push using the same threat model and architecture
|
||||
|
||||
## Summary
|
||||
|
||||
Silo should support remote Apple notifications through either an opt-in
|
||||
central APNs relay or admin-configured APNs credentials, while keeping
|
||||
notification metadata on the user's own Silo server.
|
||||
|
||||
The hosted relay should be a constrained transport service. It should receive
|
||||
only the minimum fields needed to submit an APNs request:
|
||||
|
||||
- an APNs device token
|
||||
- APNs environment and app topic
|
||||
- a push mode
|
||||
- an opaque server/device correlation key
|
||||
- an opaque delivery identifier
|
||||
- optional badge count
|
||||
|
||||
Neither provider path may send titles, item names, usernames, profile names,
|
||||
library names, server URLs, artwork URLs, watched-state metadata, or
|
||||
notification body text through APNs.
|
||||
|
||||
When the Apple device wakes, the app should fetch real notification metadata
|
||||
directly from the user's Silo server using the existing authenticated
|
||||
server connection.
|
||||
|
||||
## Problem
|
||||
|
||||
Reliable Apple remote notifications require APNs. A self-hosted Silo server
|
||||
cannot directly wake an iPhone, iPad, or Apple TV app in the background without
|
||||
going through Apple's push infrastructure.
|
||||
|
||||
That creates two product concerns:
|
||||
|
||||
1. Privacy: notification contents should not be exposed to Apple or a Silo
|
||||
operated relay.
|
||||
2. Operational friction: most server admins should not need to create and manage
|
||||
their own Apple developer account, APNs key, bundle topic, and push service.
|
||||
|
||||
Silo should offer a practical default that preserves the self-hosted trust
|
||||
model as much as the platform allows.
|
||||
|
||||
## Decision
|
||||
|
||||
Add configurable Apple push providers for the official Apple app:
|
||||
|
||||
- `off`: no remote Apple push
|
||||
- `silo_relay`: Silo's hosted APNs relay
|
||||
- `custom_apns`: admin-supplied APNs credentials used directly by the server
|
||||
|
||||
The relay submits APNs requests using Silo-controlled Apple credentials for
|
||||
the official app bundle. A self-hosted server admin may opt in to use this relay.
|
||||
The relay's API should intentionally prevent notification content from being
|
||||
sent through the relay.
|
||||
|
||||
If `custom_apns` is selected, the user's Silo server submits directly to
|
||||
APNs using admin-provided Apple developer credentials. This avoids the Silo
|
||||
relay entirely, but it must use the same minimal payload and metadata-fetch
|
||||
rules as the hosted relay path.
|
||||
|
||||
The default push mode should be **Private Alert**:
|
||||
|
||||
- APNs displays a generic Silo notification.
|
||||
- The APNs payload contains only opaque identifiers.
|
||||
- The app fetches the actual notification row from the user's server when it is
|
||||
opened or granted background execution.
|
||||
|
||||
The implementation should also support **Background Wake** for low-noise sync,
|
||||
with the explicit understanding that iOS background pushes are opportunistic and
|
||||
not a reliable user-visible notification mechanism.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make Apple push possible without every admin managing APNs credentials.
|
||||
- Keep actual notification metadata on the user's Silo server.
|
||||
- Make relay participation explicit and disabled by default.
|
||||
- Make APNs credentials configurable for admins who want to avoid the hosted
|
||||
relay.
|
||||
- Keep the relay stateless for device subscriptions where possible.
|
||||
- Keep APNs payloads opaque and generic.
|
||||
- Keep the hosted relay and custom APNs paths behaviorally equivalent from the
|
||||
app's point of view.
|
||||
- Fit on top of the durable notification inbox design.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing APNs for Apple remote push.
|
||||
- Shipping notification content through the central relay.
|
||||
- Requiring Firebase or Google services for this Apple path.
|
||||
- Solving Android push in this spec.
|
||||
- Adding marketing, analytics, or delivery-tracking exports to the relay.
|
||||
- Hiding APNs itself from Apple devices. Custom APNs still uses Apple APNs.
|
||||
- Making silent background delivery as reliable as visible push.
|
||||
|
||||
## Threat Model
|
||||
|
||||
### What The User's Server Knows
|
||||
|
||||
The user's Silo server knows:
|
||||
|
||||
- the notification content
|
||||
- the profile and user recipients
|
||||
- the registered local devices
|
||||
- APNs device tokens for those devices
|
||||
- push delivery attempts and failures
|
||||
|
||||
This is acceptable because the server is already the authority for the user's
|
||||
media, profiles, sessions, and notification inbox.
|
||||
|
||||
### What The Central Relay May Know
|
||||
|
||||
The relay may see:
|
||||
|
||||
- an opt-in relay account or install identifier
|
||||
- the server's egress IP address (inherent to any hosted relay — the server
|
||||
connects to it directly; for home hosting this identifies the household's
|
||||
connection, making it the closest thing to an identity leak in this design)
|
||||
- APNs device tokens submitted in send requests
|
||||
- request timestamps
|
||||
- coarse push mode, such as `private_alert` or `background_wake`
|
||||
- opaque `collapse_id` values (per-server-keyed HMACs of series identity; the
|
||||
relay can group one server's pushes into series-equivalence classes but
|
||||
cannot recover which series, nor correlate across servers — see "Send Apple
|
||||
Push" validation)
|
||||
- APNs response status and APNs request IDs
|
||||
- opaque delivery identifiers
|
||||
|
||||
The relay must not receive enough information to know what media item,
|
||||
notification type, profile, user, library, server hostname, or server URL caused
|
||||
the push.
|
||||
|
||||
### What Apple May Know
|
||||
|
||||
Apple/APNs may see:
|
||||
|
||||
- the official app topic
|
||||
- the target device token
|
||||
- APNs headers
|
||||
- request timing
|
||||
- the generic APNs payload
|
||||
|
||||
Apple must not receive media titles, usernames, library names, server URLs,
|
||||
artwork URLs, or other notification metadata in the payload.
|
||||
|
||||
### Residual Metadata Leakage
|
||||
|
||||
This design cannot hide that a device received a Silo push at a particular
|
||||
time, nor the egress IP of the server sending relay requests. It can only hide
|
||||
the meaning and content of the push. Admins for whom IP exposure matters can
|
||||
route relay traffic through their existing VPN/proxy egress; the relay path is
|
||||
plain HTTPS and needs no special handling.
|
||||
|
||||
## User And Admin Controls
|
||||
|
||||
### Server Admin Control
|
||||
|
||||
The server admin must explicitly choose an Apple push provider.
|
||||
|
||||
Suggested setting:
|
||||
|
||||
```text
|
||||
notifications.apple_push.provider = off | silo_relay | custom_apns
|
||||
```
|
||||
|
||||
Default:
|
||||
|
||||
```text
|
||||
off
|
||||
```
|
||||
|
||||
If `silo_relay` is selected, the admin configures a relay API key and
|
||||
accepts a clear privacy notice:
|
||||
|
||||
```text
|
||||
Silo Relay can wake Apple devices through APNs, but notification details
|
||||
stay on this server. The relay receives APNs device tokens, timestamps, opaque
|
||||
delivery IDs, and generic push mode only.
|
||||
```
|
||||
|
||||
If `custom_apns` is selected, the admin configures their own Apple developer
|
||||
APNs credentials:
|
||||
|
||||
```text
|
||||
notifications.apple_push.custom.team_id
|
||||
notifications.apple_push.custom.key_id
|
||||
notifications.apple_push.custom.private_key
|
||||
notifications.apple_push.custom.default_environment = production | sandbox
|
||||
notifications.apple_push.custom.allowed_topics = [...]
|
||||
```
|
||||
|
||||
The server then sends directly to APNs and does not call the Silo relay.
|
||||
The same private payload rules still apply.
|
||||
|
||||
### Device User Control
|
||||
|
||||
Each Apple device must opt in independently:
|
||||
|
||||
- the OS notification permission must be granted
|
||||
- the app must be signed into a Silo server
|
||||
- the active profile must enable push notifications for that device
|
||||
- the server admin must have enabled an Apple push provider
|
||||
|
||||
Admins must not be able to silently enroll every profile/device into remote push
|
||||
without the device having granted OS notification permission.
|
||||
|
||||
### Profile Control
|
||||
|
||||
Push preferences should sit on top of the durable notification preferences from
|
||||
the inbox design.
|
||||
|
||||
Suggested profile/device modes:
|
||||
|
||||
- `off`: never send remote push to this device
|
||||
- `in_app_only`: websocket and inbox only
|
||||
- `private_push`: generic APNs wake/alert, fetch details from server
|
||||
- `full_preview`: out of scope for v1; would require explicit user consent and
|
||||
a separate privacy warning
|
||||
|
||||
V1 should ship only `off`, `in_app_only`, and `private_push`.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Apple App
|
||||
participant Server as User Silo Server
|
||||
participant Relay as Silo APNs Relay
|
||||
participant APNs as Apple APNs
|
||||
|
||||
App->>App: Request OS notification permission
|
||||
App->>App: Register for remote notifications
|
||||
App->>Server: Register device token and opaque server_device_id
|
||||
Server->>Server: Store encrypted token and device preferences
|
||||
Server->>Server: Create durable notification_delivery
|
||||
Server->>Relay: Send minimal APNs relay request
|
||||
Relay->>APNs: Submit generic APNs payload
|
||||
APNs->>App: Deliver private alert or background wake
|
||||
App->>Server: Fetch notification metadata directly
|
||||
Server->>App: Return inbox rows for active profile
|
||||
```
|
||||
|
||||
## Ownership Boundaries
|
||||
|
||||
### This Repository
|
||||
|
||||
Silo owns:
|
||||
|
||||
- admin settings for selecting a push provider
|
||||
- profile and device notification preferences
|
||||
- APNs token registration from Apple clients
|
||||
- durable storage of device registrations
|
||||
- push fanout from `notification_deliveries`
|
||||
- relay client integration
|
||||
- retry, backoff, and APNs error handling
|
||||
- inbox sync APIs used after wake
|
||||
|
||||
### Apple Client Repository
|
||||
|
||||
The Apple app owns:
|
||||
|
||||
- APNs registration
|
||||
- notification permission UX
|
||||
- device registration API calls
|
||||
- local server/device correlation
|
||||
- wake handling
|
||||
- fetching metadata from the user's server
|
||||
- rendering local/in-app notification details
|
||||
|
||||
### Central Relay Service
|
||||
|
||||
The central relay owns:
|
||||
|
||||
- APNs provider credentials for official Silo bundle topics
|
||||
- relay API authentication
|
||||
- request validation and rate limiting
|
||||
- APNs request submission
|
||||
- redacted operational logs
|
||||
- minimal delivery status reporting
|
||||
|
||||
The relay should live outside this repo unless the project later decides to
|
||||
co-locate the service code. This repo should define the contract and implement
|
||||
the self-hosted server side.
|
||||
|
||||
### Custom APNs Mode
|
||||
|
||||
When `custom_apns` is selected, there is no central relay in the send path.
|
||||
|
||||
The user's Silo server owns:
|
||||
|
||||
- APNs provider authentication using the configured team ID, key ID, and private
|
||||
key
|
||||
- topic allowlisting for the official Silo app bundle IDs it intends to
|
||||
support
|
||||
- sandbox vs production environment selection
|
||||
- APNs request construction
|
||||
- APNs response handling
|
||||
- APNs credential rotation and secret storage
|
||||
|
||||
Custom APNs mode must not unlock richer payloads. It exists to let admins avoid
|
||||
the Silo relay, not to bypass the privacy-preserving notification contract.
|
||||
|
||||
## Data Model
|
||||
|
||||
This spec assumes `notification_deliveries` exists from the durable notification
|
||||
inbox design.
|
||||
|
||||
### `push_devices`
|
||||
|
||||
Purpose: profile/device-scoped remote push registration. Shared across Apple and Android — see [`03-fcm-relay.md`](./03-fcm-relay.md) for the FCM-specific columns.
|
||||
|
||||
Columns:
|
||||
|
||||
- `id text primary key`
|
||||
- `user_id integer not null` (matches `users.id integer`)
|
||||
- `profile_id text not null`
|
||||
- `device_id text not null`
|
||||
- `platform text not null` — `'apple'` or `'android'`. Required; the row is invalid without it.
|
||||
- `provider text not null` — for Apple rows: `'silo_relay'` or `'custom_apns'`. For Android rows: `'silo_relay'` or `'custom_fcm'`. (Effectively the active configured provider at registration time; updates rewrite the row.)
|
||||
- `apns_environment text` — `'production'` / `'sandbox'`. Nullable; populated only for `platform = 'apple'`.
|
||||
- `apns_topic text` — bundle topic. Nullable; populated only for `platform = 'apple'`.
|
||||
- `apns_token_ciphertext bytea` — nullable; populated only for `platform = 'apple'`.
|
||||
- `apns_token_hash text` — nullable; populated only for `platform = 'apple'`.
|
||||
- `fcm_token_ciphertext bytea` — nullable; populated only for `platform = 'android'`. Defined here so the table is forward-compatible with [`03-fcm-relay.md`](./03-fcm-relay.md); the FCM spec adds the rest of the Android-specific columns.
|
||||
- `fcm_token_hash text` — nullable; Android only.
|
||||
- `fcm_project_id text` — nullable; Android only.
|
||||
- `fcm_package_name text` — nullable; Android only.
|
||||
- `server_device_id text not null` — random opaque value, generated server-side at registration. Stable across token rotation; rotates only when the device is removed and re-registered.
|
||||
- `push_mode text not null default 'private_push'` — profile-level mode (see [`00-architecture-overview.md`](./00-architecture-overview.md) "Mode terminology").
|
||||
- `enabled boolean not null default true`
|
||||
- `last_seen_at timestamptz`
|
||||
- `last_success_at timestamptz`
|
||||
- `last_failure_at timestamptz`
|
||||
- `last_failure_code text`
|
||||
- `created_at timestamptz not null default now()`
|
||||
- `updated_at timestamptz not null default now()`
|
||||
|
||||
Constraints:
|
||||
|
||||
- unique on `(profile_id, device_id, platform)` — a device can register exactly once per platform per profile, regardless of which provider the admin happens to have configured. This matters because admins may flip between `silo_relay` and `custom_apns` mid-rotation; a `provider`-scoped unique key would leave stale rows behind.
|
||||
- unique on `(server_device_id)`
|
||||
- `CHECK ((platform = 'apple' AND apns_token_hash IS NOT NULL AND fcm_token_hash IS NULL) OR (platform = 'android' AND fcm_token_hash IS NOT NULL AND apns_token_hash IS NULL))` — exactly the platform-appropriate token columns are populated.
|
||||
|
||||
Notes:
|
||||
|
||||
- `server_device_id` must be random and opaque. It is the value included in the
|
||||
APNs payload so the app can map the push back to the correct local server
|
||||
account.
|
||||
- Store APNs tokens encrypted at rest when the local encryption facility exists.
|
||||
- Store `apns_token_hash` for dedupe and diagnostics without logging raw tokens.
|
||||
|
||||
### `push_delivery_attempts`
|
||||
|
||||
Purpose: operational record of push attempts without notification content.
|
||||
|
||||
Columns:
|
||||
|
||||
- `id text primary key`
|
||||
- `notification_delivery_id text not null`
|
||||
- `push_device_id text not null`
|
||||
- `provider text not null`
|
||||
- `attempt_number integer not null` — 1-based counter incremented on each retry. Used as the `Idempotency-Key` suffix for relay calls (see Send Apple Push), and lets diagnostics distinguish "10 attempts of one delivery" from "10 deliveries each first-attempted".
|
||||
- `relay_request_id text`
|
||||
- `apns_id text` — Apple's APNs ID returned in the response. Nullable; null for FCM rows.
|
||||
- `fcm_message_name text` — FCM v1 message name (see [`03-fcm-relay.md`](./03-fcm-relay.md)). Nullable; null for APNs rows.
|
||||
- `status text not null` — `'pending'`, `'delivered'`, `'retrying'`, `'failed'`, `'device_disabled'`. `pending` rows are the dispatch outbox: the fanout transaction in [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) inserts attempt 1 as `pending` in the same transaction as the delivery; the push dispatcher claims and sends them post-commit.
|
||||
- `failure_code text`
|
||||
- `attempted_at timestamptz not null default now()`
|
||||
- `next_retry_at timestamptz`
|
||||
|
||||
Constraints:
|
||||
|
||||
- unique on `(notification_delivery_id, push_device_id, attempt_number)` to make the relay's `Idempotency-Key` deterministic and prevent double-claims of an attempt.
|
||||
- add an index on `(status, next_retry_at)` for retry workers. The same index serves outbox recovery: a sweeper claims `pending` rows older than ~60s (crash between delivery commit and dispatch) with `FOR UPDATE SKIP LOCKED`.
|
||||
|
||||
Retention:
|
||||
|
||||
- keep successful attempts for a short operational window, such as 14 days
|
||||
- keep failed attempts longer, such as 30 to 90 days, to diagnose device issues
|
||||
|
||||
## Server API Surface
|
||||
|
||||
### Register Apple Push Device
|
||||
|
||||
```http
|
||||
POST /api/v1/devices/push/apple
|
||||
```
|
||||
|
||||
Profile-scoped. Requires normal auth plus active profile context.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "apple-device-local-id",
|
||||
"apns_token": "hex-or-base64-token",
|
||||
"apns_environment": "production",
|
||||
"apns_topic": "com.continuum.app.ios",
|
||||
"push_mode": "private_push"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01J...",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"enabled": true,
|
||||
"push_mode": "private_push"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `apns_topic` must be allowlisted by app build/channel.
|
||||
- `apns_environment` must be `sandbox` or `production`.
|
||||
- registration is idempotent by `(profile_id, device_id, provider)`.
|
||||
- token rotation updates the encrypted token and token hash.
|
||||
- a disabled server push provider should still allow registration, but should
|
||||
report that remote push is unavailable in a capability field if the frontend
|
||||
needs it.
|
||||
|
||||
### Disable Push Device
|
||||
|
||||
```http
|
||||
DELETE /api/v1/devices/push/{id}
|
||||
```
|
||||
|
||||
Profile-scoped and idempotent.
|
||||
|
||||
The server may either delete the row or mark `enabled = false`. Marking disabled
|
||||
is preferred for diagnostics and future re-enable flows.
|
||||
|
||||
### Push Capability
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/capability
|
||||
```
|
||||
|
||||
The full envelope is defined in [`00-architecture-overview.md`](./00-architecture-overview.md). The Apple-relevant slice is:
|
||||
|
||||
```json
|
||||
{
|
||||
"apple_push": {
|
||||
"available": true,
|
||||
"provider": "silo_relay",
|
||||
"available_providers": ["silo_relay", "custom_apns"],
|
||||
"supported_modes": ["private_push", "in_app_only"],
|
||||
"privacy_mode": "metadata_fetch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The full response also includes `in_app`, `android_push` (see [`03-fcm-relay.md`](./03-fcm-relay.md)), and `webhooks` (see [`04-outbound-webhooks.md`](./04-outbound-webhooks.md)).
|
||||
|
||||
This lets clients present truthful setup UI without guessing whether the admin
|
||||
has enabled remote push.
|
||||
|
||||
## Relay API Contract
|
||||
|
||||
The relay API should be intentionally narrow. The user server should not send an
|
||||
arbitrary APNs payload.
|
||||
|
||||
### Send Apple Push
|
||||
|
||||
```http
|
||||
POST /v1/apple/send
|
||||
Authorization: Bearer <relay_api_key>
|
||||
Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "apns-token",
|
||||
"environment": "production",
|
||||
"topic": "com.continuum.app.ios",
|
||||
"mode": "private_alert",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY...",
|
||||
"badge": null,
|
||||
"collapse_id": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "01JRELAY...",
|
||||
"apns_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "accepted"
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `token` is required and must be plausible for APNs.
|
||||
- `environment` must be `sandbox` or `production`. The relay forwards `production` requests to `https://api.push.apple.com` and `sandbox` requests to `https://api.development.push.apple.com` (Apple's current canonical hostnames; older docs/libraries also reference `api.sandbox.push.apple.com`, but `api.development.push.apple.com` is the current Apple-documented form).
|
||||
- `topic` must be allowlisted for the relay account.
|
||||
- `mode` must be `private_alert` or `background_wake`.
|
||||
- `server_device_id` and `delivery_id` must be opaque values with length limits (recommend ≤128 chars, ULID-shaped).
|
||||
- `collapse_id` must be opaque and ≤64 bytes — Apple enforces a 64-byte cap on `apns-collapse-id`. Reject longer values with HTTP 400.
|
||||
- `collapse_id` derivation (server-side rule, not relay-enforced): the server computes `collapse_id = base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated to 26 chars, where `server_collapse_secret` is a random per-server secret generated at first use. Never send raw or plainly-hashed `series_id` — an unkeyed hash would let the relay (or Apple) dictionary-match popular series IDs. Residual: the relay can still group one server's pushes into per-series equivalence classes (that is what collapse is for); the per-server key prevents recovering the series or correlating across servers.
|
||||
- `badge` is optional. Default should be omitted to avoid leaking unread counts through APNs unless the user/admin explicitly enables badge sync.
|
||||
- no free-form notification title or body fields are accepted.
|
||||
- no image URL, media ID, username, server hostname, or server URL field is accepted.
|
||||
|
||||
### Relay-Built APNs Payloads
|
||||
|
||||
For `private_alert`, the relay constructs:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title-loc-key": "SILO_NOTIFICATION_TITLE",
|
||||
"loc-key": "SILO_NOTIFICATION_GENERIC_BODY"
|
||||
},
|
||||
"sound": "default"
|
||||
},
|
||||
"silo": {
|
||||
"v": 1,
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `background_wake`, the relay constructs:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"content-available": 1
|
||||
},
|
||||
"silo": {
|
||||
"v": 1,
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Headers:
|
||||
|
||||
- `apns-topic`: selected from the allowlisted `topic`
|
||||
- `apns-push-type`: `alert` for `private_alert`, `background` for
|
||||
`background_wake`
|
||||
- `apns-priority`: `10` for `private_alert`, `5` for `background_wake`
|
||||
- `apns-collapse-id`: optional opaque `collapse_id`
|
||||
|
||||
The relay should not allow callers to override this payload shape in v1.
|
||||
|
||||
## Custom APNs Contract
|
||||
|
||||
`custom_apns` should share the same internal send model as `silo_relay`,
|
||||
but replace the relay HTTP call with a direct APNs provider request.
|
||||
|
||||
The server-side push sender should accept only a structured internal request:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "apns-token",
|
||||
"environment": "production",
|
||||
"topic": "com.continuum.app.ios",
|
||||
"mode": "private_alert",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY...",
|
||||
"badge": null,
|
||||
"collapse_id": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
```
|
||||
|
||||
The sender builds the APNs payload locally using the exact same payload shapes
|
||||
defined above for `private_alert` and `background_wake`.
|
||||
|
||||
Configuration:
|
||||
|
||||
- `team_id`: Apple developer team ID.
|
||||
- `key_id`: APNs auth key ID.
|
||||
- `private_key`: APNs `.p8` private key, stored as a secret.
|
||||
- `default_environment`: `production` or `sandbox`.
|
||||
- `allowed_topics`: explicit bundle topics this server is allowed to send for.
|
||||
|
||||
Rules:
|
||||
|
||||
- The admin may configure credentials, environment, and topics.
|
||||
- The server must not expose a free-form APNs JSON payload setting.
|
||||
- The server must not expose custom title/body templates for remote push in v1.
|
||||
- Topic values must match the device registration topic and the configured
|
||||
allowlist.
|
||||
- Sandbox tokens must be sent to the sandbox endpoint and production tokens to
|
||||
the production endpoint.
|
||||
- APNs auth tokens should be cached briefly and regenerated before expiry.
|
||||
- Credential validation should provide a test-send or dry-run diagnostic that
|
||||
does not include notification content.
|
||||
|
||||
## Wake And Metadata Fetch
|
||||
|
||||
When the app receives a push:
|
||||
|
||||
1. Read `silo.v`, `server_device_id`, and `delivery_id`.
|
||||
2. Find the local server account that owns `server_device_id`.
|
||||
3. If background execution is available, call the server immediately.
|
||||
4. If the app is opened from the notification, call the server before rendering
|
||||
the target screen.
|
||||
5. Fetch notification metadata from the user's server.
|
||||
6. Render the real notification from server data.
|
||||
|
||||
Suggested fetch:
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/sync?since=<last_cursor>
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/{delivery_id}
|
||||
```
|
||||
|
||||
The sync endpoint is preferred because push delivery is not guaranteed and
|
||||
multiple notification deliveries can be coalesced.
|
||||
|
||||
If the app cannot reach the server, it should keep the generic notification and
|
||||
retry the inbox sync later.
|
||||
|
||||
## Fanout Rules
|
||||
|
||||
Push fanout should happen after durable inbox delivery commits.
|
||||
|
||||
Flow:
|
||||
|
||||
1. `notification_deliveries` row is created. **In the same transaction**, the
|
||||
fanout worker enqueues one `pending` `push_delivery_attempts` row per
|
||||
enabled `push_device` of the recipient profile (the dispatch outbox — see
|
||||
[`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
|
||||
"Transaction and Concurrency Rules").
|
||||
2. The realtime websocket event is published for connected clients.
|
||||
3. The push dispatcher claims the `pending` attempt rows post-commit
|
||||
(`FOR UPDATE SKIP LOCKED`); a recovery sweeper claims rows older than ~60s
|
||||
whose dispatch never ran.
|
||||
4. Each claimed attempt sends through the configured provider:
|
||||
- `silo_relay`: send the minimal relay request
|
||||
- `custom_apns`: build the same minimal APNs payload locally and send it
|
||||
directly to APNs
|
||||
5. APNs errors update device state and attempt status.
|
||||
|
||||
Do not block notification delivery creation on APNs or relay availability.
|
||||
|
||||
### Relay pacing
|
||||
|
||||
The dispatcher paces relay calls client-side with a token bucket (default 5
|
||||
req/sec, setting `notifications.push.relay_send_rate`), shared across the APNs
|
||||
and FCM dispatchers since both consume the same relay account quota. On a
|
||||
server with hundreds of users, a popular release can enqueue several hundred
|
||||
push attempts at once; pacing drains the queue smoothly instead of slamming
|
||||
into the relay's rate limit and burning retries on 429s. `TooManyRequests`
|
||||
from the relay is still honored with backoff — pacing is the steady-state
|
||||
mechanism, backoff is the safety net. The per-series burst cap in `01` bounds
|
||||
worst-case queue depth. Custom APNs sends are paced separately and more
|
||||
generously (Apple's own limits are far higher than any single-server relay
|
||||
quota).
|
||||
|
||||
## APNs Error Handling
|
||||
|
||||
The server should handle relay/APNs failures without exposing notification
|
||||
content.
|
||||
|
||||
Recommended handling:
|
||||
|
||||
- `BadDeviceToken` / `InvalidToken`: disable the token and require re-registration. (Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`; treat them equivalently.)
|
||||
- `Unregistered`: disable the token.
|
||||
- `DeviceTokenNotForTopic`: disable and log a build/topic mismatch.
|
||||
- `TooManyRequests`: retry with backoff.
|
||||
- `ExpiredProviderToken`: applies only to `custom_apns`. The cached APNs JWT has expired. Regenerate the JWT immediately and retry the request once before backing off. The relay path never surfaces this code because the relay manages its own JWTs.
|
||||
- `TooManyProviderTokenUpdates`: applies only to `custom_apns`. Apple has rate-limited JWT regeneration. Stop regenerating; reuse the existing token; retry after backoff. Indicates a bug in the JWT cache (regenerating more often than every 20 minutes).
|
||||
- relay unavailable, APNs unavailable, or network timeout: retry with backoff.
|
||||
- malformed request: mark failed and surface admin diagnostics.
|
||||
|
||||
Errors should be visible to admins as operational status, not to normal users
|
||||
unless their device needs reauthorization.
|
||||
|
||||
## Privacy Requirements
|
||||
|
||||
The implementation must satisfy these requirements before shipping:
|
||||
|
||||
- Relay requests contain no notification title or body.
|
||||
- Relay requests contain no media identifiers.
|
||||
- Relay requests contain no server hostname or base URL.
|
||||
- Relay requests contain no profile, username, library, collection, or item names.
|
||||
- Relay requests *do* carry `apns-topic` (e.g., `com.continuum.app.ios`) and `mode` (`private_alert` / `background_wake`). These are platform/build identifiers that Apple already sees; documenting them here so the privacy claim list is exhaustive rather than over-strong.
|
||||
- Direct APNs payloads contain no notification title or body beyond generic
|
||||
localizable keys.
|
||||
- Direct APNs payloads contain no media identifiers, server hostname, server URL,
|
||||
profile name, username, library name, collection name, or item name.
|
||||
- Relay logs redact raw APNs tokens.
|
||||
- Relay logs redact authorization headers.
|
||||
- Relay logs do not persist full request payloads.
|
||||
- Server logs redact APNs tokens and relay API keys.
|
||||
- Badge count is disabled by default.
|
||||
- Push registration and remote push provider use are opt-in.
|
||||
- Device unregister disables further push attempts for that device.
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- Relay API uses TLS only.
|
||||
- Relay API keys are scoped to a relay account or install identifier.
|
||||
- Relay API keys can be revoked without changing the user's Silo server
|
||||
authentication.
|
||||
- Relay requests are rate limited by API key and coarse token hash.
|
||||
- Relay supports idempotency keys to prevent duplicate APNs sends during retry.
|
||||
- Relay does not accept arbitrary APNs payload JSON in v1.
|
||||
- Server stores relay API keys as secrets.
|
||||
- Server stores custom APNs private keys as secrets.
|
||||
- Server never logs custom APNs private keys, generated provider tokens, or APNs
|
||||
auth headers.
|
||||
- Server stores APNs tokens encrypted at rest where local secret storage exists.
|
||||
- Admin diagnostics must not print raw APNs tokens.
|
||||
|
||||
## Settings And UX
|
||||
|
||||
Admin settings should explain the tradeoff plainly:
|
||||
|
||||
```text
|
||||
Apple Push Provider
|
||||
|
||||
Off
|
||||
No Apple remote push. Devices still receive in-app realtime updates while open.
|
||||
|
||||
Silo Relay
|
||||
Uses Silo's APNs relay to wake Apple devices. Notification details stay on
|
||||
this server. The relay receives APNs device tokens, timestamps, and opaque
|
||||
delivery IDs.
|
||||
|
||||
Custom APNs
|
||||
Advanced. Send directly to Apple APNs using your own Apple developer
|
||||
credentials. Notification details still stay on this server.
|
||||
```
|
||||
|
||||
Device settings should describe the user-visible mode:
|
||||
|
||||
```text
|
||||
Private Push
|
||||
Show a generic Silo notification, then fetch details from your server when
|
||||
this device wakes.
|
||||
```
|
||||
|
||||
Do not claim the central relay is fully self-hosted. The truthful claim is:
|
||||
|
||||
```text
|
||||
Notification content stays on your server. Apple, and the Silo relay if
|
||||
selected, may still process generic wake messages needed for Apple push
|
||||
delivery.
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Task 1: Add Push Provider Settings
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `internal/api/handlers/settings.go`
|
||||
- `web/src/lib/settingsManifest.ts`
|
||||
- settings UI files as needed
|
||||
|
||||
Add server/admin settings for:
|
||||
|
||||
- provider selection
|
||||
- relay endpoint
|
||||
- relay API key
|
||||
- custom APNs team ID
|
||||
- custom APNs key ID
|
||||
- custom APNs private key
|
||||
- custom APNs environment
|
||||
- custom APNs topic allowlist
|
||||
- badge sync enabled or disabled
|
||||
- relay send rate (`notifications.push.relay_send_rate`, shared with FCM pacing)
|
||||
|
||||
Default provider must be `off`.
|
||||
|
||||
### Task 2: Add Push Device Registration
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- new migration under `migrations/`
|
||||
- new package or service under `internal/notifications`
|
||||
- `internal/api/handlers/notifications.go`
|
||||
- `internal/api/router.go`
|
||||
|
||||
Add profile-scoped Apple push device registration, token rotation, and disable
|
||||
APIs.
|
||||
|
||||
### Task 3: Add Apple Push Provider Clients
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `internal/notifications/apple_relay.go`
|
||||
- `internal/notifications/apple_apns.go`
|
||||
- config/settings accessors
|
||||
|
||||
Implement the narrow `/v1/apple/send` client. The client should not accept a
|
||||
free-form title/body/content payload.
|
||||
|
||||
Implement the direct APNs client for `custom_apns` using the same internal
|
||||
request shape and payload builder.
|
||||
|
||||
### Task 4: Add Push Fanout Worker
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `internal/notifications/push_fanout.go`
|
||||
- notification delivery creation path from the durable inbox implementation
|
||||
|
||||
Trigger push fanout after `notification_deliveries` commit. Use retries and
|
||||
record `push_delivery_attempts`.
|
||||
|
||||
### Task 5: Add Apple Client Registration And Wake Handling
|
||||
|
||||
Files live in the Apple client repository, not this server repo.
|
||||
|
||||
Required client behavior:
|
||||
|
||||
- request notification permission
|
||||
- register APNs token with the user's server
|
||||
- store `server_device_id` locally
|
||||
- handle token rotation
|
||||
- fetch notification metadata after wake/open
|
||||
- unregister or disable on sign-out/profile removal
|
||||
|
||||
### Task 6: Add Admin Diagnostics
|
||||
|
||||
Expose high-level status:
|
||||
|
||||
- provider enabled/disabled
|
||||
- number of registered Apple devices
|
||||
- last relay success
|
||||
- last relay failure code
|
||||
- custom APNs credential presence and topic/environment status
|
||||
- last custom APNs success
|
||||
- last custom APNs failure code
|
||||
- token/topic mismatch warnings
|
||||
|
||||
Do not expose raw APNs tokens.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
Use minimal verification while the work is still design-only. When implemented,
|
||||
verify:
|
||||
|
||||
- relay-disabled servers never call the relay
|
||||
- `custom_apns` servers never call the relay
|
||||
- device registration is profile-scoped and idempotent
|
||||
- APNs tokens are redacted in logs
|
||||
- relay request bodies contain no titles, body text, item IDs, server URLs, or
|
||||
profile names
|
||||
- direct APNs payloads contain no titles, body text, item IDs, server URLs, or
|
||||
profile names beyond the generic localizable notification keys
|
||||
- custom APNs private keys and generated provider tokens are redacted in logs
|
||||
- creating one `notification_delivery` sends at most one push per enabled device
|
||||
- APNs token rotation updates the stored token without duplicating devices
|
||||
- `Unregistered` and `BadDeviceToken` disable the affected device
|
||||
- app wake fetches notification metadata from the user's server
|
||||
- server offline after push leaves only the generic notification visible
|
||||
|
||||
## Resolved Questions (was: Open Questions)
|
||||
|
||||
These four open questions from the original draft were resolved during the multi-channel refinement:
|
||||
|
||||
- **Topics: per-platform.** Official Silo builds use separate APNs topics for iOS, tvOS, and macOS. Each is a different bundle ID with its own provisioning. The `apns_topic` allowlist on a relay account enumerates the per-platform topics the account may push to.
|
||||
- **Badge counts: disabled in v1.** Badge count leaks unread volume through APNs. v1 ships without badge updates; an explicit per-profile opt-in to badge sync can land in v2 alongside a privacy notice.
|
||||
- **Relay statefulness: stateless v1.** No stored token aliases. The relay accepts raw APNs tokens on every request. Token aliases add complexity for a benefit (reduced repeat-token exposure to relay logs) that is small compared to "redact tokens from logs." Add aliases later if abuse-pattern analysis or rate-limit pressure justifies it.
|
||||
- **Relay deployment repo: out of scope for this spec.** The relay implementation lives in a separate repo (provisional name `silo-push-relay`). This document defines the contract the user-facing Silo server implements; the relay's internal architecture (deployment platform, dependency tree, etc.) is the relay repo's concern.
|
||||
|
||||
## Remaining Open Questions
|
||||
|
||||
- **Multi-bundle topic allowlist UX.** When an admin configures custom APNs, how do they declare allowlisted topics — free-text textarea, or a structured list? Recommendation: structured list with platform tags (iOS / tvOS / macOS) so the UI can show which platforms have working push.
|
||||
- **APNs JWT cache lifetime.** APNs JWTs are valid for 60 minutes per Apple's docs but may be reused across requests. Should the custom APNs path cache and reuse, or sign per-request? Recommendation: cache with regeneration at 50 minutes to leave headroom, matching Apple's recommendation.
|
||||
@@ -0,0 +1,748 @@
|
||||
# Privacy-Preserving FCM Relay Spec
|
||||
|
||||
**Date:** 2026-04-28
|
||||
**Status:** Draft
|
||||
**Scope:** Silo server push integration for Android, opt-in central FCM relay, custom FCM provider configuration
|
||||
**Depends On:**
|
||||
- [`00-architecture-overview.md`](./00-architecture-overview.md)
|
||||
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
|
||||
- [`02-apns-relay.md`](./02-apns-relay.md) — sibling spec; structurally identical with FCM-specific differences
|
||||
|
||||
## Summary
|
||||
|
||||
Silo should support remote Android notifications through either an opt-in central FCM relay or admin-configured Firebase Cloud Messaging credentials, while keeping notification metadata on the user's own Silo server.
|
||||
|
||||
The hosted relay should be a constrained transport service. It should receive only the minimum fields needed to submit a Firebase Cloud Messaging v1 API request:
|
||||
|
||||
- an FCM registration token
|
||||
- the target Firebase project ID and Android package name (allowlisted)
|
||||
- a push mode
|
||||
- an opaque server/device correlation key
|
||||
- an opaque delivery identifier
|
||||
- optional badge count (disabled by default)
|
||||
|
||||
Neither provider path may send titles, item names, usernames, profile names, library names, server URLs, artwork URLs, watched-state metadata, or notification body text through FCM.
|
||||
|
||||
When the Android device wakes, the app should fetch real notification metadata directly from the user's Silo server using the existing authenticated server connection.
|
||||
|
||||
## Why this spec mirrors APNs
|
||||
|
||||
The reasoning, threat model, and fanout flow are identical to the APNs case. Apple and Google both gatekeep mobile push at the app-bundle level: only the developer who signed and published the app can authenticate pushes to it. The same arguments for a hosted relay apply unchanged. The differences are entirely about FCM's protocol and credentials:
|
||||
|
||||
- **Auth:** FCM v1 API uses **OAuth2 with a Google service account**, not a JWT signed with a `.p8` key.
|
||||
- **Endpoint:** Pushes go to `https://fcm.googleapis.com/v1/projects/{project_id}/messages:send`.
|
||||
- **Project model:** A Firebase project owns the Android app's package name + SHA-1 cert fingerprint mapping. The relay holds one service account JSON per official Firebase project.
|
||||
- **Topics vs tokens:** FCM supports both topic broadcasts and per-token sends. Silo uses **per-token sends only** — topic broadcasts would leak audience information.
|
||||
- **Message types:** FCM has `notification` and `data` messages. The privacy design uses **data-only messages** so the OS can't render content the relay shouldn't have constructed.
|
||||
|
||||
## Decision
|
||||
|
||||
Add configurable Android push providers for the official Silo Android app:
|
||||
|
||||
- `off`: no remote Android push
|
||||
- `silo_relay`: Silo's hosted FCM relay
|
||||
- `custom_fcm`: admin-supplied FCM service account JSON used directly by the server
|
||||
|
||||
The relay submits FCM v1 requests using Silo-controlled Firebase credentials for the official app package. A self-hosted server admin may opt in to use this relay. The relay's API should intentionally prevent notification content from being sent through the relay.
|
||||
|
||||
If `custom_fcm` is selected, the user's Silo server calls FCM v1 directly using admin-provided service account JSON. This avoids the Silo relay entirely, but it must use the same minimal payload and metadata-fetch rules as the hosted relay path.
|
||||
|
||||
The default push mode should be **Private Data Wake**:
|
||||
|
||||
- FCM delivers a data-only message.
|
||||
- The data payload contains only opaque identifiers.
|
||||
- The Android app handles the message via `FirebaseMessagingService.onMessageReceived` and constructs a generic local notification while it fetches the actual notification row from the user's server.
|
||||
|
||||
The implementation should also support **Background Wake** for low-noise sync, with the explicit understanding that Android background data messages are subject to Doze, App Standby, and battery-optimization restrictions and are not a reliable user-visible notification mechanism.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make Android push possible without every admin managing their own Firebase project.
|
||||
- Keep actual notification metadata on the user's Silo server.
|
||||
- Make relay participation explicit and disabled by default.
|
||||
- Make FCM credentials configurable for admins who want to avoid the hosted relay.
|
||||
- Keep the relay stateless for device subscriptions where possible.
|
||||
- Keep FCM payloads opaque and generic (data-only).
|
||||
- Keep the hosted relay and custom FCM paths behaviorally equivalent from the app's point of view.
|
||||
- Fit on top of the durable notification inbox design.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing FCM for Android remote push.
|
||||
- Shipping notification content through the central relay.
|
||||
- Requiring Apple push services for this Android path.
|
||||
- Supporting Huawei Mobile Services (HMS) push, Amazon Device Messaging, or other Android-adjacent push systems in v1. Could be added later as parallel providers.
|
||||
- Adding marketing, analytics, or delivery-tracking exports to the relay.
|
||||
- Hiding FCM itself from Android devices. Custom FCM still uses Google FCM.
|
||||
- Making silent background delivery as reliable as visible notifications.
|
||||
|
||||
## Threat Model
|
||||
|
||||
### What The User's Server Knows
|
||||
|
||||
The user's Silo server knows:
|
||||
|
||||
- the notification content
|
||||
- the profile and user recipients
|
||||
- the registered local devices
|
||||
- FCM registration tokens for those devices
|
||||
- push delivery attempts and failures
|
||||
|
||||
This is acceptable because the server is already the authority for the user's media, profiles, sessions, and notification inbox.
|
||||
|
||||
### What The Central Relay May Know
|
||||
|
||||
The relay may see:
|
||||
|
||||
- an opt-in relay account or install identifier
|
||||
- the server's egress IP address (inherent to any hosted relay — the server connects to it directly; see the identical note in [`02-apns-relay.md`](./02-apns-relay.md))
|
||||
- FCM registration tokens submitted in send requests
|
||||
- request timestamps
|
||||
- coarse push mode, such as `private_data` or `background_wake`
|
||||
- opaque `collapse_key` values (per-server-keyed HMACs of series identity; same derivation rule as APNs `collapse_id`)
|
||||
- FCM response status and FCM message names
|
||||
- opaque delivery identifiers
|
||||
|
||||
The relay must not receive enough information to know what media item, notification type, profile, user, library, server hostname, or server URL caused the push.
|
||||
|
||||
### What Google May Know
|
||||
|
||||
Google / FCM may see:
|
||||
|
||||
- the official app package name and Firebase project
|
||||
- the target FCM registration token
|
||||
- FCM v1 request headers
|
||||
- request timing
|
||||
- the generic FCM data payload (which contains only opaque identifiers)
|
||||
|
||||
Google must not receive media titles, usernames, library names, server URLs, artwork URLs, or other notification metadata in the payload. In particular, the design uses **data-only messages**, never `notification` messages, so Google's servers don't carry rendering content.
|
||||
|
||||
### Residual Metadata Leakage
|
||||
|
||||
This design cannot hide that a device received a Silo push at a particular time, nor the egress IP of the server sending relay requests. It can only hide the meaning and content of the push. Additionally, on Android, FCM delivery may be delayed by Doze / App Standby; an adversary observing wake patterns may infer rough activity but not content.
|
||||
|
||||
## User And Admin Controls
|
||||
|
||||
### Server Admin Control
|
||||
|
||||
The server admin must explicitly choose an Android push provider.
|
||||
|
||||
Suggested setting:
|
||||
|
||||
```text
|
||||
notifications.android_push.provider = off | silo_relay | custom_fcm
|
||||
```
|
||||
|
||||
Default:
|
||||
|
||||
```text
|
||||
off
|
||||
```
|
||||
|
||||
If `silo_relay` is selected, the admin configures a relay API key and accepts a clear privacy notice:
|
||||
|
||||
```text
|
||||
Silo Relay can wake Android devices through Firebase Cloud Messaging,
|
||||
but notification details stay on this server. The relay receives FCM
|
||||
registration tokens, timestamps, opaque delivery IDs, and generic push mode
|
||||
only.
|
||||
```
|
||||
|
||||
If `custom_fcm` is selected, the admin configures their own Firebase service account JSON:
|
||||
|
||||
```text
|
||||
notifications.android_push.custom.service_account_json
|
||||
notifications.android_push.custom.project_id
|
||||
notifications.android_push.custom.allowed_packages = [...]
|
||||
```
|
||||
|
||||
The server then sends directly to FCM and does not call the Silo relay. The same private payload rules still apply.
|
||||
|
||||
### Device User Control
|
||||
|
||||
Each Android device must opt in independently:
|
||||
|
||||
- the OS notification permission must be granted (Android 13+ requires `POST_NOTIFICATIONS` runtime permission)
|
||||
- the app must be signed into a Silo server
|
||||
- the active profile must enable push notifications for that device
|
||||
- the server admin must have enabled an Android push provider
|
||||
|
||||
Admins must not be able to silently enroll every profile/device into remote push without the device having granted OS notification permission.
|
||||
|
||||
### Profile Control
|
||||
|
||||
Push preferences sit on top of the durable notification preferences from the inbox design. The push-mode field on a `push_devices` row is shared across platforms (`apple` and `android`) so the same `push_mode` enum applies.
|
||||
|
||||
Suggested profile/device modes:
|
||||
|
||||
- `off`: never send remote push to this device
|
||||
- `in_app_only`: websocket and inbox only
|
||||
- `private_push`: generic FCM data wake, fetch details from server
|
||||
- `full_preview`: out of scope for v1
|
||||
|
||||
V1 should ship only `off`, `in_app_only`, and `private_push`.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Android App
|
||||
participant Server as User Silo Server
|
||||
participant Relay as Silo FCM Relay
|
||||
participant FCM as Google FCM
|
||||
|
||||
App->>App: Request POST_NOTIFICATIONS permission (API 33+)
|
||||
App->>App: Get FCM registration token via FirebaseMessaging.getToken()
|
||||
App->>Server: Register registration token and opaque server_device_id
|
||||
Server->>Server: Store encrypted token and device preferences
|
||||
Server->>Server: Create durable notification_delivery
|
||||
Server->>Relay: Send minimal FCM relay request
|
||||
Relay->>FCM: Submit data-only message via FCM v1 API
|
||||
FCM->>App: Deliver data message; FirebaseMessagingService.onMessageReceived
|
||||
App->>Server: Fetch notification metadata directly
|
||||
Server->>App: Return inbox rows for active profile
|
||||
```
|
||||
|
||||
## Ownership Boundaries
|
||||
|
||||
### This Repository
|
||||
|
||||
Silo owns:
|
||||
|
||||
- admin settings for selecting an Android push provider
|
||||
- profile and device notification preferences
|
||||
- FCM token registration from Android clients
|
||||
- durable storage of device registrations
|
||||
- push fanout from `notification_deliveries`
|
||||
- relay client integration
|
||||
- retry, backoff, and FCM error handling
|
||||
- inbox sync APIs used after wake
|
||||
|
||||
### Android Client Repository
|
||||
|
||||
The Android app owns:
|
||||
|
||||
- FCM registration via Firebase SDK
|
||||
- notification permission UX (`POST_NOTIFICATIONS` on API 33+)
|
||||
- device registration API calls
|
||||
- local server/device correlation
|
||||
- `FirebaseMessagingService.onMessageReceived` handling
|
||||
- fetching metadata from the user's server
|
||||
- rendering local notifications via `NotificationManager` after the wake
|
||||
|
||||
### Central Relay Service
|
||||
|
||||
The central relay owns:
|
||||
|
||||
- FCM service account credentials for the official Silo Firebase project(s)
|
||||
- relay API authentication
|
||||
- request validation and rate limiting
|
||||
- FCM v1 request submission with OAuth2 access token caching
|
||||
- redacted operational logs
|
||||
- minimal delivery status reporting
|
||||
|
||||
The relay should live outside this repo unless the project later decides to co-locate the service code. This repo defines the contract and implements the self-hosted server side. The relay implementation can share most code with the APNs relay; only the upstream provider client differs.
|
||||
|
||||
### Custom FCM Mode
|
||||
|
||||
When `custom_fcm` is selected, there is no central relay in the send path.
|
||||
|
||||
The user's Silo server owns:
|
||||
|
||||
- FCM v1 OAuth2 token generation from the configured service account JSON
|
||||
- access token caching (Google's OAuth2 access tokens are short-lived; cache and refresh on expiry)
|
||||
- package allowlisting for the official Silo app package(s) it intends to support
|
||||
- FCM v1 request construction (data-only)
|
||||
- FCM response handling
|
||||
- FCM credential rotation and secret storage
|
||||
|
||||
Custom FCM mode must not unlock richer payloads. It exists to let admins avoid the Silo relay, not to bypass the privacy-preserving notification contract.
|
||||
|
||||
## Data Model
|
||||
|
||||
The `push_devices` and `push_delivery_attempts` schemas are owned by [`02-apns-relay.md`](./02-apns-relay.md) and are platform-tagged from the start (`platform = 'apple' | 'android'`) so this spec adds no new columns. The Android-relevant columns on `push_devices` are:
|
||||
|
||||
- `platform text not null` — must be `'android'` for FCM rows.
|
||||
- `provider text not null` — `'silo_relay'` or `'custom_fcm'`.
|
||||
- `fcm_token_ciphertext bytea` — required for Android rows.
|
||||
- `fcm_token_hash text` — required for Android rows; used for dedupe/diagnostics without logging raw tokens.
|
||||
- `fcm_project_id text` — the Firebase project the token was minted under.
|
||||
- `fcm_package_name text` — the Android package name (e.g., `com.continuum.app.android`).
|
||||
- All `apns_*` columns must be null for Android rows. The `CHECK` constraint defined in `02-apns-relay.md` enforces this exclusivity.
|
||||
|
||||
Unique constraints (defined in `02-apns-relay.md`): `(profile_id, device_id, platform)` and `(server_device_id)`. Both are intentionally platform-scoped, not provider-scoped — the same Android device cannot register twice even if the admin flips between `silo_relay` and `custom_fcm`.
|
||||
|
||||
`push_delivery_attempts` columns relevant to FCM (also owned by 02):
|
||||
|
||||
- `provider text not null` — for FCM rows: `'silo_relay'` or `'custom_fcm'`.
|
||||
- `apns_id text` — null for FCM rows.
|
||||
- `fcm_message_name text` — populated on success with FCM v1's response `name` (e.g., `"projects/continuum-prod-android/messages/0:..."`); null for APNs rows.
|
||||
- `attempt_number integer not null` — 1-based; participates in the unique constraint and in the relay `Idempotency-Key`.
|
||||
|
||||
Retention is identical to APNs: ~14 days for successes, 30-90 for failures.
|
||||
|
||||
## Server API Surface
|
||||
|
||||
### Register Android Push Device
|
||||
|
||||
```http
|
||||
POST /api/v1/devices/push/fcm
|
||||
```
|
||||
|
||||
Profile-scoped. Requires normal auth plus active profile context.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "android-device-local-id",
|
||||
"fcm_token": "long-fcm-registration-token",
|
||||
"fcm_project_id": "continuum-prod-android",
|
||||
"fcm_package_name": "com.continuum.app.android",
|
||||
"push_mode": "private_push"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01J...",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"enabled": true,
|
||||
"push_mode": "private_push"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `fcm_package_name` must be allowlisted for the configured provider's allowed_packages list.
|
||||
- `fcm_project_id` must match the configured Firebase project for the active provider.
|
||||
- registration is idempotent by `(profile_id, device_id, platform)` where platform is implied as `android`.
|
||||
- token rotation updates the encrypted token and token hash. FCM tokens rotate periodically; the app should call this endpoint whenever `FirebaseMessagingService.onNewToken` fires.
|
||||
- a disabled server push provider should still allow registration, but should report that remote push is unavailable in the capability response.
|
||||
|
||||
### Disable Push Device
|
||||
|
||||
Shared with APNs, see [`02-apns-relay.md`](./02-apns-relay.md):
|
||||
|
||||
```http
|
||||
DELETE /api/v1/devices/push/{id}
|
||||
```
|
||||
|
||||
### Push Capability
|
||||
|
||||
Extended from the APNs spec to include Android:
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/capability
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"in_app": { "enabled": true },
|
||||
"apple_push": {
|
||||
"available": true,
|
||||
"provider": "silo_relay",
|
||||
"available_providers": ["silo_relay", "custom_apns"],
|
||||
"supported_modes": ["private_push", "in_app_only"]
|
||||
},
|
||||
"android_push": {
|
||||
"available": true,
|
||||
"provider": "silo_relay",
|
||||
"available_providers": ["silo_relay", "custom_fcm"],
|
||||
"supported_modes": ["private_push", "in_app_only"]
|
||||
},
|
||||
"webhooks": {
|
||||
"available": true,
|
||||
"max_per_profile": 10,
|
||||
"supported_types": ["discord", "generic"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Relay API Contract
|
||||
|
||||
The relay API should be intentionally narrow. The user server should not send an arbitrary FCM payload.
|
||||
|
||||
### Send Android Push
|
||||
|
||||
```http
|
||||
POST /v1/fcm/send
|
||||
Authorization: Bearer <relay_api_key>
|
||||
Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "fcm-registration-token",
|
||||
"project_id": "continuum-prod-android",
|
||||
"package_name": "com.continuum.app.android",
|
||||
"mode": "private_data",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY...",
|
||||
"badge": null,
|
||||
"collapse_key": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "01JRELAY...",
|
||||
"fcm_message_name": "projects/continuum-prod-android/messages/0:1234567890123456%abcdef",
|
||||
"status": "accepted"
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
- `token` is required and must be plausible for FCM (FCM tokens are typically 152-180+ chars, base64url-ish).
|
||||
- `project_id` must match the configured Firebase project for this relay account's allowed packages.
|
||||
- `package_name` must be allowlisted for the relay account.
|
||||
- `mode` must be `private_data` or `background_wake`. Note: this is the **wire mode** (FCM-side); the profile-level mode is `private_push`. See [`00-architecture-overview.md`](./00-architecture-overview.md) "Mode terminology" for the mapping.
|
||||
- `server_device_id`, `delivery_id`, and `collapse_key` must be opaque values with length limits.
|
||||
- `collapse_key` derivation (server-side rule, not relay-enforced): identical to the APNs `collapse_id` rule in [`02-apns-relay.md`](./02-apns-relay.md) — `base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated, using the same per-server secret. Never send raw or plainly-hashed `series_id`.
|
||||
- `badge` is optional. Default should be omitted to avoid leaking unread counts unless the user/admin explicitly enables badge sync.
|
||||
- no free-form notification title or body fields are accepted.
|
||||
- no image URL, media ID, username, server hostname, or server URL field is accepted.
|
||||
|
||||
### Relay-Built FCM Payloads
|
||||
|
||||
For `private_data`, the relay constructs:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"token": "fcm-registration-token",
|
||||
"data": {
|
||||
"v": "1",
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
},
|
||||
"android": {
|
||||
"priority": "high",
|
||||
"collapse_key": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `background_wake`, the relay constructs:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"token": "fcm-registration-token",
|
||||
"data": {
|
||||
"v": "1",
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
},
|
||||
"android": {
|
||||
"priority": "normal",
|
||||
"collapse_key": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The relay sends **data-only messages** (no top-level `notification` field) so Google never carries rendering content. The Android app constructs the local notification in `onMessageReceived`.
|
||||
- `priority: high` is used for `private_data` so the message wakes the app even under Doze. High-priority data messages are subject to FCM's anti-abuse quotas — if a server hits the quota, deliveries fall back to normal priority and may be deferred.
|
||||
- `collapse_key` allows multiple pushes for the same series to coalesce when the device is offline, mirroring `apns-collapse-id`.
|
||||
- The relay does not allow callers to override this payload shape in v1.
|
||||
|
||||
## Custom FCM Contract
|
||||
|
||||
`custom_fcm` should share the same internal send model as `silo_relay`, but replace the relay HTTP call with a direct FCM v1 API request.
|
||||
|
||||
The server-side push sender should accept only a structured internal request (same shape as the relay request body above):
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "fcm-registration-token",
|
||||
"project_id": "continuum-prod-android",
|
||||
"package_name": "com.continuum.app.android",
|
||||
"mode": "private_data",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY...",
|
||||
"badge": null,
|
||||
"collapse_key": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
```
|
||||
|
||||
The sender builds the FCM v1 payload locally using the exact same payload shapes defined above for `private_data` and `background_wake`.
|
||||
|
||||
Configuration:
|
||||
|
||||
- `service_account_json`: full Firebase service account JSON, stored as a secret.
|
||||
- `project_id`: Firebase project ID.
|
||||
- `allowed_packages`: explicit Android package names this server is allowed to send for.
|
||||
|
||||
Rules:
|
||||
|
||||
- The admin may configure credentials, project ID, and allowed packages.
|
||||
- The server must not expose a free-form FCM JSON payload setting.
|
||||
- The server must not expose custom title/body templates for remote push in v1.
|
||||
- Package values must match the device registration package and the configured allowlist.
|
||||
- OAuth2 access tokens should be cached and regenerated before expiry (Google access tokens are typically valid for 1 hour).
|
||||
- Credential validation should provide a test-send or dry-run diagnostic that does not include notification content. FCM v1 supports `validate_only: true` requests for this purpose.
|
||||
|
||||
## Wake And Metadata Fetch
|
||||
|
||||
When the app receives an FCM data message:
|
||||
|
||||
1. `FirebaseMessagingService.onMessageReceived(remoteMessage)` fires.
|
||||
2. Read `data.v`, `data.server_device_id`, and `data.delivery_id`.
|
||||
3. Find the local server account that owns `server_device_id`.
|
||||
4. If the app is in the foreground, update in-app inbox state directly.
|
||||
5. If background:
|
||||
- Construct a generic local notification using `NotificationManager` with a localized "New Silo notification" title.
|
||||
- Schedule a `WorkManager` job to fetch the actual metadata from the user's server.
|
||||
- When the fetch returns, update the visible notification with real content (or replace it).
|
||||
6. If the app is opened from the notification, call the server before rendering the target screen.
|
||||
|
||||
Suggested fetch:
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/sync?since=<last_cursor>
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/{delivery_id}
|
||||
```
|
||||
|
||||
The sync endpoint is preferred because push delivery is not guaranteed and multiple notification deliveries can be coalesced.
|
||||
|
||||
If the app cannot reach the server, it should keep the generic notification and retry the inbox sync when connectivity returns.
|
||||
|
||||
## Fanout Rules
|
||||
|
||||
Push fanout should happen after durable inbox delivery commits. Same flow as APNs:
|
||||
|
||||
1. `notification_deliveries` row is created. In the same transaction, the fanout worker enqueues one `pending` `push_delivery_attempts` row per enabled Android `push_device` (the dispatch outbox — see [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)).
|
||||
2. The realtime websocket event is published for connected clients.
|
||||
3. The push dispatcher claims the `pending` attempt rows post-commit, partitioned by platform; a recovery sweeper claims rows older than ~60s whose dispatch never ran.
|
||||
4. Each claimed attempt sends through the configured Android provider:
|
||||
- `silo_relay`: send the minimal relay request.
|
||||
- `custom_fcm`: build the same minimal FCM v1 payload locally and send directly.
|
||||
5. FCM errors update device state and attempt status.
|
||||
|
||||
Do not block notification delivery creation on FCM or relay availability.
|
||||
|
||||
Relay pacing is shared with APNs: one token bucket (`notifications.push.relay_send_rate`, default 5 req/sec) covers both dispatchers because both consume the same relay account quota. See [`02-apns-relay.md`](./02-apns-relay.md) "Relay pacing".
|
||||
|
||||
## FCM Error Handling
|
||||
|
||||
The server should handle relay/FCM failures without exposing notification content.
|
||||
|
||||
FCM v1 error codes worth handling explicitly:
|
||||
|
||||
- `UNREGISTERED` (404): the token is no longer valid (app uninstalled or data cleared). Disable the device and require re-registration.
|
||||
- `INVALID_ARGUMENT` (400): malformed request. Mark failed and surface admin diagnostics. Should be rare given the constrained payload.
|
||||
- `SENDER_ID_MISMATCH` (403): the token was minted under a different Firebase project. Disable and log.
|
||||
- `QUOTA_EXCEEDED` (429): retry with backoff, **honoring the `Retry-After` response header** if present (FCM uses standard HTTP semantics here). Common when high-priority data-message quotas are hit; FCM specifically throttles high-priority data messages that don't surface a user-visible notification on the device. Consider falling back to `priority: normal` after repeated 429s within a cooldown window.
|
||||
- `UNAVAILABLE` (503): FCM is temporarily down. Retry with backoff.
|
||||
- `INTERNAL` (500): retry with backoff.
|
||||
- `THIRD_PARTY_AUTH_ERROR` (401): credential failure (relay or custom_fcm). Surface in admin diagnostics; do not retry until the admin fixes it.
|
||||
- relay unavailable, FCM unavailable, or network timeout: retry with backoff.
|
||||
|
||||
Errors should be visible to admins as operational status, not to normal users unless their device needs reauthorization.
|
||||
|
||||
## Privacy Requirements
|
||||
|
||||
The implementation must satisfy these requirements before shipping:
|
||||
|
||||
- Relay requests contain no notification title or body.
|
||||
- Relay requests contain no media identifiers.
|
||||
- Relay requests contain no server hostname or base URL.
|
||||
- Relay requests contain no profile, username, library, collection, or item names.
|
||||
- Direct FCM payloads contain no notification title or body. Specifically: **no top-level `notification` object** in the FCM v1 message — data-only messages only.
|
||||
- Direct FCM payloads contain no media identifiers, server hostname, server URL, profile name, username, library name, collection name, or item name.
|
||||
- Relay logs redact raw FCM tokens.
|
||||
- Relay logs redact authorization headers and OAuth2 access tokens.
|
||||
- Relay logs do not persist full request payloads.
|
||||
- Server logs redact FCM tokens, custom service account JSON contents, and generated OAuth2 access tokens.
|
||||
- Badge count is disabled by default.
|
||||
- Push registration and remote push provider use are opt-in.
|
||||
- Device unregister disables further push attempts for that device.
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- Relay API uses TLS only.
|
||||
- Relay API keys are scoped to a relay account or install identifier.
|
||||
- Relay API keys can be revoked without changing the user's Silo server authentication.
|
||||
- Relay requests are rate limited by API key and coarse token hash.
|
||||
- Relay supports idempotency keys to prevent duplicate FCM sends during retry.
|
||||
- Relay does not accept arbitrary FCM payload JSON in v1.
|
||||
- Server stores relay API keys as secrets.
|
||||
- Server stores custom FCM service account JSON as a secret.
|
||||
- Server never logs custom FCM service account JSON contents, generated OAuth2 access tokens, or FCM authorization headers.
|
||||
- Server stores FCM registration tokens encrypted at rest where local secret storage exists.
|
||||
- Admin diagnostics must not print raw FCM tokens or service account JSON.
|
||||
- Custom FCM service account JSON must validate as a real Google service account JSON before being accepted (parse + sanity check `type: "service_account"` and required key fields).
|
||||
|
||||
## Settings And UX
|
||||
|
||||
Admin settings should explain the tradeoff plainly:
|
||||
|
||||
```text
|
||||
Android Push Provider
|
||||
|
||||
Off
|
||||
No Android remote push. Devices still receive in-app realtime updates while open.
|
||||
|
||||
Silo Relay
|
||||
Uses Silo's FCM relay to wake Android devices. Notification details stay on
|
||||
this server. The relay receives FCM tokens, timestamps, and opaque delivery IDs.
|
||||
|
||||
Custom FCM
|
||||
Advanced. Send directly to Google FCM using your own Firebase service account.
|
||||
Notification details still stay on this server. Only useful if you've published
|
||||
your own signed Android app variant under your own Firebase project.
|
||||
```
|
||||
|
||||
Device settings (shared with APNs) should describe the user-visible mode:
|
||||
|
||||
```text
|
||||
Private Push
|
||||
Show a generic Silo notification, then fetch details from your server when
|
||||
this device wakes.
|
||||
```
|
||||
|
||||
Do not claim the central relay is fully self-hosted. The truthful claim is:
|
||||
|
||||
```text
|
||||
Notification content stays on your server. Google FCM, and the Silo relay
|
||||
if selected, may still process generic wake messages needed for Android push
|
||||
delivery.
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Task 1: Add Android Push Provider Settings
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `internal/api/handlers/settings.go`
|
||||
- `web/src/lib/settingsManifest.ts` (or admin-scope equivalent)
|
||||
- settings UI files as needed
|
||||
|
||||
Add server/admin settings for:
|
||||
|
||||
- provider selection
|
||||
- relay endpoint
|
||||
- relay API key
|
||||
- custom FCM service account JSON
|
||||
- custom FCM project ID
|
||||
- custom FCM allowed packages
|
||||
- badge sync enabled or disabled (shared with Apple settings)
|
||||
|
||||
Default provider must be `off`.
|
||||
|
||||
### Task 2: Extend Push Device Registration
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- migration extending `push_devices` from the APNs spec to add FCM-specific columns and a check constraint
|
||||
- `internal/notifications` package
|
||||
- `internal/api/handlers/notifications.go`
|
||||
- `internal/api/router.go`
|
||||
|
||||
Add profile-scoped Android push device registration, token rotation, and disable APIs.
|
||||
|
||||
If the APNs spec lands first, this task is purely additive (new columns nullable, new endpoint, expanded `platform` enum).
|
||||
|
||||
### Task 3: Add Android Push Provider Clients
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `internal/notifications/fcm_relay.go`
|
||||
- `internal/notifications/fcm_direct.go`
|
||||
- config/settings accessors
|
||||
|
||||
Implement the narrow `/v1/fcm/send` relay client. The client should not accept a free-form notification payload.
|
||||
|
||||
Implement the direct FCM v1 client for `custom_fcm` using:
|
||||
|
||||
- `golang.org/x/oauth2/google` for service-account-based OAuth2 token generation, or an equivalent that doesn't add a heavy Google SDK dependency.
|
||||
- Cached access token with refresh-before-expiry.
|
||||
- Standard `net/http` for the FCM v1 endpoint.
|
||||
|
||||
Both clients use the same internal request shape and payload builder so the dispatcher code is uniform.
|
||||
|
||||
### Task 4: Extend Push Fanout Worker
|
||||
|
||||
Files likely involved:
|
||||
|
||||
- `internal/notifications/push_fanout.go` (shared with APNs)
|
||||
|
||||
Trigger Android push fanout for devices with `platform = 'android'`. Use retries and record `push_delivery_attempts` with `fcm_message_name` populated on success.
|
||||
|
||||
### Task 5: Add Android Client Registration And Wake Handling
|
||||
|
||||
Files live in the Android client repository, not this server repo.
|
||||
|
||||
Required client behavior:
|
||||
|
||||
- request `POST_NOTIFICATIONS` permission on Android 13+
|
||||
- get FCM registration token via `FirebaseMessaging.getInstance().getToken()`
|
||||
- register token with the user's server
|
||||
- handle `FirebaseMessagingService.onNewToken` for token rotation
|
||||
- store `server_device_id` locally, scoped to the active server account
|
||||
- handle `onMessageReceived` for both foreground and background data messages
|
||||
- fetch notification metadata after wake/open
|
||||
- unregister or disable on sign-out / profile removal / app data clear
|
||||
|
||||
### Task 6: Add Admin Diagnostics
|
||||
|
||||
Expose high-level status (extending the APNs diagnostics surface):
|
||||
|
||||
- provider enabled/disabled
|
||||
- number of registered Android devices
|
||||
- last relay success
|
||||
- last relay failure code
|
||||
- custom FCM credential presence and project/package status
|
||||
- last custom FCM success
|
||||
- last custom FCM failure code
|
||||
- token/package/project mismatch warnings
|
||||
|
||||
Do not expose raw FCM tokens or service account JSON.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
Use minimal verification while the work is still design-only. When implemented, verify:
|
||||
|
||||
- relay-disabled servers never call the relay
|
||||
- `custom_fcm` servers never call the relay
|
||||
- device registration is profile-scoped and idempotent
|
||||
- FCM tokens are redacted in logs
|
||||
- relay request bodies contain no titles, body text, item IDs, server URLs, or profile names
|
||||
- direct FCM payloads contain no `notification` object and no titles, body text, item IDs, server URLs, or profile names
|
||||
- custom FCM service account JSON is redacted in logs
|
||||
- generated OAuth2 access tokens are redacted in logs
|
||||
- creating one `notification_delivery` sends at most one push per enabled device
|
||||
- FCM token rotation updates the stored token without duplicating devices
|
||||
- `UNREGISTERED` and `SENDER_ID_MISMATCH` disable the affected device
|
||||
- `QUOTA_EXCEEDED` triggers backoff
|
||||
- app wake fetches notification metadata from the user's server
|
||||
- server offline after push leaves only the generic notification visible
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should official Silo Android builds use one Firebase project per build channel (debug / staging / prod), or share?
|
||||
- Should badge counts (Android notification dots / numbers) be allowed in `private_push`, or deferred?
|
||||
- Should the relay store hashed FCM token aliases to avoid sending raw tokens on every request? Defer to v2 unless rate-limit pressure justifies it.
|
||||
- Should the server fall back to `priority: normal` after repeated `QUOTA_EXCEEDED` to keep deliveries flowing at the cost of latency? Recommendation: yes, with a cooldown timer.
|
||||
- Should we also add a Huawei Mobile Services (HMS) push path for non-Google Android devices? Out of scope v1; could mirror this spec as `04-hms-relay.md` later.
|
||||
@@ -0,0 +1,731 @@
|
||||
# Outbound Webhooks Spec
|
||||
|
||||
**Date:** 2026-04-28
|
||||
**Status:** Draft
|
||||
**Scope:** Profile-scoped outbound webhook destinations for Silo notifications. Native Discord embed type and generic JSON+HMAC type.
|
||||
**Depends On:**
|
||||
- [`00-architecture-overview.md`](./00-architecture-overview.md)
|
||||
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Silo should let each profile send their notifications to user-chosen webhook destinations: a Discord channel, a Slack incoming webhook, a personal automation service, or any HTTP endpoint that accepts JSON. Webhooks are configured per profile, support per-webhook reason filters, and include full notification content by default — the profile chose the destination, so trust is implicit.
|
||||
|
||||
Two webhook types ship in v1:
|
||||
|
||||
- **Discord** — native Discord embed payload. The user pastes a Discord channel webhook URL; Silo POSTs Discord-formatted embeds.
|
||||
- **Generic** — canonical Silo JSON, HMAC-SHA256 signed via `X-Silo-Signature`. Suitable for Slack-incoming-webhook-style targets, custom user automations, or any HTTP endpoint.
|
||||
|
||||
## Why this is different from APNs / FCM
|
||||
|
||||
The privacy model inverts. APNs and FCM relay paths route through infrastructure operators (Apple, Google, Silo's relay) that the user did not directly choose. Therefore those paths carry no notification content — only opaque IDs.
|
||||
|
||||
Outbound webhooks go to a destination the **profile explicitly configured**. By choosing the URL, the profile is consenting to send notification content to that endpoint. There is no privacy benefit to gating content out of webhooks the profile set up themselves; doing so would just make the feature useless ("New episode! [check Silo]" is no better than what's already in the app).
|
||||
|
||||
The trust model is therefore:
|
||||
|
||||
- Profile chose the URL → notification content is included by default.
|
||||
- Discord embeds get series title, episode title, season/episode numbers, and poster.
|
||||
- Generic webhooks get the same data in canonical JSON, plus an HMAC signature derived from a per-webhook signing secret.
|
||||
- Silo still enforces privacy guardrails the profile cannot opt out of: HTTPS only, no localhost / RFC1918 destinations by default, redacted logs, retry/disable on persistent failure.
|
||||
|
||||
## Decision
|
||||
|
||||
Add per-profile webhook destinations, configurable through the profile's notification preferences UI:
|
||||
|
||||
- Profile creates a webhook by pasting a URL and optionally a name and reason filters.
|
||||
- Webhook type is detected from the URL or selected explicitly: `discord` (matches Discord webhook URL pattern) or `generic`.
|
||||
- Silo POSTs a typed payload to the URL whenever a `notification_deliveries` row commits for that profile and the webhook's reason filters allow it.
|
||||
- Failed deliveries are retried with exponential backoff and disabled after a configurable consecutive-failure threshold.
|
||||
- Each webhook has a profile-visible status: enabled, last success, last failure code, consecutive failure count.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let a profile send Silo notifications to Discord, Slack, or any user-chosen URL.
|
||||
- Render natively in Discord without the user copy-pasting JSON templates.
|
||||
- Provide a stable, signed, well-documented generic format for everything else.
|
||||
- Keep the feature profile-scoped: a profile only sees and controls their own webhooks.
|
||||
- Add no new infrastructure dependencies (no relay needed; webhooks are direct outbound HTTP from the user's Silo server).
|
||||
- Make failure modes legible to the profile so they can fix a broken webhook themselves.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Server-wide / admin-level webhooks for system events (scan complete, library health, etc.). Out of scope v1; Silo's existing realtime hub already handles operational events.
|
||||
- Webhook destinations that require OAuth (Slack apps, Discord bots, Microsoft Teams Adaptive Cards). Webhook-style endpoints with an unauth'd POST URL are sufficient for v1.
|
||||
- Per-event-type custom payload templates. The Discord embed shape and the generic JSON shape are fixed in v1; richer customization is v2.
|
||||
- Webhook retry windows beyond 24 hours. After ~24h of consecutive failures, the webhook is auto-disabled and the profile is notified in-app.
|
||||
- Multiple webhook destinations per webhook row (e.g., one row firing to two URLs). One URL per row.
|
||||
- Two-way webhooks. Silo only POSTs out; it does not consume webhook responses for state.
|
||||
|
||||
## Trust Model
|
||||
|
||||
The profile chose the destination URL. By creating a webhook, the profile consents to send full notification content (titles, posters, episode metadata) to that URL.
|
||||
|
||||
What we still don't trust the profile about:
|
||||
|
||||
- **HTTPS:** Required. `http://` URLs are rejected.
|
||||
- **Local / private destinations:** Rejected by default. Specifically, the URL host must resolve to an address outside all of the following ranges:
|
||||
- **IPv4 private/special:** `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10` (CGNAT / RFC6598), `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.0.0.0/24` (IETF protocol assignments), `192.0.2.0/24` / `198.51.100.0/24` / `203.0.113.0/24` (TEST-NET-1/2/3), `192.88.99.0/24` (deprecated 6to4 anycast), `192.168.0.0/16`, `198.18.0.0/15` (benchmarking, RFC 2544), `224.0.0.0/4` (multicast), `240.0.0.0/4` (reserved future use)
|
||||
- **IPv6 private/special:** `::/128` (unspecified), `::1/128` (loopback), `fc00::/7` (ULA), `fe80::/10` (link-local), `2001:db8::/32` (documentation), `64:ff9b::/96` (NAT64)
|
||||
- **IPv4-mapped IPv6:** `::ffff:0:0/96` — the most likely real-world bypass. A literal `::ffff:127.0.0.1` resolves to loopback when the connection is established but bypasses naive IPv4-only checks. The validator must unwrap v4-mapped addresses and re-check against the IPv4 deny set.
|
||||
- **DNS names that resolve to any of the above** at registration time *and* at delivery time (DNS rebinding mitigation; see below).
|
||||
|
||||
An admin-only setting `notifications.webhooks.allow_private_destinations = true` may be set for dev environments. It applies globally to the server and is intended only for development.
|
||||
- **Server URL leakage:** Webhook payloads must **not** include the user's Silo server hostname, base URL, or absolute artwork URLs that include the server origin. Posters in Discord embeds use a profile-token-signed proxy URL (or, for v1, the embed omits images if the server URL would leak). Generic webhooks include only relative paths and let the receiver fetch from the server using their own knowledge of the server URL.
|
||||
- **Relay leakage:** The webhook delivery path runs entirely on the user's own Silo server; no Silo-operated infrastructure is involved.
|
||||
|
||||
## User Flow
|
||||
|
||||
```
|
||||
1. Profile opens Settings -> Notifications -> Webhooks.
|
||||
2. Profile clicks "Add webhook".
|
||||
3. Pastes a URL like https://discord.com/api/webhooks/123/abc.
|
||||
4. Silo auto-detects Discord type from URL pattern.
|
||||
5. Profile names the webhook ("Family Discord") and toggles
|
||||
reason filters: favorites on, watchlist on, continue_watching off, next_up off.
|
||||
6. Profile clicks "Test", Silo POSTs a sample notification.
|
||||
7. Discord channel shows the test embed; profile clicks "Save".
|
||||
8. From now on, matching new-episode notifications post to Discord.
|
||||
9. If the webhook URL becomes invalid (Discord deletes it), Silo:
|
||||
a. Marks each 404'd delivery failed immediately (non-retryable 4xx);
|
||||
transient network failures instead retry with backoff for ~24h.
|
||||
b. After 3 consecutive non-retryable-4xx deliveries (or the retry
|
||||
threshold for persistent network failures), auto-disables the webhook.
|
||||
c. Posts an in-app notification to the profile: "Your 'Family Discord'
|
||||
webhook stopped working. Last error: 404. Edit settings to fix."
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### `notification_webhooks`
|
||||
|
||||
Purpose: profile-scoped webhook destination.
|
||||
|
||||
Columns:
|
||||
|
||||
- `id text primary key`
|
||||
- `user_id integer not null` (matches `users.id integer`)
|
||||
- `profile_id text not null`
|
||||
- `name varchar(64) not null` — user-friendly label, capped to bound row size
|
||||
- `type text not null` — `'discord'` or `'generic'`
|
||||
- `url_ciphertext bytea not null` — destination URL, encrypted at rest
|
||||
- `url_host varchar(253) not null` — denormalized host (no path) for diagnostics and validation logs (253 = max DNS host length)
|
||||
- `signing_secret_ciphertext bytea` — null for `discord` (Discord webhooks don't sign); required for `generic`
|
||||
- `enabled boolean not null default true`
|
||||
- `notify_favorites boolean not null default true`
|
||||
- `notify_watchlist boolean not null default true`
|
||||
- `notify_continue_watching boolean not null default true`
|
||||
- `notify_next_up boolean not null default true`
|
||||
- `consecutive_failures integer not null default 0`
|
||||
- `disabled_reason varchar(256)` — populated when auto-disabled
|
||||
- `last_success_at timestamptz`
|
||||
- `last_failure_at timestamptz`
|
||||
- `last_failure_status integer`
|
||||
- `last_failure_message varchar(256)` — short, non-sensitive diagnostic, e.g., "404 Not Found"
|
||||
- `created_at timestamptz not null default now()`
|
||||
- `updated_at timestamptz not null default now()`
|
||||
|
||||
Constraints:
|
||||
|
||||
- `(profile_id, name)` is unique per profile to prevent duplicate-name confusion.
|
||||
- `CHECK (type IN ('discord', 'generic'))`.
|
||||
- `CHECK (type = 'discord' OR signing_secret_ciphertext IS NOT NULL)` — generic webhooks must have a signing secret.
|
||||
|
||||
Indexes:
|
||||
|
||||
- `(profile_id)` for the listing endpoint.
|
||||
- `(profile_id, enabled)` partial index where `enabled = true` for the dispatcher hot path.
|
||||
|
||||
Notes:
|
||||
|
||||
- URLs and signing secrets are encrypted using the same local secret-storage facility as other sensitive columns (e.g., APNs tokens). If that facility doesn't exist yet, this spec lands the columns as plain `bytea` containing UTF-8 bytes for v1; an explicit follow-up adds the encryption layer.
|
||||
- `disabled_reason` is set when the system auto-disables; cleared when the profile re-enables.
|
||||
|
||||
### `webhook_delivery_attempts`
|
||||
|
||||
Purpose: operational record of webhook attempts.
|
||||
|
||||
Columns:
|
||||
|
||||
- `id text primary key`
|
||||
- `notification_delivery_id text not null references notification_deliveries(id) on delete cascade`
|
||||
- `webhook_id text not null references notification_webhooks(id) on delete cascade`
|
||||
- `attempt_number integer not null`
|
||||
- `attempted_at timestamptz not null default now()`
|
||||
- `next_retry_at timestamptz`
|
||||
- `http_status integer`
|
||||
- `outcome text not null` — `'pending'`, `'delivered'`, `'retrying'`, `'failed'`, `'auto_disabled'`
|
||||
- `failure_message varchar(256)` — short diagnostic (e.g., HTTP status text, DNS error class). Must not include payload contents.
|
||||
|
||||
Constraints:
|
||||
|
||||
- `(webhook_id, notification_delivery_id, attempt_number)` is unique to prevent double-claims under retry concurrency.
|
||||
- `CHECK (outcome IN ('pending', 'delivered', 'retrying', 'failed', 'auto_disabled'))`.
|
||||
- index on `(webhook_id, attempted_at desc)` for per-webhook history listing.
|
||||
- index on `(outcome, next_retry_at)` for the retry worker.
|
||||
|
||||
Retention:
|
||||
|
||||
- keep `delivered` rows for ~7 days
|
||||
- keep `failed`/`auto_disabled` rows for ~30 days for profile-visible debugging
|
||||
- keep `pending`/`retrying` rows until they resolve
|
||||
|
||||
## Server API Surface
|
||||
|
||||
All endpoints are profile-scoped and require `X-Profile-Id` middleware.
|
||||
|
||||
### List Webhooks
|
||||
|
||||
```http
|
||||
GET /api/v1/notifications/webhooks
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"webhooks": [
|
||||
{
|
||||
"id": "01J...",
|
||||
"name": "Family Discord",
|
||||
"type": "discord",
|
||||
"url_host": "discord.com",
|
||||
"enabled": true,
|
||||
"notify_favorites": true,
|
||||
"notify_watchlist": true,
|
||||
"notify_continue_watching": false,
|
||||
"notify_next_up": false,
|
||||
"last_success_at": "2026-04-27T18:32:11Z",
|
||||
"last_failure_at": null,
|
||||
"last_failure_status": null,
|
||||
"last_failure_message": null,
|
||||
"consecutive_failures": 0,
|
||||
"disabled_reason": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Note the response **never includes the full URL or signing secret**. It returns only `url_host` so the profile can identify which Discord/Slack/etc. the webhook points at without leaking the secret URL token (which functions as the auth credential for Discord webhooks).
|
||||
|
||||
### Create Webhook
|
||||
|
||||
```http
|
||||
POST /api/v1/notifications/webhooks
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Family Discord",
|
||||
"url": "https://discord.com/api/webhooks/123456/abcdef-token",
|
||||
"type": "discord",
|
||||
"notify_favorites": true,
|
||||
"notify_watchlist": true,
|
||||
"notify_continue_watching": false,
|
||||
"notify_next_up": false
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `type` is optional; if omitted, the server detects Discord URLs via pattern (`https://discord.com/api/webhooks/{id}/{token}` or `https://discordapp.com/...`). All other URLs default to `generic`.
|
||||
- For `generic`, the server generates a random signing secret (32 bytes, base64) and returns it **once** in the response. The profile is responsible for storing it on the receiving service.
|
||||
- URL must pass private-destination guards.
|
||||
- URL must use `https`.
|
||||
- `name` length capped at 64 chars; reject empty or whitespace-only.
|
||||
- Per-profile cap: max 10 webhooks (configurable via admin setting; documented as `notifications.webhooks.max_per_profile`).
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01J...",
|
||||
"name": "Family Discord",
|
||||
"type": "discord",
|
||||
"url_host": "discord.com",
|
||||
"enabled": true,
|
||||
"notify_favorites": true,
|
||||
"notify_watchlist": true,
|
||||
"notify_continue_watching": false,
|
||||
"notify_next_up": false,
|
||||
"signing_secret": null
|
||||
}
|
||||
```
|
||||
|
||||
For `generic`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01J...",
|
||||
"name": "My Slack Webhook",
|
||||
"type": "generic",
|
||||
"url_host": "hooks.slack.com",
|
||||
"enabled": true,
|
||||
"notify_favorites": true,
|
||||
"notify_watchlist": true,
|
||||
"notify_continue_watching": true,
|
||||
"notify_next_up": true,
|
||||
"signing_secret": "base64-encoded-secret-shown-once"
|
||||
}
|
||||
```
|
||||
|
||||
`signing_secret` is **only** returned at create time and is never re-fetchable. Rotation requires a separate endpoint.
|
||||
|
||||
### Update Webhook
|
||||
|
||||
```http
|
||||
PUT /api/v1/notifications/webhooks/{id}
|
||||
```
|
||||
|
||||
Request fields are all optional; included fields are updated:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Renamed",
|
||||
"enabled": true,
|
||||
"notify_favorites": false,
|
||||
"url": "https://discord.com/api/webhooks/.../new-token"
|
||||
}
|
||||
```
|
||||
|
||||
Updating the URL re-validates against the private-destination guard and resets `consecutive_failures` to 0.
|
||||
|
||||
### Delete Webhook
|
||||
|
||||
```http
|
||||
DELETE /api/v1/notifications/webhooks/{id}
|
||||
```
|
||||
|
||||
Idempotent. Cascades to `webhook_delivery_attempts`.
|
||||
|
||||
### Rotate Signing Secret (generic only)
|
||||
|
||||
```http
|
||||
POST /api/v1/notifications/webhooks/{id}/rotate-secret
|
||||
```
|
||||
|
||||
Generates and returns a new signing secret. The profile must update the receiving service to use the new secret.
|
||||
|
||||
### Test Webhook
|
||||
|
||||
```http
|
||||
POST /api/v1/notifications/webhooks/{id}/test
|
||||
```
|
||||
|
||||
Synchronously POSTs a sample payload to the destination and returns the result:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"http_status": 204,
|
||||
"duration_ms": 187
|
||||
}
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"http_status": 404,
|
||||
"duration_ms": 234,
|
||||
"message": "404 Not Found"
|
||||
}
|
||||
```
|
||||
|
||||
The test payload is identical in shape to a real notification but is clearly marked (e.g., embed footer text "Silo test notification" for Discord; `"test": true` field in generic). Test sends do not consume the retry/auto-disable counters.
|
||||
|
||||
## Payload Formats
|
||||
|
||||
### Discord
|
||||
|
||||
The Discord webhook API documents the `POST /api/webhooks/{id}/{token}` endpoint, which accepts a JSON body with optional `content` (plain text) and `embeds` (rich cards). Silo sends embeds only; no `content` field, so the message renders cleanly without a leading text line.
|
||||
|
||||
**v1 payload (text-only, no images):**
|
||||
|
||||
```json
|
||||
{
|
||||
"embeds": [
|
||||
{
|
||||
"title": "Severance — S2 E1: Hello, Ms. Cobel",
|
||||
"description": "New episode available on Silo",
|
||||
"color": 5814783,
|
||||
"footer": {
|
||||
"text": "Silo • Severance"
|
||||
},
|
||||
"timestamp": "2026-04-28T12:34:56Z",
|
||||
"fields": [
|
||||
{ "name": "Reason", "value": "Favorited & Continue Watching", "inline": true },
|
||||
{ "name": "Season", "value": "2", "inline": true },
|
||||
{ "name": "Episode", "value": "1", "inline": true }
|
||||
]
|
||||
}
|
||||
],
|
||||
"username": "Silo"
|
||||
}
|
||||
```
|
||||
|
||||
**v1.5 payload (with image proxy, additive):**
|
||||
|
||||
In v1.5, the builder adds `image` (poster) and `avatar_url` (Silo mark) fields, both pointing at `media.discord-cdn-proxy.silo.app` URLs. **In v1, these fields MUST be omitted entirely** — the builder must not fall back to absolute URLs that include the user's server origin. The privacy contract is broken if Discord sees the user's server URL in any embed field.
|
||||
|
||||
Notes on the Discord payload:
|
||||
|
||||
- **v1 builder rule:** the `image` field, the embed's `url` field, and the top-level `avatar_url` field must be omitted. Discord renders the embed without an image; the channel webhook's configured avatar is used. There is no fallback to the user's server URL.
|
||||
- **v1.5 builder rule:** `image.url` and `avatar_url` use a CDN proxy hosted at `media.discord-cdn-proxy.silo.app`. The proxy is a small Silo-operated service that takes a short-lived signed URL token (issued by the user's server when constructing the webhook payload) and server-side streams the corresponding asset from the user's server. The leak vector being mitigated is *Discord's own infrastructure* fetching and caching the embed image — Discord's CDN (`media.discordapp.net`) fetches once from whatever origin the embed names; end-users only ever see the cached `media.discordapp.net` URL. The proxy ensures the origin Discord fetches from is the proxy, not the user's server.
|
||||
- **Discord embed limits** (apply to both v1 and v1.5; payload builder must enforce):
|
||||
- 6,000 char total across `title` + `description` + `field.name` + `field.value` + `footer.text` per embed
|
||||
- `title`: 256 chars
|
||||
- `description`: 4,096 chars
|
||||
- `fields`: 25 max; `field.name` 256 chars, `field.value` 1,024 chars
|
||||
- `footer.text`: 2,048 chars
|
||||
- 10 embeds per message (Silo sends one)
|
||||
- **Truncation policy:** prefer to truncate `description` first (with ellipsis), then field values. Never truncate `title`. If even truncated content exceeds the 6,000-char total, drop fields right-to-left until under cap.
|
||||
- `color` is a Discord embed accent color encoded as decimal RGB. Silo picks per-reason: favorite=5814783 (Silo brand purple), watchlist=3066993 (green), continue_watching=15844367 (yellow), next_up=15158332 (red).
|
||||
- `username` overrides the Discord webhook's default name to "Silo".
|
||||
- The webhook URL itself is the auth — Discord's webhook tokens are bearer credentials in the URL path. This is why we never return the URL on read.
|
||||
|
||||
The Discord embed structure follows Discord's webhook API. See [Discord embed object docs](https://discord.com/developers/docs/resources/channel#embed-object) for the full schema.
|
||||
|
||||
### Generic
|
||||
|
||||
JSON body, signed via HMAC-SHA256 over the raw bytes Silo sends. Receivers verify against the literal bytes they received — no canonicalization required on either side.
|
||||
|
||||
Headers:
|
||||
|
||||
- `Content-Type: application/json`
|
||||
- `User-Agent: Silo-Webhook/1.0`
|
||||
- `X-Silo-Event: notification.created`
|
||||
- `X-Silo-Webhook-Id: 01J...` — the webhook row ID
|
||||
- `X-Silo-Delivery-Id: 01J...` — the underlying `notification_deliveries.id`
|
||||
- `X-Silo-Timestamp: 1714299296` — Unix epoch seconds, integer
|
||||
- `X-Silo-Signature: t=1714299296,v1=<hex-hmac-sha256>`
|
||||
- HMAC-SHA256 of the byte string `{X-Silo-Timestamp}.{request body bytes}` using the per-webhook signing secret. The result is hex-encoded.
|
||||
- Format follows Stripe's signing convention so receivers can use existing libraries (Stripe Go SDK, `stripe-signature` ports, etc.).
|
||||
- **The timestamp value in the body's `timestamp` field is informational and may be RFC3339 for human readability; only the `X-Silo-Timestamp` header value (Unix epoch integer) participates in the HMAC computation.**
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "notification.created",
|
||||
"delivery_id": "01J...",
|
||||
"webhook_id": "01J...",
|
||||
"timestamp": "2026-04-28T12:34:56Z",
|
||||
"version": 1,
|
||||
"test": false,
|
||||
"profile_id": "profile-1",
|
||||
"library_id": 7,
|
||||
"type": "episode.available",
|
||||
"reason_flags": {
|
||||
"favorite": true,
|
||||
"watchlist": false,
|
||||
"continue_watching": true,
|
||||
"next_up": true
|
||||
},
|
||||
"series": {
|
||||
"id": "series-123",
|
||||
"title": "Severance"
|
||||
},
|
||||
"episode": {
|
||||
"id": "episode-456",
|
||||
"title": "Hello, Ms. Cobel",
|
||||
"season_number": 2,
|
||||
"episode_number": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes on the generic payload:
|
||||
|
||||
- The body is canonicalized (sorted keys, UTF-8 encoded) before HMAC computation so the receiver can verify deterministically.
|
||||
- `profile_id` is included so the receiver can route by profile (one Slack channel per profile, etc.). It is the profile's own ID and is acceptable to share with the destination the profile chose.
|
||||
- No server URL, no absolute poster URLs, no library name. The receiver cannot link this back to the user's server hostname unless the profile has separately told them.
|
||||
- `version: 1` allows future schema changes without breaking existing receivers.
|
||||
- `test: true` distinguishes test sends from real sends.
|
||||
|
||||
### Signature Verification (generic)
|
||||
|
||||
Receivers verify by:
|
||||
|
||||
```
|
||||
parts = X-Silo-Signature.split(",")
|
||||
ts = parts.find("t=").value // Unix epoch integer (string form)
|
||||
v1 = parts.find("v1=").value // hex string
|
||||
|
||||
raw_body = literal request body bytes (do NOT re-parse and re-serialize JSON)
|
||||
expected = hex(hmac_sha256(secret, ts + "." + raw_body))
|
||||
|
||||
if constant_time_compare(v1, expected) and abs(now_epoch - parse_int(ts)) < 300:
|
||||
accept
|
||||
else:
|
||||
reject
|
||||
```
|
||||
|
||||
Key rules:
|
||||
|
||||
- Sign and verify the **literal request body bytes**, not a re-canonicalized form. This eliminates JSON-canonicalization ambiguity (number formatting, escape rules, key ordering) and matches Stripe's pattern.
|
||||
- `ts` and `now_epoch` are both Unix epoch seconds (integer). The timestamp window of 300 seconds (5 minutes) prevents replay; receivers may tighten if they wish.
|
||||
- Use a constant-time comparator (`hmac.equal`, `crypto/subtle.ConstantTimeCompare`, etc.) to avoid timing attacks.
|
||||
|
||||
### Signing Secret Rotation
|
||||
|
||||
`POST /api/v1/notifications/webhooks/{id}/rotate-secret` generates a new signing secret and returns it once. After rotation:
|
||||
|
||||
- The new secret takes effect immediately for all subsequent deliveries.
|
||||
- **Pending and retrying delivery attempts re-sign with the current secret on each retry**, not the secret that was active when the attempt was first enqueued. This means receivers who have updated to the new secret will accept the retried delivery; receivers still on the old secret will reject it. The expected operational pattern is: profile rotates the secret and updates their receiver atomically (or near-atomically); brief retry-window mismatches are acceptable.
|
||||
- Silo does **not** keep the old secret around after rotation. There is no dual-acceptance window.
|
||||
|
||||
## Delivery Semantics
|
||||
|
||||
### Send path
|
||||
|
||||
1. The fanout transaction in [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) commits the `notification_deliveries` row **and**, in the same transaction, one `webhook_delivery_attempts` row with `outcome = 'pending'` per enabled, reason-matching webhook (the dispatch outbox). Two filters apply at enqueue time:
|
||||
- **Type deny list.** `webhook.auto_disabled` deliveries never enqueue webhook attempts — a webhook auto-disable notice must never be re-dispatched as a webhook, otherwise a broken webhook would generate an auto-disable notification that fires another webhook attempt that fails again, looping. Other notification types may join this list as they're added.
|
||||
- **Per-webhook reason filter** (`notify_favorites`, etc.). No matching reason, no attempt row.
|
||||
2. `WebhookDispatcher.Dispatch(delivery)` runs post-commit and claims the delivery's `pending` attempt rows (`FOR UPDATE SKIP LOCKED`).
|
||||
3. For each claimed attempt:
|
||||
- Construct the payload (Discord embed or generic JSON).
|
||||
- POST the payload with a 10-second total timeout.
|
||||
- On success (HTTP 2xx): mark attempt `delivered`, update webhook `last_success_at`, reset `consecutive_failures = 0`.
|
||||
- On failure: mark attempt `retrying` with `next_retry_at`, increment `consecutive_failures` on the webhook.
|
||||
4. The retry worker reads `webhook_delivery_attempts WHERE (outcome = 'retrying' AND next_retry_at <= now()) OR (outcome = 'pending' AND attempted_at <= now() - interval '60 seconds')` — the second arm is outbox recovery for attempts whose post-commit dispatch never ran (process crash).
|
||||
5. When `consecutive_failures` exceeds the threshold (default 10) over a span > 24h, the webhook is auto-disabled (`enabled = false`, `disabled_reason = '...'`), and an in-app notification is posted to the profile via the in-app inbox.
|
||||
|
||||
### Preference precedence (relationship to `notification_preferences`)
|
||||
|
||||
Profile-level `notification_preferences.notify_*` flags are a **hard gate** applied during fanout (see [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)). A delivery row is never created if all matching reasons are disabled at the profile level — so the dispatcher never sees those events.
|
||||
|
||||
Per-webhook `notify_*` flags are an **additional filter** applied during dispatch. They can narrow what fires for a specific webhook, but they cannot re-enable a reason the profile has globally disabled. The frontend must reflect this: if a profile turns off `notify_continue_watching` globally, the per-webhook continue-watching checkbox should appear disabled with explanatory text rather than implying the user can re-enable it for one destination.
|
||||
|
||||
### Retry schedule
|
||||
|
||||
Exponential backoff, capped at 24h total:
|
||||
|
||||
| Attempt | Delay since first attempt |
|
||||
|---|---|
|
||||
| 1 | 0 (immediate) |
|
||||
| 2 | 30s |
|
||||
| 3 | 2m |
|
||||
| 4 | 10m |
|
||||
| 5 | 30m |
|
||||
| 6 | 2h |
|
||||
| 7 | 6h |
|
||||
| 8 | 12h |
|
||||
| 9 | 18h |
|
||||
| 10 | 24h, then auto-disable |
|
||||
|
||||
Each attempt has a 10s total timeout.
|
||||
|
||||
**4xx skip-retry:** 4xx HTTP responses (except `408 Request Timeout`, `425 Too Early`, `429 Too Many Requests`) are deterministic destination-side rejections — retrying *this delivery* won't help, so the attempt is marked `failed` immediately without walking the 10-attempt schedule. Auto-disable, however, requires **3 consecutive deliveries** to fail with a non-retryable 4xx (tracked via `consecutive_failures` plus the failure-status class) before the webhook is disabled. A single 4xx is not proof the webhook is dead: destination-side WAF/CDN blips (Discord behind Cloudflare) intermittently return 403/400 for valid webhooks, and instant disable on one such blip would force pointless manual re-enables. Three consecutive deterministic rejections is strong evidence the URL is actually gone. The profile is notified in-app per "Auto-Disable Notification" below.
|
||||
|
||||
`429 Too Many Requests` honors the `Retry-After` header if present (overriding the schedule above for that attempt).
|
||||
|
||||
### Concurrency
|
||||
|
||||
The dispatcher fans webhook deliveries in parallel within a small bounded pool (e.g., 16 workers per server process). One slow webhook destination cannot block other deliveries.
|
||||
|
||||
### Test delivery isolation
|
||||
|
||||
`POST /webhooks/{id}/test` sends synchronously, does not write to `webhook_delivery_attempts`, does not affect `consecutive_failures`, and returns the HTTP result inline.
|
||||
|
||||
## Privacy Requirements
|
||||
|
||||
- Webhook URLs are encrypted at rest where the local encryption facility supports it.
|
||||
- Webhook URLs are never returned in API responses (only `url_host`).
|
||||
- Generic signing secrets are returned only at create / rotate time; never readable after.
|
||||
- Logs redact full URLs (host only) and full signing secrets.
|
||||
- Logs do not persist webhook payload bodies. Failure logs may include HTTP status text.
|
||||
- Webhook payloads must not include the Silo server URL or absolute artwork URLs that include the server origin.
|
||||
- An admin must not be able to read another profile's webhook URLs or signing secrets.
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- HTTPS-only destinations.
|
||||
- Private-destination guard on URL submission and on each delivery (re-resolve to catch DNS rebinding to private IPs).
|
||||
- HMAC-SHA256 with per-webhook 32-byte signing secrets for `generic`.
|
||||
- Signing secret returned only at create / rotate; rotation immediately invalidates the previous secret.
|
||||
- Per-profile cap on webhook count (default 10).
|
||||
- Per-profile rate limit on webhook delivery (e.g., 60 deliveries / 60 seconds / profile across all webhooks). Notifications that exceed the limit are still durably stored in the inbox; webhooks just don't fire for them. Logged so admins can spot runaway scenarios.
|
||||
- TLS verification is enforced and not user-overridable.
|
||||
- HTTP redirects are followed (max 3 hops) but the final URL must still pass the private-destination guard at each hop.
|
||||
|
||||
## DNS Rebinding Mitigation
|
||||
|
||||
A naive implementation that resolves the URL once at registration time is vulnerable to DNS rebinding: the host could resolve to a public IP at registration and a private IP at delivery time. Mitigation:
|
||||
|
||||
- At delivery time, resolve the host yourself, validate the resolved IPs against the private deny set, then connect using the validated IP (or a controlled `net.Dialer` that re-validates the address it's about to connect to).
|
||||
- Do not let the standard library transparently re-resolve on each request.
|
||||
- The `internal/notifications/webhook_http.go` HTTP client should use a custom `Dialer.Control` callback that inspects the resolved address and refuses connections to private ranges.
|
||||
|
||||
## Settings And UX
|
||||
|
||||
Profile-level settings:
|
||||
|
||||
```text
|
||||
Webhooks
|
||||
|
||||
Add webhook
|
||||
Send Silo notifications to a webhook URL.
|
||||
Discord URLs render as native Discord embeds.
|
||||
Other URLs receive signed JSON.
|
||||
|
||||
[Family Discord] ✓ enabled
|
||||
discord.com
|
||||
✓ Favorites ✓ Watchlist ☐ Continue watching ☐ Next up
|
||||
Last success: 2 hours ago
|
||||
[ Test ] [ Edit ] [ Delete ]
|
||||
|
||||
[Slack: #media] ✓ enabled
|
||||
hooks.slack.com
|
||||
✓ all reasons
|
||||
Last failure: 3 minutes ago — 404 Not Found
|
||||
⚠ This webhook is failing. Check the destination URL.
|
||||
[ Test ] [ Edit ] [ Delete ]
|
||||
|
||||
+ Add webhook
|
||||
```
|
||||
|
||||
Admin-level settings (server-wide guards, default values):
|
||||
|
||||
- `notifications.webhooks.max_per_profile` (default 10)
|
||||
- `notifications.webhooks.allow_private_destinations` (default false; for dev environments)
|
||||
- `notifications.webhooks.deliveries_per_minute_per_profile` (default 60)
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Task 1: Schema
|
||||
|
||||
Files:
|
||||
|
||||
- new migration under `migrations/` adding `notification_webhooks` and `webhook_delivery_attempts` tables.
|
||||
|
||||
### Task 2: Webhook Repository And Service
|
||||
|
||||
Files:
|
||||
|
||||
- `internal/notifications/webhook_repo.go`
|
||||
- `internal/notifications/webhook_service.go` (CRUD, validation, signing-secret handling)
|
||||
|
||||
Includes:
|
||||
|
||||
- URL validation (https, private-destination guard)
|
||||
- Discord URL detection
|
||||
- signing secret generation
|
||||
- per-profile count enforcement
|
||||
|
||||
### Task 3: HTTP Client With Address Guard
|
||||
|
||||
Files:
|
||||
|
||||
- `internal/notifications/webhook_http.go`
|
||||
|
||||
Custom HTTP client with:
|
||||
|
||||
- 10-second timeout
|
||||
- TLS verification non-overridable
|
||||
- `Dialer.Control` callback that re-validates the resolved IP at connect time
|
||||
- bounded redirect handling
|
||||
- structured error reporting (DNS error class, TCP, TLS, HTTP status) for `failure_message`
|
||||
|
||||
### Task 4: Payload Builders
|
||||
|
||||
Files:
|
||||
|
||||
- `internal/notifications/webhook_payload_discord.go`
|
||||
- `internal/notifications/webhook_payload_generic.go`
|
||||
|
||||
Each pure function `func Build(delivery NotificationDelivery, hook Webhook) ([]byte, error)`. No side effects, easy to unit test.
|
||||
|
||||
### Task 5: Dispatcher Integration
|
||||
|
||||
Files:
|
||||
|
||||
- `internal/notifications/webhook_dispatcher.go`
|
||||
|
||||
Implements the channel `Dispatcher` interface from `00-architecture-overview.md`. Loads enabled webhooks for the delivery's profile, applies reason filters, posts payloads, records attempts.
|
||||
|
||||
### Task 6: Retry Worker
|
||||
|
||||
Files:
|
||||
|
||||
- `internal/notifications/webhook_retry_worker.go`
|
||||
|
||||
Polls `webhook_delivery_attempts WHERE outcome = 'retrying' AND next_retry_at <= now()` with `FOR UPDATE SKIP LOCKED`. Re-dispatches and updates state. Handles auto-disable.
|
||||
|
||||
### Task 7: API Handlers And Routes
|
||||
|
||||
Files:
|
||||
|
||||
- `internal/api/handlers/notifications_webhooks.go`
|
||||
- `internal/api/router.go`
|
||||
|
||||
Routes:
|
||||
|
||||
- `GET /api/v1/notifications/webhooks`
|
||||
- `POST /api/v1/notifications/webhooks`
|
||||
- `PUT /api/v1/notifications/webhooks/{id}`
|
||||
- `DELETE /api/v1/notifications/webhooks/{id}`
|
||||
- `POST /api/v1/notifications/webhooks/{id}/test`
|
||||
- `POST /api/v1/notifications/webhooks/{id}/rotate-secret`
|
||||
|
||||
All require `RequireProfile` middleware.
|
||||
|
||||
### Task 8: Frontend
|
||||
|
||||
Files:
|
||||
|
||||
- `web/src/pages/settings/NotificationWebhooksSettings.tsx`
|
||||
- `web/src/hooks/queries/notification-webhooks.ts`
|
||||
- form components, status pills, test-result display
|
||||
|
||||
Includes:
|
||||
|
||||
- "Show signing secret once" UX with explicit "I've saved it" confirmation.
|
||||
- Show secret-rotation flow.
|
||||
- Last-failure status with admin-level message ("404 Not Found"), not stack traces.
|
||||
- Test button.
|
||||
- Per-reason filter checkboxes.
|
||||
|
||||
### Task 9: Auto-Disable Notification
|
||||
|
||||
When auto-disable fires, create a `notification_deliveries` row with `type = "webhook.auto_disabled"`, including the webhook name and last failure code. The profile sees this in their inbox so a broken webhook doesn't fail silently.
|
||||
|
||||
### Task 10: Optional CDN Proxy For Discord Images
|
||||
|
||||
Files:
|
||||
|
||||
- separate repo (e.g., `silo-discord-cdn-proxy`)
|
||||
- `internal/notifications/webhook_image_signer.go` for issuing short-lived signed URL tokens
|
||||
|
||||
Out of scope for v1 unless Discord image rendering is wanted in the first release. v1 ships embeds without images; v1.5 adds the proxy and image URLs.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
- Unit tests for URL validators (HTTPS, private-IP guard, DNS resolution).
|
||||
- Unit tests for payload builders (Discord shape, generic canonicalization, HMAC determinism).
|
||||
- Integration test: round-trip a generic webhook against a stubbed receiver that verifies the signature.
|
||||
- Integration test: Discord embed against `https://discord.com/api/webhooks/.../test-only` (or recorded fixture) — manual verification only since Discord doesn't offer a sandbox.
|
||||
- Concurrency test: 100 deliveries in parallel; verify retry rows aren't double-counted.
|
||||
- Auto-disable test: simulate 3 consecutive deliveries returning 404 and verify the webhook is disabled with the correct in-app notification; verify 1-2 isolated 4xx blips do **not** disable it; verify persistent timeouts exhaust the 10-attempt schedule before disabling.
|
||||
- Outbox recovery test: commit deliveries with `pending` attempt rows, skip the inline dispatcher, and verify the retry worker sends them after the 60s recovery window.
|
||||
- Manual verification:
|
||||
- Create a Discord webhook URL.
|
||||
- Add it to a profile.
|
||||
- Trigger a release event.
|
||||
- Confirm the embed renders correctly in Discord.
|
||||
- Disable the Discord webhook on Discord's side.
|
||||
- Trigger another event.
|
||||
- Verify the webhook auto-disables after retries.
|
||||
- Verify the profile receives an in-app "webhook auto-disabled" notification.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Discord image proxy scope.** Build it in v1, or ship v1 without images and add v1.5? Recommendation: ship without images in v1 — text-only embeds are useful and require no new infra. Add the proxy in v1.5 if user feedback wants images.
|
||||
- **Slack and Microsoft Teams native types.** Should we add `slack` and `teams` types alongside `discord` and `generic`? Both Slack incoming webhooks and Teams Adaptive Cards have their own JSON shapes. Recommendation: `generic` works for Slack (Slack incoming webhooks accept arbitrary JSON; users can build their own message format with a Workflow). Teams requires Adaptive Card JSON specifically. Defer both to v2 unless concrete user demand emerges.
|
||||
- **Per-event-type templating.** Some users will want to customize the embed text. Recommendation: defer to v2; v1 fixed shapes ship faster and most users will accept them.
|
||||
- **Webhook "channels" beyond episode.available.** Should webhook payloads cover other event types (release-aggregated, server announcements)? V1 only fires on `notification.created` for `episode.available`. Other types are forward-compatible via the `event` and `type` fields.
|
||||
- **Outgoing IP.** Webhook deliveries originate from the user's Silo server IP. Do we need an option to route through a proxy? Recommendation: not v1; document that deliveries come from the server's egress IP.
|
||||
- **Backpressure.** What happens when a profile's webhook is slow but reachable, blocking delivery for an hour at a time? The 10s timeout caps individual deliveries; bounded worker pool caps fanout concurrency. A single bad webhook shouldn't degrade other webhooks because each delivery runs independently.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Web Push Spec
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Status:** Implemented
|
||||
**Scope:** Browser push notifications (Push API + VAPID) as a third push platform alongside the deferred APNs/FCM channels.
|
||||
**Depends On:**
|
||||
- [`00-architecture-overview.md`](./00-architecture-overview.md)
|
||||
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
|
||||
|
||||
## Why Web Push ships before APNs/FCM
|
||||
|
||||
The architecture overview deferred mobile push because Apple and Google require pushes to official store builds to be signed by the publisher's credentials, forcing a Silo-operated relay. Web Push has neither problem:
|
||||
|
||||
- **No accounts, no relay.** The server self-provisions a VAPID keypair on first use. Any standards-compliant browser push service (Chrome, Firefox, Edge, Safari 16+) accepts VAPID-signed requests from any origin.
|
||||
- **Content-safe by protocol.** Payloads are encrypted end-to-end (RFC 8291, `aes128gcm`) to keys held only by the subscribed browser. The vendor push service relays ciphertext. Unlike the APNs/FCM design, payloads can therefore carry full display content (titles, episode numbers, poster URLs) without violating the self-hosted privacy model — there is no opaque-wake/fetch dance.
|
||||
|
||||
The residual leak matches the relay threat model: the push service sees the user server's egress IP, delivery timing, and payload size. It never sees content or identity.
|
||||
|
||||
## Data model
|
||||
|
||||
- `web_push_subscriptions` — profile-scoped browser registrations: `endpoint` (unique; a resubscription from the same browser under a different profile reassigns the row), `p256dh`, `auth`, `device_name`, failure bookkeeping. No FK to profiles (per-user SQLite stores); profile deletion purges in code.
|
||||
- `web_push_delivery_attempts` — the durable dispatch outbox, mirroring `webhook_delivery_attempts`: `pending` rows enqueued in the fanout transaction, claimed post-commit with a lease, swept by the retry loop after a crash.
|
||||
|
||||
## VAPID identity
|
||||
|
||||
Generated once and persisted in `server_settings`:
|
||||
|
||||
- `notifications.web_push.vapid_public_key` — served to clients via the capability endpoint.
|
||||
- `notifications.web_push.vapid_private_key` — encrypted at rest (`SensitiveSettingKeys`).
|
||||
|
||||
The private key is persisted before the public key so a crash between writes regenerates the pair instead of stranding clients with an unusable public key. The pair must never be rotated casually: browsers bind subscriptions to it.
|
||||
|
||||
## API surface (profile-scoped)
|
||||
|
||||
- `GET /api/v1/notifications/capability` — `web_push: { available, public_key }`.
|
||||
- `POST /api/v1/notifications/web-push/subscriptions` — body is `PushSubscription.toJSON()` plus `device_name`.
|
||||
- `GET /api/v1/notifications/web-push/subscriptions` — for the settings UI device list.
|
||||
- `DELETE /api/v1/notifications/web-push/subscriptions/{id}`
|
||||
- `POST /api/v1/notifications/web-push/unsubscribe` — by endpoint (browsers don't know row IDs).
|
||||
|
||||
Subscription endpoints are attacker-controllable URLs the server will POST to, so they pass the same HTTPS + private-destination guard as webhooks, both at registration and at connect time (guarded dialer).
|
||||
|
||||
## Delivery semantics
|
||||
|
||||
- Fanout enqueues one `pending` attempt per enabled subscription of each recipient profile, in the same transaction as the delivery rows. No per-reason filters: profile preferences already gate delivery creation.
|
||||
- Retry schedule is short (30s/2m/10m/30m, 5 attempts): vendor push services queue messages for offline devices themselves (TTL 12h), so server-side retries only ride out transient push-service errors.
|
||||
- `404`/`410` from the push service is the protocol's unsubscribe signal: the subscription row is deleted, not retried.
|
||||
- `notifications.web_push_enabled` is the kill switch (default on).
|
||||
|
||||
## Client
|
||||
|
||||
- `web/public/sw.js` — displays notifications and routes clicks (episode deep link, or the inbox).
|
||||
- `web/src/lib/webPush.ts` — permission + subscribe/unsubscribe flows.
|
||||
- Settings → Notifications → "Browser Notifications" — this-browser toggle plus a revocable list of the profile's other subscribed devices.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Notifications v1.5 Roadmap
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Status:** Draft (work not started)
|
||||
**Scope:** The remaining notification work between the shipped v1 and the deferred v2 push channels. Each item is independent and sized to land as its own PR.
|
||||
**Depends On:**
|
||||
- [`00-architecture-overview.md`](./00-architecture-overview.md)
|
||||
- [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md) — implemented
|
||||
- [`04-outbound-webhooks.md`](./04-outbound-webhooks.md) — implemented
|
||||
- [`05-web-push.md`](./05-web-push.md) — implemented
|
||||
|
||||
## Where v1 landed (context for this doc)
|
||||
|
||||
Implemented 2026-06-11: the full foundation (availability seeding, release events,
|
||||
interest index, fanout worker with burst caps, websocket channel with ticket
|
||||
handshake, inbox/sync/preferences/capability APIs, web inbox + badge + settings),
|
||||
outbound webhooks (Discord + generic HMAC, SSRF guard, durable outbox, retry +
|
||||
auto-disable), Web Push (VAPID self-provisioned, E2E-encrypted payloads, service
|
||||
worker), and the shared SMTP core (`internal/mail`, see
|
||||
`docs/architecture/email.md`) with an admin Email settings page — but no feature
|
||||
consuming email yet.
|
||||
|
||||
**Deferred to v2 by explicit decision:** APNs (`02`) and FCM (`03`) — they
|
||||
require Silo-operated relay infrastructure and developer accounts. Also v2 per
|
||||
the original plans: movie availability, aggregated notifications ("3 new
|
||||
episodes"), quiet hours, cross-profile views.
|
||||
|
||||
---
|
||||
|
||||
## 1. Admin settings UI for notification controls
|
||||
|
||||
**Why:** every `notifications.*` setting works today but is reachable only
|
||||
through the raw admin settings API. Admins should not need `curl` to find the
|
||||
kill switches.
|
||||
|
||||
**What:** an admin settings page ("Notifications", next to the Email page added
|
||||
in v1) exposing:
|
||||
|
||||
| Group | Keys |
|
||||
|---|---|
|
||||
| Kill switches | `notifications.release_events_enabled`, `notifications.fanout_enabled`, `notifications.ui_enabled`, `notifications.webhooks_enabled`, `notifications.web_push_enabled` |
|
||||
| Fanout tuning | `notifications.fanout.settle_seconds` (default 30), `notifications.fanout.max_series_burst` (default 3) |
|
||||
| Webhook guards | `notifications.webhooks.max_per_profile` (10), `notifications.webhooks.allow_private_destinations` (false; dev only — label it loudly), `notifications.webhooks.deliveries_per_minute_per_profile` (60) |
|
||||
| Retention | `notifications.retention.read_days` (90), `notifications.retention.unread_days` (180), `notifications.retention.event_days` (30) |
|
||||
|
||||
**Files:** add `web/src/pages/admin-settings/NotificationsAdminSettings.tsx`
|
||||
(follow `EmailSettings.tsx` / `useSettingsForm`), register in
|
||||
`web/src/pages/admin-settings/AdminSettingsLayout.tsx`. No backend work — all
|
||||
keys are live-read.
|
||||
|
||||
**Effort:** small (one page, no migrations, no Go changes).
|
||||
|
||||
---
|
||||
|
||||
## 2. Request-fulfilled notifications (`request.fulfilled`)
|
||||
|
||||
**Why:** `00-architecture-overview.md` calls this "the most obvious next
|
||||
notification type." Users who request media currently learn it arrived by
|
||||
checking manually; every delivery channel they configured should tell them.
|
||||
|
||||
**Design:** the `notification_deliveries.type` registry is extensible by
|
||||
construction — no schema change.
|
||||
|
||||
- New type `request.fulfilled`. `reason_flags` carries the operational shape
|
||||
(like `webhook.auto_disabled` does), e.g.
|
||||
`{"request_id": "...", "tmdb_id": 123, "media_type": "movie"}` — never the
|
||||
four reason booleans.
|
||||
- **Hook point:** the request reconciliation service (`internal/mediarequests`)
|
||||
is where a request transitions to available/fulfilled. On that transition,
|
||||
insert a delivery via `DeliveryRepository.InsertOperational` (the path the
|
||||
webhook auto-disable notice already uses) and publish through the system's
|
||||
dispatchers so websocket, web push, and webhooks all fire.
|
||||
- **Recipient:** the requesting profile (requests are profile-attributed). No
|
||||
`profile_series_interest` involvement — this is a direct, not fanned-out,
|
||||
notification.
|
||||
- **Webhook enqueue:** operational inserts bypass the fanout outbox, so either
|
||||
(a) extend `InsertOperational` to optionally enqueue per-target attempt rows,
|
||||
or (b) add a small shared "dispatch one delivery durably" helper used by both
|
||||
this and the auto-disable notice. Prefer (b); the auto-disable notice
|
||||
deliberately skips webhooks (loop guard) but request notices should not.
|
||||
- **Per-reason preferences:** add nothing in v1.5. The profile master toggle
|
||||
(`notification_preferences.enabled`) gates it; a dedicated
|
||||
`notify_requests` flag can come later if users ask.
|
||||
- **Clients:** the web inbox/toast/web-push renderers fall back to a generic
|
||||
card for unknown types; add a `request.fulfilled` case with the media title,
|
||||
poster, and a deep link to the item (or the request page until matched).
|
||||
|
||||
**Effort:** medium-small. The delivery/dispatch machinery all exists.
|
||||
|
||||
---
|
||||
|
||||
## 3. Email digest channel
|
||||
|
||||
**Why:** first real consumer of `internal/mail`; reaches users who don't keep a
|
||||
browser open and have no webhook.
|
||||
|
||||
**Open design decisions (resolve before building):**
|
||||
|
||||
- **Account-level, not profile-level.** Email addresses live on `users`;
|
||||
profiles have none. A digest therefore aggregates across the account's
|
||||
profiles (group by profile inside the email body).
|
||||
- **Digest, not per-episode.** Per-episode email is spam at hundreds-of-users
|
||||
scale and duplicates the realtime channels. Recommend: opt-in daily digest of
|
||||
unread deliveries, sent by a taskmanager task (reuse the checkpointed
|
||||
iteration pattern from the interest backfill), with a per-user
|
||||
enable + cadence setting.
|
||||
- **Unsubscribe / preference surface:** account settings, not profile
|
||||
notification preferences.
|
||||
|
||||
**Files (sketch):** `internal/notifications/email_digest.go` (compose from
|
||||
`DeliveryRepository`, send via `mail.Sender`, branch on
|
||||
`mail.ErrNotConfigured`), a `taskmanager` task, a small user-settings surface.
|
||||
|
||||
**Effort:** medium. Blocked on the design decisions above, not on plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Native client adoption (no push required)
|
||||
|
||||
**Why:** the Android and Apple apps gain a full notification experience today —
|
||||
APNs/FCM only add closed-app wake-ups later.
|
||||
|
||||
Server surfaces ready for clients (`silo-android`, `silo-apple`):
|
||||
|
||||
- `GET /api/v1/notifications` + `unread-count` + read endpoints — inbox UI.
|
||||
- `GET /api/v1/notifications/sync` — opaque forward cursor for
|
||||
reconnect/foreground catch-up (this is also the wake-fetch endpoint the v2
|
||||
push specs assume, so client work done now is reused).
|
||||
- `POST /api/v1/events/ws-ticket` + `ticket` query param on `/api/v1/events/ws`,
|
||||
`notifications` channel — realtime while the app is open. Snapshot on
|
||||
subscribe hydrates recent unread.
|
||||
- `GET /api/v1/notifications/capability` — drive setup UI from this, never from
|
||||
admin settings.
|
||||
- `GET/PUT /api/v1/notifications/preferences` — per-profile reason toggles.
|
||||
|
||||
**Effort:** client-repo work; the server side is done. Coordinate per the
|
||||
multi-repo guidance in the repo root `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hardening backlog (defer freely)
|
||||
|
||||
- **DB-backed integration tests** from the `01` verification plan: idempotent
|
||||
availability/event inserts, cross-library delivery dedupe, per-series burst
|
||||
cap, outbox recovery (pending rows with no dispatch → retry worker sends),
|
||||
multi-node claim safety. The behaviors shipped and were exercised manually on
|
||||
dev; they are not yet pinned by automated tests because the repo has no
|
||||
Postgres test harness for this package.
|
||||
- **Metrics:** `01` names Prometheus-style counters
|
||||
(`release_events_suppressed_total`, etc.); v1 ships them as structured log
|
||||
fields. Revisit when the repo grows a metrics registry — keep the names.
|
||||
- **Discord embed images** via the `media.discord-cdn-proxy` service
|
||||
(see `04`, "v1.5 payload"). Requires a new Silo-operated repo/service plus
|
||||
`webhook_image_signer.go`; v1 deliberately ships text-only embeds so the
|
||||
user's server origin never reaches Discord.
|
||||
- **Webhook delivery history endpoint:** `webhook_delivery_attempts` already
|
||||
has the listing index; a `GET /api/v1/notifications/webhooks/{id}/attempts`
|
||||
endpoint + UI table would make failures self-diagnosable beyond the
|
||||
last-failure summary.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. Admin settings UI (#1) — smallest, completes operability.
|
||||
2. Request-fulfilled (#2) — highest product value per effort.
|
||||
3. Webhook history endpoint (#5, last bullet) — pairs naturally with #1.
|
||||
4. Email digest (#3) — after its design decisions are made.
|
||||
5. Native clients (#4) — parallel track in the client repos.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Notifications: Email Channel
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Status:** Implemented (written post-implementation)
|
||||
**Scope:** Item 3 of [`06-v1.5-roadmap.md`](./06-v1.5-roadmap.md) — the first real consumer of the shared SMTP core (`internal/mail`, `docs/architecture/email.md`).
|
||||
**Depends On:** [`00-architecture-overview.md`](./00-architecture-overview.md), [`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)
|
||||
|
||||
## Decisions (resolving the roadmap's open questions)
|
||||
|
||||
- **Account-level, as planned.** Email addresses live on `users`; one mode
|
||||
covers every profile on the account and one email aggregates across them.
|
||||
- **Per-episode AND digest, not digest-only.** The roadmap recommended
|
||||
digest-only; product direction chose to offer per-episode alerts too, gated
|
||||
by an admin allowance (`notifications.email.allow_per_episode`). When the
|
||||
admin disallows it, accounts set to per-episode are **coerced to the daily
|
||||
digest** rather than silenced.
|
||||
- **Interest-scoped by construction.** Email consumes existing
|
||||
`notification_deliveries` rows, which the fanout only creates for profiles
|
||||
with series interest (favorites, watchlist, continue-watching, next-up) and
|
||||
for direct notices (`request.fulfilled`, `webhook.auto_disabled`). The
|
||||
channel adds no targeting of its own — it is never "all new content".
|
||||
- **Opt-in, default off.** Users enable it per account in Settings →
|
||||
Notifications; enabling initializes the watermark to now so history never
|
||||
floods a fresh opt-in.
|
||||
|
||||
## Architecture: watermark sweep, not a third outbox
|
||||
|
||||
Webhooks and web push use per-target outbox attempt rows. Email deliberately
|
||||
does not:
|
||||
|
||||
- Deliveries already carry `user_id`, and an account whose profiles follow the
|
||||
same series gets one row per profile — a per-row outbox would email the same
|
||||
episode several times. The sweep collapses them (dedupe by `episode_id`, by
|
||||
`request_id` for requests).
|
||||
- A per-account watermark over `(created_at, id)` that advances **only after a
|
||||
successful SMTP send** gives durability for free: a crash or SMTP outage
|
||||
re-sends on the next pass instead of dropping.
|
||||
- Both cadences are the same mechanism: per-episode sweeps every minute (and
|
||||
is nudged by the dispatcher seconds after fanout commits); the digest is the
|
||||
same sweep gated on "today's send hour passed and not yet stamped today".
|
||||
|
||||
State lives in `notification_email_prefs` (`migrations/sql/`,
|
||||
`email_notification_channel`): mode, watermark, `last_digest_at`, and failure
|
||||
backoff counters (`last_attempt_at`, `consecutive_failures`; 1m doubling,
|
||||
capped at 6h). No FK to `users` per the notification-tables rule; deleted or
|
||||
disabled accounts drop out of the recipient join. A supporting index
|
||||
`notification_deliveries_user_created_idx (user_id, created_at, id)` serves
|
||||
the sweep.
|
||||
|
||||
**Multi-node safety:** each account is processed inside one transaction that
|
||||
claims the prefs row `FOR UPDATE SKIP LOCKED`, re-derives eligibility from the
|
||||
locked row (mode flips and another node's digest stamp are both re-checked),
|
||||
sends, then commits the watermark/stamp. Failed sends commit only the backoff
|
||||
counters. `mail.ErrNotConfigured` aborts the whole pass; three consecutive
|
||||
send failures end it early (SMTP trouble is global, not per-recipient).
|
||||
|
||||
**Flood bounds:** one email renders at most 30 lines (`…and N more in your
|
||||
Silo inbox`), one pass fetches at most 200 rows per account, and upstream the
|
||||
per-series burst cap already limits fanout volume. Digest emails include only
|
||||
rows still unread at compose time; the watermark passes read rows silently.
|
||||
|
||||
## Files
|
||||
|
||||
| Piece | Location |
|
||||
|---|---|
|
||||
| Modes, prefs repo (`notification_email_prefs`) | `internal/notifications/email_prefs_repo.go` |
|
||||
| Worker, dispatcher nudge, System service methods | `internal/notifications/email_digest.go` |
|
||||
| Subject/text/HTML rendering | `internal/notifications/email_compose.go` |
|
||||
| Account sweep query | `DeliveryRepository.ListForUserSince` (`internal/notifications/delivery_repo.go`) |
|
||||
| Settings accessors | `internal/notifications/settings.go` |
|
||||
| API handlers | `internal/api/handlers/notifications_email.go` (+ capability in `notifications.go`) |
|
||||
| Web UI (user) | `EmailSection` in `web/src/pages/settings/NotificationsSettings.tsx` |
|
||||
| Web UI (admin) | Email group in `web/src/pages/admin-settings/NotificationsAdminSettings.tsx` |
|
||||
| Logic tests | `internal/notifications/email_logic_test.go` |
|
||||
|
||||
The worker is wired in `notifications.NewSystem` (new `mail.Sender` parameter,
|
||||
passed from `cmd/silo/main.go`); its dispatcher joins the `MultiDispatcher`,
|
||||
so operational deliveries (`request.fulfilled`, `webhook.auto_disabled`) nudge
|
||||
it exactly like fanout rows do.
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `notifications.email_enabled` | `true` | Channel kill switch (availability still requires SMTP configured via `email.*`) |
|
||||
| `notifications.email.allow_per_episode` | `true` | Admin allowance for the per-episode cadence |
|
||||
| `notifications.email.digest_hour` | `8` | Hour (0–23, server-local) daily digests go out |
|
||||
| `notifications.email.external_url` | empty | Public base URL for deep links in emails; empty sends link-free emails (the server origin is never leaked implicitly) |
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/v1/notifications/email-preferences` → `{"mode": "off" | "per_episode" | "daily_digest"}`
|
||||
- `PUT /api/v1/notifications/email-preferences` `{"mode": ...}` — 400 codes:
|
||||
`bad_request` (unknown mode), `not_allowed` (per-episode disallowed),
|
||||
`no_email` (account has no address). Any profile on the account may set it.
|
||||
- `GET /api/v1/notifications/capability` gained
|
||||
`"email": {"available", "modes", "digest_hour"}`; clients gate setup UI on
|
||||
it as usual. `available` requires the kill switch on **and**
|
||||
`mail.Sender.Enabled()` — never read `email.*` settings directly.
|
||||
|
||||
## Deliberately not in v1
|
||||
|
||||
- Posters/images in emails (would require externally reachable presigned URLs).
|
||||
- `List-Unsubscribe` headers / tokenized unsubscribe endpoint (self-hosted,
|
||||
opt-in; revisit if servers grow beyond household scale).
|
||||
- Per-user digest hour (admin-global for now).
|
||||
- DB-backed integration tests for the sweep (same Postgres-harness gap as the
|
||||
rest of `01`'s verification backlog; pure logic is covered by
|
||||
`email_logic_test.go`).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Silo Notifications — Design Index
|
||||
|
||||
> **Provenance:** Imported 2026-06-11 from `ContinuumApp/continuum` (`docs/superpowers/plans/notifications/` at `04aa9266`), written 2026-04-28 before the project was renamed to Silo. Reviewed and amended 2026-06-11: product naming and all wire contracts (payload keys, headers, settings enums, localization keys, provisional domains/repo names) are normalized to Silo. Bundle topics, Android package names, and Firebase project IDs intentionally keep their current official client-build values (`com.continuum.app.*`, `continuum-prod-android`) — they are allowlist *config*, not part of this design's contract, and the shipped client builds still use them. Relay/proxy hostnames (`relay.silo.app`, `media.discord-cdn-proxy.silo.app`) are provisional. See "2026-06-11 review amendments" below for the design-level changes made during review.
|
||||
|
||||
This folder collects the design work for Silo's notification system, covering durable in-app inbox + websocket realtime, Apple Push (APNs), Android Push (FCM), and outbound webhooks (Discord-native + generic).
|
||||
|
||||
A self-contained visual summary of the design decisions (who/what/where/why per decision, pipeline, trust model) lives at [`design-decisions.html`](./design-decisions.html) — open it in any browser; no build or network access required.
|
||||
|
||||
## Reading order
|
||||
|
||||
1. **[`00-architecture-overview.md`](./00-architecture-overview.md)** — Cross-cutting overview. Read this first. Explains the channel model, fanout pipeline, preference shape, threat model, and addressing model (why a relay is required for mobile push). Links into the per-channel specs.
|
||||
2. **[`01-release-events-and-inbox.md`](./01-release-events-and-inbox.md)** — Foundation. Durable `release_events` -> per-profile `notification_deliveries` fanout, the in-app inbox API, the websocket channel, and per-profile preferences. Vendor-agnostic; nothing else works without this.
|
||||
3. **[`02-apns-relay.md`](./02-apns-relay.md)** — Apple push. Privacy-preserving design with three provider modes (`off` / `silo_relay` / `custom_apns`).
|
||||
4. **[`03-fcm-relay.md`](./03-fcm-relay.md)** — Android push. Mirrors the APNs spec with `off` / `silo_relay` / `custom_fcm` modes using FCM v1 (OAuth2 service-account JSON).
|
||||
5. **[`04-outbound-webhooks.md`](./04-outbound-webhooks.md)** — Profile-scoped outbound webhooks. Native Discord embed type and generic JSON+HMAC type. Content included by default because the profile chose the destination.
|
||||
|
||||
## Status
|
||||
|
||||
| Doc | Status |
|
||||
|---|---|
|
||||
| 00-architecture-overview | Draft (amended 2026-06-11) |
|
||||
| 01-release-events-and-inbox | Draft (refined, amended 2026-06-11) |
|
||||
| 02-apns-relay | Draft (refined, amended 2026-06-11) |
|
||||
| 03-fcm-relay | Draft (amended 2026-06-11) |
|
||||
| 04-outbound-webhooks | Draft (amended 2026-06-11) |
|
||||
|
||||
Nothing in this folder has been implemented. The codebase has zero notification-related code beyond the existing realtime events hub (`internal/events/`, with the operational publisher wrapper at `internal/notifications/hub.go`), which publishes catalog/jobs events and is unrelated to the user-facing notification system designed here.
|
||||
|
||||
## 2026-06-11 review amendments
|
||||
|
||||
A scaling and privacy review (self-hosted servers with hundreds of users) resolved four design-level gaps. The detailed designs live in the per-doc sections; this is the index:
|
||||
|
||||
1. **Back-catalog seeding + burst suppression** (`01`). First scan of a new library and the feature-enable backfill seed `episode_availability` *without* creating release events; bulk additions to existing libraries are bounded by a per-series fanout burst cap. Without this, importing a 200-episode back-catalog of a popular series would generate tens of thousands of deliveries and pushes in one scan.
|
||||
2. **Durable dispatch outbox** (`01`, referenced by `02`/`03`/`04`). The fanout transaction that inserts `notification_deliveries` also inserts `pending` per-target attempt rows for the push and webhook channels. A crash between delivery commit and dispatch no longer silently loses pushes/webhooks; recovery workers drain stale pending rows.
|
||||
3. **Cross-library episode dedupe** (`01`). Media items in Silo are catalog-level (`media_item_libraries` junction), so the same episode landing in "TV" and "TV 4K" shares one `episode_id`. A partial unique index on `(profile_id, episode_id)` guarantees at most one `episode.available` delivery per profile per episode across libraries.
|
||||
4. **Mobile wake-fetch endpoints defined in the foundation** (`01`). `GET /api/v1/notifications/sync` (forward cursor) and `GET /api/v1/notifications/{id}` — previously referenced by `02`/`03` but defined nowhere.
|
||||
|
||||
Smaller amendments: relay threat models now list the server's egress IP as residual leakage (`00`/`02`/`03`); `collapse_id`/`collapse_key` derivation is specified as per-server-keyed HMAC so the relay can't read series identity out of it (`02`/`03`); the websocket handshake uses a short-lived single-use ticket instead of a long-lived profile token in the query string (`00`/`01`); client-side relay pacing (`02`/`03`); webhook auto-disable requires 3 consecutive non-retryable 4xx instead of 1 (`04`); SSRF deny list gains `198.18.0.0/15` and `192.88.99.0/24` (`04`); interest recompute fires on watch-state transitions, not every progress tick (`01`); profile-deletion cascades and `release_events.dedupe_key` composition are specified (`01`); migration tasks now follow the repo's timestamped Goose convention (`01`).
|
||||
|
||||
## Scope summary
|
||||
|
||||
**In scope (v1):**
|
||||
|
||||
- Episode-availability notifications (a new episode lands in a library and is relevant to a profile via favorites / watchlist / continue-watching / next-up).
|
||||
- Durable per-profile inbox with read/unread state.
|
||||
- Realtime websocket delivery for connected clients.
|
||||
- Apple Push via opt-in Silo-operated relay or admin-supplied custom APNs credentials.
|
||||
- Android Push via opt-in Silo-operated relay or admin-supplied custom FCM credentials.
|
||||
- Outbound webhooks per profile, with native Discord embed rendering and generic JSON+HMAC.
|
||||
|
||||
**Out of scope (v1):**
|
||||
|
||||
- Movie release notifications (the trigger surface — favoriting a movie that doesn't yet exist — isn't a flow Silo supports).
|
||||
- Email, SMS, or other delivery channels.
|
||||
- Browser Web Push (PWA) notifications.
|
||||
- Cross-profile administrator dashboards.
|
||||
- Marketing or promotional notifications.
|
||||
- Third-party ingest events (Plex/Sonarr/Radarr) as notification sources.
|
||||
|
||||
## Cross-cutting decisions
|
||||
|
||||
These hold across all channels and were resolved during spec review:
|
||||
|
||||
- **Episode-only triggers in v1.** Confirmed: there's no UX path for a user to favorite/watchlist a movie before it exists in their library.
|
||||
- **Per-profile webhooks include content by default.** The profile chose the destination; trust is implicit. Discord-native embeds show series and episode details; generic webhooks include the same.
|
||||
- **Mobile push payloads stay opaque by default.** APNs and FCM relay paths never see notification content. The app fetches metadata from the user's own server after wake. No badge counts in v1 (deferred to user-opt-in in v2).
|
||||
- **Preference shape is flat:** per-profile reason flags (favorites / watchlist / continue-watching / next-up) plus a master enable toggle on each push device and each webhook. Not a per-channel × per-reason matrix.
|
||||
- **Per-platform APNs topics.** iOS, tvOS, and macOS each get their own bundle topic. Single-build collapse is a v2 concern.
|
||||
- **Stateless relay v1.** No stored token aliases. Add only if abuse/rate-limit pressure justifies it.
|
||||
- **Relay services live in their own repos.** This folder defines the contracts the user-server side implements; the actual relay deployment (APNs / FCM) is a separate operational repo.
|
||||
- **Back-catalog never floods.** Initial library scans and the feature-enable backfill seed availability silently; per-series burst caps bound fanout for bulk additions (see `01`).
|
||||
- **One delivery per episode per profile, across libraries.** Dual-quality library setups (e.g., "TV" + "TV 4K") do not double-notify.
|
||||
- **Dispatch enqueue is durable (outbox).** Push and webhook sends survive a crash between delivery commit and dispatch; the websocket channel stays best-effort because the inbox snapshot covers reconnect.
|
||||
|
||||
## Phasing
|
||||
|
||||
Implementation should land in this order, gated by feature flags so partial deploys are safe:
|
||||
|
||||
1. Schema (Phase 1 of `01-release-events-and-inbox.md`).
|
||||
2. `profile_series_interest` updaters and backfill task (no fanout yet).
|
||||
3. Availability seeding backfill, then release event creation (still no fanout). Seeding **must** complete before release events are enabled, or the first scan after enablement emits an event per back-catalog episode.
|
||||
4. In-app fanout worker, websocket channel, inbox APIs.
|
||||
5. Frontend inbox + badge + preference UI.
|
||||
6. Outbound webhooks (no new infra; can ship before mobile push).
|
||||
7. APNs relay + custom APNs.
|
||||
8. FCM relay + custom FCM.
|
||||
|
||||
The relay services (steps 7-8) require Silo to provision Apple Developer + Firebase developer accounts and host the relay services. Steps 1-6 require no new external infrastructure.
|
||||
@@ -0,0 +1,281 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Silo notifications — design decisions</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--background: #f4f4f6;
|
||||
--card: #ffffff;
|
||||
--border: #d8d8de;
|
||||
--fg: #1a1a1e;
|
||||
--fg-2: #3a3a42;
|
||||
--muted: #63636b;
|
||||
--chip: #e8e8ec;
|
||||
--green: #22c55e;
|
||||
--purple: #a855f7;
|
||||
--amber: #f59e0b;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #141417;
|
||||
--card: #1c1c20;
|
||||
--border: #28282e;
|
||||
--fg: #e8e8ec;
|
||||
--fg-2: #d0d0d6;
|
||||
--muted: #9696a0;
|
||||
--chip: #232328;
|
||||
--green: #81c995;
|
||||
--purple: #c78dbd;
|
||||
--amber: #e8a87c;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--fg);
|
||||
font-family: "Outfit", ui-sans-serif, system-ui, "Helvetica Neue", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
main { max-width: 880px; margin: 0 auto; padding: 52px 28px 48px; }
|
||||
h1 { font-size: 24px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
|
||||
.sub { color: var(--muted); font-size: 14px; margin: 0 0 8px; }
|
||||
.sub code { font-size: 12.5px; }
|
||||
h2 {
|
||||
font-size: 17px; font-weight: 600; margin: 44px 0 14px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
p { color: var(--fg-2); margin: 0 0 12px; max-width: 72ch; }
|
||||
code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12.5px; background: var(--chip); border-radius: 4px; padding: 1px 5px;
|
||||
}
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 8px 22px; margin: 10px 0 0; font-size: 13px; color: var(--muted); }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 4px 0 0; }
|
||||
th { text-align: left; font-size: 13px; font-weight: 600; color: var(--fg); padding: 8px 14px 8px 0; border-bottom: 1px solid var(--border); }
|
||||
td { font-size: 13.5px; color: var(--fg-2); padding: 9px 14px 9px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
td:first-child, th:first-child { white-space: nowrap; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; }
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 16px 18px;
|
||||
}
|
||||
.card h3 { font-size: 14.5px; font-weight: 600; margin: 0 0 10px; }
|
||||
.kv { display: grid; grid-template-columns: 54px 1fr; row-gap: 7px; column-gap: 10px; }
|
||||
.kv dt { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0; }
|
||||
.kv dd { font-size: 13.5px; color: var(--fg-2); margin: 0; line-height: 1.55; }
|
||||
.svgwrap { border: 1px solid var(--border); border-radius: 12px; background: var(--card); padding: 18px 14px 10px; }
|
||||
svg text { font-family: "Outfit", ui-sans-serif, system-ui, sans-serif; }
|
||||
.bt { font-size: 13px; font-weight: 600; fill: var(--fg); }
|
||||
.bm { font-size: 12.5px; font-weight: 600; fill: var(--fg); font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
||||
.bs { font-size: 11.5px; fill: var(--muted); }
|
||||
.bx { fill: var(--card); stroke: var(--border); }
|
||||
ul.docs { padding-left: 20px; margin: 0; color: var(--fg-2); }
|
||||
ul.docs li { margin: 4px 0; }
|
||||
footer { margin-top: 40px; font-size: 13px; color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>Silo notifications — design decisions</h1>
|
||||
<p class="sub">Episode-availability notifications for a self-hosted, privacy-focused media server, designed to hold up with hundreds of users on one install.</p>
|
||||
<p class="sub">Companion to the specs in <code>docs/superpowers/plans/notifications/</code> · drafts amended 2026-06-11</p>
|
||||
|
||||
<h2>How a notification flows</h2>
|
||||
<p>Ingest records availability, a worker fans out to interested profiles, and a durable inbox row is written before any channel fires. Every channel below the row is a transport, never a source of truth.</p>
|
||||
<div class="svgwrap">
|
||||
<svg width="100%" viewBox="0 0 760 452" role="img" aria-label="Pipeline from library scan to release events to the fanout worker, into a durable notification_deliveries row, which dispatches to websocket, Apple push, Android push, and webhook channels after commit. The push channels continue through the opt-in Silo push relay, which sees only a device token and opaque IDs, then through APNs or FCM, and the woken device fetches notification details from the user's own server. A dashed path shows custom credentials skipping the relay.">
|
||||
<defs>
|
||||
<marker id="a" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M2 1L8 5L2 9" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect class="bx" x="20" y="24" width="190" height="52" rx="8"/>
|
||||
<text class="bt" x="115" y="45" text-anchor="middle">Scan and ingest</text>
|
||||
<text class="bs" x="115" y="63" text-anchor="middle">new episode lands</text>
|
||||
<rect class="bx" x="280" y="24" width="190" height="52" rx="8"/>
|
||||
<text class="bm" x="375" y="45" text-anchor="middle">release_events</text>
|
||||
<text class="bs" x="375" y="63" text-anchor="middle">one row per episode</text>
|
||||
<rect class="bx" x="540" y="24" width="190" height="52" rx="8"/>
|
||||
<text class="bt" x="635" y="45" text-anchor="middle">Fanout worker</text>
|
||||
<text class="bs" x="635" y="63" text-anchor="middle">burst caps + eligibility</text>
|
||||
<line x1="214" y1="50" x2="274" y2="50" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<line x1="474" y1="50" x2="534" y2="50" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<path d="M635 76 L635 98 L380 98 L380 114" fill="none" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<rect class="bx" x="250" y="120" width="260" height="52" rx="8"/>
|
||||
<text class="bm" x="380" y="141" text-anchor="middle">notification_deliveries</text>
|
||||
<text class="bs" x="380" y="159" text-anchor="middle">durable row, dispatched after commit</text>
|
||||
<line x1="380" y1="174" x2="97" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<line x1="380" y1="174" x2="287" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<line x1="380" y1="174" x2="473" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<line x1="380" y1="174" x2="663" y2="206" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<rect class="bx" x="10" y="212" width="170" height="52" rx="8" stroke="var(--green)"/>
|
||||
<text class="bt" x="95" y="233" text-anchor="middle">Websocket</text>
|
||||
<text class="bs" x="95" y="251" text-anchor="middle">full content</text>
|
||||
<rect class="bx" x="200" y="212" width="170" height="52" rx="8" stroke="var(--purple)"/>
|
||||
<text class="bt" x="285" y="233" text-anchor="middle">Apple push</text>
|
||||
<text class="bs" x="285" y="251" text-anchor="middle">opaque wake</text>
|
||||
<rect class="bx" x="390" y="212" width="170" height="52" rx="8" stroke="var(--purple)"/>
|
||||
<text class="bt" x="475" y="233" text-anchor="middle">Android push</text>
|
||||
<text class="bs" x="475" y="251" text-anchor="middle">opaque wake</text>
|
||||
<rect class="bx" x="580" y="212" width="170" height="52" rx="8" stroke="var(--amber)"/>
|
||||
<text class="bt" x="665" y="233" text-anchor="middle">Webhooks</text>
|
||||
<text class="bs" x="665" y="251" text-anchor="middle">your chosen URL</text>
|
||||
<line x1="285" y1="268" x2="345" y2="292" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<line x1="475" y1="268" x2="415" y2="292" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<rect class="bx" x="290" y="296" width="180" height="52" rx="8" stroke="var(--purple)"/>
|
||||
<text class="bt" x="380" y="317" text-anchor="middle">Silo push relay</text>
|
||||
<text class="bs" x="380" y="335" text-anchor="middle">sees token + opaque IDs</text>
|
||||
<line x1="380" y1="352" x2="380" y2="380" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<path d="M230 268 L230 410 L284 410" fill="none" stroke="var(--muted)" stroke-width="1" stroke-dasharray="4 4" marker-end="url(#a)"/>
|
||||
<text class="bs" x="222" y="352" text-anchor="end">custom credentials</text>
|
||||
<text class="bs" x="222" y="368" text-anchor="end">skip the relay</text>
|
||||
<rect class="bx" x="290" y="384" width="180" height="52" rx="8" stroke="var(--purple)"/>
|
||||
<text class="bt" x="380" y="405" text-anchor="middle">APNs / FCM</text>
|
||||
<text class="bs" x="380" y="423" text-anchor="middle">deliver the generic wake</text>
|
||||
<line x1="474" y1="410" x2="536" y2="410" stroke="var(--muted)" stroke-width="1" marker-end="url(#a)"/>
|
||||
<rect class="bx" x="540" y="384" width="190" height="52" rx="8" stroke="var(--green)"/>
|
||||
<text class="bt" x="635" y="405" text-anchor="middle">Device wakes</text>
|
||||
<text class="bs" x="635" y="423" text-anchor="middle">fetches details from home</text>
|
||||
</svg>
|
||||
<div class="legend" style="padding: 8px 6px 8px;">
|
||||
<span><i class="sw" style="background: var(--green);"></i>content stays in Silo</span>
|
||||
<span><i class="sw" style="background: var(--purple);"></i>opaque wake only, device fetches from home</span>
|
||||
<span><i class="sw" style="background: var(--amber);"></i>full content to a destination the profile chose</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Trust model</h2>
|
||||
<table>
|
||||
<thead><tr><th>Channel</th><th>Who sees content</th><th>Why that's acceptable</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><i class="sw" style="background: var(--green); margin-right: 8px;"></i>Inbox + websocket</td><td>Only the user's own server and signed-in clients</td><td>Same auth boundary as the rest of Silo</td></tr>
|
||||
<tr><td><i class="sw" style="background: var(--purple); margin-right: 8px;"></i>Apple / Android push</td><td>Nobody — relay and Apple/Google see a device token, timestamps, and opaque IDs</td><td>The device fetches real content from the user's server after waking</td></tr>
|
||||
<tr><td><i class="sw" style="background: var(--amber); margin-right: 8px;"></i>Webhooks</td><td>The destination operator (e.g., Discord)</td><td>The profile pasted that URL; consent is explicit</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Foundation</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Durable inbox is the source of truth</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Every profile on the server</dd>
|
||||
<dt>What</dt><dd>One delivery row per event; every channel is just a transport on top</dd>
|
||||
<dt>Where</dt><dd>The server's own PostgreSQL</dd>
|
||||
<dt>Why</dt><dd>Transports fail — the row survives, so reconnect or refresh always recovers</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Preferences are flat, not a matrix</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Profiles tuning what they hear about</dd>
|
||||
<dt>What</dt><dd>Four reason toggles plus a master switch; per-device and per-webhook filters can only narrow further</dd>
|
||||
<dt>Where</dt><dd>Hard gate at fanout, extra filters at dispatch</dd>
|
||||
<dt>Why</dt><dd>A per-channel by per-reason grid is unexplainable in UI and impossible to debug</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Privacy boundaries</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Opaque push through a hosted relay</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Admins who opt in; official store app builds</dd>
|
||||
<dt>What</dt><dd>The relay holds the Apple and Google signing credentials and forwards a device token plus opaque IDs only</dd>
|
||||
<dt>Where</dt><dd>Silo-operated service in its own repo, stateless on the request path</dd>
|
||||
<dt>Why</dt><dd>Only the publisher's credentials can wake store builds; content never leaves the server — the device fetches details from home after waking</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Custom credentials escape hatch</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Power users and forks shipping their own signed app builds</dd>
|
||||
<dt>What</dt><dd><code>custom_apns</code> / <code>custom_fcm</code> send straight to Apple or Google with the exact same minimal payload</dd>
|
||||
<dt>Where</dt><dd>Entirely on the user's server</dd>
|
||||
<dt>Why</dt><dd>Zero Silo infrastructure in the path, without loosening the privacy contract</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Profiles, not accounts, are the unit</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Households sharing one login across several profiles</dd>
|
||||
<dt>What</dt><dd>Preferences, devices, webhooks, inbox rows, and websocket bindings are all profile-keyed</dd>
|
||||
<dt>Where</dt><dd>Every table, API route, and event envelope</dd>
|
||||
<dt>Why</dt><dd>A kid's profile and a parent's profile must not see each other's activity</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Webhooks carry full content, by choice</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Each profile, up to 10 destinations</dd>
|
||||
<dt>What</dt><dd>Discord embeds or HMAC-signed JSON with series and episode details</dd>
|
||||
<dt>Where</dt><dd>Direct HTTPS from the server to the URL the profile pasted</dd>
|
||||
<dt>Why</dt><dd>Choosing the destination is the consent; HTTPS-only, the SSRF deny list, and no-server-origin rules stay mandatory</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Scale and reliability</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Fanout reads a compact interest index</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Servers with hundreds of users, roughly 1,000 profiles</dd>
|
||||
<dt>What</dt><dd><code>profile_series_interest</code> keyed by library and series, updated on favorite, watchlist, and watch transitions</dd>
|
||||
<dt>Where</dt><dd>Written at interaction time, read once per release event</dd>
|
||||
<dt>Why</dt><dd>Cost scales with profiles interested in one series, not with a million-episode catalog</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Back-catalog imports never flood</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Anyone adding a new library or a full season pack</dd>
|
||||
<dt>What</dt><dd>First scans seed availability silently; bulk additions cap at 3 notifications per series per scan</dd>
|
||||
<dt>Where</dt><dd>Scanner seeding plus the fanout worker burst cap</dd>
|
||||
<dt>Why</dt><dd>One 200-episode import with 300 interested profiles would otherwise mean about 60,000 pushes in a single scan</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Dispatch enqueue is durable (outbox)</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Operators who care what a crash costs</dd>
|
||||
<dt>What</dt><dd>Pending push and webhook attempt rows commit in the same transaction as the inbox row</dd>
|
||||
<dt>Where</dt><dd>The fanout transaction; recovery workers sweep stale rows</dd>
|
||||
<dt>Why</dt><dd>A crash between commit and dispatch delays sends instead of silently losing them</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>One delivery per episode, across libraries</h3>
|
||||
<dl class="kv">
|
||||
<dt>Who</dt><dd>Dual-quality setups like TV plus TV 4K</dd>
|
||||
<dt>What</dt><dd>A partial unique index on profile and episode spans libraries; the first event processed wins</dd>
|
||||
<dt>Where</dt><dd>The <code>notification_deliveries</code> schema</dd>
|
||||
<dt>Why</dt><dd>Media items are catalog-level in Silo, so the same episode landing twice must not notify twice</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Source documents</h2>
|
||||
<ul class="docs">
|
||||
<li><code>00-architecture-overview.md</code> — channel model, fanout pipeline, threat model, relay addressing</li>
|
||||
<li><code>01-release-events-and-inbox.md</code> — durable inbox, fanout worker, seeding and burst suppression, sync API</li>
|
||||
<li><code>02-apns-relay.md</code> — Apple push: hosted relay and custom APNs</li>
|
||||
<li><code>03-fcm-relay.md</code> — Android push: hosted relay and custom FCM</li>
|
||||
<li><code>04-outbound-webhooks.md</code> — Discord and generic JSON+HMAC webhooks</li>
|
||||
<li><code>README.md</code> — reading order, status, and the 2026-06-11 amendment index</li>
|
||||
</ul>
|
||||
|
||||
<footer>Generated 2026-06-11 from the amended notification specs. Commands and paths assume the repository root is the cwd.</footer>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,967 @@
|
||||
# Silo Push Relay — Engineering Spec
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Draft (implementation-grade)
|
||||
**Service:** `silo-push-relay` (provisional repo name; the relay code lives in a **separate repository**)
|
||||
**Provisional hostname:** `relay.silo.app`
|
||||
**Stack:** Go 1.25+, PostgreSQL, Redis
|
||||
|
||||
**Provenance / source contracts (read these first):**
|
||||
|
||||
- [`../00-architecture-overview.md`](../00-architecture-overview.md) — cross-cutting design; "Mobile push: why a relay is necessary", addressing-flow walkthrough, mode terminology, cross-channel threat model.
|
||||
- [`../02-apns-relay.md`](../02-apns-relay.md) — **the external APNs relay contract** (`POST /v1/apple/send`): request/response/validation, relay-built payloads, headers, error handling. The self-hosted Silo server is already designed against this; the relay is its counterpart and must honor it exactly.
|
||||
- [`../03-fcm-relay.md`](../03-fcm-relay.md) — **the external FCM relay contract** (`POST /v1/fcm/send`): request/response/validation, relay-built data-only payloads, Android config, error handling.
|
||||
- [`./02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md) — the 2026-current Apple/Google/Go reference this service is built on. Where the older contract docs (authored 2026-04) glossed an upstream-provider mechanic, **this reference wins for the upstream side**; the **external relay contract from 02/03 is never changed** — reconciliations are called out explicitly in §5.2 (FCM upstream field casing), §6.2 (sandbox host), §6.4 (APNs error mapping), and §7.5.
|
||||
|
||||
> **Commands assume the repository root is the cwd.** No local absolute filesystem paths or transient worktree IDs appear in this document; all repo references are repository-relative.
|
||||
|
||||
---
|
||||
|
||||
## 1. Title, Provenance, Status, Scope
|
||||
|
||||
### 1.1 What this service is
|
||||
|
||||
`silo-push-relay` is a small, **stateless-on-the-request-path** HTTP service that:
|
||||
|
||||
1. Holds the official Silo Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials.
|
||||
2. Accepts authenticated, **opaque, content-free** push requests from opted-in self-hosted Silo servers on two endpoints: `POST /v1/apple/send` and `POST /v1/fcm/send`.
|
||||
3. Builds a fixed, generic APNs / FCM payload from those opaque fields and forwards it to APNs / FCM on the caller's behalf.
|
||||
4. Maps the upstream provider response back to a narrow caller-facing result.
|
||||
|
||||
It is the **server counterpart** to the relay-client integration in `02-apns-relay.md` and `03-fcm-relay.md`. Those two documents define the wire contract from the self-hosted Silo server's perspective; this document specifies the service that answers it. **The external request/response contract in 02/03 is authoritative and is reproduced here byte-for-byte; this spec adds the server-internal design (upstream clients, storage, auth, rate limiting, idempotency, ops) that 02/03 deliberately left to "a separate repo".**
|
||||
|
||||
### 1.2 What this service is NOT
|
||||
|
||||
- It is **not** a Silo media server, and it shares no database, auth system, or deployment with `silo-server`.
|
||||
- It is **not** a general-purpose push gateway: it accepts only the narrow JSON shape in §5; it never accepts a free-form APNs/FCM payload, title, body, or media identifier.
|
||||
- It is **not** an addressing service. Apple and Google do the addressing from the device token. The relay never resolves a token to a user, profile, device-to-user mapping, or server.
|
||||
- It does **not** store notification content, user/profile identity, server URLs, or token→user aliases (v1 is stateless on the request path; see `02-apns-relay.md` "Relay statefulness: stateless v1").
|
||||
- It is **not** in the `custom_apns` / `custom_fcm` path. When a self-hosted admin configures their own credentials, the Silo server talks to APNs/FCM directly and never touches this relay (see `00-architecture-overview.md` "Custom credentials").
|
||||
|
||||
### 1.3 Relationship to the contract docs and the separate repo
|
||||
|
||||
| Concern | Owner | Document |
|
||||
|---|---|---|
|
||||
| External relay request/response/validation/payloads | `02`/`03` (authoritative) | `../02-apns-relay.md`, `../03-fcm-relay.md` |
|
||||
| Self-hosted server's relay-client, dispatcher, pacing, retry | `silo-server` | `../02`, `../03`, `../00` |
|
||||
| Relay service internals (this doc): upstream clients, DB, auth, rate limit, idempotency, deploy | `silo-push-relay` (separate repo) | **this file** |
|
||||
| 2026-current upstream provider mechanics (JWT, OAuth2, endpoints, error tables) | reference | `./02-apns-fcm-2026-reference.md` |
|
||||
|
||||
These design docs are authored **here in `silo-server`** alongside the `02`/`03` contracts purely for co-location. The relay **code** lives elsewhere (`silo-push-relay`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals and Non-Goals
|
||||
|
||||
### 2.1 Goals
|
||||
|
||||
- **G1 — Faithful contract.** Implement `POST /v1/apple/send` and `POST /v1/fcm/send` exactly as `02`/`03` specify: same request fields, same validation rules, same response shape, same relay-built payloads, same headers.
|
||||
- **G2 — Content-free by construction.** The request schema admits **only** the opaque fields in §5. Any unexpected/free-form field is rejected with `400`. The relay cannot forward content even if a caller tries.
|
||||
- **G3 — Credential custody.** Hold the official `.p8` and SA JSON in a secret manager, never on disk in the repo/image, never in env vars where avoidable (§11).
|
||||
- **G4 — Stateless request path.** No per-request DB writes other than throttled `last_used_at` and redacted op-logs. Idempotency + rate-limit state live in Redis. The relay can be horizontally scaled and a single instance can be killed mid-flight without data loss (the server-side outbox in `../01-release-events-and-inbox.md` guarantees re-delivery).
|
||||
- **G5 — Correct upstream mechanics (2026).** One cached ES256 JWT per APNs team (regenerated ~50 min, never < 20 min apart); one cached OAuth2 access token per FCM project (~1 h, refresh with margin); HTTP/2 connection reuse with PING health checks; correct reason→error mapping. Per `./02-apns-fcm-2026-reference.md`.
|
||||
- **G6 — Multi-tenant isolation.** Every API key, rate-limit bucket, idempotency key, and log line is scoped by `account_id`. Per-account allowlists bound which APNs topics / FCM (project, package) an account may push to.
|
||||
- **G7 — Minimal attack surface for admin ops.** No public admin HTTP API in v1. Account/key/allowlist administration is a CLI (`relayctl`) writing directly to the DB (§5.6).
|
||||
- **G8 — Operable.** Structured redacted logs, Prometheus metrics, `/healthz` + `/readyz`, graceful shutdown.
|
||||
|
||||
### 2.2 Non-goals
|
||||
|
||||
- **NG1** — Storing token→user aliases, device subscriptions, or any user/profile identity (deferred; `02` "stateless v1").
|
||||
- **NG2** — Accepting arbitrary APNs/FCM JSON, custom titles/bodies, badge by default, or topic broadcasts (FCM `topic`/`condition` are never used — per-token only, per `03`).
|
||||
- **NG3** — Delivery receipts, analytics, marketing exports, or open-tracking.
|
||||
- **NG4** — APNs broadcast / Live Activity channels (`/4/broadcasts/...`) — out of scope; see `./02-apns-fcm-2026-reference.md` §1.10. v1 uses only `/3/device/<token>`.
|
||||
- **NG5** — Web Push, HMS, ADM, or any non-APNs/non-FCM transport.
|
||||
- **NG6** — A public self-service signup / billing surface. Accounts are provisioned by Silo operators via `relayctl`.
|
||||
- **NG7** — The `custom_apns` / `custom_fcm` direct paths (those run inside `silo-server`, not here).
|
||||
|
||||
---
|
||||
|
||||
## 3. Trust and Threat Model
|
||||
|
||||
### 3.1 What the relay sees (restated from `00`/`02`/`03`)
|
||||
|
||||
The relay, even fully compromised, sees only:
|
||||
|
||||
- **Which self-hosted installation** is calling — the relay API key (`rk_…`) maps 1:1 to a relay account.
|
||||
- **The server's egress IP** — inherent to any hosted relay; the server connects directly. For home hosting this identifies the household connection. **This is the closest thing to an identity leak in the design** and is listed so the privacy claim stays exhaustive, not over-strong (`00` addressing-flow note).
|
||||
- **A device token** — APNs device token (~100 bytes hex) or FCM registration token (~152–180+ chars).
|
||||
- **Request timing.**
|
||||
- **Coarse wire mode** — `private_alert`/`background_wake` (APNs) or `private_data`/`background_wake` (FCM).
|
||||
- **Opaque `collapse_id` / `collapse_key`** — per-server-keyed HMACs (see §3.3).
|
||||
- **Opaque correlation IDs** — `server_device_id`, `delivery_id`.
|
||||
- **Upstream response status** — APNs `apns-id` / FCM message name, status code, reason string.
|
||||
- **`apns-topic` / FCM (`project_id`, `package_name`)** — platform/build identifiers Apple/Google already see (`02` privacy note).
|
||||
|
||||
### 3.2 What the relay must NEVER see or store
|
||||
|
||||
Per `00`/`02`/`03`: no notification title or body, no media identifiers, no profile/user/username/library/collection/item names, no server hostname or base URL, no artwork URLs, no watched-state metadata, no device→user mapping, and **no token aliases** (stateless v1). The request schema (§5) has no field that could carry any of these, and an unknown field is a hard `400` (§12).
|
||||
|
||||
### 3.3 Collapse-ID equivalence classes (opaque to the relay)
|
||||
|
||||
The Silo server computes `collapse_id = base32(HMAC-SHA256(server_collapse_secret, series_id))` truncated to 26 chars (APNs) / equivalent for FCM `collapse_key`, where `server_collapse_secret` is a random per-server secret (`02`/`03`). The relay treats this as **opaque** and never derives it. Residual leakage: the relay can group **one server's** pushes into per-series equivalence classes (that is what collapse is *for*), but the per-server key prevents recovering the series identity or correlating the same series across two different servers.
|
||||
|
||||
### 3.4 Residual leakage (exhaustive)
|
||||
|
||||
| Leak | To whom | Why it's irreducible | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Egress IP | Relay operator | Server connects directly over HTTPS | Admin may route relay traffic through their own VPN/proxy egress (`02` residual-leakage note) |
|
||||
| Push timing | Relay operator + Apple/Google | A push is an observable network event | None — only meaning/content is hidden |
|
||||
| Device token | Relay operator + Apple/Google | Required to address the device | Tokens never logged raw; truncated/hashed (§11/§12) |
|
||||
| Collapse equivalence class | Relay operator | Collapse must be a stable per-series key | Per-server HMAC key prevents series recovery & cross-server correlation (§3.3) |
|
||||
| App topic / Firebase project + package | Relay operator + Apple/Google | Required by the upstream APIs | Platform/build identifiers Apple/Google already hold |
|
||||
|
||||
The relay **cannot** learn user identity, notification content, server URL, or library data even when fully compromised (`00` threat-model summary). It **cannot fabricate meaningful pushes** — only generic opaque wake payloads.
|
||||
|
||||
### 3.5 Adversaries
|
||||
|
||||
| Adversary | Capability | Worst case | Control |
|
||||
|---|---|---|---|
|
||||
| Hostile/compromised relay operator | Full DB + request stream | Learns device tokens, timing, egress IPs of opted-in servers; can send generic wakes | No content/identity; per-server collapse keying; opt-in & disabled by default |
|
||||
| Stolen relay API key | Can send opaque pushes as that account | Generic wakes to tokens the thief already holds; bounded by per-account allowlist + rate limits | Revoke key (`relayctl key revoke`); rotate; per-account quota caps spend (OWASP API4:2023) |
|
||||
| Cross-tenant attacker | Tries to push to another account's topic/token | Rejected: topic/project/package not on attacker's allowlist | Per-account allowlist enforcement (§8.4); namespaced Redis keys |
|
||||
| Apple / Google | Platform operator | Sees topic, token, generic payload, timing | No identity/content (data-only FCM; loc-key-only APNs alert) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### 4.1 Request path (stateless)
|
||||
|
||||
```
|
||||
1. TLS terminate (LB or in-process) → HTTP/2 inbound.
|
||||
2. Auth middleware: parse "Authorization: Bearer rk_<prefix>_<secret>";
|
||||
SELECT row by indexed prefix; constant-time-compare hash; reject revoked/expired.
|
||||
3. Rate-limit middleware: per-account Redis token bucket (Lua: refill→check→consume).
|
||||
Over limit → 429 + Retry-After.
|
||||
4. Body decode with DisallowUnknownFields (any unexpected field → 400).
|
||||
5. Validate fields (§5) incl. allowlist check (topic / project+package).
|
||||
6. Idempotency: Idempotency-Key header → Redis SET NX lock; replay / 409 / 422.
|
||||
7. Build the fixed relay payload (§5) + headers; forward to upstream APNs/FCM
|
||||
client (cached JWT / OAuth2 token, reused HTTP/2 conn).
|
||||
8. Map upstream response → caller-facing JSON (§5).
|
||||
9. Store the {status, body} under the idempotency key (TTL ~24h);
|
||||
throttled last_used_at update; emit redacted op-log + metrics.
|
||||
```
|
||||
|
||||
The only synchronous DB touch on the hot path is the **API-key lookup** (indexed, O(1) by prefix) and, at most once per minute per key, a `last_used_at` write. Idempotency and rate-limit state are in Redis. No notification content, no token alias, ever persisted.
|
||||
|
||||
### 4.2 Component diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Server["Self-hosted Silo server (caller)"]
|
||||
DISP[Push dispatcher + client-side pacing\n5 req/s token bucket]
|
||||
end
|
||||
|
||||
DISP -->|"POST /v1/apple/send Bearer rk_… Idempotency-Key"| LB[(TLS / Load balancer)]
|
||||
DISP -->|"POST /v1/fcm/send"| LB
|
||||
|
||||
subgraph Relay["silo-push-relay (stateless replicas)"]
|
||||
LB --> API[HTTP server\nnet/http, HTTP/2, timeouts]
|
||||
API --> AUTH[Auth middleware\nprefix lookup + ConstantTimeCompare]
|
||||
AUTH --> RL[Rate-limit middleware\nRedis Lua token bucket]
|
||||
RL --> IDEM[Idempotency\nRedis SET NX / replay / 409 / 422]
|
||||
IDEM --> VAL[Validate + allowlist\nDisallowUnknownFields]
|
||||
VAL --> AC[APNs client\ncached ES256 JWT ~50m\nper-team HTTP/2 pool]
|
||||
VAL --> FC[FCM client\ncached OAuth2 token ~1h\nHTTP/2]
|
||||
API --> OPS[/healthz /readyz /metrics/]
|
||||
AUTH -. redacted .-> LOG[slog JSON]
|
||||
end
|
||||
|
||||
AC -->|"POST /3/device/<token>"| APNS[(api.push.apple.com\n/ api.sandbox.push.apple.com)]
|
||||
FC -->|"POST /v1/projects/{id}/messages:send"| FCM[(fcm.googleapis.com)]
|
||||
|
||||
AUTH --> PG[(PostgreSQL\naccounts / api_keys /\nallowlists / op_logs)]
|
||||
RL --> REDIS[(Redis\nidempotency + rate buckets)]
|
||||
IDEM --> REDIS
|
||||
|
||||
CLI[relayctl CLI\naccount/key/allowlist admin] --> PG
|
||||
SM[(Secret manager)] -.->|.p8 / SA JSON / pepper| AC
|
||||
SM -.-> FC
|
||||
```
|
||||
|
||||
### 4.3 Data stores
|
||||
|
||||
| Store | Holds | Why | Notes |
|
||||
|---|---|---|---|
|
||||
| **PostgreSQL** | `relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs` | Durable, low-write config + redacted audit | `jackc/pgx/v5` + `pgxpool` (`./02-apns-fcm-2026-reference.md` §3.4). Never stores tokens, content, or identity. |
|
||||
| **Redis** | `idem:{account}:{key}` (idempotency lock/result), `rl:{account}` (token-bucket state) | Fast, TTL'd, shared across replicas | `redis/go-redis/v9`. Atomic Lua for rate-limit; `SET NX` for idempotency. Idle keys TTL out. |
|
||||
| **Secret manager** | APNs `.p8`, FCM SA JSON, API-key HMAC pepper, DB/Redis creds | Credential custody | KMS/Vault/secret-manager preferred over mounted file over env var (OWASP, `./02-apns-fcm-2026-reference.md` §4.5/§5). |
|
||||
|
||||
### 4.4 Credential holding
|
||||
|
||||
- **APNs:** one (or a few, scoped) `.p8` ES256 keys + Team ID + Key ID per official team. Prefer team-scoped + topic-specific keys (Feb-2025 hardening) for least privilege (`./02-apns-fcm-2026-reference.md` §1.2). A connection pool is **per APNs team** (a connection cannot serve multiple teams — §1.1 of the reference).
|
||||
- **FCM:** one service-account JSON per official Firebase project (`continuum-prod-android`). On GCP, prefer keyless Workload Identity / attached SA; off-GCP, the JSON lives in the secret manager (`./02-apns-fcm-2026-reference.md` §2.2/§4.5).
|
||||
- Credentials are loaded **once at startup** from the secret manager into memory; never written to disk, never logged, never returned by any endpoint. Reload requires a process restart (v1) or SIGHUP-triggered re-read (optional).
|
||||
|
||||
---
|
||||
|
||||
## 5. Public API Contract
|
||||
|
||||
All endpoints are HTTPS-only over HTTP/2. Authentication is `Authorization: Bearer rk_<key>` (§8.2). `Content-Type: application/json` for send endpoints. Bodies are decoded with `DisallowUnknownFields` — **any unrecognized field is a `400`** (§12). Unless stated otherwise, error bodies use the shape in §5.5.
|
||||
|
||||
### 5.1 `POST /v1/apple/send`
|
||||
|
||||
Authoritative source: `02-apns-relay.md` "Send Apple Push". Reproduced exactly.
|
||||
|
||||
**Request**
|
||||
|
||||
```http
|
||||
POST /v1/apple/send HTTP/2
|
||||
Host: relay.silo.app
|
||||
Authorization: Bearer rk_live_3Qw9...secret...
|
||||
Idempotency-Key: 01JDELIVERY...:01JDEVICE...:1
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "apns-token",
|
||||
"environment": "production",
|
||||
"topic": "com.continuum.app.ios",
|
||||
"mode": "private_alert",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY...",
|
||||
"badge": null,
|
||||
"collapse_id": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
```
|
||||
|
||||
**Field table**
|
||||
|
||||
| Field | Type | Required | Rule |
|
||||
|---|---|---|---|
|
||||
| `token` | string | yes | Required; must be plausible for APNs — non-empty, hex-shaped (`^[0-9a-fA-F]+$`). Apple device tokens are commonly **64 hex chars (32 bytes)** but Apple does not guarantee a fixed length and has lengthened them historically, so the relay enforces a **generous bound (e.g. ≥ 64 and ≤ 256 hex chars)** rather than a tight guess that could reject valid future-length tokens. Reject non-hex / out-of-bound → `400 invalid_token`. |
|
||||
| `environment` | string | yes | `production` or `sandbox`. Selects upstream host (§6.2). Any other value → `400 invalid_environment`. |
|
||||
| `topic` | string | yes | Must be on the account's APNs topic allowlist (§8.4). Not allowlisted → `403 topic_not_allowed`. |
|
||||
| `mode` | string | yes | `private_alert` or `background_wake`. Other → `400 invalid_mode`. |
|
||||
| `server_device_id` | string | yes | Opaque; length-limited (≤128 chars, ULID-shaped recommended). Over limit → `400 invalid_field`. |
|
||||
| `delivery_id` | string | yes | Opaque; length-limited (≤128). |
|
||||
| `badge` | integer \| null | no | Default omitted. When present, must be a **bounded non-negative integer `0 ≤ badge ≤ 9999`** — out of range → `400 invalid_field`. This is the one numeric field copied verbatim into `aps.badge`, so it is bounded to keep it from becoming an unbounded caller-controlled channel visible to Apple. Badge is **disabled by default** to avoid leaking unread counts (`02` privacy); the relay forwards it only if provided. |
|
||||
| `collapse_id` | string \| null | no | Opaque; **≤ 64 bytes** (Apple's `apns-collapse-id` cap). Over 64 bytes → `400 invalid_collapse_id`. |
|
||||
|
||||
No other fields are accepted: **no title, body, image URL, media ID, username, server hostname, or server URL** field exists (`02`). An unknown key → `400 unexpected_field`.
|
||||
|
||||
**`Idempotency-Key` header** (required for sends): format `<notification_delivery_id>:<push_device_id>:<attempt_number>` (`02`/`03`). See §10.
|
||||
|
||||
**Response — 200**
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "01JRELAY...",
|
||||
"apns_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"status": "accepted"
|
||||
}
|
||||
```
|
||||
|
||||
- `request_id` — relay-generated ULID, also echoed as the `X-Request-Id` response header and used in logs.
|
||||
- `apns_id` — Apple's `apns-id` response header (UUID).
|
||||
- `status` — `accepted` on a 200 from APNs.
|
||||
|
||||
> **Sandbox `apns-unique-id` (additive observability).** On a **200 in the sandbox/DEVELOPMENT environment only**, APNs additionally returns an `apns-unique-id` response header used by the Push Notifications Console Delivery Log (reference §1.6). It is the single most useful field for diagnosing "accepted-but-not-delivered" on sandbox. The relay **captures `apns-unique-id` (sandbox only)** into the op-log and the `relayctl ping-upstream` output, and may echo it in the sandbox 200 body, to aid delivery-log correlation during client QA. It is **never** present or logged for production sends.
|
||||
|
||||
**Relay-built APNs payloads** (the relay constructs these; callers cannot override — `02`).
|
||||
|
||||
`private_alert`:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": {
|
||||
"title-loc-key": "SILO_NOTIFICATION_TITLE",
|
||||
"loc-key": "SILO_NOTIFICATION_GENERIC_BODY"
|
||||
},
|
||||
"sound": "default"
|
||||
},
|
||||
"silo": {
|
||||
"v": 1,
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `badge` is present and non-null, the relay adds `"badge": <n>` inside `aps`. Otherwise it is omitted entirely (no `badge` key).
|
||||
|
||||
`background_wake`:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": { "content-available": 1 },
|
||||
"silo": {
|
||||
"v": 1,
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Exact APNs request headers the relay sets** (`02` Headers + reconciled with `./02-apns-fcm-2026-reference.md` §1.4):
|
||||
|
||||
| Upstream header | `private_alert` | `background_wake` | Source |
|
||||
|---|---|---|---|
|
||||
| `:method` | `POST` | `POST` | fixed |
|
||||
| `:path` | `/3/device/<token>` | `/3/device/<token>` | `token` field |
|
||||
| `authorization` | `bearer <cached ES256 JWT>` | same | relay JWT (§6.1) |
|
||||
| `apns-topic` | the allowlisted `topic` | same | `topic` field |
|
||||
| `apns-push-type` | `alert` | `background` | `02` |
|
||||
| `apns-priority` | `10` | `5` | `02` (and reference §1.4.1: background **must** be 5; 10 is an error) |
|
||||
| `apns-collapse-id` | `collapse_id` if present (≤64B) | same | `02` |
|
||||
| `apns-id` | omitted (Apple auto-generates; echoed back) | same | reference §1.4 |
|
||||
| `apns-expiration` | **finite default** (e.g. `now + 4 h`) | **short TTL** (e.g. `now + 1 h`) | reference §1.4. **Configurable**; ships a sane non-30-day default — see below. |
|
||||
|
||||
> **`apns-expiration` policy (v1 ships a finite default, not the 30-day store-and-retry).** With `apns-expiration` omitted, Apple stores and retries an undelivered push for up to **30 days** (reference §1.4) — a wake fired days later is useless or confusing for content that is fetched live from the user's server on wake (the underlying notification may already be read/deleted server-side). The relay therefore sets a **finite, configurable `apns-expiration`**: a few hours for `private_alert` (default ~4 h) and a **short** TTL for `background_wake` (default ~1 h), so a device offline for days does not get a stale wake. Both defaults are config values (per-deployment); the relay never ships the 30-day default. The FCM path sets the parallel `android.ttl` (§5.2) for parity. (Resolved from former open question §15 #5; values are tunable from product input.)
|
||||
|
||||
Payload is **uncompressed JSON**, ≤ 4 KB (reference §1.5). The relay-built payloads are far under 4 KB; the relay still rejects with `400 payload_too_large` defensively if a (future) field pushes it over, and maps an upstream `413` per §6.4.
|
||||
|
||||
**Error mapping back to the caller** — see §6.4.
|
||||
|
||||
### 5.2 `POST /v1/fcm/send`
|
||||
|
||||
Authoritative source: `03-fcm-relay.md` "Send Android Push". Reproduced exactly.
|
||||
|
||||
**Request**
|
||||
|
||||
```http
|
||||
POST /v1/fcm/send HTTP/2
|
||||
Host: relay.silo.app
|
||||
Authorization: Bearer rk_live_3Qw9...secret...
|
||||
Idempotency-Key: 01JDELIVERY...:01JDEVICE...:1
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "fcm-registration-token",
|
||||
"project_id": "continuum-prod-android",
|
||||
"package_name": "com.continuum.app.android",
|
||||
"mode": "private_data",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY...",
|
||||
"badge": null,
|
||||
"collapse_key": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
```
|
||||
|
||||
**Field table**
|
||||
|
||||
| Field | Type | Required | Rule |
|
||||
|---|---|---|---|
|
||||
| `token` | string | yes | Plausible FCM token: non-empty, base64url-ish, **length 100 ≤ len ≤ 4096** (FCM tokens are typically 152–180+ chars — `03`; the upper bound guards against oversized-token abuse without rejecting legitimate longer tokens). Implausible → `400 invalid_token`. |
|
||||
| `project_id` | string | yes | Must match the configured Firebase project for this account's allowlist (§8.4). Mismatch → `403 project_not_allowed`. |
|
||||
| `package_name` | string | yes | Must be on the account's FCM package allowlist, paired with `project_id` (§8.4). Not allowlisted → `403 package_not_allowed`. |
|
||||
| `mode` | string | yes | `private_data` or `background_wake` (the **wire** mode; profile-level is `private_push` — `00`/`03`). Other → `400 invalid_mode`. |
|
||||
| `server_device_id` | string | yes | Opaque; ≤128 chars. |
|
||||
| `delivery_id` | string | yes | Opaque; ≤128 chars. |
|
||||
| `badge` | integer \| null | no | Accepted for caller-contract parity with the APNs endpoint, but **FCM has no data-only home for it** (the relay never builds a `notification` block). v1 **ignores `badge` on the FCM path** — it is neither rendered nor smuggled into the `data` map (§5.2 construction rules, §12). When present it must still pass the same bound as APNs (`0 ≤ badge ≤ 9999`); otherwise → `400 invalid_field`. |
|
||||
| `collapse_key` | string \| null | no | Opaque; **≤ 64 bytes**. This is the **inbound caller field name** (snake_case, per `03`); the relay maps it to the FCM REST field `android.collapseKey` (camelCase — see §5.2 construction rules). Over 64 bytes → `400 invalid_collapse_key`. |
|
||||
|
||||
No free-form `notification`, title, body, image URL, media ID, username, or server URL field exists (`03`). Unknown key → `400 unexpected_field`.
|
||||
|
||||
**Response — 200**
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "01JRELAY...",
|
||||
"fcm_message_name": "projects/continuum-prod-android/messages/0:1234567890123456%abcdef",
|
||||
"status": "accepted"
|
||||
}
|
||||
```
|
||||
|
||||
- `fcm_message_name` — FCM v1 response `name`.
|
||||
|
||||
**Relay-built FCM payloads — data-only, no `notification` block** (`03`).
|
||||
|
||||
`private_data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"token": "fcm-registration-token",
|
||||
"data": {
|
||||
"v": "1",
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
},
|
||||
"android": {
|
||||
"priority": "HIGH",
|
||||
"collapseKey": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`background_wake`:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"token": "fcm-registration-token",
|
||||
"data": {
|
||||
"v": "1",
|
||||
"wake": "notifications.changed",
|
||||
"server_device_id": "01JOPAQUE...",
|
||||
"delivery_id": "01JDELIVERY..."
|
||||
},
|
||||
"android": {
|
||||
"priority": "NORMAL",
|
||||
"collapseKey": "01JOPAQUE_COLLAPSE"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Construction rules (reconciled with `./02-apns-fcm-2026-reference.md` §2.3/§2.4):**
|
||||
|
||||
- `message.data` is `map<string,string>` — **all values are strings** (`"v": "1"`, not `1`). It is a **fixed, closed set of keys** — exactly `{v, wake, server_device_id, delivery_id}` — with **no caller-controlled key or value** beyond the already-opaque IDs (§12). Reserved keys (`from`, `message_type`, `notification`, `google.*`, `gcm.*`) are never used, and `badge` is never injected into `data`.
|
||||
- No top-level `message.notification` — **data-only** so Google carries no rendering content (`03`).
|
||||
- `android.priority`: `"HIGH"` for `private_data` (wakes under Doze), `"NORMAL"` for `background_wake`. The relay emits the **canonical uppercase enum** values documented for FCM REST `AndroidConfig.priority` (reference §2.4); FCM also accepts lowercase, but the canonical form is used to avoid relying on a parenthetical allowance. **Reconciliation:** the `03` contract reproduces lowercase `high`/`normal`; the wire field accepts both and `priority` is relay-set (not a caller field), so this is an internal-upstream normalization with no external-contract impact.
|
||||
- `android.collapseKey` (REST **camelCase** — reference §2.4): set to the caller's `collapse_key` when present, omitted otherwise. **Maps to FCM `android.collapseKey`** — the relay POSTs raw JSON to the FCM HTTP v1 REST endpoint, which rejects unknown fields (including a snake_case `collapse_key` inside `android`) with `INVALID_ARGUMENT`. **Reconciliation (parallel to the sandbox-host one in §6.2):** the inbound caller field stays `collapse_key` per `03`/§5.2; only the relay-built upstream FCM field is `collapseKey`.
|
||||
- `android.ttl` (a duration string, e.g. `"14400s"`): set to a **finite, configurable** TTL for parity with the APNs `apns-expiration` policy (FCM otherwise defaults to **4 weeks** of storage). Default ~4 h for `private_data`, a shorter TTL for `background_wake`, so a device offline for days does not get a stale wake. Configurable per deployment.
|
||||
- `validateOnly` (top-level, **camelCase** — reference §2.5) is the dry-run flag for the `relayctl` test path only (§7.3, §7.5); never set on production sends.
|
||||
- The relay never sets FCM `topic`/`condition`; only `message.token` (per-token only — `03`).
|
||||
- Payload ≤ 4096 bytes for token sends (reference §2.3); the fixed payload is far under.
|
||||
|
||||
> **Reconciliation note (priority vs. reference §2.8).** The reference warns that high-priority data messages that never produce a user-facing notification can be deprioritized to normal by FCM over a ~7-day window. The `03` contract nonetheless specifies `priority: high` for `private_data` (the app *does* surface a notification after wake), and the relay **honors the `03` contract** — it does not second-guess priority. Deprioritization is an upstream behavior, not a contract change; the `03` doc already documents the quota fallback (caller may switch to normal after repeated 429s — §7.4).
|
||||
|
||||
**Exact FCM request** the relay sends:
|
||||
|
||||
```http
|
||||
POST /v1/projects/continuum-prod-android/messages:send HTTP/2
|
||||
Host: fcm.googleapis.com
|
||||
Authorization: Bearer <cached OAuth2 access token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Error mapping** — see §7.4.
|
||||
|
||||
### 5.3 `GET /healthz`
|
||||
|
||||
Liveness. Returns `200 {"status":"ok"}` if the process is up. No dependency checks. Used by the orchestrator's liveness probe. Unauthenticated.
|
||||
|
||||
### 5.4 `GET /readyz` and `GET /metrics`
|
||||
|
||||
- **`GET /readyz`** — readiness. Readiness gates on **core shared dependencies only**: Postgres reachable (`SELECT 1` via pool) and Redis reachable (`PING`). Per-provider health (APNs signer initialized; FCM token source initialized) is reported **separately, not as a binary gate**, so that a **single-provider** credential/init failure does **not** pull the whole replica from the LB and kill the healthy provider too (APNs and FCM are independent upstreams; the "channel failures are isolated" principle from `00` carried into the relay's own health model). The body is `200 {"status":"ready","providers":{"apns":"healthy|degraded","fcm":"healthy|degraded"}}` when core deps are up, or `503 {"status":"not_ready","checks":{...}}` only when **PG or Redis** is down. When a provider is `degraded`, the **send handler for that provider** returns `503 upstream_unavailable` for that provider only (the other provider keeps serving); a per-provider gauge and circuit-breaker state make `APNs degraded, FCM healthy` visible to operators rather than a binary not-ready. See §13.2 (`relay_provider_healthy`).
|
||||
- **`GET /metrics`** — Prometheus exposition (`promhttp`). Bound to the internal network / scrape-only. See §13.2 for the catalog.
|
||||
|
||||
### 5.5 Standard error body
|
||||
|
||||
All 4xx/5xx (except health endpoints) return:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "topic_not_allowed",
|
||||
"message": "topic is not on this account's allowlist",
|
||||
"request_id": "01JRELAY..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`code` is a stable machine string (see §6.4 / §7.4 tables). `request_id` matches `X-Request-Id`. `Retry-After` is set on `429`/`503` (§9, §10).
|
||||
|
||||
### 5.6 Admin surface — `relayctl` CLI (no public admin HTTP API)
|
||||
|
||||
To minimize attack surface (G7), there is **no HTTP admin API** in v1. Administration is a Go CLI, `relayctl`, that connects **directly to the relay's PostgreSQL** (via a privileged DSN held by operators only) and performs the operations below. It runs from an operator workstation or a jump host — never exposed to the public internet.
|
||||
|
||||
| Command | Action | DB effect |
|
||||
|---|---|---|
|
||||
| `relayctl account create --name <label> [--note <text>]` | Provision a relay account (one per opt-in installation). Prints the new `account_id`. | INSERT `relay_accounts`. |
|
||||
| `relayctl account list [--active]` | List accounts with status, key count, last activity. | SELECT. |
|
||||
| `relayctl account disable --account <id>` | Suspend an account (all keys rejected, but rows retained). | UPDATE `relay_accounts.status='disabled'`. |
|
||||
| `relayctl key issue --account <id> [--env live\|test] [--expires <dur>]` | Generate a new API key. **Prints the full `rk_…` token exactly once** (only the hash is stored). | INSERT `relay_api_keys`. |
|
||||
| `relayctl key list --account <id>` | List keys (prefix, created, last_used, revoked) — never the secret. | SELECT. |
|
||||
| `relayctl key revoke --key-prefix <prefix>` | Revoke a key immediately. | UPDATE `relay_api_keys.revoked_at=now()`. |
|
||||
| `relayctl allowlist apns set --account <id> --topics com.continuum.app.ios,.tvos,.macos` | Replace the account's APNs topic allowlist. | UPSERT `relay_apns_allowlist`. |
|
||||
| `relayctl allowlist fcm set --account <id> --project continuum-prod-android --packages com.continuum.app.android` | Replace the account's FCM (project, packages) allowlist. | UPSERT `relay_fcm_allowlist`. |
|
||||
| `relayctl allowlist show --account <id>` | Print effective allowlists. | SELECT. |
|
||||
|
||||
`relayctl` operations are themselves audited into `relay_op_logs` (event `admin.*`) with the operator identity (OS user / `--actor`) but never the secret token. The CLI shares the relay's data-access package so schema and constraints stay in one place.
|
||||
|
||||
---
|
||||
|
||||
## 6. Upstream APNs Client
|
||||
|
||||
Built per `./02-apns-fcm-2026-reference.md` §1 and §3.1. Library choice: `github.com/sideshow/apns2 v0.25.0` **or** a hand-rolled `net/http` + `golang.org/x/net/http2` + `golang-jwt/jwt/v5` client (reference §3.1; bus-factor caveat in §15). Either way, the mechanics below are required.
|
||||
|
||||
### 6.1 Auth — JWT ES256 lifecycle
|
||||
|
||||
- **Algorithm:** ES256 (ECDSA P-256) **only** — APNs accepts no other (reference §1.1).
|
||||
- **Header:** `{"alg":"ES256","kid":"<10-char Key ID>"}`. **Claims:** `{"iss":"<10-char Team ID>","iat":<epoch seconds>}`.
|
||||
- **Signing key:** the official `.p8` private key from the secret manager.
|
||||
- **Caching:** generate **one** JWT, hold it in memory behind a mutex, reuse across all senders/connections/topics for that team (reference §1.1/§4.4).
|
||||
- **Refresh cadence:** regenerate on a **~50-minute timer** (well inside Apple's 1-hour `iat` window), **never more often than once per 20 minutes**, and eagerly on a `403 ExpiredProviderToken` (then retry the request once). Over-frequent refresh → `429 TooManyProviderTokenUpdates`; back off and slow refresh (reference §1.1 lifecycle table).
|
||||
- **Single-flight regeneration (stampede guard).** Under a burst of concurrent sends sharing one cached JWT, an expiry or a `403 ExpiredProviderToken` is observed by **many in-flight goroutines at once**. JWT regeneration **must be single-flighted** — e.g. `golang.org/x/sync/singleflight` keyed by team, or a generation counter behind the mutex: a goroutine seeing `ExpiredProviderToken` checks whether the JWT was **already refreshed since its request started** (compare its captured generation/`iat` to the current one) and, if so, **reuses the new token** instead of triggering another refresh. Only **one** refresh happens per ~20-min window regardless of how many concurrent 403s arrive. A naive per-goroutine regenerate-on-403 would trip `429 TooManyProviderTokenUpdates`; the 20-minute floor is honored **by this concurrency mechanism**, not just asserted. (Mirrors risk R3.)
|
||||
- **Per-team pools.** A connection cannot map to multiple teams; open a separate JWT + HTTP/2 connection pool per APNs team (reference §1.1). v1 has one official team, so one pool — but the code keys pools by team to stay correct.
|
||||
- **Key selection — `(environment, topic) → .p8 key`.** With Apple's Feb-2025 team-scoped/topic-specific keys (max 2 team-scoped keys per environment), more than one `.p8` may be loaded (e.g. a topic-specific key for `com.continuum.app.ios` plus a team key; or per-environment scoped keys). A **sandbox send must use a sandbox-scoped key and a production send a production-scoped key** — using the wrong one yields `403 BadEnvironmentKeyIdInToken` / `UnrelatedKeyIdInToken`. The relay therefore holds an explicit **key-selection map `(environment, topic) → key id`** in config, falling back to an unscoped team key only where no scoped key exists. **Startup validation:** every allowlisted `(topic, environment)` must resolve to a usable key, or startup fails. A `BadEnvironmentKeyIdInToken`/`UnrelatedKeyIdInToken` at runtime maps to `upstream_auth_error` (§6.4) and is treated as a **key-selection misconfiguration** (ops alert).
|
||||
|
||||
### 6.2 Endpoint selection
|
||||
|
||||
| `environment` field | Upstream host | Port |
|
||||
|---|---|---|
|
||||
| `production` | `https://api.push.apple.com` | 443 (2197 fallback) |
|
||||
| `sandbox` | `https://api.sandbox.push.apple.com` | 443 (2197 fallback) |
|
||||
|
||||
Both hosts are **hardcoded constants** selected only by the `environment` enum — never derived from any other request field (§11 egress/SSRF posture).
|
||||
|
||||
> **Reconciliation (host name).** `02-apns-relay.md` validation text names the sandbox host `https://api.development.push.apple.com`. **Both `api.sandbox.push.apple.com` and `api.development.push.apple.com` are valid, functionally-equivalent Apple sandbox aliases — both resolve.** The relay **standardizes on `api.sandbox.push.apple.com`** (reference §1.3; it also matches `sideshow/apns2 v0.25.0`'s `HostDevelopment` constant, which points at `api.sandbox.push.apple.com`). This is an *internal upstream* detail and does **not** change the external contract: callers still send `"environment": "sandbox"` exactly as `02` specifies. (See reference open question §5 item 4 / this spec §15 — now a note that the two hosts are interchangeable, not a discrepancy to resolve at build time.)
|
||||
|
||||
### 6.3 Connection management
|
||||
|
||||
- HTTP/2 + TLS 1.2+ (1.3 acceptable). **Reuse one client / connection pool per process per environment per team**; never create a client per push (TLS setup is slow — reference §3.1). One well-tuned connection can do thousands of pushes/sec.
|
||||
- Set a non-zero `ReadIdleTimeout` (~15 s) + `PingTimeout` (~15 s) on the `http2.Transport` to reap half-open connections (reference §3.3 — "the single most important tuning for a long-lived relay").
|
||||
- Do **not** hardcode `SETTINGS_MAX_CONCURRENT_STREAMS`; read it from the server (reference §1.3/§3.3).
|
||||
- Handle `GOAWAY`: drop the connection from the pool and reconnect; finish in-flight streams ≤ `Last-Stream-ID` (RFC 9113 §6.8). Go's `net/http2` transport does most of this when one client is reused (reference §1.3). **A stream severed by `GOAWAY`/reset before a final APNs response is mapped to `503 upstream_unavailable` (retryable) and is NOT retried in-process** — see §6.4 "Connection-severed / GOAWAY" for the exact caller-facing rule and the at-most-once rationale.
|
||||
- Ensure the TLS trust store includes **USERTrust RSA** (SHA-2 root) — already in standard OS/Go pools; only matters if the relay pins or ships a minimal trust store (reference §1.7).
|
||||
|
||||
### 6.4 Response / reason mapping → caller-facing error
|
||||
|
||||
The relay parses the APNs JSON `{"reason":"…"}` (and the `timestamp` on 410), not just the status code (reference §1.6). It maps to a caller-facing `error.code` and decides retryability. **The relay does not itself retry transient upstream errors across separate caller requests** — it returns a retryable status so the caller's outbox/backoff (in `../01-release-events-and-inbox.md`/`02`) drives retries; the one exception is the in-process JWT-expiry retry (§6.1).
|
||||
|
||||
| APNs status | APNs `reason` | Caller HTTP | Caller `error.code` | Retryable (caller)? |
|
||||
|---|---|---|---|---|
|
||||
| 200 | — | 200 | — | success |
|
||||
| 400 | `BadDeviceToken`, `InvalidToken`, `DeviceTokenNotForTopic`, `MissingDeviceToken` | 422 | `bad_device_token` | **No — purge token** (caller disables device, per `02`). `02` treats `BadDeviceToken`/`InvalidToken` equivalently: "Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`; treat them equivalently." See the environment-mismatch caveat below. |
|
||||
| 400 | `BadTopic`, `MissingTopic`, `TopicDisallowed` | 400 | `bad_topic` | No (config error) |
|
||||
| 400 | `BadCollapseId` | 400 | `invalid_collapse_id` | No |
|
||||
| 400 | `BadExpirationDate`, `BadPriority`, `BadMessageId`, `InvalidPushType`, `PayloadEmpty`, `DuplicateHeaders`, `IdleTimeout` | 400 | `bad_request` | No |
|
||||
| 403 | `ExpiredProviderToken` | (internal) | — | **Relay regenerates JWT, retries once** (§6.1), then maps result |
|
||||
| 403 | `InvalidProviderToken`, `MissingProviderToken`, `UnrelatedKeyIdInToken`, `BadEnvironmentKeyIdInToken`, `Forbidden`, `BadCertificate*` | 502 | `upstream_auth_error` | No (relay misconfig — **ops alert**, not caller-fixable) |
|
||||
| 404 | `BadPath` | 502 | `upstream_error` | No |
|
||||
| 405 | `MethodNotAllowed` | 502 | `upstream_error` | No |
|
||||
| 410 | `Unregistered`, `ExpiredToken` | 410 | `unregistered` | **No — purge token** (caller disables device). `timestamp` (ms) passed through in `error.message` so caller can compare against its registration time. |
|
||||
| 413 | `PayloadTooLarge` | 400 | `payload_too_large` | No |
|
||||
| 429 | `TooManyProviderTokenUpdates` | 503 | `upstream_throttled` | Yes — relay slows JWT refresh; caller backs off |
|
||||
| 429 | `TooManyRequests` | 429 | `rate_limited` | Yes — per-device backoff; `Retry-After` set |
|
||||
| 500 | `InternalServerError` | 503 | `upstream_unavailable` | Yes (caller backoff ~15 min) |
|
||||
| 503 | `ServiceUnavailable`, `Shutdown` | 503 | `upstream_unavailable` | Yes (caller backoff) |
|
||||
|
||||
**Default / fallback for unrecognized reasons.** Any **unmapped** `reason` string on a `400`/`403`/`410` (e.g. a future Apple reason the relay does not know) maps to `502 upstream_error`, **non-retryable, no token purge** — a safe default that surfaces the anomaly for investigation rather than silently dropping it or purging a possibly-live token. An unmapped `5xx` maps to `503 upstream_unavailable` (retryable). The exact unmapped reason is recorded in the op-log `upstream_reason` for triage.
|
||||
|
||||
**Connection-severed / GOAWAY (no final response).** A request whose HTTP/2 stream is terminated by a `GOAWAY`, connection reset, or any transport drop **before a final APNs response arrives** is mapped to `503 upstream_unavailable` (retryable) — the same as a network timeout. The relay does **NOT** transparently retry such a request in-process; the caller's outbox (`01-release-events-and-inbox.md`) re-dispatches it under the same idempotency key, preserving at-most-once semantics (a request that may have been delivered must not be silently re-sent by the relay). Apple emits `GOAWAY` routinely for maintenance/overload, so this is a normal, expected outcome, distinguished in the op-log/metrics with a `connection_severed` outcome label.
|
||||
|
||||
**Environment / sandbox-vs-production mismatch caveat.** A caller that sends `environment=sandbox` with a production token (or vice-versa) gets `BadDeviceToken`/`InvalidToken` from APNs for a token that may be perfectly valid in the other environment. The relay still returns `422 bad_device_token`, but **the caller should NOT treat the first such error from a newly-onboarded device as conclusive grounds to purge** when an environment mismatch is plausible. To let operators distinguish a systemic misconfiguration from genuinely dead tokens, the relay tags these op-logs/metrics so a burst of `bad_device_token` concentrated on **one account** (a likely env mixup) is visible separately from scattered dead tokens across many accounts. See §5.1 (token rules) and §8.4.
|
||||
|
||||
Mapping to the caller's own error handling in `02`: `bad_device_token`/`unregistered` → the caller disables the device (`02` "BadDeviceToken / Unregistered: disable the token"); `upstream_throttled`/`rate_limited`/`upstream_unavailable` → the caller retries with backoff. The relay never surfaces `ExpiredProviderToken`/`TooManyProviderTokenUpdates` as caller-facing codes (those are the relay's own JWT lifecycle — `02` notes they apply only to `custom_apns`, never the relay path).
|
||||
|
||||
Network timeout / connection failure to APNs → `503 upstream_unavailable` (retryable).
|
||||
|
||||
---
|
||||
|
||||
## 7. Upstream FCM Client
|
||||
|
||||
Built per `./02-apns-fcm-2026-reference.md` §2 and §3.2. Preferred path: `golang.org/x/oauth2/google` `TokenSource` (auto-caches + refreshes) + a custom HTTP/2 client posting the relay-built JSON (reference §3.2 — lighter than the Admin SDK and gives direct control of connection reuse and error classification). The Admin SDK (`firebase.google.com/go/v4/messaging`) is an acceptable alternative; pick exactly one.
|
||||
|
||||
### 7.1 OAuth2 service-account token source + caching
|
||||
|
||||
- Build a `TokenSource` from the SA JSON with `google.CredentialsFromJSONWithType(ctx, jsonKey, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` (least-privilege scope; `CredentialsFromJSON` is deprecated — reference §2.2/§3.2). The `TokenSource` caches and auto-refreshes.
|
||||
- Access tokens are valid **1 hour**; never mint per request. The library re-mints before expiry; if hand-rolling the JWT-bearer grant, refresh with a ~5-minute safety margin (reference §2.2).
|
||||
- **Credential resolution:** prefer keyless **Workload Identity / attached SA** when the relay runs on GCP; off-GCP, load the SA JSON from the secret manager (reference §2.2/§4.5; §11 here).
|
||||
- One token source **per Firebase project**. v1 has one official project (`continuum-prod-android`).
|
||||
|
||||
### 7.2 Endpoint
|
||||
|
||||
```http
|
||||
POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
|
||||
Authorization: Bearer <oauth2_access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Global host only — **no regional/data-residency send endpoints exist** as of June 2026 (reference §2.3). The host `fcm.googleapis.com` is a **hardcoded constant**, never derived from request input. `{PROJECT_ID}` is the validated `project_id` from the request: it is checked against a **strict charset (`^[a-z0-9-]+$`) before** allowlist lookup and before URL interpolation, so an allowlist row can never inject path-traversal or override the host (§11 egress/SSRF posture).
|
||||
|
||||
### 7.3 Data-only message construction
|
||||
|
||||
As in §5.2: `message.token` set to the caller's `token`; `message.data` is string-valued only (a fixed, closed key set — §5.2/§12); `message.android.priority` is the canonical `"HIGH"`/`"NORMAL"` enum by mode; the caller's `collapse_key` is mapped to the REST field `message.android.collapseKey` (camelCase) when present; **no `notification` block**. The relay can optionally use `validateOnly: true` (top-level, camelCase) for the `relayctl`-driven test path (reference §2.5) but **never** on production sends.
|
||||
|
||||
### 7.4 Error mapping → caller-facing error + Retry-After
|
||||
|
||||
The relay classifies on `error.status` / the `FcmError.errorCode` in `details[]`, not the bare HTTP code (reference §2.6).
|
||||
|
||||
| FCM HTTP | `error.status` | Caller HTTP | Caller `error.code` | Retryable (caller)? |
|
||||
|---|---|---|---|---|
|
||||
| 200 | — | 200 | — | success |
|
||||
| 400 | `INVALID_ARGUMENT` | 422 | `invalid_argument` | No. Ambiguous (bad payload **or** bad token); since the relay payload is independently valid, the caller may treat it as a dead token (`03` / reference §2.6). |
|
||||
| 401 | `THIRD_PARTY_AUTH_ERROR` | 502 | `upstream_error` | **Not expected on this pure-Android path** — see note below. No auto-purge; surface for investigation. Do **NOT** page as a relay-credential failure. |
|
||||
| 403 | `SENDER_ID_MISMATCH` | 403 | `sender_id_mismatch` | **No — disable device** (token minted under a different project; `03`). |
|
||||
| 404 | `UNREGISTERED` | 410 | `unregistered` | **No — purge token** (`03`). Caller-facing **410** is an **intentional cross-provider normalization** — see note below. |
|
||||
| 429 | `QUOTA_EXCEEDED` (RESOURCE_EXHAUSTED) | 429 | `rate_limited` | Yes — **honor `Retry-After`** if present; else default **60 s** (reference §2.7). |
|
||||
| 429 / 400 | per-device rate (`DeviceMessageRateExceeded` — see note) | 429 | `rate_limited` | Yes — **retryable; NOT a token purge**. Conservative default `Retry-After` (60 s) if none provided. |
|
||||
| 401 / 403 | (HTTP **status**, not `FcmError` code) `UNAUTHENTICATED` / `PERMISSION_DENIED` on `messages:send`, **or** a non-200 from `oauth2.googleapis.com/token` at mint time | 502 | `upstream_auth_error` | No — **relay's own OAuth2 / service-account credential failure** (**ops alert**). See note below. |
|
||||
| 500 | `INTERNAL` | 503 | `upstream_unavailable` | Yes (backoff; no documented `Retry-After`). |
|
||||
| 503 | `UNAVAILABLE` | 503 | `upstream_unavailable` | Yes — **honor `Retry-After`** if present (reference §2.7). |
|
||||
| — | `UNSPECIFIED_ERROR` / any unmapped `errorCode` | 502 | `upstream_error` | No auto-purge; surface for investigation (reference §2.6). |
|
||||
|
||||
**Note — `THIRD_PARTY_AUTH_ERROR` is not a relay-credential signal on this path.** Per Firebase's error-codes page, `THIRD_PARTY_AUTH_ERROR` means *"APNs certificate or web-push auth key was invalid or missing"* and is returned **only** for messages targeting **iOS-via-APNs or Web Push** registration tokens — i.e. when FCM itself must authenticate downstream to Apple/VAPID. The relay's `/v1/fcm/send` sends **only Android registration tokens** (data-only `message.token`, package `com.continuum.app.android`), so a legitimate Android send can never produce `THIRD_PARTY_AUTH_ERROR`. If it ever appears it indicates a **per-token/registration anomaly** (an Android token that is somehow APNs/web-bound — a client/registration bug), not a relay `.p8`/SA misconfiguration; treat it as `502 upstream_error`, surface for investigation, and do **not** page on-call as a credential failure.
|
||||
|
||||
**Note — the relay's OWN FCM credential failure surfaces differently.** A failure of the relay's OAuth2 service-account auth shows up as a non-200 from the Google token endpoint (`oauth2.googleapis.com/token`) at mint time, or as an HTTP **status** `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` on `messages:send` (the gRPC/REST status, **not** an `FcmError.errorCode`). **These** are the signals that map to `upstream_auth_error` and warrant an ops alert (the alerting condition in `01-implementation-plan.md` keys on this `upstream_reason`, not on `THIRD_PARTY_AUTH_ERROR`).
|
||||
|
||||
**Note — `UNREGISTERED` is deliberately re-mapped from FCM's native 404 to caller-facing 410.** FCM natively returns `UNREGISTERED` as HTTP **404**; the relay normalizes it to caller-facing **410** so it matches the same "dead token, purge" semantic the APNs path uses for `Unregistered` (where APNs natively uses 410). This is an **intentional cross-provider normalization** (not a bug) so the caller's device-disable logic is uniform across APNs and FCM. Similarly `SENDER_ID_MISMATCH` keeps FCM's native 403.
|
||||
|
||||
**Note — per-device rate exceedance.** FCM enforces per-device Android caps (~240 msg/min, ~5000 msg/hr) that surface as a `DeviceMessageRateExceeded`-class error, **distinct from project-level `QUOTA_EXCEEDED`** (reference §2.7). Because the relay fans out bursty per-device wakes, this is a plausible real outcome. The exact error code/string for per-device exceedance is **not firmly documented**, so the relay detects it **defensively** by classifying on `error.status`/`errorCode` and maps it to a **retryable `429 rate_limited`** with a conservative default `Retry-After` — **never a token purge** (the device is fine, just rate-limited). The relay's coarse per-`(account, token_hash)` limiter (§9.1) is the local guard, but an upstream per-device error must still map to `429`, not fall through to `502`.
|
||||
|
||||
**Retry-After honoring (reference §2.7):** on FCM `429`, the relay reads the upstream `Retry-After`; if absent it defaults to 60 s. It forwards this as the caller-facing `Retry-After` header on the `429 rate_limited` response (relative `delay-seconds`, not an absolute epoch — reference §4.2). On `503 UNAVAILABLE` it forwards any upstream `Retry-After`.
|
||||
|
||||
Mapping to `03` caller handling: `unregistered`/`sender_id_mismatch`/`invalid_argument` → caller disables the device; `rate_limited` → caller backs off and may fall back to `priority: NORMAL` after repeated 429s (`03` open question, resolved "yes with cooldown"); `upstream_auth_error` → caller surfaces admin diagnostics, no retry. Note that `upstream_auth_error` is now sourced from the **relay's own** OAuth2/SA credential signals (HTTP `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` on `messages:send`, or a token-mint failure), **not** from `THIRD_PARTY_AUTH_ERROR` — which on this Android-only path is a registration anomaly, not a relay-credential problem (see the notes above and §7.5).
|
||||
|
||||
### 7.5 Reconciliations with the older `03` contract
|
||||
|
||||
- **`Retry-After` on 429.** `03` says "honoring the `Retry-After` response header if present (FCM uses standard HTTP semantics here)". The reference (§2.6/§2.7) refines this: FCM's error page promises only `google.rpc.QuotaFailure` (not `RetryInfo`); treat `Retry-After`/`RetryInfo` as optional and fall back to 60 s. The relay implements the **reference's** refinement while still surfacing a `Retry-After` to the caller exactly as `03` expects.
|
||||
- **No batch endpoint.** The reference (§2.1) confirms FCM's batch endpoint is discontinued; the relay sends **one POST per request** (it already does — one caller request = one device).
|
||||
- **Upstream field casing — `collapseKey`, `priority`, `validateOnly`.** `03` writes the upstream Android fields in snake_case (`collapse_key`) and lowercase priority (`high`/`normal`), and writes the dry-run flag as `validate_only`. The correct **FCM HTTP v1 REST** field names are `android.collapseKey` (camelCase), the canonical `priority` enum `"HIGH"`/`"NORMAL"` (uppercase; lowercase also accepted), and top-level `validateOnly` (camelCase) (reference §2.4/§2.5). The relay uses the **REST-correct names on the upstream wire** while leaving the **inbound caller field `collapse_key` unchanged** per `03`. FCM v1 rejects unknown fields with `INVALID_ARGUMENT`, so this reconciliation is mandatory, not cosmetic.
|
||||
- **FCM 4-distinct-collapse-key-per-device cap.** FCM stores at most **4 distinct collapse keys per device while offline**; a 5th distinct key evicts one nondeterministically (reference §2.7 — a delivery/storage behavior, **not** a per-message validation limit). Because `collapse_key` is a per-series HMAC and a device may follow **more than 4 series**, collapsible coalescing is **best-effort**: excess distinct keys evict nondeterministically and a collapsible wake may be dropped. This is **acceptable** because the durable inbox + sync (`01-release-events-and-inbox.md`) recovers any dropped wake; the relay treats `collapse_key` opaquely and takes no action. **Asymmetry note:** APNs has no equivalent documented per-device `collapse-id` cap, so the FCM path alone carries this best-effort caveat.
|
||||
- **External contract unchanged.** None of the above alters the `03` request/response shapes; only the relay's internal upstream behavior is specified more precisely.
|
||||
|
||||
---
|
||||
|
||||
## 8. Account, Key and Allowlist Model
|
||||
|
||||
### 8.1 DB schema (PostgreSQL)
|
||||
|
||||
Goose-style migration. All IDs are ULIDs stored as `text` for consistency with the Silo convention; timestamps are `timestamptz`.
|
||||
|
||||
```sql
|
||||
-- relay_accounts: one per opt-in self-hosted installation.
|
||||
CREATE TABLE relay_accounts (
|
||||
id text PRIMARY KEY, -- ULID
|
||||
name text NOT NULL, -- operator-facing label
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','disabled')),
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- relay_api_keys: revocable bearer keys (rk_ prefix). Secret never stored.
|
||||
CREATE TABLE relay_api_keys (
|
||||
id text PRIMARY KEY, -- ULID
|
||||
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
|
||||
key_prefix text NOT NULL, -- non-secret, indexed lookup handle (e.g. rk_live_3Qw9XYZ)
|
||||
key_hash bytea NOT NULL, -- HMAC-SHA256(pepper, secret), 32 bytes
|
||||
env text NOT NULL DEFAULT 'live'
|
||||
CHECK (env IN ('live','test')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz, -- throttled write (<= 1/min)
|
||||
expires_at timestamptz, -- optional
|
||||
revoked_at timestamptz -- non-null = revoked
|
||||
);
|
||||
CREATE UNIQUE INDEX relay_api_keys_prefix_uidx ON relay_api_keys (key_prefix);
|
||||
CREATE INDEX relay_api_keys_account_idx ON relay_api_keys (account_id);
|
||||
|
||||
-- relay_apns_allowlist: which APNs topics an account may push to.
|
||||
CREATE TABLE relay_apns_allowlist (
|
||||
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
|
||||
topic text NOT NULL, -- e.g. com.continuum.app.ios
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (account_id, topic)
|
||||
);
|
||||
|
||||
-- relay_fcm_allowlist: which (project, package) pairs an account may push to.
|
||||
CREATE TABLE relay_fcm_allowlist (
|
||||
account_id text NOT NULL REFERENCES relay_accounts(id) ON DELETE CASCADE,
|
||||
project_id text NOT NULL, -- e.g. continuum-prod-android
|
||||
package_name text NOT NULL, -- e.g. com.continuum.app.android
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (account_id, project_id, package_name)
|
||||
);
|
||||
|
||||
-- relay_op_logs: redacted operational/audit log. No tokens, no content.
|
||||
CREATE TABLE relay_op_logs (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
account_id text REFERENCES relay_accounts(id) ON DELETE SET NULL,
|
||||
request_id text, -- ULID echoed to caller
|
||||
event text NOT NULL, -- e.g. send.apns, send.fcm, auth.reject, admin.key_issue
|
||||
provider text, -- 'apns' | 'fcm' | null
|
||||
outcome text, -- 'accepted' | 'rejected' | 'upstream_error' | ...
|
||||
status_code int, -- caller-facing HTTP status
|
||||
error_code text, -- caller-facing error.code
|
||||
upstream_reason text, -- APNs reason / FCM status (non-sensitive)
|
||||
token_hash text, -- SHA-256(token) hex, NOT the raw token
|
||||
egress_ip inet, -- caller egress IP
|
||||
latency_ms int
|
||||
);
|
||||
CREATE INDEX relay_op_logs_account_time_idx ON relay_op_logs (account_id, occurred_at DESC);
|
||||
CREATE INDEX relay_op_logs_time_idx ON relay_op_logs (occurred_at DESC);
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `relay_op_logs` is the only table written on the hot path besides the throttled `last_used_at`. It carries **no raw token, no payload, no identity** — only a `token_hash` (SHA-256 hex) for correlating repeated failures to one device without storing the token (reference §4.6). Retention: see §13.
|
||||
- No table stores APNs/FCM device tokens, notification content, user/profile IDs, or token aliases — by design (`00` "Its database holds only…").
|
||||
|
||||
### 8.2 API-key format, hashing, lookup, compare
|
||||
|
||||
- **Format:** `rk_<env>_<random>`. The **prefix** = `rk_<env>_<first 6–8 chars of the random>` is **non-secret**, stored in cleartext, and uniquely indexed (`key_prefix`). The full random part is 32 bytes from `crypto/rand`, base62/base64url-encoded. GitHub/Stripe-style design — lets a request `SELECT` by indexed prefix in O(1), then constant-time-compare (reference §4.1).
|
||||
- **Hashing:** store `HMAC-SHA256(pepper, secret)` (32 bytes) where `pepper` is a server-side secret in the secret manager. A fast keyed hash is appropriate for **high-entropy 256-bit random keys** — OWASP's slow-KDF (argon2id) guidance is scoped to low-entropy passwords and would needlessly throttle per-request auth (reference §4.1). Never store plaintext.
|
||||
- **Lookup + compare on each request (constant-time regardless of whether the prefix exists):**
|
||||
1. Parse `Authorization: Bearer rk_<env>_<rest>`; reject malformed → `401 unauthorized`.
|
||||
2. Derive `key_prefix` from the token; `SELECT … FROM relay_api_keys WHERE key_prefix = $1` (unique index).
|
||||
3. If no row, or `revoked_at IS NOT NULL`, or `expires_at < now()`, or the account is `disabled` → still perform a **dummy `HMAC-SHA256` against a fixed decoy hash with `crypto/subtle.ConstantTimeCompare`**, then return the uniform `401 unauthorized` (do not distinguish reasons to the caller). **Do not short-circuit before the HMAC+compare** — an unknown prefix must take the same time as a known prefix with a wrong secret, or the timing difference is a key-enumeration oracle (a known/live prefix would otherwise reject measurably faster). The fine-grained `reason` (`unknown_prefix`/`hash_mismatch`/…) is kept **only in internal logs and the internal-only `/metrics`** (§13.2), never surfaced to a tenant or an untrusted scraper.
|
||||
4. Compute `HMAC-SHA256(pepper, presented_secret)` and `crypto/subtle.ConstantTimeCompare` against `key_hash` (or the decoy). Mismatch → `401`. Never use `==`/`bytes.Equal` on secret material (reference §4.1).
|
||||
5. Throttle-update `last_used_at` (at most once per minute per key). **The throttle clock lives in Redis** (`lastused:{key_prefix}` set `NX EX 60`) so the "≤1/min/key" bound holds **cross-replica** (a per-instance clock would allow N writes/min/key across N replicas); only when that `NX` succeeds does the relay issue the Postgres `UPDATE`. The Redis key carries no secret. This avoids a Postgres write per request (reference §4.1) without leaking a per-key validity side channel through DB write cadence.
|
||||
- **Rotation:** multiple live keys per account are supported (issue a new key, deploy it on the server, then revoke the old) for zero-downtime rotation (reference §4.1).
|
||||
- **Client-IP resolution (trusted-proxy requirement).** The relay runs **behind a TLS load balancer** (§4.2, §14.1), so Go's `http.Request.RemoteAddr` is the LB/proxy address, not the caller. The relay derives the client IP as follows: **use `RemoteAddr` unless the immediate peer is in the allowlisted trusted-LB CIDR set, in which case take exactly the right-most untrusted hop from the LB-set forwarding header (`X-Forwarded-For`)**. **Caller-supplied forwarding headers are ignored** — a caller cannot set `X-Forwarded-For`/`X-Real-IP` to forge an IP. The trusted-proxy CIDR list is a **config value**. Both the per-IP auth-failure limiter and the op-log `egress_ip` consume this single resolved IP. (Without this, a caller could either spread brute-force across unlimited fake IPs to defeat the per-IP throttle (OWASP API2:2023) and poison `egress_ip`, or — if `RemoteAddr` were trusted blindly behind the LB — collapse every tenant onto one IP and trip the throttle for all at once.)
|
||||
- **Anti-brute-force — per-IP auth-failure limiter (specified).** Auth failures are rate-limited **per resolved client IP** (independent of the per-account bucket) to blunt key-guessing (OWASP API2:2023, reference §4.1). Concretely:
|
||||
- **Store:** Redis, key `authfail:{client_ip}`, sliding/fixed window with `EXPIRE`.
|
||||
- **Window / threshold:** e.g. **20 failures / 5 min** per IP (tunable).
|
||||
- **Response on breach — *soft* lockout, not a hard block:** added latency / a `429` with `Retry-After` (e.g. 60 s backoff) rather than dropping the connection. This is deliberate because the doc anticipates **VPN/CGNAT shared egress** (§3.4): multiple self-hosted servers can share one egress IP, so a hard per-IP block would be collateral damage from a noisy/compromised neighbor. Optionally scope tracking by `(key_prefix, IP)` to further isolate neighbors.
|
||||
- **Fail-open/closed stance:** this limiter **fails open** if Redis is unreachable (availability over a perfectly-enforced anti-guessing cap), logging `authfail_limiter.degraded`. The hard auth boundary (HMAC compare, allowlist) is always enforced from Postgres regardless.
|
||||
- **Metric + runbook:** `relay_auth_failures_total{reason=…}` (internal only) plus a documented response runbook for the key-guessing alert (R8, §6.4 monitoring): inspect the offending IP/prefix, confirm whether a single account or a guessing pattern, revoke/rotate as needed.
|
||||
|
||||
### 8.3 The `rk_` token is shown exactly once
|
||||
|
||||
`relayctl key issue` prints the full `rk_…` token to stdout **once**; only the HMAC hash and prefix are persisted. There is no endpoint or command that can recover a secret. Lost keys are revoked and re-issued.
|
||||
|
||||
### 8.4 Allowlist enforcement
|
||||
|
||||
Enforced **after** auth, **before** building the upstream payload:
|
||||
|
||||
- **APNs (`/v1/apple/send`):** the request `topic` must exist in `relay_apns_allowlist` for the caller's `account_id`. Not present → `403 topic_not_allowed`. The official build config allowlist (allowlist config, **not** part of the contract) is the set `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos` (`02` resolved-questions: per-platform topics).
|
||||
- **FCM (`/v1/fcm/send`):** the `(project_id, package_name)` pair must exist in `relay_fcm_allowlist` for the caller's `account_id`. Missing project → `403 project_not_allowed`; project present but package not → `403 package_not_allowed`. Official build config: project `continuum-prod-android`, package `com.continuum.app.android`.
|
||||
- Allowlists are loaded per request from Postgres (cheap, small) or from a short-TTL in-memory cache keyed by `account_id` (invalidated on `relayctl allowlist … set`). The cache must be per-account-scoped so one account's allowlist can never authorize another's push.
|
||||
|
||||
These are **allowlist config values**, set by `relayctl`, not part of the external wire contract — a different deployment (e.g., a fork) would set different topics/packages.
|
||||
|
||||
> **Environment ⇄ token consistency (operational caveat).** The allowlist does **not** (and cannot) verify that the request `environment` matches the environment the device `token` was minted for. A `sandbox`-vs-`production` mixup presents downstream as APNs `BadDeviceToken`/`InvalidToken` → `422 bad_device_token` (§6.4), which would otherwise tell the caller to purge a perfectly good token. To let operators catch a **systemic** misconfiguration, the relay emits a distinct op-log/metric tag for these so a burst of `bad_device_token` concentrated on **one account** (likely an env mixup) is distinguishable from scattered genuinely-dead tokens across many accounts. The cross-field rule is documented in §5.1 and §6.4.
|
||||
|
||||
---
|
||||
|
||||
## 9. Rate Limiting and Quotas
|
||||
|
||||
### 9.1 Model
|
||||
|
||||
Per-account **token bucket** enforced in Redis with an atomic Lua script (refill → check → consume in one `EVAL`), state stored as a HASH `rl:{account_id} = {tokens, last_refill_ts}` with a TTL for idle-account eviction (reference §4.2). A Redis-backed limiter (not per-instance `x/time/rate`) is required because the relay runs multiple replicas behind a load balancer — per-instance limiters would not enforce a global cap.
|
||||
|
||||
A second, coarser limiter keys on `(account_id, token_hash)` to blunt hammering a single device (the `02`/`03` "rate limited by API key and coarse token hash" requirement).
|
||||
|
||||
**Redis operational requirements (eviction, HA, sizing).** Redis backs both idempotency (fail-closed) and rate limiting (fail-open), so its configuration is correctness-relevant, not just performance:
|
||||
|
||||
- **Non-evicting policy for idempotency.** The idempotency keyspace **must** run under `maxmemory-policy noeviction` (or live in a **dedicated Redis/db** separate from the rate-limit keyspace) so that memory pressure can **never silently evict an in-flight idempotency lock** — an evicted lock would silently violate at-most-once (a concurrent retry would get `NX`-success and double-send). Rate-limit keys may be evicted harmlessly; idempotency keys must not. This is called out in risks R4/R5.
|
||||
- **HA topology.** Run Redis with an HA topology (Sentinel or Cluster) with prompt failover. A Redis failover/restart makes **every** send `503` while idempotency fails closed (§10) — a push-delaying, not push-losing, event (the caller outbox retries), **but only as long as idempotency stays fail-closed**. A Redis outage should **page ops**.
|
||||
- **Keyspace sizing.** Size the idempotency keyspace from the quota math: ~`50k/day/account × N accounts`, each entry holding `{status, body, upstream_id, canonical_hash}` under a ~24 h TTL. Provision `maxmemory` with headroom above that working set so `noeviction` never starts rejecting writes.
|
||||
|
||||
### 9.2 Suggested limits (ties to `00` open question #1)
|
||||
|
||||
| Limit | Default | Source / rationale |
|
||||
|---|---|---|
|
||||
| Per-account burst | **~10 req/s** | `00` open question #1 ("Suggest 10 req/sec burst") |
|
||||
| Per-account daily quota | **50,000 req/day** | `00` open question #1 ("50,000 req/day initial quota") |
|
||||
| Bucket refill rate | 10 tokens/s, burst capacity 20 | derived from the 10 req/s target with a small burst allowance |
|
||||
| Per-(account, token) coarse cap | e.g. 1 req/3 s sustained | abuse guard on a single device |
|
||||
|
||||
These are tunable per account (future column on `relay_accounts`); v1 ships a single global default and tunes from logs (`00`). The Silo server already paces its calls client-side (default 5 req/s shared token bucket — `02`/`03` "Relay pacing"), so a well-behaved caller stays comfortably under the 10 req/s burst; the relay limit is the backstop against a misbehaving or compromised caller (OWASP API4:2023 — cap per-tenant downstream spend).
|
||||
|
||||
**Daily-quota mechanics (store, reset, fail-open).** The per-second token bucket (§9.1) does **not** model the 50,000 req/day cap; the daily counter is a **separate Redis counter** keyed `rl:day:{account_id}:{yyyymmdd}` with an `EXPIRE` past the day boundary, incremented atomically per accepted send:
|
||||
|
||||
- **Reset semantics:** a **fixed UTC calendar day** (the `{yyyymmdd}` is UTC), not a rolling 24 h — simple, predictable for operators, and the key self-expires.
|
||||
- **Breach:** when the counter reaches 50,000 the account's sends return `429 rate_limited` for the rest of the UTC day with a `Retry-After` = seconds until 00:00 UTC. Daily exhaustion blocks **all** of an account's pushes until reset — a notable availability event, so it is **alerted** (see below), not silent.
|
||||
- **Fail-open during a Redis outage:** like the per-second bucket, the daily counter **fails open** if Redis is unreachable — but this is exactly the abuse-amplification window, so it is **bounded** by the in-process fallback limiter in §9.4 (a compromised key cannot burn unlimited upstream sends during the outage) and the outage pages ops. The daily quota **cannot be enforced cross-replica while Redis is down**; that limitation is explicit.
|
||||
- **Alerting:** a metric/alert fires for accounts **approaching** (e.g. ≥80%) or **hitting** the daily cap, since a legitimate hundreds-of-users server can plausibly hit 50k/day on a big release burst — operators should see this and raise the cap rather than have the account silently throttle. Reconcile the 50k/day default against the `02`/`03` burst-cap math when tuning.
|
||||
|
||||
### 9.3 429 response
|
||||
|
||||
On limit exceeded the relay returns:
|
||||
|
||||
```http
|
||||
HTTP/2 429
|
||||
Retry-After: 3
|
||||
Content-Type: application/json
|
||||
X-Request-Id: 01JRELAY...
|
||||
|
||||
{ "error": { "code": "rate_limited",
|
||||
"message": "account rate limit exceeded",
|
||||
"request_id": "01JRELAY..." } }
|
||||
```
|
||||
|
||||
`Retry-After` is a **relative `delay-seconds`** integer (never an absolute Unix epoch — reference §4.2), computed from the bucket's time-to-next-token. The daily-quota breach also returns `429 rate_limited` with a longer `Retry-After` (seconds until the daily window rolls).
|
||||
|
||||
### 9.4 Fail-open vs fail-closed (bounded, not unlimited)
|
||||
|
||||
If Redis is unreachable, the rate-limit middleware **fails open** for availability (a push is more valuable than a perfectly enforced cap), but **bounded** — it does **not** simply remove the cap. When Redis is down the relay falls back to a **conservative per-instance in-process limiter** (`golang.org/x/time/rate`, keyed per `account_id`) that caps each account to a small multiple of its normal rate. This matters because the per-account quota is the primary control bounding a **stolen-key** attacker's spend and per-tenant downstream APNs/FCM cost (OWASP API4:2023, §3.5 "Stolen relay API key", G6): an unbounded fail-open would let a compromised `rk_` key send without limit against the official Apple/Google credentials — including burning the shared FCM project quota (§9.5) common to all tenants — for the entire outage. The **allowlist (always enforced from Postgres)** remains the hard boundary. A `rate_limit.degraded` op-log + metric fires; the **daily quota cannot be enforced cross-replica** during the outage, and an outage **pages ops**. The idempotency layer (§10), by contrast, **fails closed** to avoid double-sends. (Reference §4.2 — explicitly a per-endpoint decision.)
|
||||
|
||||
### 9.5 Shared FCM project quota — global limiter / circuit breaker
|
||||
|
||||
The per-account limits above are **local to each account**, but the **FCM project quota (~600k messages/min, reference §2.7) is SHARED across ALL relay accounts** sending to `continuum-prod-android`. Without a relay-side guard, one noisy/compromised account can exhaust the shared project quota and every other account's sends start returning `429 QUOTA_EXCEEDED` — a **multi-tenant fairness** failure (one noisy tenant degrades all). The relay therefore adds a **global (per-project) outbound limiter / circuit breaker** at the aggregation point — the only place that *can* see aggregate FCM rate across accounts:
|
||||
|
||||
- **(a) Track aggregate FCM send rate** against the documented project quota (a Redis counter keyed per project, e.g. `fcmq:{project_id}:{yyyymmddhhmm}` with a per-minute window).
|
||||
- **(b) Shed load before hammering FCM:** when near the quota, return `503 upstream_throttled` (retryable) **before** forwarding, rather than spending quota on calls that FCM will reject anyway (some errors still count against quota).
|
||||
- **(c) Anti-spike smoothing (optional):** honor the reference §2.7 guidance to avoid concentrating traffic in the 2-minute windows around `:00/:15/:30/:45` — the relay, as the cross-account aggregation point, is the only component that can actually implement this ramp.
|
||||
- **Observability:** a `relay_fcm_project_quota_headroom` gauge plus an alert when headroom is low. This is explicitly a **multi-tenant fairness** control (a single account must not exhaust the shared project quota for all).
|
||||
|
||||
---
|
||||
|
||||
## 10. Idempotency
|
||||
|
||||
### 10.1 Key format
|
||||
|
||||
`Idempotency-Key: <notification_delivery_id>:<push_device_id>:<attempt_number>` (`02`/`03`). This is **scoped per account** in storage (so two accounts cannot collide). The relay validates the header is present and non-empty on send endpoints; missing → `400 missing_idempotency_key`. The three-part shape is not strictly parsed (it is opaque to the relay), but it is length-limited (≤ 255 chars, Stripe-style — reference §4.3).
|
||||
|
||||
### 10.2 Store, TTL, locking, duplicate semantics
|
||||
|
||||
Redis, namespaced `idem:{account_id}:{key}` (reference §4.3):
|
||||
|
||||
1. `SET idem:{account}:{key} <in_flight_marker> NX EX <lock_ttl>` (`lock_ttl` = 30 s). The `<in_flight_marker>` carries a **unique per-attempt nonce** so the completing request can prove ownership (see invariant below).
|
||||
2. **NX success** → this is the first request. Process it; on completion, **compare-and-set**: overwrite the key with the serialized `{status, body, upstream_id, canonical_hash}` and a **~24 h TTL** **only if the stored value is still this request's own in-flight marker** (i.e. the lock was not stolen by a concurrent request after a TTL expiry). Success *and* error are both recorded (reference §4.3 / Stripe model).
|
||||
3. **NX fail + stored completed result** → **replay** it: return the original status code and body verbatim (so a retried send returns the same `apns_id`/`fcm_message_name`, never a second upstream send).
|
||||
4. **NX fail + still the in-flight marker** → the original is mid-flight → **`409 conflict`** (`{"error":{"code":"idempotency_conflict",…}}`) so the caller retries later.
|
||||
5. **Same key, different request payload** → **`422 unprocessable`** (`idempotency_key_reuse`). The relay stores a `canonical_hash` of the request alongside the result and compares (reference §4.3). **The canonical hash is computed over the validated fields with the device `token` replaced by its already-computed `token_hash` (SHA-256) — the raw token is NEVER part of the canonical form.** The stored idempotency value contains only `{status, body, upstream_id, canonical_hash}`; it holds **no raw token** (matching the op-log guarantee §12, NG1). This keeps the "no token aliases / stateless v1" privacy guarantee intact even though the value lives in Redis under `idem:{account}:{key}` for ~24 h.
|
||||
|
||||
> **Lock-TTL invariant (no double-send, no result-clobber).** The upstream APNs/FCM call **must run under a hard context deadline strictly shorter than `lock_ttl`** — e.g. **upstream timeout 10 s, `lock_ttl` 30 s** — and the handler **must refuse to send upstream if its own deadline would exceed the lock TTL**. Otherwise a slow first attempt (HTTP/2 setup + JWT/OAuth refresh + GOAWAY-reconnect can stack up) could outlive the 30 s lock; the lock would expire mid-flight, a concurrent retry of the same `Idempotency-Key` would get `NX`-success and fire a **second** upstream send (defeating at-most-once), and when the slow first attempt finally completed it would clobber the stored result with a different `apns_id`. The **compare-and-set on completion** (step 2) prevents the clobber — a lock that was stolen by a concurrent request cannot have its result overwritten by the original — and the **deadline-shorter-than-TTL** rule prevents the double-send in the first place. (Optionally, a lock heartbeat may renew the marker while genuinely in flight, but the deadline rule is the primary guarantee.) This invariant is the whole reason the idempotency store exists (R4).
|
||||
|
||||
This guarantees **at-most-once upstream send** under the caller's concurrent retries (the `../01-release-events-and-inbox.md` outbox can re-claim and re-dispatch the same `(delivery_id, device_id, attempt_number)`), which is exactly why the key includes `attempt_number`: attempt 2 is a *new* idempotency key (intended re-send after a failure), while two concurrent dispatches of attempt 1 collapse to one upstream call.
|
||||
|
||||
### 10.3 What a duplicate returns (summary)
|
||||
|
||||
| Situation | Response |
|
||||
|---|---|
|
||||
| First request | Process; record `{status, body, upstream_id, canonical_hash}` (no raw token — §10.2) for ~24 h. |
|
||||
| Duplicate, original completed | Replay stored status + body (same `apns_id`/`fcm_message_name`). |
|
||||
| Duplicate, original in-flight | `409 idempotency_conflict`. |
|
||||
| Same key, different payload | `422 idempotency_key_reuse`. |
|
||||
| Missing key | `400 missing_idempotency_key`. |
|
||||
|
||||
Idempotency **fails closed**: if Redis is unavailable, send endpoints return `503 upstream_unavailable` (retryable) rather than risk a double-send (contrast §9.4).
|
||||
|
||||
---
|
||||
|
||||
## 11. Security Requirements
|
||||
|
||||
- **TLS only.** Public endpoints are HTTPS over HTTP/2; no plaintext listener. TLS 1.2+ (prefer 1.3). HSTS on the public hostname. (`02`/`03` "Relay API uses TLS only".)
|
||||
- **Secret management.** APNs `.p8`, FCM SA JSON, the API-key HMAC pepper, and DB/Redis credentials live in a **secret manager** (KMS/Vault/cloud secret-manager). Ranking: secret-manager > tightly-permissioned mounted file > env var (**discouraged**) > in-image/repo (**forbidden**) (reference §4.5). On GCP, prefer keyless Workload Identity for FCM and avoid a downloadable SA JSON entirely (reference §4.5). Never commit or bake credentials into the image.
|
||||
- **Key storage.** API keys stored only as `HMAC-SHA256(pepper, secret)` + non-secret prefix; constant-time compare; revocable; optional expiry; multiple live keys per account for rotation (§8.2). The relay's own `rk_` secret is shown once and never recoverable.
|
||||
- **No raw secrets in transit out.** No endpoint returns the `.p8`, SA JSON, pepper, or any `rk_` secret. `/metrics` and op-logs carry none.
|
||||
- **Redaction (enforced in middleware so it cannot be bypassed).** Never log: `Authorization`/bearer header, raw API key (log only the non-secret prefix or a hash), raw APNs/FCM device tokens (log `SHA-256` truncated hash only), notification payload bodies, the relay JWT, or OAuth2 access tokens (reference §4.6; `02`/`03` privacy/security requirements). See §12/§13.
|
||||
- **Isolation / multi-tenancy.** Every API key, rate-limit bucket, idempotency key, allowlist cache entry, and log line is scoped by `account_id`. Shared-store keys are namespaced (`idem:{account}:…`, `rl:{account}`) to prevent cross-tenant collision or replay (reference §4.7). For APNs, connection pools are per-team (§6.1).
|
||||
- **Network posture.** `/metrics` and `/readyz` bound to the internal/scrape network; only `/v1/*` and `/healthz` are public. `relayctl` reaches Postgres over a private network with a privileged DSN held by operators only — there is no public admin API (§5.6).
|
||||
- **Input hardening.** `DisallowUnknownFields`, body size cap (e.g. 16 KiB — far above the ~1 KB legitimate body), explicit server timeouts (`ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes`) since the zero value means no timeout (reference §3.3). Graceful shutdown via `srv.Shutdown(ctx)` on SIGTERM.
|
||||
- **Egress / SSRF posture.** Both upstream hosts are **hardcoded constants** — `api.push.apple.com` / `api.sandbox.push.apple.com` (APNs) and `fcm.googleapis.com` (FCM) — **never derived from any request field**. The only request-derived value interpolated into an upstream URL is the FCM `project_id` (into `/v1/projects/{project_id}/messages:send`); it is **allowlist-checked** (§8.4) **and** validated against a strict charset **before** allowlist lookup and URL construction: `project_id` and `package_name` against `^[a-z0-9-]+$` / reverse-DNS, `topic` against the APNs reverse-DNS shape. This ensures an allowlist row can never introduce a path-traversal/host-override and the host can never be templated from caller input (SSRF/path-injection prevention).
|
||||
- **Anti-brute-force** on auth failures per resolved client IP, with trusted-proxy IP resolution (§8.2).
|
||||
|
||||
---
|
||||
|
||||
## 12. Privacy Requirements
|
||||
|
||||
The relay's privacy guarantee is **structural**, not policy-based: the request schema has no field that can carry content, and unknown fields are rejected.
|
||||
|
||||
- **Content-free guarantee.** The send-endpoint structs contain only the fields in §5.1/§5.2. There is **no** title, body, image URL, media ID, username, server hostname, server URL, library/collection/item name, or watched-state field (`02`/`03` privacy requirements). The relay-built payloads use only generic localization keys (`SILO_NOTIFICATION_TITLE` / `SILO_NOTIFICATION_GENERIC_BODY`) and opaque IDs.
|
||||
- **Reject any unexpected field.** JSON decoding uses `DisallowUnknownFields`; any field not in the schema → `400 unexpected_field`. This is the enforcement point for the content-free guarantee — a caller (buggy or hostile) cannot smuggle content through an extra key.
|
||||
- **Badge disabled by default.** `badge` is omitted unless explicitly provided, so unread counts don't leak through APNs/FCM by default (`02`/`03`).
|
||||
- **FCM is data-only with a closed key set.** No top-level `notification` block is ever constructed, so Google carries no rendering content (`03`). The `message.data` map is a **fixed, closed set of keys** — exactly `{v, wake, server_device_id, delivery_id}` — with **no caller-controlled key or value** beyond the already-opaque IDs (§5.2). `badge` is **never** injected into `data` (it has no data-only home and is ignored on the FCM path — §5.2), so the data-map allowlist cannot be widened by a provided badge or any extra key.
|
||||
- **No raw token in any store.** Neither the op-log nor the idempotency store holds a raw device token: the op-log carries only `token_hash` (SHA-256), and the idempotency value's `canonical_hash` is computed with the token replaced by `token_hash` (§10.2). The stateless-v1 "no token aliases" guarantee (NG1) holds across both stores.
|
||||
- **Redacted logging.** Op-logs and structured logs **never** contain: raw device tokens (only `SHA-256` hash), notification content (none exists), the `Authorization` header, the relay JWT, OAuth2 tokens, or the raw API key (only the prefix). Full request payloads are never persisted (`02`/`03` "Relay logs do not persist full request payloads"). What **is** logged for triage: `request_id`, `account_id`, status code, caller `error.code`, upstream reason, `token_hash`, egress IP, latency (reference §4.6).
|
||||
- **No identity storage.** The relay stores no user/profile identity, no device→user mapping, no server URL, and no token aliases (`00`).
|
||||
|
||||
---
|
||||
|
||||
## 13. Observability
|
||||
|
||||
### 13.1 Structured logs (with redaction)
|
||||
|
||||
`log/slog` JSON handler (reference §3.5). Every request emits one structured line through a redaction middleware that strips the disallowed fields (§12). Example (a rejected, allowlist-failed APNs send):
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2026-06-13T10:00:00Z",
|
||||
"level": "WARN",
|
||||
"msg": "send.apns rejected",
|
||||
"request_id": "01JRELAY...",
|
||||
"account_id": "01JACCOUNT...",
|
||||
"provider": "apns",
|
||||
"event": "send.apns",
|
||||
"outcome": "rejected",
|
||||
"status_code": 403,
|
||||
"error_code": "topic_not_allowed",
|
||||
"token_hash": "9f86d081...c2b5",
|
||||
"egress_ip": "203.0.113.7",
|
||||
"latency_ms": 2
|
||||
}
|
||||
```
|
||||
|
||||
Redaction is implemented in middleware so no code path can log a raw token, the bearer header, the JWT, or an OAuth2 token. The redacted audit subset also lands in `relay_op_logs` (§8.1) for queryable history. **Op-log retention:** successful sends **>14 days** are deleted; failures/upstream errors **>90 days** are deleted (mirrors the `02`/`03` `push_delivery_attempts` retention guidance). At 50k/day/account this table grows unbounded without active GC, so retention is a **shipped deliverable, not a deferral** — enforced by a scheduled sweeper / `relayctl logs --gc` (specified in `01-implementation-plan.md` Phase 5), with a disk-usage / table-growth alert. Time-partitioning of `relay_op_logs` is the recommended store strategy; see §15 #8.
|
||||
|
||||
### 13.2 Metrics catalog (Prometheus, `/metrics`)
|
||||
|
||||
`prometheus/client_golang` (reference §3.5). Labels avoid high-cardinality fields (no token, no `request_id`).
|
||||
|
||||
| Metric | Type | Labels | Meaning |
|
||||
|---|---|---|---|
|
||||
| `relay_requests_total` | counter | `provider`, `endpoint`, `status_code`, `error_code` | All inbound send requests by outcome. |
|
||||
| `relay_request_duration_seconds` | histogram | `provider`, `endpoint` | End-to-end relay latency. |
|
||||
| `relay_upstream_requests_total` | counter | `provider`, `upstream_status`, `upstream_reason` | APNs/FCM calls by upstream result. |
|
||||
| `relay_upstream_duration_seconds` | histogram | `provider` | Upstream call latency. |
|
||||
| `relay_auth_failures_total` | counter | `reason` (`bad_format`,`unknown_prefix`,`hash_mismatch`,`revoked`,`expired`,`account_disabled`) | Auth rejections. **`/metrics` is internal/scrape-only** — the `reason` split (which distinguishes `unknown_prefix` from `hash_mismatch`) must **never** be exposed to a tenant or untrusted scraper, or it becomes a key-enumeration oracle paired with the constant-time auth path (§8.2). |
|
||||
| `relay_rate_limited_total` | counter | `account_id`(low-card, hashed/bucketed if needed), `kind` (`account`,`token`) | 429s emitted. |
|
||||
| `relay_rate_limit_degraded_total` | counter | — | Fail-open events when Redis is down (§9.4). |
|
||||
| `relay_idempotency_total` | counter | `result` (`first`,`replay`,`conflict`,`reuse`) | Idempotency decisions. |
|
||||
| `relay_apns_jwt_refresh_total` | counter | `result` (`scheduled`,`expired_retry`,`throttled`) | JWT lifecycle (§6.1). |
|
||||
| `relay_fcm_token_refresh_total` | counter | `result` | OAuth2 token refreshes (§7.1). |
|
||||
| `relay_redis_up` / `relay_pg_up` | gauge | — | Core dependency health (drives `/readyz` — §5.4). |
|
||||
| `relay_provider_healthy` | gauge | `provider` | Per-provider init/circuit-breaker health (`1` healthy / `0` degraded) — does **not** gate `/readyz` (§5.4). |
|
||||
| `relay_provider_credential_healthy` | gauge | `provider` | Synthetic credential-validation result (§13.4); `0` triggers the credential alert. |
|
||||
| `relay_fcm_project_quota_headroom` | gauge | `project_id` | Remaining headroom against the shared FCM project quota (§9.5). |
|
||||
| `relay_daily_quota_used_ratio` | gauge | `account_id`(low-card/bucketed) | Fraction of the 50k/day cap consumed; alerts ≥80% (§9.2). |
|
||||
| `relay_connection_severed_total` | counter | `provider` | Sends mapped to `503` by GOAWAY/connection reset before a final response (§6.4). |
|
||||
| `relay_authfail_limiter_degraded_total` | counter | — | Per-IP auth-failure limiter fail-open events (§8.2). |
|
||||
|
||||
### 13.3 Admin diagnostics
|
||||
|
||||
`relayctl` provides read paths for operators (no raw secrets/tokens): `account list`, `key list`, `allowlist show`, and a `relayctl logs --account <id> [--since …] [--outcome rejected]` that queries `relay_op_logs` (redacted by construction). A `relayctl ping-upstream --provider apns|fcm --env sandbox` performs a credential-only health check (JWT sign / token mint, optionally an FCM `validateOnly:true` send) without sending real content — useful after a credential rotation. The same check captures the sandbox `apns-unique-id` (§5.1) when APNs returns one.
|
||||
|
||||
### 13.4 Credential health, validation and rotation alerting
|
||||
|
||||
Credentials can be revoked or rotated **out from under the relay** at any time — Apple "actively monitors and revokes leaked `.p8` keys" (`00`), and FCM SA keys / Workload-Identity bindings can be disabled. Relying only on production sends starting to fail (upstream `403 InvalidProviderToken` / the relay's own `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED`) is too late. The relay therefore validates credentials **proactively**, distinguishing an **auth-layer** failure from a mere token-layer rejection:
|
||||
|
||||
- **Startup + periodic synthetic validation.** On startup and on a periodic timer, the relay exercises the auth layer end-to-end: an FCM `validateOnly:true` send, and an APNs send to a **known-bad token** that still forces APNs to authenticate the JWT (so an auth-layer `403 InvalidProviderToken`/`BadCertificate*` is distinguishable from a token-layer `400 BadDeviceToken` — the latter proves the credential is *accepted*). Note `/readyz` alone is **insufficient**: it only proves a JWT was **signed locally** / a token **minted**, not that APNs/FCM **accept** the credential.
|
||||
- **`credential healthy` gauge.** Wire the synthetic-validation result into a per-provider `relay_provider_credential_healthy` gauge (§13.2) and surface it on the per-provider readiness signal (§5.4).
|
||||
- **Continuous health via cron.** Schedule `relayctl ping-upstream` (e.g. a cron / scheduled run) for continuous credential health between restarts.
|
||||
- **Alert on first auth failure.** Alert on the **first** `upstream_auth_error` (the relay's own credential signals — §6.4/§7.4), not after a sustained failure rate, since a revoked credential fails *all* sends immediately.
|
||||
|
||||
---
|
||||
|
||||
## 14. Deployment and Operations
|
||||
|
||||
### 14.1 Public endpoint & TLS
|
||||
|
||||
- One public hostname, `relay.silo.app`, terminating TLS (1.2+, prefer 1.3) at a load balancer or in-process. HTTP/2 end to end. Only `/v1/apple/send`, `/v1/fcm/send`, and `/healthz` are public; `/readyz` and `/metrics` are internal-only.
|
||||
- Set explicit server timeouts and graceful shutdown (§11).
|
||||
|
||||
### 14.2 High availability — "relay-down delays, never loses"
|
||||
|
||||
The relay being down does **not** lose pushes: the self-hosted server's dispatch is an **outbox**. The fanout transaction in `../01-release-events-and-inbox.md` inserts `pending` `push_delivery_attempts` rows in the same transaction as the `notification_deliveries` row; the dispatcher claims them post-commit and a recovery sweeper re-claims stale `pending` rows (`00`/`02`/`03`). So if the relay returns `503`/times out, the caller marks the attempt `retrying` and retries with backoff — the durable inbox row and the outbox attempt row guarantee eventual delivery. **This means the relay can be operated with ordinary HA (multiple stateless replicas, rolling deploys) rather than five-nines durability.** A short relay outage only delays pushes.
|
||||
|
||||
- **Stateless replicas.** No in-memory request state that can't be reconstructed; idempotency and rate-limit state are in Redis, shared across replicas. Scale horizontally behind the LB.
|
||||
- **Rolling deploys / kill-safety.** A replica can be drained (`srv.Shutdown`) or killed mid-request; the caller retries the same idempotency key, which either replays the recorded result or re-sends at-most-once (§10).
|
||||
- **Drain ordering (no orphaned idempotency locks).** A clean drain must not turn into a 30 s stall per retry. On `SIGTERM`: (1) flip `/readyz` to `503` so the LB **stops routing new requests** to this replica; (2) keep serving **in-flight** handlers; (3) bound the `srv.Shutdown` grace period to **less than the idempotency `lock_ttl`** (§10.2) — and/or **release/downgrade the in-flight idempotency marker on handler context-cancellation** — so that a send killed mid-flight does **not** leave a stale 30 s `in_flight` marker in Redis. Otherwise the caller's immediate retry hits §10.2 case 4 and gets `409 idempotency_conflict` for up to `lock_ttl` seconds. The relationship between the shutdown grace period and `lock_ttl` is load-bearing and is asserted by a drain test (no orphaned in-flight markers after a rolling restart — see `01-implementation-plan.md`).
|
||||
|
||||
### 14.3 Secret provisioning
|
||||
|
||||
- Provision the APNs `.p8` (+ Team ID, Key ID) and FCM SA JSON (or Workload Identity binding) into the secret manager out-of-band. The HMAC pepper and DB/Redis creds likewise. Never in the image or repo (§11).
|
||||
- **Credential rotation runbook (overlap window, dual-key).** Apple permits **multiple active `.p8` keys per team**, and the relay already keys connection pools by team (§6.1), so rotation is **make-before-break**: (1) provision the new credential into the secret manager alongside the old; (2) load both — the relay supports an **overlap window** where old and new keys are both valid and the key-selection map (§6.1) can resolve to either; (3) run `relayctl ping-upstream` / the synthetic validation (§13.4) to confirm the **new** credential authenticates against APNs/FCM; (4) cut sends over to the new key (rolling restart picks up the new secret); (5) retire/revoke the old credential only after the new one is confirmed healthy. The same overlap pattern applies to FCM SA keys / Workload-Identity bindings. This avoids the failure mode where a single-key rotation revokes the old credential before the new one is proven, taking all pushes down.
|
||||
|
||||
### 14.4 Sandbox vs production
|
||||
|
||||
- The relay supports both APNs environments via the request `environment` field, routing to `api.push.apple.com` (production) or `api.sandbox.push.apple.com` (sandbox) (§6.2). FCM has a single global host (no sandbox split — reference §2.3); test sends use `validateOnly:true` (reference §2.5).
|
||||
- A **staging relay deployment** (separate hostname, separate accounts) is recommended for client QA so test traffic never touches production accounts/quotas.
|
||||
|
||||
### 14.5 Scaling & regions
|
||||
|
||||
- **Scaling.** One well-tuned APNs HTTP/2 connection handles thousands of pushes/sec (reference §1.3/§3.1); one FCM token source serves the project. The bottleneck at relay scale is inbound throughput and Redis, both horizontally scalable. Keep upstream clients **process-singletons** (one per env/team/project), not per-request.
|
||||
- **Regions.** APNs and FCM are global; FCM has no data-residency send endpoints (reference §2.3). v1 runs a single region close to the operator's Postgres/Redis. Multi-region is possible later (the request path is stateless; Redis/Postgres would need regional strategy) but is out of scope for v1.
|
||||
- **Quota awareness.** FCM's default project quota is ~600k messages/min (reference §2.7); the relay's per-account 50k/day default (§9.2) is far under that even summed across many accounts. The **shared** project quota is nonetheless guarded by the relay-side global limiter / circuit breaker (§9.5) so one noisy account cannot exhaust it for all tenants; operators monitor `relay_fcm_project_quota_headroom` (§13.2), the project quota in Cloud Console, and `relay_upstream_requests_total{upstream_reason="QUOTA_EXCEEDED"}`.
|
||||
|
||||
---
|
||||
|
||||
## 15. Open Questions
|
||||
|
||||
1. **Per-account rate limits — real numbers.** v1 ships the `00`-suggested ~10 req/s burst + 50k/day defaults (§9.2); tune from production logs. Should limits become per-account columns on `relay_accounts` with `relayctl quota set`? (Ties to `00` open question #1.)
|
||||
2. **APNs sandbox hostname (resolved — note only).** `api.sandbox.push.apple.com` and `api.development.push.apple.com` are **interchangeable, both-resolving** Apple sandbox aliases; the relay standardizes on `api.sandbox.push.apple.com` (reference §1.3, reference open question §5 item 4). The external contract (`"environment":"sandbox"`) is unaffected either way. No build-time decision required.
|
||||
3. **`apns2` vs hand-rolled APNs client.** `sideshow/apns2` has had no release since v0.25.0 (Oct 2024) — bus-factor risk. Decide before implementation: adopt `apns2` or hand-roll `net/http2` + `golang-jwt/jwt/v5` (reference §3.1, §5.2).
|
||||
4. **FCM credentials: GCP-keyless vs off-GCP JSON.** If the relay runs on GCP, use Workload Identity / attached SA (no downloadable key); off-GCP, the SA JSON lives in the secret manager. Resolve in the deployment design (reference §4.5 / §2.2).
|
||||
5. **`apns-expiration` / `android.ttl` default values (resolved; tune from product input).** v1 **ships finite defaults** — `apns-expiration` ~4 h for `private_alert` / ~1 h for `background_wake`, and the parallel FCM `android.ttl` (§5.1/§5.2) — rather than the 30-day APNs / 4-week FCM store-and-retry, so stale wakes don't fire days later. The exact durations are configurable and may be tuned with product input (reference §1.4).
|
||||
6. **API-key hashing: HMAC pepper vs plain salted SHA-256.** This spec chooses HMAC-SHA-256 with a secret-manager pepper for defense-in-depth. Pepper rotation is operationally heavier (re-hash all keys or support dual peppers). Confirm the tradeoff (reference §4.1, open question §12).
|
||||
7. **Idempotency-Key spec status.** The IETF draft expired 2026-04-18 without becoming an RFC; the header name + Stripe-style semantics are de-facto standard. Re-check for an RFC before relying on its exact status-code semantics (reference §4.3).
|
||||
8. **Op-log volume & retention.** At 50k/day/account across many accounts, `relay_op_logs` grows unbounded without GC. v1 **ships** the retention sweep (delete success >14 d, failure >90 d — §13.1) as a scheduled `relayctl logs --gc` / built-in sweeper (`01-implementation-plan.md` Phase 5), with a table-growth alert. Open: whether to **time-partition** `relay_op_logs` (recommended) or offload to a log pipeline rather than Postgres at higher volume.
|
||||
9. **Multi-region.** Out of scope for v1 (single region). If latency to distant self-hosters matters, design a regional Redis/Postgres strategy for the otherwise-stateless request path.
|
||||
10. **FCM priority deprioritization.** The relay honors `03`'s `priority: high` for `private_data`. If FCM deprioritizes Silo's app-instance traffic over the 7-day window (reference §2.8), is any relay-side change warranted, or is this purely the caller's `priority: normal` fallback concern? Lean: caller's concern; revisit if metrics show systematic deprioritization.
|
||||
@@ -0,0 +1,738 @@
|
||||
# Silo Push Relay — Implementation Plan
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Draft (implementation-grade, phased)
|
||||
**Service:** `silo-push-relay` (provisional repo name; the relay code lives in a **separate repository**)
|
||||
**Provisional hostname:** `relay.silo.app`
|
||||
**Stack:** Go 1.25+, PostgreSQL, Redis
|
||||
|
||||
**Source documents (read before executing any phase):**
|
||||
|
||||
- [`./00-relay-spec.md`](./00-relay-spec.md) — the engineering spec this plan implements. Section numbers below (e.g. "spec §6.4") refer to it.
|
||||
- [`./02-apns-fcm-2026-reference.md`](./02-apns-fcm-2026-reference.md) — 2026-current Apple/Google/Go reference (cited as "reference §N").
|
||||
- [`../02-apns-relay.md`](../02-apns-relay.md) and [`../03-fcm-relay.md`](../03-fcm-relay.md) — the **authoritative external wire contract** (the `02`/`03` request/response shapes). The relay must honor these byte-for-byte.
|
||||
|
||||
> **Commands assume the repository root is the cwd.** This document contains no local absolute filesystem paths or transient worktree IDs; all repo references are repository-relative.
|
||||
|
||||
> **Repo boundary.** This plan describes a **separate repository** (`silo-push-relay`). The design docs are authored here in `silo-server` purely for co-location with the `02`/`03` contracts. The relay **code** lives elsewhere. The one deliberate coupling is the shared `pushwire` payload-builder package, mirrored into both repos (see §3 and Phase 3/4).
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
### 1.1 What is being built
|
||||
|
||||
A small, **stateless-on-the-request-path** Go HTTP service that holds the official Silo Apple (`.p8` APNs auth key) and Google (Firebase service-account JSON) push credentials, accepts authenticated **content-free** push requests from opted-in self-hosted Silo servers on two endpoints, builds a fixed generic APNs/FCM payload, forwards it upstream, and maps the upstream result back to a narrow caller-facing response.
|
||||
|
||||
The two send endpoints and their contract are fixed by `02`/`03` and reproduced in spec §5:
|
||||
|
||||
| Endpoint | Purpose | Authoritative contract |
|
||||
|---|---|---|
|
||||
| `POST /v1/apple/send` | Forward one opaque push to APNs for one device token | `../02-apns-relay.md`, spec §5.1 |
|
||||
| `POST /v1/fcm/send` | Forward one opaque data-only push to FCM for one device token | `../03-fcm-relay.md`, spec §5.2 |
|
||||
| `GET /healthz` | Liveness (public) | spec §5.3 |
|
||||
| `GET /readyz` | Readiness incl. PG/Redis core deps + per-provider credential health (internal) | spec §5.4 |
|
||||
| `GET /metrics` | Prometheus exposition (internal) | endpoint spec §5.4; metric catalog §13.2 |
|
||||
|
||||
Administration is a CLI (`relayctl`) that writes directly to the relay's PostgreSQL — **there is no public admin HTTP API** in v1 (spec §5.6).
|
||||
|
||||
### 1.2 The v1 cut line
|
||||
|
||||
**In scope (v1):**
|
||||
|
||||
- `POST /v1/apple/send` and `POST /v1/fcm/send` exactly per `02`/`03`.
|
||||
- Bearer auth (`rk_` keys), per-account allowlists, Redis token-bucket rate limiting, Redis idempotency.
|
||||
- APNs token-based (ES256 `.p8`) client with cached JWT; FCM HTTP v1 client with cached OAuth2 token.
|
||||
- PostgreSQL storage of accounts/keys/allowlists/redacted op-logs only; Goose-style migrations.
|
||||
- `relayctl` admin CLI; structured redacted logs; Prometheus metrics; `/healthz` + `/readyz`; graceful shutdown.
|
||||
- Single region, multiple stateless replicas behind a TLS load balancer.
|
||||
|
||||
**Explicitly out of scope (v1)** — from spec §2.2 / NG1–NG7:
|
||||
|
||||
- Token→user aliases, device subscriptions, any user/profile identity (stateless v1).
|
||||
- Arbitrary APNs/FCM JSON, custom titles/bodies, topic/condition broadcasts, badge-by-default.
|
||||
- Delivery receipts, analytics, open-tracking.
|
||||
- APNs broadcast / Live Activity channels (`/4/broadcasts/...`).
|
||||
- Web Push, HMS, ADM, or any non-APNs/non-FCM transport.
|
||||
- A public self-service signup / billing surface.
|
||||
- The `custom_apns` / `custom_fcm` direct paths (those run inside `silo-server`).
|
||||
- Multi-region (the request path is stateless, but Redis/Postgres regional strategy is deferred).
|
||||
|
||||
### 1.3 The real long pole: provider account provisioning
|
||||
|
||||
**Apple/Firebase account provisioning is the critical-path dependency, not the code.** Obtaining the official Apple Developer team, generating a `.p8` APNs auth key (Key ID + Team ID), registering per-platform bundle topics, standing up the Firebase project(s), and minting a service-account JSON can take days-to-weeks of organizational/approval lead time and is **fully decoupled from coding**.
|
||||
|
||||
> **Action: start the §7 provisioning checklist on day 1, in parallel with Phase 0.** Phases 0–2 (scaffold, storage, auth/rate-limit/idempotency) need **no** live provider credentials and can complete entirely against fakes and contract tests. Only Phase 3 (APNs integration tests against sandbox) and Phase 4 (FCM `validateOnly`) are gated on credentials. If provisioning slips, the code still reaches "everything but live upstream" without blocking.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Track A — Code
|
||||
P0[Phase 0\nscaffold] --> P1[Phase 1\nstorage] --> P2[Phase 2\nauth/RL/idem]
|
||||
P2 --> P3[Phase 3\nAPNs]
|
||||
P2 --> P4[Phase 4\nFCM]
|
||||
P3 --> P5[Phase 5\nobservability]
|
||||
P4 --> P5
|
||||
P5 --> P6[Phase 6\nharden+deploy]
|
||||
end
|
||||
subgraph Track B — Provisioning (parallel, long pole)
|
||||
A1[Apple team + .p8 + topics]
|
||||
G1[Firebase project + SA JSON + package]
|
||||
end
|
||||
A1 -. gates .-> P3
|
||||
G1 -. gates .-> P4
|
||||
A1 -. gates .-> P6
|
||||
G1 -. gates .-> P6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Repository Bootstrap
|
||||
|
||||
### 2.1 Go module path
|
||||
|
||||
```
|
||||
module github.com/silo-app/silo-push-relay
|
||||
```
|
||||
|
||||
Target **Go 1.25+** (required by `jackc/pgx v5.10` and `firebase.google.com/go/v4 v4.20`; reference §3 version-targeting note). Pin in `go.mod` with `go 1.25`.
|
||||
|
||||
### 2.2 Directory layout
|
||||
|
||||
```
|
||||
silo-push-relay/
|
||||
├── cmd/
|
||||
│ ├── relay/ # main HTTP service entrypoint (wires config → stores → clients → server)
|
||||
│ │ └── main.go
|
||||
│ └── relayctl/ # admin CLI (account/key/allowlist/logs/ping-upstream); writes directly to PG
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── config/ # env + secret-manager config loading, validation, defaults
|
||||
│ ├── httpapi/ # router, handlers, middleware (auth, rate limit, idempotency, redaction), error bodies
|
||||
│ ├── apns/ # APNs upstream client: ES256 JWT lifecycle, HTTP/2 pool, reason→error mapping
|
||||
│ ├── fcm/ # FCM upstream client: OAuth2 token source, HTTP/2 client, status→error mapping
|
||||
│ ├── accounts/ # data-access for accounts/keys/allowlists (shared by httpapi + relayctl)
|
||||
│ ├── ratelimit/ # Redis Lua token-bucket limiter (per-account + coarse per-token)
|
||||
│ ├── idempotency/ # Redis SET NX lock / replay / 409 / 422 store
|
||||
│ ├── pushwire/ # SHARED payload builders (APNs + FCM payload/header construction) — mirrored into silo-server
|
||||
│ ├── oplog/ # redacted op-log writer (PG) + token hashing helper
|
||||
│ ├── observability/ # slog JSON handler + redaction, Prometheus registry/metrics, /healthz /readyz /metrics
|
||||
│ └── store/ # pgxpool + go-redis bootstrapping, health pings, migration runner hook
|
||||
├── migrations/
|
||||
│ └── sql/ # Goose-style timestamped SQL migrations (see §2.6)
|
||||
├── deploy/
|
||||
│ ├── Dockerfile
|
||||
│ └── (k8s / compose manifests as applicable)
|
||||
├── Makefile
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── .golangci.yml
|
||||
├── .github/workflows/ci.yml # or equivalent CI config
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Package ownership mirrors the Silo convention (CLAUDE.md "keep new code in the package that owns the behavior"): no catch-all `utils` package; shared payload logic lives in `pushwire`; shared data-access lives in `accounts`/`store` so `relayctl` and `httpapi` share schema and constraints (spec §5.6 "The CLI shares the relay's data-access package").
|
||||
|
||||
### 2.3 The shared `pushwire` package (maintainability win)
|
||||
|
||||
`internal/pushwire` is the single source of truth for **how an opaque request becomes an upstream payload + headers** — the APNs `aps`/`silo` dictionaries and `apns-*` headers (spec §5.1), and the FCM data-only `message` body + `android` config (spec §5.2). It is intentionally **dependency-light** (pure Go structs + JSON, no network) so it can be **mirrored into `silo-server`** under its `custom_apns` / `custom_fcm` paths, where the self-hosted server builds the *same* payloads when an admin uses their own credentials. One package, two repos: the relay and the direct-credential path can never drift on payload shape. (See §3 dependency table and Phase 3/4.)
|
||||
|
||||
> **Mirroring mechanism & sync runbook (drift/ownership hazard — make it explicit).** Keep `pushwire` import-cycle-free and free of relay-only types (no DB, no config). **Designate one repo as the canonical source of truth** for `pushwire` (the relay repo `silo-push-relay`, since it owns the upstream payload contract). The copy in `silo-server` is a **checked-in generated artifact**, not a hand-edited file: commit a **content checksum** of the canonical package alongside it, and have **both** repos' CI verify their local copy against that checksum (a copied package in repo A cannot fail repo B's CI on its own, so a passive golden-file test in one repo is insufficient). Bump a **versioned payload-schema constant** on any payload change so a mismatch is unambiguous. Add a golden-file + closed-allowlist-key contract test on both sides (Phase 3/4 / §5.2). Document the ownership/sync runbook (who regenerates, in what order, how the checksum is bumped) so a payload change cannot silently diverge until the other repo happens to update. The preferred end state is a small shared module both repos import rather than a manual copy.
|
||||
|
||||
### 2.4 Config strategy
|
||||
|
||||
Twelve-factor-ish, but credentials come from a **secret manager**, not env vars (spec §11, reference §4.5 ranking: secret-manager > mounted file > env var > in-image). `internal/config` resolves, in order of preference per secret:
|
||||
|
||||
| Config | Source (preferred → fallback) | Notes |
|
||||
|---|---|---|
|
||||
| APNs `.p8` private key, Key ID, Team ID | secret manager → mounted file | never env var, never in image |
|
||||
| FCM SA JSON **or** Workload Identity binding | GCP attached SA / Workload Identity → secret manager JSON | off-GCP uses JSON; on-GCP keyless (reference §4.5) |
|
||||
| API-key HMAC pepper | secret manager | rotation is heavier (spec open Q#6) |
|
||||
| Postgres DSN, Redis URL | secret manager → env | pool-tuned |
|
||||
| Non-secret tuning (timeouts, rate defaults, listen addrs, log level, environment label) | env vars / flags | safe to log |
|
||||
|
||||
`config.Load(ctx)` returns a validated, fully-populated struct or a hard error; the process refuses to start with a missing/invalid credential. Provide a `config.Validate()` that `cmd/relay` calls before opening any listener. Non-secret defaults (rate limits from spec §9.2, timeouts from spec §11) live as constants with env overrides.
|
||||
|
||||
### 2.5 Makefile (mirror Silo's Makefile-driven workflow)
|
||||
|
||||
`make` targets, modeled on `silo-server`'s Makefile (CLAUDE.md §Build):
|
||||
|
||||
```make
|
||||
make build # go build ./cmd/relay ./cmd/relayctl
|
||||
make run # run cmd/relay locally against docker-compose PG+Redis
|
||||
make test # go test ./... (unit + contract; no live providers)
|
||||
make test-integration # go test -tags=integration ./... (APNs sandbox + FCM validateOnly; needs creds)
|
||||
make lint # golangci-lint run
|
||||
make fmt # gofmt -w + goimports
|
||||
make migrate-create NAME=add_thing # timestamped Goose migration scaffold
|
||||
make migrate-up # apply migrations
|
||||
make migrate-status # list migration state
|
||||
make relayctl ARGS="account list" # build+run the admin CLI
|
||||
make docker # build deploy/Dockerfile
|
||||
make loadtest # run the k6/vegeta burst scenario (Phase 6)
|
||||
```
|
||||
|
||||
`make migrate-create NAME=...` must produce a **timestamped** filename (CLAUDE.md: "New migrations must use timestamped filenames created with `make migrate-create`; do not run `goose fix` or create paired `.up.sql`/`.down.sql` files"). The relay uses single-file Goose SQL migrations with `-- +goose Up` / `-- +goose Down`.
|
||||
|
||||
### 2.6 Migrations (Goose-style)
|
||||
|
||||
`migrations/sql/<timestamp>_<name>.sql`, run by a Goose runner wired in `internal/store` and invokable via `make migrate-up` and on `cmd/relay` startup (behind a flag). The v1 schema is exactly spec §8.1 (`relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`). IDs are ULIDs stored as `text`; timestamps are `timestamptz` (Silo convention).
|
||||
|
||||
### 2.7 CI outline
|
||||
|
||||
CI (GitHub Actions or equivalent) runs on every push/PR:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
build-test:
|
||||
steps:
|
||||
- setup-go 1.25
|
||||
- make lint # golangci-lint
|
||||
- make build
|
||||
- make test # unit + contract tests (no provider creds)
|
||||
- upload coverage
|
||||
migrate-check:
|
||||
services: [postgres, redis]
|
||||
steps:
|
||||
- make migrate-up # migrations apply cleanly on a fresh DB
|
||||
- make migrate-status
|
||||
integration: # gated: only when provider creds are available (Phase 3+)
|
||||
if: secrets.APNS_P8 != '' && secrets.FCM_SA_JSON != ''
|
||||
services: [postgres, redis]
|
||||
steps:
|
||||
- make test-integration # APNs sandbox + FCM validateOnly contract tests
|
||||
```
|
||||
|
||||
The `integration` job is **conditional on secrets being present** so Phases 0–2 are green long before any credential exists.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency Choices
|
||||
|
||||
All versions are from the reference §3.7 pinned list. Pin exact versions in `go.mod`; bump deliberately.
|
||||
|
||||
| Concern | Library | Version | One-line rationale |
|
||||
|---|---|---|---|
|
||||
| APNs client | `github.com/sideshow/apns2` **or** hand-rolled | `v0.25.0` / `x/net/http2 v0.56.0` + `golang-jwt/jwt/v5 v5.3.1` | De-facto Go APNs HTTP/2 client (auto JWT + conn reuse); **decide vs. hand-rolled before Phase 3** because `apns2` has had no release since Oct 2024 (bus-factor; reference §3.1, spec open Q#3). |
|
||||
| FCM auth | `golang.org/x/oauth2/google` | `v0.36.0` | `CredentialsFromJSONWithType(..., google.ServiceAccount, firebase.messaging scope)` gives an auto-caching/-refreshing `TokenSource`; lighter than the Admin SDK and gives direct control of conn reuse + error classification (reference §3.2 — preferred for a fixed payload). |
|
||||
| FCM (alt, batteries-included) | `firebase.google.com/go/v4/messaging` | `v4.20.0` | Acceptable alternative with `IsUnregistered`/`IsQuotaExceeded` helpers; **pick exactly one** FCM path, not both (reference §3.2). Plan defaults to the `x/oauth2` path. |
|
||||
| JWT (ES256 sign for APNs; RS256 for hand-rolled FCM grant) | `github.com/golang-jwt/jwt/v5` | `v5.3.1` | Maintained JWT lib; ES256 for APNs, RS256 if hand-rolling the FCM token exchange (reference §3.1). |
|
||||
| HTTP/2 transport tuning | `golang.org/x/net/http2` | `v0.56.0` | `ReadIdleTimeout`+`PingTimeout` to reap half-open upstream conns — "the single most important tuning for a long-lived relay" (reference §3.3). |
|
||||
| Postgres | `github.com/jackc/pgx/v5` + `pgxpool` | `v5.10.0` | Recommended driver/pool; `pgxpool.New` is concurrency-safe + health-checked (reference §3.4). |
|
||||
| Redis | `github.com/redis/go-redis/v9` | `v9.20.1` | Context-first client with pooling; atomic Lua for rate-limit, `SET NX` for idempotency (reference §3.4). |
|
||||
| Router | stdlib `net/http` (Go 1.22+ `http.ServeMux` patterns) | stdlib | Two routes + health/metrics need no framework; stdlib `ServeMux` supports method+path patterns and gets HTTP/2 over TLS automatically. Avoids a dependency; middleware is plain `http.Handler` wrapping. |
|
||||
| Structured logging | `log/slog` | stdlib (Go 1.21+) | `slog.NewJSONHandler` for production; redaction middleware so secrets can't leak (reference §3.5, spec §13.1). |
|
||||
| Metrics | `github.com/prometheus/client_golang` | `v1.23.2` | Custom registry + `promhttp.HandlerFor` for `/metrics`; per-provider latency histograms + outcome counters (reference §3.5, spec §13.2). |
|
||||
| Per-instance rate (outbound pacing helper) | `golang.org/x/time/rate` | current | `Reservation.Delay()` to compute relative `Retry-After`; used for in-process pacing, **not** the cross-replica cap (that is Redis — reference §4.2). |
|
||||
| ULID generation | `github.com/oklog/ulid/v2` (or equivalent) | current | Generate `request_id` and entity IDs as ULIDs (Silo convention; spec §8.1). |
|
||||
|
||||
**Decision to make before Phase 3 (spec open Q#3):** adopt `sideshow/apns2` or hand-roll the APNs client. Recommendation: prototype both in Phase 3 behind the `internal/apns` interface; pick `apns2` if its recent commit/issue activity is acceptable, else hand-roll on `x/net/http2` + `golang-jwt/jwt/v5` mirroring the verified `apns2` internals (reference §3.1: `HostProduction`/`HostDevelopment`, `TokenTimeout=3000`s, `ReadIdleTimeout=15s`). Either way the rest of the service depends only on the `internal/apns` interface, not the concrete client.
|
||||
|
||||
**`pushwire` maintainability note (reference + spec §5).** The shared `pushwire` package builds the exact APNs/FCM payloads and headers. Because `silo-server`'s `custom_apns`/`custom_fcm` paths must produce identical payloads, `pushwire` is mirrored into `silo-server` with **one canonical source repo** (the relay), a **committed checksum verified in CI on both sides**, and a **closed-allowlist-key** test so an added `data`/payload key fails CI regardless of golden-file regeneration order (§2.3 sync runbook, §5.2). This is also a **supply-chain control for the content-free guarantee**: the payload-builder that enforces "data-only, no `notification` block, fixed key set" is enforced in two repos that must not drift. Payload shape is defined once.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased Tasks
|
||||
|
||||
Effort sizes: **S** ≈ 1 day, **M** ≈ 2–3 days, **L** ≈ 4–5 days (one engineer). Sizes are rough.
|
||||
|
||||
### Phase 0 — Scaffold, config, health, CI (size: M)
|
||||
|
||||
**Goal.** A buildable, lintable, testable repo that boots `cmd/relay`, serves `/healthz`, loads config, and is green in CI — with **no** provider dependency.
|
||||
|
||||
**Files to create/modify.**
|
||||
|
||||
- `go.mod`, `go.sum`, `.golangci.yml`, `Makefile`, `README.md`, `.github/workflows/ci.yml`, `deploy/Dockerfile`.
|
||||
- `cmd/relay/main.go` — load config, build slog JSON logger, start an `http.Server` with explicit timeouts and graceful shutdown, mount `/healthz`.
|
||||
- `internal/config/config.go` — `Load(ctx)`, `Validate()`, defaults; secret-manager interface stub (real wiring in Phase 6).
|
||||
- `internal/observability/logger.go` — `slog.NewJSONHandler` setup.
|
||||
- `internal/httpapi/server.go`, `internal/httpapi/router.go`, `internal/httpapi/health.go` — router, `/healthz`.
|
||||
- `internal/httpapi/errors.go` — the standard error body (spec §5.5) and a `writeError(w, status, code, msg, requestID)` helper.
|
||||
|
||||
**Implementation notes.**
|
||||
|
||||
- Server timeouts are **mandatory** (zero = no timeout): `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes` (reference §3.3, spec §11). Body size cap ~16 KiB.
|
||||
- Graceful shutdown: on SIGTERM/SIGINT call `srv.Shutdown(ctx)` with a bounded context (spec §14.2).
|
||||
- `/healthz` returns `200 {"status":"ok"}`, unauthenticated, no dependency checks (spec §5.3).
|
||||
- Error body shape is fixed (spec §5.5):
|
||||
```json
|
||||
{ "error": { "code": "string_code", "message": "human readable", "request_id": "01JRELAY..." } }
|
||||
```
|
||||
Every request gets a ULID `request_id`, echoed as the `X-Request-Id` response header.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- Unit: `config.Validate` rejects missing required fields; `writeError` emits exact JSON + `X-Request-Id`.
|
||||
- Unit: `/healthz` returns 200 with the exact body.
|
||||
- CI: `make lint`, `make build`, `make test` green; `migrate-check` job present (no migrations yet → no-op pass).
|
||||
|
||||
**Acceptance criteria.**
|
||||
|
||||
- [ ] `make build` produces `relay` and `relayctl` binaries.
|
||||
- [ ] `make lint` and `make test` pass in CI with zero provider credentials.
|
||||
- [ ] `cmd/relay` boots, serves `/healthz` 200, and shuts down gracefully on SIGTERM.
|
||||
- [ ] Every response carries `X-Request-Id`; errors use the §5.5 shape.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Storage, migrations, `relayctl` admin CLI (size: L)
|
||||
|
||||
**Goal.** The full PostgreSQL schema, a migration runner, the shared data-access package, and a working `relayctl` that can provision accounts/keys/allowlists and read op-logs — all without touching providers.
|
||||
|
||||
**Files to create/modify.**
|
||||
|
||||
- `migrations/sql/<ts>_init_relay_schema.sql` — exact schema from spec §8.1: `relay_accounts`, `relay_api_keys`, `relay_apns_allowlist`, `relay_fcm_allowlist`, `relay_op_logs`, plus indexes (`relay_api_keys_prefix_uidx`, `relay_api_keys_account_idx`, `relay_op_logs_account_time_idx`, `relay_op_logs_time_idx`).
|
||||
- `internal/store/postgres.go` — `pgxpool.New(ctx, dsn)`, `Ping`, migration runner hook.
|
||||
- `internal/store/redis.go` — `go-redis` client + `PING` (used Phase 2).
|
||||
- `internal/accounts/` — data-access: `CreateAccount`, `ListAccounts`, `DisableAccount`, `IssueKey`, `ListKeys`, `RevokeKey`, `LookupKeyByPrefix`, `SetApnsAllowlist`, `SetFcmAllowlist`, `GetAllowlists`. Used by both `httpapi` and `relayctl`.
|
||||
- `internal/accounts/keys.go` — key generation + hashing (see notes).
|
||||
- `internal/oplog/oplog.go` — `Write(ctx, entry)` to `relay_op_logs`; `TokenHash(token) string` (SHA-256 hex).
|
||||
- `cmd/relayctl/main.go` + subcommands per spec §5.6 table.
|
||||
|
||||
**Implementation notes.**
|
||||
|
||||
- **API-key format & hashing** (spec §8.2, reference §4.1):
|
||||
- Format `rk_<env>_<base62/base64url>`; env ∈ {`live`,`test`}. Random secret = 32 bytes from `crypto/rand`.
|
||||
- **Prefix** = `rk_<env>_<first 6–8 chars of random>`, non-secret, stored cleartext, uniquely indexed.
|
||||
- Store `key_hash = HMAC-SHA256(pepper, secret)` (32 bytes). Never store plaintext. Fast keyed hash is correct for 256-bit random keys (do **not** use argon2id here).
|
||||
- `relayctl key issue` prints the full `rk_…` **exactly once** to stdout; only hash+prefix persist (spec §8.3).
|
||||
- `relayctl` connects directly to Postgres with a privileged DSN held by operators (no public admin API; spec §5.6). Each mutating command writes an `admin.*` op-log with operator identity (OS user / `--actor`) and never the secret token (spec §5.6).
|
||||
- Allowlist commands replace the set atomically (UPSERT/DELETE in one tx) for `relay_apns_allowlist` / `relay_fcm_allowlist`.
|
||||
- Official allowlist config values to seed in examples/tests (allowlist config, **not** the contract — spec §8.4): APNs topics `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos`; FCM project `continuum-prod-android`, package `com.continuum.app.android`.
|
||||
|
||||
**`relayctl` command surface (spec §5.6 / §13.3).**
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `relayctl account create --name <label> [--note <text>]` | INSERT `relay_accounts`; print `account_id` |
|
||||
| `relayctl account list [--active]` | SELECT accounts + key count + last activity |
|
||||
| `relayctl account disable --account <id>` | `status='disabled'` |
|
||||
| `relayctl key issue --account <id> [--env live\|test] [--expires <dur>]` | INSERT key; print `rk_…` once |
|
||||
| `relayctl key list --account <id>` | SELECT (prefix/created/last_used/revoked; never secret) |
|
||||
| `relayctl key revoke --key-prefix <prefix>` | `revoked_at=now()` |
|
||||
| `relayctl allowlist apns set --account <id> --topics a,b,c` | replace APNs allowlist |
|
||||
| `relayctl allowlist fcm set --account <id> --project <p> --packages a,b` | replace FCM allowlist |
|
||||
| `relayctl allowlist show --account <id>` | print effective allowlists |
|
||||
| `relayctl logs --account <id> [--since …] [--outcome rejected]` | query `relay_op_logs` (redacted) |
|
||||
| `relayctl logs --gc` | retention GC: delete success rows >14d, failure/reject rows >90d (Phase 5; cron-schedulable) |
|
||||
| `relayctl ping-upstream --provider apns\|fcm --env sandbox` | credential-only health check; flips `relay_credential_healthy` (Phase 3/4/6) |
|
||||
|
||||
**Tests.**
|
||||
|
||||
- Integration (PG via docker-compose): `make migrate-up` applies cleanly on a fresh DB; `make migrate-status` lists state.
|
||||
- Unit/integration: `IssueKey` → `LookupKeyByPrefix` round-trips; hash is HMAC-SHA256; plaintext never stored.
|
||||
- Integration: full `relayctl` flow — create account → issue key → set allowlists → list/show; `admin.*` op-logs written without secrets.
|
||||
- Unit: `oplog.TokenHash` is stable SHA-256 hex; op-log entry rejects/strips raw token.
|
||||
|
||||
**Acceptance criteria.**
|
||||
|
||||
- [ ] Migrations apply on a fresh DB and match spec §8.1 exactly (column names/types/constraints/indexes).
|
||||
- [ ] `relayctl` can provision an account, issue a key (printed once), set both allowlists, and read redacted logs.
|
||||
- [ ] No table stores tokens, content, or identity; `relay_op_logs` holds only `token_hash`, never raw tokens.
|
||||
- [ ] Key hash is HMAC-SHA256(pepper, secret); constant-time compare verified in a unit test.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Auth middleware, rate limit, idempotency (size: L)
|
||||
|
||||
**Goal.** The full request-path middleware chain (auth → rate limit → idempotency → decode/validate/allowlist) wired in front of stub send handlers that return a deterministic fake upstream result. No real provider yet.
|
||||
|
||||
**Files to create/modify.**
|
||||
|
||||
- `internal/httpapi/middleware_auth.go` — bearer parse, prefix lookup, constant-time compare, revoked/expired/disabled rejection, throttled `last_used_at`, per-IP anti-brute-force.
|
||||
- `internal/ratelimit/limiter.go` + `ratelimit/bucket.lua` — Redis Lua token bucket (per-account + coarse per-token).
|
||||
- `internal/httpapi/middleware_ratelimit.go` — applies the limiter, emits `429` + `Retry-After`.
|
||||
- `internal/idempotency/store.go` + `idempotency/idem.lua` (if needed) — `SET NX` lock, replay, 409, 422.
|
||||
- `internal/httpapi/middleware_idempotency.go` — wraps send handlers.
|
||||
- `internal/httpapi/decode.go` — `DisallowUnknownFields` decoder + field validation + allowlist enforcement.
|
||||
- `internal/httpapi/apple.go`, `internal/httpapi/fcm.go` — handler skeletons returning a fake `accepted` result (real upstream in Phase 3/4).
|
||||
|
||||
**Implementation notes.**
|
||||
|
||||
- **Auth** (spec §8.2): parse `Authorization: Bearer rk_<env>_<rest>` (malformed → `401 unauthorized`); derive `key_prefix`; `SELECT … WHERE key_prefix=$1` (unique index); reject if no row / revoked / expired / account disabled (do not distinguish reasons to caller — all `401 unauthorized`); `subtle.ConstantTimeCompare(HMAC(pepper, presented), key_hash)`; throttle `last_used_at` to ≤1/min/key; rate-limit auth failures per resolved client IP (see client-IP resolution below).
|
||||
- **Constant-time auth path (no enumeration oracle).** On a missing/revoked/expired row, **still** perform a dummy `HMAC-SHA256` against a fixed decoy hash with `subtle.ConstantTimeCompare` before returning the uniform `401`, so "unknown prefix" is not measurably faster than "known prefix, wrong secret." Keep the fine-grained failure reason (`unknown_prefix` vs `hash_mismatch`) in **internal** logs/metrics only — never in a response and never exposed to an untrusted `/metrics` scraper.
|
||||
- **Client-IP resolution / trusted proxy (security-critical).** The relay sits behind a TLS load balancer, so Go's `http.Request.RemoteAddr` is the LB address, not the caller. Derive the client IP from `RemoteAddr` **unless** the immediate peer is in an **allowlisted trusted-proxy CIDR list** (a config value), in which case take the **right-most untrusted hop** from the LB-set forwarding header. **Caller-supplied `X-Forwarded-For`/`X-Real-IP` headers are ignored** — trusting them would let an attacker spread brute-force across unlimited fake IPs (defeating the per-IP throttle) and poison `egress_ip` in op-logs. Both the per-IP auth-failure limiter and the logged `egress_ip` consume this single resolved IP. Account for shared egress (CGNAT/VPN — multiple self-hosted servers behind one IP, anticipated in spec §3.4): make the brute-force lockout **soft** (added latency / `Retry-After`), not a hard block, so a noisy/compromised neighbor cannot lock out legitimate servers sharing the IP.
|
||||
- **Bounded fail-open on Redis outage.** The per-account/daily rate limit is the primary cap on a stolen-key attacker's spend (and on shared downstream APNs/FCM quota). A naive fail-open removes that cap entirely during a Redis outage. Instead, when Redis is unreachable, fall back to a conservative **in-process** per-account limiter (`x/time/rate`) so each account is still capped to a small multiple of its normal rate; the allowlist (always enforced from Postgres) remains the hard boundary. A Redis outage pages ops; the cross-replica daily quota cannot be enforced during the outage (documented limitation).
|
||||
- **Rate limit** (spec §9): Redis HASH `rl:{account_id} = {tokens, last_refill_ts}` with TTL, atomic Lua (refill→check→consume in one `EVAL`). Defaults (spec §9.2): per-account ~10 req/s burst, refill 10/s with burst capacity 20; per-account daily 50,000 req/day; coarse `(account_id, token_hash)` ~1 req/3s. Over limit → `429 rate_limited` with **relative** `Retry-After` (delay-seconds, never an absolute epoch — reference §4.2). On Redis down: **bounded fail-open** (in-process per-account fallback limiter, above — not unlimited), log `rate_limit.degraded`, increment `relay_rate_limit_degraded_total` (spec §9.4).
|
||||
- **Daily quota mechanics.** Store the daily counter as a **separate Redis counter** `rl:day:{account_id}:{yyyymmdd}` with `INCR` + `EXPIRE` to the end of the window — it is **not** the per-second token bucket and **not** a Postgres column. Reset semantics: **fixed UTC calendar day** (`yyyymmdd` in UTC). During a Redis outage the daily counter, like the per-second bucket, falls back to the bounded in-process limiter and the precise cross-replica daily cap cannot be enforced (documented; pages ops). Emit `relay_rate_limited_total{kind="daily"}` and alert on accounts approaching/hitting the cap (a big-release burst can legitimately reach 50k/day; §6.4).
|
||||
- **Project-level FCM circuit breaker (multi-tenant fairness).** The ~600k msgs/min FCM project quota (reference §2.7) is **shared across all relay accounts**, but the per-account limit is local to each account — one noisy account can exhaust the shared quota for everyone. Add a relay-side **global per-project outbound limiter / circuit breaker** that tracks aggregate FCM send rate against the project quota and **sheds load with `503 upstream_throttled` before hammering FCM** when near quota (the relay is the only aggregation point that can honor reference §2.7's anti-spike / smooth-traffic guidance — avoid sending within a 2-minute window of :00/:15/:30/:45). Export `relay_fcm_project_quota_headroom` and alert (§6.4).
|
||||
- **Idempotency** (spec §10): require `Idempotency-Key` header on sends; missing → `400 missing_idempotency_key`. Length-limit ≤255 chars; the three-part `<delivery_id>:<device_id>:<attempt>` shape is **opaque** (not strictly parsed). Namespaced `idem:{account_id}:{key}`. `SET … NX EX <lock_ttl>` (`lock_ttl` ≈ 30s) in-flight lock:
|
||||
- NX success → process; on completion overwrite with serialized `{status, body, upstream_id, canonical_hash}` + ~24h TTL (success **and** error recorded).
|
||||
- NX fail + stored completed result → **replay** verbatim (same `apns_id`/`fcm_message_name`).
|
||||
- NX fail + still in-flight → `409 idempotency_conflict`.
|
||||
- Same key + different request payload (compare stored `canonical_hash`) → `422 idempotency_key_reuse`.
|
||||
- **Fails closed**: Redis unavailable → `503 upstream_unavailable` (never risk a double-send; spec §10.2).
|
||||
- **Lock-TTL invariant (no double-send / no clobber).** The upstream APNs/FCM call (plus HTTP/2 setup, JWT/OAuth refresh, GOAWAY reconnect) can otherwise outlast `lock_ttl`, letting a concurrent retry's NX succeed and fire a **second** upstream send. Pin the upstream call with a hard context deadline **strictly shorter than `lock_ttl`** (≈10s upstream vs 30s lock) and refuse to send upstream if the handler's own deadline has already passed the lock TTL. On completion, use a **compare-and-set** that only overwrites the stored value if it is still *this* request's in-flight marker, so a lock stolen by a concurrent request cannot have its result clobbered.
|
||||
- **Privacy invariant (no stored token).** The `canonical_hash` is computed over the validated fields with the device token **replaced by its already-computed `token_hash` (SHA-256)**; the stored idempotency value is exactly `{status, body, upstream_id, canonical_hash}` and holds **no raw token** — matching the op-log no-raw-token guarantee (NG1, spec §3.2/§12).
|
||||
- **Drain interaction.** On handler context-cancellation (e.g. a drain/shutdown that severs an in-flight send), **release or downgrade** the in-flight marker so an immediate caller retry does not get spurious `409 idempotency_conflict` for the full `lock_ttl` (see Phase 6 drain ordering and §5.7 drain test).
|
||||
- **Decode/validate/allowlist** (spec §5.1/§5.2/§8.4): `DisallowUnknownFields` (unknown key → `400 unexpected_field`). Validate every field per the spec field tables (token shape, environment, mode, ID length caps, collapse-id ≤64 bytes for APNs). Allowlist check **after** auth, **before** payload build: APNs `topic` ∈ account's `relay_apns_allowlist` else `403 topic_not_allowed`; FCM `(project_id, package_name)` ∈ `relay_fcm_allowlist` else `403 project_not_allowed` / `403 package_not_allowed`. Allowlist may be a short-TTL per-account-scoped in-memory cache invalidated on `relayctl allowlist … set`.
|
||||
|
||||
**Middleware order (spec §4.1).**
|
||||
|
||||
```
|
||||
TLS → auth → rate-limit → decode(DisallowUnknownFields) → validate+allowlist → idempotency → [Phase 3/4 upstream] → map → store idem result → op-log + metrics
|
||||
```
|
||||
|
||||
**Tests.**
|
||||
|
||||
- Unit: bearer parse edge cases; constant-time compare; revoked/expired/disabled all map to `401`; `last_used_at` throttle.
|
||||
- Unit: the auth path is **constant-time for unknown-prefix vs wrong-secret** (dummy HMAC + `ConstantTimeCompare` on a missing/revoked row) — no enumeration timing oracle; the fine-grained reason stays internal-only.
|
||||
- Unit: **trusted-proxy client-IP resolution** — a forged caller `X-Forwarded-For`/`X-Real-IP` is ignored; only the LB-set header from an allowlisted trusted-proxy peer is honored; `RemoteAddr` is used otherwise.
|
||||
- Integration (Redis): token bucket refill/consume math; `429` + correct relative `Retry-After`; **daily counter** (`rl:day:{account}:{yyyymmdd}`, fixed-UTC reset, `EXPIRE`) trips at 50k; **bounded** fail-open on Redis down (in-process per-account cap engaged, not unlimited).
|
||||
- Integration (Redis): idempotency first/replay/409/422/missing-key matrix (spec §10.3); fail-closed `503` on Redis down; the stored value carries **no raw token** (canonical hash uses `token_hash`).
|
||||
- Integration (Redis): **Redis dropped mid-request** (not just down at start) — rate-limit layer degrades to bounded fail-open; idempotency layer fails closed (`503`); a lock whose TTL expires mid-send cannot be stolen into a double-send (upstream deadline < `lock_ttl`).
|
||||
- Contract: `DisallowUnknownFields` rejects every disallowed field (`title`, `body`, `image_url`, `media_id`, `username`, `server_url`, …) with `400 unexpected_field`.
|
||||
- Contract: allowlist rejections produce exact codes `topic_not_allowed` / `project_not_allowed` / `package_not_allowed`.
|
||||
|
||||
**Acceptance criteria.**
|
||||
|
||||
- [ ] A request with a valid key, allowlisted topic, and `Idempotency-Key` reaches the (stub) handler and returns `accepted`.
|
||||
- [ ] Duplicate idempotency key replays the identical body; in-flight → `409`; payload mismatch → `422`; missing → `400`.
|
||||
- [ ] Over-limit → `429` with relative `Retry-After`; Redis-down rate-limit fails open, idempotency fails closed.
|
||||
- [ ] Every unknown field and every non-allowlisted topic/project/package is rejected with the exact §5/§8 error code.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — APNs client + `POST /v1/apple/send` (size: L) · gated on Apple provisioning
|
||||
|
||||
**Goal.** A production APNs client (cached ES256 JWT, reused HTTP/2 pool, correct reason→error mapping) and the live `/v1/apple/send` endpoint building the §5.1 payloads via `pushwire`, validated against the APNs **sandbox**.
|
||||
|
||||
**Files to create/modify.**
|
||||
|
||||
- `internal/apns/client.go` — the `APNsClient` interface + concrete impl (apns2 **or** hand-rolled per §3 decision).
|
||||
- `internal/apns/jwt.go` — ES256 JWT lifecycle (cache, ~50min refresh, never <20min, eager refresh on `ExpiredProviderToken`).
|
||||
- `internal/apns/transport.go` — per-team HTTP/2 pool with `ReadIdleTimeout`/`PingTimeout`; env→host selection.
|
||||
- `internal/apns/errors.go` — APNs status+`reason` → caller `error.code` + retryability (spec §6.4 table).
|
||||
- `internal/pushwire/apns.go` — build `private_alert` / `background_wake` `aps`+`silo` payloads and `apns-*` headers (spec §5.1).
|
||||
- `internal/httpapi/apple.go` — replace the Phase 2 stub with the real send.
|
||||
|
||||
**Implementation notes.**
|
||||
|
||||
- **JWT** (spec §6.1, reference §1.1): ES256 only; header `{"alg":"ES256","kid":<10-char Key ID>}`, claims `{"iss":<10-char Team ID>,"iat":<epoch s>}`. Cache one JWT behind a mutex; regenerate on a ~50-min timer, never <20-min apart; eager regenerate + **single in-process retry** on `403 ExpiredProviderToken`; back off / slow refresh on `429 TooManyProviderTokenUpdates`. **Per-team pool** keyed by team even though v1 has one team.
|
||||
- **Single-flighted JWT regeneration (stampede guard).** Under a burst of concurrent sends sharing one cached JWT, an expiry (or a `403 ExpiredProviderToken`) is observed by many in-flight goroutines at once; a naive per-goroutine regenerate-on-403 would trip `429 TooManyProviderTokenUpdates`. Regeneration must therefore be **single-flighted** (e.g. `golang.org/x/sync/singleflight` or a generation/epoch counter under the mutex): a goroutine seeing `ExpiredProviderToken` first checks whether the JWT was already refreshed since *its* request started and reuses the new token rather than triggering another refresh. Exactly **one** refresh happens per ~20-min window regardless of how many concurrent 403s arrive.
|
||||
- **Endpoint** (spec §6.2): `production` → `https://api.push.apple.com`; `sandbox` → `https://api.sandbox.push.apple.com`. Both `api.sandbox.push.apple.com` and the older alias `api.development.push.apple.com` are **valid, equivalent sandbox hosts that still resolve** — we standardize on `api.sandbox.push.apple.com` (the current canonical name and `apns2` v0.25.0's `HostDevelopment`); `02`'s use of `api.development.*` is not a discrepancy to resolve, just the older alias. The external contract `"environment":"sandbox"` is unchanged. The upstream hosts are **hardcoded constants never derived from request input**. Port 443, 2197 fallback.
|
||||
- **Connection** (spec §6.3, reference §3.3): one client/pool per process per env per team; never per push. Non-zero `ReadIdleTimeout`(~15s) + `PingTimeout`(~15s). Do not hardcode `SETTINGS_MAX_CONCURRENT_STREAMS`. Handle `GOAWAY` (drop+reconnect). Ensure system trust store includes USERTrust RSA (default in OS/Go pools).
|
||||
- **Headers** (spec §5.1 table): `apns-topic` = allowlisted topic; `apns-push-type` = `alert` (private_alert) / `background` (background_wake); `apns-priority` = `10` / `5` (background **must** be 5; 10 is an error — reference §1.4.1); `apns-collapse-id` = `collapse_id` if present (≤64 bytes); `apns-id` omitted (Apple generates, echoed back). **`apns-expiration` ships with a finite default** (configurable) rather than 0/omitted: a few hours for `private_alert`, a short TTL for `background_wake`. Omitting it makes Apple store-and-retry an undelivered push for **up to 30 days** (reference §1.4) — for a notification whose content is fetched live on wake, a wake fired days later is useless or confusing. Mirror this on FCM with a finite `android.ttl` (default is otherwise ~4 weeks) for cross-provider parity. (Resolved open Q#5; the exact default values want product input but v1 ships a sane non-30-day default, not an omission.)
|
||||
- **Payloads** (`pushwire`, spec §5.1): `private_alert` → `aps.alert` with `title-loc-key=SILO_NOTIFICATION_TITLE`, `loc-key=SILO_NOTIFICATION_GENERIC_BODY`, `sound=default`, plus `silo` dict (`v:1`, `wake:"notifications.changed"`, `server_device_id`, `delivery_id`); add `aps.badge` only if `badge` is present, non-null, and within a **bounded range (0–9999)** — out-of-range → `400 invalid_field` (it is the one numeric field that escapes the opaque-ID discipline, so it is bounded rather than copied verbatim). `background_wake` → `aps.content-available:1` + `silo` dict. Uncompressed JSON, far under 4 KB; defensive `400 payload_too_large` if it ever exceeds.
|
||||
- **Response → caller** (spec §5.1): `200` → `{ "request_id", "apns_id" (Apple's apns-id header), "status":"accepted" }`. Error mapping per spec §6.4 (e.g. `BadDeviceToken`/**`InvalidToken`**/`DeviceTokenNotForTopic`/`MissingDeviceToken` → `422 bad_device_token`; `Unregistered`/`ExpiredToken` → `410 unregistered` with `timestamp` passed through in `error.message`; `403 InvalidProviderToken`/etc → `502 upstream_auth_error` + ops alert; `429 TooManyRequests` → `429 rate_limited` + `Retry-After`; 5xx → `503 upstream_unavailable`). **`InvalidToken` is treated equivalently to `BadDeviceToken` (token-purge, non-retryable)** — Apple has migrated some responses from `BadDeviceToken` to `InvalidToken`, and the `02` contract requires treating them equivalently; omitting it would let a dead device fall through to the catch-all and never be disabled. Add a **default/fallback rule**: any unrecognized 400/403/410 `reason` maps to a safe non-retryable `502 upstream_error` rather than being dropped. **Connection-severed sends** — a request whose HTTP/2 stream is terminated by `GOAWAY` or any connection reset **before** a final APNs response arrives → `503 upstream_unavailable` (retryable), the same as a network timeout; the relay does **not** transparently retry it (the caller's outbox does), preserving at-most-once semantics under the idempotency key. Tag the op-log outcome / metric label `connection_severed` so these are distinguishable. **Environment/token mismatch caveat:** a `sandbox`/`production` mismatch (e.g. a prod token sent with `environment=sandbox`) presents upstream as `BadDeviceToken` → `bad_device_token`, which instructs the caller to purge a perfectly good token; emit a distinct op-log/metric so a systemic misconfig (many `bad_device_token` from one account) is distinguishable from genuinely dead tokens. Network/timeout to APNs → `503 upstream_unavailable`. The relay does **not** retry transient upstream errors across caller requests (the caller's outbox does); the only in-process retry is the JWT-expiry one (single-flighted — see JWT note below).
|
||||
|
||||
**Tests.**
|
||||
|
||||
- Unit: JWT signs valid ES256 with correct header/claims; refresh-timer logic (≥20min, ≤60min, eager-on-expired); single retry on `ExpiredProviderToken`.
|
||||
- Concurrency: force JWT expiry under a burst of concurrent sends; assert **exactly one** regeneration (single-flight) and **zero** `429 TooManyProviderTokenUpdates`.
|
||||
- Unit (`pushwire`): golden-file payloads + headers for `private_alert` and `background_wake`, with and without `badge`/`collapse_id` (byte-exact vs spec §5.1). **Same golden files mirrored into `silo-server`.**
|
||||
- Unit: every row of the spec §6.4 reason→error table maps correctly (table-driven), **including `InvalidToken` → `bad_device_token`** (equivalent to `BadDeviceToken`) and the **default/fallback** rule (unrecognized reason → `502 upstream_error`, non-retryable).
|
||||
- Fake APNs upstream injecting a **`GOAWAY` / mid-stream reset** before a final response → caller gets `503 upstream_unavailable`, the op-log/metric is tagged `connection_severed`, and there is **no lost or double send** (the relay does not transparently retry).
|
||||
- Integration (**Apple sandbox**, behind `-tags=integration`, requires `.p8`): real send to `api.sandbox.push.apple.com` returns 200 + `apns_id`; an intentionally bad token returns the expected `bad_device_token`/`unregistered` mapping.
|
||||
- Integration: `relayctl ping-upstream --provider apns --env sandbox` signs+connects without sending content; optionally captures the sandbox-only `apns-unique-id` response header (reference §1.6) into the ping output for Push-Notifications-Console delivery-log correlation during client QA.
|
||||
|
||||
**Acceptance criteria.**
|
||||
|
||||
- [ ] `/v1/apple/send` builds the exact §5.1 payloads/headers and returns `{request_id, apns_id, status:"accepted"}` on success against sandbox.
|
||||
- [ ] Background pushes always send `apns-priority: 5` and `apns-push-type: background`.
|
||||
- [ ] JWT is cached, reused, and refreshed on the ~50-min cadence; `ExpiredProviderToken` triggers one regenerate+retry; concurrent 403s yield exactly one single-flighted regeneration.
|
||||
- [ ] Every spec §6.4 mapping is covered by a passing table-driven test, **including `InvalidToken` and the unrecognized-reason fallback**; a `GOAWAY`/connection-severed send maps to `503 upstream_unavailable` with no double-send; the `pushwire` golden files match across both repos.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — FCM client + `POST /v1/fcm/send` (size: L) · gated on Firebase provisioning
|
||||
|
||||
**Goal.** A production FCM HTTP v1 client (cached OAuth2 token, tuned HTTP/2, correct status→error mapping) and the live `/v1/fcm/send` endpoint building the §5.2 **data-only** payloads via `pushwire`, validated with FCM `validateOnly:true`.
|
||||
|
||||
**Files to create/modify.**
|
||||
|
||||
- `internal/fcm/client.go` — the `FCMClient` interface + concrete impl (`x/oauth2/google` path per §3).
|
||||
- `internal/fcm/oauth.go` — `CredentialsFromJSONWithType(ctx, json, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` → cached/auto-refreshing `TokenSource`, one per project.
|
||||
- `internal/fcm/transport.go` — tuned HTTP/2 client; endpoint `POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send`.
|
||||
- `internal/fcm/errors.go` — FCM `error.status` → caller `error.code` + retryability + Retry-After honoring (spec §7.4).
|
||||
- `internal/pushwire/fcm.go` — build data-only `message` + `android` config (spec §5.2).
|
||||
- `internal/httpapi/fcm.go` — replace the Phase 2 stub with the real send.
|
||||
|
||||
**Implementation notes.**
|
||||
|
||||
- **OAuth2** (spec §7.1, reference §2.2): least-privilege scope `firebase.messaging`; `TokenSource` caches + auto-refreshes (~1h tokens; never mint per request). On GCP prefer keyless Workload Identity / attached SA; off-GCP load SA JSON from the secret manager (open Q#4). One token source per Firebase project.
|
||||
- **Endpoint** (spec §7.2): global host only — no regional endpoints (reference §2.3). The host `fcm.googleapis.com` is a **hardcoded constant never derived from request input**. `{PROJECT_ID}` is the allowlist-checked `project_id`, and `project_id`/`package_name` are validated against a **strict charset** (e.g. `^[a-z0-9-]+$` / reverse-DNS) **before** allowlist lookup and URL interpolation, so an allowlist row can never introduce a path-traversal or host-override (no SSRF / path injection).
|
||||
- **Payload — data-only** (`pushwire`, spec §5.2): `message.token` = caller token; `message.data` is a **fixed, closed set of string-valued keys only** (`"v":"1"`, `"wake":"notifications.changed"`, `"server_device_id"`, `"delivery_id"`) — no caller-controlled key or value beyond the opaque IDs; **no `notification` block**; `message.android.priority` = `"HIGH"` (private_data) / `"NORMAL"` (background_wake); `message.android.collapseKey` set only if the caller sent `collapse_key`. **Field-casing reconciliation (reference §2.4):** the relay POSTs raw JSON to the real FCM HTTP v1 REST endpoint, whose AndroidConfig field is camelCase **`collapseKey`** (FCM v1 rejects unknown fields with `INVALID_ARGUMENT`, so a literal `collapse_key` inside `android` would fail every collapsible send). The **inbound caller request body field stays `collapse_key`** per `03`; only the relay-built upstream JSON uses `collapseKey`. Likewise `android.priority` is sent as canonical uppercase `"HIGH"`/`"NORMAL"` (reference §2.4 lists these as the enum values, with lowercase also accepted; the relay sends the canonical uppercase to be robust against future enum tightening). Honor the `03` contract's `priority: high`-equivalent (`"HIGH"`) for `private_data` (do not second-guess — spec §5.2 reconciliation note). Set a finite `android.ttl` (default ~4 weeks otherwise) for parity with the APNs `apns-expiration` default (above). Never set `topic`/`condition`. ≤4096 bytes. Use `validateOnly:true` (camelCase REST field — reference §2.5) only for the `relayctl` test path, never production.
|
||||
- **`badge` has no data-only FCM home.** The relay-built FCM payload is strictly data-only (no `notification` block), so a caller-provided `badge` has nowhere legitimate to go. **Drop `badge` from the FCM accepted schema** (it is disabled-by-default and would otherwise have to be smuggled into the closed `data` map, weakening the allowlist). On the APNs side, bound `badge` to a sane range (e.g. 0–9999) and reject otherwise with `400 invalid_field` — it is the one numeric field that escapes the opaque-ID discipline.
|
||||
- **Collapse-key coalescing is best-effort (reference §2.7).** FCM stores at most **4 distinct `collapseKey`s per device** while offline; a 5th evicts one nondeterministically. Since `collapse_key` is a per-series HMAC and a device may follow >4 series, collapsible coalescing is best-effort and excess distinct keys evict nondeterministically — acceptable because the durable inbox + sync (the caller's outbox) covers any dropped collapsible wake. APNs has no equivalent documented per-device collapse-id cap (note the asymmetry).
|
||||
- **Response → caller** (spec §5.2): `200` → `{ "request_id", "fcm_message_name" (FCM v1 `name`), "status":"accepted" }`. Error mapping per spec §7.4 (e.g. `INVALID_ARGUMENT` → `422 invalid_argument`; `SENDER_ID_MISMATCH` → `403 sender_id_mismatch`; `UNREGISTERED` → `410 unregistered`; `QUOTA_EXCEEDED` → `429 rate_limited` honoring upstream `Retry-After` else default **60s**; `INTERNAL`/`UNAVAILABLE` → `503 upstream_unavailable` honoring `Retry-After` on 503; `UNSPECIFIED_ERROR` → `502 upstream_error`, no auto-purge). Classify on `error.status`/`FcmError.errorCode`, not the bare HTTP code. **Retry-After** forwarded to the caller as relative delay-seconds (reference §4.2). Add a **default/fallback rule** so any unrecognized FCM `error.status`/`errorCode` maps to a safe non-retryable `502 upstream_error` (never an auto-purge), rather than being dropped.
|
||||
- **`THIRD_PARTY_AUTH_ERROR` (401) — re-classified (do NOT page as a relay-credential failure).** Per Firebase's error-codes page this means an invalid/missing **APNs cert/key or Web Push auth credential** and is returned only for iOS-via-APNs or Web Push registration tokens. `/v1/fcm/send` sends **only** Android registration tokens (`message.token`, package `com.continuum.app.android`, data-only), so a legitimate Android send can never produce `THIRD_PARTY_AUTH_ERROR`. If it ever appears, treat it as a **per-token/registration anomaly** → `502 upstream_error`, surface for investigation, and do **not** page on-call as a relay `.p8`/SA misconfig. The relay's **actual** OAuth2/service-account credential failures surface as a non-200 from `oauth2.googleapis.com/token` at mint time, or HTTP `401 UNAUTHENTICATED` / `403 PERMISSION_DENIED` (the `status`, not an `FcmError` code) on `messages:send` — **those** map to `502 upstream_auth_error` + ops alert.
|
||||
- **Per-device rate exceedance (`DeviceMessageRateExceeded`, reference §2.7).** The Android per-device caps (240/min, 5,000/hr) are **distinct** from project-level `QUOTA_EXCEEDED` and surface defensively as a quota/`INVALID_ARGUMENT`-adjacent signal whose exact code/string is not firmly documented; detect on `error.status`/`errorCode` and map to a retryable `429 rate_limited` with a conservative default `Retry-After`, **not** a token purge.
|
||||
- **`UNREGISTERED` caller-facing 410 is an intentional cross-provider normalization.** FCM natively returns `UNREGISTERED` as HTTP **404**; the relay remaps it to caller `410 unregistered` so the caller's device-disable ("purge dead token") logic is uniform with the APNs path (which natively uses 410). This is a deliberate divergence from FCM's native status, not a bug.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- Unit: token source caches; no per-request mint; refresh margin.
|
||||
- Unit (`pushwire`): golden-file data-only payloads for `private_data` and `background_wake`, with/without `collapse_key` (byte-exact vs spec §5.2; **all data values strings**; **no `notification` block**). Mirrored into `silo-server`.
|
||||
- Unit: every row of the spec §7.4 status→error table (table-driven), including `Retry-After` honoring + 60s default, the unrecognized-`error.status` fallback (`502 upstream_error`), the per-device-rate (`DeviceMessageRateExceeded`) → `429 rate_limited` (no purge), and `THIRD_PARTY_AUTH_ERROR` → `502 upstream_error` (per-token anomaly, **not** paged as relay-credential).
|
||||
- Unit/contract: the FCM upstream JSON uses camelCase `collapseKey`, uppercase `priority` (`"HIGH"`/`"NORMAL"`), and `validateOnly`; `badge` is rejected from the FCM schema; the `data` map is the closed key set (no extra keys).
|
||||
- Integration (**FCM `validateOnly:true`**, `-tags=integration`, requires SA JSON): a valid request validates OK; a malformed token surfaces `invalid_argument`/`unregistered` mapping.
|
||||
- Integration: `relayctl ping-upstream --provider fcm --env sandbox` mints a token + does a `validateOnly` send.
|
||||
|
||||
**Acceptance criteria.**
|
||||
|
||||
- [ ] `/v1/fcm/send` builds the exact §5.2 data-only payload (string values, no `notification`) and returns `{request_id, fcm_message_name, status:"accepted"}`.
|
||||
- [ ] OAuth2 token is cached/refreshed, never minted per request; one source per project.
|
||||
- [ ] Every spec §7.4 mapping is covered (incl. the unrecognized-status fallback, per-device-rate, and `THIRD_PARTY_AUTH_ERROR` as a non-paging per-token anomaly); `QUOTA_EXCEEDED`/`UNAVAILABLE` forward `Retry-After` (default 60s on 429).
|
||||
- [ ] The upstream FCM JSON uses `collapseKey` (camelCase), `"HIGH"`/`"NORMAL"` priority, `validateOnly` (camelCase), a finite `android.ttl`, and the closed `data` key set; `badge` is not accepted on the FCM path.
|
||||
- [ ] `validateOnly` test path works; production sends never set `validateOnly`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Observability, redaction, metrics (size: M)
|
||||
|
||||
**Goal.** Production-grade redacted logging, op-log persistence, the full Prometheus metric catalog, and `/readyz`/`/metrics` wired and network-scoped.
|
||||
|
||||
**Files to create/modify.**
|
||||
|
||||
- `internal/observability/redact.go` — redaction middleware (strips `Authorization`/bearer, raw key, raw token, JWT, OAuth2 token, payload bodies).
|
||||
- `internal/observability/metrics.go` — Prometheus registry + the spec §13.2 catalog.
|
||||
- `internal/httpapi/health.go` — `/readyz` (PG `SELECT 1`, Redis `PING`; APNs and FCM reported **per-provider**, not as a single combined gate) and `/metrics` (`promhttp.HandlerFor`), both bound to the internal network.
|
||||
- `internal/oplog/gc.go` + `cmd/relayctl` `logs --gc` subcommand (or a background sweeper in `cmd/relay`) — the retention GC deliverable; plus the §8.1 partitioning DDL (or a documented partition/offload decision).
|
||||
- Wire `oplog.Write` into every send path with the redacted subset.
|
||||
|
||||
**Implementation notes.**
|
||||
|
||||
- **Redaction enforced in middleware** so no code path can leak (spec §11/§12/§13.1). Never log: bearer header, raw API key (log prefix or hash only), raw device tokens (log `SHA-256` truncated hash only), payload bodies, relay JWT, OAuth2 tokens. Do log: `request_id`, `account_id`, `status_code`, `error_code`, upstream reason, `token_hash`, `egress_ip`, `latency_ms`.
|
||||
- **Op-log retention is a concrete Phase 5 deliverable** (spec §8.1/§13.1): every send writes one `relay_op_logs` row (redacted by construction). Ship a real GC — **add `relayctl logs --gc`** (to the §5.6 command table and Phase 1/5 file lists) **or** a built-in background sweeper in `cmd/relay` — with a definite retention query (`DELETE` success rows >14d, failure/reject rows >90d) on a schedule with an owner. Either add **time-partitioning DDL** to the §8.1 schema or document an explicit partition/offload decision here (do **not** defer it to an open question). Add a disk-usage / `relay_op_logs` table-growth alert (at 50k/day/account the table grows unbounded without GC — a classic "works for months then the disk fills" surprise).
|
||||
- **Metrics catalog** (spec §13.2): `relay_requests_total`, `relay_request_duration_seconds`, `relay_upstream_requests_total`, `relay_upstream_duration_seconds`, `relay_auth_failures_total`, `relay_rate_limited_total` (incl. `kind="daily"`), `relay_rate_limit_degraded_total`, `relay_idempotency_total`, `relay_apns_jwt_refresh_total`, `relay_fcm_token_refresh_total`, `relay_redis_up`, `relay_pg_up`, plus `relay_provider_healthy{provider=...}`, `relay_credential_healthy{provider=...}`, and `relay_fcm_project_quota_headroom`. **Keep labels low-cardinality** (no token, no `request_id`).
|
||||
- **`/readyz` — per-provider, not all-or-nothing.** Return `200` only when the **core deps** (PG + Redis) are reachable, and report **per-provider** APNs/FCM health separately in `checks` (a `relay_provider_healthy{provider}` gauge). A single provider's credential/init failure must **not** down the whole replica (that would pull a healthy APNs replica from the LB just because FCM creds are temporarily unresolvable, killing Apple push too); instead the send handler for an un-initialized/degraded provider returns `503 upstream_unavailable` for **that provider only**. Surface per-provider circuit-breaker state so operators see "APNs degraded, FCM healthy" rather than a binary not-ready. Body shape: `503 {"status":"not_ready","checks":{...}}` only when a core dep is down (spec §5.4).
|
||||
- `/readyz` and `/metrics` bound to the internal/scrape network; only `/v1/*` and `/healthz` are public (spec §11/§14.1).
|
||||
|
||||
**Tests.**
|
||||
|
||||
- Unit: redaction middleware drops every disallowed field even when handlers attempt to log them (assert no token/bearer/JWT/OAuth2 string appears in captured log output).
|
||||
- Unit: a success and a failure path each produce exactly one op-log row with the redacted subset and a `token_hash` (never raw token).
|
||||
- Unit: metric counters increment with correct labels for first/replay/conflict idempotency, auth-failure reasons, rate-limit kinds, JWT/token refresh results.
|
||||
- Integration: `/readyz` flips to `503` when PG or Redis is down; `/metrics` exposes the full catalog.
|
||||
|
||||
**Acceptance criteria.**
|
||||
|
||||
- [ ] No log line or op-log row can contain a raw token, bearer header, JWT, OAuth2 token, or payload body (asserted by tests).
|
||||
- [ ] All spec §13.2 metrics are exported with low-cardinality labels.
|
||||
- [ ] `/readyz` reflects real dependency + credential health; `/metrics` and `/readyz` are not publicly routable.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Hardening, load test, deploy (size: L) · gated on provisioning + secret manager
|
||||
|
||||
**Goal.** Production hardening (real secret-manager wiring, TLS, anti-abuse), a hundreds-of-users burst load/pacing test, idempotency-under-concurrency proof, container build, and rollout.
|
||||
|
||||
**Files to create/modify.**
|
||||
|
||||
- `internal/config/secrets_*.go` — real secret-manager integration (KMS/Vault/cloud secret manager); load `.p8`, SA JSON, pepper, DSNs at startup into memory; optional SIGHUP re-read.
|
||||
- `deploy/Dockerfile` (distroless/minimal), deploy manifests, TLS/HSTS config.
|
||||
- `test/load/` — k6/vegeta burst scenario + concurrency idempotency test harness.
|
||||
- Alerting rules (Prometheus → alertmanager / SLO doc).
|
||||
|
||||
**Implementation notes.**
|
||||
|
||||
- **Secrets** (spec §11, reference §4.5): credentials loaded once at startup from the secret manager into memory; never on disk in the image/repo, never logged, never returned by any endpoint. On GCP prefer keyless FCM (Workload Identity). Rolling restart picks up rotated secrets.
|
||||
- **TLS** (spec §11/§14.1): HTTPS/HTTP-2 only, TLS 1.2+ (prefer 1.3), HSTS on `relay.silo.app`. Terminate at LB or in-process.
|
||||
- **Anti-abuse** (spec §8.2/§11): per-IP auth-failure rate limiting keyed on the **trusted-proxy-resolved client IP** (caller-supplied `X-Forwarded-For`/`X-Real-IP` ignored — see Phase 2 client-IP resolution; the trusted-proxy CIDR list is config); **soft** lockout (latency / `Retry-After`) to avoid CGNAT/VPN collateral; body size cap; explicit timeouts; graceful shutdown. Document a response runbook for the key-guessing alert.
|
||||
- **Credential-health validation & rotation runbook** (spec §4.4/§14.3). Add a **startup + periodic synthetic credential check** that proves the loaded credential actually authenticates upstream — not just that a JWT signs / a token mints locally (which `/readyz` already checks). For FCM, a `validateOnly:true` send; for APNs, an auth-exercising send to a known-bad token that still distinguishes an auth-layer `403` (`InvalidProviderToken`) from a token-layer `400` (`BadDeviceToken`). Wire the result into a `relay_credential_healthy{provider=...}` gauge and into `/readyz`'s per-provider health (below). Schedule `relayctl ping-upstream` as a cron for continuous health, and **alert on the first `upstream_auth_error`** (§6.4) — Apple actively revokes leaked `.p8` keys and SA keys/bindings can be rotated out from under the relay. Document the **rotation runbook** (overlap window, dual-key support — APNs allows multiple keys and the relay pools by team).
|
||||
- **Graceful-shutdown drain ordering** (spec §14.2). On `SIGTERM`: (1) flip `/readyz` to `503` so the LB stops routing **new** requests; (2) keep serving in-flight handlers; (3) bound `srv.Shutdown(ctx)` to **less than `lock_ttl`** so any marker left by a killed-mid-flight handler expires quickly — **and** release/downgrade the in-flight idempotency marker on handler context-cancellation (Phase 2), so a drained-but-incomplete send frees its lock immediately rather than forcing the caller's retry to eat a 30s `409 idempotency_conflict`. A clean drain must leave **no orphaned in-flight idempotency markers**; the relationship between the shutdown grace period and `lock_ttl` is explicit.
|
||||
- **Load/pacing test** (spec §13 / testing strategy §5 below): simulate a hundreds-of-users burst (e.g. 300–500 concurrent device sends), assert the relay stays under the per-account cap, returns correct `429` + relative `Retry-After`, and one well-tuned upstream connection sustains the throughput (reference §1.3 — thousands/sec). Verify the FCM smooth-traffic guidance does not trip quota (reference §2.7).
|
||||
- **Idempotency under concurrency**: fire N concurrent requests with the **same** `Idempotency-Key`; assert **at most one** upstream send (count via a fake upstream), the rest replay or `409`.
|
||||
- **FCM quota-storm shedding**: when the shared project quota is exhausted, the relay-side project circuit breaker sheds with `503 upstream_throttled` before forwarding (Phase 2 / §6.4), rather than hammering FCM into a 429 stampede.
|
||||
- **HA** (spec §14.2): multiple stateless replicas behind the LB; rolling deploys; kill-safety (a drained/killed replica is covered by the caller's outbox retry). Relay-down delays, never loses.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- Load: hundreds-of-users burst (latency p50/p95/p99, error rate, `429` correctness).
|
||||
- Concurrency: same-key idempotency → at-most-once upstream (assert against a counting fake).
|
||||
- Security: secret-manager load path; no secret in image (`docker history`/scan); TLS config (min version, HSTS).
|
||||
- Security: forged caller `X-Forwarded-For`/`X-Real-IP` does **not** change the resolved client IP / `egress_ip` (only the trusted-proxy header from an allowlisted peer is honored); per-IP throttle cannot be bypassed.
|
||||
- Drain: rolling-restart drains in-flight requests via `srv.Shutdown` without dropping a completed send, `/readyz` flips to `503` before draining, and **no orphaned in-flight idempotency markers** remain afterward (an immediate caller retry does not get a spurious `409`).
|
||||
- Resilience: FCM quota-exhaustion → relay sheds with `503 upstream_throttled` (project circuit breaker) instead of forwarding into a 429 storm.
|
||||
- Credential health: the synthetic startup/periodic check flips `relay_credential_healthy` and `/readyz` when the loaded credential is rejected upstream (auth-layer `403`), distinct from a token-layer `400`.
|
||||
|
||||
**Acceptance criteria.**
|
||||
|
||||
- [ ] Secrets load exclusively from the secret manager; image/repo scans show no embedded `.p8`/SA JSON/pepper.
|
||||
- [ ] A hundreds-of-users burst is handled within the per-account caps with correct `429`/`Retry-After`; one upstream connection sustains the load.
|
||||
- [ ] Concurrent identical-key sends produce **at most one** upstream call.
|
||||
- [ ] A rolling restart drains in-flight requests, flips `/readyz` to `503` first, and leaves **no orphaned in-flight idempotency markers**.
|
||||
- [ ] The synthetic credential check gates `/readyz`/`relay_credential_healthy` on real upstream acceptance; the first `upstream_auth_error` pages ops; the rotation runbook is documented.
|
||||
- [ ] Forged caller forwarding headers cannot poison `egress_ip` or bypass the per-IP throttle.
|
||||
- [ ] TLS 1.2+ enforced, HSTS set; `/metrics`+`/readyz` internal-only; staging deployment validated before production rollout.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Strategy
|
||||
|
||||
### 5.1 Layers
|
||||
|
||||
| Layer | What it covers | Provider creds? |
|
||||
|---|---|---|
|
||||
| **Unit** | Field validation, JWT/token lifecycle math, key hashing/compare, error-table mappings, redaction, metric labels | No |
|
||||
| **Contract** | Exact `02`/`03` request/response shapes: `DisallowUnknownFields` rejects every disallowed field; success/error bodies are byte-exact; `pushwire` golden files | No |
|
||||
| **Integration** | Real upstream behavior: APNs **sandbox** sends; FCM **`validateOnly:true`** validation; PG migrations; Redis rate-limit/idempotency | APNs `.p8` / FCM SA (sandbox) |
|
||||
| **Redaction assertions** | No raw token/bearer/JWT/OAuth2/payload ever reaches a log or op-log | No |
|
||||
| **Load / pacing** | Hundreds-of-users burst; per-account cap + `429`/`Retry-After`; upstream connection reuse sustains throughput | Optional (fake upstream) |
|
||||
| **Idempotency-under-concurrency** | Same key, many concurrent requests → at-most-once upstream send | No (fake upstream) |
|
||||
| **Failure-mode** | APNs `GOAWAY`/mid-stream reset mapping (no lost/double send); JWT-expiry single-flight (one regen, zero `TooManyProviderTokenUpdates`); Redis dropped mid-request (idempotency fail-closed, rate-limit bounded fail-open); graceful-drain leaves no orphaned idempotency lock; FCM quota-exhaustion shedding | No (fakes) |
|
||||
|
||||
### 5.2 Contract tests against the 02/03 shapes
|
||||
|
||||
For each endpoint, a golden corpus of requests/responses derived from spec §5.1/§5.2:
|
||||
|
||||
- **Valid** `private_alert` / `background_wake` (APNs) and `private_data` / `background_wake` (FCM) requests → assert the built upstream payload + headers are **byte-exact** vs the spec (the `pushwire` golden files).
|
||||
- **Every disallowed field** (`title`, `body`, `image_url`, `media_id`, `username`, `server_url`, `notification`, …) → `400 unexpected_field`.
|
||||
- **Every validation failure** → its exact `error.code` (`invalid_token`, `invalid_environment`, `invalid_mode`, `invalid_collapse_id`, `topic_not_allowed`, `project_not_allowed`, `package_not_allowed`, `missing_idempotency_key`, …).
|
||||
- The same `pushwire` golden corpus is shared with `silo-server`'s `custom_apns`/`custom_fcm` tests. Because a copied package in one repo cannot fail the other repo's CI on its own, the mirror is a **checked-in generated artifact with a committed checksum** that **both** repos verify in CI against the canonical hash (see §3 sync runbook) — a divergence fails CI on both sides regardless of which repo changed first.
|
||||
- **Closed-allowlist assertion (privacy guarantee):** a dedicated test asserts the FCM `data` map keys and the APNs payload key set are **exactly** the closed allowlist, so adding any key (e.g. smuggling content into `data`) fails CI even if golden files are regenerated. The content-free guarantee is enforced structurally, not only by golden equality.
|
||||
|
||||
### 5.3 Integration against sandbox / validateOnly
|
||||
|
||||
- **APNs sandbox** (`api.sandbox.push.apple.com`): a real send returns 200 + `apns_id`; a deliberately invalid token returns the mapped `bad_device_token` / `unregistered`. Run behind `-tags=integration`, only when `.p8` secrets are present (CI `integration` job).
|
||||
- **FCM `validateOnly:true`** (reference §2.5): a valid request validates without delivering; a malformed token surfaces `invalid_argument`/`unregistered`. **Never** `validateOnly` on production sends.
|
||||
|
||||
### 5.4 Redaction assertions
|
||||
|
||||
Capture log + op-log output across success and every error path; assert via regex that none of: the bearer value, the raw `rk_` secret, a raw device token, the relay JWT, or an OAuth2 access token ever appears, and that `token_hash` is present where a token was involved (spec §12/§13.1).
|
||||
|
||||
### 5.5 Load / pacing test (hundreds-of-users burst)
|
||||
|
||||
Simulate a burst of several hundred concurrent device sends (one account, mixed APNs/FCM) against a **fake upstream** that records call counts and timing:
|
||||
|
||||
- Assert per-account rate limiting holds (10 req/s burst + 50k/day; spec §9.2) and over-limit responses are `429` with a **relative** `Retry-After`.
|
||||
- Assert one upstream HTTP/2 connection is reused (no connection-per-push) and sustains the burst (reference §1.3/§3.1).
|
||||
- Assert latency percentiles and zero double-sends.
|
||||
|
||||
### 5.6 Idempotency-under-concurrency test
|
||||
|
||||
Fire N (e.g. 50) concurrent requests sharing one `Idempotency-Key` at a fake upstream that increments a counter per call. Assert: **exactly one** upstream call; the other N−1 either replay the stored result or receive `409 idempotency_conflict`; a follow-up with a **different** payload but the same key returns `422 idempotency_key_reuse`. This proves at-most-once upstream send under the caller's concurrent outbox retries (spec §10.2).
|
||||
|
||||
### 5.7 Failure-mode tests (the exact modes on-call hits)
|
||||
|
||||
Beyond the happy path and the error-table mappings, the plan tests every failure mode that actually pages an engineer (all use fakes — no provider creds):
|
||||
|
||||
- **APNs `GOAWAY` / mid-stream reset.** A fake APNs upstream injects `GOAWAY` (or resets the stream) before a final response mid-burst → the caller-facing result is `503 upstream_unavailable`, the op-log/metric is tagged `connection_severed`, and a counting fake confirms **no lost or double send** (no transparent retry).
|
||||
- **JWT expiry under concurrency.** Force the cached APNs JWT to expire under a concurrent burst → assert **exactly one** single-flighted regeneration and **zero** `429 TooManyProviderTokenUpdates`.
|
||||
- **Redis dropped mid-request** (not just down at start) → idempotency fails **closed** (`503`); rate-limit degrades to **bounded** fail-open; a lock whose TTL expires mid-send cannot be stolen (upstream deadline `<` `lock_ttl`).
|
||||
- **Graceful-drain idempotency.** A rolling restart during in-flight sends leaves **no orphaned in-flight idempotency markers** (an immediate caller retry does not eat a spurious `409`); `/readyz` flips to `503` before draining.
|
||||
- **FCM quota-exhaustion shedding.** With the shared project quota exhausted, the relay-side project circuit breaker sheds with `503 upstream_throttled` instead of forwarding into a 429 storm.
|
||||
|
||||
---
|
||||
|
||||
## 6. Deployment
|
||||
|
||||
### 6.1 Container build
|
||||
|
||||
- `deploy/Dockerfile`: multi-stage; static `relay` (and `relayctl`) binaries on a distroless/minimal base. **No credentials baked in** (spec §11). Non-root user; read-only filesystem where possible.
|
||||
- Image carries only the binary + CA roots (ensure USERTrust RSA present — default in `ca-certificates`; spec §6.3).
|
||||
|
||||
### 6.2 Secret provisioning
|
||||
|
||||
| Secret | Source | Notes |
|
||||
|---|---|---|
|
||||
| APNs `.p8` + Key ID + Team ID | secret manager (or mounted file) | per official team; never env/image (spec §14.3) |
|
||||
| FCM SA JSON **or** Workload Identity | GCP keyless (on-GCP) / secret manager JSON (off-GCP) | open Q#4 (spec §15) |
|
||||
| API-key HMAC pepper | secret manager | rotation is heavier (open Q#6) |
|
||||
| Postgres DSN, Redis URL | secret manager | privileged `relayctl` DSN held only by operators |
|
||||
|
||||
Rotation: issue a new APNs key / FCM credential, deploy, retire the old; the relay loads secrets at startup so a rolling restart picks them up (spec §14.3). Optional SIGHUP re-read.
|
||||
|
||||
### 6.3 TLS, health, metrics wiring
|
||||
|
||||
- TLS 1.2+ (prefer 1.3), HTTP/2, HSTS on `relay.silo.app`; terminate at LB or in-process (spec §14.1).
|
||||
- **Public:** `/v1/apple/send`, `/v1/fcm/send`, `/healthz`. **Internal-only:** `/readyz`, `/metrics` (spec §11/§14.1).
|
||||
- Liveness probe → `/healthz`; readiness probe → `/readyz`; Prometheus scrapes `/metrics` on the internal network.
|
||||
|
||||
### 6.4 Monitoring / alerting
|
||||
|
||||
Alert on (from the §13.2 catalog):
|
||||
|
||||
- **First `upstream_auth_error` → page ops.** Alert on the caller-facing `upstream_auth_error` reason (the relay's genuine `.p8`/SA/OAuth2 credential failure — a non-200 from `oauth2.googleapis.com/token`, or `401 UNAUTHENTICATED`/`403 PERMISSION_DENIED`/`InvalidProviderToken` on send) > 0 → **credential misconfig, page ops**: never caller-fixable. **Do NOT** key this alert on `THIRD_PARTY_AUTH_ERROR`, which on this pure-Android path indicates a per-token/registration anomaly, not a relay-credential problem (see Phase 4 reclassification) — surface it for investigation at most, never page.
|
||||
- **Proactive credential-health alert.** Alert if the periodic synthetic credential check (Phase 6: a scheduled `relayctl ping-upstream` / `validateOnly` FCM send + APNs auth-exercising probe, plus the `relay_credential_healthy` gauge wired into `/readyz`) reports the loaded `.p8`/SA/Workload-Identity credential is no longer accepted upstream — Apple actively revokes leaked `.p8` keys and SA keys/bindings can be rotated out from under the relay, so this must fire **before** production sends start failing.
|
||||
- Sustained `relay_rate_limit_degraded_total` increase → Redis degraded (fail-open active; in-process per-account fallback limiter engaged).
|
||||
- `relay_redis_up == 0` / `relay_pg_up == 0`; any provider showing `relay_provider_healthy{provider=...} == 0` → that upstream is degraded (per-provider, not a whole-replica outage).
|
||||
- Elevated `relay_requests_total{status_code="5xx"}` rate; `relay_request_duration_seconds` p99 regression.
|
||||
- `relay_upstream_requests_total{upstream_reason="QUOTA_EXCEEDED"}` and **low `relay_fcm_project_quota_headroom`** → shared FCM project quota pressure: the relay-side project limiter is shedding with `503 upstream_throttled` before hammering FCM (watch Cloud Console; spec §14.5). Treat as a multi-tenant fairness event — one noisy account must not exhaust the ~600k/min project quota shared across all accounts.
|
||||
- Accounts approaching/hitting the **per-account daily cap** (`relay_rate_limited_total{kind="daily"}`) → a legitimate big-release burst can hit 50k/day; surface for an operator to consider a temporary cap bump.
|
||||
- Anomalous `relay_auth_failures_total` rate from an egress IP → possible key-guessing (see §6.4 anti-brute-force; the per-IP limiter consumes the resolved client IP, not a caller-supplied header). Note: the fine-grained `reason` (unknown-prefix vs hash-mismatch) is internal-only and must not be exposed to tenants.
|
||||
|
||||
### 6.5 Rollout
|
||||
|
||||
- **Staging first** (spec §14.4): separate hostname + separate accounts so client QA never touches production quotas. Validate APNs sandbox + FCM `validateOnly` end-to-end.
|
||||
- **HA / rolling** (spec §14.2): multiple stateless replicas behind the LB; rolling deploy with drain (`srv.Shutdown`); a killed replica is covered by the caller's outbox retry — **relay-down delays, never loses**.
|
||||
- Single region in v1, close to PG/Redis (spec §14.5).
|
||||
|
||||
---
|
||||
|
||||
## 7. Provisioning Checklist (the long pole — start day 1)
|
||||
|
||||
> These gate the phases that touch live providers (Phase 3 APNs, Phase 4 FCM, Phase 6 production). Run them **in parallel** with Phases 0–2, which need no credentials.
|
||||
|
||||
### 7.1 Apple (gates Phase 3 + Phase 6)
|
||||
|
||||
- [ ] Apple Developer **Program account / team** with push entitlement (capture the 10-char **Team ID**).
|
||||
- [ ] Generate an **APNs Auth Key (`.p8`)** in the developer portal; capture the **Key ID** (10 chars) and download the `.p8` **once** (Apple shows it once). Store in the secret manager (spec §14.3).
|
||||
- [ ] Consider Feb-2025 **scoped keys** (team-scoped or topic-specific) for least privilege (reference §1.2).
|
||||
- [ ] Register per-platform **bundle topics** for the official build config and seed the account allowlist (spec §8.4): `com.continuum.app.ios`, `com.continuum.app.tvos`, `com.continuum.app.macos`.
|
||||
- [ ] Standardize on the **sandbox host** `api.sandbox.push.apple.com` (reference §1.3; `api.development.push.apple.com` is an equivalent still-resolving alias — no build-time resolution needed) and confirm the APNs server TLS root (**USERTrust RSA**, present in standard pools; spec §6.3 / reference §1.7).
|
||||
- [ ] Decide `apns2` vs hand-rolled client (open Q#3) before Phase 3.
|
||||
|
||||
### 7.2 Firebase / Google (gates Phase 4 + Phase 6)
|
||||
|
||||
- [ ] Firebase **project(s)** for the official build config: `continuum-prod-android` (and a staging project if desired).
|
||||
- [ ] Register the Android **package** `com.continuum.app.android`; seed the FCM allowlist `(project_id, package_name)` (spec §8.4).
|
||||
- [ ] Provision a **service account** with the least-privilege `firebase.messaging` scope (reference §2.2). On GCP, prefer keyless **Workload Identity / attached SA**; off-GCP, download the **service-account JSON** and store it in the secret manager — never in repo/image (reference §2.2/§4.5; open Q#4).
|
||||
- [ ] Confirm the global send host `fcm.googleapis.com` (no regional endpoints; reference §2.3) and the default ~600k msgs/min project quota (reference §2.7 / spec §14.5).
|
||||
|
||||
### 7.3 Cross-cutting
|
||||
|
||||
- [ ] Secret manager chosen and wired (KMS/Vault/cloud secret manager) for `.p8`, SA JSON, pepper, DSNs (spec §11 / Phase 6).
|
||||
- [ ] DNS + TLS cert for `relay.silo.app` (and the staging hostname).
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks, Mitigations, and Milestone Sequencing
|
||||
|
||||
### 8.1 Risks and mitigations
|
||||
|
||||
| # | Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|---|
|
||||
| R1 | **Provider provisioning slips** (Apple/Firebase approvals) — the real long pole | Med | High (blocks Phase 3/4/6) | Start §7 on day 1 in parallel; Phases 0–2 + all unit/contract tests need no creds; fakes for upstream |
|
||||
| R2 | `sideshow/apns2` is unmaintained (no release since Oct 2024) | Med | Med | Hide behind `internal/apns` interface; prototype hand-rolled `x/net/http2`+`golang-jwt/jwt/v5` in Phase 3; decide per open Q#3 |
|
||||
| R3 | **JWT/token mishandling** (over-frequent refresh → APNs `429 TooManyProviderTokenUpdates`; per-request mint → throttling; concurrent-403 stampede) | Med | High | Cache + mutex; ~50-min APNs refresh (never <20min), eager-on-expired; **single-flighted regeneration** so a concurrent burst of `ExpiredProviderToken` yields exactly one refresh per ~20-min window; OAuth2 `TokenSource` auto-refresh; metrics `relay_apns_jwt_refresh_total`/`relay_fcm_token_refresh_total` |
|
||||
| R4 | **Double-send** under concurrent caller retries (incl. lock-TTL expiry mid-send, and stale in-flight markers after a killed/drained replica) | Med | High | Redis `SET NX` idempotency, **fail-closed**; pin the upstream call deadline **strictly shorter than `lock_ttl`** (≈10 s upstream vs 30 s lock) and refuse to send if the deadline already passed the lock TTL; release/downgrade the in-flight marker on handler context-cancellation; at-most-once test (§5.6); drain test asserting no orphaned in-flight markers (§5.7); key includes `attempt_number` |
|
||||
| R5 | **Redis outage / eviction** | Low | Med | Rate-limit **fails open but bounded** (in-process per-account fallback limiter, logged + metric); idempotency **fails closed** (`503`, retryable via caller outbox); idempotency keyspace requires a **non-evicting `maxmemory-policy` (`noeviction`)** or a dedicated Redis/db so a rate-limit eviction can never silently drop an in-flight idempotency lock (which would violate at-most-once) |
|
||||
| R6 | **Content/identity leak** despite design | Low | Critical | Structural: `DisallowUnknownFields`, content-free schema, data-only FCM, badge-off-by-default, redaction in middleware, op-logs hold only `token_hash` |
|
||||
| R7 | **Cross-tenant push** (one account targeting another's topic/token) | Low | High | Per-account allowlist enforced after auth; namespaced Redis keys; per-account-scoped allowlist cache |
|
||||
| R8 | **Stolen relay API key** (incl. abuse-amplification during a Redis outage, and per-IP-throttle bypass via forged forwarding headers) | Low | Med | Revocable keys (`relayctl key revoke`), per-account rate caps + allowlist bound spend; **bounded** fail-open (in-process per-account cap when Redis is down — never unlimited); per-IP auth-failure throttling keyed on a **trusted-proxy-resolved client IP** (caller-supplied `X-Forwarded-For`/`X-Real-IP` ignored) |
|
||||
| R9 | **`pushwire` drift** between relay and `silo-server` | Med | Med | One **canonical source repo**; mirror is a checked-in generated artifact with a committed **checksum** verified in CI on **both** sides; golden-file + closed-allowlist-key tests fail CI on divergence regardless of regeneration order |
|
||||
| R10 | **APNs TLS root rotation** (USERTrust) | Low | Low | Use `api.sandbox.push.apple.com` (the `api.development.*` alias is equivalent and still resolves — not a risk); rely on the standard trust store (already includes USERTrust RSA) |
|
||||
| R11 | **FCM high-priority deprioritization** over 7-day window (reference §2.8) | Low | Low | Honor `03`'s `priority:high` for `private_data` (app surfaces a notification); caller-side `priority:normal` fallback is the lever (open Q#10) |
|
||||
| R12 | **Op-log volume** at 50k/day/account across many accounts (unbounded table → disk fills) | Med | Low | **Shipped** retention GC (`relayctl logs --gc` / background sweeper: 14d success / 90d failure) on a schedule with an owner; time-partitioning DDL (or a documented partition/offload decision); table-growth/disk alert |
|
||||
|
||||
### 8.2 Milestone sequencing & rough effort
|
||||
|
||||
| Milestone | Phases | Effort | Provider creds needed | Parallelizable |
|
||||
|---|---|---|---|---|
|
||||
| **M0 — Skeleton green** | Phase 0 | ~M (1 wk) | No | — |
|
||||
| **M1 — Storage + admin** | Phase 1 | ~L (1 wk) | No | with §7 provisioning |
|
||||
| **M2 — Request path (no upstream)** | Phase 2 | ~L (1 wk) | No | with §7 provisioning |
|
||||
| **M3 — APNs live (sandbox)** | Phase 3 | ~L (1 wk) | **Apple `.p8`** | Phase 3 ∥ Phase 4 |
|
||||
| **M4 — FCM live (validateOnly)** | Phase 4 | ~L (1 wk) | **Firebase SA** | Phase 4 ∥ Phase 3 |
|
||||
| **M5 — Observability** | Phase 5 | ~M (3 d) | No | — |
|
||||
| **M6 — Hardened + deployed** | Phase 6 | ~L (1 wk) | All + secret manager | — |
|
||||
|
||||
**Rough total:** ~6–7 engineer-weeks of code, with **§7 provisioning running in parallel from day 1** as the gating long pole for M3/M4/M6. Phases 3 and 4 can be split across two engineers once Phase 2 lands. The critical path is: M0 → M1 → M2 → (M3 ∥ M4) → M5 → M6, intersected with provisioning availability.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions (carried from spec §15)
|
||||
|
||||
These do not block starting; resolve at the noted phase.
|
||||
|
||||
1. **Per-account rate limits — real numbers** (resolve from logs post-M6; spec §9.2). v1 ships ~10 req/s burst + 50k/day.
|
||||
2. **APNs sandbox hostname** (resolved — note only). Use `api.sandbox.push.apple.com`; `api.development.push.apple.com` is an equivalent, still-resolving sandbox alias. The two are interchangeable, so no build-time resolution is required — this is a standardization note, not an open question.
|
||||
3. **`apns2` vs hand-rolled APNs client** — decide before Phase 3 (§3, reference §3.1).
|
||||
4. **FCM credentials: GCP-keyless vs off-GCP JSON** — resolve in deployment design (Phase 6).
|
||||
5. **`apns-expiration` / `android.ttl` defaults** (largely resolved). v1 ships a **finite** default (not omitted/30-day): a few hours for `private_alert`, short TTL for `background_wake`, with a matching finite FCM `android.ttl`; both configurable. Only the exact numeric values want product input.
|
||||
6. **API-key hashing: HMAC pepper vs plain salted SHA-256** — plan chooses HMAC pepper; confirm rotation tradeoff (Phase 1).
|
||||
7. **Idempotency-Key spec status** — header name + Stripe-style semantics are de-facto; re-check for an RFC.
|
||||
8. **Op-log volume & retention** (resolved into a Phase 5 deliverable). Ship `relayctl logs --gc` (or a background sweeper): 14d success / 90d failure, on a schedule with an owner, plus time-partitioning DDL (or a documented partition/offload decision) and a table-growth alert — not deferred.
|
||||
9. **Multi-region** — out of scope for v1; design later if latency to distant self-hosters matters.
|
||||
10. **FCM priority deprioritization** — honor `03`'s `priority:high`; revisit only if metrics show systematic deprioritization.
|
||||
@@ -0,0 +1,554 @@
|
||||
# APNs & FCM 2026 Reference — Privacy-Preserving Push Relay
|
||||
|
||||
> **Provenance.** This document reflects **2026-current** Apple (APNs) and Google (FCM HTTP v1) developer documentation, plus the current Go ecosystem and OWASP guidance. Every non-obvious factual claim carries an inline citation in the form `(source: URL, date)`. Where a primary source disagreed with secondary/blog material, the primary source wins and the secondary claim is flagged as inaccurate. Where a fact remains uncertain, it is called out explicitly rather than overstated.
|
||||
>
|
||||
> **Compile date:** _<!-- PLACEHOLDER: set at compile time -->_
|
||||
>
|
||||
> **Commands assume the repository root is the cwd.** No local absolute filesystem paths appear in this document; all repo references are repository-relative.
|
||||
|
||||
## Scope
|
||||
|
||||
This is the factual foundation for a self-hosted, privacy-preserving push **relay** — a server that accepts notification requests from authenticated tenants and forwards them to Apple Push Notification service (APNs) and Firebase Cloud Messaging (FCM) on their behalf, without the providers (or other tenants) learning more than necessary. It captures the wire-level contracts (auth, endpoints, headers, payloads, errors, throttling), the recommended Go libraries and tuning, and the security/ops practices the spec and implementation plan are written against. It does **not** cover client-side SDK integration except where client behavior (token rotation, notification-permission gating) constrains relay design.
|
||||
|
||||
---
|
||||
|
||||
## 1. APNs (2026)
|
||||
|
||||
### 1.1 Provider authentication — token-based (JWT, ES256)
|
||||
|
||||
APNs supports **both** token-based and certificate-based provider authentication over HTTP/2 + TLS in 2026; Apple does **not** deprecate certificate-based auth. Both have dedicated docs and no deprecation notice (source: https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server, 2026-06-13). The relay should use **token-based** (`.p8`) auth: the signing key does not expire (it can be revoked), one key covers sandbox + production and all/subset of a team's apps, and there is no yearly certificate renewal.
|
||||
|
||||
> **Correction vs. common blog claims:** Third-party posts claiming Apple is "deprecating / shutting down certificate-based auth in 2025" are **inaccurate**. The only confirmed 2025 changes are (a) the APNs **server** TLS root-CA rotation (§1.7) and (b) the optional token-key hardening features (§1.2). Neither deprecates provider auth (source: https://developer.apple.com/documentation/usernotifications/setting-up-a-remote-notification-server, 2026-06-13).
|
||||
|
||||
**JWT structure.** APNs supports **only the ES256** algorithm (ECDSA over P-256); no other algorithm is accepted (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13).
|
||||
|
||||
```jsonc
|
||||
// JWT header
|
||||
{
|
||||
"alg": "ES256", // ES256 ONLY
|
||||
"kid": "ABC123DEFG" // 10-char Key ID from the developer account
|
||||
}
|
||||
// JWT claims
|
||||
{
|
||||
"iss": "DEF123GHIJ", // 10-char Team ID
|
||||
"iat": 1437179036 // issued-at, seconds since Epoch (UTC)
|
||||
}
|
||||
```
|
||||
|
||||
The JWT is signed with the `.p8` ECDSA P-256 private key using ES256. It is sent on **every** notification request as an HTTP/2 header in Base64URL-encoded JWT form (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13):
|
||||
|
||||
```http
|
||||
authorization: bearer eyAia2lkIjog...MEYCIQDz...
|
||||
```
|
||||
|
||||
**Lifecycle & regeneration cadence (critical):**
|
||||
|
||||
| Rule | Value | Source |
|
||||
|---|---|---|
|
||||
| Max token age (`iat`) | ≤ 1 hour, else `ExpiredProviderToken` (403) | (source: …/establishing-a-token-based-connection-to-apns, 2026-06-13) |
|
||||
| Refresh **no more** often than | once every **20 minutes** (per connection) | (same) |
|
||||
| Refresh **no less** often than | once every **60 minutes** | (same) |
|
||||
| Over-frequent refresh → | `TooManyProviderTokenUpdates` (429) | (source: …/handling-notification-responses-from-apns, 2026-06-13) |
|
||||
|
||||
**Relay pattern:** Generate **one** ES256 JWT, cache it in memory behind a mutex, and reuse it across **all** senders/connections/topics. Regenerate on a ~50-minute timer (well inside the 1-hour window, never < 20 min apart), or eagerly on a `403 ExpiredProviderToken`. Never sign a fresh JWT per request.
|
||||
|
||||
**One key, many servers.** A single key/token can be reused across multiple provider servers, multiple connections, and all (or a subset) of a team's apps/topics — confirmed verbatim by Apple: "You can use the same token from multiple provider servers" and "You can use one token to distribute notifications for all or a subset of your company's apps" (source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13). **Caveat:** a connection cannot map to multiple teams — "If you manage APNs for multiple developer accounts, open separate connection pools for each of them" (same source). For a multi-tenant relay, this means **per-team connection pools**.
|
||||
|
||||
### 1.2 Token-key hardening (Feb 2025, optional)
|
||||
|
||||
On **2025-02-17** Apple added optional scoped token keys; the classic unrestricted team key still works, and "you don't have to update your keys unless you want to take advantage of the new capabilities" (source: https://developer.apple.com/news/?id=wy4tb0uo, 2025-02-17). Two new key types:
|
||||
|
||||
| Key type | Scope | Limit |
|
||||
|---|---|---|
|
||||
| **Team-scoped key** | Sandbox **or** Production only; all topics in the team | max **2 keys per environment** |
|
||||
| **Topic-specific (topic-based) key** | Bound to specific bundle IDs (least privilege) | max **200 keys for Sandbox and 200 for Production, with up to 400 topics per topic-based key** |
|
||||
|
||||
(source: https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns, 2026-06-13 — verbatim scoped-key limit confirmed). For a multi-tenant relay these are a meaningful isolation lever: issue separately scoped `.p8` keys per environment/app rather than one all-powerful key.
|
||||
|
||||
### 1.3 Endpoints & HTTP/2 connection management
|
||||
|
||||
| Environment | Host | Ports |
|
||||
|---|---|---|
|
||||
| Production | `api.push.apple.com` | 443 or 2197 |
|
||||
| Development / sandbox | `api.sandbox.push.apple.com` | 443 or 2197 |
|
||||
|
||||
(source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13). Port 2197 is functionally equivalent to 443 and exists so firewalls can allow APNs while blocking other 443 traffic — keep it as a fallback. `api.push.apple.com` (production) and `api.sandbox.push.apple.com` (sandbox) are the current canonical hosts named in Apple's docs. The older `api.development.push.apple.com` is a **still-valid equivalent alias for sandbox**: as of 2026-06-13 both names resolve to the same Akamai CNAME (`api-sand-vs.push-apple.com.akadns.net`) and the same IP set (verified by DNS, e.g. `17.188.168.x`), so they hit the identical sandbox backend. We **standardize on `api.sandbox.push.apple.com`** (it matches the canonical docs and `sideshow/apns2` v0.25.0's `HostDevelopment` constant), but the alias is not dead — see §5.
|
||||
|
||||
- **Transport:** HTTP/2 with **TLS 1.2 or later** (TLS 1.3 acceptable). `:method POST`, `:path /3/device/<device_token>` where the token is the hex bytes of the device token (source: …/sending-notification-requests-to-apns, 2026-06-13).
|
||||
- **Connection reuse:** "Reuse a connection as long as possible. In most cases, you can reuse a connection for many hours to days. If your connection is mostly idle, you may send a HTTP2 PING frame after an hour of inactivity." Reuse reduces bandwidth/CPU; rapid connect/disconnect is treated as DoS-like behavior (source: …/sending-notification-requests-to-apns, 2026-06-13).
|
||||
- **Stream multiplexing:** "APNs allows multiple concurrent streams for each connection, but don't assume a specific number of streams." Read the server's `SETTINGS_MAX_CONCURRENT_STREAMS`; do not hardcode. The exact number varies by server load and auth type (same source). A single well-tuned connection handles thousands of pushes/sec.
|
||||
- **GOAWAY handling:** When APNs terminates a connection it sends an HTTP/2 `GOAWAY` frame whose payload includes JSON with a `reason` key. Apply RFC 9113 §6.8: stop opening new streams, finish in-flight streams with ID ≤ `Last-Stream-ID`, open a new connection for the rest; `NO_ERROR` = graceful shutdown, any other code = connection error (source: https://www.rfc-editor.org/rfc/rfc9113#section-6.8, 2022-06). The relay should drop a GOAWAY'd connection from the pool and transparently reconnect. Go's `net/http2` transport does most of this automatically when one `http.Client` is reused.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R[Relay sender goroutines] -->|stream 1..N over 1 conn| C{HTTP/2 conn pool}
|
||||
C -->|POST /3/device/token| A[api.push.apple.com:443]
|
||||
A -. GOAWAY reason JSON .-> C
|
||||
C -->|drop + reconnect| C2[new conn]
|
||||
C2 -->|in-flight <= Last-Stream-ID finish on old conn| A
|
||||
```
|
||||
|
||||
### 1.4 Request headers
|
||||
|
||||
`:method = POST`, `:path = /3/device/<device_token>`. Payload is **uncompressed** JSON.
|
||||
|
||||
| Header | Required | Allowed values / format | Notes |
|
||||
|---|---|---|---|
|
||||
| `authorization` | yes (token auth) | `bearer <ES256 JWT>` | Ignored on cert connections |
|
||||
| `apns-topic` | yes | bundle ID, optionally with a type suffix (see §1.5) | `MissingTopic`/`BadTopic` (400) on error |
|
||||
| `apns-push-type` | required on **watchOS 6+**, strongly recommended elsewhere | `alert`, `background`, `controls`, `fileprovider`, `liveactivity`, `location`, `mdm`, `pushtotalk`, `voip`, `widgets`, `complication` | Must match payload; invalid → `InvalidPushType` (400) |
|
||||
| `apns-priority` | no (default 10) | `10`, `5`, `1` | See §1.4.1; invalid → `BadPriority` (400) |
|
||||
| `apns-expiration` | no | UNIX epoch seconds (UTC) | `0` = deliver once, no store; nonzero = store & retry up to **30 days**; invalid → `BadExpirationDate` (400) |
|
||||
| `apns-collapse-id` | no | ≤ **64 bytes** | Same value merges notifications on-device (latest wins); >64B → `BadCollapseId` (400) |
|
||||
| `apns-id` | no | canonical UUID (8-4-4-4-12, lowercase hex) | Echoed in response; auto-generated if omitted; invalid → `BadMessageId` (400) |
|
||||
|
||||
(source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13). `apns-push-type` required on watchOS 6+ confirmed (source: https://developer.apple.com/documentation/usernotifications, 2026-06-13).
|
||||
|
||||
**`apns-topic` suffixes by push type** (source: …/sending-notification-requests-to-apns, 2026-06-13):
|
||||
|
||||
| Push type | `apns-topic` suffix |
|
||||
|---|---|
|
||||
| `complication` | `.h.complication` |
|
||||
| `controls` | `.push-type.controls` |
|
||||
| `fileprovider` | `.pushkit.fileprovider` |
|
||||
| `liveactivity` | `.push-type.liveactivity` |
|
||||
| `location` | `.location-query` |
|
||||
| `pushtotalk` | `.voip-ptt` |
|
||||
| `voip` | `.voip` |
|
||||
| `widgets` | `.push-type.widgets` |
|
||||
| `alert` / `background` | (none — bare bundle ID) |
|
||||
|
||||
#### 1.4.1 `apns-priority` semantics (corrected)
|
||||
|
||||
`apns-priority` is optional; if omitted, APNs sets priority to 10. Three values, with these exact semantics (source: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns, 2026-06-13):
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `10` | Send the notification **immediately** (use for alert/sound/badge). |
|
||||
| `5` | Send based on **power considerations** on the user's device. |
|
||||
| `1` | Prioritize power over all factors and **do not wake the device** (lowest-power). |
|
||||
|
||||
- For `apns-push-type=background`, Apple's guidance is explicit and **narrower** than some sources state: "**Always use priority 5. Using priority 10 is an error.**" Apple does **not** list priority `1` as an accepted alternative for background pushes (source: same, 2026-06-13).
|
||||
- The claim that priority `1` is a "new tier for Live-Activity-style delivery" is **not supported** by Apple's docs. Live Activity updates use `apns-push-type=liveactivity` (typically priority 10 for immediate or 5 for throttled), not a priority-1 mechanism. Priority `1` is simply the lowest-power option (source: same, 2026-06-13).
|
||||
|
||||
### 1.5 Payload shapes & size limits
|
||||
|
||||
**Size limits** (source: https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification, 2026-06-13):
|
||||
|
||||
| Type | Max payload |
|
||||
|---|---|
|
||||
| Regular (alert/background/etc.) | **4096 bytes (4 KB)** |
|
||||
| VoIP (PushKit) | **5120 bytes (5 KB)** |
|
||||
|
||||
Oversize → `413 PayloadTooLarge`. Payload must **not** be compressed. Live Activity payloads use the standard 4 KB limit.
|
||||
|
||||
**Payload root** = reserved `aps` dictionary + optional custom keys. `aps` keys (source: same, 2026-06-13): `alert`, `badge`, `sound`, `thread-id`, `category`, `content-available`, `mutable-content`, `target-content-id`, `interruption-level`, `relevance-score`, `filter-criteria`.
|
||||
|
||||
**Alert (user-facing) example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"alert": { "title": "Title", "subtitle": "Sub", "body": "Body text" },
|
||||
"badge": 1,
|
||||
"sound": "default",
|
||||
"thread-id": "thread-42",
|
||||
"interruption-level": "active",
|
||||
"relevance-score": 0.8
|
||||
},
|
||||
"custom-key": "opaque-relay-payload"
|
||||
}
|
||||
```
|
||||
|
||||
The `alert` sub-dictionary supports `title`/`subtitle`/`body` plus localization keys (`loc-key`/`loc-args`, `title-loc-key`/`title-loc-args`, `subtitle-loc-key`/`subtitle-loc-args`), `launch-image`, and the legacy `action-loc-key` (source: same, 2026-06-13).
|
||||
|
||||
`interruption-level` accepts exactly: `passive`, `active` (default), `time-sensitive` (needs Time Sensitive Notifications entitlement), `critical` (needs Critical Alerts entitlement, Apple-approved). Critical sound uses a **dictionary**: `{ "critical": 1, "name": "...", "volume": 0.0–1.0 }` (source: same, 2026-06-13).
|
||||
|
||||
**Background (silent) example** — must set `apns-push-type=background`, `apns-priority=5`, and an `aps` containing **only** `content-available: 1` (no `alert`/`badge`/`sound`):
|
||||
|
||||
```json
|
||||
{ "aps": { "content-available": 1 } }
|
||||
```
|
||||
|
||||
(source: https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app, 2026-06).
|
||||
|
||||
**Live Activity** remote pushes use `apns-push-type=liveactivity`, `apns-topic=<bundleID>.push-type.liveactivity`, **token-based auth only**. The `aps` adds: `event` (`start`|`update`|`end`), `content-state` (decodes into the app's `ActivityAttributes.ContentState`), `timestamp`, `stale-date`, `dismissal-date` (UNIX seconds), optional `relevance-score` and `alert`. `attributes-type` + `attributes` are included **only** for `event: start` (push-to-start). For Live Activities, `relevance-score` is an **unconstrained Double** (Smart Stack ordering), not the 0–1 range used by `UNNotificationContent.relevanceScore` (source: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications, 2026-06).
|
||||
|
||||
### 1.6 Status codes & reason strings
|
||||
|
||||
**HTTP status codes** (source: https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns, 2026-06-13): 200, 400, 403, 404, 405, 410, 413, 429, 500, 503.
|
||||
|
||||
For unsuccessful requests the body is a JSON object `{"reason":"<String>"}`; for **410** it additionally includes a `timestamp` (Number, **milliseconds** since Epoch) indicating when the token became invalid for the topic (source: same).
|
||||
|
||||
| Status | Reason strings | Meaning / handling |
|
||||
|---|---|---|
|
||||
| 200 | — | Success. Echoed `apns-id` header; in **dev**, `apns-unique-id` header (response-only) for Push Notifications Console Delivery Log. |
|
||||
| 400 | `BadCollapseId`, `BadDeviceToken`, `BadExpirationDate`, `BadMessageId`, `BadPriority`, `BadTopic`, `DeviceTokenNotForTopic`, `DuplicateHeaders`, `IdleTimeout`, `InvalidPushType`, `MissingDeviceToken`, `MissingTopic`, `PayloadEmpty`, `TopicDisallowed` | Bad request. **Do not retry.** `BadDeviceToken`/`DeviceTokenNotForTopic` ⇒ purge token. |
|
||||
| 403 | `BadCertificate`, `BadCertificateEnvironment`, `ExpiredProviderToken`, `Forbidden`, `InvalidProviderToken`, `MissingProviderToken`, `UnrelatedKeyIdInToken`, `BadEnvironmentKeyIdInToken` | Auth/cert error. `ExpiredProviderToken` ⇒ regenerate JWT & retry once. `Invalid/MissingProviderToken` ⇒ config error (don't blind-retry). `UnrelatedKeyIdInToken` ⇒ open a new connection. |
|
||||
| 404 | `BadPath` | Invalid `:path`. Don't retry. |
|
||||
| 405 | `MethodNotAllowed` | Only `POST` allowed. Don't retry. |
|
||||
| 410 | `Unregistered`, `ExpiredToken` | Token no longer active for topic. **Permanently stop sending** to that token (until app re-registers). Use the `timestamp` (ms): only purge if your registration is older than it. Not a connection error / no disconnection. |
|
||||
| 413 | `PayloadTooLarge` | Payload exceeds 4 KB (5 KB VoIP). Don't retry. |
|
||||
| 429 | `TooManyProviderTokenUpdates`, `TooManyRequests` | Back off. `TooManyProviderTokenUpdates` ⇒ slow JWT refresh (≤ once/20 min). `TooManyRequests` ⇒ per-device-token backoff. |
|
||||
| 500 | `InternalServerError` | Transient. Retry after ~15 min with backoff. |
|
||||
| 503 | `ServiceUnavailable`, `Shutdown` | Transient. Retry after ~15 min with backoff. |
|
||||
|
||||
(source: https://developer.apple.com/documentation/usernotifications/handling-notification-responses-from-apns, 2026-06-13).
|
||||
|
||||
**Retry policy summary.** Never retry permanent errors (`BadDeviceToken`, `Unregistered`, `ExpiredToken`, `PayloadTooLarge`, `DeviceTokenNotForTopic`, `Forbidden`); back off on 429; retry 5xx after ~15 min with backoff. 410 is **not** treated as a connection error (source: same, 2026-06-13). Always parse the JSON `reason`, not just the status code.
|
||||
|
||||
### 1.7 APNs server TLS root rotation (2025) — trust-store only
|
||||
|
||||
APNs migrated its **server** TLS certificate to a new SHA-2 root, **USERTrust RSA Certification Authority** (a Sectigo root): sandbox **2025-01-20**, production **2025-02-24** (source: https://developer.apple.com/news/?id=09za8wzy, 2024-10-17; effective per https://developer.apple.com/news/upcoming-requirements/?id=02242025a, 2025-02-24).
|
||||
|
||||
- This affects only how the relay **validates the APNs server TLS chain** — it does **not** require updating the APNs SSL provider certificates Apple issued you, nor app/account certs.
|
||||
- Standard OS / Go system root pools (macOS/iOS, Linux `ca-certificates`, Go `crypto/x509` system pool) **already trust** USERTrust RSA, so no action is needed unless the relay **pins** or ships a **custom/minimal** trust store — in which case it must add this root or TLS handshakes fail after the cut-over dates.
|
||||
- No newer APNs cert/infrastructure requirement is published on Apple's Upcoming Requirements page as of June 2026 (source: https://developer.apple.com/news/upcoming-requirements/?id=02242025a, 2025-02-24).
|
||||
|
||||
### 1.8 Background-push throttling
|
||||
|
||||
Background (`content-available`) pushes are best-effort and throttled. Apple's verbatim guidance: "The number of background notifications allowed by the system depends on current conditions, but don't try to send more than **two or three per hour**." The system may delay, coalesce, or drop them based on battery/network/usage (source: https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app, 2026-06). The older "throttle disabled when a debugger is attached" behavior is Apple-Forums developer-experience guidance, **not** in the current spec — do not rely on it.
|
||||
|
||||
### 1.9 Legacy binary protocol — dead
|
||||
|
||||
The legacy binary (TCP, ports 2195/2196) APNs protocol is fully retired; Apple set the migration deadline to the HTTP/2 provider API at **March 31, 2021** (source: https://developer.apple.com/news/?id=c88acm2b, 2020-10-09; earlier announced https://developer.apple.com/news/?id=11042019a, 2019-11-04). **Do not implement it.** There is no binary fallback in 2026.
|
||||
|
||||
### 1.10 Broadcast push (Live Activity channels) — situational
|
||||
|
||||
Broadcast push for Live Activities (channels) is current (iOS/iPadOS 18+). You `POST /4/broadcasts/apps/<bundleID>` over HTTP/2 + TLS 1.2+. Required headers: `apns-channel-id` (base64), `apns-push-type=liveactivity`, `apns-priority` (10/5/1), `apns-expiration`; optional `apns-request-id` (UUID, echoed back). Channels are managed via a **separate** channel-management API (`/1/apps/<bundleId>/channels`, `/1/apps/<bundleId>/all-channels`); limit 10,000 channels per app per environment (source: https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns, 2026-06-13).
|
||||
|
||||
> **Hostname nuance (correction):** Apple's broadcast **send** code samples use broadcast-specific hosts `api-broadcast.push.apple.com` (prod) / `api-broadcast.sandbox.push.apple.com` (sandbox) — **not** the plain `api.push.apple.com` used for `/3/device`. Channel **management** uses a third host pair `api-manage-broadcast(.sandbox).push.apple.com` on ports 2195/2196. Apple's own connection-section text confusingly lists the plain hosts (a documented inconsistency) (source: same, 2026-06-13). If broadcast is in scope, verify the exact host against Apple's code samples at build time.
|
||||
|
||||
---
|
||||
|
||||
## 2. FCM HTTP v1 (2026)
|
||||
|
||||
### 2.1 Legacy-API shutdown status
|
||||
|
||||
Sending with the legacy FCM HTTP and XMPP APIs was **deprecated 2023-06-20**, and **shutdown began 2024-07-22** — verbatim: "Sending messages (including upstream messages) with the FCM legacy APIs for HTTP and XMPP was deprecated on June 20, 2023, and shutdown begins on July 22, 2024" (source: https://firebase.google.com/docs/cloud-messaging/migrate-v1, 2025-05-29). The legacy HTTP endpoint (`fcm.googleapis.com/fcm/send`), XMPP (`fcm-xmpp.googleapis.com`), and legacy "server key" string auth are **gone**. Only **HTTP v1** remains.
|
||||
|
||||
The **batch send** endpoint (`fcm.googleapis.com/batch`) was also discontinued, shutdown date **2024-06-20** (source: same, 2025-05-29). For a raw-protocol relay there is **no batch endpoint** — send one `POST` per registration token and fan out via HTTP/2 multiplexing or a bounded concurrency pool.
|
||||
|
||||
### 2.2 OAuth2 service-account authentication
|
||||
|
||||
Auth is **OAuth2 service-account bearer tokens**. Recommended (least-privilege) scope: `https://www.googleapis.com/auth/firebase.messaging` (the broader `cloud-platform` works but is a superset) (source: https://firebase.google.com/docs/cloud-messaging/auth-server, 2025-05-29). Send the token as:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <oauth2_access_token>
|
||||
```
|
||||
|
||||
**Token minting (RFC 7523 JWT-bearer grant):** build a JWT signed **RS256** with the service-account private key, then exchange it (source: https://developers.google.com/identity/protocols/oauth2/service-account, 2025):
|
||||
|
||||
```jsonc
|
||||
// JWT claims for the token exchange
|
||||
{
|
||||
"iss": "<service-account-email>",
|
||||
"scope": "https://www.googleapis.com/auth/firebase.messaging",
|
||||
"aud": "https://oauth2.googleapis.com/token",
|
||||
"iat": 1700000000,
|
||||
"exp": 1700003600 // iat + up to 3600s (max 1 hour)
|
||||
}
|
||||
```
|
||||
|
||||
```http
|
||||
POST https://oauth2.googleapis.com/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<signed_JWT>
|
||||
```
|
||||
|
||||
Response: `{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600 }`. **Access tokens are valid 3600s (1 hour).** Cache and re-mint before expiry (≈5-minute safety margin); never mint per request (source: same, 2025). Prefer a maintained Google auth library over hand-rolling.
|
||||
|
||||
**Credential resolution (ADC):** `GOOGLE_APPLICATION_CREDENTIALS` env var (path to SA JSON) → platform attached/default SA → error. Firebase: the JSON "should be kept private. Do not add it to public repos!" (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06). On GCP, prefer keyless auth (§4.5).
|
||||
|
||||
### 2.3 Endpoint & message JSON
|
||||
|
||||
```http
|
||||
POST https://fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send
|
||||
Authorization: Bearer <oauth2_access_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
The path accepts the project ID **or** project number. Host is global `fcm.googleapis.com`; **no** regional/data-residency send endpoints exist as of June 2026, and no new required top-level fields were introduced through 2025–2026 (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06-12).
|
||||
|
||||
**Request body** = `{ "validateOnly"?: bool, "message": Message }`. `message` is **required** and must carry **exactly one** target: `token`, `topic`, or `condition` (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current). For a relay sending to one device, use `message.token`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"message": {
|
||||
"token": "<registration_token>",
|
||||
"data": { "k1": "v1", "k2": "v2" }, // map<string,string> ONLY
|
||||
"android": { "priority": "HIGH", "ttl": "0s", "collapseKey": "sync" },
|
||||
"apns": { "headers": { "apns-priority": "10" }, "payload": { "aps": { /* ... */ } } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Data-only emphasis (relay-critical):** A message with a `notification` block is **auto-displayed** by the FCM SDK when the app is backgrounded/killed, and the app code does **not** run until the user taps it. A **data-only** message (no `notification` block) is **always** delivered to the app's handler (`onMessageReceived` / background handler) and never auto-displayed — full app control in foreground and background. **A relay that needs its own logic/custom display on every message should send DATA-ONLY messages** (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/set-message-type, 2025-05-29).
|
||||
|
||||
**`message.data` is `map<string,string>` — all values MUST be strings** (no nested objects/numbers/booleans; JSON-stringify anything structured). Reserved/forbidden keys: `from`, `message_type`, `notification`, and any key prefixed `google.`/`gcm.` (or reserved words) → `INVALID_ARGUMENT`. `android.data` overrides top-level `data` for Android (source: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages, 2025-05-29; https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12).
|
||||
|
||||
**Payload size:** max **4096 bytes** for token/device sends, **2048 bytes** for topic sends; over → `INVALID_ARGUMENT` (source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12).
|
||||
|
||||
### 2.4 AndroidConfig
|
||||
|
||||
`message.android` (REST camelCase) (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current):
|
||||
|
||||
| Field | Type / values | Notes |
|
||||
|---|---|---|
|
||||
| `priority` | `"NORMAL"` \| `"HIGH"` (lowercase also accepted) | Default: NORMAL for data messages, HIGH for notification messages. HIGH attempts immediate delivery and can wake a dozing device. |
|
||||
| `collapseKey` | string | Group id; only the last queued message delivered on reconnect. **Not** a request-validation limit: FCM *stores* at most **4** distinct collapse keys per device while the device is offline; a 5th evicts one nondeterministically (a delivery/storage behavior — see §2.7). The API does **not** reject a request for using a 5th distinct key. |
|
||||
| `ttl` | Duration string in seconds with `s` suffix, e.g. `"3s"`, `"3.000000001s"` | `"0s"` = deliver-now-or-drop. Range **0 – 2,419,200s (28 days)**; out-of-range → `INVALID_ARGUMENT`. |
|
||||
| `restrictedPackageName` | string | Deliver only if the token's app package matches. |
|
||||
| `directBootOk` | bool | Allow delivery during Direct Boot. |
|
||||
| `data` | map<string,string> | Overrides `message.data` for Android. |
|
||||
|
||||
Newer **optional** fields exist (`bandwidthConstrainedOk`, `restrictedSatelliteOk`); admin-SDK support landed ~March 2026; both are documented Optional and do not change the required contract (source: https://firebase.google.com/docs/cloud-messaging/send/v1-api, 2026-06-12).
|
||||
|
||||
### 2.5 `validateOnly` (dry-run)
|
||||
|
||||
Set top-level `validateOnly: true` to fully validate the request (token format, payload size, field correctness) and return a message-id-shaped response **without delivering**. Default `false` (source: https://pkg.go.dev/google.golang.org/api/fcm/v1, current). Use in CI/tests.
|
||||
|
||||
### 2.6 Error model
|
||||
|
||||
Error body shape (source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12):
|
||||
|
||||
```json
|
||||
{ "error": { "code": 404, "message": "...", "status": "UNREGISTERED",
|
||||
"details": [ { "@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": "UNREGISTERED" } ] } }
|
||||
```
|
||||
|
||||
Classify on `error.status` / the FcmError `errorCode`, not just the HTTP code.
|
||||
|
||||
| HTTP | `error.status` | Meaning | Handling |
|
||||
|---|---|---|---|
|
||||
| 400 | `INVALID_ARGUMENT` | Malformed payload **or** bad token (bad format, mismatched package, oversize, reserved data key, bad TTL). `details` = `google.rpc.BadRequest`. | **Do not retry.** Delete token **only** if payload is independently known-valid; otherwise fix payload. |
|
||||
| 401 | `THIRD_PARTY_AUTH_ERROR` | Invalid/missing APNs cert/key or Web Push auth credential. | Fix credentials (ops alert). **Do not retry.** |
|
||||
| 403 | `SENDER_ID_MISMATCH` | Authenticated sender ID ≠ sender that registered the token. | Send from the correct project; permanent. **Do not retry.** |
|
||||
| 404 | `UNREGISTERED` | App unregistered (uninstall, APNs token invalid, 270-day Android inactivity, restore). | **Permanently delete the token.** Never retry. |
|
||||
| 429 | `QUOTA_EXCEEDED` (RESOURCE_EXHAUSTED) | Project / device / topic rate quota exceeded. `details` = `google.rpc.QuotaFailure` naming which quota. | Retry with exponential backoff; min initial delay **1 minute** for project- and topic-rate cases. |
|
||||
| 500 | `INTERNAL` | Unknown internal error. | Retry per "Handling retries"; **no** documented `Retry-After`. |
|
||||
| 503 | `UNAVAILABLE` | Server overloaded / temporarily unavailable. | Retry with backoff + jitter; **honor `Retry-After`** if present. |
|
||||
| (none) | `UNSPECIFIED_ERROR` | Catch-all; "No more information is available about this error." | Conservative: **do not** auto-delete token; surface for investigation. |
|
||||
|
||||
(source: https://firebase.google.com/docs/cloud-messaging/error-codes, 2026-06-12 / 2026-06-13).
|
||||
|
||||
> **Corrections / precision notes:**
|
||||
> - `QUOTA_EXCEEDED` does **not** carry a `google.rpc.RetryInfo` per FCM's own page — only `google.rpc.QuotaFailure` is promised. General Google API guidance allows `RetryInfo`; treat its presence as optional and fall back to `Retry-After`/60 s (source: same, 2026-06-13).
|
||||
> - `INVALID_ARGUMENT` (400) is **ambiguous**: it can mean malformed payload **or** bad token. Only treat it as a dead token when the payload is independently validated (source: same, 2026-06-12).
|
||||
> - Strictly, `UNSPECIFIED_ERROR` is the `FcmError.errorCode` inside `details[]`, not the top-level google.rpc `status` — operationally equivalent (source: same, 2026-06-13).
|
||||
|
||||
### 2.7 Retry-After, backoff, quotas, throttling
|
||||
|
||||
**Retry classification at scale** (source: https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12):
|
||||
|
||||
- **Never retry** 400 / 401 / 403 / 404 — "abort, and do not retry."
|
||||
- **429:** "retry after waiting for the duration set in the retry-after header. If no retry-after header is set, default to **60 seconds**."
|
||||
- **500 / 503:** "retry with exponential back-off" (+ jitter; honor `Retry-After` on 503 if present).
|
||||
- "Wait at least **10 seconds** before retrying a failed request"; set ≥10 s send timeout; cap retries; a request still failing after **60 minutes** is an outage/miscategorization, not something to retry forever.
|
||||
|
||||
**Exponential backoff must use jitter** ("varying the retry delays through a random process"). **Smooth traffic:** ramp 0→max RPS over ≥60 s, and "avoid sending messages within a 2 minute window of each of the :00, :15, :30, and :45 minute marks" to dodge synchronized spikes (source: same, 2026-06-12).
|
||||
|
||||
**Project quota.** Default per-project downstream quota is **600,000 messages per minute** ("covers over 99% of FCM developers"), enforced via a **600K-token 1-minute token bucket that refills to full at the end of each (non-clock-aligned) minute**. Over quota → `429 RESOURCE_EXHAUSTED` (`QUOTA_EXCEEDED`) until refill. **The quota measures messages, not requests.** Client errors (HTTP 400–499) count against quota **except 429s** (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13; corroborated https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12).
|
||||
|
||||
> **Correction:** The research claim that "different message types cost different token amounts" is **not** documented and is treated as inaccurate — quota is counted **per message** regardless of type; topics are merely more efficient because one request fans out to many subscribers (source: https://firebase.google.com/docs/cloud-messaging/scale-fcm, 2026-06-12).
|
||||
|
||||
**Per-device Android limits:** up to **240 messages/minute** and **5,000 messages/hour** to a single device (headroom for bursts, e.g. rapid chat). On iOS, FCM returns an error when APNs per-device limits are exceeded (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
|
||||
|
||||
> **Correction:** Exceeding the **per-device** Android caps is **not** reported as `QUOTA_EXCEEDED` (429). `429 / QUOTA_EXCEEDED` is the **project-level** per-minute quota. Firebase's throttling/quotas page does **not** specify a particular error code or status string for the per-device caps — it only says "For iOS, we return an error when the rate exceeds APNs limits." Secondary sources name the per-device signal `DeviceMessageRateExceeded`, but this string is **not** firmly documented on a primary Google page, so **classify it defensively** rather than matching an exact literal (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
|
||||
>
|
||||
> **Relay handling (per-device rate).** Because the exact code/string is uncertain, the relay should treat any per-device-rate signal — whether surfaced as an `INVALID_ARGUMENT`-adjacent error, a quota-style `RESOURCE_EXHAUSTED` that names a per-device (not project) quota in `details`, or a `DeviceMessageRateExceeded`-shaped string — as a **transient, retryable rate condition**: map it to a caller-facing **429 with a conservative default `Retry-After`** and **never** as a token purge (the token is healthy; only the rate is too high). Detect on `error.status` / the `FcmError.errorCode` and the `QuotaFailure` scope, not on a hardcoded string. The relay's whole purpose is fanning bursty per-device wakes, so this is a plausible real outcome that must have a defined mapping rather than falling through to `UNSPECIFIED_ERROR`/502.
|
||||
|
||||
**Collapsible-message throttling:** burst of **20 per app per device**, refill **1 every 3 minutes** (source: same, 2026-06-13).
|
||||
|
||||
> **Correction:** Exceeding the collapsible burst is a **silent delay** (battery protection), **not** a `QUOTA_EXCEEDED` error: "we delay messages to reduce the impact on a user's battery" (source: same, 2026-06-13).
|
||||
|
||||
**Distinct collapse keys:** FCM stores at most **4** different collapsible messages per device (one per distinct `collapse_key`); a 5th evicts a key nondeterministically. Default `collapse_key` = app package name (source: https://firebase.google.com/docs/cloud-messaging/customize-messages/collapsible-message-types, 2026-06-12).
|
||||
|
||||
> **Design implication (collapse-key cap vs. per-series keys).** If the relay derives `collapse_key` per notification series (e.g. a per-series HMAC), a single device that follows **more than 4 active series** will exceed FCM's 4-distinct-key store, and excess distinct keys evict each other nondeterministically — so **collapsible coalescing is best-effort**, not guaranteed, once a device crosses 4 series. This is acceptable only if a dropped collapsible wake is recoverable (e.g. the caller's durable inbox + sync covers it); otherwise the caller should cap distinct `collapse_key`s per device. **Asymmetry to note:** APNs has **no** documented per-device cap on the number of distinct `apns-collapse-id` values, so this 4-key eviction behavior is FCM-only — the two providers are not symmetric here, and the cross-provider design docs should reconcile this rather than assume parity.
|
||||
|
||||
**Non-collapsible offline storage (Android):** up to **100** messages stored while offline; beyond that, **all** stored messages are discarded and the device gets a single "contact your server" signal on reconnect. Do not treat FCM as a durable queue; the app must re-sync (source: same, 2026-06-12).
|
||||
|
||||
**Quota increases:** self-serve up to **+25%** in Google Cloud Console (gated on ≥80% usage for ≥5 consecutive minutes/day, <5% client-error ratio at peak, scale best practices). Larger/temporary bumps via Firebase Support: ≥15 days lead (≥30 days for >18M msgs/min), ≤2 events/year, ≤30-day total duration (source: https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas, 2026-06-13).
|
||||
|
||||
### 2.8 High-priority data-message behavior & power management
|
||||
|
||||
- **Normal priority:** delivered immediately when awake; in Doze, batched to maintenance windows. **High priority:** FCM attempts immediate delivery, can wake a sleeping device, grants temporary network + a partial wakelock (source: https://developer.android.com/training/monitoring-device-state/doze-standby, 2024-07-28).
|
||||
- **Deprioritization:** A high-priority message that does **not** produce a user-facing notification gets **deprioritized to normal** (and may be throttled): "If FCM detects a pattern in which messages don't result in user-facing notifications, your messages may be deprioritized to normal priority." FCM evaluates ~7 days of per-app-instance behavior (source: https://firebase.blog/posts/2025/04/fcm-on-android/, 2025-04-17). **Do not use high priority for silent data-only syncs; use normal priority and reserve high priority for messages that produce a notification.**
|
||||
- **App Standby Buckets:** high-priority delivery is still subject to the device's standby-bucket quota; once exhausted, further high-priority messages are effectively treated as normal. Exact per-bucket numeric caps are **not published** by Google and are dynamic/OEM-dependent (source: https://firebase.blog/posts/2019/02/life-of-a-message/, 2019-02-26).
|
||||
- **Background-restricted apps:** receive **no** FCM messages (any priority, any state) until launched to the foreground (source: https://developer.android.com/training/monitoring-device-state/doze-standby, 2024-07-28).
|
||||
- **Android 13+ (`POST_NOTIFICATIONS`):** notifications require the runtime `POST_NOTIFICATIONS` permission; without it, delivered notification messages are silently dropped. If the app's first notification channel is created while in the background (which the FCM SDK does on first receipt), Android won't display or prompt until the next app open — so early notifications are **lost**. **Data-message delivery to `onMessageReceived` is unaffected** (source: https://developer.android.com/develop/ui/views/notifications/notification-permission, 2024-08-08).
|
||||
|
||||
### 2.9 Registration-token lifecycle
|
||||
|
||||
- **Rotation hook:** `onNewToken()` fires on initial generation and every refresh (including FCM-initiated refresh when "the security of the previous token had been compromised"). Tokens also change on reinstall, data-clear, device restore. Persist the token to your server inside `onNewToken` (source: https://firebase.google.com/docs/cloud-messaging/manage-tokens, 2026-06-13).
|
||||
- **Android inactivity expiry:** "When stale tokens reach **270 days** of inactivity, FCM considers them expired." iOS/web rely on APNs/Web Push (no inactivity-based expiry), so cleanup there is driven by send-time error responses (source: same, 2026-06-13).
|
||||
- **Freshness strategy:** store each token **with a server timestamp** (FCM SDKs don't provide one), refresh ~**monthly** (no benefit more often than weekly), skip tokens outside your staleness window before sending, and delete on `UNREGISTERED` (404) or on `INVALID_ARGUMENT` (400) **when the payload is verified valid** (source: same, 2026-06-13).
|
||||
|
||||
---
|
||||
|
||||
## 3. Go ecosystem (2026)
|
||||
|
||||
> **Version targeting:** `jackc/pgx v5.10` and `firebase.google.com/go/v4 v4.20` both require **Go 1.25+**, so target **Go 1.25+** for the whole relay (source: https://pkg.go.dev/github.com/jackc/pgx/v5, 2026-06-03; https://pkg.go.dev/firebase.google.com/go/v4/messaging, 2026-05-14).
|
||||
|
||||
### 3.1 APNs client
|
||||
|
||||
| Choice | Version | Rationale |
|
||||
|---|---|---|
|
||||
| `github.com/sideshow/apns2` | **v0.25.0** (tagged 2024-10-25 UTC; pkg.go.dev shows "Oct 24, 2024" due to TZ rounding) | De-facto Go APNs HTTP/2 client. Auto-manages ES256 JWT generation/refresh and HTTP/2 connection reuse. Host constants match Apple. **Hold ONE `*apns2.Client` per process/environment; never recreate per push** (TLS setup is slow). One client "can potentially do 4,000+ pushes per second." (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2024-10-25) |
|
||||
| Hand-rolled (alternative) | `net/http` + `golang.org/x/net/http2` (**v0.56.0**, 2026-06-09) + `golang-jwt/jwt/v5` (**v5.3.1**) | Full control of the HTTP/2 transport; mirror apns2: parse `.p8` EC key, sign one ES256 JWT, cache ~50 min, reuse one `*http2.Transport` with `ReadIdleTimeout`/`PingTimeout` set. (source: https://pkg.go.dev/github.com/golang-jwt/jwt/v5, 2026-01-01; https://pkg.go.dev/golang.org/x/net/http2, 2026-06-09) |
|
||||
|
||||
`apns2` internals (verified against v0.25.0 source): `HostProduction = "https://api.push.apple.com"`, `HostDevelopment = "https://api.sandbox.push.apple.com"`; `NewTokenClient(*token.Token{AuthKey, KeyID, TeamID})` / `NewClient(cert)`; chain `.Production()`/`.Development()`. `token.TokenTimeout = 3000` (50 min, deliberately under Apple's 1-hour window); `GenerateIfExpired()` regenerates under a mutex. Timeouts: `HTTPClientTimeout=60s`, `ReadIdleTimeout=15s`, `TCPKeepAlive=15s`, `TLSDialTimeout=20s`. `ClientManager` (MaxSize 64, MaxAge 10m) pools clients across multiple certs/keys (source: https://github.com/sideshow/apns2/blob/v0.25.0/client.go, 2024-10-25; token/token.go, same).
|
||||
|
||||
> **Maintenance caveat:** `apns2` has had no release since v0.25.0 (Oct 2024); bus-factor risk. Verify recent commit/issue activity before committing vs. the hand-rolled path (see §5, item 2).
|
||||
|
||||
### 3.2 FCM / OAuth2
|
||||
|
||||
Pick **exactly one** of these two FCM approaches, not both:
|
||||
|
||||
| Choice | Version | Rationale |
|
||||
|---|---|---|
|
||||
| `golang.org/x/oauth2/google` (lighter) | **v0.36.0** (2026-02-11) | Best for a **fixed/known payload**: `CredentialsFromJSONWithType(ctx, jsonKey, google.ServiceAccount, "https://www.googleapis.com/auth/firebase.messaging")` → `creds.TokenSource` already caches+auto-refreshes; POST your own JSON to the v1 endpoint with a tuned HTTP/2 client. Fewer deps; full control of connection reuse; **you implement retries/error-classification**. Note: `CredentialsFromJSON` is deprecated in favor of `CredentialsFromJSONWithType`. (source: https://pkg.go.dev/golang.org/x/oauth2/google, 2026-02-11) |
|
||||
| `firebase.google.com/go/v4/messaging` (batteries-included) | **v4.20.0** (2026-05-14, Go 1.25+) | Auto-mints/caches OAuth token, auto-retries transient errors, rich error helpers: `IsUnregistered` (drop token), `IsQuotaExceeded`, `IsUnavailable`, `IsInvalidArgument`, `IsThirdPartyAuthError`, `IsSenderIDMismatch`. Use `Send` / `SendEach` / `SendEachForMulticast` (each = one HTTP call per message, ≤500). **`SendAll`/`SendMulticast` are deprecated (and removed in newer SDK majors)** — do not use. (source: https://pkg.go.dev/firebase.google.com/go/v4/messaging, 2026-05-14) |
|
||||
|
||||
For a privacy-preserving relay forwarding opaque tenant payloads, the **`x/oauth2/google` + custom HTTP/2 client** path is usually preferable: it avoids the heavy Admin SDK and gives direct control of connection reuse and error handling.
|
||||
|
||||
### 3.3 HTTP/2 tuning (both transports)
|
||||
|
||||
`golang.org/x/net/http2.Transport` (**v0.56.0**, 2026-06-09) knobs (source: https://pkg.go.dev/golang.org/x/net/http2, 2026-06-09):
|
||||
|
||||
- `ReadIdleTimeout` (~10–15 s): if no frame received for this duration, send a PING health check (0 = disabled).
|
||||
- `PingTimeout` (~15 s, default): close the conn if no PING response arrives.
|
||||
- `WriteByteTimeout`: close if no bytes can be written within the timeout.
|
||||
- `StrictMaxConcurrentStreams` (bool): if `false` (default) new TCP conns open to stay under each conn's `SETTINGS_MAX_CONCURRENT_STREAMS`; if `true` the server limit is global and callers block.
|
||||
|
||||
**Setting a non-zero `ReadIdleTimeout` + `PingTimeout` is the single most important tuning for a long-lived relay** — it reaps half-open APNs/FCM connections. Do not hardcode max-concurrent-streams; let the transport read the server SETTINGS (the exact APNs/FCM advertised value is not a published primary-source constant — measure at runtime; see §5, item 3).
|
||||
|
||||
**Inbound server:** `net/http` gets HTTP/2 automatically over TLS and supports graceful shutdown. **Set explicit timeouts** — the zero value means no timeout: `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`, `MaxHeaderBytes` (default 1 MiB). On SIGTERM call `srv.Shutdown(ctx)` with a bounded context (source: https://pkg.go.dev/net/http, 2026-06-01).
|
||||
|
||||
### 3.4 Storage
|
||||
|
||||
| Choice | Version | Rationale |
|
||||
|---|---|---|
|
||||
| `github.com/jackc/pgx/v5` + `pgxpool` | **v5.10.0** (2026-06-03, Go 1.25+) | Recommended Postgres driver/pool. Use `pgxpool.New(ctx, dsn)` (not `pgx.Connect`) for a server — concurrency-safe, health-checked. Token store, API-key store, idempotency. (source: https://pkg.go.dev/github.com/jackc/pgx/v5, 2026-06-03) |
|
||||
| `github.com/redis/go-redis/v9` | **v9.20.1** (2026-06-11) | Context-first Redis client with built-in pooling. Distributed rate-limiting, idempotency in-flight locks, dedup/queue state. (source: https://pkg.go.dev/github.com/redis/go-redis/v9, 2026-06-11) |
|
||||
|
||||
### 3.5 Observability
|
||||
|
||||
| Choice | Version | Rationale |
|
||||
|---|---|---|
|
||||
| `log/slog` (stdlib) | Go 1.21+ | Structured logging, no third-party dep. `slog.NewJSONHandler` for production aggregation; redaction middleware (§4.6). (source: https://pkg.go.dev/log/slog, 2026-06-01) |
|
||||
| `github.com/prometheus/client_golang` | **v1.23.2** (2025-09-05) | Prometheus instrumentation; custom registry + `promhttp.HandlerFor` for `/metrics`. Instrument per-provider send latency (histogram) and outcome counters labeled by provider + status/reason. (source: https://pkg.go.dev/github.com/prometheus/client_golang/prometheus, 2025-09-05) |
|
||||
|
||||
### 3.6 Reference pattern (not a dependency)
|
||||
|
||||
`github.com/appleboy/gorush` is a maintained reference push **server** (built on `sideshow/apns2`, supports FCM) with a notification queue, worker pool, and pluggable stats storage — useful as a **pattern reference** for worker pools and retry/queue design, but it is a full server, not a library to embed (source: https://github.com/appleboy/gorush, 2025-11-20).
|
||||
|
||||
### 3.7 Pinned versions (summary)
|
||||
|
||||
```
|
||||
github.com/sideshow/apns2 v0.25.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
golang.org/x/net v0.56.0 // http2
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
firebase.google.com/go/v4 v4.20.0 // only if using the Admin SDK path
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
golang.org/x/time (rate — see §4.2)
|
||||
// Go 1.25+ required
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Security & ops (2026)
|
||||
|
||||
### 4.1 API-key storage & comparison
|
||||
|
||||
- **Generate** 32 bytes from `crypto/rand`; format as `<prefix>_<base62/base64url>` where the **prefix** (8–12 chars, optionally tenant/env-tagged) is **non-secret**, stored in cleartext, and **indexed**. The remaining 32 bytes are the secret. This GitHub/Stripe-style design lets you `SELECT` the row by indexed prefix in O(1), then constant-time-compare the hash — no full-table scan, no SQL index over the secret (engineering consensus aligned with OWASP API2:2023, source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2023).
|
||||
- **Hashing:** Store **SHA-256** (or **HMAC-SHA-256** with a server-side pepper kept in the secret manager) of the secret — never plaintext. For **high-entropy random keys** (≥128, ideally 256 bits), a fast hash is acceptable; OWASP's slow-KDF warning is scoped to **low-entropy human passwords**, where "Fast hashing algorithms such as SHA-256 are not suitable" — that warning does **not** apply to 256-bit random API keys, and `argon2id` per-request would needlessly throttle the relay (source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html, 2026-06). _Note: this passwords-vs-keys distinction is engineering consensus consistent with OWASP, not a single verbatim OWASP line — see §5, item 12._
|
||||
- **If** you ever store a low-entropy password (not API keys), OWASP's current argon2id minimum is **m=19456 KiB (19 MiB), t=2, p=1** with a unique per-secret salt (source: same, 2026-06). Do **not** apply argon2id to per-request API-key verification.
|
||||
- **Comparison:** use `crypto/subtle.ConstantTimeCompare(x, y []byte)` on the fixed-length (32-byte) hashes. Never use `==` or `bytes.Equal` on raw secret material (source: https://pkg.go.dev/crypto/subtle, current).
|
||||
- **OWASP API2:2023:** "API keys should not be used for user authentication. They should only be used for API clients authentication." Never put keys in URLs/query strings; implement anti-brute-force on auth endpoints (source: https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/, 2023).
|
||||
- **Metadata per key:** `account_id`, `key_hash`, `prefix` (indexed), `created_at`, `last_used_at` (written throttled, e.g. ≤ once/min, to avoid a DB write per request), `expires_at` (optional), `revoked_at`. Support **multiple live keys per account** for zero-downtime rotation; reject revoked/expired keys on every request (aligned with OWASP API2:2023, source: same, 2023).
|
||||
|
||||
### 4.2 Rate limiting
|
||||
|
||||
- **Single instance:** `golang.org/x/time/rate` token bucket — `NewLimiter(r Limit, b int)`. Use `Allow()` to drop/reject (return 429) on inbound; `Wait(ctx)`/`Reserve()` to pace outbound APNs/FCM calls. `Reservation.Delay()` gives the wait → use it to compute `Retry-After`. Per-account: `map[accountID]*Limiter` with eviction (per-instance only) (source: https://pkg.go.dev/golang.org/x/time/rate, current).
|
||||
- **Multi-instance (behind LB):** per-instance limiters no longer enforce a global limit — use a **Redis-backed distributed limiter** with an **atomic Lua** script (refill → check → consume in one `EVAL`), state as a Redis HASH `{tokens, last_refill_ts}` + TTL for idle-account eviction. Alternatives: `redis-cell` (GCRA, `CL.THROTTLE` returns remaining quota + retry-after) or `redis-gcra` Lua scripts. Decide **fail-open vs fail-closed** when Redis is down (fail-open for availability; fail-closed for abuse-sensitive endpoints) (source: https://github.com/Losant/redis-gcra, current).
|
||||
- **Response:** on limit exceeded, return **`429 Too Many Requests`** (RFC 6585 §4, 2012-04) with a **`Retry-After`** header. ABNF: `Retry-After = HTTP-date / delay-seconds`, `delay-seconds = 1*DIGIT` (RFC 9110 §10.2.3, 2022-06). **Common bug to avoid:** do **not** emit an absolute Unix epoch integer — `delay-seconds` is a **relative** count of seconds; an HTTP-date must use the full date format. Prefer an integer relative-seconds value from `Reservation.Delay()` (source: https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3, 2022-06).
|
||||
- **OWASP API4:2023** (renamed "Unrestricted Resource Consumption"): enforce rate limits, "Define and enforce a maximum size of data on all incoming parameters and payloads," cap per-account push volume, and "Configure spending limits for all service providers/API integrations" — directly relevant to capping per-tenant APNs/FCM spend (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2023).
|
||||
|
||||
### 4.3 Idempotency
|
||||
|
||||
Accept an **`Idempotency-Key`** header (UUIDv4, **scoped per account**). _Status note:_ this is an IETF **draft** (`draft-ietf-httpapi-idempotency-key-header-07`, 2025-10-15), **not** a ratified RFC, and that version **expired 2026-04-18** — treat the header **name** and pattern as the de-facto standard (Stripe uses the same header), but do not cite it as an RFC (source: https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07, 2025-10-15).
|
||||
|
||||
Status-code lifecycle from the draft:
|
||||
|
||||
| Situation | Response |
|
||||
|---|---|
|
||||
| First request | Process; record the response. |
|
||||
| Duplicate, original **completed** | Replay the previously completed result (success or error). |
|
||||
| Duplicate, original **in-flight** | **409 Conflict** (RFC 7807 body, type `.../idempotency`). |
|
||||
| Same key, **different payload** | **422 Unprocessable Content**. |
|
||||
| Missing key where required | **400 Bad Request**. |
|
||||
|
||||
(source: same, 2025-10-15).
|
||||
|
||||
**Stripe-style production model** (de-facto reference): header `Idempotency-Key`, value up to 255 chars, recommend UUIDv4; saved result (status + body) replayed for **≥24 hours** for both success and failure; same key + different params → error; concurrent in-flight → not saved, client may retry; applies to POST (GET/DELETE ignore it) (source: https://docs.stripe.com/api/idempotent_requests, 2026-06).
|
||||
|
||||
**Relay implementation:** atomic `SET idem:{account}:{key}` with **NX + TTL** as an in-flight lock. NX success → process, then overwrite the marker with the serialized `{status, body}` and a ~24 h TTL. NX fail + stored response → replay it with the original status. NX fail + still in-flight → return **409**. Same key + different payload → **422**. This prevents double-sends to APNs/FCM under concurrent retries.
|
||||
|
||||
### 4.4 Provider auth recap (security view)
|
||||
|
||||
- **APNs:** cache one ES256 JWT, regenerate on a ~50-min timer (never < 20 min apart) under a mutex, reuse across senders; refresh on `403 ExpiredProviderToken`; back off on `429 TooManyProviderTokenUpdates`. Prefer team-scoped + topic-specific `.p8` keys (Feb 2025) for least privilege. Ensure the TLS trust store includes USERTrust RSA / SHA-2 root (§1.7). (sources as cited in §1.)
|
||||
- **FCM:** cache the OAuth2 access token (~1 h) rather than minting per message. On GCP, prefer keyless auth (§4.5). (sources as cited in §2.)
|
||||
|
||||
### 4.5 Secret management
|
||||
|
||||
OWASP Secrets Management ranking — **KMS/Vault/secret-manager (best) > tightly-permissioned mounted secret file (acceptable) > plain env var (discouraged) > in image/repo (forbidden)**. Verbatim: "environment variables are generally accessible to all processes and may be included in logs or system dumps. Using environment variables is therefore not recommended unless the other methods are not possible." Encrypt at rest (AES-256-GCM or ChaCha20-Poly1305); "You should not store keys next to the secrets they encrypt" (envelope encryption / KMS); least privilege; prefer dynamic/short-lived secrets (source: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html, 2026-06). Store the APNs `.p8` and FCM SA JSON in a secret manager; never commit or bake into the image.
|
||||
|
||||
**Google keyless direction (2025):** prefer Workload Identity / service-account impersonation / attached SA over downloadable JSON keys; org policy `constraints/iam.disableServiceAccountKeyCreation` blocks new keys. If the relay runs **on GCP**, use an attached SA / Workload Identity. If **off-GCP**, a JSON key is sometimes unavoidable — then it **must** live in a secret manager, not a repo or plain env var (source: https://docs.cloud.google.com/iam/docs/keys-disable-enable, 2026-06).
|
||||
|
||||
### 4.6 Log redaction
|
||||
|
||||
OWASP Logging exclude-list (verbatim items): "Access tokens"; "Authentication passwords"; "Session identification values"; "Database connection strings"; "Encryption keys and other primary secrets"; payment/PII. Redact by remove/mask/sanitize/hash/encrypt (source: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html, 2026-06).
|
||||
|
||||
For the relay specifically — **never log**: the `Authorization`/bearer header, the raw API key (log only the non-secret **prefix** or a hash), full APNs/FCM **device tokens** (truncate/hash), or **notification payload bodies**. **Do log**: request ID, `account_id`/tenant ID, status code, error `reason`/`status`, and Retry-After/idempotency decisions for triage. Implement redaction in logging middleware so it can't be bypassed.
|
||||
|
||||
### 4.7 Multi-tenant isolation
|
||||
|
||||
Combine **noisy-neighbor resource control** (per-tenant rate/volume quotas at gateway/service/data tiers) with **per-tenant scoping** of every stored API key, rate-limit bucket, idempotency key, cached provider token, and **log line**. "Attach a tenant ID to every request, log entry, metric, and trace." Namespace all shared-store keys by `account_id` to prevent cross-tenant collision/replay. Cap downstream APNs/FCM spend per tenant (OWASP API4:2023). For APNs, recall the per-team connection-pool constraint (§1.1) (source: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/, 2023).
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
T[Tenant request + API key + Idempotency-Key] --> AUTH[Auth middleware: prefix lookup + ConstantTimeCompare]
|
||||
AUTH --> RL[Per-tenant rate limit: Redis Lua token bucket]
|
||||
RL -->|429 + Retry-After| T
|
||||
RL --> IDEM[Idempotency: Redis SET NX lock / replay / 409 / 422]
|
||||
IDEM --> ROUTE{Provider}
|
||||
ROUTE -->|APNs| APNS[ES256 JWT cached ~50m, per-team HTTP/2 pool]
|
||||
ROUTE -->|FCM| FCM[OAuth2 token cached ~1h, v1 messages:send]
|
||||
APNS --> STORE[(pgx: tokens / keys / idempotency)]
|
||||
FCM --> STORE
|
||||
AUTH -. redacted .-> LOG[slog JSON: tenant, req-id, status, reason]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions / re-verify before implementation
|
||||
|
||||
1. **APNs token page exact wording.** `establishing-a-token-based-connection-to-apns` is a JS-rendered SPA; content was confirmed via Apple's JSON data endpoint and corroborating sources. Re-read the live page to confirm exact 20/60-minute refresh wording and the scoped-key limits ("200 keys per env, up to 400 topics per topic-based key") near build time.
|
||||
2. **`apns2` maintenance / bus factor.** No release since v0.25.0 (Oct 2024). Check recent commit/issue activity before committing to it vs. a hand-rolled `net/http2` + `golang-jwt/jwt/v5` client.
|
||||
3. **APNs `SETTINGS_MAX_CONCURRENT_STREAMS`.** Intentionally variable and not documented by Apple. Measure at runtime; do not hardcode connection sizing.
|
||||
4. **APNs sandbox hostname alias (resolved — interchangeable).** `api.sandbox.push.apple.com` and `api.development.push.apple.com` are interchangeable sandbox aliases: as of 2026-06-13 both resolve to the same Akamai CNAME and IP set (DNS-verified, §1.3), so either reaches the same sandbox backend. We standardize on `api.sandbox.push.apple.com` (canonical in Apple's docs and `apns2`'s `HostDevelopment`). No build-time blocker — this is a naming preference, not a discrepancy.
|
||||
5. **APNs broadcast hosts.** If Live Activity broadcast/channels are in scope, confirm the broadcast-specific hosts (`api-broadcast.*`, `api-manage-broadcast.*` on 2195/2196) against Apple's code samples; the connection-section text in Apple's docs is internally inconsistent.
|
||||
6. **Future APNs requirements.** None published after Feb 2025 as of June 2026 — re-check Apple's Upcoming Requirements page (e.g. a future TLS-1.3 minimum or another CA rotation) close to deployment.
|
||||
7. **FCM `RetryInfo` on 429.** FCM's error-codes page promises only `google.rpc.QuotaFailure`, not `RetryInfo`. Treat `RetryInfo` as optional; fall back to `Retry-After` / 60 s default.
|
||||
8. **FCM data-residency endpoints.** None for FCM as of June 2026 (global `fcm.googleapis.com` only). Re-check release notes if EU/other data-residency requirements emerge.
|
||||
9. **FCM project quota tiers above 600K/min.** Confirm a project's real ceiling via Google Cloud Console quotas rather than assuming a secondary-source tier.
|
||||
10. **App Standby Bucket caps.** Per-bucket high-priority wake-up caps are unpublished and OEM/version-dependent; confirm via on-device testing (`adb shell am set-standby-bucket` / `get-standby-bucket`) if precise behavior matters.
|
||||
11. **Android 270-day token expiry.** Currently documented (manage-tokens, 2026-06-13) but a platform constant that can change — re-verify at build time.
|
||||
12. **API-key "fast hash OK" citation.** Engineering consensus consistent with OWASP's passwords-vs-keys threat model, but not a single verbatim OWASP line for API keys; if a hard citation is required, point to that distinction, and decide plain salted SHA-256 vs HMAC-SHA-256 pepper (pepper adds defense-in-depth but complicates rotation).
|
||||
13. **Idempotency-Key draft status.** Draft 07 expired 2026-04-18 without becoming an RFC; re-check for an 08+/RFC before relying on its exact status-code semantics as "standard."
|
||||
14. **GCP vs off-GCP for FCM credentials.** Whether the relay runs on GCP (keyless Workload Identity) or off-GCP (JSON key in a secret manager) changes the recommended FCM credential approach — resolve in the deployment design.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user