Merge remote-tracking branch 'origin/release/0.19' into feat/advanced-permissions

This commit is contained in:
dswbx
2025-10-03 20:27:07 +02:00
14 changed files with 321 additions and 43 deletions

View File

@@ -1,3 +1,41 @@
import { Authenticator } from "auth/authenticate/Authenticator";
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
describe("Authenticator", async () => {}); describe("Authenticator", async () => {
test("should return auth cookie headers", async () => {
const auth = new Authenticator({}, null as any, {
jwt: {
secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
});
const headers = await auth.getAuthCookieHeader("token");
const cookie = headers.get("Set-Cookie");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
// now expect it to be removed
const headers2 = await auth.removeAuthCookieHeader(headers);
const cookie2 = headers2.get("Set-Cookie");
expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires=");
expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
test("should return auth cookie string", async () => {
const auth = new Authenticator({}, null as any, {
jwt: {
secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
});
const cookie = await auth.unsafeGetAuthCookie("token");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
});

View File

@@ -1,5 +1,5 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, spyOn, test } from "bun:test";
import { randomString } from "core/utils"; import { randomString } from "core/utils";
import { Entity, EntityManager } from "data/entities"; import { Entity, EntityManager } from "data/entities";
import { TextField, EntityIndex } from "data/fields"; import { TextField, EntityIndex } from "data/fields";
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
const diffAfter = await em.schema().getDiff(); const diffAfter = await em.schema().getDiff();
expect(diffAfter.length).toBe(0); expect(diffAfter.length).toBe(0);
}); });
test("returns statements", async () => {
const amount = 5;
const entities = new Array(amount)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const statements = await em.schema().sync({ force: true });
expect(statements.length).toBe(amount);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
});
test("batches statements", async () => {
const { dummyConnection } = getDummyConnection();
const entities = new Array(20)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const spy = spyOn(em.connection, "executeQueries");
const statements = await em.schema().sync();
expect(statements.length).toBe(entities.length);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
await em.schema().sync({ force: true });
expect(spy).toHaveBeenCalledTimes(1);
const tables = await em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute();
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
});
}); });

View File

