@bunbase-ae/js 2.4.0 → 2.4.1-next.161.79fd318

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.161.79fd318",
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,
@@ -325,6 +370,35 @@ export class AuthClient {
325
370
  await this.http.request<{ ok: boolean }>("DELETE", `/api/v1/auth/api-keys/${id}`);
326
371
  }
327
372
 
373
+ // ─── Multi-tenancy ───────────────────────────────────────────────────────────
374
+
375
+ // List the tenants the authenticated user is a member of.
376
+ // Use the returned `tenant_id` values with switchTenant() to re-scope the session.
377
+ async listTenants(): Promise<TenantMembership[]> {
378
+ const result = await this.http.request<{ items: TenantMembership[]; total: number }>(
379
+ "GET",
380
+ "/api/v1/auth/tenants",
381
+ );
382
+ return result.items;
383
+ }
384
+
385
+ // Re-issue the session token pair scoped to a different tenant.
386
+ //
387
+ // The caller's current session is revoked server-side before the new pair is
388
+ // issued, so the pre-switch refresh/access tokens can no longer be used. The
389
+ // returned token pair is written into the HTTP layer automatically — the
390
+ // reactive AuthSnapshot picks up the new `tenant_id` claim on the next tick,
391
+ // so useAuth() callers see the updated `tenantId` without any extra wiring.
392
+ async switchTenant(tenantId: string): Promise<SwitchTenantResult> {
393
+ const result = await this.http.request<SwitchTenantResult>(
394
+ "POST",
395
+ "/api/v1/auth/switch-tenant",
396
+ { body: { tenant_id: tenantId } },
397
+ );
398
+ this.http.setTokens(result.access_token, result.refresh_token);
399
+ return result;
400
+ }
401
+
328
402
  // ─── Auth change listeners ────────────────────────────────────────────────
329
403
 
330
404
  // 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