Skip to content
Open
14 changes: 11 additions & 3 deletions packages/cli/deno-runtime/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/cli/dev/dev-server/auth/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import jwt from "jsonwebtoken";

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.
*/
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;
3 changes: 2 additions & 1 deletion packages/cli/src/cli/dev/dev-server/db/rls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export function checkRLS(
record: Record<string, unknown>,
user: Record<string, unknown> | undefined,
): boolean {
if (user?.is_service === true) return true;
if (rule === undefined) return true;
if (typeof rule === "boolean") return rule;
if (!user) return false;
Expand Down Expand Up @@ -187,7 +188,7 @@ export function applyFLS(
const result: Record<string, unknown> = {};
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;
}
}
Expand Down
9 changes: 1 addition & 8 deletions packages/cli/src/cli/dev/dev-server/routes/auth-router.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
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,
USER_COLLECTION,
} 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() });

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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" };
Expand Down Expand Up @@ -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<UserDocument>({ email: subject });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down Expand Up @@ -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;
}
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/cli/dev/dev-server/routes/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -19,14 +20,17 @@ 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);
}
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",
createServiceAuthorizationHeader(),
);
proxyReq.setHeader(
"Base44-Api-Url",
`${(req as unknown as Request).protocol}://${req.headers.host}`,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,4 @@ async function runCLI(options?: RunCLIOptions): Promise<void> {
}
}

export { runCLI, createProgram, CLIExitError };
export { CLIExitError, createProgram, runCLI };
39 changes: 39 additions & 0 deletions packages/cli/tests/cli/dev-rls.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading