add photos, an immich-backed library behind its own sidecar

officer-photos owns the whole Immich contract: the instance URL and the API
key live there and nowhere else, and the platform side is an auth-gated
forwarder holding no credentials. The route surface is an allow-list keyed on
the first path segment, so admin, auth, api-keys, sessions, jobs, system-config
and libraries are unreachable by construction rather than by enumeration.

The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums,
people, favorites, sharing, archive, trash — because the point of a sidecar
screen is to reproduce what the upstream already ships, then extend it. The
timeline reads Immich's columnar time-bucket format directly; selection lives
in the URL per docs/navigation-audit.md.

Two things worth knowing for anyone touching this later:

- `duration` is an integer count of milliseconds in Immich 3.0. It was an
  HH:MM:SS.mmm string before, and every stale example still shows that form.
- the map container is sized with h-full/w-full, never `absolute inset-0`.
  maplibre's stylesheet sets `position: relative; overflow: hidden` on the
  element it is given, and an unlayered vendor rule beats Tailwind 4's layered
  `.absolute` regardless of source order — so the div collapses to height 0 and
  clips its own canvas away. Nothing errors: the GL context is healthy, tiles
  download and pixels are drawn into a buffer nobody ever composites.

maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file
from import.meta.url, which Officer's index.html fallback answers with HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:18:33 +00:00
co-authored by Claude Opus 5
parent 4f4e0c5dbc
commit 035a1ba8f6
42 changed files with 3429 additions and 1 deletions
+137
View File
@@ -0,0 +1,137 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleOfficerRoute } from './routes';
import { callUpstream, getBase, getConfig } from './upstream';
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
// credentials.
//
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
//
// Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated).
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
//
// GET /_health ours. Confirms the key is live and reports the Immich version and who the key is.
// * /_officer/<path> forwarded to <IMMICH_URL>/api/<path>, first-segment allow-list (routes.ts)
// anything else 404
//
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Uploads go through this proxy as multipart bodies, and a phone's video is not small.
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const cfg = getConfig();
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, error: 'IMMICH_URL/IMMICH_API_KEY not configured' }, { status: 503 });
const started = Date.now();
try {
// Version is public, so it separates "instance down" from "key rejected" in one shot.
const [versionRes, meRes] = await Promise.all([
callUpstream(cfg, { path: '/api/server/version', withKey: false }),
callUpstream(cfg, { path: '/api/users/me' }),
]);
if (!versionRes.ok) {
return Response.json({ ok: false, error: `upstream returned ${versionRes.status}` }, { status: 502 });
}
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
const version = [v.major, v.minor, v.patch].every((n) => typeof n === 'number')
? `${v.major}.${v.minor}.${v.patch}`
: null;
if (!meRes.ok) {
return Response.json(
{ ok: false, version, error: `IMMICH_API_KEY rejected (${meRes.status})`, ms: Date.now() - started },
{ status: 502 },
);
}
const me = (await meRes.json()) as { email?: string; name?: string };
return Response.json({ ok: true, version, user: me.email ?? me.name ?? null, ms: Date.now() - started });
} catch (err) {
return Response.json({ ok: false, error: String(err), ms: Date.now() - started }, { status: 502 });
}
}
if (url.pathname.startsWith('/_officer/')) {
if (!cfg) return Response.json({ error: 'photos not configured' }, { status: 503 });
try {
const res = await handleOfficerRoute(cfg, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[photos] listening on 127.0.0.1:${port} -> ${getBase() ?? '(IMMICH_URL unset)'}`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'photos',
capabilities: ['photos'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'photos:server', port });
console.log(`[photos] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[photos] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+108
View File
@@ -0,0 +1,108 @@
import type { UpstreamConfig } from './upstream';
import { callUpstream } from './upstream';
// The Officer-owned route surface. This is an allow-list, not a passthrough.
//
// Immich's API is already REST and already shaped the way a photo UI wants it, so the value this sidecar
// adds is (a) holding the API key, (b) streaming the binary routes through without buffering them, and
// (c) refusing to expose the administrative half of the API at all.
//
// The gate is the FIRST path segment plus the method. Anything whose first segment is not listed here is a
// 404 before a request is made, so the deny side is closed by construction rather than by enumeration.
//
// DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and
// password machinery — the key is the credential here, and nothing should be minting sessions through a
// dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/*
// (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external
// library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it.
// Adding one should be a decision, not an accident.
const RESOURCES: Record<string, readonly string[]> = {
// The library itself.
assets: ['GET', 'POST', 'PUT', 'DELETE'],
albums: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
timeline: ['GET'],
memories: ['GET', 'POST', 'PUT', 'DELETE'],
people: ['GET', 'POST', 'PUT'],
faces: ['GET', 'PUT'],
tags: ['GET', 'POST', 'PUT', 'DELETE'],
stacks: ['GET', 'POST', 'PUT', 'DELETE'],
// Finding things. `search/metadata`, `search/smart` and `search/random` are POSTs with a JSON body.
search: ['GET', 'POST'],
duplicates: ['GET'],
map: ['GET'],
view: ['GET'],
// Getting things out. `download/info` then `download/archive` — both POST, the archive streams a zip.
download: ['POST'],
// Recoverable deletes. `trash/empty` is the one irreversible route in this list; it is here because it is
// the counterpart of a delete the UI can already perform, not because it is safe.
trash: ['GET', 'POST'],
// Sharing and social. Read-mostly, but a shared link is the point of them.
'shared-links': ['GET', 'POST', 'PATCH', 'DELETE'],
activities: ['GET', 'POST', 'DELETE'],
partners: ['GET'],
notifications: ['GET', 'PUT', 'DELETE'],
// Read-only context: who the key acts as, and what the server is.
users: ['GET'],
server: ['GET'],
};
// Response headers worth carrying back. Content-Type and Content-Length for every response; the rest so a
// thumbnail can be cached and revalidated and a video can be seeked without the proxy understanding either.
const PASSTHROUGH_HEADERS = [
'content-type',
'content-length',
'content-disposition',
'content-range',
'accept-ranges',
'etag',
'last-modified',
'cache-control',
] as const;
/**
* Forward one request to Immich and stream the answer back.
*
* The body is a stream, never an ArrayBuffer: a full-resolution original or a `download/archive` zip is
* hundreds of megabytes and buffering it would hold all of it in the sidecar's heap for no reason.
*/
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
const rest = url.pathname.slice('/_officer/'.length);
if (!rest) return null;
const [resource] = rest.split('/');
const allowedMethods = resource ? RESOURCES[resource] : undefined;
if (!allowedMethods) return null;
if (!allowedMethods.includes(req.method)) {
return Response.json({ error: `${req.method} not allowed on ${resource}` }, { status: 405 });
}
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
// An <img>/<video> cannot send an Authorization header, so the browser puts Officer's JWT in `?token=`
// (userMiddleware accepts it there). That token is Officer's, not Immich's: forwarding it would write the
// owner's session credential into Immich's access log for every thumbnail. Drop it at the boundary.
const query = new URLSearchParams(url.search);
query.delete('token');
const search = query.toString();
const res = await callUpstream(cfg, {
path: `/api/${rest}`,
method: req.method,
query: search ? `?${search}` : '',
body: hasBody ? await req.arrayBuffer() : null,
contentType: req.headers.get('content-type'),
range: req.headers.get('range'),
ifNoneMatch: req.headers.get('if-none-match'),
});
const headers = new Headers();
for (const name of PASSTHROUGH_HEADERS) {
const value = res.headers.get(name);
if (value) headers.set(name, value);
}
// 204 and 304 must not carry a body, and Bun throws if one is attached.
const body = res.status === 204 || res.status === 304 ? null : res.body;
return new Response(body, { status: res.status, headers });
}
+85
View File
@@ -0,0 +1,85 @@
// Immich upstream config for the officer-photos sidecar.
//
// All knowledge of the Immich instance — its URL and its API key — lives here, mirroring
// officer-invoiceshelf/officer-transmission/officer-slskd: the platform API is a thin auth+forward proxy
// and holds NO Immich credentials.
//
// Two things about Immich's API are load-bearing:
//
// 1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a
// 403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad
// credential. Create the key with all permissions unless there is a reason not to.
// 2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative
// credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is
// exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
// nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.
const { IMMICH_URL, IMMICH_API_KEY } = process.env;
export type UpstreamConfig = { base: string; key: string };
let warnedUnset = false;
/** The instance URL alone, for logging — set without a key is a real state and should read as one. */
export function getBase(): string | null {
return IMMICH_URL?.trim().replace(/\/+$/, '') || null;
}
/**
* The configured instance, or null when unconfigured — the sidecar then answers 503 rather than pretending
* to work. Warns once so a misconfigured deployment is obvious in the logs without flooding them.
*/
export function getConfig(): UpstreamConfig | null {
const base = IMMICH_URL?.trim().replace(/\/+$/, '');
const key = IMMICH_API_KEY?.trim();
if (!base || !key) {
if (!warnedUnset) {
const missing = [!base && 'IMMICH_URL', !key && 'IMMICH_API_KEY'].filter(Boolean).join(' and ');
console.warn(`[photos] ${missing} unset — the sidecar will respond 503 until set`);
warnedUnset = true;
}
return null;
}
return { base, key };
}
type CallOptions = {
/** Absolute path on the Immich host, e.g. `/api/albums`. */
path: string;
method?: string;
/** Raw search string including the leading `?`, or empty. */
query?: string;
body?: BodyInit | null;
contentType?: string | null;
/** Byte range for thumbnail/original/video reads, forwarded verbatim. */
range?: string | null;
ifNoneMatch?: string | null;
/** Set false for the version/ping routes, which Immich serves unauthenticated. */
withKey?: boolean;
};
/** The single door to Immich. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamConfig, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (opts.range) headers.Range = opts.range;
if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body ?? undefined,
// A redirect from an API route means something has gone wrong with auth; surface it rather than
// following it into an HTML page.
redirect: 'manual',
});
if (res.status === 401 || res.status === 403) {
// 403 is the interesting one: the key is valid but lacks the permission this route needs.
console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path}${res.status} (key rejected or under-scoped)`);
}
return res;
}
+2
View File
@@ -73,6 +73,8 @@ export type SidecarEvent =
| { type: 'transmission:server'; port: number }
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'invoiceshelf:server'; port: number }
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'photos:server'; port: number }
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'wallet:server'; port: number }
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect