give each member their own immich account and key
Photos had no user provisioning at all: one registry row, the owner's, and every other account resolving to nothing. Same shape as the Jellyfin gap, but this one was never built rather than built and dropped. photos_accounts records who on Officer is who on Immich, and carries that person's own API key. getConfig falls back to it when the caller has no registry of their own, and there is no path from there to the owner's key — an Immich key with permissions:['all'] can read and delete the whole library, so a member resolving to it would be administering everyone's photographs rather than reading their own. Matching is on email, which Immich enforces uniqueness on and Officer already holds. That is exact where the Jellyfin side has to go through an uppercased username index and cope with two Officer names colliding into one. Two deliberate differences from the Jellyfin flow: The admin's own account is reported as itself, not as "no Officer account". That bug shipped there and was fixed in 591c0da; there was no reason to write it twice, so reconcile takes the key's identity from /api/users/me and the test pins it. And the temporary password is HANDED OVER rather than discarded. Jellyfin discards because it has no must-change-password flag, so anything handed over stays valid forever. Immich has one, and Immich's own app is where phone backup runs — discarding would leave a photo account that can never back a phone up again. So: set a password, spend it on a key, raise shouldChangePassword, return it once. POST /_config/:id/handover re-issues on demand, because "setting up my phone" happens long after provisioning and more than once. Verified against the running 3.1.0 image that an API key survives a password change, so re-issuing never costs Officer its access. Applied on alpha: conceiba and fedra linked, guipinto and diogo created, all four keyed and each resolving to their own Immich identity. jrg.conceicao left alone. Members still need the photos grant on the Member role before the permission gate lets them through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+117
-2
@@ -1,6 +1,6 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, isNotNull } from 'drizzle-orm';
|
||||
import { db } from 'officerdb/db';
|
||||
import { photosConfig } from './schema';
|
||||
import { photosAccounts, photosConfig } from './schema';
|
||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||
|
||||
// Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from
|
||||
@@ -53,6 +53,44 @@ export async function getActivePhotosCredentials(userId: number): Promise<Photos
|
||||
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The credentials a NON-OWNER reads the library with: the owner's selected instance, and that person's own
|
||||
* API key from their link row.
|
||||
*
|
||||
* `id` and `label` are the REGISTRY row's, so a probe is recorded against the instance it actually reached
|
||||
* and everything downstream treats this as "the connection in use" without caring whose key is inside it.
|
||||
* The only field that differs from the owner's is the one that must — the key.
|
||||
*
|
||||
* Null rather than a fallback in two cases, and both are deliberate:
|
||||
*
|
||||
* - **The instance must be the ACTIVE one.** Accounts are provisioned against one Immich, so a member
|
||||
* linked to an instance the owner has since switched away from is genuinely not provisioned on the one
|
||||
* in use.
|
||||
* - **The key must exist.** Minting needs a password, which is not always ours to set.
|
||||
*
|
||||
* There is deliberately no path from here to the OWNER's key. An Immich key created with `permissions:
|
||||
* ['all']` can read and delete the entire library, so a member resolving to it would not be reading their
|
||||
* own photographs — they would be administering everyone's.
|
||||
*/
|
||||
export async function getLinkedPhotosCredentials(userId: number): Promise<PhotosCredentials | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: photosConfig.id,
|
||||
label: photosConfig.label,
|
||||
url: photosConfig.url,
|
||||
apiKey: photosAccounts.apiKey,
|
||||
})
|
||||
.from(photosAccounts)
|
||||
.innerJoin(photosConfig, eq(photosAccounts.configId, photosConfig.id))
|
||||
.where(
|
||||
and(eq(photosAccounts.userId, userId), eq(photosConfig.isActive, true), isNotNull(photosAccounts.apiKey)),
|
||||
);
|
||||
|
||||
// The isNotNull above already excludes it; this narrows the type and keeps the guarantee local.
|
||||
if (!row?.apiKey) return null;
|
||||
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
|
||||
}
|
||||
|
||||
/** One account's credentials by id — for probing a specific account rather than the active one. */
|
||||
export async function getPhotosCredentials(userId: number, id: number): Promise<PhotosCredentials | null> {
|
||||
const [row] = await db
|
||||
@@ -175,3 +213,80 @@ export async function recordPhotosProbe(userId: number, id: number, version: str
|
||||
.set({ version, lastSeenAt: new Date() })
|
||||
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
|
||||
}
|
||||
|
||||
// ── The Officer ↔ Immich account mapping ──
|
||||
|
||||
/**
|
||||
* Links already recorded for one instance, for the reconcile to compare against.
|
||||
*
|
||||
* `hasKey` rather than the key itself: callers need to know whether this person can reach their library,
|
||||
* and none of them needs the credential to answer that. The ciphertext does not leave this file.
|
||||
*/
|
||||
export async function listPhotosAccountLinks(configId: number): Promise<
|
||||
{ userId: number; immichUserId: string; immichEmail: string; adopted: boolean; hasKey: boolean }[]
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: photosAccounts.userId,
|
||||
immichUserId: photosAccounts.immichUserId,
|
||||
immichEmail: photosAccounts.immichEmail,
|
||||
adopted: photosAccounts.adopted,
|
||||
apiKey: photosAccounts.apiKey,
|
||||
})
|
||||
.from(photosAccounts)
|
||||
.where(eq(photosAccounts.configId, configId));
|
||||
return rows.map(({ apiKey, ...rest }) => ({ ...rest, hasKey: !!apiKey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a pairing.
|
||||
*
|
||||
* `onConflictDoNothing` on BOTH unique indexes, so a re-run is silent rather than an error — a second
|
||||
* reconcile finding the same thing already true is the normal case, not a fault.
|
||||
*/
|
||||
export async function linkPhotosAccount(params: {
|
||||
userId: number;
|
||||
configId: number;
|
||||
immichUserId: string;
|
||||
immichEmail: string;
|
||||
adopted: boolean;
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.insert(photosAccounts)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
configId: params.configId,
|
||||
immichUserId: params.immichUserId,
|
||||
immichEmail: params.immichEmail,
|
||||
// Mirrors what Immich stores, so matching is a lookup rather than a convention every call site has
|
||||
// to remember.
|
||||
normalizedEmail: params.immichEmail.trim().toLowerCase(),
|
||||
adopted: params.adopted,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the key minted for one person, encrypted.
|
||||
*
|
||||
* Separate from `linkPhotosAccount` rather than a parameter on it, for the reason the Jellyfin side learned
|
||||
* the hard way: linking is `onConflictDoNothing`, but a key is exactly the thing you re-run provisioning to
|
||||
* REPLACE. An insert that does nothing on conflict cannot express that.
|
||||
*/
|
||||
export async function setPhotosAccountKey(params: {
|
||||
userId: number;
|
||||
configId: number;
|
||||
apiKey: string;
|
||||
}): Promise<void> {
|
||||
await db
|
||||
.update(photosAccounts)
|
||||
.set({ apiKey: encryptSecret('photos', params.apiKey) })
|
||||
.where(and(eq(photosAccounts.userId, params.userId), eq(photosAccounts.configId, params.configId)));
|
||||
}
|
||||
|
||||
/** Drop a pairing. Removes Officer's RECORD of the account, never the account itself. */
|
||||
export async function unlinkPhotosAccount(userId: number, configId: number): Promise<void> {
|
||||
await db
|
||||
.delete(photosAccounts)
|
||||
.where(and(eq(photosAccounts.userId, userId), eq(photosAccounts.configId, configId)));
|
||||
}
|
||||
|
||||
@@ -47,3 +47,66 @@ export const photosConfig = pgTable(
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
);
|
||||
|
||||
// ── Which Officer account owns which Immich account ──
|
||||
//
|
||||
// The same shape as `jellyfin_accounts`, and for the same reason: Officer's users and Immich's are two
|
||||
// populations that overlap, and this records where they meet. Without it a member has a permission grant,
|
||||
// a dock tile, and no credential — which reads as a permissions fault and is not one.
|
||||
//
|
||||
// ── Matching is on EMAIL, not a username ──
|
||||
//
|
||||
// This is the one real difference from the Jellyfin side, and it is a simplification. Immich identifies an
|
||||
// account by email and enforces uniqueness on it, while Jellyfin has usernames and a case-folded
|
||||
// `NormalizedUsername` index to match against. Officer's `users.email` is therefore the same key on both
|
||||
// sides, so pairing is an exact lookup rather than a convention. `normalizedEmail` is lowercased, because
|
||||
// Immich lowercases what it stores (`ZodEmail` with a transform) and a mixed-case address would otherwise
|
||||
// fail to match the account it just created.
|
||||
//
|
||||
// ── `adopted` earns its column here too ──
|
||||
//
|
||||
// False means Officer created the account and may remove it. True means it pre-existed and was merely
|
||||
// linked — on this server `conceiba@gmail.com` and `fedrasantamaria@gmail.com` both had Immich accounts,
|
||||
// with photographs in them, before Officer knew who they were.
|
||||
export const photosAccounts = pgTable(
|
||||
'photos_accounts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** Which registry row this account lives on. Immich users are per-instance. */
|
||||
configId: integer('config_id')
|
||||
.notNull()
|
||||
.references(() => photosConfig.id, { onDelete: 'cascade' }),
|
||||
/** Immich's own user id — a UUID. The only handle its admin API accepts. */
|
||||
immichUserId: text('immich_user_id').notNull(),
|
||||
/** As Immich holds it, for display. */
|
||||
immichEmail: text('immich_email').notNull(),
|
||||
/** Lowercased, mirroring what Immich stores. What matching compares. */
|
||||
normalizedEmail: text('normalized_email').notNull(),
|
||||
/** False = Officer created it and may remove it. True = it pre-existed and was linked. */
|
||||
adopted: boolean('adopted').notNull().default(false),
|
||||
/**
|
||||
* That person's OWN Immich API key, encrypted — the credential their Officer session reads the library
|
||||
* with.
|
||||
*
|
||||
* Nullable, and null is a real state rather than an error: minting requires a session, a session
|
||||
* requires a password, and a password is not always ours to set. Null resolves to the 503 the UI
|
||||
* already knows how to explain, never to the owner's key.
|
||||
*
|
||||
* An Immich key survives a later password change — `UserAdminService.update` hashes and writes the
|
||||
* password and touches nothing else, verified against the running 3.1.0 image. That is what makes it
|
||||
* safe to hand somebody a temporary password for their own mobile app without this going stale.
|
||||
*/
|
||||
apiKey: text('api_key'), // encrypted
|
||||
linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One Immich account per Officer user per instance. uniqueIndex, not unique().on() — drizzle-kit
|
||||
// re-creates named composite CONSTRAINTS on every push. See src/databases/CLAUDE.md.
|
||||
uniqueIndex('uq_photos_accounts_user_config').on(t.userId, t.configId),
|
||||
// And one Officer user per Immich account: two people cannot share a library.
|
||||
uniqueIndex('uq_photos_accounts_config_immich_user').on(t.configId, t.immichUserId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -29,6 +29,14 @@ export const manifest: PluginManifest = {
|
||||
icon: 'Images',
|
||||
color: '#10b981',
|
||||
|
||||
// Immich accounts are created one per Officer account. Declared here so the owner sees it on the install
|
||||
// screen beside the permissions — "this plugin will be told who your users are" belongs where a person
|
||||
// decides, not in a log afterwards.
|
||||
//
|
||||
// What it turns on: the sidecar receives `users:list` when it registers and whenever an account is
|
||||
// created or removed. See src/servers/plugins/user-provisioning.ts and plugins/USER_PROVISIONING.md.
|
||||
userProvisioning: true,
|
||||
|
||||
permissions: [
|
||||
{
|
||||
key: 'photos',
|
||||
|
||||
+69
-1
@@ -3,12 +3,15 @@ import {
|
||||
createPhotosAccount,
|
||||
deletePhotosAccount,
|
||||
getPhotosCredentials,
|
||||
listPhotosAccountLinks,
|
||||
listPhotosAccounts,
|
||||
recordPhotosProbe,
|
||||
setActivePhotosAccount,
|
||||
updatePhotosAccount,
|
||||
} from '../db/queries';
|
||||
import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream';
|
||||
import { backfillAccountKeys, runProvisioning } from './provision-run';
|
||||
import { reissuePassword } from './provision';
|
||||
|
||||
// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server.
|
||||
//
|
||||
@@ -152,13 +155,68 @@ async function addAccount(req: Request, userId: number): Promise<Response> {
|
||||
try {
|
||||
const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate });
|
||||
invalidateConfig(userId);
|
||||
return Response.json({ account, user: result.user });
|
||||
|
||||
// ── Provision, if this connection can ──
|
||||
//
|
||||
// The trigger is registering an ADMIN connection, not installing the plugin: at install time there is
|
||||
// no credential, so there is nothing to provision with. Awaited, so the response says what happened —
|
||||
// and, importantly, carries the temporary passwords, which exist nowhere else.
|
||||
const provisioning = await runProvisioning({ cfg: { base: url, key: apiKey }, configId: account.id });
|
||||
return Response.json({ account, user: result.user, provisioning });
|
||||
} catch (err) {
|
||||
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh one-time password for one person, so they can set up Immich's own app.
|
||||
*
|
||||
* Returned here and nowhere else. Not logged, not stored, and not readable back — asking again issues a
|
||||
* new one and invalidates the last, which is the correct behaviour for a credential whose whole life is
|
||||
* the walk from this reply to the person it belongs to.
|
||||
*/
|
||||
async function handover(req: Request, userId: number, id: number): Promise<Response> {
|
||||
const creds = await getPhotosCredentials(userId, id);
|
||||
if (!creds) return bad('no such account', 404);
|
||||
|
||||
const body = ((await req.json().catch(() => null)) as { userId?: unknown } | null) ?? {};
|
||||
const target = Number(body.userId);
|
||||
if (!Number.isInteger(target) || target <= 0) return bad('userId is required');
|
||||
|
||||
const links = await listPhotosAccountLinks(id);
|
||||
const account = links.find((l) => l.userId === target);
|
||||
if (!account) return bad('that account is not provisioned on this instance', 404);
|
||||
|
||||
const temporaryPassword = await reissuePassword(
|
||||
{ base: normalizeBase(creds.url), key: creds.apiKey },
|
||||
{ immichUserId: account.immichUserId, email: account.immichEmail },
|
||||
);
|
||||
return Response.json({ email: account.immichEmail, temporaryPassword });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run provisioning against an instance already registered, and mint whatever keys are missing.
|
||||
*
|
||||
* The repair action, and the one that carries hand-over passwords back. `addAccount` does this once, at
|
||||
* connect; this is the same work without needing the owner to re-enter a credential.
|
||||
*
|
||||
* Owner-only WITHOUT a permission rule, and worth saying out loud: every `/_config` route resolves its
|
||||
* account through `getPhotosCredentials(userId, id)`, scoped to the caller's own rows. A member holding
|
||||
* `photos` at write reaches this handler and gets a 404, because they have no registry of their own — the
|
||||
* key they read the library with lives on their link row, not here.
|
||||
*/
|
||||
async function reprovision(userId: number, id: number): Promise<Response> {
|
||||
const creds = await getPhotosCredentials(userId, id);
|
||||
if (!creds) return bad('no such account', 404);
|
||||
|
||||
const cfg = { base: normalizeBase(creds.url), key: creds.apiKey };
|
||||
const provisioning = await runProvisioning({ cfg, configId: id });
|
||||
const keys = await backfillAccountKeys({ cfg, configId: id });
|
||||
|
||||
return Response.json({ provisioning, keys });
|
||||
}
|
||||
|
||||
/** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */
|
||||
async function editAccount(req: Request, userId: number, id: number): Promise<Response> {
|
||||
const body = await readBody(req);
|
||||
@@ -213,6 +271,16 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s
|
||||
const id = Number(rawId);
|
||||
if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);
|
||||
|
||||
if (action === 'provision') {
|
||||
if (req.method !== 'POST') return bad('method not allowed', 405);
|
||||
return reprovision(userId, id);
|
||||
}
|
||||
|
||||
if (action === 'handover') {
|
||||
if (req.method !== 'POST') return bad('method not allowed', 405);
|
||||
return handover(req, userId, id);
|
||||
}
|
||||
|
||||
if (action === 'activate') {
|
||||
if (req.method !== 'POST') return bad('method not allowed', 405);
|
||||
const account = await setActivePhotosAccount(userId, id);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { handleConfigRoute, noteProbe, probe } from './config';
|
||||
import { handleLockedRoute } from './locked';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { getConfig } from './upstream';
|
||||
import { setProvisionableUsers } from './user-list';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
|
||||
@@ -27,6 +28,9 @@ import { API_URL } from '@@/officer-url.mjs';
|
||||
// key and is never stored. Validated against the instance, then stored encrypted
|
||||
// PATCH /_config/:id same fields, all optional — no credential keeps the stored key
|
||||
// POST /_config/:id/activate switch to that account
|
||||
// POST /_config/:id/provision create/link an Immich account per Officer user and mint each their own
|
||||
// key. Replies with a one-time password per person for THEIR Immich app —
|
||||
// returned here and nowhere else, never logged, never stored
|
||||
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
|
||||
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
|
||||
//
|
||||
@@ -146,6 +150,12 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
// The platform's account list, pushed because this plugin's manifest declares `userProvisioning`.
|
||||
// Arrives on registration and again on every account create or delete. No reply — it is a statement,
|
||||
// not a question.
|
||||
case 'users:list':
|
||||
setProvisionableUsers(cmd.users);
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { linkPhotosAccount, listPhotosAccountLinks, setPhotosAccountKey } from '../db/queries';
|
||||
import { establishKey, provision, type ProvisionOutcome } from './provision';
|
||||
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
|
||||
import { invalidateConfig, type UpstreamTarget } from './upstream';
|
||||
|
||||
// The one place that turns "an admin just connected" into Immich accounts and stored keys.
|
||||
//
|
||||
// Sits between `addAccount` and `provision()` so neither has to know about the other: `provision` talks to
|
||||
// Immich and returns what it did, this writes it down, and the caller only has to say when.
|
||||
//
|
||||
// ── Why it never throws ──
|
||||
//
|
||||
// A failure here must not fail the connection. Registering the instance is the thing the owner asked for
|
||||
// and it has already succeeded by the time this runs — losing it because provisioning went wrong would
|
||||
// leave them with no library AND no accounts, which is strictly worse than the accounts alone.
|
||||
//
|
||||
// ── Temporary passwords come back to the caller ──
|
||||
//
|
||||
// `handover` is the half that has to reach a person. It is returned, never logged, and never stored: see
|
||||
// the header of provision.ts for why Immich gets a hand-over password where Jellyfin gets none.
|
||||
|
||||
export type Handover = { userId: number; email: string; temporaryPassword: string };
|
||||
|
||||
export type ProvisioningReport =
|
||||
| { ran: false; reason: string }
|
||||
| {
|
||||
ran: true;
|
||||
linked: number;
|
||||
created: number;
|
||||
untouched: number;
|
||||
failed: { email: string; error: string }[];
|
||||
log: string[];
|
||||
/** One entry per person who now has a key. Carries a live credential — do not log it. */
|
||||
handover: Handover[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Record one person's pairing and give them a key.
|
||||
*
|
||||
* Written one at a time rather than in a batch: a key minted upstream whose row fails to write is the bad
|
||||
* case, because it leaves an Immich API key nothing in Officer knows about. Recording each as soon as it is
|
||||
* known keeps that window as small as it can be.
|
||||
*/
|
||||
async function linkAndKey(params: {
|
||||
cfg: UpstreamTarget;
|
||||
configId: number;
|
||||
userId: number;
|
||||
immichUserId: string;
|
||||
email: string;
|
||||
adopted: boolean;
|
||||
}): Promise<Handover> {
|
||||
const { cfg, configId, userId, immichUserId, email, adopted } = params;
|
||||
|
||||
await linkPhotosAccount({ userId, configId, immichUserId, immichEmail: email, adopted });
|
||||
const { apiKey, temporaryPassword } = await establishKey(cfg, { immichUserId, email });
|
||||
await setPhotosAccountKey({ userId, configId, apiKey });
|
||||
// Their cached config says "nothing configured" and would hold that for up to a minute.
|
||||
invalidateConfig(userId);
|
||||
|
||||
return { userId, email, temporaryPassword };
|
||||
}
|
||||
|
||||
export async function runProvisioning(params: {
|
||||
cfg: UpstreamTarget;
|
||||
configId: number;
|
||||
}): Promise<ProvisioningReport> {
|
||||
const { cfg, configId } = params;
|
||||
|
||||
// Never told, versus told there is nobody. The first means the push has not arrived or this plugin is
|
||||
// not entitled to it; the second is a server with only an owner.
|
||||
if (!hasProvisionableUsers()) {
|
||||
return { ran: false, reason: 'the platform has not sent the account list yet' };
|
||||
}
|
||||
|
||||
const users = getProvisionableUsers();
|
||||
if (users.length === 0) return { ran: false, reason: 'no accounts to provision — this server has only an owner' };
|
||||
|
||||
try {
|
||||
const existing = await listPhotosAccountLinks(configId);
|
||||
const outcome: ProvisionOutcome = await provision({
|
||||
cfg,
|
||||
users,
|
||||
existing: existing.map((e) => ({ userId: e.userId, immichUserId: e.immichUserId })),
|
||||
});
|
||||
|
||||
const handover: Handover[] = [];
|
||||
const failed = [...outcome.failed];
|
||||
|
||||
for (const entry of [...outcome.linked, ...outcome.created]) {
|
||||
const adopted = outcome.created.every((c) => c.userId !== entry.userId);
|
||||
try {
|
||||
handover.push(await linkAndKey({ cfg, configId, adopted, ...entry }));
|
||||
} catch (error) {
|
||||
failed.push({ email: entry.email, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of outcome.log) console.log(`[photos:provision] ${line}`);
|
||||
for (const f of failed) console.error(`[photos:provision] ${f.email}: ${f.error}`);
|
||||
|
||||
return {
|
||||
ran: true,
|
||||
linked: outcome.linked.length,
|
||||
created: outcome.created.length,
|
||||
untouched: outcome.untouched.length,
|
||||
failed,
|
||||
log: outcome.log,
|
||||
handover,
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
console.error('[photos:provision] failed:', reason);
|
||||
return { ran: false, reason };
|
||||
}
|
||||
}
|
||||
|
||||
export type BackfillReport = {
|
||||
minted: Handover[];
|
||||
/** Already had a key. Reported rather than silent, so a re-run visibly does nothing. */
|
||||
alreadyKeyed: string[];
|
||||
failed: { email: string; error: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Give a key to every linked account that has none.
|
||||
*
|
||||
* The repair path, for rows recorded before the key column existed or whose minting failed. Cheap when
|
||||
* there is nothing to do: an account that already has a key costs no upstream call, so this is safe to run
|
||||
* on every re-provision.
|
||||
*
|
||||
* Unlike the Jellyfin equivalent there is no `includeAdopted` switch, and the difference is the
|
||||
* must-change-password flag. There, minting for an adopted account destroys the person's own access,
|
||||
* because the new password is discarded and Jellyfin cannot demand a change. Here they get the password
|
||||
* back and are made to replace it, so an adopted account ends the run with MORE access than it started —
|
||||
* their library, through Officer, plus their own app once they sign in. There is nothing to opt into.
|
||||
*/
|
||||
export async function backfillAccountKeys(params: {
|
||||
cfg: UpstreamTarget;
|
||||
configId: number;
|
||||
}): Promise<BackfillReport> {
|
||||
const { cfg, configId } = params;
|
||||
const report: BackfillReport = { minted: [], alreadyKeyed: [], failed: [] };
|
||||
|
||||
for (const account of await listPhotosAccountLinks(configId)) {
|
||||
if (account.hasKey) {
|
||||
report.alreadyKeyed.push(account.immichEmail);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { apiKey, temporaryPassword } = await establishKey(cfg, {
|
||||
immichUserId: account.immichUserId,
|
||||
email: account.immichEmail,
|
||||
});
|
||||
await setPhotosAccountKey({ userId: account.userId, configId, apiKey });
|
||||
invalidateConfig(account.userId);
|
||||
report.minted.push({ userId: account.userId, email: account.immichEmail, temporaryPassword });
|
||||
console.log(`[photos:provision] minted a key for ${account.immichEmail}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
report.failed.push({ email: account.immichEmail, error: message });
|
||||
console.error(`[photos:provision] ${account.immichEmail}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { ProvisionableUser } from 'officerdb';
|
||||
import { callUpstream, mintApiKey, type UpstreamTarget } from './upstream';
|
||||
import { describePlan, normalize, planReconcile, type ImmichUser, type ReconcilePlan } from './reconcile';
|
||||
|
||||
// Carrying out the plan. `reconcile.ts` decides; this does, and does nothing else.
|
||||
//
|
||||
// ── Where this deliberately differs from the Jellyfin plugin ──
|
||||
//
|
||||
// Jellyfin's provisioning generates a password, spends it once on a sign-in, and discards it so that
|
||||
// nobody ever learns it. That is right THERE for a reason it states: Jellyfin has no must-change-password
|
||||
// flag, so a password handed to somebody stays valid indefinitely, and the deployment plan is that
|
||||
// Jellyfin's own web UI is never exposed at all.
|
||||
//
|
||||
// Neither half of that holds for Immich:
|
||||
//
|
||||
// 1. **Immich HAS the flag.** `shouldChangePassword` is on both the create and the update DTO. A handed
|
||||
// -over password can be made single-use in the only sense that matters — the account forces a change
|
||||
// on first sign-in.
|
||||
// 2. **Immich's own app is the point.** Background phone backup runs in Immich's mobile app, not in
|
||||
// Officer, and it needs a password. An account whose password nobody knows can be browsed through
|
||||
// Officer and can never back a phone up again. For a photo library that is most of the value.
|
||||
//
|
||||
// So this mints the API key AND reports the temporary password, once, in the response. The owner passes it
|
||||
// on; the person signs into the Immich app and is immediately forced to set their own. Officer's key is
|
||||
// unaffected by that change — `UserAdminService.update` hashes the new password and touches nothing else,
|
||||
// so API keys and sessions both survive it (read from the running 3.1.0 image, not assumed).
|
||||
//
|
||||
// The password is returned and never logged. It is in exactly one place, the reply to the request that
|
||||
// caused it, and it stops being useful the moment its owner signs in.
|
||||
|
||||
/** Long enough to be worthless to guess in the window before it is changed. */
|
||||
const generatePassword = (): string => randomBytes(24).toString('base64url');
|
||||
|
||||
/**
|
||||
* The storage quota given to an account Officer creates. `null` is Immich's "unlimited".
|
||||
*
|
||||
* Unlimited matches every account already on this instance, so a provisioned member is indistinguishable
|
||||
* from one made by hand — a cap only on Officer-made accounts would be a rule to remember rather than a
|
||||
* policy. Change this one constant to enforce one; Immich applies it server-side and the owner can still
|
||||
* raise it per person from Immich's own admin screen.
|
||||
*/
|
||||
const QUOTA_BYTES: number | null = null;
|
||||
|
||||
export type ProvisionOutcome = {
|
||||
linked: { userId: number; immichUserId: string; email: string }[];
|
||||
created: { userId: number; immichUserId: string; email: string }[];
|
||||
/**
|
||||
* Per person: the key to store, and the temporary password to hand over. Keyed by Officer user id.
|
||||
*
|
||||
* Separate from `linked`/`created` because it is the half that must not be logged, and keeping it in its
|
||||
* own structure makes that visible at every call site rather than depending on nobody dumping the outcome.
|
||||
*/
|
||||
credentials: { userId: number; email: string; apiKey: string; temporaryPassword: string }[];
|
||||
/** Left alone, reported so the owner can see accounts Officer does not manage. */
|
||||
untouched: ImmichUser[];
|
||||
/** The account the API key belongs to — the owner's own. */
|
||||
self: ImmichUser | null;
|
||||
failed: { email: string; error: string }[];
|
||||
/** One line per decision, for the log. Never contains a credential. */
|
||||
log: string[];
|
||||
};
|
||||
|
||||
/** Everything Immich knows about, as `GET /api/admin/users` reports it. */
|
||||
export async function listImmichUsers(cfg: UpstreamTarget): Promise<ImmichUser[]> {
|
||||
const res = await callUpstream(cfg, { path: '/api/admin/users' });
|
||||
if (!res.ok) throw new Error(`GET /api/admin/users → ${res.status}`);
|
||||
const body = (await res.json()) as { id: string; email: string; name?: string }[];
|
||||
return body.map((u) => ({ id: u.id, email: u.email, name: u.name ?? '' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the configured API key authenticates as.
|
||||
*
|
||||
* Read rather than assumed, and the whole reason the owner's own Immich account does not end up reported
|
||||
* as belonging to nobody.
|
||||
*/
|
||||
export async function whoAmI(cfg: UpstreamTarget): Promise<string | null> {
|
||||
const res = await callUpstream(cfg, { path: '/api/users/me' });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { id?: unknown };
|
||||
return typeof body.id === 'string' ? body.id : null;
|
||||
}
|
||||
|
||||
/** Admin-set a password on an account. Empty-bodied 200/204 either way; only the status matters. */
|
||||
async function setPassword(cfg: UpstreamTarget, immichUserId: string, password: string): Promise<void> {
|
||||
const res = await callUpstream(cfg, {
|
||||
method: 'PUT',
|
||||
path: `/api/admin/users/${immichUserId}`,
|
||||
contentType: 'application/json',
|
||||
// `shouldChangePassword: false` for THIS write: the sign-in that mints the key happens next, and the
|
||||
// flag is raised afterwards. Setting it here would be asking the account to change a password in the
|
||||
// same breath as using it.
|
||||
body: JSON.stringify({ password, shouldChangePassword: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`PUT /api/admin/users/${immichUserId} → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Raise the must-change flag, once the key is safely in hand. */
|
||||
async function requirePasswordChange(cfg: UpstreamTarget, immichUserId: string): Promise<void> {
|
||||
const res = await callUpstream(cfg, {
|
||||
method: 'PUT',
|
||||
path: `/api/admin/users/${immichUserId}`,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ shouldChangePassword: true }),
|
||||
});
|
||||
// Not fatal. The key exists and the library is reachable; all that is lost is the prompt, and failing the
|
||||
// whole provisioning over it would throw away a credential that already works.
|
||||
if (!res.ok) {
|
||||
console.error(`[photos:provision] could not set shouldChangePassword on ${immichUserId} → ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give one account a fresh password, spend it on an API key, then require it to be changed.
|
||||
*
|
||||
* Shared by the create and the adopt paths, which differ only in whether the account existed a moment ago.
|
||||
* Both end in the same place: Officer holds a durable key, and one temporary password is on its way to a
|
||||
* person who will be made to replace it.
|
||||
*/
|
||||
async function establishKey(
|
||||
cfg: UpstreamTarget,
|
||||
account: { immichUserId: string; email: string },
|
||||
): Promise<{ apiKey: string; temporaryPassword: string }> {
|
||||
const temporaryPassword = generatePassword();
|
||||
await setPassword(cfg, account.immichUserId, temporaryPassword);
|
||||
const apiKey = await mintApiKey(cfg.base, account.email, temporaryPassword);
|
||||
await requirePasswordChange(cfg, account.immichUserId);
|
||||
return { apiKey, temporaryPassword };
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a fresh temporary password for one account, without touching its API key.
|
||||
*
|
||||
* "This person is setting their phone up" — which is a thing that happens long after provisioning, and
|
||||
* more than once. The alternative would be storing the password from the provisioning run so it could be
|
||||
* read back later, and a stored password is exactly what this design exists to avoid: it would sit in
|
||||
* Postgres indefinitely, still valid, for an account whose owner may already have chosen their own.
|
||||
*
|
||||
* Safe to call at any time. An Immich API key is independent of the password, so Officer's access is
|
||||
* unaffected — the person simply gets a new way in, and is made to replace it on arrival.
|
||||
*
|
||||
* One call rather than two, unlike `establishKey`: nothing signs in with this password here, so the flag
|
||||
* can go up in the same write that sets it.
|
||||
*/
|
||||
export async function reissuePassword(
|
||||
cfg: UpstreamTarget,
|
||||
account: { immichUserId: string; email: string },
|
||||
): Promise<string> {
|
||||
const temporaryPassword = generatePassword();
|
||||
const res = await callUpstream(cfg, {
|
||||
method: 'PUT',
|
||||
path: `/api/admin/users/${account.immichUserId}`,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ password: temporaryPassword, shouldChangePassword: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`PUT /api/admin/users/${account.immichUserId} → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
return temporaryPassword;
|
||||
}
|
||||
|
||||
/** Create one account, then give it a key. */
|
||||
async function createOne(cfg: UpstreamTarget, user: ProvisionableUser): Promise<{ immichUserId: string; email: string }> {
|
||||
const res = await callUpstream(cfg, {
|
||||
method: 'POST',
|
||||
path: '/api/admin/users',
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
email: user.email,
|
||||
// Set here and replaced moments later by `establishKey`. Immich requires one on create and this
|
||||
// value never leaves the function, so there is nothing to hand over or to store.
|
||||
password: generatePassword(),
|
||||
name: user.name || user.username || user.email,
|
||||
quotaSizeInBytes: QUOTA_BYTES,
|
||||
shouldChangePassword: false,
|
||||
// No email. Immich would try SMTP it may not have, and what it sends is a password — which is
|
||||
// precisely the thing this flow keeps out of a mailbox.
|
||||
notify: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`POST /api/admin/users → ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const created = (await res.json()) as { id?: string; email?: string };
|
||||
if (!created.id) throw new Error('POST /api/admin/users returned no id');
|
||||
return { immichUserId: created.id, email: created.email ?? user.email };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile Officer's accounts against Immich's, and carry out what is missing.
|
||||
*
|
||||
* Idempotent: run it twice and the second does nothing, because `existing` grows and the plan shrinks.
|
||||
* Never deletes anything.
|
||||
*/
|
||||
export async function provision(params: {
|
||||
cfg: UpstreamTarget;
|
||||
users: ProvisionableUser[];
|
||||
existing: { userId: number; immichUserId: string }[];
|
||||
}): Promise<ProvisionOutcome> {
|
||||
const { cfg, users, existing } = params;
|
||||
|
||||
const [immichUsers, adminImmichUserId] = await Promise.all([listImmichUsers(cfg), whoAmI(cfg)]);
|
||||
const plan: ReconcilePlan = planReconcile({ users, immichUsers, existing, adminImmichUserId });
|
||||
|
||||
const outcome: ProvisionOutcome = {
|
||||
linked: [],
|
||||
created: [],
|
||||
credentials: [],
|
||||
untouched: plan.leave,
|
||||
self: plan.self,
|
||||
failed: [],
|
||||
log: describePlan(plan),
|
||||
};
|
||||
|
||||
// Adoptions first: recording a pairing touches nothing upstream, so it cannot fail halfway and leave
|
||||
// Immich ahead of the mapping.
|
||||
for (const { user, immich } of plan.link) {
|
||||
outcome.linked.push({ userId: user.id, immichUserId: immich.id, email: immich.email });
|
||||
}
|
||||
|
||||
for (const user of plan.create) {
|
||||
try {
|
||||
const made = await createOne(cfg, user);
|
||||
outcome.created.push({ userId: user.id, immichUserId: made.immichUserId, email: made.email });
|
||||
} catch (error) {
|
||||
// One person failing must not stop the rest. A partially provisioned instance is recoverable by
|
||||
// running again; a run that aborts on the first error leaves everyone after them unprovisioned.
|
||||
outcome.failed.push({
|
||||
email: user.email ?? String(user.id),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export { establishKey, normalize };
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { normalize, planReconcile, type ImmichUser } from './reconcile';
|
||||
import type { ProvisionableUser } from 'officerdb';
|
||||
|
||||
const user = (id: number, username: string, email: string): ProvisionableUser =>
|
||||
({ id, username, email, name: null, role: 'Member' }) as ProvisionableUser;
|
||||
|
||||
// The real populations on alpha as of 2026-08-21, read from `GET /api/admin/users` and from
|
||||
// `listProvisionableUsers()`. Real names rather than invented ones because every interesting case is
|
||||
// already present here, and because a test built on the real overlap fails when the real overlap changes.
|
||||
const PLATFORM = [
|
||||
user(24, 'conceiba', 'conceiba@gmail.com'),
|
||||
user(25, 'guipinto', 'tatudoemcampo@gmail.com'),
|
||||
user(26, 'fedra', 'fedrasantamaria@gmail.com'),
|
||||
user(27, 'diogo', 'dingoshf@gmail.com'),
|
||||
];
|
||||
|
||||
const ADMIN_ID = '0e0b05c7';
|
||||
|
||||
const IMMICH: ImmichUser[] = [
|
||||
{ id: ADMIN_ID, email: 'pastilhas@officer.dev', name: 'André Padez' }, // the connection's own account
|
||||
{ id: 'aaaa1111', email: 'conceiba@gmail.com', name: 'Bruno Conceição' },
|
||||
{ id: 'bbbb2222', email: 'fedrasantamaria@gmail.com', name: 'Fedra' },
|
||||
{ id: 'cccc3333', email: 'jrg.conceicao@gmail.com', name: 'Jorge Conceição' }, // no Officer account
|
||||
];
|
||||
|
||||
describe('the live instance, first run', () => {
|
||||
const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing: [], adminImmichUserId: ADMIN_ID });
|
||||
|
||||
test('links the two whose email already exists', () => {
|
||||
expect(plan.link.map((l) => l.user.email).sort()).toEqual(['conceiba@gmail.com', 'fedrasantamaria@gmail.com']);
|
||||
expect(plan.link.find((l) => l.user.id === 24)!.immich.id).toBe('aaaa1111');
|
||||
});
|
||||
|
||||
test('creates the two with no Immich account', () => {
|
||||
expect(plan.create.map((u) => u.email).sort()).toEqual(['dingoshf@gmail.com', 'tatudoemcampo@gmail.com']);
|
||||
});
|
||||
|
||||
test('leaves the account nobody on Officer owns, and never the admin', () => {
|
||||
expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']);
|
||||
});
|
||||
|
||||
// The bug the Jellyfin plugin shipped and had to fix in 591c0da: the owner is excluded from the
|
||||
// provisionable list on purpose, which made their own account read as belonging to nobody.
|
||||
test('reports the admin as itself rather than as unclaimed', () => {
|
||||
expect(plan.self?.email).toBe('pastilhas@officer.dev');
|
||||
expect(plan.leave.map((u) => u.email)).not.toContain('pastilhas@officer.dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a second run', () => {
|
||||
const existing = [
|
||||
{ userId: 24, immichUserId: 'aaaa1111' },
|
||||
{ userId: 26, immichUserId: 'bbbb2222' },
|
||||
];
|
||||
const plan = planReconcile({ users: PLATFORM, immichUsers: IMMICH, existing, adminImmichUserId: ADMIN_ID });
|
||||
|
||||
test('re-decides nothing that is already recorded', () => {
|
||||
expect(plan.link).toHaveLength(0);
|
||||
expect(plan.unchanged).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('still creates the two that are genuinely missing', () => {
|
||||
expect(plan.create.map((u) => u.id).sort()).toEqual([25, 27]);
|
||||
});
|
||||
|
||||
test('does not report a linked account as unclaimed', () => {
|
||||
expect(plan.leave.map((u) => u.email)).toEqual(['jrg.conceicao@gmail.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matching', () => {
|
||||
test('is case- and whitespace-insensitive, because Immich lowercases what it stores', () => {
|
||||
const plan = planReconcile({
|
||||
users: [user(30, 'shouty', ' FEDRASANTAMARIA@GMAIL.COM ')],
|
||||
immichUsers: IMMICH,
|
||||
existing: [],
|
||||
adminImmichUserId: ADMIN_ID,
|
||||
});
|
||||
expect(plan.create).toHaveLength(0);
|
||||
expect(plan.link[0]!.immich.id).toBe('bbbb2222');
|
||||
});
|
||||
|
||||
test('normalize agrees with what Immich does to an address', () => {
|
||||
expect(normalize(' Fedra@Example.COM ')).toBe('fedra@example.com');
|
||||
});
|
||||
|
||||
test('skips an Officer account with no email rather than inventing one', () => {
|
||||
const plan = planReconcile({
|
||||
users: [{ id: 31, username: 'nomail', email: '', name: null, role: 'Member' } as ProvisionableUser],
|
||||
immichUsers: IMMICH,
|
||||
existing: [],
|
||||
adminImmichUserId: ADMIN_ID,
|
||||
});
|
||||
expect(plan.create).toHaveLength(0);
|
||||
expect(plan.link).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('two Officer accounts cannot both claim one Immich account', () => {
|
||||
const plan = planReconcile({
|
||||
users: [user(40, 'a', 'fedrasantamaria@gmail.com'), user(41, 'b', 'FedraSantamaria@gmail.com')],
|
||||
immichUsers: IMMICH,
|
||||
existing: [],
|
||||
adminImmichUserId: ADMIN_ID,
|
||||
});
|
||||
expect(plan.link).toHaveLength(1);
|
||||
// The loser is not silently created either — that would duplicate an address Immich would refuse.
|
||||
expect(plan.create).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an account deleted in Immich behind Officer’s back', () => {
|
||||
const plan = planReconcile({
|
||||
users: PLATFORM,
|
||||
immichUsers: IMMICH,
|
||||
existing: [{ userId: 25, immichUserId: 'gone9999' }],
|
||||
adminImmichUserId: ADMIN_ID,
|
||||
});
|
||||
|
||||
test('is reported as broken rather than quietly recreated', () => {
|
||||
expect(plan.brokenLinks).toEqual([{ userId: 25, immichUserId: 'gone9999' }]);
|
||||
expect(plan.create.map((u) => u.id)).not.toContain(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ProvisionableUser } from 'officerdb';
|
||||
|
||||
// Deciding what to do about each account, with no side effects.
|
||||
//
|
||||
// Separated from the doing on purpose. The execution talks to a live Immich and cannot be rehearsed; the
|
||||
// decision is where every mistake that matters lives, and it can be checked against the real addresses on
|
||||
// the real instance without creating anything.
|
||||
//
|
||||
// ── The outcomes, and why none of them is "delete" ──
|
||||
//
|
||||
// LINK an Officer user whose email already exists on Immich. Record the pairing; create nothing.
|
||||
// CREATE an Officer user with no Immich account. Make one.
|
||||
// LEAVE an Immich account with no Officer user. Do nothing at all, and say so.
|
||||
//
|
||||
// Deleting an Immich account because nobody holds a matching Officer login would destroy somebody's
|
||||
// photographs on the strength of an absence — and an absence is exactly what an incomplete list looks
|
||||
// like. So they are reported, never touched, and the report is the feature.
|
||||
//
|
||||
// ── Matching is on email, lowercased ──
|
||||
//
|
||||
// Immich identifies an account by email and stores it lowercased (`ZodEmail` with a transform on both the
|
||||
// create and update DTOs). Officer's `users.email` is the same key, so pairing is exact rather than
|
||||
// heuristic — a real simplification over the Jellyfin side, which has to match usernames through Jellyfin's
|
||||
// uppercased `NormalizedUsername` index and cope with two Officer names colliding into one.
|
||||
//
|
||||
// ── The admin's own account is not "not an Officer account" ──
|
||||
//
|
||||
// `listProvisionableUsers` excludes the owner deliberately: they hold the admin account this whole flow
|
||||
// authenticates as, and provisioning them would be a second admin nobody asked for. But that makes the
|
||||
// owner's own Immich account look like it belongs to nobody. It is identified here from the key's own
|
||||
// identity rather than matched by name, and reported as itself. The Jellyfin plugin shipped this bug and
|
||||
// had to fix it in `591c0da`; there is no reason to ship it twice.
|
||||
|
||||
/** An Immich account as `GET /api/admin/users` reports it. Only the fields a decision needs. */
|
||||
export type ImmichUser = { id: string; email: string; name: string };
|
||||
|
||||
/** An existing pairing, so a second run does not re-decide what is already recorded. */
|
||||
export type ExistingLink = { userId: number; immichUserId: string };
|
||||
|
||||
export type ReconcilePlan = {
|
||||
/** Officer users whose Immich account already exists — record the pairing, create nothing. */
|
||||
link: { user: ProvisionableUser; immich: ImmichUser }[];
|
||||
/** Officer users with no Immich account. */
|
||||
create: ProvisionableUser[];
|
||||
/** Immich accounts with no Officer user. Reported, never touched. */
|
||||
leave: ImmichUser[];
|
||||
/** The account the API key itself belongs to — the owner. Neither provisioned nor unclaimed. */
|
||||
self: ImmichUser | null;
|
||||
/** Already linked, and still valid on both sides. Nothing to do. */
|
||||
unchanged: ExistingLink[];
|
||||
/**
|
||||
* Links whose Immich account has vanished — deleted in Immich's own UI, most likely.
|
||||
*
|
||||
* Separated from `create` even though the remedy is the same, because they are different events: one is a
|
||||
* new person, the other is someone's account disappearing behind Officer's back.
|
||||
*/
|
||||
brokenLinks: ExistingLink[];
|
||||
};
|
||||
|
||||
/** Immich's own rule, applied on this side so the two agree. */
|
||||
export const normalize = (email: string): string => email.trim().toLowerCase();
|
||||
|
||||
/**
|
||||
* Work out what should happen. Nothing here talks to anything.
|
||||
*
|
||||
* `adminImmichUserId` is who the configured API key authenticates as, read from `/api/users/me` rather
|
||||
* than assumed — it is what keeps the owner out of `leave`.
|
||||
*/
|
||||
export function planReconcile(params: {
|
||||
users: ProvisionableUser[];
|
||||
immichUsers: ImmichUser[];
|
||||
existing: ExistingLink[];
|
||||
adminImmichUserId: string | null;
|
||||
}): ReconcilePlan {
|
||||
const { users, immichUsers, existing, adminImmichUserId } = params;
|
||||
|
||||
const immichByEmail = new Map(immichUsers.map((u) => [normalize(u.email), u]));
|
||||
const immichById = new Map(immichUsers.map((u) => [u.id, u]));
|
||||
const linkedUserIds = new Set(existing.map((e) => e.userId));
|
||||
/** Immich accounts spoken for — pre-existing links, plus anything claimed during this pass. */
|
||||
const claimed = new Set(existing.map((e) => e.immichUserId));
|
||||
|
||||
const plan: ReconcilePlan = {
|
||||
link: [],
|
||||
create: [],
|
||||
leave: [],
|
||||
self: adminImmichUserId ? (immichById.get(adminImmichUserId) ?? null) : null,
|
||||
unchanged: [],
|
||||
brokenLinks: [],
|
||||
};
|
||||
|
||||
for (const link of existing) {
|
||||
(immichById.has(link.immichUserId) ? plan.unchanged : plan.brokenLinks).push(link);
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
if (linkedUserIds.has(user.id)) continue; // already recorded — handled above
|
||||
|
||||
// An Officer account with no email cannot be matched or created: Immich has no other identifier and
|
||||
// requires one on create. Skipped rather than invented from the username, which would claim an address
|
||||
// the person does not own.
|
||||
if (!user.email) continue;
|
||||
|
||||
const match = immichByEmail.get(normalize(user.email));
|
||||
if (match && !claimed.has(match.id)) {
|
||||
plan.link.push({ user, immich: match });
|
||||
claimed.add(match.id);
|
||||
} else if (!match) {
|
||||
plan.create.push(user);
|
||||
}
|
||||
// A match already claimed falls through deliberately: it is neither this user's account to adopt nor
|
||||
// safe to duplicate, and `leave` below reports it for a human to settle.
|
||||
}
|
||||
|
||||
for (const immich of immichUsers) {
|
||||
if (claimed.has(immich.id)) continue;
|
||||
if (immich.id === adminImmichUserId) continue; // the connection's own account, reported as `self`
|
||||
plan.leave.push(immich);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** One line per outcome, for the log and the panel. Says what will happen before it happens. */
|
||||
export function describePlan(plan: ReconcilePlan): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const { user, immich } of plan.link) lines.push(`link ${user.email} → existing Immich "${immich.email}"`);
|
||||
for (const user of plan.create) lines.push(`create ${user.email}`);
|
||||
for (const immich of plan.leave) lines.push(`leave "${immich.email}" alone — no Officer account`);
|
||||
for (const broken of plan.brokenLinks) lines.push(`link for user ${broken.userId} is broken — Immich account is gone`);
|
||||
if (plan.self) lines.push(`"${plan.self.email}" is the account Officer connects as`);
|
||||
if (plan.unchanged.length) lines.push(`${plan.unchanged.length} already linked`);
|
||||
return lines;
|
||||
}
|
||||
+12
-4
@@ -19,7 +19,7 @@
|
||||
// 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.
|
||||
|
||||
import { getActivePhotosCredentials } from '../db/queries';
|
||||
import { getActivePhotosCredentials, getLinkedPhotosCredentials } from '../db/queries';
|
||||
|
||||
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
||||
export type UpstreamTarget = { base: string; key: string };
|
||||
@@ -43,14 +43,22 @@ const TTL_MS = 60_000;
|
||||
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||
|
||||
/**
|
||||
* The owner's SELECTED account, or null when /photos has no account yet — the sidecar then answers 503,
|
||||
* and the UI turns that into the setup form rather than a wall of empty grids.
|
||||
* What THIS account reads Immich with, or null when there is nothing — the sidecar then answers 503, and
|
||||
* the UI turns that into the setup form rather than a wall of empty grids.
|
||||
*
|
||||
* Two sources, in this order:
|
||||
*
|
||||
* 1. **An instance this account registered itself.** The owner's normal case, and also a member who
|
||||
* deliberately connected an Immich of their own. Checked first so that somebody who went to the
|
||||
* trouble of adding one is not overridden by an inherited link.
|
||||
* 2. **A provisioned link on the owner's active instance**, carrying that person's OWN API key. This is
|
||||
* how a member reaches their library without ever holding the owner's credential.
|
||||
*/
|
||||
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
||||
const hit = cache.get(userId);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||
|
||||
const creds = await getActivePhotosCredentials(userId);
|
||||
const creds = (await getActivePhotosCredentials(userId)) ?? (await getLinkedPhotosCredentials(userId));
|
||||
const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : null;
|
||||
cache.set(userId, { cfg, at: Date.now() });
|
||||
return cfg;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ProvisionableUser } from 'officerdb';
|
||||
|
||||
// The account list the platform pushes, held for as long as this process lives.
|
||||
//
|
||||
// Pushed rather than fetched — see src/servers/plugins/user-provisioning.ts. It arrives once when this
|
||||
// sidecar registers, and again whenever an Officer account is created or removed, so it is the current
|
||||
// truth rather than a cache with an expiry: each message REPLACES what was held, never merges.
|
||||
//
|
||||
// In memory on purpose. Persisting it would create a second copy of the platform's own users that could
|
||||
// disagree with it, and the recovery from disagreement would be re-reading the platform anyway. A restart
|
||||
// re-registers, which re-sends.
|
||||
|
||||
let users: ProvisionableUser[] = [];
|
||||
let receivedAt: Date | null = null;
|
||||
|
||||
export function setProvisionableUsers(next: ProvisionableUser[]): void {
|
||||
users = next;
|
||||
receivedAt = new Date();
|
||||
console.log(`[photos] user list: ${next.length} account(s) the platform can provision`);
|
||||
}
|
||||
|
||||
export const getProvisionableUsers = (): ProvisionableUser[] => users;
|
||||
|
||||
/**
|
||||
* Whether the platform has told us anything yet.
|
||||
*
|
||||
* The distinction matters: an EMPTY list means "this server has only an owner", while never having been
|
||||
* told means the plugin is not entitled or the push has not arrived. Provisioning nothing is right in the
|
||||
* first case and wrong in the second, so the caller has to be able to tell them apart.
|
||||
*/
|
||||
export const hasProvisionableUsers = (): boolean => receivedAt !== null;
|
||||
Reference in New Issue
Block a user