@bunbase-ae/js 2.10.1 → 2.11.0

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.10.1",
3
+ "version": "2.11.0",
4
4
  "type": "module",
5
5
  "description": "TypeScript/JavaScript SDK for BunBase",
6
6
  "license": "UNLICENSED",
package/src/admin.ts CHANGED
@@ -301,14 +301,25 @@ export interface ServerSettings {
301
301
  // storage tiers handled out-of-band (e.g. S3 versioning).
302
302
  backup_include_storage?: boolean;
303
303
  // Phase 2 (#350): when true, every backup also captures an AES-256-GCM
304
- // encrypted bundle of secret env vars under `backup_env_passphrase`.
305
- // Default false opt-in. Passphrase loss is unrecoverable for the env
306
- // restore path; DB+storage still restore without it.
304
+ // encrypted bundle of secret env vars. The passphrase used to encrypt the
305
+ // bundle is sourced from the `BACKUP_ENV_PASSPHRASE` environment variable
306
+ // on the BunBase host (see `backup_env_passphrase_source` below for the
307
+ // read-only set/unset indicator). Default false — opt-in. Passphrase loss
308
+ // is unrecoverable for the env restore path; DB+storage still restore
309
+ // without it.
307
310
  backup_include_env?: boolean;
308
- // Operator-provided passphrase for `backup_include_env`. Must be at least
309
- // 32 chars. Returned as the redacted sentinel ("********") on read once
310
- // #379 lands.
311
+ /**
312
+ * @deprecated Removed in #402. The env-bundle passphrase is sourced from
313
+ * the `BACKUP_ENV_PASSPHRASE` environment variable only — never from
314
+ * `_settings`, never via this API. Setting this field via the admin API
315
+ * is silently ignored. Use `backup_env_passphrase_source` to read whether
316
+ * the env var is set on the BunBase host.
317
+ */
311
318
  backup_env_passphrase?: string;
319
+ // Read-only indicator (#402). `"env_set"` means BACKUP_ENV_PASSPHRASE is
320
+ // present in the BunBase host's process environment; `"env_unset"` means
321
+ // it is missing — encrypted env-bundle capture will skip with a warn log.
322
+ backup_env_passphrase_source?: "env_set" | "env_unset";
312
323
  server_timezone?: string;
313
324
  server_locale?: string;
314
325
  access_log_level?: "info" | "warn" | "error";
@@ -400,6 +411,24 @@ export interface StatsResponse {
400
411
  time_series: MinuteBucket[];
401
412
  }
402
413
 
414
+ export interface TenantSummary {
415
+ tenant_id: string;
416
+ member_count: number;
417
+ }
418
+
419
+ export interface TenantMember {
420
+ user_id: string;
421
+ role: string;
422
+ created_at: number;
423
+ }
424
+
425
+ export interface AdminTenantMembership {
426
+ tenant_id: string;
427
+ user_id: string;
428
+ role: string;
429
+ created_at: number;
430
+ }
431
+
403
432
  // ─── Sub-clients ──────────────────────────────────────────────────────────────
404
433
 
405
434
  class AdminUsersClient {
@@ -1129,6 +1158,58 @@ class AdminNamedQueriesClient {
1129
1158
  }
1130
1159
  }
1131
1160
 
1161
+ // Tenant membership management — surfaces the /api/v1/admin/tenants/* endpoints
1162
+ // added in v2.5.2 (#277/#323) so operators don't need to hand-roll fetch calls
1163
+ // to onboard a tenant member.
1164
+ class AdminTenantsClient {
1165
+ constructor(private readonly http: HttpClient) {}
1166
+
1167
+ async list(): Promise<TenantSummary[]> {
1168
+ const res = await this.http.request<{ items: TenantSummary[] }>("GET", "/api/v1/admin/tenants");
1169
+ return res.items;
1170
+ }
1171
+
1172
+ async listMembers(tenantId: string): Promise<TenantMember[]> {
1173
+ const res = await this.http.request<{ items: TenantMember[] }>(
1174
+ "GET",
1175
+ `/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members`,
1176
+ );
1177
+ return res.items;
1178
+ }
1179
+
1180
+ async addMember(
1181
+ tenantId: string,
1182
+ params: { userId: string; role?: string },
1183
+ ): Promise<AdminTenantMembership> {
1184
+ const body: Record<string, unknown> = { user_id: params.userId };
1185
+ if (params.role !== undefined) body.role = params.role;
1186
+ return this.http.request<AdminTenantMembership>(
1187
+ "POST",
1188
+ `/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members`,
1189
+ { body },
1190
+ );
1191
+ }
1192
+
1193
+ async setMemberRole(
1194
+ tenantId: string,
1195
+ userId: string,
1196
+ role: string,
1197
+ ): Promise<AdminTenantMembership> {
1198
+ return this.http.request<AdminTenantMembership>(
1199
+ "PATCH",
1200
+ `/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`,
1201
+ { body: { role } },
1202
+ );
1203
+ }
1204
+
1205
+ async removeMember(tenantId: string, userId: string): Promise<void> {
1206
+ await this.http.request<{ ok: boolean }>(
1207
+ "DELETE",
1208
+ `/api/v1/admin/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`,
1209
+ );
1210
+ }
1211
+ }
1212
+
1132
1213
  // ─── Main AdminClient ─────────────────────────────────────────────────────────
1133
1214
 
1134
1215
  export class AdminClient {
@@ -1143,6 +1224,7 @@ export class AdminClient {
1143
1224
  readonly queries: AdminNamedQueriesClient;
1144
1225
  readonly logs: AdminLogsClient;
1145
1226
  readonly system: AdminSystemClient;
1227
+ readonly tenants: AdminTenantsClient;
1146
1228
 
1147
1229
  constructor(http: HttpClient) {
1148
1230
  this.users = new AdminUsersClient(http);
@@ -1156,5 +1238,6 @@ export class AdminClient {
1156
1238
  this.queries = new AdminNamedQueriesClient(http);
1157
1239
  this.logs = new AdminLogsClient(http);
1158
1240
  this.system = new AdminSystemClient(http);
1241
+ this.tenants = new AdminTenantsClient(http);
1159
1242
  }
1160
1243
  }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  type AdminRecord,
8
8
  type AdminSession,
9
9
  type AdminStoredFile,
10
+ type AdminTenantMembership,
10
11
  type AdminUser,
11
12
  type BackupDestinationConfig,
12
13
  type BackupFile,
@@ -38,6 +39,8 @@ export {
38
39
  type StatsResponse,
39
40
  type StorageBucket,
40
41
  type TemplateName,
42
+ type TenantMember,
43
+ type TenantSummary,
41
44
  type UpdateNamedQueryInput,
42
45
  type UpdateUserParams,
43
46
  type WebhookLogRow,
package/src/realtime.ts CHANGED
@@ -88,6 +88,25 @@ export class RealtimeClient {
88
88
  private ws: WebSocket | null = null;
89
89
  // Map from channel key → { callbacks, options }
90
90
  private channels = new Map<string, ChannelState>();
91
+ // Reverse map: server-resolved topic → user-supplied channel key. Populated
92
+ // from the subscribe ack (`{ type: "ack", channel: <resolved-topic>,
93
+ // requestedChannel: <user-key> }`) so dispatch can translate the topic on
94
+ // incoming change messages back to the key the caller used. Without this,
95
+ // three of four subscribe shapes silently drop every event because the
96
+ // resolved topic differs from the user key:
97
+ // - record:{collection}:{id} → record:{collection}:{id} (matches, unless tenant-scoped)
98
+ // - record:{collection}:{id} (tenant-scoped) → record:{collection}:tenant:{tid}:{id}
99
+ // - collection:{name}:mine → collection:{name}:user:{uid}
100
+ // - collection:{name} (tenant-scoped) → collection:{name}:tenant:{tid}
101
+ //
102
+ // The earlier #408 fix paired acks to subscribes via a FIFO queue. That broke
103
+ // whenever the response stream wasn't 1:1 with the request stream — most
104
+ // notably when the server replied with `{ type: "error" }` to a subscribe
105
+ // (e.g. `tenant_required` for a tenant-scoped collection on a JWT without a
106
+ // tenant claim) or when an unsubscribe ack arrived in between. Both desync
107
+ // the queue permanently. Replacing FIFO pairing with an explicit
108
+ // `requestedChannel` echo makes correlation direct and stateless.
109
+ private resolvedTopicToKey = new Map<string, string>();
91
110
  private connected = false;
92
111
  private intentionalClose = false;
93
112
  private reconnectDelay = INITIAL_RECONNECT_MS;
@@ -281,6 +300,7 @@ export class RealtimeClient {
281
300
  this.ws = null;
282
301
  this.connected = false;
283
302
  this.connectionState = "disconnected";
303
+ this.resolvedTopicToKey.clear();
284
304
  }
285
305
 
286
306
  // ─── Internal ──────────────────────────────────────────────────────────────
@@ -291,6 +311,12 @@ export class RealtimeClient {
291
311
  state.callbacks.delete(callback);
292
312
  if (state.callbacks.size === 0) {
293
313
  this.channels.delete(channel);
314
+ // Drop any reverse-map entries that pointed at this user key — leaving
315
+ // stale rows would route a future event for a recycled topic to a
316
+ // deleted subscription.
317
+ for (const [topic, key] of this.resolvedTopicToKey) {
318
+ if (key === channel) this.resolvedTopicToKey.delete(topic);
319
+ }
294
320
  if (this.connected) this.send({ type: "unsubscribe", channel });
295
321
  }
296
322
  }
@@ -317,6 +343,9 @@ export class RealtimeClient {
317
343
  this.intentionalClose = false;
318
344
  this.connectionState = "connecting";
319
345
  this.sawSuccessfulSubscribe = false;
346
+ // Clear any stale reverse-topic mappings — the server will reissue acks
347
+ // for every channel we re-send in onopen below.
348
+ this.resolvedTopicToKey.clear();
320
349
  this.ws = new WebSocket(`${this.wsUrl}/realtime`);
321
350
 
322
351
  this.ws.onopen = () => {
@@ -360,10 +389,52 @@ export class RealtimeClient {
360
389
 
361
390
  // A subscribe ack proves we can actually hold a subscription on this
362
391
  // connection — reset the unauth-bailout state. (Auth-event acks flow
363
- // through the "auth" branch below and carry no `channel` field.)
392
+ // through the "auth" branch below and carry no `channel` field;
393
+ // unsubscribe acks carry `channel` but no `requestedChannel`, so they
394
+ // don't disturb the reverse map.)
364
395
  if (msg.type === "ack" && (msg as { channel?: string }).channel) {
365
396
  this.sawSuccessfulSubscribe = true;
366
397
  this.unauthCloseTimestamps = [];
398
+ const ackChannel = (msg as unknown as { channel: string }).channel;
399
+ const requestedChannel = (msg as unknown as { requestedChannel?: string }).requestedChannel;
400
+ if (requestedChannel) {
401
+ // Direct correlation: the server echoed the user-supplied channel
402
+ // string back, so we can pair the resolved topic to the user key
403
+ // without any FIFO state.
404
+ this.resolvedTopicToKey.set(ackChannel, requestedChannel);
405
+ } else {
406
+ // Back-compat with older servers that don't echo requestedChannel:
407
+ // identity-map the resolved topic so dispatch still finds the
408
+ // channel when the user keyed by topic. Only the simple non-tenant
409
+ // `collection:{name}` shape works against an old server — that
410
+ // matches the pre-fix behavior, no regression here.
411
+ this.resolvedTopicToKey.set(ackChannel, ackChannel);
412
+ }
413
+ return;
414
+ }
415
+
416
+ // Subscribe-error: server returned `{ type: "error", requestedChannel }`
417
+ // for a subscribe that failed without closing the socket (e.g.
418
+ // `tenant_required`, `subscription_limit`, `invalid channel format`,
419
+ // RPC catch-all). Drop the channel from this.channels so the failed sub
420
+ // doesn't get re-sent on reconnect (where it would just fail again) and
421
+ // so a future subscribe of the same key starts clean. Surface to onError
422
+ // with channel context — currently the only way the caller can find out
423
+ // their subscribe didn't take.
424
+ if (msg.type === "error" && (msg as { requestedChannel?: string }).requestedChannel) {
425
+ const errMsg = msg as unknown as {
426
+ requestedChannel: string;
427
+ code?: string;
428
+ message?: string;
429
+ };
430
+ this.channels.delete(errMsg.requestedChannel);
431
+ for (const [topic, key] of this.resolvedTopicToKey) {
432
+ if (key === errMsg.requestedChannel) this.resolvedTopicToKey.delete(topic);
433
+ }
434
+ const summary = errMsg.code
435
+ ? `${errMsg.code}: ${errMsg.message ?? ""}`
436
+ : (errMsg.message ?? "subscribe failed");
437
+ this.onError?.(`Realtime subscribe failed for "${errMsg.requestedChannel}": ${summary}`);
367
438
  return;
368
439
  }
369
440
 
@@ -405,7 +476,16 @@ export class RealtimeClient {
405
476
  if (msg.type !== "change") return;
406
477
 
407
478
  const changeMsg = msg as ServerChangeMessage;
408
- const state = this.channels.get(changeMsg.channel);
479
+ // Translate the server's resolved topic back to the user-supplied
480
+ // channel key before dispatching. Server fanout sets msg.channel to the
481
+ // topic the publish landed on (e.g. `collection:posts:user:U` for
482
+ // :mine, `record:posts:tenant:T:01ABC` for tenant-scoped record subs),
483
+ // which differs from the key the caller passed to subscribe(). Fall
484
+ // through to identity lookup for unmapped topics — keeps backwards
485
+ // compatibility with older servers and the simple non-tenant
486
+ // collection-channel case where the topic matches the user key.
487
+ const userKey = this.resolvedTopicToKey.get(changeMsg.channel) ?? changeMsg.channel;
488
+ const state = this.channels.get(userKey);
409
489
  if (!state) return;
410
490
 
411
491
  const realtimeEvent: RealtimeEvent = {