@taruvi/sdk 1.4.0-beta.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taruvi/sdk",
3
- "version": "1.4.0-beta.1",
3
+ "version": "1.4.2",
4
4
  "description": "Taruvi SDK",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -33,11 +33,9 @@ export class Client {
33
33
  }
34
34
 
35
35
  /**
36
- * Extracts authentication tokens from URL hash and stores them using TokenClient.
37
- * This handles Web UI Flow callback URLs like:
38
- * #session_token=xxx&access_token=yyy&refresh_token=zzz&expires_in=172800&token_type=Bearer
39
- *
40
- * After successful extraction, the URL hash is cleared to prevent token exposure.
36
+ * Extracts session token from URL hash and stores it using TokenClient.
37
+ * Handles callback URLs like: #session_token=xxx
38
+ * After extraction, the URL hash is cleared.
41
39
  */
42
40
  private extractTokensFromUrl(): void {
43
41
  if (typeof window === "undefined" || typeof localStorage === "undefined") {
@@ -51,32 +49,12 @@ export class Client {
51
49
 
52
50
  const params = new URLSearchParams(hash.substring(1))
53
51
  const sessionToken = params.get("session_token")
54
- const accessToken = params.get("access_token")
55
- const refreshToken = params.get("refresh_token")
56
- const expiresIn = params.get("expires_in")
57
- const tokenType = params.get("token_type")
58
52
 
59
- // Only proceed if we have the required tokens
60
- if (!accessToken || !refreshToken) {
53
+ if (!sessionToken) {
61
54
  return
62
55
  }
63
56
 
64
- // Store tokens using TokenClient
65
- const tokens: AuthTokens = {
66
- accessToken,
67
- refreshToken,
68
- tokenType: tokenType || "Bearer"
69
- }
70
-
71
- if (sessionToken) {
72
- tokens.sessionToken = sessionToken
73
- }
74
-
75
- if (expiresIn) {
76
- tokens.expiresIn = parseInt(expiresIn, 10)
77
- }
78
-
79
- this._tokenClient.setTokens(tokens)
57
+ this._tokenClient.setTokens({ sessionToken })
80
58
 
81
59
  // Clear hash from URL without reloading page
82
60
  if (window.history && window.history.replaceState) {
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export { Client } from "./client.js"
3
3
 
4
4
  // Export error classes
5
- export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError } from "./lib-internal/errors/index.js"
5
+ export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError, RateLimitError } from "./lib-internal/errors/index.js"
6
6
  export { ErrorCode } from "./lib-internal/errors/index.js"
7
7
  export type { ErrorResponseBody } from "./lib-internal/errors/index.js"
8
8
 
@@ -5,13 +5,8 @@ import { UserRoutes } from "../../lib-internal/routes/UserRoutes.js";
5
5
 
6
6
  /**
7
7
  * Auth Client - Handles user authentication using Web UI Flow
8
- * Implements cross-domain authentication with redirect-based token delivery
9
- *
10
- * Flow:
11
- * 1. User calls login() → Redirects to backend /accounts/login/
12
- * 2. User authenticates on backend
13
- * 3. Backend redirects back with tokens in URL hash
14
- * 4. Client extracts and stores tokens automatically
8
+ * Uses session token for API authentication via X-Session-Token header.
9
+ * On 401/403, tokens are cleared automatically by HttpClient interceptor.
15
10
  */
16
11
  export class Auth {
17
12
  private client: Client
@@ -22,7 +17,6 @@ export class Auth {
22
17
 
23
18
  /**
24
19
  * Redirect to login page (Web UI Flow)
25
- * @param callbackUrl - URL to redirect to after successful login (defaults to current page)
26
20
  */
27
21
  login(callbackUrl?: string): void {
28
22
  if (typeof window === "undefined") {
@@ -33,11 +27,8 @@ export class Auth {
33
27
  const config = this.client.getConfig()
34
28
  const callback = callbackUrl || window.location.origin + window.location.pathname
35
29
  const deskUrl = config.deskUrl || config.apiUrl
36
-
37
- // Redirect to /accounts/login/ with redirect_to parameter
38
30
  const loginUrl = `${deskUrl}/accounts/login/?redirect_to=${encodeURIComponent(callback)}`
39
31
 
40
- // Optional: Store state before redirecting
41
32
  if (typeof sessionStorage !== "undefined") {
42
33
  sessionStorage.setItem("auth_state", JSON.stringify({
43
34
  returnTo: window.location.pathname,
@@ -50,7 +41,6 @@ export class Auth {
50
41
 
51
42
  /**
52
43
  * Redirect to signup page (Web UI Flow)
53
- * @param callbackUrl - URL to redirect to after successful signup (defaults to current page)
54
44
  */
55
45
  signup(callbackUrl?: string): void {
56
46
  if (typeof window === "undefined") {
@@ -60,11 +50,8 @@ export class Auth {
60
50
 
61
51
  const config = this.client.getConfig()
62
52
  const callback = callbackUrl || window.location.origin + window.location.pathname
63
-
64
- // Redirect to /accounts/signup/ with redirect_to parameter
65
53
  const signupUrl = `${config.apiUrl}/accounts/signup/?redirect_to=${encodeURIComponent(callback)}`
66
54
 
67
- // Optional: Store state before redirecting
68
55
  if (typeof sessionStorage !== "undefined") {
69
56
  sessionStorage.setItem("auth_state", JSON.stringify({
70
57
  returnTo: window.location.pathname,
@@ -77,8 +64,6 @@ export class Auth {
77
64
 
78
65
  /**
79
66
  * Logout user and redirect to logout page
80
- * Fetches frontendUrl from site settings for redirect
81
- * @param callbackUrl - URL to redirect to after logout (overrides frontendUrl from settings)
82
67
  */
83
68
  async logout(callbackUrl?: string): Promise<void> {
84
69
  if (typeof window === "undefined") {
@@ -86,14 +71,12 @@ export class Auth {
86
71
  return
87
72
  }
88
73
 
89
- // Clear tokens immediately
90
74
  this.client.tokenClient.clearTokens()
91
75
 
92
76
  const config = this.client.getConfig()
93
77
  const deskUrl = config.deskUrl || config.apiUrl
94
78
  let callback: string = callbackUrl || ""
95
79
 
96
- // If no callback provided, fetch frontendUrl from site settings
97
80
  if (!callback) {
98
81
  try {
99
82
  const settings = await this.client.httpClient.get<{ frontend_url?: string }>(
@@ -106,93 +89,22 @@ export class Auth {
106
89
  }
107
90
  }
108
91
 
109
- // Redirect to /accounts/logout/
110
92
  const logoutUrl = `${deskUrl}/accounts/logout/?redirect_to=${encodeURIComponent(callback)}`
111
-
112
93
  window.location.href = logoutUrl
113
94
  }
114
95
 
115
96
  /**
116
- * Check if user is authenticated
97
+ * Check if user is authenticated (has session token)
117
98
  */
118
99
  isUserAuthenticated(): boolean {
119
100
  return this.client.tokenClient.isAuthenticated()
120
101
  }
121
102
 
122
103
  /**
123
- * Get the current access token
104
+ * Get the current session token
124
105
  */
125
- getAccessToken(): string | null {
126
- return this.client.tokenClient.getToken()
127
- }
128
-
129
- /**
130
- * Get the current refresh token
131
- */
132
- getRefreshToken(): string | null {
133
- return this.client.tokenClient.getRefreshToken()
134
- }
135
-
136
- /**
137
- * Check if the access token is expired
138
- */
139
- isTokenExpired(): boolean {
140
- return this.client.tokenClient.isTokenExpired()
141
- }
142
-
143
- /**
144
- * Refresh the access token using the refresh token
145
- * ⚠️ IMPORTANT: Taruvi uses refresh token rotation
146
- * You will receive BOTH a new access token AND a new refresh token
147
- *
148
- * @returns Promise with new tokens or null if refresh failed
149
- */
150
- async refreshAccessToken(): Promise<{ access: string; refresh: string; expires_in: number } | null> {
151
- const refreshToken = this.client.tokenClient.getRefreshToken()
152
-
153
- if (!refreshToken) {
154
- console.error("No refresh token available")
155
- return null
156
- }
157
-
158
- try {
159
- const config = this.client.getConfig()
160
- const response = await fetch(`${config.apiUrl}/api/cloud/auth/jwt/token/refresh/`, {
161
- method: "POST",
162
- headers: { "Content-Type": "application/json" },
163
- body: JSON.stringify({ refresh: refreshToken })
164
- })
165
-
166
- if (!response.ok) {
167
- throw new Error(`Token refresh failed: ${response.statusText}`)
168
- }
169
-
170
- const data = await response.json()
171
-
172
- // Update tokens in storage (both access and refresh due to rotation)
173
- this.client.tokenClient.updateAccessToken(data.access, data.expires_in || 172800)
174
-
175
- if (data.refresh) {
176
- this.client.tokenClient.updateRefreshToken(data.refresh)
177
- }
178
-
179
- return {
180
- access: data.access,
181
- refresh: data.refresh || refreshToken,
182
- expires_in: data.expires_in || 172800
183
- }
184
- } catch (error) {
185
- console.error("Failed to refresh access token:", error)
186
-
187
- // Clear tokens and redirect to login
188
- this.client.tokenClient.clearTokens()
189
-
190
- if (typeof window !== "undefined") {
191
- this.login()
192
- }
193
-
194
- return null
195
- }
106
+ getSessionToken(): string | null {
107
+ return this.client.tokenClient.getSessionToken()
196
108
  }
197
109
 
198
110
  /**
@@ -211,4 +123,4 @@ export class Auth {
211
123
  return null
212
124
  }
213
125
  }
214
- }
126
+ }
@@ -22,8 +22,9 @@ export class Database<T = Record<string, unknown>> {
22
22
  private queryParams: DatabaseFilters | undefined
23
23
  private graphParams: GraphQueryParams
24
24
  private isEdges: boolean
25
+ private isUpsert: boolean
25
26
 
26
- constructor(client: Client, urlParams: UrlParams = {}, operation?: HttpMethod | undefined, body?: object | undefined, queryParams?: DatabaseFilters, graphParams: GraphQueryParams = {}, isEdges: boolean = false) {
27
+ constructor(client: Client, urlParams: UrlParams = {}, operation?: HttpMethod | undefined, body?: object | undefined, queryParams?: DatabaseFilters, graphParams: GraphQueryParams = {}, isEdges: boolean = false, isUpsert: boolean = false) {
27
28
  this.client = client
28
29
  this.urlParams = urlParams
29
30
  this.operation = operation
@@ -32,6 +33,7 @@ export class Database<T = Record<string, unknown>> {
32
33
  this.queryParams = queryParams
33
34
  this.graphParams = graphParams
34
35
  this.isEdges = isEdges
36
+ this.isUpsert = isUpsert
35
37
  }
36
38
 
37
39
  from<U = Record<string, unknown>>(dataTables: string): Database<U> {
@@ -135,10 +137,18 @@ export class Database<T = Record<string, unknown>> {
135
137
  return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.POST, body as object, this.queryParams, { ...this.graphParams }, this.isEdges)
136
138
  }
137
139
 
140
+ upsert(body: Partial<T> | Partial<T>[]): Database<T> {
141
+ return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.POST, body as object, this.queryParams, { ...this.graphParams }, this.isEdges, true)
142
+ }
143
+
138
144
  update(body: Partial<T> | EdgeRequest): Database<T> {
139
145
  return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.PATCH, body as object, this.queryParams, { ...this.graphParams }, this.isEdges)
140
146
  }
141
147
 
148
+ bulkUpdate(body: Partial<T>[]): Database<T> {
149
+ return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.PATCH, body as object, this.queryParams, { ...this.graphParams }, this.isEdges)
150
+ }
151
+
142
152
  delete(recordIdOrEdgeIds: string | number[]): Database<T> {
143
153
  if (Array.isArray(recordIdOrEdgeIds)) {
144
154
  const body: EdgeDeleteRequest = { edge_ids: recordIdOrEdgeIds }
@@ -147,6 +157,17 @@ export class Database<T = Record<string, unknown>> {
147
157
  return new Database<T>(this.client, { ...this.urlParams, recordId: recordIdOrEdgeIds }, HttpMethod.DELETE, undefined, this.queryParams, { ...this.graphParams }, this.isEdges)
148
158
  }
149
159
 
160
+ bulkDelete(ids: string[]): Database<T> {
161
+ return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.DELETE, undefined, {
162
+ ...this.queryParams,
163
+ ids: ids.join(',')
164
+ }, { ...this.graphParams }, this.isEdges)
165
+ }
166
+
167
+ deleteFiltered(): Database<T> {
168
+ return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.DELETE, undefined, this.queryParams, { ...this.graphParams }, this.isEdges)
169
+ }
170
+
150
171
  async first(): Promise<T | null> {
151
172
  const response = await this.execute()
152
173
  const data = response.data
@@ -175,6 +196,7 @@ export class Database<T = Record<string, unknown>> {
175
196
  const base = DatabaseRoutes.baseUrl(this.config.appSlug) +
176
197
  DatabaseRoutes.dataTables(tableName) +
177
198
  (this.urlParams.recordId ? DatabaseRoutes.recordId(this.urlParams.recordId) : '') +
199
+ (this.isUpsert ? DatabaseRoutes.upsert() : '') +
178
200
  '/'
179
201
 
180
202
  // Merge database filters and graph params into one query string
@@ -195,7 +217,7 @@ export class Database<T = Record<string, unknown>> {
195
217
  return await this.client.httpClient.post(url, this.body)
196
218
 
197
219
  case HttpMethod.PATCH:
198
- if (!this.urlParams.recordId) {
220
+ if (!this.urlParams.recordId && !Array.isArray(this.body)) {
199
221
  throw new Error('PATCH operation requires a record ID.')
200
222
  }
201
223
  return await this.client.httpClient.patch(url, this.body)
@@ -63,6 +63,16 @@ export class TimeoutError extends TaruviError {
63
63
  }
64
64
  }
65
65
 
66
+ export class RateLimitError extends TaruviError {
67
+ public readonly retryAfter: number | undefined
68
+
69
+ constructor(message = 'Rate limit exceeded', retryAfter?: number) {
70
+ super(message, 429, ErrorCode.RATE_LIMITED)
71
+ this.name = 'RateLimitError'
72
+ this.retryAfter = retryAfter
73
+ }
74
+ }
75
+
66
76
  export class NetworkError extends TaruviError {
67
77
  constructor(message = 'Network error') {
68
78
  super(message, 0, ErrorCode.NETWORK_ERROR)
@@ -94,6 +104,8 @@ export function createErrorFromResponse(statusCode: number, body?: ErrorResponse
94
104
  return new NotFoundError(message)
95
105
  case 409:
96
106
  return new ConflictError(message, detail)
107
+ case 429:
108
+ return new RateLimitError(message)
97
109
  case 504:
98
110
  return new TimeoutError(message)
99
111
  default:
@@ -1,3 +1,3 @@
1
- export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError, createErrorFromResponse } from './ErrorClient.js'
1
+ export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError, RateLimitError, createErrorFromResponse } from './ErrorClient.js'
2
2
  export { ErrorCode } from './types.js'
3
3
  export type { ErrorResponseBody } from './types.js'
@@ -10,6 +10,7 @@ export enum ErrorCode {
10
10
  NOT_FOUND = 'NOT_FOUND',
11
11
  CONFLICT = 'CONFLICT',
12
12
  INTERNAL_ERROR = 'INTERNAL_ERROR',
13
+ RATE_LIMITED = 'RATE_LIMITED',
13
14
  GATEWAY_TIMEOUT = 'GATEWAY_TIMEOUT',
14
15
  NETWORK_ERROR = 'NETWORK_ERROR'
15
16
  }
@@ -1,40 +1,53 @@
1
1
  import type { TaruviConfig } from "../../types.js";
2
2
  import type { TokenClient } from "../token/TokenClient.js";
3
- import axios, { AxiosError } from "axios";
3
+ import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
4
4
  import { createErrorFromResponse, NetworkError, TaruviError } from "../errors/index.js";
5
5
  import type { ErrorResponseBody } from "../errors/index.js";
6
6
 
7
7
  /**
8
8
  * HttpClient handles all HTTP requests to the Taruvi API.
9
- * Automatically adds authentication headers and converts
10
- * error responses to typed SDK errors.
9
+ * Sends session token via X-Session-Token header.
10
+ * Clears tokens on 401/403 auth failures.
11
11
  *
12
12
  * @internal
13
13
  */
14
14
  export class HttpClient {
15
15
  private config: TaruviConfig
16
16
  private tokenClient: TokenClient
17
+ private axiosInstance: AxiosInstance
17
18
 
18
19
  constructor(config: TaruviConfig, tokenClient: TokenClient) {
19
20
  this.config = config
20
21
  this.tokenClient = tokenClient
22
+ this.axiosInstance = axios.create({ baseURL: config.apiUrl })
23
+ this.setupInterceptors()
21
24
  }
22
25
 
23
- private getAuthHeaders(isFormData: boolean = false): Record<string, string> {
24
- const headers: Record<string, string> = {}
25
-
26
- // Don't set Content-Type for FormData - let axios set it with the boundary
27
- if (!isFormData) {
28
- headers['Content-Type'] = 'application/json'
29
- }
30
-
31
- // Tenant admin session token
32
- const jwt = this.tokenClient.getToken()
33
- if (jwt) {
34
- headers['Authorization'] = `Bearer ${jwt}`
35
- }
26
+ private setupInterceptors(): void {
27
+ // Request interceptor: attach session token
28
+ this.axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
29
+ const isFormData = config.data instanceof FormData
30
+ if (!isFormData) {
31
+ config.headers['Content-Type'] = 'application/json'
32
+ }
33
+ const sessionToken = this.tokenClient.getSessionToken()
34
+ if (sessionToken) {
35
+ config.headers['X-Session-Token'] = sessionToken
36
+ }
37
+ return config
38
+ })
36
39
 
37
- return headers
40
+ // Response interceptor: clear tokens on auth failure
41
+ this.axiosInstance.interceptors.response.use(
42
+ (response) => response,
43
+ (error: AxiosError) => {
44
+ const status = error.response?.status
45
+ if (status === 401 || status === 403) {
46
+ this.tokenClient.clearTokens()
47
+ }
48
+ return Promise.reject(error)
49
+ }
50
+ )
38
51
  }
39
52
 
40
53
  private handleError(error: unknown): never {
@@ -47,19 +60,15 @@ export class HttpClient {
47
60
  const body = error.response.data as ErrorResponseBody | undefined
48
61
  throw createErrorFromResponse(error.response.status, body)
49
62
  }
50
- // No response — network error
51
63
  throw new NetworkError(error.message)
52
64
  }
53
65
 
54
- // Unknown error
55
66
  throw error
56
67
  }
57
68
 
58
69
  async get<T>(endpoint: string): Promise<T> {
59
70
  try {
60
- const { data } = await axios.get<T>(`${this.config.apiUrl}/${endpoint}`, {
61
- headers: this.getAuthHeaders()
62
- })
71
+ const { data } = await this.axiosInstance.get<T>(`/${endpoint}`)
63
72
  return data
64
73
  } catch (error) {
65
74
  this.handleError(error)
@@ -68,14 +77,7 @@ export class HttpClient {
68
77
 
69
78
  async post<T, D = unknown>(endpoint: string, body: D): Promise<T> {
70
79
  try {
71
- const isFormData = body instanceof FormData
72
- const { data } = await axios.post<T>(
73
- `${this.config.apiUrl}/${endpoint}`,
74
- body,
75
- {
76
- headers: this.getAuthHeaders(isFormData)
77
- }
78
- )
80
+ const { data } = await this.axiosInstance.post<T>(`/${endpoint}`, body)
79
81
  return data
80
82
  } catch (error) {
81
83
  this.handleError(error)
@@ -84,12 +86,7 @@ export class HttpClient {
84
86
 
85
87
  async put<T, D = unknown>(endpoint: string, body: D): Promise<T> {
86
88
  try {
87
- const isFormData = body instanceof FormData
88
- const { data } = await axios.put<T>(`${this.config.apiUrl}/${endpoint}`,
89
- body,
90
- {
91
- headers: this.getAuthHeaders(isFormData)
92
- })
89
+ const { data } = await this.axiosInstance.put<T>(`/${endpoint}`, body)
93
90
  return data
94
91
  } catch (error) {
95
92
  this.handleError(error)
@@ -98,13 +95,7 @@ export class HttpClient {
98
95
 
99
96
  async delete<T, D = unknown>(endpoint: string, body?: D): Promise<T> {
100
97
  try {
101
- const { data } = await axios.delete<T>(
102
- `${this.config.apiUrl}/${endpoint}`,
103
- {
104
- headers: this.getAuthHeaders(),
105
- data: body
106
- }
107
- )
98
+ const { data } = await this.axiosInstance.delete<T>(`/${endpoint}`, { data: body })
108
99
  return data
109
100
  } catch (error) {
110
101
  this.handleError(error)
@@ -113,14 +104,7 @@ export class HttpClient {
113
104
 
114
105
  async patch<T, D = unknown>(endpoint: string, body: D): Promise<T> {
115
106
  try {
116
- const isFormData = body instanceof FormData
117
- const { data } = await axios.patch<T>(
118
- `${this.config.apiUrl}/${endpoint}`,
119
- body,
120
- {
121
- headers: this.getAuthHeaders(isFormData)
122
- }
123
- )
107
+ const { data } = await this.axiosInstance.patch<T>(`/${endpoint}`, body)
124
108
  return data
125
109
  } catch (error) {
126
110
  this.handleError(error)
@@ -1,7 +1,8 @@
1
1
  export const DatabaseRoutes = {
2
2
  baseUrl: (appSlug: string) => `api/apps/${appSlug}`,
3
3
  dataTables: (tableName: string): string => `/datatables/${tableName}/data`,
4
- recordId: (recordId: string): string => `/${recordId}`
4
+ recordId: (recordId: string): string => `/${recordId}`,
5
+ upsert: (): string => `/upsert`
5
6
  }
6
7
 
7
8
  type AllRouteKeys = keyof typeof DatabaseRoutes
@@ -1,27 +1,14 @@
1
1
  import { getRuntimeEnvironment } from "../../utils/utils.js"
2
2
 
3
3
  /**
4
- * Token management interface for Web UI Flow authentication
4
+ * TokenClient - Manages session token for browser authentication
5
5
  */
6
6
  export interface AuthTokens {
7
- sessionToken?: string | undefined;
8
- accessToken: string;
9
- refreshToken: string;
10
- expiresIn?: number | undefined;
11
- expiresAt?: number | undefined;
12
- tokenType?: string | undefined;
7
+ sessionToken: string;
13
8
  }
14
9
 
15
- /**
16
- * TokenClient - Manages authentication tokens for both browser and server environments
17
- * Implements Web UI Flow token handling as described in Taruvi documentation
18
- */
19
10
  export class TokenClient {
20
- private static readonly ACCESS_TOKEN_KEY = 'jwt';
21
- private static readonly REFRESH_TOKEN_KEY = 'refresh_token';
22
11
  private static readonly SESSION_TOKEN_KEY = 'session_token';
23
- private static readonly EXPIRES_AT_KEY = 'token_expires_at';
24
- private static readonly TOKEN_TYPE_KEY = 'token_type';
25
12
 
26
13
  private runTimeEnvironment: string
27
14
  private browserRunTime: boolean
@@ -31,179 +18,61 @@ export class TokenClient {
31
18
  this.runTimeEnvironment = getRuntimeEnvironment()
32
19
  this.browserRunTime = this.runTimeEnvironment == "Browser"
33
20
 
34
- // For server-side usage, store the token
35
21
  if (!this.browserRunTime && token) {
36
22
  this.serverToken = token
37
23
  }
38
24
  }
39
25
 
40
26
  /**
41
- * Get access token (supports both browser and server)
27
+ * Get session token
42
28
  */
43
- getToken(): string | null {
29
+ getSessionToken(): string | null {
44
30
  if (this.browserRunTime) {
45
31
  if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
46
32
  return null
47
33
  }
48
- return localStorage.getItem(TokenClient.ACCESS_TOKEN_KEY)
34
+ return localStorage.getItem(TokenClient.SESSION_TOKEN_KEY)
49
35
  }
50
36
  return this.serverToken
51
37
  }
52
38
 
53
39
  /**
54
- * Set all authentication tokens from Web UI Flow callback
40
+ * Alias for getSessionToken (used by server-side code)
55
41
  */
56
- setTokens(tokens: AuthTokens): void {
57
- if (!this.browserRunTime) {
58
- console.warn('Token storage is only available in browser environment')
59
- return
60
- }
61
-
62
- if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
63
- return
64
- }
65
-
66
- try {
67
- // Store access token
68
- localStorage.setItem(TokenClient.ACCESS_TOKEN_KEY, tokens.accessToken)
69
-
70
- // Store refresh token
71
- localStorage.setItem(TokenClient.REFRESH_TOKEN_KEY, tokens.refreshToken)
72
-
73
- // Store session token if provided
74
- if (tokens.sessionToken) {
75
- localStorage.setItem(TokenClient.SESSION_TOKEN_KEY, tokens.sessionToken)
76
- }
77
-
78
- // Calculate and store expiration time
79
- if (tokens.expiresIn) {
80
- const expiresAt = Date.now() + (tokens.expiresIn * 1000)
81
- localStorage.setItem(TokenClient.EXPIRES_AT_KEY, expiresAt.toString())
82
- } else if (tokens.expiresAt) {
83
- localStorage.setItem(TokenClient.EXPIRES_AT_KEY, tokens.expiresAt.toString())
84
- }
85
-
86
- // Store token type
87
- if (tokens.tokenType) {
88
- localStorage.setItem(TokenClient.TOKEN_TYPE_KEY, tokens.tokenType)
89
- }
90
- } catch (err) {
91
- console.error('Failed to store authentication tokens:', err)
92
- }
42
+ getToken(): string | null {
43
+ return this.getSessionToken()
93
44
  }
94
45
 
95
46
  /**
96
- * Get all stored tokens
47
+ * Store session token
97
48
  */
98
- getTokens(): AuthTokens | null {
49
+ setTokens(tokens: AuthTokens): void {
99
50
  if (!this.browserRunTime) {
100
- return null
51
+ console.warn('Token storage is only available in browser environment')
52
+ return
101
53
  }
102
54
 
103
55
  if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
104
- return null
56
+ return
105
57
  }
106
58
 
107
59
  try {
108
- const accessToken = localStorage.getItem(TokenClient.ACCESS_TOKEN_KEY)
109
- const refreshToken = localStorage.getItem(TokenClient.REFRESH_TOKEN_KEY)
110
- const sessionToken = localStorage.getItem(TokenClient.SESSION_TOKEN_KEY)
111
- const expiresAt = localStorage.getItem(TokenClient.EXPIRES_AT_KEY)
112
- const tokenType = localStorage.getItem(TokenClient.TOKEN_TYPE_KEY)
113
-
114
- if (!accessToken || !refreshToken) {
115
- return null
116
- }
117
-
118
- const tokens: AuthTokens = {
119
- accessToken,
120
- refreshToken,
121
- tokenType: tokenType || 'Bearer',
122
- }
123
-
124
- if (sessionToken) {
125
- tokens.sessionToken = sessionToken
126
- }
127
-
128
- if (expiresAt) {
129
- tokens.expiresIn = Math.floor((parseInt(expiresAt) - Date.now()) / 1000)
130
- tokens.expiresAt = parseInt(expiresAt)
131
- }
132
-
133
- return tokens
60
+ localStorage.setItem(TokenClient.SESSION_TOKEN_KEY, tokens.sessionToken)
134
61
  } catch (err) {
135
- console.error('Failed to retrieve authentication tokens:', err)
136
- return null
137
- }
138
- }
139
-
140
- /**
141
- * Get refresh token
142
- */
143
- getRefreshToken(): string | null {
144
- if (!this.browserRunTime) {
145
- return null
146
- }
147
-
148
- if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
149
- return null
150
- }
151
-
152
- return localStorage.getItem(TokenClient.REFRESH_TOKEN_KEY)
153
- }
154
-
155
- /**
156
- * Get session token
157
- */
158
- getSessionToken(): string | null {
159
- if (!this.browserRunTime) {
160
- return null
161
- }
162
-
163
- if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
164
- return null
62
+ console.error('Failed to store session token:', err)
165
63
  }
166
-
167
- return localStorage.getItem(TokenClient.SESSION_TOKEN_KEY)
168
- }
169
-
170
- /**
171
- * Check if user is authenticated (has valid access token)
172
- */
173
- isAuthenticated(): boolean {
174
- return !!this.getToken()
175
64
  }
176
65
 
177
66
  /**
178
- * Check if access token is expired
179
- */
180
- isTokenExpired(): boolean {
181
- if (!this.browserRunTime) {
182
- return true
183
- }
184
-
185
- if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
186
- return true
187
- }
188
-
189
- const expiresAt = localStorage.getItem(TokenClient.EXPIRES_AT_KEY)
190
- if (!expiresAt) {
191
- return true
192
- }
193
-
194
- return parseInt(expiresAt) < Date.now()
195
- }
196
-
197
- /**
198
- * Set access token directly (e.g., from external storage)
67
+ * Set session token directly
199
68
  */
200
69
  setAccessToken(token: string): void {
201
70
  if (this.browserRunTime) {
202
71
  if (typeof window === 'undefined' || typeof localStorage === 'undefined') return
203
72
  try {
204
- localStorage.setItem(TokenClient.ACCESS_TOKEN_KEY, token)
73
+ localStorage.setItem(TokenClient.SESSION_TOKEN_KEY, token)
205
74
  } catch (err) {
206
- console.error('Failed to set access token:', err)
75
+ console.error('Failed to set session token:', err)
207
76
  }
208
77
  } else {
209
78
  this.serverToken = token
@@ -211,53 +80,14 @@ export class TokenClient {
211
80
  }
212
81
 
213
82
  /**
214
- * Update access token after refresh
215
- * ⚠️ IMPORTANT: Taruvi uses refresh token rotation
216
- * When you refresh, you get BOTH a new access token AND a new refresh token
83
+ * Check if user is authenticated (has a session token)
217
84
  */
218
- updateAccessToken(accessToken: string, expiresIn: number): void {
219
- if (!this.browserRunTime) {
220
- console.warn('Token update is only available in browser environment')
221
- return
222
- }
223
-
224
- if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
225
- return
226
- }
227
-
228
- try {
229
- localStorage.setItem(TokenClient.ACCESS_TOKEN_KEY, accessToken)
230
- const expiresAt = Date.now() + (expiresIn * 1000)
231
- localStorage.setItem(TokenClient.EXPIRES_AT_KEY, expiresAt.toString())
232
- } catch (err) {
233
- console.error('Failed to update access token:', err)
234
- }
235
- }
236
-
237
- /**
238
- * Update refresh token after rotation
239
- * ⚠️ IMPORTANT: Taruvi rotates refresh tokens
240
- * Always update the refresh token when you receive a new one
241
- */
242
- updateRefreshToken(refreshToken: string): void {
243
- if (!this.browserRunTime) {
244
- console.warn('Token update is only available in browser environment')
245
- return
246
- }
247
-
248
- if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
249
- return
250
- }
251
-
252
- try {
253
- localStorage.setItem(TokenClient.REFRESH_TOKEN_KEY, refreshToken)
254
- } catch (err) {
255
- console.error('Failed to update refresh token:', err)
256
- }
85
+ isAuthenticated(): boolean {
86
+ return !!this.getSessionToken()
257
87
  }
258
88
 
259
89
  /**
260
- * Clear all tokens (logout)
90
+ * Clear session token
261
91
  */
262
92
  clearTokens(): void {
263
93
  if (!this.browserRunTime) {
@@ -270,13 +100,9 @@ export class TokenClient {
270
100
  }
271
101
 
272
102
  try {
273
- localStorage.removeItem(TokenClient.ACCESS_TOKEN_KEY)
274
- localStorage.removeItem(TokenClient.REFRESH_TOKEN_KEY)
275
103
  localStorage.removeItem(TokenClient.SESSION_TOKEN_KEY)
276
- localStorage.removeItem(TokenClient.EXPIRES_AT_KEY)
277
- localStorage.removeItem(TokenClient.TOKEN_TYPE_KEY)
278
104
  } catch (err) {
279
- console.error('Failed to clear tokens:', err)
105
+ console.error('Failed to clear session token:', err)
280
106
  }
281
107
  }
282
108
  }
@@ -5,11 +5,8 @@ import { Client } from '../../../src/client.js'
5
5
  const mockTokenClient = {
6
6
  isAuthenticated: vi.fn(),
7
7
  getToken: vi.fn(),
8
- getRefreshToken: vi.fn(),
9
- isTokenExpired: vi.fn(),
8
+ getSessionToken: vi.fn(),
10
9
  clearTokens: vi.fn(),
11
- updateAccessToken: vi.fn(),
12
- updateRefreshToken: vi.fn()
13
10
  }
14
11
 
15
12
  const mockHttpClient = {
@@ -47,45 +44,17 @@ describe('Auth', () => {
47
44
  })
48
45
  })
49
46
 
50
- describe('getAccessToken()', () => {
51
- it('returns access token from tokenClient', () => {
52
- mockTokenClient.getToken.mockReturnValue('access-token-123')
47
+ describe('getSessionToken()', () => {
48
+ it('returns session token from tokenClient', () => {
49
+ mockTokenClient.getSessionToken.mockReturnValue('session-token-123')
53
50
  const auth = new Auth(mockClient)
54
- expect(auth.getAccessToken()).toBe('access-token-123')
51
+ expect(auth.getSessionToken()).toBe('session-token-123')
55
52
  })
56
53
 
57
- it('returns null when no token exists', () => {
58
- mockTokenClient.getToken.mockReturnValue(null)
54
+ it('returns null when no session token exists', () => {
55
+ mockTokenClient.getSessionToken.mockReturnValue(null)
59
56
  const auth = new Auth(mockClient)
60
- expect(auth.getAccessToken()).toBeNull()
61
- })
62
- })
63
-
64
- describe('getRefreshToken()', () => {
65
- it('returns refresh token from tokenClient', () => {
66
- mockTokenClient.getRefreshToken.mockReturnValue('refresh-token-456')
67
- const auth = new Auth(mockClient)
68
- expect(auth.getRefreshToken()).toBe('refresh-token-456')
69
- })
70
-
71
- it('returns null when no refresh token exists', () => {
72
- mockTokenClient.getRefreshToken.mockReturnValue(null)
73
- const auth = new Auth(mockClient)
74
- expect(auth.getRefreshToken()).toBeNull()
75
- })
76
- })
77
-
78
- describe('isTokenExpired()', () => {
79
- it('returns true when token is expired', () => {
80
- mockTokenClient.isTokenExpired.mockReturnValue(true)
81
- const auth = new Auth(mockClient)
82
- expect(auth.isTokenExpired()).toBe(true)
83
- })
84
-
85
- it('returns false when token is valid', () => {
86
- mockTokenClient.isTokenExpired.mockReturnValue(false)
87
- const auth = new Auth(mockClient)
88
- expect(auth.isTokenExpired()).toBe(false)
57
+ expect(auth.getSessionToken()).toBeNull()
89
58
  })
90
59
  })
91
60
 
@@ -119,13 +88,4 @@ describe('Auth', () => {
119
88
  expect(result).toBeNull()
120
89
  })
121
90
  })
122
-
123
- describe('refreshAccessToken()', () => {
124
- it('returns null when no refresh token available', async () => {
125
- mockTokenClient.getRefreshToken.mockReturnValue(null)
126
- const auth = new Auth(mockClient)
127
- const result = await auth.refreshAccessToken()
128
- expect(result).toBeNull()
129
- })
130
- })
131
91
  })
@@ -423,6 +423,84 @@ describe('Database', () => {
423
423
  })
424
424
  })
425
425
 
426
+ describe('upsert()', () => {
427
+ it('calls httpClient.post with body to upsert URL', async () => {
428
+ const body = { name: 'Test', status: 'active' }
429
+ mockHttpClient.post.mockResolvedValue({ id: '1', ...body })
430
+ await new Database(mockClient).from('accounts').upsert(body).execute()
431
+ expect(mockHttpClient.post).toHaveBeenCalledWith(
432
+ 'api/apps/test-app/datatables/accounts/data/upsert/',
433
+ body
434
+ )
435
+ })
436
+
437
+ it('builds correct upsert URL with array body', async () => {
438
+ const body = [{ name: 'A' }, { name: 'B' }]
439
+ mockHttpClient.post.mockResolvedValue({ data: body })
440
+ await new Database(mockClient).from('accounts').upsert(body).execute()
441
+ expect(mockHttpClient.post).toHaveBeenCalledWith(
442
+ 'api/apps/test-app/datatables/accounts/data/upsert/',
443
+ body
444
+ )
445
+ })
446
+ })
447
+
448
+ describe('bulkUpdate()', () => {
449
+ it('calls httpClient.patch with array body without recordId', async () => {
450
+ const body = [{ id: '1', status: 'inactive' }, { id: '2', status: 'active' }]
451
+ mockHttpClient.patch.mockResolvedValue({ data: body })
452
+ await new Database(mockClient).from('accounts').bulkUpdate(body).execute()
453
+ expect(mockHttpClient.patch).toHaveBeenCalledWith(
454
+ 'api/apps/test-app/datatables/accounts/data/',
455
+ body
456
+ )
457
+ })
458
+
459
+ it('does not throw without recordId when body is array', async () => {
460
+ const body = [{ id: '1', name: 'Updated' }]
461
+ mockHttpClient.patch.mockResolvedValue({ data: body })
462
+ await expect(
463
+ new Database(mockClient).from('accounts').bulkUpdate(body).execute()
464
+ ).resolves.toBeDefined()
465
+ })
466
+ })
467
+
468
+ describe('bulkDelete()', () => {
469
+ it('calls httpClient.delete with ids in query string', async () => {
470
+ mockHttpClient.delete.mockResolvedValue({ status: 'success' })
471
+ await new Database(mockClient).from('accounts').bulkDelete(['1']).execute()
472
+ expect(mockHttpClient.delete).toHaveBeenCalledWith(
473
+ expect.stringContaining('ids=1')
474
+ )
475
+ })
476
+
477
+ it('joins multiple ids with comma', async () => {
478
+ mockHttpClient.delete.mockResolvedValue({ status: 'success' })
479
+ await new Database(mockClient).from('accounts').bulkDelete(['1', '2', '3']).execute()
480
+ expect(mockHttpClient.delete).toHaveBeenCalledWith(
481
+ expect.stringContaining('ids=1%2C2%2C3')
482
+ )
483
+ })
484
+ })
485
+
486
+ describe('deleteFiltered()', () => {
487
+ it('calls httpClient.delete with filter params in query string', async () => {
488
+ mockHttpClient.delete.mockResolvedValue({ status: 'success' })
489
+ await new Database(mockClient).from('accounts').filter('status', 'eq', 'inactive').deleteFiltered().execute()
490
+ const url = mockHttpClient.delete.mock.calls[0][0]
491
+ expect(url).toContain('status=inactive')
492
+ expect(url).not.toContain('/undefined/')
493
+ })
494
+
495
+ it('hits collection endpoint without recordId', async () => {
496
+ mockHttpClient.delete.mockResolvedValue({ status: 'success' })
497
+ await new Database(mockClient).from('accounts').filter('age', 'lt', 18).deleteFiltered().execute()
498
+ expect(mockHttpClient.delete).toHaveBeenCalledWith(
499
+ expect.stringContaining('api/apps/test-app/datatables/accounts/data/?')
500
+ )
501
+ })
502
+ })
503
+
426
504
  describe('edges()', () => {
427
505
  it('targets _edges table for list', async () => {
428
506
  mockHttpClient.get.mockResolvedValue({ edges: [], total: 0 })
@@ -8,6 +8,7 @@ import {
8
8
  ConflictError,
9
9
  TimeoutError,
10
10
  NetworkError,
11
+ RateLimitError,
11
12
  createErrorFromResponse,
12
13
  ErrorCode
13
14
  } from '../../../src/lib-internal/errors/index.js'
@@ -81,6 +82,21 @@ describe('Error classes', () => {
81
82
  expect(err.code).toBe('NETWORK_ERROR')
82
83
  })
83
84
 
85
+ it('RateLimitError defaults', () => {
86
+ const err = new RateLimitError()
87
+ expect(err.name).toBe('RateLimitError')
88
+ expect(err.statusCode).toBe(429)
89
+ expect(err.code).toBe('RATE_LIMITED')
90
+ expect(err.retryAfter).toBeUndefined()
91
+ })
92
+
93
+ it('RateLimitError with retryAfter', () => {
94
+ const err = new RateLimitError('Too many requests', 60)
95
+ expect(err.message).toBe('Too many requests')
96
+ expect(err.retryAfter).toBe(60)
97
+ expect(err).toBeInstanceOf(TaruviError)
98
+ })
99
+
84
100
  it('all errors are instanceof TaruviError', () => {
85
101
  expect(new ValidationError()).toBeInstanceOf(TaruviError)
86
102
  expect(new AuthError()).toBeInstanceOf(TaruviError)
@@ -89,6 +105,7 @@ describe('Error classes', () => {
89
105
  expect(new ConflictError()).toBeInstanceOf(TaruviError)
90
106
  expect(new TimeoutError()).toBeInstanceOf(TaruviError)
91
107
  expect(new NetworkError()).toBeInstanceOf(TaruviError)
108
+ expect(new RateLimitError()).toBeInstanceOf(TaruviError)
92
109
  })
93
110
 
94
111
  it('all errors are instanceof Error', () => {
@@ -171,6 +188,16 @@ describe('createErrorFromResponse', () => {
171
188
  expect(err).toBeInstanceOf(TimeoutError)
172
189
  })
173
190
 
191
+ it('429 returns RateLimitError', () => {
192
+ const err = createErrorFromResponse(429, {
193
+ status: 'error',
194
+ code: 'RATE_LIMITED',
195
+ message: 'Too many requests'
196
+ })
197
+ expect(err).toBeInstanceOf(RateLimitError)
198
+ expect(err.message).toBe('Too many requests')
199
+ })
200
+
174
201
  it('500 returns TaruviError', () => {
175
202
  const err = createErrorFromResponse(500, {
176
203
  status: 'error',