@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 +1 -1
- package/src/auth.ts +81 -7
- package/src/index.ts +2 -0
- package/src/types.ts +27 -0
package/package.json
CHANGED
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
|
|
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
|
|
39
|
-
// or server-pushed revocation via RealtimeClient), keep
|
|
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
|
-
|
|
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
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
|