invoiceshelf: accounts are configured from the ui, not the environment

The same registry photos got: any number of labelled instances stored encrypted in
invoiceshelf_accounts, one selected, switchable from the nav. The token is write-only across
the sidecar boundary — the list has no field that could carry it back — and nothing reads
INVOICESHELF_URL/TOKEN/COMPANY_ID any more, so officer's own process.env no longer holds a
credential only the sidecar can use.

The company is pinned on the account row rather than resolved per request. InvoiceShelf's
`company` header does not error on a wrong or missing value; it silently returns another
company's books. So the choice is made once, at add time, and a token that can act for several
answers 409 with the list instead of guessing.

Both apps also take an email and password now, because neither service makes a key easy to get:
InvoiceShelf 2.4.2 ships no screen that issues tokens at all (POST /auth/login is the only way),
and Immich's is buried in account settings. The sidecar does the exchange — InvoiceShelf mints a
Sanctum token, Immich logs in, creates an all-permissions API key and closes the session again —
and stores only what comes back. The password is never persisted. Pasting a key still works.

Verified against the live instances: InvoiceShelf 2.4.2 and Immich 3.1.0, routes and DTOs read
from the running containers. The two sign-in paths are untested end to end — no second login to
try them with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 17:44:28 +00:00
co-authored by Claude Opus 5
parent 632d5a1c1f
commit f20d4a300e
21 changed files with 1785 additions and 144 deletions
+11
View File
@@ -141,6 +141,17 @@ export {
recordHeadscaleProbe,
} from './queries/headscale';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
export {
listInvoiceshelfAccounts,
getActiveInvoiceshelfCredentials,
getInvoiceshelfCredentials,
createInvoiceshelfAccount,
updateInvoiceshelfAccount,
setActiveInvoiceshelfAccount,
deleteInvoiceshelfAccount,
recordInvoiceshelfProbe,
} from './queries/invoiceshelf';
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf';
export {
listPhotosAccounts,
getActivePhotosCredentials,
@@ -0,0 +1,200 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { invoiceshelfAccounts } from '../schema';
import { encryptSecret, decryptSecret } from '../crypto';
// InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption
// to and from at-rest ciphertext happens here. See ../crypto.ts and ../schema/invoiceshelf.ts.
//
// Two return types, and the split is the safety property:
// InvoiceshelfAccount — safe to serialize to the browser. Has NO token field at all, not even masked.
// InvoiceshelfCredentials — url + decrypted token + company, for the sidecar's own upstream calls.
// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
// response the moment someone forgot to strip it.
export type InvoiceshelfAccount = {
id: number;
label: string;
url: string;
companyId: number | null;
companyName: string | null;
version: string | null;
isActive: boolean;
lastSeenAt: Date | null;
createdAt: Date;
};
export type InvoiceshelfCredentials = {
id: number;
label: string;
url: string;
token: string;
companyId: number | null;
};
const accountCols = {
id: invoiceshelfAccounts.id,
label: invoiceshelfAccounts.label,
url: invoiceshelfAccounts.url,
companyId: invoiceshelfAccounts.companyId,
companyName: invoiceshelfAccounts.companyName,
version: invoiceshelfAccounts.version,
isActive: invoiceshelfAccounts.isActive,
lastSeenAt: invoiceshelfAccounts.lastSeenAt,
createdAt: invoiceshelfAccounts.createdAt,
};
/** Every account the owner has added, active first then newest. Never includes the token. */
export async function listInvoiceshelfAccounts(userId: number): Promise<InvoiceshelfAccount[]> {
return db
.select(accountCols)
.from(invoiceshelfAccounts)
.where(eq(invoiceshelfAccounts.userId, userId))
.orderBy(desc(invoiceshelfAccounts.isActive), desc(invoiceshelfAccounts.createdAt));
}
/** The selected account with its token decrypted, or null when none is added. */
export async function getActiveInvoiceshelfCredentials(userId: number): Promise<InvoiceshelfCredentials | null> {
const [row] = await db
.select()
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
}
/** One account's credentials by id — for probing a specific account rather than the active one. */
export async function getInvoiceshelfCredentials(userId: number, id: number): Promise<InvoiceshelfCredentials | null> {
const [row] = await db
.select()
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
}
type CreateInvoiceshelfAccountParams = {
userId: number;
label: string;
url: string;
token: string;
companyId: number | null;
companyName: string | null;
version: string | null;
/** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */
activate: boolean;
};
/** Add an account. The token is encrypted before write; the returned row carries no token. */
export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccountParams): Promise<InvoiceshelfAccount> {
const { userId, label, url, token, companyId, companyName, version, activate } = params;
return db.transaction(async (tx) => {
if (activate) {
await tx
.update(invoiceshelfAccounts)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
}
const [row] = await tx
.insert(invoiceshelfAccounts)
.values({
userId,
label,
url,
token: encryptSecret(token),
companyId,
companyName,
version,
isActive: activate,
lastSeenAt: version ? new Date() : null,
})
.returning(accountCols);
return row!;
});
}
type UpdateInvoiceshelfAccountParams = {
label?: string;
url?: string;
token?: string;
companyId?: number | null;
companyName?: string | null;
version?: string | null;
};
/** Edit an account. Omitted fields are left alone; a supplied token is re-encrypted. */
export async function updateInvoiceshelfAccount(
userId: number,
id: number,
params: UpdateInvoiceshelfAccountParams,
): Promise<InvoiceshelfAccount | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.label !== undefined) set.label = params.label;
if (params.url !== undefined) set.url = params.url;
if (params.token !== undefined) set.token = encryptSecret(params.token);
if (params.companyId !== undefined) set.companyId = params.companyId;
if (params.companyName !== undefined) set.companyName = params.companyName;
if (params.version !== undefined) set.version = params.version;
const [row] = await db
.update(invoiceshelfAccounts)
.set(set)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)))
.returning(accountCols);
return row ?? null;
}
/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */
export async function setActiveInvoiceshelfAccount(userId: number, id: number): Promise<InvoiceshelfAccount | null> {
return db.transaction(async (tx) => {
await tx
.update(invoiceshelfAccounts)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
const [row] = await tx
.update(invoiceshelfAccounts)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)))
.returning(accountCols);
return row ?? null;
});
}
/**
* Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the
* account in use would leave the owner with accounts added but none selected, which reads as "not connected"
* and is a confusing place to land.
*/
export async function deleteInvoiceshelfAccount(userId: number, id: number): Promise<boolean> {
return db.transaction(async (tx) => {
const [deleted] = await tx
.delete(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)))
.returning({ id: invoiceshelfAccounts.id, wasActive: invoiceshelfAccounts.isActive });
if (!deleted) return false;
if (deleted.wasActive) {
const [next] = await tx
.select({ id: invoiceshelfAccounts.id })
.from(invoiceshelfAccounts)
.where(eq(invoiceshelfAccounts.userId, userId))
.orderBy(desc(invoiceshelfAccounts.createdAt))
.limit(1);
if (next) {
await tx
.update(invoiceshelfAccounts)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(invoiceshelfAccounts.id, next.id));
}
}
return true;
});
}
/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */
export async function recordInvoiceshelfProbe(userId: number, id: number, version: string | null): Promise<void> {
await db
.update(invoiceshelfAccounts)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
}
@@ -3,6 +3,7 @@ export * from './chat-events';
export * from './dashboards';
export * from './email';
export * from './headscale';
export * from './invoiceshelf';
export * from './music';
export * from './notify';
export * from './operations';
@@ -0,0 +1,58 @@
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar.
//
// This used to be INVOICESHELF_URL + INVOICESHELF_TOKEN + INVOICESHELF_COMPANY_ID in the platform-wide
// `.env`, which was wrong twice over: Bun auto-loads `.env` into EVERY process started in the platform
// directory, so `officer` itself held a Sanctum token it has no code to use — and connecting a books
// instance was a shell task on the server rather than something the owner could do from the app.
//
// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the
// same shape headscale_servers and photos_config use. Uniqueness is on the label rather than the URL,
// because the same instance with a different company is a different account here, and two of those share
// a URL AND a token.
//
// `token` is encrypted at rest via ../crypto.ts. A Sanctum token can read and write the entire books — every
// invoice, customer and payment — so a DB dump must not hand it over. Encryption is confined to
// queries/invoiceshelf.ts; nothing outside that file sees ciphertext, and no route ever returns the token.
export const invoiceshelfAccounts = pgTable(
'invoiceshelf_accounts',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */
label: text('label').notNull(),
// Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator.
url: text('url').notNull(),
token: text('token').notNull(), // encrypted
/**
* The company every request is scoped to, pinned explicitly.
*
* This is the sharpest edge in InvoiceShelf's API: the `company` header scopes almost every route, and a
* wrong or missing value does NOT error — it silently returns another company's books. Storing the
* choice means it is made once, visibly, instead of falling back per request.
*/
companyId: integer('company_id'),
/** Shown in the switcher next to the label, so two companies on one token are told apart at a glance. */
companyName: text('company_name'),
/** InvoiceShelf version seen at the last successful probe — shown in the UI, never used for behaviour. */
version: text('version'),
isActive: boolean('is_active').notNull().default(false),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
unique('uq_invoiceshelf_accounts_user_label').on(t.userId, t.label),
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
// index over the active rows only. setActiveInvoiceshelfAccount still clears the others in a transaction,
// but a bug there fails loudly here instead of silently leaving two active and the UI picking one.
uniqueIndex('uq_invoiceshelf_accounts_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);