@bunbase-ae/js 2.10.1 → 2.11.1-next.257.70d253d
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/admin.ts +89 -6
- package/src/index.ts +3 -0
- package/src/realtime.ts +82 -2
package/package.json
CHANGED
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
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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 = {
|