diff --git a/crates/assets/js/admin/src/components/auth/Totp.tsx b/crates/assets/js/admin/src/components/auth/Totp.tsx index e29cee19..0eeb0691 100644 --- a/crates/assets/js/admin/src/components/auth/Totp.tsx +++ b/crates/assets/js/admin/src/components/auth/Totp.tsx @@ -30,7 +30,7 @@ export function TotpToggleButton(props: { client: Client; user: User }) { try { const res = await props.client.registerTOTP({ png: true }); setTotp({ - url: res.totp_url, + url: res.url, png: res.png ?? "", }); } catch (err) { diff --git a/crates/assets/js/client/bindings b/crates/assets/js/client/bindings new file mode 120000 index 00000000..009fc9d0 --- /dev/null +++ b/crates/assets/js/client/bindings @@ -0,0 +1 @@ +../bindings \ No newline at end of file diff --git a/crates/assets/js/client/package.json b/crates/assets/js/client/package.json index 10577929..d4660d21 100644 --- a/crates/assets/js/client/package.json +++ b/crates/assets/js/client/package.json @@ -2,18 +2,18 @@ "name": "trailbase", "description": "Official TrailBase Client", "homepage": "https://trailbase.io", - "version": "0.10.0", + "version": "0.11.0", "license": "Apache-2.0 OR OSL-3.0", "type": "module", "main": "./src/index.ts", "publishConfig": { "access": "public", - "main": "./dist/client/src/index.js", - "types": "/dist/client/src/index.d.ts", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "default": "./dist/client/src/index.js", - "types": "./dist/client/src/index.d.ts" + "default": "./dist/index.js", + "types": "./dist/index.d.ts" } } }, @@ -27,13 +27,19 @@ "directory": "crates/assets/js/client" }, "scripts": { - "build": "tsc", + "build": "vite build", "check": "tsc --noEmit --skipLibCheck && eslint", "format": "prettier -w src tests", - "prepack": "rm -rf ./dist && pnpm build && test -e ./dist/client/src/index.js", - "start": "tsc && node dist/client/src/index.js", + "prepack": "rm -rf ./dist && pnpm build && test -e ./dist/index.js", "test": "vitest run && vite-node tests/integration_test_runner.ts" }, + "dependencies": { + "@types/geojson": "^7946.0.16", + "@ungap/raw-json": "0.4.4", + "geojson": "^0.5.0", + "jwt-decode": "^4.0.0", + "uuid": "^13.0.0" + }, "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.0.3", @@ -47,14 +53,9 @@ "tinybench": "^6.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", + "vite": "^8.0.1", "vite-node": "^6.0.0", + "vite-plugin-dts": "^4.5.4", "vitest": "^4.1.0" - }, - "dependencies": { - "@types/geojson": "^7946.0.16", - "@ungap/raw-json": "0.4.4", - "geojson": "^0.5.0", - "jwt-decode": "^4.0.0", - "uuid": "^13.0.0" } } diff --git a/crates/assets/js/client/src/client.ts b/crates/assets/js/client/src/client.ts new file mode 100644 index 00000000..f716a39f --- /dev/null +++ b/crates/assets/js/client/src/client.ts @@ -0,0 +1,606 @@ +import { jwtDecode } from "jwt-decode"; +import * as JSON from "@ungap/raw-json"; + +import { isDev } from "./constants"; +import { jsonContentTypeHeader } from "./constants"; +import { parseJSON } from "./json"; +import type { + RecordApi, + RecordId, + CreateOperation, + UpdateOperation, + DeleteOperation, +} from "./record_api"; +import { RecordApiImpl } from "./record_api"; +import { ThinClient, Transport } from "./transport"; + +export type { Transport } from "./transport"; + +import type { ChangeEmailRequest } from "@bindings/ChangeEmailRequest"; +import type { RequestOtpRequest } from "@bindings/RequestOtpRequest"; +import type { LoginOtpRequest } from "@bindings/LoginOtpRequest"; +import type { RegisterTotpResponse } from "@bindings/RegisterTotpResponse"; +import type { ConfirmRegisterTotpRequest } from "@bindings/ConfirmRegisterTotpRequest"; +import type { DisableTotpRequest } from "@bindings/DisableTotpRequest"; +import type { MfaTokenResponse } from "@bindings/MfaTokenResponse"; +import type { LoginRequest } from "@bindings/LoginRequest"; +import type { LoginMfaRequest } from "@bindings/LoginMfaRequest"; +import type { LoginResponse } from "@bindings/LoginResponse"; +import type { LoginStatusResponse } from "@bindings/LoginStatusResponse"; +import type { LogoutRequest } from "@bindings/LogoutRequest"; +import type { RefreshRequest } from "@bindings/RefreshRequest"; +import type { RefreshResponse } from "@bindings/RefreshResponse"; + +export type User = { + id: string; + email: string; + admin?: boolean; + mfa?: boolean; +}; + +export interface MultiFactorAuthToken { + token: string; +} + +export type RegisterTotp = { url: string; png: string | null }; + +export type Tokens = { + auth_token: string; + refresh_token: string | null; + csrf_token: string | null; +}; + +type TokenClaims = { + sub: string; + iat: number; + exp: number; + email: string; + csrf_token: string; + admin?: boolean; + mfa?: boolean; +}; + +type TokenState = { + state?: { + tokens: Tokens; + claims: TokenClaims; + }; + headers: HeadersInit; +}; + +export type Event = + | { Insert: object } + | { Update: object } + | { Delete: object } + | { Error: string }; + +function buildTokenState(tokens?: Tokens): TokenState { + return { + state: tokens && { + tokens, + claims: jwtDecode(tokens.auth_token), + }, + headers: headers(tokens), + }; +} + +function buildUser(state: TokenState): User | undefined { + const claims = state.state?.claims; + if (claims) { + return { + id: claims.sub, + email: claims.email, + admin: claims.admin, + mfa: claims.mfa, + }; + } +} + +function isExpired(state: TokenState): boolean { + const claims = state.state?.claims; + if (claims) { + const now = Date.now() / 1000; + if (claims.exp < now) { + return true; + } + } + + return false; +} + +/// Returns the refresh token if should refresh. +function shouldRefresh(tokenState: TokenState): string | undefined { + const state = tokenState.state; + if (state && state.claims.exp - 60 < Date.now() / 1000) { + return state.tokens?.refresh_token ?? undefined; + } +} + +export type FetchOptions = RequestInit & { + throwOnError?: boolean; +}; + +export class FetchError extends Error { + public status: number; + public url: string | URL | undefined; + + constructor(status: number, msg: string, url?: string | URL) { + super(msg); + this.status = status; + this.url = url; + } + + static async from( + response: Response, + url?: string | URL, + ): Promise { + // Some IntoResponse implementations return a body, e.g. RecordError::BadRequest. + const msg: string = await response.text().then( + (b) => (b !== "" ? b : response.statusText), + (_err) => response.statusText, + ); + return new FetchError(response.status, msg, url); + } + + public isClient(): boolean { + return this.status >= 400 && this.status < 500; + } + + public isServer(): boolean { + return this.status >= 500; + } + + public toString(): string { + return `FetchError(${[this.status, this.message, this.url].filter((e) => e !== undefined).join(", ")})`; + } +} + +export interface ClientOptions { + tokens?: Tokens; + onAuthChange?: (client: Client, user?: User) => void; + transport?: Transport; +} + +export interface Client { + get base(): URL | undefined; + + /// Low-level access to tokens (auth, refresh, csrf) useful for persisting them. + tokens(): Tokens | undefined; + + /// Provides current user. + user(): User | undefined; + + /// Provides current user. + headers(): HeadersInit; + + /// Construct accessor for Record API with given name. + records>(name: string): RecordApi; + + avatarUrl(userId?: string): string | undefined; + + login( + email: string, + password: string, + ): Promise; + loginSecond(opts: { + mfaToken: MultiFactorAuthToken; + totpCode: string; + }): Promise; + requestOtp(email: string, opts?: { redirectUri?: string }): Promise; + loginOtp(email: string, code: string): Promise; + logout(): Promise; + + registerTOTP(opts?: { png: boolean }): Promise; + confirmTOTP(totpUrl: string, totp: string): Promise; + unregisterTOTP(totp: string): Promise; + + deleteUser(): Promise; + checkCookies(): Promise; + refreshAuthToken(): Promise; + + /// Fetches data from TrailBase endpoints, e.g.: + /// const response = await client.fetch("/api/auth/v1/status"); + /// + /// Unlike native fetch, will throw in case !response.ok. + fetch(path: string, init?: FetchOptions): Promise; + + /// Execute a batch query. + execute( + operations: (CreateOperation | UpdateOperation | DeleteOperation)[], + transaction?: boolean, + ): Promise; +} + +/// Client for interacting with TrailBase auth and record APIs. +class ClientImpl implements Client { + private readonly _base: URL | undefined; + private readonly _client: Transport; + private readonly _authChange: + | undefined + | ((client: Client, user?: User) => void); + private _tokenState: TokenState; + + constructor(baseUrl: URL | string | undefined, opts?: ClientOptions) { + this._base = baseUrl ? new URL(baseUrl) : undefined; + this._client = opts?.transport ?? new ThinClient(this._base); + this._authChange = opts?.onAuthChange; + + const tokens = opts?.tokens; + // Note: this is a double assignment to _tokenState to ensure the linter + // that it's really initialized in the constructor. + this._tokenState = this.setTokenState(buildTokenState(tokens), true); + + if (tokens?.refresh_token !== undefined) { + // Validate session. This is currently async, which allows to initialize + // a Client synchronously from invalid tokens. We may want to consider + // offering a safer async initializer to avoid "racy" behavior. Especially, + // when the auth token is valid while the session has already been closed. + this.checkAuthStatus() + .then((tokens) => { + if (tokens === undefined) { + // In this case, the auth state has changed, so we should invoke the callback. + this.setTokenState(buildTokenState(undefined), false); + } else { + // In this case, the auth state has remained the same, we're merely + // updating the reminted auth token. + this.setTokenState(buildTokenState(tokens), true); + } + }) + .catch(console.error); + } + } + + public get base(): URL | undefined { + return this._base; + } + + /// Low-level access to tokens (auth, refresh, csrf) useful for persisting them. + public tokens = (): Tokens | undefined => this._tokenState?.state?.tokens; + + /// Provides current user. + public user = (): User | undefined => buildUser(this._tokenState); + + /// Provides current user. + public headers = (): HeadersInit => this._tokenState.headers; + + /// Construct accessor for Record API with given name. + public records>(name: string): RecordApi { + return new RecordApiImpl(this, name); + } + + /// Execute a batch query. + async execute( + operations: (CreateOperation | UpdateOperation | DeleteOperation)[], + transaction: boolean = true, + ): Promise { + const response = await this.fetch(transactionApiBasePath, { + method: "POST", + body: JSON.stringify({ operations, transaction }), + headers: jsonContentTypeHeader, + }); + + return parseJSON(await response.text()).ids; + } + + public avatarUrl(userId?: string): string | undefined { + const id = userId ?? this.user()?.id; + if (id) { + return `${authApiBasePath}/avatar/${id}`; + } + return undefined; + } + + public async login( + email: string, + password: string, + ): Promise { + try { + const response = await this.fetch(`${authApiBasePath}/login`, { + method: "POST", + body: JSON.stringify({ + email, + password, + } as LoginRequest), + headers: jsonContentTypeHeader, + }); + + this.setTokenState( + buildTokenState((await response.json()) as LoginResponse), + ); + } catch (err) { + if (err instanceof FetchError && err.status === 403) { + const mfaTokenResponse = JSON.parse(err.message) as MfaTokenResponse; + return { + token: mfaTokenResponse.mfa_token, + }; + } + + throw err; + } + } + + public async loginSecond(opts: { + mfaToken: MultiFactorAuthToken; + totpCode: string; + }): Promise { + const response = await this.fetch(`${authApiBasePath}/login_mfa`, { + method: "POST", + body: JSON.stringify({ + mfa_token: opts.mfaToken.token, + totp: opts.totpCode, + } as LoginMfaRequest), + headers: jsonContentTypeHeader, + }); + + this.setTokenState( + buildTokenState((await response.json()) as LoginResponse), + ); + } + + public async requestOtp( + email: string, + opts?: { redirectUri?: string }, + ): Promise { + const redirect = opts?.redirectUri; + const params = redirect ? `?redirect_uri=${redirect}` : ""; + await this.fetch(`${authApiBasePath}/otp/request${params}`, { + method: "POST", + body: JSON.stringify({ + email, + } as RequestOtpRequest), + headers: jsonContentTypeHeader, + }); + } + + public async loginOtp(email: string, code: string): Promise { + const response = await this.fetch(`${authApiBasePath}/otp/login`, { + method: "POST", + body: JSON.stringify({ + email, + code, + } as LoginOtpRequest), + headers: jsonContentTypeHeader, + }); + + this.setTokenState( + buildTokenState((await response.json()) as LoginResponse), + ); + } + + public async logout(): Promise { + try { + const refresh_token = this._tokenState.state?.tokens.refresh_token; + if (refresh_token) { + await this.fetch(`${authApiBasePath}/logout`, { + method: "POST", + body: JSON.stringify({ + refresh_token, + } as LogoutRequest), + headers: jsonContentTypeHeader, + }); + } else { + await this.fetch(`${authApiBasePath}/logout`); + } + } catch (err) { + console.debug(err); + } + this.setTokenState(buildTokenState(undefined)); + return true; + } + + public async deleteUser(): Promise { + await this.fetch(`${authApiBasePath}/delete`); + this.setTokenState(buildTokenState(undefined)); + } + + public async changeEmail(email: string): Promise { + await this.fetch(`${authApiBasePath}/change_email`, { + method: "POST", + body: JSON.stringify({ + new_email: email, + } as ChangeEmailRequest), + headers: jsonContentTypeHeader, + }); + } + + public async registerTOTP(opts?: { png: boolean }): Promise { + const response = await this.fetch( + `${authApiBasePath}/totp/register?png=${opts?.png ?? false}`, + { + method: "GET", + headers: jsonContentTypeHeader, + }, + ); + + const parsed: RegisterTotpResponse = parseJSON(await response.text()); + return { + url: parsed.totp_url, + png: parsed.png, + }; + } + + public async confirmTOTP(totpUrl: string, totp: string): Promise { + await this.fetch(`${authApiBasePath}/totp/confirm`, { + method: "POST", + body: JSON.stringify({ + totp_url: totpUrl, + totp, + } as ConfirmRegisterTotpRequest), + headers: jsonContentTypeHeader, + }); + await this.refreshAuthToken({ force: true }); + } + + public async unregisterTOTP(totp: string): Promise { + await this.fetch(`${authApiBasePath}/totp/unregister`, { + method: "POST", + body: JSON.stringify({ + totp, + } as DisableTotpRequest), + headers: jsonContentTypeHeader, + }); + await this.refreshAuthToken({ force: true }); + } + + /// This will call the status endpoint, which validates any provided tokens + /// but also hoists any tokens provided as cookies into a JSON response. + private async checkAuthStatus(): Promise { + const response = await this.fetch(`${authApiBasePath}/status`, { + throwOnError: false, + }); + if (response.ok) { + const status: LoginStatusResponse = await response.json(); + const auth_token = status.auth_token; + if (auth_token) { + return { + auth_token, + refresh_token: status.refresh_token, + csrf_token: status.csrf_token, + }; + } + } + return undefined; + } + + public async checkCookies(): Promise { + const tokens = await this.checkAuthStatus(); + if (tokens) { + const newState = buildTokenState(tokens); + this.setTokenState(newState); + return newState.state?.tokens; + } + } + + public async refreshAuthToken(opts?: { force?: boolean }): Promise { + const force = opts?.force ?? false; + const refreshToken = force + ? this._tokenState.state?.tokens.refresh_token + : shouldRefresh(this._tokenState); + if (refreshToken) { + // Note: refreshTokenImpl will auto-logout on 401. + this.setTokenState(await this.refreshTokensImpl(refreshToken)); + } + } + + private async refreshTokensImpl(refreshToken: string): Promise { + const path = `${authApiBasePath}/refresh`; + const response = await this._client.fetch(path, this._tokenState.headers, { + method: "POST", + body: JSON.stringify({ + refresh_token: refreshToken, + } as RefreshRequest), + headers: jsonContentTypeHeader, + }); + + if (!response.ok) { + if (response.status === 401) { + this.logout(); + } + throw await FetchError.from( + response, + isDev ? new URL(path, this.base) : undefined, + ); + } + + return buildTokenState({ + ...((await response.json()) as RefreshResponse), + refresh_token: refreshToken, + }); + } + + private setTokenState( + state: TokenState, + skipCb: boolean = false, + ): TokenState { + this._tokenState = state; + if (!skipCb) { + this._authChange?.(this, buildUser(state)); + } + + if (isExpired(state)) { + // This can happen on initial construction, i.e. if a client is + // constructed from older, persisted tokens. + console.debug(`Set token state (expired)`); + } + + return this._tokenState; + } + + /// Fetches data from TrailBase endpoints, e.g.: + /// const response = await client.fetch("/api/auth/v1/status"); + /// + /// Unlike native fetch, will throw in case !response.ok. + public async fetch(path: string, init?: FetchOptions): Promise { + let tokenState = this._tokenState; + const refreshToken = shouldRefresh(tokenState); + if (refreshToken) { + tokenState = this.setTokenState( + await this.refreshTokensImpl(refreshToken), + ); + } + + try { + const response = await this._client.fetch(path, tokenState.headers, init); + if (!response.ok && (init?.throwOnError ?? true)) { + throw await FetchError.from( + response, + isDev ? new URL(path, this.base) : undefined, + ); + } + return response; + } catch (err) { + if (err instanceof TypeError) { + console.debug(`Connection refused ${err}. TrailBase down or CORS?`); + } + throw err; + } + } +} + +/// Initialize a new TrailBase client. +export function initClient(site?: URL | string, opts?: ClientOptions): Client { + return new ClientImpl(site, opts); +} + +/// Asynchronously initialize a new TrailBase client trying to convert any +/// potentially existing cookies into an authenticated client. +export async function initClientFromCookies( + site?: URL | string, + opts?: ClientOptions, +): Promise { + const client = new ClientImpl(site, opts); + + // Prefer explicit tokens. When given, do not update/refresh infinite recursion + // with `($token) => Client` factories. + if (!client.tokens()) { + try { + await client.checkCookies(); + } catch (err) { + console.debug("No valid cookies found: ", err); + } + } + + return client; +} + +const authApiBasePath = "/api/auth/v1"; +const transactionApiBasePath = "/api/transaction/v1/execute"; + +function headers(tokens?: Tokens): HeadersInit { + if (tokens) { + const { auth_token, refresh_token, csrf_token } = tokens; + return { + ...(auth_token && { + Authorization: `Bearer ${auth_token}`, + }), + ...(refresh_token && { + "Refresh-Token": refresh_token, + }), + ...(csrf_token && { + "CSRF-Token": csrf_token, + }), + }; + } + + return {}; +} diff --git a/crates/assets/js/client/src/constants.ts b/crates/assets/js/client/src/constants.ts new file mode 100644 index 00000000..b6ba3d1e --- /dev/null +++ b/crates/assets/js/client/src/constants.ts @@ -0,0 +1,16 @@ +export const jsonContentTypeHeader = { + "Content-Type": "application/json", +}; + +function _isDev(): boolean { + type ImportMeta = { + env: object | undefined; + }; + const env = (import.meta as unknown as ImportMeta).env; + const key = "DEV" as keyof typeof env; + const isDev = env?.[key] ?? false; + + return isDev; +} + +export const isDev = _isDev(); diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index 5ab81844..930c4680 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -1,1166 +1,7 @@ -import { jwtDecode } from "jwt-decode"; -import * as JSON from "@ungap/raw-json"; -import { FeatureCollection } from "geojson"; +import { isDev } from "./constants"; -import type { ChangeEmailRequest } from "@bindings/ChangeEmailRequest"; -import type { RequestOtpRequest } from "@bindings/RequestOtpRequest"; -import type { LoginOtpRequest } from "@bindings/LoginOtpRequest"; -import type { RegisterTotpResponse } from "@bindings/RegisterTotpResponse"; -import type { ConfirmRegisterTotpRequest } from "@bindings/ConfirmRegisterTotpRequest"; -import type { DisableTotpRequest } from "@bindings/DisableTotpRequest"; -import type { MfaTokenResponse } from "@bindings/MfaTokenResponse"; -import type { LoginRequest } from "@bindings/LoginRequest"; -import type { LoginMfaRequest } from "@bindings/LoginMfaRequest"; -import type { LoginResponse } from "@bindings/LoginResponse"; -import type { LoginStatusResponse } from "@bindings/LoginStatusResponse"; -import type { LogoutRequest } from "@bindings/LogoutRequest"; -import type { RefreshRequest } from "@bindings/RefreshRequest"; -import type { RefreshResponse } from "@bindings/RefreshResponse"; -import type { WsProtocol } from "@bindings/WsProtocol"; - -export type User = { - id: string; - email: string; - admin?: boolean; - mfa?: boolean; -}; - -export type Pagination = { - cursor?: string; - limit?: number; - offset?: number; -}; - -export type ListResponse = { - cursor?: string; - records: T[]; - total_count?: number; -}; - -export interface MultiFactorAuthToken { - token: string; -} - -export type Tokens = { - auth_token: string; - refresh_token: string | null; - csrf_token: string | null; -}; - -type TokenClaims = { - sub: string; - iat: number; - exp: number; - email: string; - csrf_token: string; - admin?: boolean; - mfa?: boolean; -}; - -type TokenState = { - state?: { - tokens: Tokens; - claims: TokenClaims; - }; - headers: HeadersInit; -}; - -export type Event = - | { Insert: object } - | { Update: object } - | { Delete: object } - | { Error: string }; - -function buildTokenState(tokens?: Tokens): TokenState { - return { - state: tokens && { - tokens, - claims: jwtDecode(tokens.auth_token), - }, - headers: headers(tokens), - }; -} - -function buildUser(state: TokenState): User | undefined { - const claims = state.state?.claims; - if (claims) { - return { - id: claims.sub, - email: claims.email, - admin: claims.admin, - mfa: claims.mfa, - }; - } -} - -function isExpired(state: TokenState): boolean { - const claims = state.state?.claims; - if (claims) { - const now = Date.now() / 1000; - if (claims.exp < now) { - return true; - } - } - - return false; -} - -/// Returns the refresh token if should refresh. -function shouldRefresh(tokenState: TokenState): string | undefined { - const state = tokenState.state; - if (state && state.claims.exp - 60 < Date.now() / 1000) { - return state.tokens?.refresh_token ?? undefined; - } -} - -export type FetchOptions = RequestInit & { - throwOnError?: boolean; -}; - -export class FetchError extends Error { - public status: number; - public url: string | URL | undefined; - - constructor(status: number, msg: string, url?: string | URL) { - super(msg); - this.status = status; - this.url = url; - } - - static async from( - response: Response, - url?: string | URL, - ): Promise { - // Some IntoResponse implementations return a body, e.g. RecordError::BadRequest. - const msg: string = await response.text().then( - (b) => (b !== "" ? b : response.statusText), - (_err) => response.statusText, - ); - return new FetchError(response.status, msg, url); - } - - public isClient(): boolean { - return this.status >= 400 && this.status < 500; - } - - public isServer(): boolean { - return this.status >= 500; - } - - public toString(): string { - return `FetchError(${[this.status, this.message, this.url].filter((e) => e !== undefined).join(", ")})`; - } -} - -export interface FileUpload { - content_type?: null | string; - filename?: null | string; - mime_type?: null | string; - objectstore_path: string; -} - -export type CompareOp = - | "equal" - | "notEqual" - | "lessThan" - | "lessThanEqual" - | "greaterThan" - | "greaterThanEqual" - | "like" - | "regexp" - | "@within" - | "@intersects" - | "@contains"; - -function formatCompareOp(op: CompareOp): string { - switch (op) { - case "equal": - return "$eq"; - case "notEqual": - return "$ne"; - case "lessThan": - return "$lt"; - case "lessThanEqual": - return "$lte"; - case "greaterThan": - return "$gt"; - case "greaterThanEqual": - return "$gte"; - case "like": - return "$like"; - case "regexp": - return "$re"; - // Geospatials: - case "@within": - case "@intersects": - case "@contains": - return op; - } -} - -export type Filter = { - column: string; - op?: CompareOp; - value: string; -}; - -export type And = { - and: FilterOrComposite[]; -}; - -export type Or = { - or: FilterOrComposite[]; -}; - -export type FilterOrComposite = Filter | And | Or; - -export type RecordId = string | number; - -// TODO: Use `ts-rs` generated types. -interface CreateOp { - Create: { - api_name: string; - value: Record; - }; -} - -interface UpdateOp { - Update: { - api_name: string; - record_id: RecordId; - value: Record; - }; -} - -interface DeleteOp { - Delete: { - api_name: string; - record_id: RecordId; - }; -} - -export interface DeferredOperation { - query(): Promise; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface DeferredMutation< - ResponseType, -> extends DeferredOperation {} - -export class CreateOperation< - T = Record, -> implements DeferredMutation { - constructor( - private readonly client: Client, - private readonly apiName: string, - private readonly record: Partial, - ) {} - - async query(): Promise { - const response = await this.client.fetch( - `${recordApiBasePath}/${this.apiName}`, - { - method: "POST", - body: JSON.stringify(this.record), - headers: jsonContentTypeHeader, - }, - ); - - return parseJSON(await response.text()).ids[0]; - } - - protected toJSON(): CreateOp { - return { - Create: { - api_name: this.apiName, - value: this.record, - }, - }; - } -} - -export class UpdateOperation< - T = Record, -> implements DeferredMutation { - constructor( - private readonly client: Client, - private readonly apiName: string, - private readonly id: RecordId, - private readonly record: Partial, - ) {} - - async query(): Promise { - await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { - method: "PATCH", - body: JSON.stringify(this.record), - headers: jsonContentTypeHeader, - }); - } - - protected toJSON(): UpdateOp { - return { - Update: { - api_name: this.apiName, - record_id: this.id, - value: this.record, - }, - }; - } -} - -export class DeleteOperation implements DeferredMutation { - constructor( - private readonly client: Client, - private readonly apiName: string, - private readonly id: RecordId, - ) {} - async query(): Promise { - await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { - method: "DELETE", - }); - } - - protected toJSON(): DeleteOp { - return { - Delete: { - api_name: this.apiName, - record_id: this.id, - }, - }; - } -} - -export interface ReadOpts { - expand?: string[]; -} - -export class ReadOperation< - T = Record, -> implements DeferredOperation { - constructor( - private readonly client: Client, - private readonly apiName: string, - private readonly id: RecordId, - private readonly opt?: ReadOpts, - ) {} - - async query(): Promise { - const expand = this.opt?.expand; - const response = await this.client.fetch( - expand - ? `${recordApiBasePath}/${this.apiName}/${this.id}?expand=${expand.join(",")}` - : `${recordApiBasePath}/${this.apiName}/${this.id}`, - ); - return parseJSON(await response.text()) as T; - } -} - -export interface ListOpts { - pagination?: Pagination; - order?: string[]; - filters?: FilterOrComposite[]; - count?: boolean; - expand?: string[]; -} - -export class ListOperation< - T = Record, - R = ListResponse, -> implements DeferredOperation { - constructor( - private readonly client: Client, - private readonly apiName: string, - private readonly opts?: ListOpts, - private readonly geojson?: string, - ) {} - async query(): Promise { - const params = new URLSearchParams(); - const pagination = this.opts?.pagination; - if (pagination) { - const cursor = pagination.cursor; - if (cursor) params.append("cursor", cursor); - - const limit = pagination.limit; - if (limit) params.append("limit", limit.toString()); - - const offset = pagination.offset; - if (offset) params.append("offset", offset.toString()); - } - const order = this.opts?.order; - if (order) params.append("order", order.join(",")); - - if (this.opts?.count) params.append("count", "true"); - - const expand = this.opts?.expand; - if (expand) params.append("expand", expand.join(",")); - - const filters = this.opts?.filters; - if (filters) { - for (const filter of filters) { - addFiltersToParams(params, "filter", filter); - } - } - - if (this.geojson) params.append("geojson", this.geojson); - - const response = await this.client.fetch( - `${recordApiBasePath}/${this.apiName}?${params}`, - ); - return parseJSON(await response.text()) as R; - } -} - -export interface SubscribeOpts { - filters?: FilterOrComposite[]; -} - -export interface RecordApi> { - list(opts?: ListOpts): Promise>; - listOp(opts?: ListOpts): ListOperation; - // For queries on TABLE/VIEWs with geometry columns wantin to return GeoJSON. - listGeoOp( - geometryColumn: string, - opts?: ListOpts, - ): ListOperation; - - read(id: RecordId, opt?: ReadOpts): Promise; - readOp(id: RecordId, opt?: ReadOpts): ReadOperation; - - create(record: T): Promise; - createOp(record: T): CreateOperation; - // TODO: Retire in favor of `client.execute`. - createBulk(records: T[]): Promise; - - update(id: RecordId, record: Partial): Promise; - updateOp(id: RecordId, record: Partial): UpdateOperation; - - delete(id: RecordId): Promise; - deleteOp(id: RecordId): DeleteOperation; - - subscribe(id: RecordId): Promise>; - subscribeAll(opts?: SubscribeOpts): Promise>; -} - -/// Provides CRUD access to records through TrailBase's record API. -export class RecordApiImpl< - T = Record, -> implements RecordApi { - constructor( - private readonly client: Client, - private readonly name: string, - ) {} - - public async list(opts?: ListOpts): Promise> { - return new ListOperation(this.client, this.name, opts).query(); - } - - public listOp(opts?: ListOpts): ListOperation { - return new ListOperation(this.client, this.name, opts); - } - - public listGeoOp( - geometryColumn: string, - opts?: ListOpts, - ): ListOperation { - return new ListOperation( - this.client, - this.name, - opts, - geometryColumn, - ); - } - - public async read>( - id: RecordId, - opt?: ReadOpts, - ): Promise { - return new ReadOperation(this.client, this.name, id, opt).query(); - } - - public readOp(id: RecordId, opt?: ReadOpts): ReadOperation { - return new ReadOperation(this.client, this.name, id, opt); - } - - public async create(record: T): Promise { - return new CreateOperation(this.client, this.name, record).query(); - } - - public createOp(record: T): CreateOperation { - return new CreateOperation(this.client, this.name, record); - } - public async createBulk>( - records: T[], - ): Promise { - const response = await this.client.fetch( - `${recordApiBasePath}/${this.name}`, - { - method: "POST", - body: JSON.stringify(records), - headers: jsonContentTypeHeader, - }, - ); - - return parseJSON(await response.text()).ids; - } - - public async update(id: RecordId, record: Partial): Promise { - return new UpdateOperation(this.client, this.name, id, record).query(); - } - - public updateOp(id: RecordId, record: Partial): UpdateOperation { - return new UpdateOperation(this.client, this.name, id, record); - } - - public async delete(id: RecordId): Promise { - return new DeleteOperation(this.client, this.name, id).query(); - } - - public deleteOp(id: RecordId): DeleteOperation { - return new DeleteOperation(this.client, this.name, id); - } - - public async subscribe(id: RecordId): Promise> { - return await this.subscribeImpl(id); - } - - public async subscribeAll( - opts?: SubscribeOpts, - ): Promise> { - return await this.subscribeImpl("*", opts); - } - - private async subscribeImpl( - id: RecordId, - opts?: SubscribeOpts, - ): Promise> { - const params = new URLSearchParams(); - const filters = opts?.filters ?? []; - if (filters.length > 0) { - for (const filter of filters) { - addFiltersToParams(params, "filter", filter); - } - } - - const response = await this.client.fetch( - filters.length > 0 - ? `${recordApiBasePath}/${this.name}/subscribe/${id}?${params}` - : `${recordApiBasePath}/${this.name}/subscribe/${id}`, - ); - const body = response.body; - if (!body) { - throw Error("Subscription reader is null."); - } - - const decoder = new TextDecoder(); - const transformStream = new TransformStream({ - transform(chunk: Uint8Array, controller) { - const messages = decoder.decode(chunk).trimEnd().split("\n\n"); - for (const msg of messages) { - if (msg.startsWith("data: ")) { - controller.enqueue(parseJSON(msg.substring(6))); - } - } - }, - flush(controller) { - controller.terminate(); - }, - }); - - return body.pipeThrough(transformStream); - } - - async subscribeWs( - id: RecordId, - opts?: SubscribeOpts, - ): Promise> { - const params = new URLSearchParams(); - params.append("ws", "true"); - - const filters = opts?.filters ?? []; - if (filters.length > 0) { - for (const filter of filters) { - addFiltersToParams(params, "filter", filter); - } - } - - return new Promise>((resolve, reject) => { - const host = this.client.base?.host ?? ""; - const protocol = this.client.base?.protocol === "https" ? "wss" : "ws"; - const url = `${protocol}://${host}${recordApiBasePath}/${this.name}/subscribe/${id}?${params}`; - - const socket = new WebSocket(url); - - const timeout = setTimeout(() => { - reject("WS connection timeout"); - }, 5000); - - const readable = new ReadableStream({ - start: (controller) => { - socket.addEventListener("open", (_openEvent) => { - // Initialize connection and authenticate. - socket.send( - JSON.stringify({ - Init: { - auth_token: this.client.tokens()?.auth_token ?? null, - }, - } as WsProtocol), - ); - - clearTimeout(timeout); - resolve(readable); - }); - - socket.addEventListener("close", () => { - controller.close(); - }); - - socket.addEventListener("error", (err) => { - controller.error(err); - }); - - // Listen for messages - socket.addEventListener("message", (event) => { - if (typeof event.data !== "string") { - new Error("expected JSON string"); - } - controller.enqueue(parseJSON(event.data)); - }); - }, - cancel: () => { - socket.close(); - }, - }); - }); - } -} - -class ThinClient { - constructor(public readonly base: URL | undefined) {} - - async fetch( - path: string, - headers: HeadersInit, - init?: RequestInit, - ): Promise { - // NOTE: We need to merge the headers in such a complicated fashion - // to avoid user-provided `init` with headers unintentionally suppressing - // the credentials. - const response = await fetch(this.base ? new URL(path, this.base) : path, { - credentials: isDev ? "include" : "same-origin", - ...init, - headers: init - ? { - ...headers, - ...init?.headers, - } - : headers, - }); - - return response; - } -} - -export interface ClientOptions { - tokens?: Tokens; - onAuthChange?: (client: Client, user?: User) => void; -} - -export interface Client { - get base(): URL | undefined; - - /// Low-level access to tokens (auth, refresh, csrf) useful for persisting them. - tokens(): Tokens | undefined; - - /// Provides current user. - user(): User | undefined; - - /// Provides current user. - headers(): HeadersInit; - - /// Construct accessor for Record API with given name. - records>(name: string): RecordApi; - - avatarUrl(userId?: string): string | undefined; - - login( - email: string, - password: string, - ): Promise; - loginSecond(opts: { - mfaToken: MultiFactorAuthToken; - totpCode: string; - }): Promise; - requestOtp(email: string, opts?: { redirectUri?: string }): Promise; - loginOtp(email: string, code: string): Promise; - logout(): Promise; - - registerTOTP(opts?: { png: boolean }): Promise; - confirmTOTP(totpUrl: string, totp: string): Promise; - unregisterTOTP(totp: string): Promise; - - deleteUser(): Promise; - checkCookies(): Promise; - refreshAuthToken(): Promise; - - /// Fetches data from TrailBase endpoints, e.g.: - /// const response = await client.fetch("/api/auth/v1/status"); - /// - /// Unlike native fetch, will throw in case !response.ok. - fetch(path: string, init?: FetchOptions): Promise; - - /// Execute a batch query. - execute( - operations: (CreateOperation | UpdateOperation | DeleteOperation)[], - transaction?: boolean, - ): Promise; -} - -/// Client for interacting with TrailBase auth and record APIs. -class ClientImpl implements Client { - private readonly _client: ThinClient; - private readonly _authChange: - | undefined - | ((client: Client, user?: User) => void); - private _tokenState: TokenState; - - constructor(baseUrl: URL | string | undefined, opts?: ClientOptions) { - this._client = new ThinClient(baseUrl ? new URL(baseUrl) : undefined); - this._authChange = opts?.onAuthChange; - - const tokens = opts?.tokens; - // Note: this is a double assignment to _tokenState to ensure the linter - // that it's really initialized in the constructor. - this._tokenState = this.setTokenState(buildTokenState(tokens), true); - - if (tokens?.refresh_token !== undefined) { - // Validate session. This is currently async, which allows to initialize - // a Client synchronously from invalid tokens. We may want to consider - // offering a safer async initializer to avoid "racy" behavior. Especially, - // when the auth token is valid while the session has already been closed. - this.checkAuthStatus() - .then((tokens) => { - if (tokens === undefined) { - // In this case, the auth state has changed, so we should invoke the callback. - this.setTokenState(buildTokenState(undefined), false); - } else { - // In this case, the auth state has remained the same, we're merely - // updating the reminted auth token. - this.setTokenState(buildTokenState(tokens), true); - } - }) - .catch(console.error); - } - } - - public get base(): URL | undefined { - const b = this._client.base; - return b !== undefined ? new URL(b) : undefined; - } - - /// Low-level access to tokens (auth, refresh, csrf) useful for persisting them. - public tokens = (): Tokens | undefined => this._tokenState?.state?.tokens; - - /// Provides current user. - public user = (): User | undefined => buildUser(this._tokenState); - - /// Provides current user. - public headers = (): HeadersInit => this._tokenState.headers; - - /// Construct accessor for Record API with given name. - public records>(name: string): RecordApi { - return new RecordApiImpl(this, name); - } - - /// Execute a batch query. - async execute( - operations: (CreateOperation | UpdateOperation | DeleteOperation)[], - transaction: boolean = true, - ): Promise { - const response = await this.fetch(transactionApiBasePath, { - method: "POST", - body: JSON.stringify({ operations, transaction }), - headers: jsonContentTypeHeader, - }); - - return parseJSON(await response.text()).ids; - } - - public avatarUrl(userId?: string): string | undefined { - const id = userId ?? this.user()?.id; - if (id) { - return `${authApiBasePath}/avatar/${id}`; - } - return undefined; - } - - public async login( - email: string, - password: string, - ): Promise { - try { - const response = await this.fetch(`${authApiBasePath}/login`, { - method: "POST", - body: JSON.stringify({ - email, - password, - } as LoginRequest), - headers: jsonContentTypeHeader, - }); - - this.setTokenState( - buildTokenState((await response.json()) as LoginResponse), - ); - } catch (err) { - if (err instanceof FetchError && err.status === 403) { - const mfaTokenResponse = JSON.parse(err.message) as MfaTokenResponse; - return { - token: mfaTokenResponse.mfa_token, - }; - } - - throw err; - } - } - - public async loginSecond(opts: { - mfaToken: MultiFactorAuthToken; - totpCode: string; - }): Promise { - const response = await this.fetch(`${authApiBasePath}/login_mfa`, { - method: "POST", - body: JSON.stringify({ - mfa_token: opts.mfaToken.token, - totp: opts.totpCode, - } as LoginMfaRequest), - headers: jsonContentTypeHeader, - }); - - this.setTokenState( - buildTokenState((await response.json()) as LoginResponse), - ); - } - - public async requestOtp( - email: string, - opts?: { redirectUri?: string }, - ): Promise { - const redirect = opts?.redirectUri; - const params = redirect ? `?redirect_uri=${redirect}` : ""; - await this.fetch(`${authApiBasePath}/otp/request${params}`, { - method: "POST", - body: JSON.stringify({ - email, - } as RequestOtpRequest), - headers: jsonContentTypeHeader, - }); - } - - public async loginOtp(email: string, code: string): Promise { - const response = await this.fetch(`${authApiBasePath}/otp/login`, { - method: "POST", - body: JSON.stringify({ - email, - code, - } as LoginOtpRequest), - headers: jsonContentTypeHeader, - }); - - this.setTokenState( - buildTokenState((await response.json()) as LoginResponse), - ); - } - - public async logout(): Promise { - try { - const refresh_token = this._tokenState.state?.tokens.refresh_token; - if (refresh_token) { - await this.fetch(`${authApiBasePath}/logout`, { - method: "POST", - body: JSON.stringify({ - refresh_token, - } as LogoutRequest), - headers: jsonContentTypeHeader, - }); - } else { - await this.fetch(`${authApiBasePath}/logout`); - } - } catch (err) { - console.debug(err); - } - this.setTokenState(buildTokenState(undefined)); - return true; - } - - public async deleteUser(): Promise { - await this.fetch(`${authApiBasePath}/delete`); - this.setTokenState(buildTokenState(undefined)); - } - - public async changeEmail(email: string): Promise { - await this.fetch(`${authApiBasePath}/change_email`, { - method: "POST", - body: JSON.stringify({ - new_email: email, - } as ChangeEmailRequest), - headers: jsonContentTypeHeader, - }); - } - - public async registerTOTP(opts?: { - png: boolean; - }): Promise { - const response = await this.fetch( - `${authApiBasePath}/totp/register?png=${opts?.png ?? false}`, - { - method: "GET", - headers: jsonContentTypeHeader, - }, - ); - return parseJSON(await response.text()); - } - - public async confirmTOTP(totpUrl: string, totp: string): Promise { - await this.fetch(`${authApiBasePath}/totp/confirm`, { - method: "POST", - body: JSON.stringify({ - totp_url: totpUrl, - totp, - } as ConfirmRegisterTotpRequest), - headers: jsonContentTypeHeader, - }); - await this.refreshAuthToken({ force: true }); - } - - public async unregisterTOTP(totp: string): Promise { - await this.fetch(`${authApiBasePath}/totp/unregister`, { - method: "POST", - body: JSON.stringify({ - totp, - } as DisableTotpRequest), - headers: jsonContentTypeHeader, - }); - await this.refreshAuthToken({ force: true }); - } - - // public async verifyTOTP( - // email: string, - // totp: string, - // password?: string, - // otp?: string, - // ): Promise { - // const response = await this.fetch(`${authApiBasePath}/totp/verify`, { - // method: "POST", - // body: JSON.stringify({ - // email, - // totp, - // password, - // otp, - // } as VerifyTOTPRequest), - // headers: jsonContentTypeHeader, - // }); - // - // this.setTokenState( - // buildTokenState((await response.json()) as LoginResponse), - // ); - // } - - /// This will call the status endpoint, which validates any provided tokens - /// but also hoists any tokens provided as cookies into a JSON response. - private async checkAuthStatus(): Promise { - const response = await this.fetch(`${authApiBasePath}/status`, { - throwOnError: false, - }); - if (response.ok) { - const status: LoginStatusResponse = await response.json(); - const auth_token = status.auth_token; - if (auth_token) { - return { - auth_token, - refresh_token: status.refresh_token, - csrf_token: status.csrf_token, - }; - } - } - return undefined; - } - - public async checkCookies(): Promise { - const tokens = await this.checkAuthStatus(); - if (tokens) { - const newState = buildTokenState(tokens); - this.setTokenState(newState); - return newState.state?.tokens; - } - } - - public async refreshAuthToken(opts?: { force?: boolean }): Promise { - const force = opts?.force ?? false; - const refreshToken = force - ? this._tokenState.state?.tokens.refresh_token - : shouldRefresh(this._tokenState); - if (refreshToken) { - // Note: refreshTokenImpl will auto-logout on 401. - this.setTokenState(await this.refreshTokensImpl(refreshToken)); - } - } - - private async refreshTokensImpl(refreshToken: string): Promise { - const path = `${authApiBasePath}/refresh`; - const response = await this._client.fetch(path, this._tokenState.headers, { - method: "POST", - body: JSON.stringify({ - refresh_token: refreshToken, - } as RefreshRequest), - headers: jsonContentTypeHeader, - }); - - if (!response.ok) { - if (response.status === 401) { - this.logout(); - } - throw await FetchError.from( - response, - _isDev() ? new URL(path, this.base) : undefined, - ); - } - - return buildTokenState({ - ...((await response.json()) as RefreshResponse), - refresh_token: refreshToken, - }); - } - - private setTokenState( - state: TokenState, - skipCb: boolean = false, - ): TokenState { - this._tokenState = state; - if (!skipCb) { - this._authChange?.(this, buildUser(state)); - } - - if (isExpired(state)) { - // This can happen on initial construction, i.e. if a client is - // constructed from older, persisted tokens. - console.debug(`Set token state (expired)`); - } - - return this._tokenState; - } - - /// Fetches data from TrailBase endpoints, e.g.: - /// const response = await client.fetch("/api/auth/v1/status"); - /// - /// Unlike native fetch, will throw in case !response.ok. - public async fetch(path: string, init?: FetchOptions): Promise { - let tokenState = this._tokenState; - const refreshToken = shouldRefresh(tokenState); - if (refreshToken) { - tokenState = this.setTokenState( - await this.refreshTokensImpl(refreshToken), - ); - } - - try { - const response = await this._client.fetch(path, tokenState.headers, init); - if (!response.ok && (init?.throwOnError ?? true)) { - throw await FetchError.from( - response, - _isDev() ? new URL(path, this.base) : undefined, - ); - } - return response; - } catch (err) { - if (err instanceof TypeError) { - console.debug(`Connection refused ${err}. TrailBase down or CORS?`); - } - throw err; - } - } -} - -/// Initialize a new TrailBase client. -export function initClient(site?: URL | string, opts?: ClientOptions): Client { - return new ClientImpl(site, opts); -} - -/// Asynchronously initialize a new TrailBase client trying to convert any -/// potentially existing cookies into an authenticated client. -export async function initClientFromCookies( - site?: URL | string, - opts?: ClientOptions, -): Promise { - const client = new ClientImpl(site, opts); - - // Prefer explicit tokens. When given, do not update/refresh infinite recursion - // with `($token) => Client` factories. - if (!client.tokens()) { - try { - await client.checkCookies(); - } catch (err) { - console.debug("No valid cookies found: ", err); - } - } - - return client; -} - -const recordApiBasePath = "/api/records/v1"; -const authApiBasePath = "/api/auth/v1"; -const transactionApiBasePath = "/api/transaction/v1/execute"; - -export function filePath( - apiName: string, - recordId: RecordId, - columnName: string, -): string { - return `${recordApiBasePath}/${apiName}/${recordId}/file/${columnName}`; -} - -export function filesPath( - apiName: string, - recordId: RecordId, - columnName: string, - fileName: string, -): string { - return `${recordApiBasePath}/${apiName}/${recordId}/files/${columnName}/${fileName}`; -} - -function _isDev(): boolean { - type ImportMeta = { - env: object | undefined; - }; - const env = (import.meta as unknown as ImportMeta).env; - const key = "DEV" as keyof typeof env; - const isDev = env?.[key] ?? false; - - return isDev; -} -const isDev = _isDev(); - -function headers(tokens?: Tokens): HeadersInit { - if (tokens) { - const { auth_token, refresh_token, csrf_token } = tokens; - return { - ...(auth_token && { - Authorization: `Bearer ${auth_token}`, - }), - ...(refresh_token && { - "Refresh-Token": refresh_token, - }), - ...(csrf_token && { - "CSRF-Token": csrf_token, - }), - }; - } - - return {}; -} - -const jsonContentTypeHeader = { - "Content-Type": "application/json", -}; +export * from "./client"; +export * from "./record_api"; /// Decode a base64 string to bytes. function base64Decode(base64: string): Uint8Array { @@ -1182,63 +23,9 @@ export function urlSafeBase64Encode(bytes: Uint8Array): string { return base64Encode(bytes).replace(/\//g, "_").replace(/\+/g, "-"); } -function addFiltersToParams( - params: URLSearchParams, - path: string, - filter: FilterOrComposite, -) { - if ("and" in filter) { - for (const [i, f] of (filter as And).and.entries()) { - addFiltersToParams(params, `${path}[$and][${i}]`, f); - } - } else if ("or" in filter) { - for (const [i, f] of (filter as Or).or.entries()) { - addFiltersToParams(params, `${path}[$or][${i}]`, f); - } - } else { - const f = filter as Filter; - const op = f.op; - if (op) { - params.append(`${path}[${f.column}][${formatCompareOp(op)}]`, f.value); - } else { - params.append(`${path}[${f.column}]`, f.value); - } - } -} - -// BigInt JSON stringify/parse shenanigans. -declare global { - interface BigInt { - toJSON(): unknown; - } -} - -BigInt.prototype.toJSON = function () { - return JSON.rawJSON(this.toString()); -}; - -function parseJSON(text: string) { - function reviver(_key: string, value: unknown, context: { source: string }) { - if ( - typeof value === "number" && - Number.isInteger(value) && - !Number.isSafeInteger(value) - ) { - // Ignore the value because it has already lost precision - return BigInt(context.source); - } - return value; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return JSON.parse(text, reviver as any); -} - export const exportedForTesting = isDev ? { base64Decode, base64Encode, - parseJSON, - subscribeWs: (api: RecordApiImpl, id: RecordId) => api.subscribeWs(id), } : undefined; diff --git a/crates/assets/js/client/src/json.ts b/crates/assets/js/client/src/json.ts new file mode 100644 index 00000000..5b836cdb --- /dev/null +++ b/crates/assets/js/client/src/json.ts @@ -0,0 +1,29 @@ +import * as JSON from "@ungap/raw-json"; + +// BigInt JSON stringify/parse shenanigans. +declare global { + interface BigInt { + toJSON(): unknown; + } +} + +BigInt.prototype.toJSON = function () { + return JSON.rawJSON(this.toString()); +}; + +export function parseJSON(text: string) { + function reviver(_key: string, value: unknown, context: { source: string }) { + if ( + typeof value === "number" && + Number.isInteger(value) && + !Number.isSafeInteger(value) + ) { + // Ignore the value because it has already lost precision + return BigInt(context.source); + } + return value; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return JSON.parse(text, reviver as any); +} diff --git a/crates/assets/js/client/src/record_api.ts b/crates/assets/js/client/src/record_api.ts new file mode 100644 index 00000000..4bff1ca1 --- /dev/null +++ b/crates/assets/js/client/src/record_api.ts @@ -0,0 +1,552 @@ +import * as JSON from "@ungap/raw-json"; +import { FeatureCollection } from "geojson"; + +import { isDev, jsonContentTypeHeader } from "./constants"; +import { parseJSON } from "./json"; +import { Client } from "./client"; + +import type { WsProtocol } from "@bindings/WsProtocol"; + +export interface FileUpload { + content_type?: null | string; + filename?: null | string; + mime_type?: null | string; + objectstore_path: string; +} + +export type CompareOp = + | "equal" + | "notEqual" + | "lessThan" + | "lessThanEqual" + | "greaterThan" + | "greaterThanEqual" + | "like" + | "regexp" + | "@within" + | "@intersects" + | "@contains"; + +function formatCompareOp(op: CompareOp): string { + switch (op) { + case "equal": + return "$eq"; + case "notEqual": + return "$ne"; + case "lessThan": + return "$lt"; + case "lessThanEqual": + return "$lte"; + case "greaterThan": + return "$gt"; + case "greaterThanEqual": + return "$gte"; + case "like": + return "$like"; + case "regexp": + return "$re"; + // Geospatials: + case "@within": + case "@intersects": + case "@contains": + return op; + } +} + +export type Filter = { + column: string; + op?: CompareOp; + value: string; +}; + +export type And = { + and: FilterOrComposite[]; +}; + +export type Or = { + or: FilterOrComposite[]; +}; + +export type FilterOrComposite = Filter | And | Or; + +export type RecordId = string | number; + +// TODO: Use `ts-rs` generated types. +interface CreateOp { + Create: { + api_name: string; + value: Record; + }; +} + +interface UpdateOp { + Update: { + api_name: string; + record_id: RecordId; + value: Record; + }; +} + +interface DeleteOp { + Delete: { + api_name: string; + record_id: RecordId; + }; +} + +export interface DeferredOperation { + query(): Promise; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface DeferredMutation< + ResponseType, +> extends DeferredOperation {} + +export class CreateOperation< + T = Record, +> implements DeferredMutation { + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly record: Partial, + ) {} + + async query(): Promise { + const response = await this.client.fetch( + `${recordApiBasePath}/${this.apiName}`, + { + method: "POST", + body: JSON.stringify(this.record), + headers: jsonContentTypeHeader, + }, + ); + + return parseJSON(await response.text()).ids[0]; + } + + protected toJSON(): CreateOp { + return { + Create: { + api_name: this.apiName, + value: this.record, + }, + }; + } +} + +export class UpdateOperation< + T = Record, +> implements DeferredMutation { + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly id: RecordId, + private readonly record: Partial, + ) {} + + async query(): Promise { + await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { + method: "PATCH", + body: JSON.stringify(this.record), + headers: jsonContentTypeHeader, + }); + } + + protected toJSON(): UpdateOp { + return { + Update: { + api_name: this.apiName, + record_id: this.id, + value: this.record, + }, + }; + } +} + +export class DeleteOperation implements DeferredMutation { + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly id: RecordId, + ) {} + async query(): Promise { + await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { + method: "DELETE", + }); + } + + protected toJSON(): DeleteOp { + return { + Delete: { + api_name: this.apiName, + record_id: this.id, + }, + }; + } +} + +export interface ReadOpts { + expand?: string[]; +} + +export class ReadOperation< + T = Record, +> implements DeferredOperation { + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly id: RecordId, + private readonly opt?: ReadOpts, + ) {} + + async query(): Promise { + const expand = this.opt?.expand; + const response = await this.client.fetch( + expand + ? `${recordApiBasePath}/${this.apiName}/${this.id}?expand=${expand.join(",")}` + : `${recordApiBasePath}/${this.apiName}/${this.id}`, + ); + return parseJSON(await response.text()) as T; + } +} + +export type Pagination = { + cursor?: string; + limit?: number; + offset?: number; +}; + +export type ListResponse = { + cursor?: string; + records: T[]; + total_count?: number; +}; + +export interface ListOpts { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; +} + +export class ListOperation< + T = Record, + R = ListResponse, +> implements DeferredOperation { + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly opts?: ListOpts, + private readonly geojson?: string, + ) {} + async query(): Promise { + const params = new URLSearchParams(); + const pagination = this.opts?.pagination; + if (pagination) { + const cursor = pagination.cursor; + if (cursor) params.append("cursor", cursor); + + const limit = pagination.limit; + if (limit) params.append("limit", limit.toString()); + + const offset = pagination.offset; + if (offset) params.append("offset", offset.toString()); + } + const order = this.opts?.order; + if (order) params.append("order", order.join(",")); + + if (this.opts?.count) params.append("count", "true"); + + const expand = this.opts?.expand; + if (expand) params.append("expand", expand.join(",")); + + const filters = this.opts?.filters; + if (filters) { + for (const filter of filters) { + addFiltersToParams(params, "filter", filter); + } + } + + if (this.geojson) params.append("geojson", this.geojson); + + const response = await this.client.fetch( + `${recordApiBasePath}/${this.apiName}?${params}`, + ); + return parseJSON(await response.text()) as R; + } +} + +export interface SubscribeOpts { + filters?: FilterOrComposite[]; +} + +export interface RecordApi> { + list(opts?: ListOpts): Promise>; + listOp(opts?: ListOpts): ListOperation; + // For queries on TABLE/VIEWs with geometry columns wantin to return GeoJSON. + listGeoOp( + geometryColumn: string, + opts?: ListOpts, + ): ListOperation; + + read(id: RecordId, opt?: ReadOpts): Promise; + readOp(id: RecordId, opt?: ReadOpts): ReadOperation; + + create(record: T): Promise; + createOp(record: T): CreateOperation; + // TODO: Retire in favor of `client.execute`. + createBulk(records: T[]): Promise; + + update(id: RecordId, record: Partial): Promise; + updateOp(id: RecordId, record: Partial): UpdateOperation; + + delete(id: RecordId): Promise; + deleteOp(id: RecordId): DeleteOperation; + + subscribe(id: RecordId): Promise>; + subscribeAll(opts?: SubscribeOpts): Promise>; +} + +/// Provides CRUD access to records through TrailBase's record API. +export class RecordApiImpl< + T = Record, +> implements RecordApi { + constructor( + private readonly client: Client, + private readonly name: string, + ) {} + + public async list(opts?: ListOpts): Promise> { + return new ListOperation(this.client, this.name, opts).query(); + } + + public listOp(opts?: ListOpts): ListOperation { + return new ListOperation(this.client, this.name, opts); + } + + public listGeoOp( + geometryColumn: string, + opts?: ListOpts, + ): ListOperation { + return new ListOperation( + this.client, + this.name, + opts, + geometryColumn, + ); + } + + public async read>( + id: RecordId, + opt?: ReadOpts, + ): Promise { + return new ReadOperation(this.client, this.name, id, opt).query(); + } + + public readOp(id: RecordId, opt?: ReadOpts): ReadOperation { + return new ReadOperation(this.client, this.name, id, opt); + } + + public async create(record: T): Promise { + return new CreateOperation(this.client, this.name, record).query(); + } + + public createOp(record: T): CreateOperation { + return new CreateOperation(this.client, this.name, record); + } + public async createBulk>( + records: T[], + ): Promise { + const response = await this.client.fetch( + `${recordApiBasePath}/${this.name}`, + { + method: "POST", + body: JSON.stringify(records), + headers: jsonContentTypeHeader, + }, + ); + + return parseJSON(await response.text()).ids; + } + + public async update(id: RecordId, record: Partial): Promise { + return new UpdateOperation(this.client, this.name, id, record).query(); + } + + public updateOp(id: RecordId, record: Partial): UpdateOperation { + return new UpdateOperation(this.client, this.name, id, record); + } + + public async delete(id: RecordId): Promise { + return new DeleteOperation(this.client, this.name, id).query(); + } + + public deleteOp(id: RecordId): DeleteOperation { + return new DeleteOperation(this.client, this.name, id); + } + + public async subscribe(id: RecordId): Promise> { + return await this.subscribeImpl(id); + } + + public async subscribeAll( + opts?: SubscribeOpts, + ): Promise> { + return await this.subscribeImpl("*", opts); + } + + private async subscribeImpl( + id: RecordId, + opts?: SubscribeOpts, + ): Promise> { + const params = new URLSearchParams(); + const filters = opts?.filters ?? []; + if (filters.length > 0) { + for (const filter of filters) { + addFiltersToParams(params, "filter", filter); + } + } + + const response = await this.client.fetch( + filters.length > 0 + ? `${recordApiBasePath}/${this.name}/subscribe/${id}?${params}` + : `${recordApiBasePath}/${this.name}/subscribe/${id}`, + ); + const body = response.body; + if (!body) { + throw Error("Subscription reader is null."); + } + + const decoder = new TextDecoder(); + const transformStream = new TransformStream({ + transform(chunk: Uint8Array, controller) { + const messages = decoder.decode(chunk).trimEnd().split("\n\n"); + for (const msg of messages) { + if (msg.startsWith("data: ")) { + controller.enqueue(parseJSON(msg.substring(6))); + } + } + }, + flush(controller) { + controller.terminate(); + }, + }); + + return body.pipeThrough(transformStream); + } + + async subscribeWs( + id: RecordId, + opts?: SubscribeOpts, + ): Promise> { + const params = new URLSearchParams(); + params.append("ws", "true"); + + const filters = opts?.filters ?? []; + if (filters.length > 0) { + for (const filter of filters) { + addFiltersToParams(params, "filter", filter); + } + } + + return new Promise>((resolve, reject) => { + const host = this.client.base?.host ?? ""; + const protocol = this.client.base?.protocol === "https" ? "wss" : "ws"; + const url = `${protocol}://${host}${recordApiBasePath}/${this.name}/subscribe/${id}?${params}`; + + const socket = new WebSocket(url); + + const timeout = setTimeout(() => { + reject("WS connection timeout"); + }, 5000); + + const readable = new ReadableStream({ + start: (controller) => { + socket.addEventListener("open", (_openEvent) => { + // Initialize connection and authenticate. + socket.send( + JSON.stringify({ + Init: { + auth_token: this.client.tokens()?.auth_token ?? null, + }, + } as WsProtocol), + ); + + clearTimeout(timeout); + resolve(readable); + }); + + socket.addEventListener("close", () => { + controller.close(); + }); + + socket.addEventListener("error", (err) => { + controller.error(err); + }); + + // Listen for messages + socket.addEventListener("message", (event) => { + if (typeof event.data !== "string") { + new Error("expected JSON string"); + } + controller.enqueue(parseJSON(event.data)); + }); + }, + cancel: () => { + socket.close(); + }, + }); + }); + } +} + +export function filePath( + apiName: string, + recordId: RecordId, + columnName: string, +): string { + return `${recordApiBasePath}/${apiName}/${recordId}/file/${columnName}`; +} + +export function filesPath( + apiName: string, + recordId: RecordId, + columnName: string, + fileName: string, +): string { + return `${recordApiBasePath}/${apiName}/${recordId}/files/${columnName}/${fileName}`; +} + +function addFiltersToParams( + params: URLSearchParams, + path: string, + filter: FilterOrComposite, +) { + if ("and" in filter) { + for (const [i, f] of (filter as And).and.entries()) { + addFiltersToParams(params, `${path}[$and][${i}]`, f); + } + } else if ("or" in filter) { + for (const [i, f] of (filter as Or).or.entries()) { + addFiltersToParams(params, `${path}[$or][${i}]`, f); + } + } else { + const f = filter as Filter; + const op = f.op; + if (op) { + params.append(`${path}[${f.column}][${formatCompareOp(op)}]`, f.value); + } else { + params.append(`${path}[${f.column}]`, f.value); + } + } +} + +const recordApiBasePath = "/api/records/v1"; + +export const exportedForTesting = isDev + ? { + subscribeWs: (api: RecordApiImpl, id: RecordId) => api.subscribeWs(id), + } + : undefined; diff --git a/crates/assets/js/client/src/transport.ts b/crates/assets/js/client/src/transport.ts new file mode 100644 index 00000000..96bf7e44 --- /dev/null +++ b/crates/assets/js/client/src/transport.ts @@ -0,0 +1,35 @@ +import { isDev } from "./constants"; + +export interface Transport { + fetch: ( + path: string, + headers: HeadersInit, + init?: RequestInit, + ) => Promise; +} + +export class ThinClient implements Transport { + constructor(private readonly base: URL | undefined) {} + + async fetch( + path: string, + headers: HeadersInit, + init?: RequestInit, + ): Promise { + // NOTE: We need to merge the headers in such a complicated fashion + // to avoid user-provided `init` with headers unintentionally suppressing + // the credentials. + const response = await fetch(this.base ? new URL(path, this.base) : path, { + credentials: isDev ? "include" : "same-origin", + ...init, + headers: init + ? { + ...headers, + ...init?.headers, + } + : headers, + }); + + return response; + } +} diff --git a/crates/assets/js/client/tests/integration/client_integration.test.ts b/crates/assets/js/client/tests/integration/client_integration.test.ts index 31564455..3d459a6b 100644 --- a/crates/assets/js/client/tests/integration/client_integration.test.ts +++ b/crates/assets/js/client/tests/integration/client_integration.test.ts @@ -6,7 +6,7 @@ import { FeatureCollection } from "geojson"; import { generate } from "otplib"; import { - exportedForTesting, + exportedForTesting as indexExportForTesting, FetchError, filePath, filesPath, @@ -14,9 +14,12 @@ import { urlSafeBase64Encode, } from "../../src/index"; import type { Client, Event, RecordApiImpl } from "../../src/index"; +import { exportedForTesting as recordExportForTesting } from "../../src/record_api"; + import { ADDRESS, USE_WS } from "../constants"; -const { base64Encode, subscribeWs } = exportedForTesting!; +const { base64Encode } = indexExportForTesting!; +const { subscribeWs } = recordExportForTesting!; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -214,7 +217,7 @@ test("Record integration tests", async () => { await api.delete(ids[1]); - await expect(async () => await api.read(ids[1])).rejects.toThrowError( + await expect(async () => await api.read(ids[1])).rejects.toThrow( expect.objectContaining({ status: status.NOT_FOUND, }), @@ -223,7 +226,7 @@ test("Record integration tests", async () => { expect(await client.logout()).toBe(true); expect(client.user()).toBe(undefined); - await expect(async () => await api.read(ids[0])).rejects.toThrowError( + await expect(async () => await api.read(ids[0])).rejects.toThrow( expect.objectContaining({ status: status.FORBIDDEN, }), @@ -375,7 +378,7 @@ test("API Errors", async () => { await expect( async () => await client.fetch("/nonexistent/api/path"), - ).rejects.toThrowError( + ).rejects.toThrow( new FetchError( status.NOT_FOUND, "Not Found", @@ -387,7 +390,7 @@ test("API Errors", async () => { const nonExistentApi = client.records("non-existent"); await expect( async () => await nonExistentApi.read(nonExistentId), - ).rejects.toThrowError( + ).rejects.toThrow( new FetchError( status.METHOD_NOT_ALLOWED, "Method Not Allowed", @@ -397,7 +400,7 @@ test("API Errors", async () => { const api = client.records("simple_strict_table"); const invalidId = "InvalidId0123"; - await expect(async () => await api.read(invalidId)).rejects.toThrowError( + await expect(async () => await api.read(invalidId)).rejects.toThrow( new FetchError( status.BAD_REQUEST, // Custom error reply by RecordError's IntoResponse impl. @@ -408,7 +411,7 @@ test("API Errors", async () => { ), ); - await expect(async () => await api.read(nonExistentId)).rejects.toThrowError( + await expect(async () => await api.read(nonExistentId)).rejects.toThrow( new FetchError( status.NOT_FOUND, "Not Found", @@ -724,7 +727,6 @@ test("GeoJson access", async ({ expect }) => { { const json: FeatureCollection = await api.listGeoOp("geom").query(); - console.log("HERE", json); expect(json.features).toHaveLength(4); } diff --git a/crates/assets/js/client/tests/unit.test.ts b/crates/assets/js/client/tests/unit.test.ts index 396f56e2..e9a9e8d5 100644 --- a/crates/assets/js/client/tests/unit.test.ts +++ b/crates/assets/js/client/tests/unit.test.ts @@ -1,7 +1,7 @@ import { test } from "vitest"; -import { FetchError, initClient, exportedForTesting } from "../src/index"; -const { parseJSON } = exportedForTesting!; +import { FetchError, initClient } from "../src/index"; +import { parseJSON } from "../src/json"; test("error-handling", async ({ expect }) => { expect(new FetchError(404, "test", "url").toString()).toEqual( diff --git a/crates/assets/js/client/tsconfig.json b/crates/assets/js/client/tsconfig.json index 68582cbb..1ade3960 100644 --- a/crates/assets/js/client/tsconfig.json +++ b/crates/assets/js/client/tsconfig.json @@ -10,8 +10,7 @@ "declaration": true, "outDir": "./dist", "paths": { - "@/*": ["./src/*"], - "@bindings/*": ["../bindings/*"] + "@bindings/*": ["./bindings/*"] } }, "include": [ diff --git a/crates/assets/js/client/vite.config.ts b/crates/assets/js/client/vite.config.ts new file mode 100644 index 00000000..11b0b6ed --- /dev/null +++ b/crates/assets/js/client/vite.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; +import { resolve } from 'path' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function external(source: string, _importer: string | undefined, _isResolved: boolean): boolean { + console.log(source) + return source.startsWith("../bindings"); +} + +export default defineConfig({ + build: { + outDir: "./dist", + minify: false, + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: "trailbase", + fileName: "index", + formats: ["es"], + }, + // rollupOptions: { + // external: external, + // plugins: [ + // dts({ + // copyDtsFiles: true, + // staticImport: true, + // insertTypesEntry: true, + // rollupTypes: true, + // }), + // ], + // }, + }, + plugins: [ + dts({ + strictOutput: true, + // copyDtsFiles: true, + // staticImport: true, + // insertTypesEntry: true, + rollupTypes: true, + }), + ], +}) diff --git a/crates/assets/js/client/vitest.config.ts b/crates/assets/js/client/vitest.config.ts index 06f1cb32..a040797b 100644 --- a/crates/assets/js/client/vitest.config.ts +++ b/crates/assets/js/client/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, test: { globals: true, environment: "jsdom", diff --git a/crates/auth-ui/ui/src/components/Totp.tsx b/crates/auth-ui/ui/src/components/Totp.tsx index 2e878087..1889210d 100644 --- a/crates/auth-ui/ui/src/components/Totp.tsx +++ b/crates/auth-ui/ui/src/components/Totp.tsx @@ -30,7 +30,7 @@ export function TotpToggleButton(props: { client: Client; user: User }) { try { const res = await props.client.registerTOTP({ png: true }); setTotp({ - url: res.totp_url, + url: res.url, png: res.png ?? "", }); } catch (err) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2ec280a..fcae0293 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -287,9 +287,15 @@ importers: typescript-eslint: specifier: ^8.57.1 version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^8.0.1 + version: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2) vite-node: specifier: ^6.0.0 version: 6.0.0(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@25.5.0)(rollup@4.59.0)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2)) vitest: specifier: ^4.1.0 version: 4.1.0(@types/node@25.5.0)(happy-dom@15.11.7)(jsdom@29.0.0(@noble/hashes@2.0.1))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(yaml@2.8.2))