mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge remote-tracking branch 'origin/release/0.19' into feat/advanced-permissions
This commit is contained in:
@@ -1,3 +1,41 @@
|
||||
import { Authenticator } from "auth/authenticate/Authenticator";
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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 { Entity, EntityManager } from "data/entities";
|
||||
import { TextField, EntityIndex } from "data/fields";
|
||||
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
|
||||
const diffAfter = await em.schema().getDiff();
|
||||
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 */
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
||||
|
||||
/* beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog); */
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("cf adapter", () => {
|
||||
const DB_URL = ":memory:";
|
||||
|
||||
@@ -49,6 +49,8 @@ export function registerMedia(
|
||||
* @todo: add tests (bun tests won't work, need node native tests)
|
||||
*/
|
||||
export class StorageR2Adapter extends StorageAdapter {
|
||||
public keyPrefix: string = "";
|
||||
|
||||
constructor(private readonly bucket: R2Bucket) {
|
||||
super();
|
||||
}
|
||||
@@ -175,7 +177,7 @@ export class StorageR2Adapter extends StorageAdapter {
|
||||
}
|
||||
|
||||
protected getKey(key: string) {
|
||||
return key;
|
||||
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
|
||||
@@ -327,6 +327,31 @@ export class Authenticator<
|
||||
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> {
|
||||
// this works for as long as cookieOptions.prefix is not set
|
||||
return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
log as $log,
|
||||
password as $password,
|
||||
text as $text,
|
||||
select as $select,
|
||||
} from "@clack/prompts";
|
||||
import type { App } from "App";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
@@ -29,6 +30,11 @@ async function action(action: "create" | "update" | "token", options: WithConfig
|
||||
server: "node",
|
||||
});
|
||||
|
||||
if (!app.module.auth.enabled) {
|
||||
$log.error("Auth is not enabled");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "create":
|
||||
await create(app, options);
|
||||
@@ -43,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: WithConfig
|
||||
}
|
||||
|
||||
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) {
|
||||
$log.error("Password strategy not configured");
|
||||
@@ -76,6 +103,7 @@ async function create(app: App, options: any) {
|
||||
const created = await app.createUser({
|
||||
email,
|
||||
password: await strategy.hash(password as string),
|
||||
role,
|
||||
});
|
||||
$log.success(`Created user: ${c.cyan(created.email)}`);
|
||||
process.exit(0);
|
||||
|
||||
@@ -34,7 +34,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _indices: EntityIndex[] = [];
|
||||
private _schema?: SchemaManager;
|
||||
readonly emgr: EventManager<typeof EntityManager.Events>;
|
||||
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
|
||||
|
||||
@@ -249,11 +248,7 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
}
|
||||
|
||||
schema() {
|
||||
if (!this._schema) {
|
||||
this._schema = new SchemaManager(this);
|
||||
}
|
||||
|
||||
return this._schema;
|
||||
return new SchemaManager(this);
|
||||
}
|
||||
|
||||
// @todo: centralize and add tests
|
||||
|
||||
@@ -248,20 +248,16 @@ export class SchemaManager {
|
||||
|
||||
async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) {
|
||||
const diff = await this.getDiff();
|
||||
let updates: number = 0;
|
||||
const statements: { sql: string; parameters: readonly unknown[] }[] = [];
|
||||
const schema = this.em.connection.kysely.schema;
|
||||
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
|
||||
|
||||
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 dropFields = table.columns.drop;
|
||||
const dropIndices = table.indices.drop;
|
||||
|
||||
if (table.isDrop) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
if (config.drop) {
|
||||
qbs.push(schema.dropTable(table.name));
|
||||
}
|
||||
@@ -269,8 +265,6 @@ export class SchemaManager {
|
||||
let createQb = schema.createTable(table.name);
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
createQb = createQb.addColumn(...fieldSchema);
|
||||
}
|
||||
@@ -281,8 +275,6 @@ export class SchemaManager {
|
||||
if (addFieldSchemas.length > 0) {
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
|
||||
}
|
||||
@@ -292,8 +284,6 @@ export class SchemaManager {
|
||||
if (config.drop && dropFields.length > 0) {
|
||||
// drop fields
|
||||
for (const column of dropFields) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
qbs.push(schema.alterTable(table.name).dropColumn(column));
|
||||
}
|
||||
}
|
||||
@@ -311,35 +301,33 @@ export class SchemaManager {
|
||||
qb = qb.unique();
|
||||
}
|
||||
qbs.push(qb);
|
||||
local_updates++;
|
||||
updates++;
|
||||
}
|
||||
|
||||
// drop indices
|
||||
if (config.drop) {
|
||||
for (const index of dropIndices) {
|
||||
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
|
||||
// @todo: run in batches
|
||||
for (const qb of qbs) {
|
||||
const { sql, parameters } = qb.compile();
|
||||
statements.push({ sql, parameters });
|
||||
$console.debug(
|
||||
"[SchemaManager]",
|
||||
`${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`,
|
||||
);
|
||||
|
||||
if (config.force) {
|
||||
try {
|
||||
$console.debug("[SchemaManager]", sql);
|
||||
await qb.execute();
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.em.connection.executeQueries(...qbs);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to execute batch: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ declare module "bknd" {
|
||||
// @todo: current workaround to make it all required
|
||||
export class AppMedia extends Module<Required<TAppMediaConfig>> {
|
||||
private _storage?: Storage;
|
||||
options = {
|
||||
body_max_size: null as number | null,
|
||||
};
|
||||
|
||||
override async build() {
|
||||
if (!this.config.enabled) {
|
||||
|
||||
@@ -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()) {
|
||||
hono.post(
|
||||
|
||||
@@ -48,7 +48,7 @@ export function buildMediaSchema() {
|
||||
{
|
||||
default: {},
|
||||
},
|
||||
);
|
||||
).strict();
|
||||
}
|
||||
|
||||
export const mediaConfigSchema = buildMediaSchema();
|
||||
|
||||
74
app/src/plugins/data/timestamp.plugin.spec.ts
Normal file
74
app/src/plugins/data/timestamp.plugin.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
app/src/plugins/data/timestamps.plugin.ts
Normal file
86
app/src/plugins/data/timestamps.plugin.ts
Normal 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",
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
|
||||
export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
|
||||
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||
|
||||
Reference in New Issue
Block a user