From b4ed9c96990078ed25c7d261572e50e10023b964 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:36:27 +0300 Subject: [PATCH 1/9] 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 41902b2a7dadf1395f8373ccb39146729cd230f6 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:38:03 +0300 Subject: [PATCH 2/9] 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 39981cf5bfa879f9c1a2373ab397a276cdfdf4aa Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:40:30 +0300 Subject: [PATCH 3/9] 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 3613c6fbdfa43f62ca3d9e90e8b4fdcfc4b5beda Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Sun, 10 May 2026 14:55:44 +0300 Subject: [PATCH 4/9] 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 e7a8a41c8cf965d306dde818a23dd286e35d2f8c Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 17:03:42 +0300 Subject: [PATCH 5/9] 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 3b0dcbc7788e23761dda7d3736d9f7543430f630 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 17:22:27 +0300 Subject: [PATCH 6/9] 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 d10338136d46674cfae03ca3079efe551ce06795 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 17:47:14 +0300 Subject: [PATCH 7/9] 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 59a3d96921781139294bf3a06c4e79fc8ba1f101 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Mon, 1 Jun 2026 18:27:16 +0300 Subject: [PATCH 8/9] 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 33ab553ced181faad57b7dbf5174d33ad01b7321 Mon Sep 17 00:00:00 2001 From: Yury Michurin Date: Tue, 2 Jun 2026 14:59:47 +0300 Subject: [PATCH 9/9] 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 () => {