From 2b8b4100d9b15f6c97178a82d893a40aa2087895 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Mon, 20 Jul 2026 15:33:08 +0300 Subject: [PATCH 1/3] feat(exec): add --privileged and --data-env flags Faithful port of #435 (Netanel Gilad) with the renamed flags: - --privileged sends the X-Bypass-RLS header (bypass RLS) - --data-env sends the X-Data-Env header Both flow exec.ts -> run-script.ts (BASE44_PRIVILEGED / BASE44_DATA_ENV env vars) -> the Deno wrapper, which builds the SDK request headers. Open backend questions surfaced during review (RLS-bypass exposure and prod-edge survival, data-env value validation, and data-env scope for backend-function invocations) are flagged inline as QUESTION(@netanelgilad) comments for Netanel to resolve at review. Refs #435 Co-Authored-By: Netanel Gilad Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/deno-runtime/exec.ts | 14 +++ packages/cli/src/cli/commands/exec.ts | 67 +++++++---- packages/cli/src/core/exec/run-script.ts | 6 +- packages/cli/tests/cli/exec.spec.ts | 139 +++++++++++++++++++++++ 4 files changed, 206 insertions(+), 20 deletions(-) diff --git a/packages/cli/deno-runtime/exec.ts b/packages/cli/deno-runtime/exec.ts index 2c273aa5..649ad0fc 100644 --- a/packages/cli/deno-runtime/exec.ts +++ b/packages/cli/deno-runtime/exec.ts @@ -9,6 +9,8 @@ * - BASE44_APP_ID: App identifier from .app.jsonc * - BASE44_ACCESS_TOKEN: User's access token * - BASE44_APP_BASE_URL: App's published URL / subdomain (used for function calls) + * - BASE44_PRIVILEGED: When "true", adds the X-Bypass-RLS header (bypass RLS) + * - BASE44_DATA_ENV: When set, adds the X-Data-Env header (target data environment) */ export {}; @@ -17,6 +19,8 @@ const scriptPath = Deno.env.get("SCRIPT_PATH"); const appId = Deno.env.get("BASE44_APP_ID"); const accessToken = Deno.env.get("BASE44_ACCESS_TOKEN"); const appBaseUrl = Deno.env.get("BASE44_APP_BASE_URL"); +const isPrivileged = Deno.env.get("BASE44_PRIVILEGED") === "true"; +const dataEnv = Deno.env.get("BASE44_DATA_ENV"); if (!scriptPath) { console.error("SCRIPT_PATH environment variable is required"); @@ -35,10 +39,20 @@ if (!appBaseUrl) { import { createClient } from "npm:@base44/sdk"; +const customHeaders: Record = {}; +if (isPrivileged) customHeaders["X-Bypass-RLS"] = "true"; +// QUESTION(@netanelgilad): backend get_data_environment_from_request honors only +// "dev" and "prod" on X-Data-Env ("share" is host-derived, unknown values fall +// through to prod). It applies to entity CRUD, but for backend-function invocations +// a caller-supplied "dev" is dropped by trusted_request_data_env (prod/no-signal +// pass through). Intended scope for --data-env? +if (dataEnv) customHeaders["X-Data-Env"] = dataEnv; + const base44 = createClient({ appId, token: accessToken, serverUrl: appBaseUrl, + headers: customHeaders, }); (globalThis as any).base44 = base44; diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 5288d874..ff03babf 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -10,6 +10,8 @@ import { readAuth } from "@/core/index.js"; interface ExecOptions { local?: boolean; port?: string; + privileged?: boolean; + dataEnv?: string; } function readStdin(): Promise { @@ -91,7 +93,13 @@ async function execAction( ) : undefined; - const { exitCode } = await runScript({ appId: app!.id, code, local }); + const { exitCode } = await runScript({ + appId: app!.id, + code, + local, + privileged: options.privileged, + dataEnv: options.dataEnv, + }); if (exitCode !== 0) { process.exitCode = exitCode; @@ -101,21 +109,38 @@ async function execAction( } export function getExecCommand(): Command { - return new Base44Command("exec") - .description( - "Run a script with the Base44 SDK pre-authenticated as the current user", - ) - .option( - "--local", - "Run against the local `base44 dev` server instead of the deployed app", - ) - .option( - "--port ", - `Port the local dev server is on (with --local; defaults to ${DEFAULT_DEV_SERVER_PORT})`, - ) - .addHelpText( - "after", - ` + return ( + new Base44Command("exec") + .description( + "Run a script with the Base44 SDK pre-authenticated as the current user", + ) + .option( + "--local", + "Run against the local `base44 dev` server instead of the deployed app", + ) + .option( + "--port ", + `Port the local dev server is on (with --local; defaults to ${DEFAULT_DEV_SERVER_PORT})`, + ) + // QUESTION(@netanelgilad): remote exec already requires app owner/editor (its + // token endpoint is on AppAdminRouter / is_genuine_editor). Separately, the + // X-Bypass-RLS header itself is NOT re-gated at the standard entity-CRUD route + // (_is_bypass_rls_request has no owner/admin check there), and it may be + // stripped at the prod edge (untested). Intended exposure? + .option( + "--privileged", + "Run with admin privileges (bypass RLS). Requires app owner/editor role.", + ) + // QUESTION(@netanelgilad): value is passed through raw. The backend honors + // only "dev" and "prod" on X-Data-Env; "share" is host-derived and unknown + // values silently fall through to prod. Should the CLI validate this? + .option( + "--data-env ", + "Data environment to run against (e.g. dev, prod)", + ) + .addHelpText( + "after", + ` Examples: Run a script file: $ cat ./script.ts | base44 exec @@ -124,7 +149,11 @@ Examples: $ echo "const users = await base44.entities.User.list()" | base44 exec Against the local dev server (base44 dev must be running): - $ echo "await base44.entities.Task.create({ title: 'seed' })" | base44 exec --local`, - ) - .action(execAction); + $ echo "await base44.entities.Task.create({ title: 'seed' })" | base44 exec --local + + With privileged access (bypass RLS): + $ echo "const all = await base44.entities.Task.list()" | base44 exec --privileged`, + ) + .action(execAction) + ); } diff --git a/packages/cli/src/core/exec/run-script.ts b/packages/cli/src/core/exec/run-script.ts index 37d63b9f..82473b8b 100644 --- a/packages/cli/src/core/exec/run-script.ts +++ b/packages/cli/src/core/exec/run-script.ts @@ -14,6 +14,8 @@ interface RunScriptOptions { * rather than fetched via `getSiteUrl()` / `getAppUserToken()`. */ local?: { serverUrl: string; token: string }; + privileged?: boolean; + dataEnv?: string; } interface RunScriptResult { @@ -23,7 +25,7 @@ interface RunScriptResult { export async function runScript( options: RunScriptOptions, ): Promise { - const { appId, code, local } = options; + const { appId, code, local, privileged, dataEnv } = options; verifyDenoInstalled("to run scripts with exec"); @@ -60,6 +62,8 @@ export async function runScript( BASE44_APP_ID: appId, BASE44_ACCESS_TOKEN: appUserToken, BASE44_APP_BASE_URL: appBaseUrl, + ...(privileged ? { BASE44_PRIVILEGED: "true" } : {}), + ...(dataEnv ? { BASE44_DATA_ENV: dataEnv } : {}), }, stdio: "inherit", }, diff --git a/packages/cli/tests/cli/exec.spec.ts b/packages/cli/tests/cli/exec.spec.ts index eda1ef84..85e11421 100644 --- a/packages/cli/tests/cli/exec.spec.ts +++ b/packages/cli/tests/cli/exec.spec.ts @@ -175,4 +175,143 @@ describe("exec command", () => { t.expectResult(result).toFail(); t.expectResult(result).toContain("--port can only be used with --local"); }); + + // ─── PRIVILEGED / DATA-ENV HEADERS (ported from #435) ───────── + + it("sends the X-Bypass-RLS header when --privileged is set", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec", "--privileged"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-bypass-rls"]).toBe("true"); + }); + + it("does not send the X-Bypass-RLS header without --privileged", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-bypass-rls"]).toBeUndefined(); + }); + + it("sends the X-Data-Env header with the given value", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec", "--data-env", "dev"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-data-env"]).toBe("dev"); + }); + + it("does not send the X-Data-Env header without --data-env", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-data-env"]).toBeUndefined(); + }); + + it("sends both headers when --privileged and --data-env are combined", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: t.api.baseUrl }); + + let capturedHeaders: Record = {}; + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/entities/Task`, + (req, res) => { + capturedHeaders = req.headers as Record; + res.json([]); + }, + ); + t.givenStdin("await base44.entities.Task.list();"); + + const result = await t.run("exec", "--privileged", "--data-env", "dev"); + + t.expectResult(result).toSucceed(); + expect(capturedHeaders["x-bypass-rls"]).toBe("true"); + expect(capturedHeaders["x-data-env"]).toBe("dev"); + }); + + it("passes BASE44_PRIVILEGED to the Deno subprocess with --privileged", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + t.givenStdin( + 'console.log("PRIVILEGED=" + Deno.env.get("BASE44_PRIVILEGED"));', + ); + + const result = await t.run("exec", "--privileged"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toContain("PRIVILEGED=true"); + }); + + it("passes BASE44_DATA_ENV to the Deno subprocess with --data-env", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAuthToken("test-app-token"); + t.api.mockSiteUrl({ url: "https://test-app.base44.app" }); + t.givenStdin('console.log("DATA_ENV=" + Deno.env.get("BASE44_DATA_ENV"));'); + + const result = await t.run("exec", "--data-env", "dev"); + + t.expectResult(result).toSucceed(); + expect(result.stdout).toContain("DATA_ENV=dev"); + }); }); From 2d38b3f38482b2d9824447f0fac9ece7832ffdd2 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Thu, 23 Jul 2026 10:50:17 +0300 Subject: [PATCH 2/3] chore(exec): remove resolved review questions on --data-env Netanel's calls: no CLI-side value validation (server's job), and the backend-function invocation scope is a backend concern, not the CLI's. Co-Authored-By: Claude Fable 5 --- packages/cli/deno-runtime/exec.ts | 5 ----- packages/cli/src/cli/commands/exec.ts | 3 --- 2 files changed, 8 deletions(-) diff --git a/packages/cli/deno-runtime/exec.ts b/packages/cli/deno-runtime/exec.ts index 649ad0fc..6a17cd1a 100644 --- a/packages/cli/deno-runtime/exec.ts +++ b/packages/cli/deno-runtime/exec.ts @@ -41,11 +41,6 @@ import { createClient } from "npm:@base44/sdk"; const customHeaders: Record = {}; if (isPrivileged) customHeaders["X-Bypass-RLS"] = "true"; -// QUESTION(@netanelgilad): backend get_data_environment_from_request honors only -// "dev" and "prod" on X-Data-Env ("share" is host-derived, unknown values fall -// through to prod). It applies to entity CRUD, but for backend-function invocations -// a caller-supplied "dev" is dropped by trusted_request_data_env (prod/no-signal -// pass through). Intended scope for --data-env? if (dataEnv) customHeaders["X-Data-Env"] = dataEnv; const base44 = createClient({ diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index ff03babf..5289e9aa 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -131,9 +131,6 @@ export function getExecCommand(): Command { "--privileged", "Run with admin privileges (bypass RLS). Requires app owner/editor role.", ) - // QUESTION(@netanelgilad): value is passed through raw. The backend honors - // only "dev" and "prod" on X-Data-Env; "share" is host-derived and unknown - // values silently fall through to prod. Should the CLI validate this? .option( "--data-env ", "Data environment to run against (e.g. dev, prod)", From 6e9087007cf8617d1c7f4a6586888825d8e77bf0 Mon Sep 17 00:00:00 2001 From: David Susskind Date: Thu, 23 Jul 2026 11:52:10 +0300 Subject: [PATCH 3/3] chore(exec): drop resolved review question on --privileged The X-Bypass-RLS header is gated to editors server-side in EntityCRUD (_verify_admin_permissions); no CLI-side concern remains. Co-Authored-By: Claude Fable 5 --- packages/cli/src/cli/commands/exec.ts | 57 ++++++++++++--------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/cli/commands/exec.ts b/packages/cli/src/cli/commands/exec.ts index 5289e9aa..f496efcd 100644 --- a/packages/cli/src/cli/commands/exec.ts +++ b/packages/cli/src/cli/commands/exec.ts @@ -109,35 +109,29 @@ async function execAction( } export function getExecCommand(): Command { - return ( - new Base44Command("exec") - .description( - "Run a script with the Base44 SDK pre-authenticated as the current user", - ) - .option( - "--local", - "Run against the local `base44 dev` server instead of the deployed app", - ) - .option( - "--port ", - `Port the local dev server is on (with --local; defaults to ${DEFAULT_DEV_SERVER_PORT})`, - ) - // QUESTION(@netanelgilad): remote exec already requires app owner/editor (its - // token endpoint is on AppAdminRouter / is_genuine_editor). Separately, the - // X-Bypass-RLS header itself is NOT re-gated at the standard entity-CRUD route - // (_is_bypass_rls_request has no owner/admin check there), and it may be - // stripped at the prod edge (untested). Intended exposure? - .option( - "--privileged", - "Run with admin privileges (bypass RLS). Requires app owner/editor role.", - ) - .option( - "--data-env ", - "Data environment to run against (e.g. dev, prod)", - ) - .addHelpText( - "after", - ` + return new Base44Command("exec") + .description( + "Run a script with the Base44 SDK pre-authenticated as the current user", + ) + .option( + "--local", + "Run against the local `base44 dev` server instead of the deployed app", + ) + .option( + "--port ", + `Port the local dev server is on (with --local; defaults to ${DEFAULT_DEV_SERVER_PORT})`, + ) + .option( + "--privileged", + "Run with admin privileges (bypass RLS). Requires app owner/editor role.", + ) + .option( + "--data-env ", + "Data environment to run against (e.g. dev, prod)", + ) + .addHelpText( + "after", + ` Examples: Run a script file: $ cat ./script.ts | base44 exec @@ -150,7 +144,6 @@ Examples: With privileged access (bypass RLS): $ echo "const all = await base44.entities.Task.list()" | base44 exec --privileged`, - ) - .action(execAction) - ); + ) + .action(execAction); }