fix: per-connection search_path + sidebar tenant dropdown
Some checks are pending
release / meta (push) Waiting to run
release / build (push) Blocked by required conditions

PG adapter: setSearchPath now stores schema name, runner applies
SET search_path on every connection checkout. Eliminates cross-request
schema bleed (previous: setSearchPath mutated shared connection state).

Middleware: always set search_path (removed 'public' skip condition).

Sidebar: tenant switcher dropdown at bottom, loaded via htmx from
/admin/_tenant_switcher. Hidden when only one tenant. Auto-submits
on change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-27 02:15:00 +02:00
parent 1dbb56752c
commit 10f5cf7fac
4 changed files with 26 additions and 12 deletions

View file

@ -65,12 +65,8 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void {
const tenant = await deps.repo.getTenantBySlug(tenantSlug);
if (tenant && tenant.is_active) {
event.context.tenant = tenant;
// Set PG search_path to the tenant's schema.
if (tenant.schema_name !== "public") {
await deps.repo.adapter.setSearchPath(tenant.schema_name);
}
await deps.repo.adapter.setSearchPath(tenant.schema_name);
} else {
// Fall back to default tenant.
const defaultTenant = await deps.repo.getTenantBySlug("default");
if (defaultTenant) {
event.context.tenant = defaultTenant;

View file

@ -2219,6 +2219,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
// ---- Tenant switcher fragment (htmx) ----------------------------------------
app.get("/admin/_tenant_switcher", async (event) => {
const tenants = await deps.repo.listTenants();
if (tenants.length <= 1) return new Response("", { headers: { "content-type": "text/html" } });
const current = (event.context as any).tenant?.slug ?? "default";
const options = tenants.map((t: any) =>
`<option value="${t.slug as string}"${t.slug === current ? " selected" : ""}>${t.name as string}</option>`
).join("");
const html = `<form method="post" action="/admin/tenants/switch" style="padding:0.5rem 1rem">
<label style="font-size:0.75rem; color:#888; display:block; margin-bottom:0.25rem">Tenant</label>
<select name="tenant_slug" style="width:100%; font-size:0.8rem; padding:0.25rem" onchange="this.form.submit()">${options}</select>
</form>`;
return new Response(html, { headers: { "content-type": "text/html" } });
});
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
//
// All payloads run through `stripSecrets` so credential-bearing fields

View file

@ -13,9 +13,9 @@ import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js";
export class PgAdapter implements DbAdapter {
private readonly pool: Pool;
/** Per-async-context client when inside transaction(). */
private currentTxClient: PoolClient | null = null;
private txDepth = 0;
private searchPath = "public";
constructor(connectionString: string, poolMax: number = 10) {
this.pool = new Pool({
@ -70,8 +70,12 @@ export class PgAdapter implements DbAdapter {
private async runner<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
if (this.currentTxClient) return fn(this.currentTxClient);
const client = await this.pool.connect();
try { return await fn(client); }
finally { client.release(); }
try {
await client.query(`SET search_path TO ${this.searchPath}, public`);
return await fn(client);
} finally {
client.release();
}
}
async run(sql: string, params: ReadonlyArray<SqlValue> = []): Promise<RunResult> {
@ -149,13 +153,10 @@ export class PgAdapter implements DbAdapter {
dialect(): "postgres" { return "postgres"; }
async setSearchPath(schema: string): Promise<void> {
// Validate schema name to prevent SQL injection (only allow alphanumeric + underscore).
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) {
throw new Error(`invalid schema name: ${schema}`);
}
await this.runner(async (c) => {
await c.query(`SET search_path TO ${schema}, public`);
});
this.searchPath = schema;
}
async close(): Promise<void> {

View file

@ -84,6 +84,7 @@ function Sidebar(props: { activeNav?: string }) {
<hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} />
<div class="tenant-switcher" {...{"hx-get": "/admin/_tenant_switcher", "hx-trigger": "load", "hx-swap": "innerHTML"}}></div>
</nav>
</aside>
);
@ -223,6 +224,7 @@ const baseStyles = {
".nav-group summary::-webkit-details-marker": { display: "none" },
".nav-group-items": { paddingLeft: "1.25rem" },
".nav-group-items .nav-item": { fontSize: "0.8rem", padding: "0.35rem 1rem" },
".tenant-switcher": { marginTop: "auto", borderTop: "1px solid #2a2a4e", paddingTop: "0.25rem" },
".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" },
".topbar": {
display: "flex",