Receiving users:list updated an in-memory array and did nothing else, so a person added to Officer next month reached Photos only if somebody remembered to re-run provisioning by hand. The platform already broadcasts on every create and delete, and re-sends on every sidecar registration — nothing was listening. Now it acts on it. A restart is therefore also a reconciliation, which is the cheap way to recover from any push that was missed while the sidecar was down. An automatic run is NOT allowed to key an adopted account. Keying one means resetting its password, because that is the only way Immich issues a key, and for an account that pre-dated Officer that takes away a login somebody is using — most likely in Immich's own app. Doing that unattended, to a person who did not know it was at stake, is not the same act as the owner choosing it: so the account is linked, left keyless, and named in `pending`. The deliberate /provision route still does the whole job. Tested against the case this is for: jrg.conceicao has an Immich library and no Officer account today. When that account exists, the plan adopts the library rather than building a second one beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
import { OWNER_USER_ID } from 'officerdb';
|
|
import {
|
|
getActivePhotosCredentials,
|
|
linkPhotosAccount,
|
|
listPhotosAccountLinks,
|
|
setPhotosAccountKey,
|
|
} from '../db/queries';
|
|
import { establishKey, provision, type ProvisionOutcome } from './provision';
|
|
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
|
|
import { invalidateConfig, normalizeBase, 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[];
|
|
/**
|
|
* Linked, but deliberately left without a key: an account that pre-dated Officer, found by a run
|
|
* that was not allowed to touch its password. They reach Photos once somebody runs the deliberate
|
|
* provision. Named rather than counted, because the owner has to know WHO to go and do.
|
|
*/
|
|
pending: string[];
|
|
};
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
|
|
/**
|
|
* `keyAdopted` decides what happens to an account that existed before Officer did.
|
|
*
|
|
* Keying one means resetting its password, because that is the only way Immich will issue a key. For an
|
|
* account Officer just created that costs nothing — nobody has ever held that password. For an adopted one
|
|
* it takes away a login somebody is using, most likely in Immich's own app, and hands back a replacement
|
|
* that only reaches them if the owner passes it on.
|
|
*
|
|
* So it is TRUE when a person asked for it (the `/provision` route) and FALSE when the trigger was a
|
|
* background event (a new account appearing). An automatic action must not reset a password nobody
|
|
* knew was at stake; the adopted account is linked and named in `pending` instead.
|
|
*/
|
|
export async function runProvisioning(params: {
|
|
cfg: UpstreamTarget;
|
|
configId: number;
|
|
keyAdopted: boolean;
|
|
}): Promise<ProvisioningReport> {
|
|
const { cfg, configId, keyAdopted } = 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 pending: string[] = [];
|
|
const failed = [...outcome.failed];
|
|
|
|
// Created accounts always get a key: their password was set moments ago by this process and has never
|
|
// been anybody's, so spending it costs nothing.
|
|
for (const entry of outcome.created) {
|
|
try {
|
|
handover.push(await linkAndKey({ cfg, configId, adopted: false, ...entry }));
|
|
} catch (error) {
|
|
failed.push({ email: entry.email, error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
// Adopted ones are recorded either way — the pairing is a fact and costs nothing to write. Whether they
|
|
// also get a key is the caller's decision, for the reason on `keyAdopted` above.
|
|
for (const entry of outcome.linked) {
|
|
try {
|
|
if (keyAdopted) {
|
|
handover.push(await linkAndKey({ cfg, configId, adopted: true, ...entry }));
|
|
} else {
|
|
await linkPhotosAccount({
|
|
userId: entry.userId,
|
|
configId,
|
|
immichUserId: entry.immichUserId,
|
|
immichEmail: entry.email,
|
|
adopted: true,
|
|
});
|
|
pending.push(entry.email);
|
|
}
|
|
} 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 email of pending) {
|
|
console.log(`[photos:provision] ${email} is linked but has no key — it had an Immich account already.`);
|
|
console.log(`[photos:provision] run POST /_config/${configId}/provision to give them one.`);
|
|
}
|
|
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,
|
|
pending,
|
|
};
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
console.error('[photos:provision] failed:', reason);
|
|
return { ran: false, reason };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Provision because the ACCOUNT LIST changed, rather than because somebody asked.
|
|
*
|
|
* This is what makes an Officer account created next month reach Photos without anyone remembering that a
|
|
* plugin needed telling. `users:list` arrives on every create and delete — and on every sidecar
|
|
* registration, so a restart is also a reconciliation.
|
|
*
|
|
* Three properties it has to have, because nobody is watching it run:
|
|
*
|
|
* - **Idempotent.** The plan shrinks as links are recorded, so the common case does no upstream writes
|
|
* at all and the run costs two GETs.
|
|
* - **Never destructive.** `keyAdopted: false` — see `runProvisioning`. An account that pre-dated
|
|
* Officer keeps its password; the owner is told it is waiting.
|
|
* - **Quiet about the outcome, not about the plan.** `runProvisioning` logs its plan either way, so a
|
|
* restart prints a few lines describing the world as this plugin sees it. Only the "something
|
|
* changed" summary below is suppressed when nothing did, which is what stops a run that mattered from
|
|
* reading like the dozen that did not.
|
|
*
|
|
* Never throws. It runs detached from the command handler, and a plugin failing to provision must not take
|
|
* down the socket carrying the message.
|
|
*/
|
|
export async function provisionForAccountChange(): Promise<void> {
|
|
try {
|
|
// The instance members inherit is the owner's selected one. A member who registered an Immich of their
|
|
// own is not a provisioning target — that is their connection, not a shared library.
|
|
const creds = await getActivePhotosCredentials(OWNER_USER_ID);
|
|
if (!creds) return; // nothing connected yet; connecting is itself a provisioning trigger
|
|
|
|
const report = await runProvisioning({
|
|
cfg: { base: normalizeBase(creds.url), key: creds.apiKey },
|
|
configId: creds.id,
|
|
keyAdopted: false,
|
|
});
|
|
|
|
if (!report.ran) {
|
|
console.log(`[photos:provision] not run — ${report.reason}`);
|
|
return;
|
|
}
|
|
if (report.created === 0 && report.linked === 0 && report.failed.length === 0) return; // nothing changed
|
|
|
|
console.log(
|
|
`[photos:provision] account list changed: ${report.created} created, ${report.linked} linked, ` +
|
|
`${report.pending.length} awaiting a key`,
|
|
);
|
|
} catch (error) {
|
|
console.error('[photos:provision] automatic run failed:', error instanceof Error ? error.message : error);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|