From 0c6fed9daa50b902c080b6534e10ab97b9d8ac7e Mon Sep 17 00:00:00 2001 From: zuchka Date: Mon, 4 May 2026 15:12:59 -0700 Subject: [PATCH 1/5] fix(db-scripts): refuse to run unscoped, surface 0-row writes Previously, db-exec / db-query / db-patch silently fell back to unscoped mode when neither a request context nor AGENT_USER_EMAIL was set. INSERTs landed with the migration default owner_email='local@localhost' (invisible to the UI session user) and UPDATE/DELETE ran cross-tenant against every user's rows. Changes: - scoping.ts: getUserEmail() throws on null/empty as well as the local@localhost sentinel; both buildScoping* functions drop the inactive short-circuit. Error message names AGENT_USER_EMAIL. - exec.ts: when a write returns 0 rows changed, append a hint that the WHERE clause may not match a row visible to the current user (mirrors the wording db-patch already uses). - scoping.spec.ts / parameterized.spec.ts: tests follow the new contract; three parameterized tests now stub AGENT_USER_EMAIL. - templates/slides/.gitignore: ignore stray app.db / local.db at the template root so a misconfigured cwd or --db override can't litter the working tree. Deferred follow-ups (separate PRs): auto-load dev session in the CLI, drop DEFAULT 'local@localhost' from template migrations, add a CI guard, document the three access paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/scripts/db/exec.ts | 31 ++++++++++++- .../core/src/scripts/db/parameterized.spec.ts | 34 +++++++++----- packages/core/src/scripts/db/scoping.spec.ts | 25 ++++++----- packages/core/src/scripts/db/scoping.ts | 45 ++++++------------- templates/slides/.gitignore | 10 +++++ 5 files changed, 92 insertions(+), 53 deletions(-) diff --git a/packages/core/src/scripts/db/exec.ts b/packages/core/src/scripts/db/exec.ts index a1bc61abf..aeb229b4b 100644 --- a/packages/core/src/scripts/db/exec.ts +++ b/packages/core/src/scripts/db/exec.ts @@ -487,7 +487,32 @@ function printResult( if (result.lastInsertRowid && changes > 0) { console.log(`Last Insert Row ID: ${result.lastInsertRowid}`); } + if (changes === 0) { + console.log(zeroChangesHint(sql)); + } + } +} + +/** + * Hint emitted when an UPDATE/DELETE/REPLACE matches zero rows. Matches the + * wording used by db-patch's "no rows matched" error so the agent gets the + * same scoping nudge from both tools — without this hint, the agent reports + * "Changes: 0" as success and the user sees no UI update because the row + * either didn't exist or wasn't visible to the current user under per-user + * scoping. + */ +function zeroChangesHint(sql: string): string { + const upper = sql.replace(/^\s+/, "").toUpperCase(); + if (upper.startsWith("INSERT")) { + // INSERT changes=0 means INSERT OR IGNORE skipped a duplicate — different + // failure mode, not a scoping issue. + return "Hint: 0 rows inserted. The row likely violated a UNIQUE / PRIMARY KEY constraint and was skipped (INSERT OR IGNORE)."; } + return ( + "Hint: 0 rows changed. The WHERE clause matched no rows — either the row " + + "doesn't exist, or it exists but is owned by a different user (per-user " + + "and per-org scoping is automatic for db-exec)." + ); } function printBatchResult(results: DbExecResult[], format?: string): void { @@ -542,7 +567,11 @@ function printBatchResult(results: DbExecResult[], format?: string): void { console.log(`[${result.index}] Returned ${result.rows.length} row(s):`); console.log(JSON.stringify(result.rows, null, 2)); } else { - console.log(`[${result.index}] Changes: ${result.changes ?? 0}`); + const changes = Number(result.changes ?? 0); + console.log(`[${result.index}] Changes: ${changes}`); + if (changes === 0) { + console.log(`[${result.index}] ${zeroChangesHint(result.sql)}`); + } } } console.log(`Total changes: ${totalChanges}`); diff --git a/packages/core/src/scripts/db/parameterized.spec.ts b/packages/core/src/scripts/db/parameterized.spec.ts index e6c171397..58fcb72c1 100644 --- a/packages/core/src/scripts/db/parameterized.spec.ts +++ b/packages/core/src/scripts/db/parameterized.spec.ts @@ -45,6 +45,7 @@ describe("db scripts parameterized SQL", () => { } it("passes db-query bind args through to libsql", async () => { + vi.stubEnv("AGENT_USER_EMAIL", "params+qa@test.com"); const execute = vi.fn(async (input: unknown) => { if (typeof input === "object" && input) { return { rows: [["ada"]], columns: ["name"] }; @@ -71,6 +72,7 @@ describe("db scripts parameterized SQL", () => { }); it("passes db-exec bind args through to libsql", async () => { + vi.stubEnv("AGENT_USER_EMAIL", "params+qa@test.com"); const execute = vi.fn(async () => ({ rows: [], columns: [], @@ -97,12 +99,21 @@ describe("db scripts parameterized SQL", () => { }); it("executes db-exec statement batches in one SQLite transaction", async () => { - const execute = vi.fn(async () => ({ - rows: [], - columns: [], - rowsAffected: 1, - lastInsertRowid: undefined, - })); + vi.stubEnv("AGENT_USER_EMAIL", "params+qa@test.com"); + // Return an empty sqlite_master so scoping introspection doesn't generate + // setup views — keeps this test focused on the BEGIN/INSERT/UPDATE/COMMIT + // ordering. The first call is the introspection SELECT that returns []. + const execute = vi.fn(async (input: unknown) => { + if (typeof input === "string" && input.includes("sqlite_master")) { + return { rows: [], columns: [] }; + } + return { + rows: [], + columns: [], + rowsAffected: 1, + lastInsertRowid: undefined, + }; + }); mockSqliteClient(execute); const { default: dbExec } = await import("./exec.js"); @@ -123,16 +134,19 @@ describe("db scripts parameterized SQL", () => { "json", ]); - expect(execute).toHaveBeenNthCalledWith(1, "BEGIN"); - expect(execute).toHaveBeenNthCalledWith(2, { + const txCalls = execute.mock.calls.filter( + ([arg]) => !(typeof arg === "string" && arg.includes("sqlite_master")), + ); + expect(txCalls[0]?.[0]).toBe("BEGIN"); + expect(txCalls[1]?.[0]).toEqual({ sql: "INSERT INTO notes (id, title) VALUES (?, ?)", args: ["note-1", "One"], }); - expect(execute).toHaveBeenNthCalledWith(3, { + expect(txCalls[2]?.[0]).toEqual({ sql: "UPDATE notes SET title = ? WHERE id = ?", args: ["Two", "note-1"], }); - expect(execute).toHaveBeenNthCalledWith(4, "COMMIT"); + expect(txCalls[3]?.[0]).toBe("COMMIT"); }); it("rejects ad-hoc schema changes through db-exec", async () => { diff --git a/packages/core/src/scripts/db/scoping.spec.ts b/packages/core/src/scripts/db/scoping.spec.ts index 8f50b24b1..00a98daf0 100644 --- a/packages/core/src/scripts/db/scoping.spec.ts +++ b/packages/core/src/scripts/db/scoping.spec.ts @@ -36,18 +36,19 @@ describe("scoping", () => { expect(ctx.setup.length).toBeGreaterThan(0); }); - it("returns inactive scoping when there is no request user", async () => { + it("throws when there is no request user (no inactive fallback — would silently land writes with the dev sentinel owner_email)", async () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("AGENT_USER_EMAIL", ""); const { buildScopingSqlite } = await import("./scoping.js"); const mockClient = { - execute: vi.fn().mockResolvedValue({ rows: [] }), + execute: vi.fn(), }; - const ctx = await buildScopingSqlite(mockClient); - expect(ctx.active).toBe(false); - expect(ctx.userEmail).toBeNull(); + await expect(buildScopingSqlite(mockClient)).rejects.toThrow( + "require an authenticated user identity", + ); + expect(mockClient.execute).not.toHaveBeenCalled(); }); it("builds scoping views for core tables in prod mode", async () => { @@ -298,7 +299,7 @@ describe("scoping", () => { }; await expect(buildScopingSqlite(mockClient)).rejects.toThrow( - "requires a real user identity", + "require an authenticated user identity", ); expect(mockClient.execute).not.toHaveBeenCalled(); }); @@ -346,14 +347,16 @@ describe("scoping", () => { expect(ctx.userEmail).toBe("user+qa@test.com"); }); - it("returns inactive scoping when there is no request user", async () => { + it("throws when there is no request user (matches sqlite path — refuses to run unscoped against a multi-user database)", async () => { vi.stubEnv("NODE_ENV", "production"); vi.stubEnv("AGENT_USER_EMAIL", ""); const { buildScopingPostgres } = await import("./scoping.js"); - const mockPgSql: any = {}; - const ctx = await buildScopingPostgres(mockPgSql); - expect(ctx.active).toBe(false); + const mockPgSql = vi.fn(); + await expect(buildScopingPostgres(mockPgSql)).rejects.toThrow( + "require an authenticated user identity", + ); + expect(mockPgSql).not.toHaveBeenCalled(); }); it("refuses to scope Postgres DB scripts to the local fallback identity", async () => { @@ -364,7 +367,7 @@ describe("scoping", () => { const mockPgSql = vi.fn(); await expect(buildScopingPostgres(mockPgSql)).rejects.toThrow( - "requires a real user identity", + "require an authenticated user identity", ); expect(mockPgSql).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/scripts/db/scoping.ts b/packages/core/src/scripts/db/scoping.ts index d4a777c4b..bbae3afb0 100644 --- a/packages/core/src/scripts/db/scoping.ts +++ b/packages/core/src/scripts/db/scoping.ts @@ -41,11 +41,16 @@ interface ScopedTable { viewSql: string; } -function getUserEmail(): string | null { +function getUserEmail(): string { const userEmail = getRequestUserEmail() || null; - if (userEmail === DEV_FALLBACK_EMAIL) { + if (!userEmail || userEmail === DEV_FALLBACK_EMAIL) { throw new Error( - "DB script scoping requires a real user identity; refusing to run with local@localhost.", + "db-exec / db-query / db-patch require an authenticated user identity. " + + "Set AGENT_USER_EMAIL= in the env, or invoke through an HTTP " + + "action that runs under runWithRequestContext. Refusing to run unscoped — " + + "an unscoped UPDATE/DELETE would touch every user's rows, and an " + + "unscoped INSERT would land with the dev sentinel owner and be invisible " + + "to the UI.", ); } return userEmail; @@ -218,21 +223,12 @@ export interface ScopingContext { export async function buildScopingPostgres( pgSql: any, ): Promise { - const inactive: ScopingContext = { - setup: [], - teardown: [], - active: false, - userEmail: null, - orgId: null, - ownerEmailTables: new Set(), - orgIdTables: new Set(), - }; - - // Scoping is always active when there is a request user (dev, preview, and - // prod). Previously this short-circuited outside production, which created - // a cross-user read in dev mode. See audit 05-tools-sandbox.md (C3.d). + // getUserEmail() throws when there is no authenticated user (no request + // context AND no AGENT_USER_EMAIL env) or when it resolves to the dev + // sentinel `local@localhost`. We let that throw propagate: the script + // refuses to run unscoped rather than silently writing rows that the UI + // then can't see, or running an UPDATE/DELETE across every user's data. const userEmail = getUserEmail(); - if (!userEmail) return inactive; const orgId = getOrgId(); const allColumns = await discoverColumnsPostgres(pgSql); @@ -268,21 +264,8 @@ export async function buildScopingPostgres( * Returns setup/teardown SQL to run before/after the user's query. */ export async function buildScopingSqlite(client: any): Promise { - const inactive: ScopingContext = { - setup: [], - teardown: [], - active: false, - userEmail: null, - orgId: null, - ownerEmailTables: new Set(), - orgIdTables: new Set(), - }; - - // Scoping is always active when there is a request user (dev, preview, and - // prod). Previously this short-circuited outside production, which created - // a cross-user read in dev mode. See audit 05-tools-sandbox.md (C3.d). + // See buildScopingPostgres: getUserEmail() throws on no user / dev sentinel. const userEmail = getUserEmail(); - if (!userEmail) return inactive; const orgId = getOrgId(); const allColumns = await discoverColumnsSqlite(client); diff --git a/templates/slides/.gitignore b/templates/slides/.gitignore index 5baab3dc6..b8c30d296 100644 --- a/templates/slides/.gitignore +++ b/templates/slides/.gitignore @@ -41,6 +41,16 @@ data/app.db data/app.db-wal data/app.db-shm +# Stray SQLite files at the template root. The real DB lives under data/; +# anything that creates app.db / local.db here is a misconfigured cwd or an +# explicit `--db ./app.db` override. +/app.db +/app.db-shm +/app.db-wal +/local.db +/local.db-shm +/local.db-wal + # Learnings (personal preferences and memory — use learnings.defaults.md for tracked defaults) learnings.md From c47bec9d5ffa2ffe70ce5d39e306ff41aa745539 Mon Sep 17 00:00:00 2001 From: zuchka Date: Mon, 4 May 2026 15:57:41 -0700 Subject: [PATCH 2/5] feat(db-scripts): auto-load dev session in CLI runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After changes-53 (`fix(db-scripts): refuse to run unscoped, …`), every `pnpm action db-…` invocation threw "require an authenticated user identity" unless the operator manually exported AGENT_USER_EMAIL. The commit message flagged this as a deferred follow-up; this is it. - New `scripts/dev-session.ts` with `resolveDevUserEmail()`. Returns `AGENT_USER_EMAIL` if explicitly set, otherwise reads `SELECT email FROM sessions ORDER BY created_at DESC LIMIT 1` — mirroring the A2A receiver fallback in `agent-chat-plugin.ts`. Strict gating: `NODE_ENV !== "production"`, `AUTH_MODE` unset or `local`, sentinel `local@localhost` filtered at the SQL level. - `scripts/runner.ts` extracts dispatch into `dispatchAction()` and wraps it in `runWithRequestContext({ userEmail, orgId })`. One injection point covers both local-action and core-script branches. Uses request-context (not env mutation) per the warning in `server/request-context.ts`. - `scripts/db/scoping.ts` error message now leads with "open the app and sign in" — keeps the existing "require an authenticated user identity" substring that scoping.spec.ts asserts on. - New `dev-session.spec.ts` covers env pass-through, prod refusal, AUTH_MODE gate, dev success, empty/missing sessions table, blank email handling. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/scripts/db/scoping.ts | 4 +- packages/core/src/scripts/dev-session.spec.ts | 121 ++++++++++++++++++ packages/core/src/scripts/dev-session.ts | 69 ++++++++++ packages/core/src/scripts/runner.ts | 28 ++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/scripts/dev-session.spec.ts create mode 100644 packages/core/src/scripts/dev-session.ts diff --git a/packages/core/src/scripts/db/scoping.ts b/packages/core/src/scripts/db/scoping.ts index bbae3afb0..da1b838a9 100644 --- a/packages/core/src/scripts/db/scoping.ts +++ b/packages/core/src/scripts/db/scoping.ts @@ -46,7 +46,9 @@ function getUserEmail(): string { if (!userEmail || userEmail === DEV_FALLBACK_EMAIL) { throw new Error( "db-exec / db-query / db-patch require an authenticated user identity. " + - "Set AGENT_USER_EMAIL= in the env, or invoke through an HTTP " + + "Easiest fix: open the app at http://localhost:3000 and sign in — " + + "the CLI then auto-loads your session. Otherwise set " + + "AGENT_USER_EMAIL= in the env, or invoke through an HTTP " + "action that runs under runWithRequestContext. Refusing to run unscoped — " + "an unscoped UPDATE/DELETE would touch every user's rows, and an " + "unscoped INSERT would land with the dev sentinel owner and be invisible " + diff --git a/packages/core/src/scripts/dev-session.spec.ts b/packages/core/src/scripts/dev-session.spec.ts new file mode 100644 index 000000000..def6102a8 --- /dev/null +++ b/packages/core/src/scripts/dev-session.spec.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("resolveDevUserEmail", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("returns AGENT_USER_EMAIL when explicitly set, without touching the DB", async () => { + vi.stubEnv("AGENT_USER_EMAIL", "explicit@test.com"); + const execute = vi.fn(); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBe("explicit@test.com"); + expect(execute).not.toHaveBeenCalled(); + }); + + it("returns undefined in production regardless of sessions table", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "production"); + const execute = vi.fn(); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + }); + + it("returns undefined when AUTH_MODE is set to a non-local mode", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("AUTH_MODE", "google"); + const execute = vi.fn(); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + }); + + it("returns the latest sessions.email row in dev with AUTH_MODE unset", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi.fn().mockResolvedValue({ + rows: [{ email: "matthew@builder.io" }], + }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBe("matthew@builder.io"); + expect(execute).toHaveBeenCalledOnce(); + const call = execute.mock.calls[0][0]; + expect(call.sql).toContain("FROM sessions"); + expect(call.sql).toContain("ORDER BY created_at DESC"); + // Sentinel must be excluded from the result set + expect(call.args).toEqual(["local@localhost"]); + }); + + it("returns the latest sessions.email row when AUTH_MODE === 'local'", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + vi.stubEnv("AUTH_MODE", "local"); + const execute = vi.fn().mockResolvedValue({ + rows: [{ email: "alice@local" }], + }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBe("alice@local"); + }); + + it("returns undefined when sessions table is empty", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi.fn().mockResolvedValue({ rows: [] }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + }); + + it("returns undefined when sessions table is missing (DB throws)", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi + .fn() + .mockRejectedValue(new Error("no such table: sessions")); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + }); + + it("ignores blank emails in the sessions row", async () => { + vi.stubEnv("AGENT_USER_EMAIL", ""); + vi.stubEnv("NODE_ENV", "development"); + const execute = vi.fn().mockResolvedValue({ rows: [{ email: " " }] }); + vi.doMock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + })); + + const { resolveDevUserEmail } = await import("./dev-session.js"); + expect(await resolveDevUserEmail()).toBeUndefined(); + }); +}); diff --git a/packages/core/src/scripts/dev-session.ts b/packages/core/src/scripts/dev-session.ts new file mode 100644 index 000000000..35f49fe59 --- /dev/null +++ b/packages/core/src/scripts/dev-session.ts @@ -0,0 +1,69 @@ +/** + * Dev-only session bootstrap for `pnpm action ` (and any other CLI + * caller of `runScript`). + * + * After changes-53, db-exec / db-query / db-patch refuse to run unless + * `getRequestUserEmail()` returns a real identity. In an HTTP request the + * Nitro plugin wraps the handler in `runWithRequestContext({ userEmail })` + * so scoping just works. CLI invocations have no such wrapper, so without + * this helper every db-* CLI run hands the user a stack trace. + * + * What this does: when the runner is about to dispatch, resolve a real + * email by reading the most-recent row from the legacy `sessions` table + * (the same table that `addSession()` writes from google-oauth.ts and the + * A2A receiver fallback already consults). The runner then wraps dispatch + * in `runWithRequestContext({ userEmail })` so the action sees a real + * identity. + * + * Strict gating mirrors the A2A precedent in + * `server/agent-chat-plugin.ts` (search for "latest session"): + * - NODE_ENV !== "production". + * - AUTH_MODE unset or === "local" — don't auto-impersonate when an + * admin or hosted auth mode is in use. + * + * If `process.env.AGENT_USER_EMAIL` is already set we return it unchanged + * — explicit env wins over any DB-derived guess (matches how + * `getRequestUserEmail()` itself behaves). + */ + +const DEV_FALLBACK_EMAIL = "local@localhost"; // guard:allow-localhost-fallback — sentinel intentionally rejected so the resolver doesn't return it + +/** + * Resolve the local dev user's email for the current CLI invocation. + * + * Returns the resolved email, or `undefined` when no real identity is + * available. Callers should let the downstream "no authenticated user" + * error propagate — its message points the user at the two fixes + * (sign in via the running app, or set `AGENT_USER_EMAIL`). + */ +export async function resolveDevUserEmail(): Promise { + const explicit = process.env.AGENT_USER_EMAIL; + if (explicit) return explicit; + + // Hard refusal: this helper must never source identity in prod. + if (process.env.NODE_ENV === "production") return undefined; + + // AUTH_MODE may be unset (default dev shim) or "local". Anything else + // means a non-dev auth mode is in play; don't try to fish a session + // out of the DB on its behalf. + const authMode = process.env.AUTH_MODE; + if (authMode && authMode !== "local") return undefined; + + try { + const { getDbExec } = await import("../db/client.js"); + const { rows } = await getDbExec().execute({ + sql: `SELECT email FROM sessions + WHERE email IS NOT NULL AND email <> ? + ORDER BY created_at DESC LIMIT 1`, + args: [DEV_FALLBACK_EMAIL], + }); + const email = rows[0]?.email as string | undefined; + return email && email.trim().length > 0 ? email : undefined; + } catch { + // The sessions table doesn't exist yet (fresh install where the web + // server has never booted) or the DB isn't reachable. Either way, + // we can't produce an identity — let the caller throw with the + // friendlier "sign in first" hint. + return undefined; + } +} diff --git a/packages/core/src/scripts/runner.ts b/packages/core/src/scripts/runner.ts index 2d815b439..c4ebe082e 100644 --- a/packages/core/src/scripts/runner.ts +++ b/packages/core/src/scripts/runner.ts @@ -16,6 +16,8 @@ import { pathToFileURL } from "url"; import { coreScripts, getCoreScriptNames } from "./core-scripts.js"; import { closeDbExec } from "../db/client.js"; import { loadEnv } from "./utils.js"; +import { runWithRequestContext } from "../server/request-context.js"; +import { resolveDevUserEmail } from "./dev-session.js"; // Load .env from cwd so DATABASE_URL and other vars are available to all actions. loadEnv(); @@ -81,6 +83,32 @@ export async function runScript(): Promise { const args = process.argv.slice(3); + // Establish a request context for the duration of this CLI run. Without + // it, db-exec / db-query / db-patch and any action that calls + // `getRequestUserEmail()` see no identity and refuse to run. The + // resolver picks up `AGENT_USER_EMAIL` if explicitly set, otherwise + // reads the most-recent signed-in session from the DB (dev-only, + // narrowly gated — see dev-session.ts). + // + // This wrap is intentionally a single point of injection: it covers + // both the local-action branch and the fall-through to core scripts + // (db-query, db-exec, …) so every CLI entrypoint runs scoped to a real + // user. It uses `runWithRequestContext` rather than mutating + // `process.env.AGENT_USER_EMAIL` because env mutation leaks across + // boundaries — see the cautionary comment in + // `server/request-context.ts` about exactly that pattern. + const userEmail = await resolveDevUserEmail(); + const orgId = process.env.AGENT_ORG_ID || undefined; + + return runWithRequestContext({ userEmail, orgId }, () => + dispatchAction(actionName, args), + ); +} + +async function dispatchAction( + actionName: string, + args: string[], +): Promise { // 1. Try local app action first (actions/ then scripts/ for backwards compat) const actionsPath = path.resolve( process.cwd(), From 029d910a74c249d4f5f69c58a09241fb5bbef388 Mon Sep 17 00:00:00 2001 From: zuchka Date: Mon, 4 May 2026 15:57:50 -0700 Subject: [PATCH 3/5] feat(db-scripts): add db-reset-dev-owner action One-shot fix for local DBs that accumulated rows owned by the dev sentinel `local@localhost`. Pre-changes-53, the scoping wrapper silently fell back to that owner when no real identity was present, so data created via CLI runs (or older runner versions) landed under the sentinel and is now invisible to the actual signed-in user. Discovers every table with an `owner_email` column and reassigns `local@localhost` rows to the email passed via `--to`. Refuses on `NODE_ENV=production`; refuses against Postgres unless `AN_ALLOW_PG_DEV_OWNER_RESET=1` is explicitly set (so a misconfigured DATABASE_URL can't sweep production data into one tenant). Usage: pnpm action db-reset-dev-owner --to me@example.com --dry-run pnpm action db-reset-dev-owner --to me@example.com pnpm action db-reset-dev-owner --to me@example.com --table decks Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/core/src/scripts/db/index.ts | 2 + .../core/src/scripts/db/reset-dev-owner.ts | 282 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 packages/core/src/scripts/db/reset-dev-owner.ts diff --git a/packages/core/src/scripts/db/index.ts b/packages/core/src/scripts/db/index.ts index a9216ea8e..38f334045 100644 --- a/packages/core/src/scripts/db/index.ts +++ b/packages/core/src/scripts/db/index.ts @@ -10,4 +10,6 @@ export const coreDbScripts: Record Promise> = import("./wipe-leaked-builder-keys.js").then((m) => m.default(args)), "db-migrate-user-api-keys": (args) => import("./migrate-user-api-keys.js").then((m) => m.default(args)), + "db-reset-dev-owner": (args) => + import("./reset-dev-owner.js").then((m) => m.default(args)), }; diff --git a/packages/core/src/scripts/db/reset-dev-owner.ts b/packages/core/src/scripts/db/reset-dev-owner.ts new file mode 100644 index 000000000..ea84daab8 --- /dev/null +++ b/packages/core/src/scripts/db/reset-dev-owner.ts @@ -0,0 +1,282 @@ +/** + * Core script: db-reset-dev-owner + * + * One-shot fix for local DBs that accumulated rows owned by the dev + * sentinel `local@localhost`. Pre-changes-53, db-exec / db-query / + * db-patch silently fell back to that owner when no real identity was + * present, so any data created via CLI runs (or by older versions of + * the runner) landed under the sentinel and is now invisible to the + * actual signed-in user. + * + * This script discovers every ownable table (those with an + * `owner_email` column), then re-points each `local@localhost` row to + * the email passed via `--to`. Optionally restricted to a single table + * with `--table`. + * + * Local-dev-only safety: refuses to run when `NODE_ENV=production` or + * when targeting a non-`file:` SQLite URL (no Postgres / Turso / + * shared-DB writes). + * + * Usage: + * pnpm action db-reset-dev-owner --to matthew@builder.io + * pnpm action db-reset-dev-owner --to matthew@builder.io --dry-run + * pnpm action db-reset-dev-owner --to matthew@builder.io --table decks + * pnpm action db-reset-dev-owner --to matthew@builder.io --db ./data/app.db + */ + +import path from "path"; +import { createClient } from "@libsql/client"; +import { getDatabaseUrl, getDatabaseAuthToken } from "../../db/client.js"; +import { parseArgs } from "../utils.js"; + +const DEV_FALLBACK_EMAIL = "local@localhost"; // guard:allow-localhost-fallback — script intentionally targets these rows + +function isPostgresUrl(url: string): boolean { + return url.startsWith("postgres://") || url.startsWith("postgresql://"); +} + +interface Args { + to: string; + table?: string; + dryRun: boolean; + dbPath?: string; +} + +function parseScriptArgs(args: string[]): Args | null { + const parsed = parseArgs(args); + if (parsed.help === "true") return null; + + const to = parsed.to?.trim(); + if (!to || !to.includes("@")) { + console.error( + "Error: --to is required and must look like an email address.", + ); + return null; + } + if (to === DEV_FALLBACK_EMAIL) { + console.error( + `Error: --to cannot be ${DEV_FALLBACK_EMAIL} (that's the sentinel we're fixing).`, + ); + return null; + } + + return { + to, + table: parsed.table?.trim() || undefined, + dryRun: parsed["dry-run"] === "true", + dbPath: parsed.db?.trim() || undefined, + }; +} + +function printHelp(): void { + console.log(`Usage: pnpm action db-reset-dev-owner --to [options] + +Reassigns rows owned by '${DEV_FALLBACK_EMAIL}' to the given email across +every table that has an 'owner_email' column. Use this once when an old +local DB still has rows that the new (post-changes-53) scoping won't show +to the actual signed-in user. + +Required: + --to Target email — usually the address you sign in with locally + +Options: + --table Only reset one table (default: every ownable table) + --dry-run Print what would change without writing + --db SQLite database path (default: DATABASE_URL or ./data/app.db) + --help Show this help message + +Refuses to run when NODE_ENV=production or against a non-local DB URL.`); +} + +export default async function dbResetDevOwner(args: string[]): Promise { + if (args.includes("--help") || args.length === 0) { + printHelp(); + return; + } + + const parsed = parseScriptArgs(args); + if (!parsed) { + process.exit(1); + } + + if (process.env.NODE_ENV === "production") { + console.error( + "Error: refusing to run db-reset-dev-owner with NODE_ENV=production.", + ); + process.exit(1); + } + + // Resolve target DB URL — same precedence as wipe-leaked-builder-keys. + let url: string; + if (parsed.dbPath) { + url = "file:" + path.resolve(parsed.dbPath); + } else if (getDatabaseUrl()) { + url = getDatabaseUrl(); + } else { + url = "file:" + path.resolve(process.cwd(), "data", "app.db"); + } + + const isPostgres = isPostgresUrl(url); + const isLocalSqlite = url.startsWith("file:"); + + if (!isPostgres && !isLocalSqlite) { + console.error( + `Error: refusing to run against shared DB URL ${url}. ` + + "This script is only for local SQLite files.", + ); + process.exit(1); + } + if (isPostgres && process.env.AN_ALLOW_PG_DEV_OWNER_RESET !== "1") { + console.error( + "Error: refusing to run against a Postgres DB. Set " + + "AN_ALLOW_PG_DEV_OWNER_RESET=1 to override (only do this on a " + + "local Postgres you fully own — never on Neon/prod).", + ); + process.exit(1); + } + + const dbLabel = isLocalSqlite + ? url.slice("file:".length) + : (() => { + try { + return new URL(url).host || url; + } catch { + return url; + } + })(); + + console.log( + `[reset-dev-owner] target: ${dbLabel}` + + `${parsed.dryRun ? " (dry-run)" : ""}`, + ); + console.log( + `[reset-dev-owner] reassigning '${DEV_FALLBACK_EMAIL}' → '${parsed.to}'`, + ); + + if (isPostgres) { + await runPostgres(url, parsed); + } else { + await runSqlite(url, parsed); + } +} + +async function runSqlite(url: string, args: Args): Promise { + const client = createClient({ url, authToken: getDatabaseAuthToken() }); + try { + const tables = args.table + ? [args.table] + : await discoverSqliteOwnerTables(client); + + if (tables.length === 0) { + console.log( + "[reset-dev-owner] no tables with owner_email column — nothing to do.", + ); + return; + } + + let totalUpdated = 0; + for (const table of tables) { + const escaped = table.replace(/"/g, '""'); + const countRes = await client.execute({ + sql: `SELECT COUNT(*) AS c FROM "${escaped}" WHERE owner_email = ?`, + args: [DEV_FALLBACK_EMAIL], + }); + const count = Number((countRes.rows[0] as any)?.c ?? 0); + if (count === 0) { + console.log(` ${table}: 0 rows`); + continue; + } + console.log( + ` ${table}: ${count} row(s)${args.dryRun ? " (dry-run)" : ""}`, + ); + if (args.dryRun) continue; + const updateRes = await client.execute({ + sql: `UPDATE "${escaped}" SET owner_email = ? WHERE owner_email = ?`, + args: [args.to, DEV_FALLBACK_EMAIL], + }); + totalUpdated += updateRes.rowsAffected; + } + + console.log( + args.dryRun + ? `[reset-dev-owner] dry-run complete.` + : `[reset-dev-owner] reassigned ${totalUpdated} row(s) across ${tables.length} table(s).`, + ); + } finally { + client.close(); + } +} + +async function runPostgres(url: string, args: Args): Promise { + const { default: pg } = await import("postgres"); + const sql = pg(url); + try { + const tables = args.table + ? [args.table] + : await discoverPostgresOwnerTables(sql); + + if (tables.length === 0) { + console.log( + "[reset-dev-owner] no tables with owner_email column — nothing to do.", + ); + return; + } + + let totalUpdated = 0; + for (const table of tables) { + const countRes = (await sql.unsafe( + `SELECT COUNT(*)::int AS c FROM "${table.replace(/"/g, '""')}" WHERE owner_email = $1`, + [DEV_FALLBACK_EMAIL], + )) as unknown as Array<{ c: number }>; + const count = countRes[0]?.c ?? 0; + if (count === 0) { + console.log(` ${table}: 0 rows`); + continue; + } + console.log( + ` ${table}: ${count} row(s)${args.dryRun ? " (dry-run)" : ""}`, + ); + if (args.dryRun) continue; + const updateRes = (await sql.unsafe( + `UPDATE "${table.replace(/"/g, '""')}" SET owner_email = $1 WHERE owner_email = $2`, + [args.to, DEV_FALLBACK_EMAIL], + )) as unknown as { count?: number }; + totalUpdated += updateRes.count ?? 0; + } + + console.log( + args.dryRun + ? `[reset-dev-owner] dry-run complete.` + : `[reset-dev-owner] reassigned ${totalUpdated} row(s) across ${tables.length} table(s).`, + ); + } finally { + await sql.end(); + } +} + +async function discoverSqliteOwnerTables(client: any): Promise { + const tablesRes = await client.execute( + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`, + ); + const out: string[] = []; + for (const row of tablesRes.rows) { + const table = (row.name ?? row[0]) as string; + const escaped = table.replace(/"/g, '""'); + const colsRes = await client.execute(`PRAGMA table_info("${escaped}")`); + const hasOwner = colsRes.rows.some( + (r: any) => (r.name ?? r[1]) === "owner_email", + ); + if (hasOwner) out.push(table); + } + return out; +} + +async function discoverPostgresOwnerTables(sql: any): Promise { + const rows = (await sql` + SELECT table_name + FROM information_schema.columns + WHERE table_schema = 'public' AND column_name = 'owner_email' + ORDER BY table_name + `) as unknown as Array<{ table_name: string }>; + return Array.from(rows).map((r) => r.table_name); +} From 21d092890569fc8b9ba4ec2f2cb0fa5753558587 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 5 May 2026 14:26:49 +0000 Subject: [PATCH 4/5] fix(pr-490): address review feedback --- packages/core/src/scripts/db/exec.ts | 2 +- packages/core/src/scripts/db/reset-dev-owner.ts | 3 ++- packages/core/src/scripts/dev-session.ts | 15 ++++++++++++++- templates/analytics/.gitignore | 6 ++++++ templates/calendar/.gitignore | 14 ++++++++++---- templates/clips/.gitignore | 7 ++++++- templates/content/.gitignore | 6 ++++++ templates/design/.gitignore | 7 ++++++- templates/dispatch/.gitignore | 7 ++++++- templates/forms/.gitignore | 6 ++++++ templates/issues/.gitignore | 6 ++++++ templates/macros/.gitignore | 6 ++++++ templates/mail/.gitignore | 16 +++++++++++----- templates/recruiting/.gitignore | 6 ++++++ templates/scheduling/.gitignore | 6 ++++++ templates/starter/.gitignore | 7 ++++++- templates/videos/.gitignore | 14 ++++++++++---- 17 files changed, 114 insertions(+), 20 deletions(-) diff --git a/packages/core/src/scripts/db/exec.ts b/packages/core/src/scripts/db/exec.ts index aeb229b4b..a30afa1a1 100644 --- a/packages/core/src/scripts/db/exec.ts +++ b/packages/core/src/scripts/db/exec.ts @@ -502,7 +502,7 @@ function printResult( * scoping. */ function zeroChangesHint(sql: string): string { - const upper = sql.replace(/^\s+/, "").toUpperCase(); + const upper = sql.toUpperCase(); // leading whitespace already stripped by normalizeUserSql if (upper.startsWith("INSERT")) { // INSERT changes=0 means INSERT OR IGNORE skipped a duplicate — different // failure mode, not a scoping issue. diff --git a/packages/core/src/scripts/db/reset-dev-owner.ts b/packages/core/src/scripts/db/reset-dev-owner.ts index ea84daab8..563aa5cf0 100644 --- a/packages/core/src/scripts/db/reset-dev-owner.ts +++ b/packages/core/src/scripts/db/reset-dev-owner.ts @@ -96,7 +96,8 @@ export default async function dbResetDevOwner(args: string[]): Promise { const parsed = parseScriptArgs(args); if (!parsed) { - process.exit(1); + // parseScriptArgs already printed the error; exit non-zero. + throw new Error("invalid arguments"); } if (process.env.NODE_ENV === "production") { diff --git a/packages/core/src/scripts/dev-session.ts b/packages/core/src/scripts/dev-session.ts index 35f49fe59..c263ab9e3 100644 --- a/packages/core/src/scripts/dev-session.ts +++ b/packages/core/src/scripts/dev-session.ts @@ -15,6 +15,15 @@ * in `runWithRequestContext({ userEmail })` so the action sees a real * identity. * + * SHARED-DEV-BOX CAVEAT: the `SELECT email FROM sessions ORDER BY + * created_at DESC LIMIT 1` query is unscoped — on a machine where + * multiple developers have signed in (or after a `pnpm action …` run + * from another team's app), this will bind to whoever signed in most + * recently across *all* sessions in the DB. If that is wrong, set + * `AGENT_USER_EMAIL=` in your shell or `.env`; explicit env + * always wins. A `[dev-session]` log line is emitted so wrong-binding + * is easy to spot. + * * Strict gating mirrors the A2A precedent in * `server/agent-chat-plugin.ts` (search for "latest session"): * - NODE_ENV !== "production". @@ -58,7 +67,11 @@ export async function resolveDevUserEmail(): Promise { args: [DEV_FALLBACK_EMAIL], }); const email = rows[0]?.email as string | undefined; - return email && email.trim().length > 0 ? email : undefined; + if (!email || email.trim().length === 0) return undefined; + console.log( + `[dev-session] auto-bound to ${email} (set AGENT_USER_EMAIL to override)`, + ); + return email; } catch { // The sessions table doesn't exist yet (fresh install where the web // server has never booted) or the DB isn't reachable. Either way, diff --git a/templates/analytics/.gitignore b/templates/analytics/.gitignore index 1dbd12ac9..65277e1c6 100644 --- a/templates/analytics/.gitignore +++ b/templates/analytics/.gitignore @@ -46,4 +46,10 @@ build # Migration scratchpad (not for commit) .migration/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/calendar/.gitignore b/templates/calendar/.gitignore index 136aa5048..66aba7f03 100644 --- a/templates/calendar/.gitignore +++ b/templates/calendar/.gitignore @@ -37,10 +37,16 @@ data/.sessions.json data/google-auth.json data/google-accounts/ -# SQLite database files (generated at runtime) -data/app.db -data/app.db-wal -data/app.db-shm +# SQLite database files (generated at runtime) +data/app.db +data/app.db-wal +data/app.db-shm + +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db # Event data (generated at runtime) data/events/ diff --git a/templates/clips/.gitignore b/templates/clips/.gitignore index 295dcf032..446946399 100644 --- a/templates/clips/.gitignore +++ b/templates/clips/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/content/.gitignore b/templates/content/.gitignore index 81553c5a0..28ed7a729 100644 --- a/templates/content/.gitignore +++ b/templates/content/.gitignore @@ -37,6 +37,12 @@ data/app.db data/app.db-shm data/app.db-wal +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + # Ephemeral editor state .editor-selection.json diff --git a/templates/design/.gitignore b/templates/design/.gitignore index 295dcf032..446946399 100644 --- a/templates/design/.gitignore +++ b/templates/design/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/dispatch/.gitignore b/templates/dispatch/.gitignore index 295dcf032..446946399 100644 --- a/templates/dispatch/.gitignore +++ b/templates/dispatch/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/forms/.gitignore b/templates/forms/.gitignore index e9355c75a..cd5008e10 100644 --- a/templates/forms/.gitignore +++ b/templates/forms/.gitignore @@ -33,6 +33,12 @@ data/app.db data/app.db-wal data/app.db-shm +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + # Learnings learnings.md diff --git a/templates/issues/.gitignore b/templates/issues/.gitignore index 7577e7827..17fb2f57c 100644 --- a/templates/issues/.gitignore +++ b/templates/issues/.gitignore @@ -35,6 +35,12 @@ data/settings.json data/.sessions.json data/app.db +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + # Build build/ diff --git a/templates/macros/.gitignore b/templates/macros/.gitignore index 0775a23f1..038a925b9 100644 --- a/templates/macros/.gitignore +++ b/templates/macros/.gitignore @@ -30,6 +30,12 @@ build/ data/app.db .react-router/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .generated/ .vercel/ diff --git a/templates/mail/.gitignore b/templates/mail/.gitignore index bb5bf34f4..b41af1a4d 100644 --- a/templates/mail/.gitignore +++ b/templates/mail/.gitignore @@ -45,11 +45,17 @@ data/google-accounts/ # Uploaded media (images, attachments) data/uploads/ -# Ephemeral data -data/refresh-trigger.json -data/app.db -data/app.db-wal -data/app.db-shm +# Ephemeral data +data/refresh-trigger.json +data/app.db +data/app.db-wal +data/app.db-shm + +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db .claude/scheduled_tasks.lock # Learnings (personal preferences and memory — use learnings.defaults.md for tracked defaults) diff --git a/templates/recruiting/.gitignore b/templates/recruiting/.gitignore index 583b74dc8..fae913df7 100644 --- a/templates/recruiting/.gitignore +++ b/templates/recruiting/.gitignore @@ -13,6 +13,12 @@ data/app.db data/app.db-wal data/app.db-shm +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + build .generated/ diff --git a/templates/scheduling/.gitignore b/templates/scheduling/.gitignore index df1824e85..5c86f3be7 100644 --- a/templates/scheduling/.gitignore +++ b/templates/scheduling/.gitignore @@ -10,4 +10,10 @@ dist .env !.env.example +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/starter/.gitignore b/templates/starter/.gitignore index 295dcf032..446946399 100644 --- a/templates/starter/.gitignore +++ b/templates/starter/.gitignore @@ -1,8 +1,13 @@ - build .output/ templates/starter/.env .generated/ +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db + .vercel/ diff --git a/templates/videos/.gitignore b/templates/videos/.gitignore index 2e15b9538..d864746a2 100644 --- a/templates/videos/.gitignore +++ b/templates/videos/.gitignore @@ -29,10 +29,16 @@ dist-ssr .env .output/ -# SQLite database files -data/app.db -data/app.db-wal -data/app.db-shm +# SQLite database files +data/app.db +data/app.db-wal +data/app.db-shm + +# Stray SQLite files at the template root (real DB lives under data/) +/app.db +/app.db-shm +/app.db-wal +/local.db # Learnings (personal preferences and memory — use learnings.defaults.md for tracked defaults) learnings.md From a1fef8025d88e0c029950b6ee93bc4def8339ed0 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 5 May 2026 16:52:22 +0000 Subject: [PATCH 5/5] chore: add changeset for core patch --- .changeset/fix-dev-session-logging.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-dev-session-logging.md diff --git a/.changeset/fix-dev-session-logging.md b/.changeset/fix-dev-session-logging.md new file mode 100644 index 000000000..a0393cd69 --- /dev/null +++ b/.changeset/fix-dev-session-logging.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add [dev-session] log when auto-binding email in CLI runner; fix TS narrowing in db-reset-dev-owner; remove redundant trim in zeroChangesHint.