updated API instantiation, and update user on verify

This commit is contained in:
dswbx
2025-01-29 14:44:32 +01:00
parent 86ba055f5e
commit c2b3316fcb
11 changed files with 250 additions and 57 deletions

View File

@@ -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");
});
});

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "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.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {

View File

@@ -17,14 +17,21 @@ declare global {
} }
export type ApiOptions = { export type ApiOptions = {
host: string; host?: string;
user?: TApiUser;
token?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
localStorage?: boolean; localStorage?: boolean;
fetcher?: typeof fetch; fetcher?: typeof fetch;
}; verified?: boolean;
} & (
| {
token?: string;
user?: TApiUser;
}
| {
request: Request;
}
);
export type AuthState = { export type AuthState = {
token?: string; token?: string;
@@ -43,14 +50,26 @@ export class Api {
public auth!: AuthApi; public auth!: AuthApi;
public media!: MediaApi; public media!: MediaApi;
constructor(private readonly options: ApiOptions) { constructor(private options: ApiOptions = {}) {
if (options.user) { // only mark verified if forced
this.user = options.user; this.verified = options.verified === true;
this.token_transport = "none";
this.verified = true; // prefer request if given
} else if (options.token) { 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.token_transport = "header";
this.updateToken(options.token); 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 { } else {
this.extractToken(); this.extractToken();
} }
@@ -59,7 +78,7 @@ export class Api {
} }
get baseUrl() { get baseUrl() {
return this.options.host; return this.options.host ?? "http://localhost";
} }
get tokenKey() { get tokenKey() {
@@ -67,13 +86,15 @@ export class Api {
} }
private extractToken() { private extractToken() {
// if token has to be extracted, it's never verified
this.verified = false;
if (this.options.headers) { if (this.options.headers) {
// try cookies // try cookies
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
if (cookieToken) { if (cookieToken) {
this.updateToken(cookieToken);
this.token_transport = "cookie"; this.token_transport = "cookie";
this.verified = true; this.updateToken(cookieToken);
return; return;
} }
@@ -97,6 +118,8 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) { updateToken(token?: string, rebuild?: boolean) {
this.token = token; this.token = token;
this.verified = false;
if (token) { if (token) {
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else { } else {
@@ -116,11 +139,15 @@ export class Api {
if (rebuild) this.buildApis(); if (rebuild) this.buildApis();
} }
markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
this.verified = verfied; this.verified = verfied;
return this; return this;
} }
isAuthVerified(): boolean {
return this.verified;
}
getAuthState(): AuthState { getAuthState(): AuthState {
return { return {
token: this.token, token: this.token,
@@ -129,6 +156,11 @@ export class Api {
}; };
} }
isAuthenticated(): boolean {
const { token, user } = this.getAuthState();
return !!token && !!user;
}
async getVerifiedAuthState(): Promise<AuthState> { async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth(); await this.verifyAuth();
return this.getAuthState(); return this.getAuthState();
@@ -141,11 +173,13 @@ export class Api {
} }
try { try {
const res = await this.auth.me(); const { ok, data } = await this.auth.me();
if (!res.ok || !res.body.user) { const user = data?.user;
if (!ok || !user) {
throw new Error(); throw new Error();
} }
this.user = user;
this.markAuthVerified(true); this.markAuthVerified(true);
} catch (e) { } catch (e) {
this.markAuthVerified(false); this.markAuthVerified(false);
@@ -157,13 +191,17 @@ export class Api {
return this.user || null; return this.user || null;
} }
private buildApis() { getParams() {
const baseParams = { return Object.freeze({
host: this.options.host, host: this.baseUrl,
token: this.token, token: this.token,
headers: this.options.headers, headers: this.options.headers,
token_transport: this.token_transport token_transport: this.token_transport
}; });
}
private buildApis() {
const baseParams = this.getParams();
const fetcher = this.options.fetcher; const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher); this.system = new SystemApi(baseParams, fetcher);

View File

@@ -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 { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>; export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;

View File

@@ -1,7 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http"; 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 { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { nodeRequestToRequest } from "bknd/adapter/node"; import { nodeRequestToRequest } from "bknd/adapter/node";
import { Api } from "bknd/client";
export type NextjsBkndConfig = FrameworkBkndConfig & { export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanSearch?: string[]; cleanSearch?: string[];

View File

@@ -21,6 +21,15 @@ export class AuthController extends Controller {
return this.auth.ctx.guard; 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<ServerEnv>) { private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.(); const actions = strategy.getActions?.();
if (!actions) { if (!actions) {
@@ -96,7 +105,10 @@ export class AuthController extends Controller {
hono.get("/me", auth(), async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { 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); return c.json({ user: null }, 403);

View File

@@ -12,7 +12,5 @@ export {
export * as middlewares from "modules/middlewares"; export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries"; export { registries } from "modules/registries";
export { Api, type ApiOptions } from "./Api";
export type { MediaFieldSchema } from "media/AppMedia"; export type { MediaFieldSchema } from "media/AppMedia";
export type { UserFieldSchema } from "auth/AppAuth"; export type { UserFieldSchema } from "auth/AppAuth";

View File

@@ -68,6 +68,12 @@ export type InitialModuleConfigs =
} & ModuleConfigs) } & ModuleConfigs)
| PartialRec<ModuleConfigs>; | PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
error = 1,
log = 2
}
export type ModuleManagerOptions = { export type ModuleManagerOptions = {
initial?: InitialModuleConfigs; initial?: InitialModuleConfigs;
eventManager?: EventManager<any>; eventManager?: EventManager<any>;
@@ -85,6 +91,8 @@ export type ModuleManagerOptions = {
trustFetched?: boolean; trustFetched?: boolean;
// runs when initial config provided on a fresh database // runs when initial config provided on a fresh database
seed?: (ctx: ModuleBuildContext) => Promise<void>; seed?: (ctx: ModuleBuildContext) => Promise<void>;
// wether
verbosity?: Verbosity;
}; };
type ConfigTable<Json = ModuleConfigs> = { type ConfigTable<Json = ModuleConfigs> = {
@@ -135,7 +143,7 @@ export class ModuleManager {
private _built = false; private _built = false;
private readonly _booted_with?: "provided" | "partial"; private readonly _booted_with?: "provided" | "partial";
private logger = new DebugLogger(false); private logger: DebugLogger;
constructor( constructor(
private readonly connection: Connection, private readonly connection: Connection,
@@ -144,6 +152,7 @@ export class ModuleManager {
this.__em = new EntityManager([__bknd], this.connection); this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules; this.modules = {} as Modules;
this.emgr = new EventManager(); this.emgr = new EventManager();
this.logger = new DebugLogger(this.verbosity === Verbosity.log);
const context = this.ctx(true); const context = this.ctx(true);
let initial = {} as Partial<ModuleConfigs>; let initial = {} as Partial<ModuleConfigs>;
@@ -171,6 +180,10 @@ export class ModuleManager {
} }
} }
private get verbosity() {
return this.options?.verbosity ?? Verbosity.silent;
}
isBuilt(): boolean { isBuilt(): boolean {
return this._built; return this._built;
} }
@@ -245,20 +258,23 @@ export class ModuleManager {
const startTime = performance.now(); const startTime = performance.now();
// disabling console log, because the table might not exist yet // disabling console log, because the table might not exist yet
const result = await withDisabledConsole(async () => { const result = await withDisabledConsole(
const { data: result } = await this.repo().findOne( async () => {
{ type: "config" }, const { data: result } = await this.repo().findOne(
{ { type: "config" },
sort: { by: "version", dir: "desc" } {
sort: { by: "version", dir: "desc" }
}
);
if (!result) {
throw BkndError.with("no config");
} }
);
if (!result) { return result as unknown as ConfigTable;
throw BkndError.with("no config"); },
} this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"]
);
return result as unknown as ConfigTable;
}, ["log", "error", "warn"]);
this.logger this.logger
.log("took", performance.now() - startTime, "ms", { .log("took", performance.now() - startTime, "ms", {

View File

@@ -30,7 +30,7 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
console.error("error .....", e); 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 }); const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
return ( return (

View File

@@ -9,4 +9,4 @@ export {
export * from "./api/use-api"; export * from "./api/use-api";
export * from "./api/use-entity"; export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth"; export { useAuth } from "./schema/auth/use-auth";
export { Api } from "../../Api"; export { Api, type TApiUser, type AuthState, type ApiOptions } from "../../Api";

View File

@@ -5,19 +5,74 @@ description: 'Use the bknd SDK in TypeScript'
To start using the bknd API, start by creating a new API instance: To start using the bknd API, start by creating a new API instance:
```ts ```ts
import { Api } from "bknd"; import { Api } from "bknd/client";
const api = new Api({ const api = new Api();
host: "..." // point to your bknd instance
});
// make sure to verify auth // always make sure to verify auth
await api.verifyAuth(); await api.verifyAuth();
``` ```
The `Api` class is the main entry point for interacting with the bknd API. It provides methods The `Api` class is the main entry point for interacting with the bknd API. It provides methods
for all available modules described below. 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://<your-endpoint>",
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://<your-endpoint>",
token: "<your-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`) ## Data (`api.data`)
Access the `Data` specific API methods at `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 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. API will automatically save the token and use it for subsequent requests.
### `auth.loginWithPassword([input])` ### `auth.strategies()`
To log in with a password, use the `loginWithPassword` method: To retrieve the available authentication strategies, use the `strategies` method:
```ts ```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: "...", email: "...",
password: "..." password: "..."
}); });
``` ```
### `auth.registerWithPassword([input])` ### `auth.register([strategy], [input])`
To register with a password, use the `registerWithPassword` method: To register with a password, use the `register` method:
```ts ```ts
const { data } = await api.auth.registerWithPassword({ const { data } = await api.auth.register("password", {
email: "...", email: "...",
password: "..." password: "..."
}); });
@@ -103,8 +164,3 @@ To retrieve the current user, use the `me` method:
const { data } = await api.auth.me(); 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();
```