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:
@@ -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}`),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user