invoices, extracted into plugins/invoices — the platform half

Ninth extraction, third resurrection. Switched off since 2026-08-13 with the
permission reduced to api: [], so assertPermissionTotality was satisfied, the
server booted clean, and every panel request 404'd. Closes the same data hazard
as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix,
frontend route, table prefix, sidecar suffix, install key — and before
extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted
apart, which was harmless only while they were separate hardcoded strings. The
route is the part a person sees, and the permission key and label were already
`invoices`, so the API prefix was the odd one out. Cost three call sites and a
table rename to invoices_accounts, which was free only because the table
existed on no server — verified before deciding, not after.

members.test.ts re-anchored again, this time to caldav and memos: services with
no extraction planned. That assertion has now been re-pointed four times by
services leaving for plugins/, which is the whole reason installer.test.ts was
made literal earlier tonight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 03:47:54 +00:00
co-authored by Claude Opus 5
parent ee321f49fd
commit f566fda0b2
49 changed files with 26 additions and 8443 deletions
-1
View File
@@ -46,7 +46,6 @@ export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
export * from './invoiceshelf';
export * from './notify';
export * from './wallet';
@@ -1,12 +0,0 @@
export {
listInvoiceshelfAccounts,
getActiveInvoiceshelfCredentials,
getInvoiceshelfCredentials,
createInvoiceshelfAccount,
updateInvoiceshelfAccount,
setActiveInvoiceshelfAccount,
deleteInvoiceshelfAccount,
recordInvoiceshelfProbe,
} from './queries';
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries';
@@ -1,212 +0,0 @@
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('invoiceshelf', 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('invoiceshelf', 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('invoiceshelf', 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('invoiceshelf', 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)));
}
@@ -1,58 +0,0 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
// 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 offscale_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) => [
uniqueIndex('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}`),
],
);
-1
View File
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
// export * from './notify/schema'; // push_devices officer-notify
// export * from './dav/schema'; // dav_app_passwords officer-caldav
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────