@bunbase-ae/js 2.3.1 → 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.3.1",
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/storage.ts CHANGED
@@ -24,6 +24,10 @@ export interface SignedUploadResult {
24
24
  url: string;
25
25
  key: string;
26
26
  expires_in: number;
27
+ // HMAC-signed token binding this sign call to the caller. S3 provider only
28
+ // — `null` for local (no confirm step). Required by /storage/confirm so the
29
+ // server can use server-signed metadata instead of trusting the client.
30
+ confirm_token: string | null;
27
31
  }
28
32
 
29
33
  export class StorageClient {
@@ -121,7 +125,7 @@ export class StorageClient {
121
125
  options: UploadOptions & { expiresIn?: number } = {},
122
126
  ): Promise<FileRecord> {
123
127
  const filename = file instanceof File ? file.name : `upload-${Date.now()}`;
124
- const { url, key } = await this.signedUpload(filename, {
128
+ const { url, confirm_token } = await this.signedUpload(filename, {
125
129
  ...options,
126
130
  contentType: file.type || "application/octet-stream",
127
131
  });
@@ -146,39 +150,40 @@ export class StorageClient {
146
150
  }
147
151
 
148
152
  // S3 provider: PUT succeeded (200/204) — register metadata with BunBase.
153
+ // The server uses the HMAC-signed token's fields as the source of truth;
154
+ // `size`, `collection`, `recordId` are the only body fields still honoured.
155
+ if (!confirm_token) {
156
+ throw new BunBaseError(
157
+ "Missing confirm_token in sign response — server may be running a pre-#231 build.",
158
+ 500,
159
+ null,
160
+ );
161
+ }
149
162
  return this.confirmUpload({
150
- key,
151
- bucket: options.bucket,
152
- filename,
163
+ confirmToken: confirm_token,
153
164
  collection: options.collection,
154
165
  recordId: options.recordId,
155
- isPublic: options.isPublic,
156
- mimeType: file.type || "application/octet-stream",
157
166
  size: file.size,
158
167
  });
159
168
  }
160
169
 
161
170
  // Confirm an S3 presigned upload by registering the file metadata in BunBase.
162
171
  // Not needed for local provider (the PUT handler registers metadata automatically).
172
+ //
173
+ // `confirmToken` (from the sign response) is required — it carries the
174
+ // server-signed key / bucket / is_public / mime_type / owner. Body fields
175
+ // outside `size`, `collection`, `recordId` are ignored by the server.
163
176
  async confirmUpload(options: {
164
- key: string;
165
- bucket?: string;
166
- filename?: string;
177
+ confirmToken: string;
167
178
  collection?: string;
168
179
  recordId?: string;
169
- isPublic?: boolean;
170
- mimeType?: string;
171
180
  size?: number;
172
181
  }): Promise<FileRecord> {
173
182
  return this.http.request<FileRecord>("POST", "/api/v1/storage/confirm", {
174
183
  body: {
175
- key: options.key,
176
- bucket: options.bucket,
177
- filename: options.filename,
184
+ confirm_token: options.confirmToken,
178
185
  collection: options.collection,
179
186
  record_id: options.recordId,
180
- is_public: options.isPublic,
181
- mime_type: options.mimeType,
182
187
  size: options.size,
183
188
  },
184
189
  });
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