From 683ffb9fc1ed5e83bff4ddeb8ee738f79a738eb9 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:36:27 +0300 Subject: [PATCH 01/10] fix(dev-server): inject service role token for unauthenticated function calls The function router only forwarded Base44-Service-Authorization when a user Authorization header was present. Public-facing functions (e.g. a subscribe form) are called without user auth, so asServiceRole threw "Service token is required" before making any HTTP request. In production, Base44 always injects the service role token when forwarding requests to functions. Mirror that behaviour in the dev server by defaulting to a synthetic dev token when no user auth header exists. Co-Authored-By: Claude Sonnet 4.6 --- .../cli/src/cli/dev/dev-server/routes/functions.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli/dev/dev-server/routes/functions.ts b/packages/cli/src/cli/dev/dev-server/routes/functions.ts index 17a534b9..bd3789c0 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/functions.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/functions.ts @@ -24,9 +24,13 @@ export function createFunctionRouter( if (xAppId) { proxyReq.setHeader("Base44-App-Id", xAppId as string); } - if (authorization) { - proxyReq.setHeader("Base44-Service-Authorization", authorization); - } + // In production, Base44 always injects a service role token when forwarding + // to functions. Replicate that here so asServiceRole works even for + // unauthenticated callers (e.g. public-facing subscribe forms). + proxyReq.setHeader( + "Base44-Service-Authorization", + authorization ?? "Bearer base44-dev-service-token", + ); proxyReq.setHeader( "Base44-Api-Url", `${(req as unknown as Request).protocol}://${req.headers.host}`, From 9e06a6b2bab36105ae56e15771ddc8f6bcf06156 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:38:03 +0300 Subject: [PATCH 02/10] test(dev-server): add test for service token injection on unauthenticated calls Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/tests/cli/dev.spec.ts | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 38d7d731..527ec59c 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -71,4 +71,50 @@ describe("dev command", () => { const result = await handle.stop(); t.expectResult(result).toSucceed(); }); + + it("injects a synthetic service token for unauthenticated function calls", async () => { + await t.givenLoggedInWithProject(fixture("full-project")); + + await writeFile( + join( + t.getTempDir(), + "project", + "base44", + "functions", + "hello", + "index.ts", + ), + outdent` + Deno.serve((req: Request) => + Response.json({ + authorization: req.headers.get("authorization"), + serviceAuthorization: req.headers.get("base44-service-authorization"), + }), + ); + `, + ); + + const handle = await t.runLive("dev"); + const devServerUrl = await waitForDevServer(handle); + + // Call the function with no Authorization header (unauthenticated caller, + // e.g. a public subscribe form). The dev server must still inject a + // Base44-Service-Authorization so that asServiceRole works inside the function. + const response = await fetch( + `${devServerUrl}/api/apps/${t.api.appId}/functions/hello`, + { + headers: { + "X-App-Id": t.api.appId, + }, + }, + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.authorization).toBeNull(); + expect(body.serviceAuthorization).toBe("Bearer base44-dev-service-token"); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); }); From ab4a6d3d81917e6156e4e6a5e1e52f9007e4e09b Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:40:30 +0300 Subject: [PATCH 03/10] fix(test): cast response.json() to fix TS18046 type error --- packages/cli/tests/cli/dev.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 527ec59c..7d1ffe69 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -110,7 +110,7 @@ describe("dev command", () => { ); expect(response.status).toBe(200); - const body = await response.json(); + const body = await response.json() as Record; expect(body.authorization).toBeNull(); expect(body.serviceAuthorization).toBe("Bearer base44-dev-service-token"); From 6ffc50c1b3e23c4203660df6b77fa48903616703 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:55:44 +0300 Subject: [PATCH 04/10] fix(lint): wrap response.json() cast in parens for biome --- packages/cli/tests/cli/dev.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 7d1ffe69..325e6539 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -110,7 +110,7 @@ describe("dev command", () => { ); expect(response.status).toBe(200); - const body = await response.json() as Record; + const body = (await response.json()) as Record; expect(body.authorization).toBeNull(); expect(body.serviceAuthorization).toBe("Bearer base44-dev-service-token"); From be260b0613d2307e539bfaa80c0ab9b6b2caf6ed Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 17:03:42 +0300 Subject: [PATCH 05/10] fix(dev-server): use service role JWT locally --- .../cli/src/cli/dev/dev-server/auth/tokens.ts | 29 +++++ packages/cli/src/cli/dev/dev-server/db/rls.ts | 3 +- .../cli/dev/dev-server/routes/auth-router.ts | 9 +- .../routes/entities/current-user.ts | 17 +++ .../routes/entities/entities-router.ts | 4 +- .../cli/dev/dev-server/routes/functions.ts | 4 +- packages/cli/tests/cli/dev-rls.spec.ts | 39 ++++++ packages/cli/tests/cli/dev.spec.ts | 117 ++++++++++++++++-- 8 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/cli/dev/dev-server/auth/tokens.ts create mode 100644 packages/cli/tests/cli/dev-rls.spec.ts diff --git a/packages/cli/src/cli/dev/dev-server/auth/tokens.ts b/packages/cli/src/cli/dev/dev-server/auth/tokens.ts new file mode 100644 index 00000000..7c7fcd34 --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/auth/tokens.ts @@ -0,0 +1,29 @@ +import jwt from "jsonwebtoken"; + +export const LOCAL_DEV_SECRET = "LOCAL_DEV_SECRET"; + +/** + * Sentinel identity used for service-role (`asServiceRole`) requests in dev. + * In production Base44 injects a privileged service token; locally we mint a + * JWT for this subject and grant it full access (see `checkRLS`). + */ +export const SERVICE_ROLE_EMAIL = "server@server.com"; + +export const createJwtToken = (email: string) => { + return jwt.sign({ sub: email }, LOCAL_DEV_SECRET, { + expiresIn: "360d", + }); +}; + +/** + * Mints the service-role JWT injected as `Base44-Service-Authorization` so + * `asServiceRole` works locally regardless of how the caller is authenticated. + */ +export const createServiceToken = () => createJwtToken(SERVICE_ROLE_EMAIL); + +export const createServiceAuthorizationHeader = () => + `Bearer ${createServiceToken()}`; + +/** True when a JWT subject identifies the service-role principal. */ +export const isServiceSubject = (subject: string): boolean => + subject === SERVICE_ROLE_EMAIL; diff --git a/packages/cli/src/cli/dev/dev-server/db/rls.ts b/packages/cli/src/cli/dev/dev-server/db/rls.ts index 5e72700b..717852ff 100644 --- a/packages/cli/src/cli/dev/dev-server/db/rls.ts +++ b/packages/cli/src/cli/dev/dev-server/db/rls.ts @@ -153,6 +153,7 @@ export function checkRLS( record: Record, user: Record | undefined, ): boolean { + if (user?.is_service === true) return true; if (rule === undefined) return true; if (typeof rule === "boolean") return rule; if (!user) return false; @@ -187,7 +188,7 @@ export function applyFLS( const result: Record = {}; for (const [key, value] of Object.entries(record)) { const rule = schema.properties[key]?.rls?.[operation]; - if (!rule || checkRLS(rule, record, user)) { + if (rule === undefined || checkRLS(rule, record, user)) { result[key] = value; } } diff --git a/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts b/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts index c62975c7..d71151b1 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts @@ -1,11 +1,11 @@ import { randomInt } from "node:crypto"; import type { Request } from "express"; import { json, Router } from "express"; -import jwt from "jsonwebtoken"; import { nanoid } from "nanoid"; import * as z from "zod"; import { theme } from "@/cli/utils/theme.js"; import type { DevLogger } from "../../createDevLogger.js"; +import { createJwtToken } from "../auth/tokens.js"; import { type Database, PRIVATE_USER_COLLECTION, @@ -13,19 +13,12 @@ import { } from "../db/database.js"; import { getNowISOTimestamp } from "../utils.js"; -const LOCAL_DEV_SECRET = "LOCAL_DEV_SECRET"; const TEN_MINUTES = 10 * 60 * 1000; const generateCode = () => { return randomInt(100000, 1000000).toString(); }; -const createJwtToken = (email: string) => { - return jwt.sign({ sub: email }, LOCAL_DEV_SECRET, { - expiresIn: "360d", - }); -}; - const LoginBody = z.object({ email: z.email(), password: z.string() }); const VerifyOtpBody = z.object({ email: z.email(), otp_code: z.string() }); diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts index 08f1e5e4..3d5909cc 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts @@ -1,6 +1,10 @@ import type { Document } from "@seald-io/nedb"; import type { Request } from "express"; import jwt, { type JwtPayload } from "jsonwebtoken"; +import { + isServiceSubject, + SERVICE_ROLE_EMAIL, +} from "@/cli/dev/dev-server/auth/tokens.js"; import { type Database, USER_COLLECTION, @@ -9,9 +13,18 @@ import { export type UserDocument = Document<{ email: string; id: string; + is_service?: boolean; role: "admin" | "user"; }>; +const SERVICE_USER: UserDocument = { + _id: "service-role", + id: "service-role", + email: SERVICE_ROLE_EMAIL, + role: "admin", + is_service: true, +}; + type CurrentUserLookupResult = | { ok: true; user: UserDocument } | { ok: false; reason: "missing" | "invalid" | "not_found" }; @@ -43,6 +56,10 @@ export async function resolveCurrentUser( return { ok: false, reason: "invalid" }; } + if (isServiceSubject(subject)) { + return { ok: true, user: SERVICE_USER }; + } + const currentUser = await db .getCollection(USER_COLLECTION) ?.findOneAsync({ email: subject }); diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts index 5cadd9d1..473235d5 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts @@ -175,7 +175,7 @@ export async function createEntityRoutes( await queryEntity(collection, req.query), ); - if (schema.rls?.read && schema.rls.read !== true) { + if (schema.rls?.read !== undefined && schema.rls.read !== true) { results = results.filter((doc) => checkRLS(schema.rls!.read, doc, currentUser), ); @@ -392,7 +392,7 @@ export async function createEntityRoutes( // When RLS has a condition, find matching records and only delete allowed ones if (rlsDelete !== undefined && rlsDelete !== true) { - if (rlsDelete === false) { + if (rlsDelete === false && currentUser?.is_service !== true) { res.status(403).json({ error: "Permission denied" }); return; } diff --git a/packages/cli/src/cli/dev/dev-server/routes/functions.ts b/packages/cli/src/cli/dev/dev-server/routes/functions.ts index bd3789c0..cc4bc554 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/functions.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/functions.ts @@ -4,6 +4,7 @@ import type { Request, Response } from "express"; import { Router } from "express"; import { createProxyMiddleware } from "http-proxy-middleware"; import type { DevLogger } from "@/cli/dev/createDevLogger.js"; +import { createServiceAuthorizationHeader } from "@/cli/dev/dev-server/auth/tokens.js"; import type { FunctionManager } from "@/cli/dev/dev-server/function-manager.js"; export function createFunctionRouter( @@ -19,7 +20,6 @@ export function createFunctionRouter( on: { proxyReq: (proxyReq, req) => { const xAppId = req.headers["x-app-id"]; - const authorization = req.headers.authorization; if (xAppId) { proxyReq.setHeader("Base44-App-Id", xAppId as string); @@ -29,7 +29,7 @@ export function createFunctionRouter( // unauthenticated callers (e.g. public-facing subscribe forms). proxyReq.setHeader( "Base44-Service-Authorization", - authorization ?? "Bearer base44-dev-service-token", + createServiceAuthorizationHeader(), ); proxyReq.setHeader( "Base44-Api-Url", diff --git a/packages/cli/tests/cli/dev-rls.spec.ts b/packages/cli/tests/cli/dev-rls.spec.ts new file mode 100644 index 00000000..99f3ce77 --- /dev/null +++ b/packages/cli/tests/cli/dev-rls.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { applyFLS, checkRLS } from "@/cli/dev/dev-server/db/rls.js"; +import type { Entity } from "@/core/resources/entity/schema.js"; + +const serviceUser = { + email: "server@server.com", + id: "service-role", + is_service: true, + role: "admin", +}; + +describe("dev RLS", () => { + it("allows service users to bypass explicit false RLS rules", () => { + expect(checkRLS(false, {}, serviceUser)).toBe(true); + }); + + it("treats explicit false FLS as deny for normal users and allow for service users", () => { + const schema: Entity = { + name: "PrivateNote", + type: "object", + properties: { + title: { type: "string" }, + secret: { + type: "string", + rls: { + read: false, + }, + }, + }, + source: { type: "project" }, + }; + const record = { title: "Visible", secret: "Hidden" }; + + expect(applyFLS(record, schema, undefined, "read")).toEqual({ + title: "Visible", + }); + expect(applyFLS(record, schema, serviceUser, "read")).toEqual(record); + }); +}); diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 325e6539..fd33c290 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -1,10 +1,21 @@ -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import jwt from "jsonwebtoken"; import { outdent } from "outdent"; import { describe, expect, it } from "vitest"; +import { + createServiceAuthorizationHeader, + SERVICE_ROLE_EMAIL, +} from "@/cli/dev/dev-server/auth/tokens.js"; import { waitForDevServer } from "./testkit/dev-utils.js"; import { fixture, setupCLITests } from "./testkit/index.js"; +const expectServiceAuthorization = (value: unknown) => { + expect(value).toEqual(expect.stringMatching(/^Bearer \S+$/)); + const token = (value as string).replace("Bearer ", ""); + expect(jwt.decode(token)?.sub).toBe(SERVICE_ROLE_EMAIL); +}; + describe("dev command", () => { const t = setupCLITests(); @@ -27,7 +38,7 @@ describe("dev command", () => { t.expectResult(result).toSucceed(); }); - it("forwards the service token header from Authorization to local functions", async () => { + it("forwards caller Authorization and injects a service JWT to local functions", async () => { await t.givenLoggedInWithProject(fixture("full-project")); await writeFile( @@ -63,10 +74,9 @@ describe("dev command", () => { ); expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - authorization: "Bearer test-app-token", - serviceAuthorization: "Bearer test-app-token", - }); + const body = (await response.json()) as Record; + expect(body.authorization).toBe("Bearer test-app-token"); + expectServiceAuthorization(body.serviceAuthorization); const result = await handle.stop(); t.expectResult(result).toSucceed(); @@ -112,7 +122,100 @@ describe("dev command", () => { expect(response.status).toBe(200); const body = (await response.json()) as Record; expect(body.authorization).toBeNull(); - expect(body.serviceAuthorization).toBe("Bearer base44-dev-service-token"); + expectServiceAuthorization(body.serviceAuthorization); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("allows service-role JWTs to bypass denied entity create RLS", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + + const entitiesDir = join(t.getTempDir(), "project", "base44", "entities"); + await mkdir(entitiesDir, { recursive: true }); + await writeFile( + join(entitiesDir, "private-note.jsonc"), + outdent` + { + "name": "PrivateNote", + "type": "object", + "properties": { + "title": { "type": "string" } + }, + "rls": { + "create": false, + "read": false + } + } + `, + ); + + const handle = await t.runLive("dev"); + const devServerUrl = await waitForDevServer(handle); + const url = `${devServerUrl}/api/apps/${t.api.appId}/entities/PrivateNote`; + + const unauthenticatedResponse = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-App-Id": t.api.appId, + }, + body: JSON.stringify({ title: "Unauthenticated" }), + }); + + expect(unauthenticatedResponse.status).toBe(403); + + const serviceResponse = await fetch(url, { + method: "POST", + headers: { + Authorization: createServiceAuthorizationHeader(), + "Content-Type": "application/json", + "X-App-Id": t.api.appId, + }, + body: JSON.stringify({ title: "Service role" }), + }); + + expect(serviceResponse.status).toBe(201); + const body = (await serviceResponse.json()) as Record; + expect(body.title).toBe("Service role"); + expect(body.created_by).toBe(SERVICE_ROLE_EMAIL); + + const unauthenticatedListResponse = await fetch(url, { + headers: { + "X-App-Id": t.api.appId, + }, + }); + expect(unauthenticatedListResponse.status).toBe(200); + await expect(unauthenticatedListResponse.json()).resolves.toEqual([]); + + const serviceListResponse = await fetch(url, { + headers: { + Authorization: createServiceAuthorizationHeader(), + "X-App-Id": t.api.appId, + }, + }); + expect(serviceListResponse.status).toBe(200); + const serviceListBody = (await serviceListResponse.json()) as Record< + string, + unknown + >[]; + expect(serviceListBody).toHaveLength(1); + expect(serviceListBody[0].title).toBe("Service role"); + + const serviceDeleteResponse = await fetch(url, { + method: "DELETE", + headers: { + Authorization: createServiceAuthorizationHeader(), + "Content-Type": "application/json", + "X-App-Id": t.api.appId, + }, + body: JSON.stringify({}), + }); + expect(serviceDeleteResponse.status).toBe(200); + await expect(serviceDeleteResponse.json()).resolves.toMatchObject({ + deleted: 1, + success: true, + }); const result = await handle.stop(); t.expectResult(result).toSucceed(); From b24046fad12b128c42254fa8c03da1b8f48992f0 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 17:22:27 +0300 Subject: [PATCH 06/10] fix(lint): sort cli exports --- packages/cli/src/cli/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 6dbd36ad..3b928229 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -71,4 +71,4 @@ async function runCLI(options?: RunCLIOptions): Promise { } } -export { runCLI, createProgram, CLIExitError }; +export { CLIExitError, createProgram, runCLI }; From e0eca0bc1f4f863429eddeb0dd1618e92af37b0d Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 17:47:14 +0300 Subject: [PATCH 07/10] fix(ci): harden dev service-token tests --- .../cli/src/cli/dev/dev-server/auth/tokens.ts | 4 ++-- packages/cli/tests/cli/dev.spec.ts | 20 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/cli/dev/dev-server/auth/tokens.ts b/packages/cli/src/cli/dev/dev-server/auth/tokens.ts index 7c7fcd34..b1f259e4 100644 --- a/packages/cli/src/cli/dev/dev-server/auth/tokens.ts +++ b/packages/cli/src/cli/dev/dev-server/auth/tokens.ts @@ -1,6 +1,6 @@ import jwt from "jsonwebtoken"; -export const LOCAL_DEV_SECRET = "LOCAL_DEV_SECRET"; +const LOCAL_DEV_SECRET = "LOCAL_DEV_SECRET"; /** * Sentinel identity used for service-role (`asServiceRole`) requests in dev. @@ -19,7 +19,7 @@ export const createJwtToken = (email: string) => { * Mints the service-role JWT injected as `Base44-Service-Authorization` so * `asServiceRole` works locally regardless of how the caller is authenticated. */ -export const createServiceToken = () => createJwtToken(SERVICE_ROLE_EMAIL); +const createServiceToken = () => createJwtToken(SERVICE_ROLE_EMAIL); export const createServiceAuthorizationHeader = () => `Bearer ${createServiceToken()}`; diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index fd33c290..7696b5a5 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -51,12 +51,14 @@ describe("dev command", () => { "index.ts", ), outdent` - Deno.serve((req: Request) => - Response.json({ + Deno.serve((req: Request) => { + return new Response(JSON.stringify({ authorization: req.headers.get("authorization"), serviceAuthorization: req.headers.get("base44-service-authorization"), - }), - ); + }), { + headers: { "Content-Type": "application/json" }, + }); + }); `, ); @@ -95,12 +97,14 @@ describe("dev command", () => { "index.ts", ), outdent` - Deno.serve((req: Request) => - Response.json({ + Deno.serve((req: Request) => { + return new Response(JSON.stringify({ authorization: req.headers.get("authorization"), serviceAuthorization: req.headers.get("base44-service-authorization"), - }), - ); + }), { + headers: { "Content-Type": "application/json" }, + }); + }); `, ); From dabbb521c894778e5a8055de4c79675f1b503544 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 18:27:16 +0300 Subject: [PATCH 08/10] fix(ci): avoid deno dependency in service-token tests --- packages/cli/tests/cli/dev.spec.ts | 195 +++++++++++++++++------------ 1 file changed, 113 insertions(+), 82 deletions(-) diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 7696b5a5..9d4b4206 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -1,12 +1,17 @@ import { mkdir, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; import { join } from "node:path"; +import express from "express"; import jwt from "jsonwebtoken"; import { outdent } from "outdent"; import { describe, expect, it } from "vitest"; +import type { DevLogger } from "@/cli/dev/createDevLogger.js"; import { createServiceAuthorizationHeader, SERVICE_ROLE_EMAIL, } from "@/cli/dev/dev-server/auth/tokens.js"; +import type { FunctionManager } from "@/cli/dev/dev-server/function-manager.js"; +import { createFunctionRouter } from "@/cli/dev/dev-server/routes/functions.js"; import { waitForDevServer } from "./testkit/dev-utils.js"; import { fixture, setupCLITests } from "./testkit/index.js"; @@ -16,6 +21,76 @@ const expectServiceAuthorization = (value: unknown) => { expect(jwt.decode(token)?.sub).toBe(SERVICE_ROLE_EMAIL); }; +const noopLogger: DevLogger = { + error: () => {}, + log: () => {}, + warn: () => {}, +}; + +const listen = async (server: Server): Promise => { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Test server did not receive a TCP port")); + return; + } + + resolve(address.port); + }); + }); +}; + +const close = async (server: Server): Promise => { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +}; + +const startHeaderEchoFunctionProxy = async () => { + const upstream = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + appId: req.headers["base44-app-id"] ?? null, + authorization: req.headers.authorization ?? null, + serviceAuthorization: + req.headers["base44-service-authorization"] ?? null, + }), + ); + }); + const upstreamPort = await listen(upstream); + + const manager = { + ensureRunning: async () => upstreamPort, + } as unknown as FunctionManager; + + const app = express(); + app.use( + "/api/apps/:appId/functions", + createFunctionRouter(manager, noopLogger), + ); + + const proxy = createServer(app); + const proxyPort = await listen(proxy); + + return { + close: async () => { + await close(proxy); + await close(upstream); + }, + url: `http://127.0.0.1:${proxyPort}`, + }; +}; + describe("dev command", () => { const t = setupCLITests(); @@ -39,97 +114,53 @@ describe("dev command", () => { }); it("forwards caller Authorization and injects a service JWT to local functions", async () => { - await t.givenLoggedInWithProject(fixture("full-project")); - - await writeFile( - join( - t.getTempDir(), - "project", - "base44", - "functions", - "hello", - "index.ts", - ), - outdent` - Deno.serve((req: Request) => { - return new Response(JSON.stringify({ - authorization: req.headers.get("authorization"), - serviceAuthorization: req.headers.get("base44-service-authorization"), - }), { - headers: { "Content-Type": "application/json" }, - }); - }); - `, - ); + const proxy = await startHeaderEchoFunctionProxy(); - const handle = await t.runLive("dev"); - const devServerUrl = await waitForDevServer(handle); - - const response = await fetch( - `${devServerUrl}/api/apps/${t.api.appId}/functions/hello`, - { - headers: { - Authorization: "Bearer test-app-token", - "X-App-Id": t.api.appId, + try { + const response = await fetch( + `${proxy.url}/api/apps/${t.api.appId}/functions/hello`, + { + headers: { + Authorization: "Bearer test-app-token", + "X-App-Id": t.api.appId, + }, }, - }, - ); + ); - expect(response.status).toBe(200); - const body = (await response.json()) as Record; - expect(body.authorization).toBe("Bearer test-app-token"); - expectServiceAuthorization(body.serviceAuthorization); - - const result = await handle.stop(); - t.expectResult(result).toSucceed(); + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.authorization).toBe("Bearer test-app-token"); + expect(body.appId).toBe(t.api.appId); + expectServiceAuthorization(body.serviceAuthorization); + } finally { + await proxy.close(); + } }); it("injects a synthetic service token for unauthenticated function calls", async () => { - await t.givenLoggedInWithProject(fixture("full-project")); - - await writeFile( - join( - t.getTempDir(), - "project", - "base44", - "functions", - "hello", - "index.ts", - ), - outdent` - Deno.serve((req: Request) => { - return new Response(JSON.stringify({ - authorization: req.headers.get("authorization"), - serviceAuthorization: req.headers.get("base44-service-authorization"), - }), { - headers: { "Content-Type": "application/json" }, - }); - }); - `, - ); + const proxy = await startHeaderEchoFunctionProxy(); - const handle = await t.runLive("dev"); - const devServerUrl = await waitForDevServer(handle); - - // Call the function with no Authorization header (unauthenticated caller, - // e.g. a public subscribe form). The dev server must still inject a - // Base44-Service-Authorization so that asServiceRole works inside the function. - const response = await fetch( - `${devServerUrl}/api/apps/${t.api.appId}/functions/hello`, - { - headers: { - "X-App-Id": t.api.appId, + try { + // Call the function with no Authorization header (unauthenticated caller, + // e.g. a public subscribe form). The dev server must still inject a + // Base44-Service-Authorization so that asServiceRole works inside the function. + const response = await fetch( + `${proxy.url}/api/apps/${t.api.appId}/functions/hello`, + { + headers: { + "X-App-Id": t.api.appId, + }, }, - }, - ); + ); - expect(response.status).toBe(200); - const body = (await response.json()) as Record; - expect(body.authorization).toBeNull(); - expectServiceAuthorization(body.serviceAuthorization); - - const result = await handle.stop(); - t.expectResult(result).toSucceed(); + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.authorization).toBeNull(); + expect(body.appId).toBe(t.api.appId); + expectServiceAuthorization(body.serviceAuthorization); + } finally { + await proxy.close(); + } }); it("allows service-role JWTs to bypass denied entity create RLS", async () => { From 625f52244ec2eee07074f82ece4a457b3563bed4 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 2 Jun 2026 14:59:47 +0300 Subject: [PATCH 09/10] Revert "fix(ci): avoid deno dependency in service-token tests" This reverts commit dabbb521c894778e5a8055de4c79675f1b503544. --- packages/cli/tests/cli/dev.spec.ts | 195 ++++++++++++----------------- 1 file changed, 82 insertions(+), 113 deletions(-) diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 9d4b4206..7696b5a5 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -1,17 +1,12 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { createServer, type Server } from "node:http"; import { join } from "node:path"; -import express from "express"; import jwt from "jsonwebtoken"; import { outdent } from "outdent"; import { describe, expect, it } from "vitest"; -import type { DevLogger } from "@/cli/dev/createDevLogger.js"; import { createServiceAuthorizationHeader, SERVICE_ROLE_EMAIL, } from "@/cli/dev/dev-server/auth/tokens.js"; -import type { FunctionManager } from "@/cli/dev/dev-server/function-manager.js"; -import { createFunctionRouter } from "@/cli/dev/dev-server/routes/functions.js"; import { waitForDevServer } from "./testkit/dev-utils.js"; import { fixture, setupCLITests } from "./testkit/index.js"; @@ -21,76 +16,6 @@ const expectServiceAuthorization = (value: unknown) => { expect(jwt.decode(token)?.sub).toBe(SERVICE_ROLE_EMAIL); }; -const noopLogger: DevLogger = { - error: () => {}, - log: () => {}, - warn: () => {}, -}; - -const listen = async (server: Server): Promise => { - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, () => { - const address = server.address(); - if (!address || typeof address === "string") { - reject(new Error("Test server did not receive a TCP port")); - return; - } - - resolve(address.port); - }); - }); -}; - -const close = async (server: Server): Promise => { - return new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } - - resolve(); - }); - }); -}; - -const startHeaderEchoFunctionProxy = async () => { - const upstream = createServer((req, res) => { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - appId: req.headers["base44-app-id"] ?? null, - authorization: req.headers.authorization ?? null, - serviceAuthorization: - req.headers["base44-service-authorization"] ?? null, - }), - ); - }); - const upstreamPort = await listen(upstream); - - const manager = { - ensureRunning: async () => upstreamPort, - } as unknown as FunctionManager; - - const app = express(); - app.use( - "/api/apps/:appId/functions", - createFunctionRouter(manager, noopLogger), - ); - - const proxy = createServer(app); - const proxyPort = await listen(proxy); - - return { - close: async () => { - await close(proxy); - await close(upstream); - }, - url: `http://127.0.0.1:${proxyPort}`, - }; -}; - describe("dev command", () => { const t = setupCLITests(); @@ -114,53 +39,97 @@ describe("dev command", () => { }); it("forwards caller Authorization and injects a service JWT to local functions", async () => { - const proxy = await startHeaderEchoFunctionProxy(); + await t.givenLoggedInWithProject(fixture("full-project")); - try { - const response = await fetch( - `${proxy.url}/api/apps/${t.api.appId}/functions/hello`, - { - headers: { - Authorization: "Bearer test-app-token", - "X-App-Id": t.api.appId, - }, + await writeFile( + join( + t.getTempDir(), + "project", + "base44", + "functions", + "hello", + "index.ts", + ), + outdent` + Deno.serve((req: Request) => { + return new Response(JSON.stringify({ + authorization: req.headers.get("authorization"), + serviceAuthorization: req.headers.get("base44-service-authorization"), + }), { + headers: { "Content-Type": "application/json" }, + }); + }); + `, + ); + + const handle = await t.runLive("dev"); + const devServerUrl = await waitForDevServer(handle); + + const response = await fetch( + `${devServerUrl}/api/apps/${t.api.appId}/functions/hello`, + { + headers: { + Authorization: "Bearer test-app-token", + "X-App-Id": t.api.appId, }, - ); + }, + ); - expect(response.status).toBe(200); - const body = (await response.json()) as Record; - expect(body.authorization).toBe("Bearer test-app-token"); - expect(body.appId).toBe(t.api.appId); - expectServiceAuthorization(body.serviceAuthorization); - } finally { - await proxy.close(); - } + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.authorization).toBe("Bearer test-app-token"); + expectServiceAuthorization(body.serviceAuthorization); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); }); it("injects a synthetic service token for unauthenticated function calls", async () => { - const proxy = await startHeaderEchoFunctionProxy(); + await t.givenLoggedInWithProject(fixture("full-project")); - try { - // Call the function with no Authorization header (unauthenticated caller, - // e.g. a public subscribe form). The dev server must still inject a - // Base44-Service-Authorization so that asServiceRole works inside the function. - const response = await fetch( - `${proxy.url}/api/apps/${t.api.appId}/functions/hello`, - { - headers: { - "X-App-Id": t.api.appId, - }, + await writeFile( + join( + t.getTempDir(), + "project", + "base44", + "functions", + "hello", + "index.ts", + ), + outdent` + Deno.serve((req: Request) => { + return new Response(JSON.stringify({ + authorization: req.headers.get("authorization"), + serviceAuthorization: req.headers.get("base44-service-authorization"), + }), { + headers: { "Content-Type": "application/json" }, + }); + }); + `, + ); + + const handle = await t.runLive("dev"); + const devServerUrl = await waitForDevServer(handle); + + // Call the function with no Authorization header (unauthenticated caller, + // e.g. a public subscribe form). The dev server must still inject a + // Base44-Service-Authorization so that asServiceRole works inside the function. + const response = await fetch( + `${devServerUrl}/api/apps/${t.api.appId}/functions/hello`, + { + headers: { + "X-App-Id": t.api.appId, }, - ); + }, + ); - expect(response.status).toBe(200); - const body = (await response.json()) as Record; - expect(body.authorization).toBeNull(); - expect(body.appId).toBe(t.api.appId); - expectServiceAuthorization(body.serviceAuthorization); - } finally { - await proxy.close(); - } + expect(response.status).toBe(200); + const body = (await response.json()) as Record; + expect(body.authorization).toBeNull(); + expectServiceAuthorization(body.serviceAuthorization); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); }); it("allows service-role JWTs to bypass denied entity create RLS", async () => { From 59274738ea0568e6694c6b6095d75b0d8fc77a6b Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 2 Jun 2026 14:42:24 +0300 Subject: [PATCH 10/10] fix(dev): patch Deno.serve via defineProperty for Deno 2.8 compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deno 2.8 exposes `Deno.serve` as a getter-only property, so the wrapper's plain `Deno.serve = ...` assignment throws `TypeError: Cannot set property serve ... which has only a getter`. The function process crashes on startup, the dev-server proxy can't reach it, and requests return 500. Override it with Object.defineProperty (writable + configurable) instead, which works on both the old writable property and the new 2.8 accessor. CI installs Deno via `setup-deno@v2` with `deno-version: v2.x`, which now floats to 2.8.1 — this is why the local-functions dev tests started failing on every PR despite no related code change. Co-Authored-By: Claude Opus 4.8 --- packages/cli/deno-runtime/main.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/deno-runtime/main.ts b/packages/cli/deno-runtime/main.ts index d6c6874a..d888697c 100644 --- a/packages/cli/deno-runtime/main.ts +++ b/packages/cli/deno-runtime/main.ts @@ -25,9 +25,8 @@ if (!functionPath) { // Store the original Deno.serve const originalServe = Deno.serve.bind(Deno); -// Patch Deno.serve to inject our port and add onListen callback -// @ts-expect-error - We're intentionally overriding Deno.serve -Deno.serve = ( +// Patch Deno.serve to inject our port and add onListen callback. +const patchedServe = ( optionsOrHandler: | Deno.ServeOptions | Deno.ServeHandler @@ -60,6 +59,15 @@ Deno.serve = ( return originalServe({ ...options, port, onListen }); }; +// Deno 2.8 exposes `Deno.serve` as a getter-only property, so a plain +// `Deno.serve = ...` assignment throws. Use defineProperty to override it +// (works on both the old writable property and the new accessor). +Object.defineProperty(Deno, "serve", { + value: patchedServe, + writable: true, + configurable: true, +}); + console.log(`[${functionName}] Starting function from ${functionPath}`); // Dynamically import the user's function