From c2b3316fcbb63ac141893dd06960b0c53600b195 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 29 Jan 2025 14:44:32 +0100 Subject: [PATCH 1/3] updated API instantiation, and update user on verify --- app/__test__/api/Api.spec.ts | 71 +++++++++++++++++++ app/package.json | 2 +- app/src/Api.ts | 78 +++++++++++++++------ app/src/adapter/astro/astro.adapter.ts | 3 +- app/src/adapter/nextjs/nextjs.adapter.ts | 3 +- app/src/auth/api/AuthController.ts | 14 +++- app/src/index.ts | 2 - app/src/modules/ModuleManager.ts | 42 +++++++---- app/src/ui/client/ClientProvider.tsx | 2 +- app/src/ui/client/index.ts | 2 +- docs/usage/sdk.mdx | 88 +++++++++++++++++++----- 11 files changed, 250 insertions(+), 57 deletions(-) create mode 100644 app/__test__/api/Api.spec.ts diff --git a/app/__test__/api/Api.spec.ts b/app/__test__/api/Api.spec.ts new file mode 100644 index 0000000..85f144d --- /dev/null +++ b/app/__test__/api/Api.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { sign } from "hono/jwt"; +import { Api } from "../../src/Api"; + +describe("Api", async () => { + it("should construct without options", () => { + const api = new Api(); + expect(api.baseUrl).toBe("http://localhost"); + expect(api.isAuthVerified()).toBe(false); + }); + + it("should ignore force verify if no claims given", () => { + const api = new Api({ verified: true }); + expect(api.baseUrl).toBe("http://localhost"); + expect(api.isAuthVerified()).toBe(false); + }); + + it("should construct from request (token)", async () => { + const token = await sign({ foo: "bar" }, "1234"); + const request = new Request("http://example.com/test", { + headers: { + Authorization: `Bearer ${token}` + } + }); + const api = new Api({ request }); + expect(api.isAuthVerified()).toBe(false); + + const params = api.getParams(); + expect(params.token).toBe(token); + expect(params.token_transport).toBe("header"); + expect(params.host).toBe("http://example.com"); + }); + + it("should construct from request (cookie)", async () => { + const token = await sign({ foo: "bar" }, "1234"); + const request = new Request("http://example.com/test", { + headers: { + Cookie: `auth=${token}` + } + }); + const api = new Api({ request }); + expect(api.isAuthVerified()).toBe(false); + + const params = api.getParams(); + console.log(params); + expect(params.token).toBe(token); + expect(params.token_transport).toBe("cookie"); + expect(params.host).toBe("http://example.com"); + }); + + it("should construct from token", async () => { + const token = await sign({ foo: "bar" }, "1234"); + const api = new Api({ token }); + expect(api.isAuthVerified()).toBe(false); + + const params = api.getParams(); + expect(params.token).toBe(token); + expect(params.token_transport).toBe("header"); + expect(params.host).toBe("http://localhost"); + }); + + it("should prefer host when request is given", async () => { + const request = new Request("http://example.com/test"); + const api = new Api({ request, host: "http://another.com" }); + + const params = api.getParams(); + expect(params.token).toBeUndefined(); + expect(params.token_transport).toBe("header"); + expect(params.host).toBe("http://another.com"); + }); +}); diff --git a/app/package.json b/app/package.json index 09fd7d8..9baffe0 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.7.0-rc.1", + "version": "0.7.0-rc.4", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { diff --git a/app/src/Api.ts b/app/src/Api.ts index 2310c0f..f0946f0 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -17,14 +17,21 @@ declare global { } export type ApiOptions = { - host: string; - user?: TApiUser; - token?: string; + host?: string; headers?: Headers; key?: string; localStorage?: boolean; fetcher?: typeof fetch; -}; + verified?: boolean; +} & ( + | { + token?: string; + user?: TApiUser; + } + | { + request: Request; + } +); export type AuthState = { token?: string; @@ -43,14 +50,26 @@ export class Api { public auth!: AuthApi; public media!: MediaApi; - constructor(private readonly options: ApiOptions) { - if (options.user) { - this.user = options.user; - this.token_transport = "none"; - this.verified = true; - } else if (options.token) { + constructor(private options: ApiOptions = {}) { + // only mark verified if forced + this.verified = options.verified === true; + + // prefer request if given + if ("request" in options) { + this.options.host = options.host ?? new URL(options.request.url).origin; + this.options.headers = options.headers ?? options.request.headers; + this.extractToken(); + + // then check for a token + } else if ("token" in options) { this.token_transport = "header"; this.updateToken(options.token); + + // then check for an user object + } else if ("user" in options) { + this.token_transport = "none"; + this.user = options.user; + this.verified = options.verified !== false; } else { this.extractToken(); } @@ -59,7 +78,7 @@ export class Api { } get baseUrl() { - return this.options.host; + return this.options.host ?? "http://localhost"; } get tokenKey() { @@ -67,13 +86,15 @@ export class Api { } private extractToken() { + // if token has to be extracted, it's never verified + this.verified = false; + if (this.options.headers) { // try cookies const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); if (cookieToken) { - this.updateToken(cookieToken); this.token_transport = "cookie"; - this.verified = true; + this.updateToken(cookieToken); return; } @@ -97,6 +118,8 @@ export class Api { updateToken(token?: string, rebuild?: boolean) { this.token = token; + this.verified = false; + if (token) { this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; } else { @@ -116,11 +139,15 @@ export class Api { if (rebuild) this.buildApis(); } - markAuthVerified(verfied: boolean) { + private markAuthVerified(verfied: boolean) { this.verified = verfied; return this; } + isAuthVerified(): boolean { + return this.verified; + } + getAuthState(): AuthState { return { token: this.token, @@ -129,6 +156,11 @@ export class Api { }; } + isAuthenticated(): boolean { + const { token, user } = this.getAuthState(); + return !!token && !!user; + } + async getVerifiedAuthState(): Promise { await this.verifyAuth(); return this.getAuthState(); @@ -141,11 +173,13 @@ export class Api { } try { - const res = await this.auth.me(); - if (!res.ok || !res.body.user) { + const { ok, data } = await this.auth.me(); + const user = data?.user; + if (!ok || !user) { throw new Error(); } + this.user = user; this.markAuthVerified(true); } catch (e) { this.markAuthVerified(false); @@ -157,13 +191,17 @@ export class Api { return this.user || null; } - private buildApis() { - const baseParams = { - host: this.options.host, + getParams() { + return Object.freeze({ + host: this.baseUrl, token: this.token, headers: this.options.headers, token_transport: this.token_transport - }; + }); + } + + private buildApis() { + const baseParams = this.getParams(); const fetcher = this.options.fetcher; this.system = new SystemApi(baseParams, fetcher); diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index a4886c4..c15d570 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -1,5 +1,6 @@ -import { Api, type ApiOptions, type App } from "bknd"; +import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; +import { Api, type ApiOptions } from "bknd/client"; export type AstroBkndConfig = FrameworkBkndConfig; diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 7310727..f674eb1 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,7 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { Api, type App } from "bknd"; +import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { nodeRequestToRequest } from "bknd/adapter/node"; +import { Api } from "bknd/client"; export type NextjsBkndConfig = FrameworkBkndConfig & { cleanSearch?: string[]; diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 82b50e1..2ba24a5 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -21,6 +21,15 @@ export class AuthController extends Controller { return this.auth.ctx.guard; } + get em() { + return this.auth.ctx.em; + } + + get userRepo() { + const entity_name = this.auth.config.entity_name; + return this.em.repo(entity_name as "users"); + } + private registerStrategyActions(strategy: Strategy, mainHono: Hono) { const actions = strategy.getActions?.(); if (!actions) { @@ -96,7 +105,10 @@ export class AuthController extends Controller { hono.get("/me", auth(), async (c) => { if (this.auth.authenticator.isUserLoggedIn()) { - return c.json({ user: this.auth.authenticator.getUser() }); + const claims = this.auth.authenticator.getUser()!; + const { data: user } = await this.userRepo.findId(claims.id); + + return c.json({ user }); } return c.json({ user: null }, 403); diff --git a/app/src/index.ts b/app/src/index.ts index bedbe45..9593e7c 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -12,7 +12,5 @@ export { export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; -export { Api, type ApiOptions } from "./Api"; - export type { MediaFieldSchema } from "media/AppMedia"; export type { UserFieldSchema } from "auth/AppAuth"; diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index b1868fb..8019c50 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -68,6 +68,12 @@ export type InitialModuleConfigs = } & ModuleConfigs) | PartialRec; +enum Verbosity { + silent = 0, + error = 1, + log = 2 +} + export type ModuleManagerOptions = { initial?: InitialModuleConfigs; eventManager?: EventManager; @@ -85,6 +91,8 @@ export type ModuleManagerOptions = { trustFetched?: boolean; // runs when initial config provided on a fresh database seed?: (ctx: ModuleBuildContext) => Promise; + // wether + verbosity?: Verbosity; }; type ConfigTable = { @@ -135,7 +143,7 @@ export class ModuleManager { private _built = false; private readonly _booted_with?: "provided" | "partial"; - private logger = new DebugLogger(false); + private logger: DebugLogger; constructor( private readonly connection: Connection, @@ -144,6 +152,7 @@ export class ModuleManager { this.__em = new EntityManager([__bknd], this.connection); this.modules = {} as Modules; this.emgr = new EventManager(); + this.logger = new DebugLogger(this.verbosity === Verbosity.log); const context = this.ctx(true); let initial = {} as Partial; @@ -171,6 +180,10 @@ export class ModuleManager { } } + private get verbosity() { + return this.options?.verbosity ?? Verbosity.silent; + } + isBuilt(): boolean { return this._built; } @@ -245,20 +258,23 @@ export class ModuleManager { const startTime = performance.now(); // disabling console log, because the table might not exist yet - const result = await withDisabledConsole(async () => { - const { data: result } = await this.repo().findOne( - { type: "config" }, - { - sort: { by: "version", dir: "desc" } + const result = await withDisabledConsole( + async () => { + const { data: result } = await this.repo().findOne( + { type: "config" }, + { + sort: { by: "version", dir: "desc" } + } + ); + + if (!result) { + throw BkndError.with("no config"); } - ); - if (!result) { - throw BkndError.with("no config"); - } - - return result as unknown as ConfigTable; - }, ["log", "error", "warn"]); + return result as unknown as ConfigTable; + }, + this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"] + ); this.logger .log("took", performance.now() - startTime, "ms", { diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index f456f94..f5027e0 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -30,7 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) console.error("error .....", e); } - console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); + //console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user }); return ( diff --git a/app/src/ui/client/index.ts b/app/src/ui/client/index.ts index 9367294..168d127 100644 --- a/app/src/ui/client/index.ts +++ b/app/src/ui/client/index.ts @@ -9,4 +9,4 @@ export { export * from "./api/use-api"; export * from "./api/use-entity"; export { useAuth } from "./schema/auth/use-auth"; -export { Api } from "../../Api"; +export { Api, type TApiUser, type AuthState, type ApiOptions } from "../../Api"; diff --git a/docs/usage/sdk.mdx b/docs/usage/sdk.mdx index ccb5cb2..919a8ee 100644 --- a/docs/usage/sdk.mdx +++ b/docs/usage/sdk.mdx @@ -5,19 +5,74 @@ description: 'Use the bknd SDK in TypeScript' To start using the bknd API, start by creating a new API instance: ```ts -import { Api } from "bknd"; +import { Api } from "bknd/client"; -const api = new Api({ - host: "..." // point to your bknd instance -}); +const api = new Api(); -// make sure to verify auth +// always make sure to verify auth await api.verifyAuth(); ``` The `Api` class is the main entry point for interacting with the bknd API. It provides methods for all available modules described below. +## Setup +You can initialize an API instance by providing the `Request` object, or manually specifying the details such as `host` and `token`. + +### Using the `Request` object +The recommended way to create an API instance is by passing the current `Request` object. This will automatically point the API to your current instance and extract the token from the headers (either from cookies or `Authorization` header): + +```ts +import { Api } from "bknd/client"; + +// replace this with the actual request +let request: Request; + +const api = new Api({ request }); +``` + +If the authentication details are contained in the current request, but you're hosting your bknd instance somewhere else, you can specify a `host` option: + +```ts +import { Api } from "bknd/client"; + +// replace this with the actual request +let request: Request; + +const api = new Api({ + host: "https://", + request, +}); +``` + +### Using the `token` option +If you want to have an API instance that is using a different token, e.g. an admin token, you can create it by specifying the `host` and `token` option: + +```ts +import { Api } from "bknd/client"; +const api = new Api({ + host: "https://", + token: "" +}); +``` + +### Using a local API +In case the place where you're using the API is the same as your bknd instance (e.g. when using it embedded in a React framework), you can specify a `fetcher` option to point to your bknd app. This way, requests won't travel over the network and instead processed locally: + +```ts +import type { App } from "bknd"; +import { Api } from "bknd/client"; + +// replace this with your actual `App` instance +let app: App; + +const api = new Api({ + fetcher: app.server.request as typeof fetch, + // specify `host` and `token` or `request` +}); +``` + + ## Data (`api.data`) Access the `Data` specific API methods at `api.data`. @@ -79,19 +134,25 @@ const { data } = await api.data.deleteOne("posts", 1); Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the API will automatically save the token and use it for subsequent requests. -### `auth.loginWithPassword([input])` -To log in with a password, use the `loginWithPassword` method: +### `auth.strategies()` +To retrieve the available authentication strategies, use the `strategies` method: ```ts -const { data } = await api.auth.loginWithPassword({ +const { data } = await api.auth.strategies(); +``` + +### `auth.login([strategy], [input])` +To log in with a password, use the `login` method: +```ts +const { data } = await api.auth.login("password", { email: "...", password: "..." }); ``` -### `auth.registerWithPassword([input])` -To register with a password, use the `registerWithPassword` method: +### `auth.register([strategy], [input])` +To register with a password, use the `register` method: ```ts -const { data } = await api.auth.registerWithPassword({ +const { data } = await api.auth.register("password", { email: "...", password: "..." }); @@ -103,8 +164,3 @@ To retrieve the current user, use the `me` method: const { data } = await api.auth.me(); ``` -### `auth.strategies()` -To retrieve the available authentication strategies, use the `strategies` method: -```ts -const { data } = await api.auth.strategies(); -``` \ No newline at end of file From 10db26716451832c6a01d894715f6b1d3b465cc5 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 29 Jan 2025 20:26:06 +0100 Subject: [PATCH 2/3] added a `redirect` param to password strategy --- app/src/auth/authenticate/Authenticator.ts | 9 ++- .../strategies/PasswordStrategy.ts | 61 ++++++++++++++----- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 19088d9..63f4f22 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -299,8 +299,8 @@ export class Authenticator = Record< } } - private getSuccessPath(c: Context) { - const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); + private getSafeUrl(c: Context, path: string) { + const p = path.replace(/\/+$/, "/"); // nextjs doesn't support non-fq urls // but env could be proxied (stackblitz), so we shouldn't fq every url @@ -316,7 +316,10 @@ export class Authenticator = Record< return c.json(data); } - const successUrl = this.getSuccessPath(c); + const successUrl = this.getSafeUrl( + c, + redirect ? redirect : (this.config.cookie.pathSuccess ?? "/") + ); const referer = redirect ?? c.req.header("Referer") ?? successUrl; //console.log("auth respond", { redirect, successUrl, successPath }); diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index d8f8a23..c6a9a37 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,4 +1,5 @@ import type { Authenticator, Strategy } from "auth"; +import { isDebug, tbValidator as tb } from "core"; import { type Static, StringEnum, Type, parse } from "core/utils"; import { hash } from "core/utils"; import { type Context, Hono } from "hono"; @@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy { const hono = new Hono(); return hono - .post("/login", async (c) => { - const body = await authenticator.getBody(c); + .post( + "/login", + tb( + "query", + Type.Object({ + redirect: Type.Optional(Type.String()) + }) + ), + async (c) => { + const body = await authenticator.getBody(c); + const { redirect } = c.req.valid("query"); - try { - const payload = await this.login(body); - const data = await authenticator.resolve("login", this, payload.password, payload); + try { + const payload = await this.login(body); + const data = await authenticator.resolve( + "login", + this, + payload.password, + payload + ); - return await authenticator.respond(c, data); - } catch (e) { - return await authenticator.respond(c, e); + return await authenticator.respond(c, data, redirect); + } catch (e) { + return await authenticator.respond(c, e); + } } - }) - .post("/register", async (c) => { - const body = await authenticator.getBody(c); + ) + .post( + "/register", + tb( + "query", + Type.Object({ + redirect: Type.Optional(Type.String()) + }) + ), + async (c) => { + const body = await authenticator.getBody(c); + const { redirect } = c.req.valid("query"); - const payload = await this.register(body); - const data = await authenticator.resolve("register", this, payload.password, payload); + const payload = await this.register(body); + const data = await authenticator.resolve( + "register", + this, + payload.password, + payload + ); - return await authenticator.respond(c, data); - }); + return await authenticator.respond(c, data, redirect); + } + ); } getActions(): StrategyActions { From 4755288fedd93a12825d5c9bf0157014a4784177 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 29 Jan 2025 20:30:27 +0100 Subject: [PATCH 3/3] improve fallbacks for redirection --- app/src/auth/authenticate/Authenticator.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 63f4f22..13fa6bc 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -316,10 +316,7 @@ export class Authenticator = Record< return c.json(data); } - const successUrl = this.getSafeUrl( - c, - redirect ? redirect : (this.config.cookie.pathSuccess ?? "/") - ); + const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/"); const referer = redirect ?? c.req.header("Referer") ?? successUrl; //console.log("auth respond", { redirect, successUrl, successPath });