@bunbase-ae/js 2.4.0 → 2.4.1-next.162.9c38140

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": "@bunbase-ae/js",
3
- "version": "2.4.0",
3
+ "version": "2.4.1-next.162.9c38140",
4
4
  "type": "module",
5
5
  "description": "TypeScript/JavaScript SDK for BunBase",
6
6
  "license": "UNLICENSED",
package/src/auth.ts CHANGED
@@ -10,6 +10,8 @@ import type {
10
10
  AuthUser,
11
11
  LoginResult,
12
12
  PersistSessionOptions,
13
+ SwitchTenantResult,
14
+ TenantMembership,
13
15
  TotpChallenge,
14
16
  TotpSetup,
15
17
  } from "./types";
@@ -17,10 +19,39 @@ import type {
17
19
  // Reactive snapshot of auth state. Consumed by useAuth() via useSyncExternalStore.
18
20
  export interface AuthSnapshot {
19
21
  user: AuthUser | null;
22
+ /**
23
+ * Tenant the current session is scoped to. Derived from the access token's
24
+ * `tenant_id` claim. Null for unscoped sessions (login without tenantId,
25
+ * API-key auth, logged-out state).
26
+ */
27
+ tenantId: string | null;
20
28
  loading: boolean;
21
29
  error: Error | null;
22
30
  }
23
31
 
32
+ // Decode the base64url payload of a JWT to read claims client-side. Returns
33
+ // null for malformed tokens — callers treat that as "no tenant claim".
34
+ function decodeJwtPayload(jwt: string): Record<string, unknown> | null {
35
+ const parts = jwt.split(".");
36
+ const raw = parts[1];
37
+ if (parts.length !== 3 || !raw) return null;
38
+ try {
39
+ const b64 = raw.replace(/-/g, "+").replace(/_/g, "/");
40
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
41
+ const decoded = atob(b64 + pad);
42
+ return JSON.parse(decoded) as Record<string, unknown>;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function tenantIdFromAccessToken(accessToken: string | null | undefined): string | null {
49
+ if (!accessToken) return null;
50
+ const payload = decodeJwtPayload(accessToken);
51
+ const claim = payload?.tenant_id;
52
+ return typeof claim === "string" && claim.length > 0 ? claim : null;
53
+ }
54
+
24
55
  // Represents the active session passed to onAuthChange listeners.
25
56
  // Token-based sessions carry access/refresh tokens; API-key sessions carry the key.
26
57
  export type AuthSession = { accessToken: string; refreshToken: string } | { apiKey: string };
@@ -30,18 +61,24 @@ export class AuthClient {
30
61
  // Cleared on logout. Lets useAuth() initialize synchronously without a network round-trip.
31
62
  private cachedUser: AuthUser | null = null;
32
63
 
33
- // External store for useSyncExternalStore — holds user/loading/error as a snapshot.
34
- private _snap: AuthSnapshot = { user: null, loading: false, error: null };
64
+ // External store for useSyncExternalStore — holds user/tenantId/loading/error.
65
+ private _snap: AuthSnapshot = { user: null, tenantId: null, loading: false, error: null };
35
66
  private _snapSubs = new Set<() => void>();
36
67
 
37
68
  constructor(private readonly http: HttpClient) {
38
- // When tokens are cleared from ANY path (logout, logoutAll, deleteAccount,
39
- // or server-pushed revocation via RealtimeClient), keep cachedUser and the
40
- // reactive snapshot in sync automatically.
69
+ // When tokens change (login, refresh, switch-tenant, logout, logoutAll,
70
+ // deleteAccount, or server-pushed revocation via RealtimeClient), keep
71
+ // cachedUser and the reactive snapshot in sync automatically.
72
+ //
73
+ // tenantId is derived from the access token's `tenant_id` claim on every
74
+ // transition, so callers never have to plumb it manually.
41
75
  this.http.onTokenChange(({ accessToken }) => {
76
+ const tenantId = tenantIdFromAccessToken(accessToken);
42
77
  if (!accessToken && this.cachedUser !== null) {
43
78
  this.cachedUser = null;
44
- this.patchSnapshot({ user: null });
79
+ this.patchSnapshot({ user: null, tenantId });
80
+ } else {
81
+ this.patchSnapshot({ tenantId });
45
82
  }
46
83
  });
47
84
  }
@@ -91,7 +128,15 @@ export class AuthClient {
91
128
 
92
129
  // Returns AuthResult on success, or TotpChallenge when 2FA is enabled.
93
130
  // On TotpChallenge, call verifyTotp(result.totp_token, code) to complete sign-in.
94
- async login(credentials: { email: string; password: string }): Promise<LoginResult> {
131
+ //
132
+ // Pass `tenantId` to scope the issued session to a specific tenant — the
133
+ // access token then carries a `tenant_id` claim and all subsequent requests
134
+ // are restricted to that tenant. Omit it for unscoped sessions.
135
+ async login(credentials: {
136
+ email: string;
137
+ password: string;
138
+ tenantId?: string;
139
+ }): Promise<LoginResult> {
95
140
  const result = await this.http.request<LoginResult>("POST", "/api/v1/auth/login", {
96
141
  body: credentials,
97
142
  skipAuth: true,
@@ -136,6 +181,41 @@ export class AuthClient {
136
181
  return result;
137
182
  }
138
183
 
184
+ // Redeem an OAuth handoff code for the token pair.
185
+ //
186
+ // The OAuth callback redirects to `{APP_URL}/auth/callback?handoff=<code>`.
187
+ // Tokens no longer travel through the URL query string (which would leak
188
+ // them into browser history, proxy logs, and third-party Referer headers);
189
+ // instead, the frontend reads the `handoff` param and posts it here.
190
+ //
191
+ // Single-use — a second call with the same code returns 404.
192
+ async exchangeHandoff(code: string): Promise<{
193
+ access_token: string;
194
+ refresh_token: string;
195
+ expires_in: number;
196
+ }> {
197
+ const result = await this.http.request<{
198
+ access_token: string;
199
+ refresh_token: string;
200
+ expires_in: number;
201
+ }>("POST", "/api/v1/auth/oauth/handoff", {
202
+ body: { handoff: code },
203
+ skipAuth: true,
204
+ });
205
+ this.http.setTokens(result.access_token, result.refresh_token);
206
+ // Populate the reactive snapshot so useAuth() lights up without the caller
207
+ // having to issue a separate me() round-trip. Swallow errors here — the
208
+ // token store is already primed; any auth-dependent view can retry.
209
+ try {
210
+ const user = await this.me();
211
+ this.cachedUser = user;
212
+ this.patchSnapshot({ user });
213
+ } catch {
214
+ // Caller can retry via auth.me() — tokens are already stored.
215
+ }
216
+ return result;
217
+ }
218
+
139
219
  async refresh(): Promise<AuthResult> {
140
220
  const refreshToken = this.http.getRefreshToken();
141
221
  if (!refreshToken) throw new Error("No refresh token stored. Call login() first.");
@@ -325,6 +405,35 @@ export class AuthClient {
325
405
  await this.http.request<{ ok: boolean }>("DELETE", `/api/v1/auth/api-keys/${id}`);
326
406
  }
327
407
 
408
+ // ─── Multi-tenancy ───────────────────────────────────────────────────────────
409
+
410
+ // List the tenants the authenticated user is a member of.
411
+ // Use the returned `tenant_id` values with switchTenant() to re-scope the session.
412
+ async listTenants(): Promise<TenantMembership[]> {
413
+ const result = await this.http.request<{ items: TenantMembership[]; total: number }>(
414
+ "GET",
415
+ "/api/v1/auth/tenants",
416
+ );
417
+ return result.items;
418
+ }
419
+
420
+ // Re-issue the session token pair scoped to a different tenant.
421
+ //
422
+ // The caller's current session is revoked server-side before the new pair is
423
+ // issued, so the pre-switch refresh/access tokens can no longer be used. The
424
+ // returned token pair is written into the HTTP layer automatically — the
425
+ // reactive AuthSnapshot picks up the new `tenant_id` claim on the next tick,
426
+ // so useAuth() callers see the updated `tenantId` without any extra wiring.
427
+ async switchTenant(tenantId: string): Promise<SwitchTenantResult> {
428
+ const result = await this.http.request<SwitchTenantResult>(
429
+ "POST",
430
+ "/api/v1/auth/switch-tenant",
431
+ { body: { tenant_id: tenantId } },
432
+ );
433
+ this.http.setTokens(result.access_token, result.refresh_token);
434
+ return result;
435
+ }
436
+
328
437
  // ─── Auth change listeners ────────────────────────────────────────────────
329
438
 
330
439
  // Register a listener for token-based auth transitions: fires with
package/src/index.ts CHANGED
@@ -82,6 +82,8 @@ export {
82
82
  type RealtimeEvent,
83
83
  type RealtimeEventType,
84
84
  type StorageAdapter,
85
+ type SwitchTenantResult,
86
+ type TenantMembership,
85
87
  type TotpChallenge,
86
88
  type TotpSetup,
87
89
  type TransactionCreate,
package/src/types.ts CHANGED
@@ -6,6 +6,11 @@ export interface BunBaseRecord {
6
6
  _created_at: number;
7
7
  _updated_at: number;
8
8
  _owner_id: string | null;
9
+ /**
10
+ * Tenant this record belongs to. Null for records created outside a tenant-scoped session.
11
+ * Always present on the wire — the server stamps it on every record.
12
+ */
13
+ _tenant_id: string | null;
9
14
  _deleted_at?: number | null;
10
15
  }
11
16
 
@@ -170,6 +175,7 @@ export type Filter<T = Record<string, unknown>> = {
170
175
  _created_at?: FilterFieldValue<number>;
171
176
  _updated_at?: FilterFieldValue<number>;
172
177
  _owner_id?: FilterFieldValue<string | null>;
178
+ _tenant_id?: FilterFieldValue<string | null>;
173
179
  _deleted_at?: FilterFieldValue<number | null | undefined>;
174
180
  };
175
181
 
@@ -361,6 +367,27 @@ export interface ApiKey {
361
367
  created_at: number;
362
368
  }
363
369
 
370
+ // ─── Multi-tenancy ────────────────────────────────────────────────────────────
371
+
372
+ /** A single tenant membership for the authenticated user. Returned by `AuthClient.listTenants()`. */
373
+ export interface TenantMembership {
374
+ tenant_id: string;
375
+ role: string;
376
+ created_at: number;
377
+ }
378
+
379
+ /**
380
+ * Returned by `AuthClient.switchTenant()`. Carries a re-issued token pair scoped
381
+ * to the target tenant. The caller's prior session is revoked server-side before
382
+ * the new pair is minted, so stale tokens cannot be replayed.
383
+ */
384
+ export interface SwitchTenantResult {
385
+ access_token: string;
386
+ refresh_token: string;
387
+ expires_in: number;
388
+ tenant: { tenant_id: string; role: string };
389
+ }
390
+
364
391
  // Minimal storage interface — deliberately async-compatible so the same API
365
392
  // works with synchronous Web Storage (localStorage, sessionStorage) and
366
393
  // Promise-based stores such as React Native's AsyncStorage or any custom