From db4c86f4a6c58ac1de57e27a336c1d17a7d7f6fb Mon Sep 17 00:00:00 2001 From: Artem Demochko Date: Thu, 5 Mar 2026 13:16:54 +0200 Subject: [PATCH 1/5] User Entity --- .../cli/src/cli/dev/dev-server/db/database.ts | 82 +++++- .../src/cli/dev/dev-server/db/validator.ts | 21 +- packages/cli/src/cli/dev/dev-server/main.ts | 22 +- .../entities-router.ts} | 76 ++--- .../routes/entities/entities-user-router.ts | 124 ++++++++ packages/cli/src/cli/dev/dev-server/utils.ts | 19 ++ .../src/cli/utils/command/Base44Command.ts | 1 + packages/cli/tests/scripts/validator.spec.ts | 271 ++++++++++-------- 8 files changed, 417 insertions(+), 199 deletions(-) rename packages/cli/src/cli/dev/dev-server/routes/{entities.ts => entities/entities-router.ts} (83%) create mode 100644 packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts create mode 100644 packages/cli/src/cli/dev/dev-server/utils.ts diff --git a/packages/cli/src/cli/dev/dev-server/db/database.ts b/packages/cli/src/cli/dev/dev-server/db/database.ts index 242abe2d..3d24f4a1 100644 --- a/packages/cli/src/cli/dev/dev-server/db/database.ts +++ b/packages/cli/src/cli/dev/dev-server/db/database.ts @@ -1,21 +1,87 @@ import Datastore from "@seald-io/nedb"; +import { nanoid } from "nanoid"; +import { readAuth } from "@/core/index.js"; import type { Entity } from "@/core/resources/entity/schema.js"; +import { getNowISOTimestamp } from "../utils.js"; import { type EntityRecord, Validator } from "./validator.js"; +export const USER_COLLECTION = "user" as const; + export class Database { private collections: Map = new Map(); private schemas: Map = new Map(); private validator: Validator = new Validator(); - load(entities: Entity[]) { + async load(entities: Entity[]) { + await this.loadUserCollection(entities); + for (const entity of entities) { - this.collections.set(entity.name, new Datastore()); - this.schemas.set(entity.name, entity); + const entityName = this.normalizeName(entity.name); + if (entityName === USER_COLLECTION) { + continue; + } + + this.collections.set(entityName, new Datastore()); + this.schemas.set(entityName, entity); } } + private async loadUserCollection(entities: Entity[]) { + const userEntity = entities.find( + (e) => this.normalizeName(e.name) === USER_COLLECTION, + ); + + this.schemas.set(USER_COLLECTION, this.buildUserSchema(userEntity)); + + const collection = new Datastore(); + this.collections.set(USER_COLLECTION, collection); + + const userInfo = await readAuth(); + const now = getNowISOTimestamp(); + await collection.insertAsync({ + id: nanoid(), + email: userInfo.email, + full_name: userInfo.name, + is_service: false, + is_verified: true, + disabled: null, + role: "admin", + collaborator_role: "editor", + created_date: now, + updated_date: now, + }); + } + + private buildUserSchema(customUserEntity: Entity | undefined): Entity { + const builtInFields = { + full_name: { type: "string" as const }, + email: { type: "string" as const }, + }; + + if (!customUserEntity) { + return { + name: "User", + type: "object", + properties: { ...builtInFields, role: { type: "string" } }, + }; + } + + for (const field of Object.keys(builtInFields)) { + if (field in customUserEntity.properties) { + throw new Error( + `Error syncing entities: Invalid User schema: User schema cannot contain base fields: ${field}. These fields are built-in and managed by the system.`, + ); + } + } + + return { + ...customUserEntity, + properties: { ...customUserEntity.properties, ...builtInFields }, + }; + } + getCollection(name: string): Datastore | undefined { - return this.collections.get(name); + return this.collections.get(this.normalizeName(name)); } getCollectionNames(): string[] { @@ -31,7 +97,7 @@ export class Database { } validate(entityName: string, record: EntityRecord, partial: boolean = false) { - const schema = this.schemas.get(entityName); + const schema = this.schemas.get(this.normalizeName(entityName)); if (!schema) { throw new Error(`Entity "${entityName}" not found`); } @@ -44,7 +110,7 @@ export class Database { record: EntityRecord, partial: boolean = false, ) { - const schema = this.schemas.get(entityName); + const schema = this.schemas.get(this.normalizeName(entityName)); if (!schema) { throw new Error(`Entity "${entityName}" not found`); } @@ -55,4 +121,8 @@ export class Database { } return this.validator.applyDefaults(filteredRecord, schema); } + + private normalizeName(entityName: string): string { + return entityName.toLowerCase(); + } } diff --git a/packages/cli/src/cli/dev/dev-server/db/validator.ts b/packages/cli/src/cli/dev/dev-server/db/validator.ts index d6b103b6..9d9f5a5a 100644 --- a/packages/cli/src/cli/dev/dev-server/db/validator.ts +++ b/packages/cli/src/cli/dev/dev-server/db/validator.ts @@ -5,20 +5,26 @@ import type { export type EntityRecord = Record; -type ValidationError = { +type ValidationErrorContext = { error_type: "ValidationError"; message: string; request_id: null; traceback: ""; }; +export class EntityValidationError extends Error { + constructor(public context: ValidationErrorContext) { + super(context.message); + } +} + type ValidationResponse = | { hasError: false; } | { hasError: true; - error: ValidationError; + error: ValidationErrorContext; }; // https://docs.base44.com/developers/backend/resources/entities/entity-schemas#field-types @@ -65,7 +71,7 @@ export class Validator { record: EntityRecord, entitySchema: Entity, partial: boolean = false, - ): ValidationResponse { + ) { // Partial validation happening, when user updates existing entity. // In this case not all data will be passed. if (!partial) { @@ -74,19 +80,16 @@ export class Validator { entitySchema, ); if (requiredFieldsResponse.hasError) { - return requiredFieldsResponse; + throw new EntityValidationError(requiredFieldsResponse.error); } } const fieldTypesResponse = this.validateFieldTypes(record, entitySchema); if (fieldTypesResponse.hasError) { - return fieldTypesResponse; + throw new EntityValidationError(fieldTypesResponse.error); } - return { - hasError: false, - }; } - private createValidationError(message: string): ValidationError { + private createValidationError(message: string): ValidationErrorContext { return { error_type: "ValidationError", message, diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index edcd368e..5cf015aa 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -16,7 +16,7 @@ import { broadcastEntityEvent, createRealtimeServer, } from "./realtime.js"; -import { createEntityRoutes } from "./routes/entities.js"; +import { createEntityRoutes } from "./routes/entities/entities-router.js"; import { createCustomIntegrationRoutes, createFileToken, @@ -76,6 +76,15 @@ export async function createDevServer( next(); }); + app.use((req, res, next) => { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); + }); + const devLogger = createDevLogger(); const functionManager = new FunctionManager( @@ -93,7 +102,7 @@ export async function createDevServer( } const db = new Database(); - db.load(entities); + await db.load(entities); if (db.getCollectionNames().length > 0) { clackLog.info(`Loaded entities: ${db.getCollectionNames().join(", ")}`); } @@ -101,11 +110,8 @@ export async function createDevServer( // Socket.IO is attached after the HTTP server starts; entity routes receive // a broadcast callback that becomes a no-op until the server is ready. let emitEntityEvent: BroadcastEntityEvent = () => {}; - const entityRoutes = createEntityRoutes( - db, - devLogger, - remoteProxy, - (...args) => emitEntityEvent(...args), + const entityRoutes = await createEntityRoutes(db, devLogger, (...args) => + emitEntityEvent(...args), ); app.use("/api/apps/:appId/entities", entityRoutes); @@ -205,7 +211,7 @@ export async function createDevServer( if (previousEntityCount > 0) { devLogger.log("Entities directory changed, clearing data..."); } - db.load(entities); + await db.load(entities); if (db.getCollectionNames().length > 0) { devLogger.log( `Loaded entities: ${db.getCollectionNames().join(", ")}`, diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts similarity index 83% rename from packages/cli/src/cli/dev/dev-server/routes/entities.ts rename to packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts index 96d287e8..5de91083 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-router.ts @@ -1,10 +1,13 @@ import type Datastore from "@seald-io/nedb"; -import type { Request, RequestHandler, Response, Router } from "express"; +import type { Request, Response, Router } from "express"; import { Router as createRouter, json } from "express"; import { nanoid } from "nanoid"; -import type { Logger } from "../../createDevLogger.js"; -import type { Database } from "../db/database.js"; -import type { BroadcastEntityEvent, EntityEventType } from "../realtime.js"; +import type { Logger } from "../../../createDevLogger.js"; +import type { Database } from "../../db/database.js"; +import { EntityValidationError } from "../../db/validator.js"; +import type { BroadcastEntityEvent, EntityEventType } from "../../realtime.js"; +import { stripInternalFields } from "../../utils.js"; +import { createUserRouter } from "./entities-user-router.js"; interface EntityParams { appId: string; @@ -51,28 +54,11 @@ function parseFields( return Object.keys(projection).length > 0 ? projection : undefined; } -function stripInternalFields>( - doc: T[], -): Omit[]; -function stripInternalFields>( - doc: T, -): Omit; -function stripInternalFields>( - doc: T | T[], -): Omit | Omit[] { - if (Array.isArray(doc)) { - return doc.map((d) => stripInternalFields(d)); - } - const { _id, ...rest } = doc; - return rest; -} - -export function createEntityRoutes( +export async function createEntityRoutes( db: Database, logger: Logger, - remoteProxy: RequestHandler, broadcast: BroadcastEntityEvent, -): Router { +): Promise { const router = createRouter({ mergeParams: true }); const parseBody = json(); @@ -116,15 +102,8 @@ export function createEntityRoutes( broadcast(appId, entityName, createData(data)); } - router.get("/User/:id", (req, res, next) => { - logger.warn( - `"${req.originalUrl}" is not supported in local development, passing call to production`, - ); - // This is necessary because Express strips the router prefix from req.url, - // so without this the proxy would send just `/User/:id` instead of the full path. - req.url = req.originalUrl; - remoteProxy(req, res, next); - }); + const userRouter = createUserRouter(db, logger); + router.use("/User", userRouter); router.get( "/:entityName/:id", @@ -209,12 +188,7 @@ export function createEntityRoutes( const { _id, ...body } = req.body; const filteredBody = db.prepareRecord(entityName, body); - const validation = db.validate(entityName, filteredBody); - - if (validation.hasError) { - res.status(422).json(validation.error); - return; - } + db.validate(entityName, filteredBody); const record = { ...filteredBody, @@ -229,6 +203,10 @@ export function createEntityRoutes( emit(appId, entityName, "create", inserted); res.status(201).json(inserted); } catch (error) { + if (error instanceof EntityValidationError) { + res.status(422).json(error.context); + return; + } logger.error(`Error in POST /${entityName}:`, error); res.status(500).json({ error: "Internal server error" }); } @@ -252,12 +230,7 @@ export function createEntityRoutes( for (const record of req.body) { const filteredRecord = db.prepareRecord(entityName, record); - const validation = db.validate(entityName, filteredRecord); - - if (validation.hasError) { - res.status(422).json(validation.error); - return; - } + db.validate(entityName, filteredRecord); records.push({ ...filteredRecord, @@ -273,6 +246,10 @@ export function createEntityRoutes( emit(appId, entityName, "create", inserted); res.status(201).json(inserted); } catch (error) { + if (error instanceof EntityValidationError) { + res.status(422).json(error.context); + return; + } logger.error(`Error in POST /${entityName}/bulk:`, error); res.status(500).json({ error: "Internal server error" }); } @@ -288,12 +265,7 @@ export function createEntityRoutes( try { const filteredBody = db.prepareRecord(entityName, body, true); - const validation = db.validate(entityName, filteredBody, true); - - if (validation.hasError) { - res.status(422).json(validation.error); - return; - } + db.validate(entityName, filteredBody, true); const updateData = { ...filteredBody, @@ -315,6 +287,10 @@ export function createEntityRoutes( emit(appId, entityName, "update", updated); res.json(updated); } catch (error) { + if (error instanceof EntityValidationError) { + res.status(422).json(error.context); + return; + } logger.error(`Error in PUT /${entityName}/${id}:`, error); res.status(500).json({ error: "Internal server error" }); } diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts new file mode 100644 index 00000000..b12e4e2e --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts @@ -0,0 +1,124 @@ +import type { Request, Response, Router } from "express"; +import { Router as createRouter, json } from "express"; +import { nanoid } from "nanoid"; +import type { Logger } from "@/cli/dev/createDevLogger.js"; +import { readAuth } from "@/core/index.js"; +import { type Database, USER_COLLECTION } from "../../db/database.js"; +import { EntityValidationError } from "../../db/validator.js"; +import { getNowISOTimestamp, stripInternalFields } from "../../utils.js"; + +export function createUserRouter(db: Database, logger: Logger): Router { + const router = createRouter({ mergeParams: true }); + const parseBody = json(); + + router.get("/:id", async (req: Request<{ id: string }>, res: Response) => { + let result: Record | undefined; + + if (req.params.id === "me") { + const userInfo = await readAuth(); + result = await db + .getCollection(USER_COLLECTION) + ?.findOneAsync({ email: userInfo.email }); + } else { + result = await db + .getCollection(USER_COLLECTION) + ?.findOneAsync({ id: req.params.id }); + } + + if (!result) { + res + .status(404) + .json({ error: `User with id "${req.params.id}" not found` }); + return; + } + res.json(stripInternalFields(result)); + }); + + router.post("/", parseBody, async (req, res) => { + const userInfo = await readAuth(); + const currentUser = await db + .getCollection(USER_COLLECTION) + ?.findOneAsync({ email: userInfo.email }); + + if (currentUser) { + const now = getNowISOTimestamp(); + + // Production is not allowing to add user entity directly. + // In case developer tries to do it - backend silently fails. + res.json({ + created_by: userInfo.email, + created_by_id: currentUser.id, + id: nanoid(), + created_date: now, + updated_date: now, + is_sample: false, + ...req.body, + }); + } else { + res + .status(404) + .json({ error: "Unable to read data for the current user" }); + } + }); + + router.post("/bulk", async (_req, res) => { + // not supported in direct call: NO-OP + res.json({}); + }); + + router.put("/:id", parseBody, async (req: Request<{ id: string }>, res) => { + // User is only allowed to update himself, not other users + if (req.params.id === "me") { + const userInfo = await readAuth(); + const collection = db.getCollection(USER_COLLECTION); + const userRecord = await collection?.findOneAsync({ + email: userInfo.email, + }); + if (userRecord) { + try { + const { id: _id, created_date: _created_date, ...body } = req.body; + const filteredBody = db.prepareRecord(USER_COLLECTION, body, true); + db.validate(USER_COLLECTION, filteredBody, true); + + const updateData = { + ...filteredBody, + updated_date: new Date().toISOString(), + }; + + const updResult = await collection?.updateAsync( + { id: userRecord.id }, + { $set: updateData }, + { returnUpdatedDocs: true }, + ); + + if (!updResult?.affectedDocuments) { + throw new Error(`Failed to update user`); + } + + res.json(stripInternalFields(updResult.affectedDocuments)); + } catch (error) { + if (error instanceof EntityValidationError) { + res.status(422).json(error.context); + return; + } + logger.error( + `Error in PUT /${USER_COLLECTION}/${req.params.id}:`, + error, + ); + res.status(500).json({ error: "Internal server error" }); + } + } else { + res.status(404).json({ error: `User record not found` }); + } + } else { + res.json({}); + } + }); + + router.delete("/:id", async (_req, res) => { + // not supported in direct call: NO-OP + res.json({}); + }); + + return router; +} diff --git a/packages/cli/src/cli/dev/dev-server/utils.ts b/packages/cli/src/cli/dev/dev-server/utils.ts new file mode 100644 index 00000000..6fa0aef8 --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/utils.ts @@ -0,0 +1,19 @@ +export function stripInternalFields>( + doc: T[], +): Omit[]; +export function stripInternalFields>( + doc: T, +): Omit; +export function stripInternalFields>( + doc: T | T[], +): Omit | Omit[] { + if (Array.isArray(doc)) { + return doc.map((d) => stripInternalFields(d)); + } + const { _id, ...rest } = doc; + return rest; +} + +export const getNowISOTimestamp = () => { + return new Date().toISOString().replace("Z", "000"); +}; diff --git a/packages/cli/src/cli/utils/command/Base44Command.ts b/packages/cli/src/cli/utils/command/Base44Command.ts index 44b780d9..d4652b3c 100644 --- a/packages/cli/src/cli/utils/command/Base44Command.ts +++ b/packages/cli/src/cli/utils/command/Base44Command.ts @@ -108,6 +108,7 @@ export class Base44Command extends Command { /** @public - called by Commander internally via command dispatch */ override action( // biome-ignore lint/suspicious/noExplicitAny: must match Commander.js action() signature + // biome-ignore lint/suspicious/noConfusingVoidType: must match Commander.js action() signature fn: (...args: any[]) => void | Promise, ): this { // biome-ignore lint/suspicious/noExplicitAny: must match Commander.js action() signature diff --git a/packages/cli/tests/scripts/validator.spec.ts b/packages/cli/tests/scripts/validator.spec.ts index ebc08c54..67412a33 100644 --- a/packages/cli/tests/scripts/validator.spec.ts +++ b/packages/cli/tests/scripts/validator.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type EntityRecord, + EntityValidationError, Validator, } from "../../src/cli/dev/dev-server/db/validator.js"; import type { Entity } from "../../src/core/resources/entity/schema.js"; @@ -89,20 +90,21 @@ describe("Validator", () => { it("should pass when all required fields are present", () => { const schema = makeEntity({ name: { type: "string" } }, ["name"]); - const result = validator.validate({ name: "Alice" }, schema); - - expect(result.hasError).toBe(false); + expect(() => validator.validate({ name: "Alice" }, schema)).not.toThrow(); }); it("should fail when a required field is missing", () => { const schema = makeEntity({ name: { type: "string" } }, ["name"]); - const result = validator.validate({}, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("name"); - expect(result.error.message).toContain("Field required"); + expect(() => validator.validate({}, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({}, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("name"); + expect(err.context.message).toContain("Field required"); } }); @@ -110,105 +112,116 @@ describe("Validator", () => { it("should pass for valid string field", () => { const schema = makeEntity({ name: { type: "string" } }); - const result = validator.validate({ name: "Alice" }, schema); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ name: "Alice" }, schema), + ).not.toThrow(); }); it("should fail when string field gets a number", () => { const schema = makeEntity({ name: { type: "string" } }); - const result = validator.validate({ name: 123 }, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("name"); - expect(result.error.message).toContain("string"); + expect(() => validator.validate({ name: 123 }, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({ name: 123 }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("name"); + expect(err.context.message).toContain("string"); } }); it("passes for valid boolean field", () => { const schema = makeEntity({ active: { type: "boolean" } }); - const result = validator.validate({ active: true }, schema); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ active: true }, schema), + ).not.toThrow(); }); it("should fail when boolean field gets a string", () => { const schema = makeEntity({ active: { type: "boolean" } }); - const result = validator.validate({ active: "yes" }, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("boolean"); + expect(() => validator.validate({ active: "yes" }, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({ active: "yes" }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("boolean"); } }); it("should pass for valid number field", () => { const schema = makeEntity({ score: { type: "number" } }); - const result = validator.validate({ score: 3.14 }, schema); - - expect(result.hasError).toBe(false); + expect(() => validator.validate({ score: 3.14 }, schema)).not.toThrow(); }); it("should pass for valid integer field", () => { const schema = makeEntity({ score: { type: "integer" } }); - const result = validator.validate({ score: 3 }, schema); - - expect(result.hasError).toBe(false); + expect(() => validator.validate({ score: 3 }, schema)).not.toThrow(); }); it("should fail for invalid integer field", () => { const schema = makeEntity({ score: { type: "integer" } }); - const result = validator.validate({ score: 3.3333 }, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("integer"); + expect(() => validator.validate({ score: 3.3333 }, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({ score: 3.3333 }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("integer"); } }); it("should pass for valid array field", () => { const schema = makeEntity({ tags: { type: "array" } }); - const result = validator.validate({ tags: ["a", "b"] }, schema); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ tags: ["a", "b"] }, schema), + ).not.toThrow(); }); it("should fail when array field gets a non-array", () => { const schema = makeEntity({ tags: { type: "array" } }); - const result = validator.validate({ tags: "not-array" }, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("array"); + expect(() => validator.validate({ tags: "not-array" }, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({ tags: "not-array" }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("array"); } }); it("should fail when array field gets a object", () => { const schema = makeEntity({ tags: { type: "array" } }); - const result = validator.validate({ tags: {} }, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("array"); + expect(() => validator.validate({ tags: {} }, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({ tags: {} }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("array"); } }); it("should pass for valid object field", () => { const schema = makeEntity({ meta: { type: "object" } }); - const result = validator.validate({ meta: { key: "val" } }, schema); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ meta: { key: "val" } }, schema), + ).not.toThrow(); }); describe("array items validation", () => { @@ -217,9 +230,9 @@ describe("Validator", () => { tags: { type: "array", items: { type: "string" } }, }); - const result = validator.validate({ tags: ["a", "b"] }, schema); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ tags: ["a", "b"] }, schema), + ).not.toThrow(); }); it("should fail when array item has wrong type", () => { @@ -227,12 +240,15 @@ describe("Validator", () => { tags: { type: "array", items: { type: "string" } }, }); - const result = validator.validate({ tags: ["a", 42] }, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("tags[1]"); - expect(result.error.message).toContain("string"); + expect(() => validator.validate({ tags: ["a", 42] }, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({ tags: ["a", 42] }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("tags[1]"); + expect(err.context.message).toContain("string"); } }); @@ -240,12 +256,9 @@ describe("Validator", () => { it("should skip item validation when items is not defined", () => { const schema = makeEntity({ tags: { type: "array" } }); - const result = validator.validate( - { tags: [1, "mixed", true] }, - schema, - ); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ tags: [1, "mixed", true] }, schema), + ).not.toThrow(); }); it("should validate array of objects recursively", () => { @@ -262,20 +275,25 @@ describe("Validator", () => { }, }); - const passing = validator.validate( - { entries: [{ name: "x", count: 1 }] }, - schema, - ); - expect(passing.hasError).toBe(false); - - const failing = validator.validate( - { entries: [{ name: "x", count: 1.5 }] }, - schema, - ); - expect(failing.hasError).toBe(true); - if (failing.hasError) { - expect(failing.error.message).toContain("entries[0].count"); - expect(failing.error.message).toContain("integer"); + expect(() => + validator.validate({ entries: [{ name: "x", count: 1 }] }, schema), + ).not.toThrow(); + + expect(() => + validator.validate( + { entries: [{ name: "x", count: 1.5 }] }, + schema, + ), + ).toThrow(EntityValidationError); + try { + validator.validate( + { entries: [{ name: "x", count: 1.5 }] }, + schema, + ); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("entries[0].count"); + expect(err.context.message).toContain("integer"); } }); }); @@ -292,17 +310,19 @@ describe("Validator", () => { }, }); - const passing = validator.validate( - { data: { name: "x", count: 1 } }, - schema, - ); - expect(passing.hasError).toBe(false); - - const failing = validator.validate({ data: { name: 123 } }, schema); - expect(failing.hasError).toBe(true); - if (failing.hasError) { - expect(failing.error.message).toContain("data.name"); - expect(failing.error.message).toContain("string"); + expect(() => + validator.validate({ data: { name: "x", count: 1 } }, schema), + ).not.toThrow(); + + expect(() => + validator.validate({ data: { name: 123 } }, schema), + ).toThrow(EntityValidationError); + try { + validator.validate({ data: { name: 123 } }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("data.name"); + expect(err.context.message).toContain("string"); } }); @@ -310,12 +330,9 @@ describe("Validator", () => { it("should skip property validation when properties is not defined", () => { const schema = makeEntity({ meta: { type: "object" } }); - const result = validator.validate( - { meta: { anything: 123, goes: true } }, - schema, - ); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ meta: { anything: 123, goes: true } }, schema), + ).not.toThrow(); }); it("should ignore keys not in the schema properties", () => { @@ -326,12 +343,9 @@ describe("Validator", () => { }, }); - const result = validator.validate( - { data: { name: "ok", extra: 999 } }, - schema, - ); - - expect(result.hasError).toBe(false); + expect(() => + validator.validate({ data: { name: "ok", extra: 999 } }, schema), + ).not.toThrow(); }); }); @@ -339,19 +353,20 @@ describe("Validator", () => { const schema = makeEntity({ name: { type: "string" } }); const record: EntityRecord = { name: "Alice", extra: "not in schema" }; - const result = validator.validate(record, schema); - - expect(result.hasError).toBe(false); + expect(() => validator.validate(record, schema)).not.toThrow(); }); it("should fail for unsupported field type", () => { const schema = makeEntity({ field: { type: "date" } }); - const result = validator.validate({ field: "2024-01-01" }, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("Unsupported field type date"); + expect(() => + validator.validate({ field: "2024-01-01" }, schema), + ).toThrow(EntityValidationError); + try { + validator.validate({ field: "2024-01-01" }, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("Unsupported field type date"); } }); }); @@ -360,19 +375,20 @@ describe("Validator", () => { it("should skip required field check when partial is true", () => { const schema = makeEntity({ name: { type: "string" } }, ["name"]); - const result = validator.validate({}, schema, true); - - expect(result.hasError).toBe(false); + expect(() => validator.validate({}, schema, true)).not.toThrow(); }); it("should still validate field types when partial is true", () => { const schema = makeEntity({ name: { type: "string" } }, ["name"]); - const result = validator.validate({ name: 123 }, schema, true); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error.message).toContain("string"); + expect(() => validator.validate({ name: 123 }, schema, true)).toThrow( + EntityValidationError, + ); + try { + validator.validate({ name: 123 }, schema, true); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context.message).toContain("string"); } }); }); @@ -381,11 +397,14 @@ describe("Validator", () => { it("should return properly shaped ValidationError", () => { const schema = makeEntity({ name: { type: "string" } }, ["name"]); - const result = validator.validate({}, schema); - - expect(result.hasError).toBe(true); - if (result.hasError) { - expect(result.error).toEqual({ + expect(() => validator.validate({}, schema)).toThrow( + EntityValidationError, + ); + try { + validator.validate({}, schema); + } catch (e) { + const err = e as EntityValidationError; + expect(err.context).toEqual({ error_type: "ValidationError", message: expect.stringContaining("name"), request_id: null, From 8937312452d153629356dc7fa504953b0f3850cb Mon Sep 17 00:00:00 2001 From: Artem Demochko Date: Sun, 22 Mar 2026 14:09:07 +0200 Subject: [PATCH 2/5] restrict fields --- .../routes/entities/entities-user-router.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts index b12e4e2e..2a643236 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts @@ -4,7 +4,10 @@ import { nanoid } from "nanoid"; import type { Logger } from "@/cli/dev/createDevLogger.js"; import { readAuth } from "@/core/index.js"; import { type Database, USER_COLLECTION } from "../../db/database.js"; -import { EntityValidationError } from "../../db/validator.js"; +import { + type EntityRecord, + EntityValidationError, +} from "../../db/validator.js"; import { getNowISOTimestamp, stripInternalFields } from "../../utils.js"; export function createUserRouter(db: Database, logger: Logger): Router { @@ -69,6 +72,8 @@ export function createUserRouter(db: Database, logger: Logger): Router { router.put("/:id", parseBody, async (req: Request<{ id: string }>, res) => { // User is only allowed to update himself, not other users if (req.params.id === "me") { + // These fields are built-in in the schema, but user still can not update them using Entities API + const restrictedFields = ["full_name", "email", "role"]; const userInfo = await readAuth(); const collection = db.getCollection(USER_COLLECTION); const userRecord = await collection?.findOneAsync({ @@ -78,10 +83,16 @@ export function createUserRouter(db: Database, logger: Logger): Router { try { const { id: _id, created_date: _created_date, ...body } = req.body; const filteredBody = db.prepareRecord(USER_COLLECTION, body, true); - db.validate(USER_COLLECTION, filteredBody, true); + const allowedFields: EntityRecord = {}; + for (const [key, property] of Object.entries(filteredBody)) { + if (!restrictedFields.includes(key)) { + allowedFields[key] = property; + } + } + db.validate(USER_COLLECTION, allowedFields, true); const updateData = { - ...filteredBody, + ...allowedFields, updated_date: new Date().toISOString(), }; From 32d78cbe50e38a2bb6aa90ae9cb8823fdb0ddf24 Mon Sep 17 00:00:00 2001 From: Artem Demochko Date: Sun, 22 Mar 2026 14:51:53 +0200 Subject: [PATCH 3/5] upd --- packages/cli/src/cli/dev/dev-server/main.ts | 16 ++-- .../routes/entities/entities-user-router.ts | 90 +++++++++---------- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index 5cf015aa..a5025b79 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -76,14 +76,14 @@ export async function createDevServer( next(); }); - app.use((req, res, next) => { - const auth = req.headers.authorization; - if (!auth || !auth.startsWith("Bearer ")) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - next(); - }); + // app.use((req, res, next) => { + // const auth = req.headers.authorization; + // if (!auth || !auth.startsWith("Bearer ")) { + // res.status(401).json({ error: "Unauthorized" }); + // return; + // } + // next(); + // }); const devLogger = createDevLogger(); diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts index 2a643236..c4241948 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts @@ -70,59 +70,59 @@ export function createUserRouter(db: Database, logger: Logger): Router { }); router.put("/:id", parseBody, async (req: Request<{ id: string }>, res) => { - // User is only allowed to update himself, not other users - if (req.params.id === "me") { - // These fields are built-in in the schema, but user still can not update them using Entities API - const restrictedFields = ["full_name", "email", "role"]; - const userInfo = await readAuth(); - const collection = db.getCollection(USER_COLLECTION); - const userRecord = await collection?.findOneAsync({ - email: userInfo.email, - }); - if (userRecord) { - try { - const { id: _id, created_date: _created_date, ...body } = req.body; - const filteredBody = db.prepareRecord(USER_COLLECTION, body, true); - const allowedFields: EntityRecord = {}; - for (const [key, property] of Object.entries(filteredBody)) { - if (!restrictedFields.includes(key)) { - allowedFields[key] = property; - } + // These fields are built-in in the schema, but user still can not update them using Entities API + const restrictedFields = ["full_name", "email", "role"]; + const collection = db.getCollection(USER_COLLECTION); + const userInfo = await readAuth(); + const userRecord = + req.params.id === "me" + ? await collection?.findOneAsync({ + email: userInfo.email, + }) + : await collection?.findOneAsync({ + id: req.params.id, + }); + if (userRecord) { + try { + const { id: _id, created_date: _created_date, ...body } = req.body; + const filteredBody = db.prepareRecord(USER_COLLECTION, body, true); + const allowedFields: EntityRecord = {}; + for (const [key, property] of Object.entries(filteredBody)) { + if (!restrictedFields.includes(key)) { + allowedFields[key] = property; } - db.validate(USER_COLLECTION, allowedFields, true); + } + db.validate(USER_COLLECTION, allowedFields, true); - const updateData = { - ...allowedFields, - updated_date: new Date().toISOString(), - }; + const updateData = { + ...allowedFields, + updated_date: new Date().toISOString(), + }; - const updResult = await collection?.updateAsync( - { id: userRecord.id }, - { $set: updateData }, - { returnUpdatedDocs: true }, - ); + const updResult = await collection?.updateAsync( + { id: userRecord.id }, + { $set: updateData }, + { returnUpdatedDocs: true }, + ); - if (!updResult?.affectedDocuments) { - throw new Error(`Failed to update user`); - } + if (!updResult?.affectedDocuments) { + throw new Error(`Failed to update user`); + } - res.json(stripInternalFields(updResult.affectedDocuments)); - } catch (error) { - if (error instanceof EntityValidationError) { - res.status(422).json(error.context); - return; - } - logger.error( - `Error in PUT /${USER_COLLECTION}/${req.params.id}:`, - error, - ); - res.status(500).json({ error: "Internal server error" }); + res.json(stripInternalFields(updResult.affectedDocuments)); + } catch (error) { + if (error instanceof EntityValidationError) { + res.status(422).json(error.context); + return; } - } else { - res.status(404).json({ error: `User record not found` }); + logger.error( + `Error in PUT /${USER_COLLECTION}/${req.params.id}:`, + error, + ); + res.status(500).json({ error: "Internal server error" }); } } else { - res.json({}); + res.status(404).json({ error: `User record not found` }); } }); From 3439aa490456a0a6a8e8d55352f4fedca3605d3d Mon Sep 17 00:00:00 2001 From: Artem Demochko Date: Sun, 22 Mar 2026 15:33:30 +0200 Subject: [PATCH 4/5] moved check for authorization --- packages/cli/src/cli/dev/dev-server/main.ts | 9 --------- .../dev-server/routes/entities/entities-user-router.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index a5025b79..5ee621ca 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -76,15 +76,6 @@ export async function createDevServer( next(); }); - // app.use((req, res, next) => { - // const auth = req.headers.authorization; - // if (!auth || !auth.startsWith("Bearer ")) { - // res.status(401).json({ error: "Unauthorized" }); - // return; - // } - // next(); - // }); - const devLogger = createDevLogger(); const functionManager = new FunctionManager( diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts index c4241948..3607f860 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts @@ -14,6 +14,15 @@ export function createUserRouter(db: Database, logger: Logger): Router { const router = createRouter({ mergeParams: true }); const parseBody = json(); + router.use((req, res, next) => { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); + }); + router.get("/:id", async (req: Request<{ id: string }>, res: Response) => { let result: Record | undefined; From 5ff7d9e6c85d705114c8aa837982598feae2bb8f Mon Sep 17 00:00:00 2001 From: Artem Demochko Date: Sun, 22 Mar 2026 17:50:52 +0200 Subject: [PATCH 5/5] upd --- bun.lock | 36 +++- package.json | 6 +- .../routes/entities/entities-user-router.ts | 203 ++++++++++-------- 3 files changed, 151 insertions(+), 94 deletions(-) diff --git a/bun.lock b/bun.lock index 895a8a94..54fd41dd 100644 --- a/bun.lock +++ b/bun.lock @@ -4,14 +4,18 @@ "workspaces": { "": { "name": "base44-cli", + "dependencies": { + "jsonwebtoken": "^9.0.3", + }, "devDependencies": { "@biomejs/biome": "^2.0.0", + "@types/jsonwebtoken": "^9.0.10", "knip": "^5.83.1", }, }, "packages/cli": { "name": "base44", - "version": "0.0.41", + "version": "0.0.45", "bin": { "base44": "./bin/run.js", }, @@ -313,8 +317,12 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/multer": ["@types/multer@2.0.0", "", { "dependencies": { "@types/express": "*" } }, "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw=="], "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], @@ -373,6 +381,8 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], @@ -439,6 +449,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -611,6 +623,12 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "knip": ["knip@5.83.1", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "js-yaml": "^4.1.1", "minimist": "^1.2.8", "oxc-resolver": "^11.15.0", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-av3ZG/Nui6S/BNL8Tmj12yGxYfTnwWnslouW97m40him7o8MwiMjZBY9TPvlEWUci45aVId0/HbgTwSKIDGpMw=="], "ky": ["ky@1.14.3", "", {}, "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw=="], @@ -621,6 +639,20 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -741,6 +773,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], diff --git a/package.json b/package.json index ee34043d..aa544ce9 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,13 @@ }, "devDependencies": { "@biomejs/biome": "^2.0.0", + "@types/jsonwebtoken": "^9.0.10", "knip": "^5.83.1" }, "workspaces": [ "packages/*" - ] + ], + "dependencies": { + "jsonwebtoken": "^9.0.3" + } } diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts index 3607f860..f6ec6c91 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/entities-user-router.ts @@ -1,8 +1,9 @@ +import type { Document } from "@seald-io/nedb"; import type { Request, Response, Router } from "express"; import { Router as createRouter, json } from "express"; +import jwt from "jsonwebtoken"; import { nanoid } from "nanoid"; import type { Logger } from "@/cli/dev/createDevLogger.js"; -import { readAuth } from "@/core/index.js"; import { type Database, USER_COLLECTION } from "../../db/database.js"; import { type EntityRecord, @@ -10,55 +11,78 @@ import { } from "../../db/validator.js"; import { getNowISOTimestamp, stripInternalFields } from "../../utils.js"; +type UserDocument = Document<{ + email: string; + id: string; +}>; + export function createUserRouter(db: Database, logger: Logger): Router { const router = createRouter({ mergeParams: true }); const parseBody = json(); - router.use((req, res, next) => { - const auth = req.headers.authorization; - if (!auth || !auth.startsWith("Bearer ")) { - res.status(401).json({ error: "Unauthorized" }); - return; - } - next(); - }); + function withAuth( + handler: ( + req: Request, + res: Response, + currentUser: UserDocument, + ) => Promise | void, + ): (req: Request, res: Response) => Promise { + return async (req, res) => { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + try { + const { payload } = + jwt.decode(auth.replace("Bearer ", ""), { complete: true }) ?? {}; - router.get("/:id", async (req: Request<{ id: string }>, res: Response) => { - let result: Record | undefined; + const result = await db + .getCollection(USER_COLLECTION) + ?.findOneAsync({ email: payload?.sub }); - if (req.params.id === "me") { - const userInfo = await readAuth(); - result = await db - .getCollection(USER_COLLECTION) - ?.findOneAsync({ email: userInfo.email }); - } else { - result = await db - .getCollection(USER_COLLECTION) - ?.findOneAsync({ id: req.params.id }); - } - - if (!result) { - res - .status(404) - .json({ error: `User with id "${req.params.id}" not found` }); - return; - } - res.json(stripInternalFields(result)); - }); + if (!result) { + res + .status(404) + .json({ error: "Unable to read data for the current user" }); + return; + } - router.post("/", parseBody, async (req, res) => { - const userInfo = await readAuth(); - const currentUser = await db - .getCollection(USER_COLLECTION) - ?.findOneAsync({ email: userInfo.email }); + await handler(req, res, result as UserDocument); + } catch { + res.status(401).json({ error: "Unauthorized" }); + } + }; + } + + router.get( + "/:id", + withAuth<{ id: string }>(async (req, res, currentUser) => { + const id = req.params.id === "me" ? currentUser.id : req.params.id; + const result = await db + .getCollection(USER_COLLECTION) + ?.findOneAsync({ id }); - if (currentUser) { + if (!result) { + res + .status(404) + .json({ error: `User with id "${req.params.id}" not found` }); + return; + } + res.json(stripInternalFields(result)); + }), + ); + + router.post( + "/", + parseBody, + withAuth(async (req, res, currentUser) => { const now = getNowISOTimestamp(); // Production is not allowing to add user entity directly. // In case developer tries to do it - backend silently fails. res.json({ - created_by: userInfo.email, + created_by: currentUser.email, created_by_id: currentUser.id, id: nanoid(), created_date: now, @@ -66,74 +90,69 @@ export function createUserRouter(db: Database, logger: Logger): Router { is_sample: false, ...req.body, }); - } else { - res - .status(404) - .json({ error: "Unable to read data for the current user" }); - } - }); + }), + ); router.post("/bulk", async (_req, res) => { // not supported in direct call: NO-OP res.json({}); }); - router.put("/:id", parseBody, async (req: Request<{ id: string }>, res) => { - // These fields are built-in in the schema, but user still can not update them using Entities API - const restrictedFields = ["full_name", "email", "role"]; - const collection = db.getCollection(USER_COLLECTION); - const userInfo = await readAuth(); - const userRecord = - req.params.id === "me" - ? await collection?.findOneAsync({ - email: userInfo.email, - }) - : await collection?.findOneAsync({ - id: req.params.id, - }); - if (userRecord) { - try { - const { id: _id, created_date: _created_date, ...body } = req.body; - const filteredBody = db.prepareRecord(USER_COLLECTION, body, true); - const allowedFields: EntityRecord = {}; - for (const [key, property] of Object.entries(filteredBody)) { - if (!restrictedFields.includes(key)) { - allowedFields[key] = property; + router.put( + "/:id", + parseBody, + withAuth<{ id: string }>(async (req, res, currentUser) => { + // These fields are built-in in the schema, but user still can not update them using Entities API + const restrictedFields = ["full_name", "email", "role"]; + const collection = db.getCollection(USER_COLLECTION); + const id = req.params.id === "me" ? currentUser.id : req.params.id; + const userRecord = await collection?.findOneAsync({ + id, + }); + if (userRecord) { + try { + const { id: _id, created_date: _created_date, ...body } = req.body; + const filteredBody = db.prepareRecord(USER_COLLECTION, body, true); + const allowedFields: EntityRecord = {}; + for (const [key, property] of Object.entries(filteredBody)) { + if (!restrictedFields.includes(key)) { + allowedFields[key] = property; + } } - } - db.validate(USER_COLLECTION, allowedFields, true); + db.validate(USER_COLLECTION, allowedFields, true); - const updateData = { - ...allowedFields, - updated_date: new Date().toISOString(), - }; + const updateData = { + ...allowedFields, + updated_date: new Date().toISOString(), + }; - const updResult = await collection?.updateAsync( - { id: userRecord.id }, - { $set: updateData }, - { returnUpdatedDocs: true }, - ); + const updResult = await collection?.updateAsync( + { id: userRecord.id }, + { $set: updateData }, + { returnUpdatedDocs: true }, + ); - if (!updResult?.affectedDocuments) { - throw new Error(`Failed to update user`); - } + if (!updResult?.affectedDocuments) { + throw new Error(`Failed to update user`); + } - res.json(stripInternalFields(updResult.affectedDocuments)); - } catch (error) { - if (error instanceof EntityValidationError) { - res.status(422).json(error.context); - return; + res.json(stripInternalFields(updResult.affectedDocuments)); + } catch (error) { + if (error instanceof EntityValidationError) { + res.status(422).json(error.context); + return; + } + logger.error( + `Error in PUT /${USER_COLLECTION}/${req.params.id}:`, + error, + ); + res.status(500).json({ error: "Internal server error" }); } - logger.error( - `Error in PUT /${USER_COLLECTION}/${req.params.id}:`, - error, - ); - res.status(500).json({ error: "Internal server error" }); + } else { + res.status(404).json({ error: `User record not found` }); } - } else { - res.status(404).json({ error: `User record not found` }); - } - }); + }), + ); router.delete("/:id", async (_req, res) => { // not supported in direct call: NO-OP