R-1007 spike A: how the box can ask Immich without the household's password — Immich's own queue works for every user, a key only for the admin
gates / gates (push) Successful in 6m45s

On 9202, Immich v3.2.4, admin made through the setup gate as a household. A1 (immich-admin) has no
job command. A2 (scoped key: asset.update + job.create) repairs the admin's photos only — a family
member's photo is refused. A3 (nightly missing thumbnails) reads only the database. A4 (the same job
on Immich's own queue, added with its own bullmq) repaired 5 assets of both users in 1.08 s with 0
of 50 controls touched, and found a lost original in 0.44 s. Scan of 20,000 files: 0.29 s.
Decision page for the operator (Part B). Immich removed through the product.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-14 15:59:23 +02:00
parent 3e2bdda721
commit 5d39171e90
8 changed files with 216 additions and 0 deletions
@@ -0,0 +1,10 @@
15:49:27 immich: deploy -> 202 {'ok': True, 'message': 'Telepítés elindítva – az állapot a kártyán követhető'}
15:51:02 immich: deployed, state=running
deploy: ok
15:51:02 immich: deployed, state=running
stranger (no dashboard session) admin-sign-up -> 401
household (dashboard session, through the gate) admin-sign-up -> 401
server config isInitialized: None
gate passed as the household: True
household admin-sign-up through the gate -> 201
isInitialized: True
@@ -0,0 +1,31 @@
family member: 201
admin uploaded: 20
member uploaded: 10
jobs idle
thumbs files: 60; users dirs: 2
== A1: immich-admin in v3.2.4
/usr/src/app/server/bin/immich-admin
Usage: main [options] [command]
Options:
-h, --help display help for command
Commands:
reset-admin-password Reset the admin password
enable-password-login Enable password login
disable-password-login Disable password login
enable-maintenance-mode Enable maintenance mode or regenerate the
maintenance token
disable-maintenance-mode Disable maintenance mode
enable-oauth-login Enable OAuth login
disable-oauth-login Disable OAuth login
list-users List Immich users
version Print Immich version
grant-admin Grant admin privileges to a user (by email)
revoke-admin Revoke admin privileges from a user (by email)
change-media-location Change database file paths to align with a new
media location
schema-check Verify database migrations and check for schema
drift
help [command] display help for command
@@ -0,0 +1,73 @@
== A2: an API key with ONLY asset.update, made by the admin (the household's session)
key made: yes (secret never printed)
the admin's own photo: {"message":"Missing required permission: job.create"} [403]
the family member's photo: {"message":"Missing required permission: job.create"} [403]
integrity report with this key: 403
visible to the household in Account settings -> API keys: ['Felhom box repair (A2 spike)']
key deleted: 204
== A3: what does the nightly/"missing" thumbnail run read?
services/queue.service.js:214: if (config.nightlyTasks.missingThumbnails) {
nightly tasks setting: {'startTime': '00:00', 'databaseCleanup': True, 'missingThumbnails': True, 'clusterNewFaces': True, 'generateMemories': True, 'syncQuotaUsage': True}
== A2 retry: key with asset.update + job.create (+ maintenance for the report)
key made: yes
the admin's own photo: [204]
the family member's photo: {"message":"Not found or no asset.update access"} [400]
integrity report with this key: 200
key deleted: 204
== A3: the missing-thumbnail query
repositories/asset-job.repository.d.ts
293: streamForThumbnailJob(options: {
294- force: boolean | undefined;
295- fullsizeEnabled: boolean;
296- }): AsyncIterableIterator<{
297- id: string;
298- isEdited: boolean;
299- } & Partial<Omit<unknown, "id" | "isEdited">>>;
300- getForMigrationJob(id: string): Promise<{
301- id: string;
302- ownerId: string;
303- files: {
304- id: string;
305- type: AssetFileType;
306- path: string;
307- isEdited: boolean;
308- }[];
309- } | undefined>;
310- getForGenerateThumbnailJob(id: string): Promise<{
311- id: string;
312- type: AssetType;
313- ownerId: string;
314- edits: ({
315- action: import("../dtos/editing.dto").AssetEditAction.Crop;
316- parameters: {
317- x: number;
58: streamForThumbnailJob(options) {
59- return this.db
60- .selectFrom('asset')
61- .select(['asset.id', 'asset.isEdited'])
62- .where('asset.deletedAt', 'is', null)
63- .where('asset.visibility', '!=', kysely_1.sql.lit(enum_1.AssetVisibility.Hidden))
64- .$if(!options.force, (qb) => qb
65- .innerJoin('asset_job_status', 'asset_job_status.assetId', 'asset.id')
66- .where(({ and, eb, exists, not, or, selectFrom }) => {
67- const file = (type) => selectFrom('asset_file').whereRef('assetId', '=', 'asset.id').where('type', '=', kysely_1.sql.lit(type));
68- const conditions = [
69- not(exists(file(enum_1.AssetFileType.Thumbnail))),
70- not(exists(file(enum_1.AssetFileType.Preview))),
71- and([
72- eb('asset.isEdited', '=', kysely_1.sql.lit(true)),
73- not(exists(file(enum_1.AssetFileType.FullSize).where('asset_file.isEdited', '=', kysely_1.sql.lit(true)))),
74- ]),
75- eb('asset.thumbhash', 'is', null),
76- ];
77- if (options.fullsizeEnabled) {
78- const isWebUnsupported = kysely_1.sql.join(Object.keys(mime_types_1.mimeTypes.webUnsupportedImage).map((ext) => kysely_1.sql.lit(`%${ext}`)));
79- conditions.push(and([
80- not(exists(file(enum_1.AssetFileType.FullSize))),
81- eb((0, kysely_1.sql) `f_unaccent(asset."originalFileName")`, 'like', (0, kysely_1.sql) `any(array[${isWebUnsupported}]::text[])`),
82- ]));
83- }
84- return or(conditions);
@@ -0,0 +1,23 @@
244- }
245: async handleMissingFilesQueueAll({ refreshOnly } = {}) {
246- if (refreshOnly) {
247- await this.queueRefreshAllMissingFiles();
248- return enum_1.JobStatus.Success;
249- }
250- this.logger.log(`Scanning for missing files...`);
251- const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles();
252- let total = 0;
253- for await (const batchPaths of (0, misc_1.batched)(assetPaths, constants_1.JOBS_LIBRARY_PAGINATION_SIZE)) {
254- await this.jobRepository.queue({
255- name: enum_1.JobName.IntegrityMissingFiles,
256- data: {
257- items: batchPaths,
deleted one original (the 5th by name)
report rows before: 0
== 13:54:39 queue IntegrityMissingFilesQueueAll on integrityCheck (no key, no login)
queued 1
missing_file rows after 0.44 s: 1
== the report, READ from the database, joined to what a household note needs (name + date taken + owner):
missing_file|gen-004.jpg|2026-01-03 01:00|household@example.invalid
@@ -0,0 +1,10 @@
users in thumbs/: 2 (10 and 20 photos)
found from the disk: 5 broken assets
== 13:53:50 A4: queue AssetGenerateThumbnails on thumbnailGeneration with Immich's own bullmq (no key, no login)
queued 5
rc=0
no empty file left after 1.08 s: 0 empty
broken assets' files: 10, rewritten 10, empty 0; other assets' files: 50, rewritten 0
== the household's view (its own login): served bytes for the 5
5964/198427 47/47 5630/197443 47/47 47/47
@@ -0,0 +1,6 @@
files made: 20000 on /mnt/scan-test
dirs: 10525
scan 1: 10 empty-file assets found among 20000 files in 0.285 s
scan 2: 10 empty-file assets found among 20000 files in 0.287 s
scratch folder removed: yes
@@ -0,0 +1,6 @@
15:56:41 immich: stop 200, remove 200, deployed=False, leftovers=''
True
left: 0 dirs
containers: felhom-controller paperless-webserver paperless-postgres paperless-redis filebrowser traefik
immich volumes: 0; images: 0
@@ -0,0 +1,57 @@
# Immich repairs its broken photos after a power cut (R-1007) — 2026-10-14
Times UTC. Scratch guest 9202 on demo-hp (Tier 0). Immich v3.2.4. No key, token, password, photo or file name of a
household in this folder (bench content only).
## Part A — how can the box ask Immich without the household's password?
**Predictions (written 2026-10-14 before any test):**
| # | Candidate | Prediction |
|---|---|---|
| A1 | `immich-admin` CLI | it exists in v3.2.4 but has only account/maintenance commands (reset admin password, password/OAuth login on/off, list users, media location) — **no job command**; fails |
| A2 | a scoped API key owned by the box | v3.2.4 has per-permission keys (`asset.update` for the per-asset job); but **an API key acts as its user**: the per-asset job checks access per asset, so the admin's key cannot repair another family member's photos; and the box has no clean moment to mint it without the password or the household's session → works only partly, and needs a capture we do not want |
| A3 | Immich's nightly „missing thumbnails" | „missing" reads the database (the asset's file rows), not the disk; nothing short of a database write makes an empty file count as missing → only with A5 |
| A4 | queue the same job in Immich's Redis | works, with no key: the job is `AssetGenerateThumbnails {id}` on queue `thumbnailGeneration`; done with Immich's OWN queue library inside `immich-server` (`docker exec … node`) it is the same job the API queues; breaks if Immich renames the job or the queue (an update can) |
| A5 | a database write | works (the box has `DB_PASSWORD`) but writes into the household's data; refused unless chosen |
| A6 | the household presses a button | works always; costs the household a step and a full rebuild |
Lost originals: the integrity job can be queued the same way as A4, and its report READ from the database (a read, not a
write) — or the box compares the database's `originalPath`s with the disk itself.
**Set-up (`A/A0-*.txt`):** Immich deployed through the product on 9202. The admin made **as a household**: a stranger's
`admin-sign-up` → 401 (the setup gate); the household passed the gate with its dashboard session (`felhom_gate` cookie)
→ 201, `isInitialized` true. A second family member made by the admin. 20 photos as the admin, 10 as the member. From
here the household password was used only to look at results as the household; no candidate used it.
**Measured:**
| # | Works? | What it writes into the household's Immich | What the household sees | What an Immich update breaks | What a compromised controller gains |
|---|---|---|---|---|---|
| A1 `immich-admin` | **no** — v3.2.4's commands: reset-admin-password, password/OAuth/maintenance on-off, list-users, grant/revoke-admin, change-media-location, schema-check, version. No job command (`A/A0-photos.txt`) | — | — | — | — |
| A2 scoped API key | **partly** — needs `asset.update` + `job.create` (with `asset.update` alone: 403 „Missing required permission: job.create"); the admin's own photo 204, **the family member's photo 400 „Not found or no asset.update access"**; the lost-file report needs `maintenance` + an admin. The box cannot make the key itself: only the household's session can (`A/A2-A3-key-and-missing.txt`) | one API key row in the admin's account | „Felhom box repair" under Account settings → API keys; deleting it stops the repair | little: the public API is versioned | the key: update the admin's photos' metadata, run any job — less than it has already |
| A3 nightly „missing thumbnails" | **no** (on by default, 00:00) — it reads only the database: no thumbnail/preview ROW, an edited asset without a full-size row, or `thumbhash` null (`asset-job.repository.js` `streamForThumbnailJob`). An empty file never counts; only a database write (A5) would make it | — | — | — | — |
| **A4 Immich's own queue** | **yes** — `AssetGenerateThumbnails {id}` on `thumbnailGeneration` (prefix `immich_bull`), added with Immich's own `bullmq` inside `immich-server` (`docker exec … node`) — the same job `POST /api/assets/jobs` queues: **5 broken assets of BOTH users repaired in 1.08 s; their 10 files rewritten; 0 of the other 50 files touched**. Lost originals: `IntegrityMissingFilesQueueAll` on `integrityCheck` → one `missing_file` row in 0.44 s, read from the database with a SELECT (name, date taken, owner) (`A/A4-*.txt`) | only what Immich itself writes: the rebuilt thumbnails and its own integrity-report rows | nothing (the admin's Jobs page shows the queue counts while it runs) | the job name, the queue name, the prefix, the report table — internal; an update can rename them | nothing new: the controller already holds the docker socket and `DB_PASSWORD` |
| A5 database write | not tried (not chosen) | rows in the household's database | depends | the schema | — |
| A6 the household presses | always works | — | a note; „thumbnails: all" rebuilds the whole library | — | — |
**Scan cost (`A/scan-20000.txt`):** 20,000 files in the same layout (10,525 folders), 10 empty → found in **0.29 s**
(`find -xdev -type f -size 0`; warm cache — a cold scan cannot be forced inside the guest).
**Path:** `<upload>/thumbs/<user id>/<aa>/<bb>/<asset id>_thumbnail.webp` / `_preview.jpeg`; the asset id is the first
36 characters, followed by **`_`** (the prompt's draft regex had `-`).
**Teardown:** Immich removed through the product; no folder, container, volume or image left (`A/teardown.txt`).
## Part B — the decision (operator)
**Question: how should the box ask Immich to repair the broken photos after a power cut?**
| | Option 1 — Immich's own queue (A4) — **my pick** | Option 2 — an API key the household makes (A2) |
|---|---|---|
| How | the controller runs Immich's own queue library inside `immich-server` and adds the same job Immich's API adds; reads the lost-file report from Immich's database (read only) | the household makes a key in Immich (admin, 3 permissions) and pastes it into the dashboard; the box calls the public API |
| Writes into Immich | only what Immich itself writes when it repairs (thumbnails, its report rows) | one API key in the admin's account |
| The household sees | nothing (a note only if a photo is lost) | the key in its account; one extra setup step |
| Covers | every user's photos | **only the admin's** — a family member's photos are refused (measured) |
| An Immich update can break | the job/queue names and the report table → the catalog pins the tested Immich version; any other version: skip and tell the operator, never guess | little (public API) |
| Threat | none new: the controller already has the docker socket and the database password | a stored key the box must guard |
**If you do nothing:** the fallback A6 — no automatic repair; a note tells the household to run „thumbnails: all".