@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
/* beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); */ afterAll(enableConsoleLog);
describe("cf adapter", () => { describe("cf adapter", () => {
const DB_URL = ":memory:"; const DB_URL = ":memory:";

View File

@@ -49,6 +49,8 @@ export function registerMedia(
* @todo: add tests (bun tests won't work, need node native tests) * @todo: add tests (bun tests won't work, need node native tests)
*/ */
export class StorageR2Adapter extends StorageAdapter { export class StorageR2Adapter extends StorageAdapter {
public keyPrefix: string = "";
constructor(private readonly bucket: R2Bucket) { constructor(private readonly bucket: R2Bucket) {
super(); super();
} }
@@ -175,7 +177,7 @@ export class StorageR2Adapter extends StorageAdapter {
} }
protected getKey(key: string) { protected getKey(key: string) {
return key; return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
} }
toJSON(secrets?: boolean) { toJSON(secrets?: boolean) {

View File

@@ -327,6 +327,31 @@ export class Authenticator<
await setSignedCookie(c, "auth", token, secret, this.cookieOptions); await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
} }
async getAuthCookieHeader(token: string, headers = new Headers()) {
const c = {
header: (key: string, value: string) => {
headers.set(key, value);
},
};
await this.setAuthCookie(c as any, token);
return headers;
}
async removeAuthCookieHeader(headers = new Headers()) {
const c = {
header: (key: string, value: string) => {
headers.set(key, value);
},
req: {
raw: {
headers,
},
},
};
this.deleteAuthCookie(c as any);
return headers;
}
async unsafeGetAuthCookie(token: string): Promise<string | undefined> { async unsafeGetAuthCookie(token: string): Promise<string | undefined> {
// this works for as long as cookieOptions.prefix is not set // this works for as long as cookieOptions.prefix is not set
return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions); return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions);

View File

@@ -3,6 +3,7 @@ import {
log as $log, log as $log,
password as $password, password as $password,
text as $text, text as $text,
select as $select,
} from "@clack/prompts"; } from "@clack/prompts";
import type { App } from "App"; import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
@@ -29,6 +30,11 @@ async function action(action: "create" | "update" | "token", options: WithConfig
server: "node", server: "node",
}); });
if (!app.module.auth.enabled) {
$log.error("Auth is not enabled");
process.exit(1);
}
switch (action) { switch (action) {
case "create": case "create":
await create(app, options); await create(app, options);
@@ -43,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: WithConfig
} }
async function create(app: App, options: any) { async function create(app: App, options: any) {
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; const auth = app.module.auth;
let role: string | null = null;
const roles = Object.keys(auth.config.roles ?? {});
const strategy = auth.authenticator.strategy("password") as PasswordStrategy;
if (roles.length > 0) {
role = (await $select({
message: "Select role",
options: [
{
value: null,
label: "<none>",
hint: "No role will be assigned to the user",
},
...roles.map((role) => ({
value: role,
label: role,
})),
],
})) as any;
if ($isCancel(role)) process.exit(1);
}
if (!strategy) { if (!strategy) {
$log.error("Password strategy not configured"); $log.error("Password strategy not configured");
@@ -76,6 +103,7 @@ async function create(app: App, options: any) {
const created = await app.createUser({ const created = await app.createUser({
email, email,
password: await strategy.hash(password as string), password: await strategy.hash(password as string),
role,
}); });
$log.success(`Created user: ${c.cyan(created.email)}`); $log.success(`Created user: ${c.cyan(created.email)}`);
process.exit(0); process.exit(0);

View File

@@ -34,7 +34,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
private _entities: Entity[] = []; private _entities: Entity[] = [];
private _relations: EntityRelation[] = []; private _relations: EntityRelation[] = [];
private _indices: EntityIndex[] = []; private _indices: EntityIndex[] = [];
private _schema?: SchemaManager;
readonly emgr: EventManager<typeof EntityManager.Events>; readonly emgr: EventManager<typeof EntityManager.Events>;
static readonly Events = { ...MutatorEvents, ...RepositoryEvents }; static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
@@ -249,11 +248,7 @@ export class EntityManager<TBD extends object = DefaultDB> {
} }
schema() { schema() {
if (!this._schema) { return new SchemaManager(this);
this._schema = new SchemaManager(this);
}
return this._schema;
} }
// @todo: centralize and add tests // @todo: centralize and add tests

View File

@@ -248,20 +248,16 @@ export class SchemaManager {
async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) { async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) {
const diff = await this.getDiff(); const diff = await this.getDiff();
let updates: number = 0;
const statements: { sql: string; parameters: readonly unknown[] }[] = []; const statements: { sql: string; parameters: readonly unknown[] }[] = [];
const schema = this.em.connection.kysely.schema; const schema = this.em.connection.kysely.schema;
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
for (const table of diff) { for (const table of diff) {
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
let local_updates: number = 0;
const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add); const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add);
const dropFields = table.columns.drop; const dropFields = table.columns.drop;
const dropIndices = table.indices.drop; const dropIndices = table.indices.drop;
if (table.isDrop) { if (table.isDrop) {
updates++;
local_updates++;
if (config.drop) { if (config.drop) {
qbs.push(schema.dropTable(table.name)); qbs.push(schema.dropTable(table.name));
} }
@@ -269,8 +265,6 @@ export class SchemaManager {
let createQb = schema.createTable(table.name); let createQb = schema.createTable(table.name);
// add fields // add fields
for (const fieldSchema of addFieldSchemas) { for (const fieldSchema of addFieldSchemas) {
updates++;
local_updates++;
// @ts-ignore // @ts-ignore
createQb = createQb.addColumn(...fieldSchema); createQb = createQb.addColumn(...fieldSchema);
} }
@@ -281,8 +275,6 @@ export class SchemaManager {
if (addFieldSchemas.length > 0) { if (addFieldSchemas.length > 0) {
// add fields // add fields
for (const fieldSchema of addFieldSchemas) { for (const fieldSchema of addFieldSchemas) {
updates++;
local_updates++;
// @ts-ignore // @ts-ignore
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema)); qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
} }
@@ -292,8 +284,6 @@ export class SchemaManager {
if (config.drop && dropFields.length > 0) { if (config.drop && dropFields.length > 0) {
// drop fields // drop fields
for (const column of dropFields) { for (const column of dropFields) {
updates++;
local_updates++;
qbs.push(schema.alterTable(table.name).dropColumn(column)); qbs.push(schema.alterTable(table.name).dropColumn(column));
} }
} }
@@ -311,35 +301,33 @@ export class SchemaManager {
qb = qb.unique(); qb = qb.unique();
} }
qbs.push(qb); qbs.push(qb);
local_updates++;
updates++;
} }
// drop indices // drop indices
if (config.drop) { if (config.drop) {
for (const index of dropIndices) { for (const index of dropIndices) {
qbs.push(schema.dropIndex(index)); qbs.push(schema.dropIndex(index));
local_updates++;
updates++;
} }
} }
}
if (local_updates === 0) continue; if (qbs.length > 0) {
statements.push(
...qbs.map((qb) => {
const { sql, parameters } = qb.compile();
return { sql, parameters };
}),
);
// iterate through built qbs $console.debug(
// @todo: run in batches "[SchemaManager]",
for (const qb of qbs) { `${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`,
const { sql, parameters } = qb.compile(); );
statements.push({ sql, parameters });
if (config.force) { try {
try { await this.em.connection.executeQueries(...qbs);
$console.debug("[SchemaManager]", sql); } catch (e) {
await qb.execute(); throw new Error(`Failed to execute batch: ${String(e)}`);
} catch (e) {
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
}
}
} }
} }

View File

@@ -22,6 +22,9 @@ declare module "bknd" {
// @todo: current workaround to make it all required // @todo: current workaround to make it all required
export class AppMedia extends Module<Required<TAppMediaConfig>> { export class AppMedia extends Module<Required<TAppMediaConfig>> {
private _storage?: Storage; private _storage?: Storage;
options = {
body_max_size: null as number | null,
};
override async build() { override async build() {
if (!this.config.enabled) { if (!this.config.enabled) {

View File

@@ -93,7 +93,10 @@ export class MediaController extends Controller {
}, },
); );
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY; const maxSize =
this.media.options.body_max_size ??
this.getStorage().getConfig().body_max_size ??
Number.POSITIVE_INFINITY;
if (isDebug()) { if (isDebug()) {
hono.post( hono.post(

View File

@@ -48,7 +48,7 @@ export function buildMediaSchema() {
{ {
default: {}, default: {},
}, },
); ).strict();
} }
export const mediaConfigSchema = buildMediaSchema(); export const mediaConfigSchema = buildMediaSchema();

View File

@@ -0,0 +1,74 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { timestamps } from "./timestamps.plugin";
import { em, entity, text } from "bknd";
import { createApp } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("timestamps plugin", () => {
test("should ignore if no or invalid entities are provided", async () => {
const app = createApp({
options: {
plugins: [timestamps({ entities: [] })],
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual([]);
{
const app = createApp({
options: {
plugins: [timestamps({ entities: ["posts"] })],
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual([]);
}
});
test("should add timestamps to the specified entities", async () => {
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts", "invalid"] })],
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual(["posts"]);
expect(app.em.entity("posts")?.fields.map((f) => f.name)).toEqual([
"id",
"title",
"created_at",
"updated_at",
]);
// insert
const mutator = app.em.mutator(app.em.entity("posts"));
const { data } = await mutator.insertOne({ title: "Hello" });
expect(data.created_at).toBeDefined();
expect(data.updated_at).toBeDefined();
expect(data.created_at).toBeInstanceOf(Date);
expect(data.updated_at).toBeInstanceOf(Date);
const diff = data.created_at.getTime() - data.updated_at.getTime();
expect(diff).toBeLessThan(10);
expect(diff).toBeGreaterThan(-1);
// update (set updated_at to null, otherwise it's too fast to test)
await app.em.connection.kysely
.updateTable("posts")
.set({ updated_at: null })
.where("id", "=", data.id)
.execute();
const { data: updatedData } = await mutator.updateOne(data.id, { title: "Hello 2" });
expect(updatedData.updated_at).toBeDefined();
expect(updatedData.updated_at).toBeInstanceOf(Date);
});
});

View File

@@ -0,0 +1,86 @@
import { type App, type AppPlugin, em, entity, datetime, DatabaseEvents } from "bknd";
import { $console } from "bknd/utils";
export type TimestampsPluginOptions = {
entities: string[];
setUpdatedOnCreate?: boolean;
};
/**
* This plugin adds `created_at` and `updated_at` fields to the specified entities.
* Add it to your plugins in `bknd.config.ts` like this:
*
* ```ts
* export default {
* plugins: [timestamps({ entities: ["posts"] })],
* }
* ```
*/
export function timestamps({
entities = [],
setUpdatedOnCreate = true,
}: TimestampsPluginOptions): AppPlugin {
return (app: App) => ({
name: "timestamps",
schema: () => {
if (entities.length === 0) {
$console.warn("No entities specified for timestamps plugin");
return;
}
const appEntities = app.em.entities.map((e) => e.name);
return em(
Object.fromEntries(
entities
.filter((e) => appEntities.includes(e))
.map((e) => [
e,
entity(e, {
created_at: datetime(),
updated_at: datetime(),
}),
]),
),
);
},
onBuilt: async () => {
app.emgr.onEvent(
DatabaseEvents.MutatorInsertBefore,
(event) => {
const { entity, data } = event.params;
if (entities.includes(entity.name)) {
return {
...data,
created_at: new Date(),
updated_at: setUpdatedOnCreate ? new Date() : null,
};
}
return data;
},
{
mode: "sync",
id: "bknd-timestamps",
},
);
app.emgr.onEvent(
DatabaseEvents.MutatorUpdateBefore,
async (event) => {
const { entity, data } = event.params;
if (entities.includes(entity.name)) {
return {
...data,
updated_at: new Date(),
};
}
return data;
},
{
mode: "sync",
id: "bknd-timestamps",
},
);
},
});
}

View File

@@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";