@bunbase-ae/js 2.9.1-next.222.cb1adc4 → 2.9.1-next.228.75fefb5
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 +21 -2
- package/src/http.ts +52 -25
package/package.json
CHANGED
package/src/admin.ts
CHANGED
|
@@ -296,6 +296,19 @@ export interface ServerSettings {
|
|
|
296
296
|
// a destinations list from the legacy fields at runtime — the Studio UI uses
|
|
297
297
|
// this to drive a per-destination editor with explicit retention.
|
|
298
298
|
backup_destinations?: BackupDestinationConfig[];
|
|
299
|
+
// Phase 1 (#350): when true, every backup also captures `_files` blobs as a
|
|
300
|
+
// content-addressed sibling tree. Default true — disable for very large
|
|
301
|
+
// storage tiers handled out-of-band (e.g. S3 versioning).
|
|
302
|
+
backup_include_storage?: boolean;
|
|
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.
|
|
307
|
+
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
|
+
backup_env_passphrase?: string;
|
|
299
312
|
server_timezone?: string;
|
|
300
313
|
server_locale?: string;
|
|
301
314
|
access_log_level?: "info" | "warn" | "error";
|
|
@@ -340,6 +353,12 @@ export interface BackupFile {
|
|
|
340
353
|
sha256?: string;
|
|
341
354
|
/** Per-destination push results. Only populated for newly-created backups. */
|
|
342
355
|
destinations?: Array<{ id: string; type: string; ok: boolean; error?: string }>;
|
|
356
|
+
/**
|
|
357
|
+
* Whether an encrypted env bundle sidecar (`<filename>.env.json.enc`) is
|
|
358
|
+
* present alongside the dump. When true, the Studio Restore flow prompts
|
|
359
|
+
* for the env passphrase before calling restore. (#350 Phase 2)
|
|
360
|
+
*/
|
|
361
|
+
has_env_bundle?: boolean;
|
|
343
362
|
}
|
|
344
363
|
|
|
345
364
|
export interface HealthResponse {
|
|
@@ -941,11 +960,11 @@ class AdminBackupsClient {
|
|
|
941
960
|
await this.http.request("DELETE", `/api/v1/admin/backups/${encodeURIComponent(filename)}`);
|
|
942
961
|
}
|
|
943
962
|
|
|
944
|
-
async restore(filename: string): Promise<void> {
|
|
963
|
+
async restore(filename: string, opts?: { env_passphrase?: string }): Promise<void> {
|
|
945
964
|
await this.http.request(
|
|
946
965
|
"POST",
|
|
947
966
|
`/api/v1/admin/backups/${encodeURIComponent(filename)}/restore`,
|
|
948
|
-
{ body: {} },
|
|
967
|
+
{ body: opts ?? {} },
|
|
949
968
|
);
|
|
950
969
|
}
|
|
951
970
|
|
package/src/http.ts
CHANGED
|
@@ -112,6 +112,34 @@ export class HttpClient {
|
|
|
112
112
|
keepalive?: boolean;
|
|
113
113
|
} = {},
|
|
114
114
|
): Promise<T> {
|
|
115
|
+
const res = await this.performWithRefresh(method, path, options);
|
|
116
|
+
return this.parse<T>(res);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Returns the raw Response without parsing — for file downloads / blobs.
|
|
120
|
+
// Participates in the same 401 → refresh → retry flow as request<T>, and
|
|
121
|
+
// throws BunBaseError (with .status) on non-ok responses.
|
|
122
|
+
async requestRaw(method: string, path: string): Promise<Response> {
|
|
123
|
+
const res = await this.performWithRefresh(method, path, {});
|
|
124
|
+
if (!res.ok) throw await this.toError(res);
|
|
125
|
+
return res;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Sends the request and, if the response is 401 and we hold a refresh token,
|
|
129
|
+
// refreshes once and retries. Returns the raw Response either way — the
|
|
130
|
+
// caller decides how to read/parse the body.
|
|
131
|
+
private async performWithRefresh(
|
|
132
|
+
method: string,
|
|
133
|
+
path: string,
|
|
134
|
+
options: {
|
|
135
|
+
body?: unknown;
|
|
136
|
+
formData?: FormData;
|
|
137
|
+
query?: Record<string, string>;
|
|
138
|
+
skipAuth?: boolean;
|
|
139
|
+
signal?: AbortSignal;
|
|
140
|
+
keepalive?: boolean;
|
|
141
|
+
},
|
|
142
|
+
): Promise<Response> {
|
|
115
143
|
const res = await this.send(method, path, options);
|
|
116
144
|
|
|
117
145
|
// Static credentials (adminSecret, apiKey) don't use refresh tokens — skip the 401 retry flow.
|
|
@@ -128,26 +156,9 @@ export class HttpClient {
|
|
|
128
156
|
// Refresh failed — tokens are already cleared in doRefresh.
|
|
129
157
|
throw new BunBaseError("Session expired. Please log in again.", 401, null);
|
|
130
158
|
}
|
|
131
|
-
|
|
132
|
-
return this.parse<T>(retried);
|
|
159
|
+
return this.send(method, path, options);
|
|
133
160
|
}
|
|
134
161
|
|
|
135
|
-
return this.parse<T>(res);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Returns the raw Response without parsing — for file downloads / blobs.
|
|
139
|
-
async requestRaw(method: string, path: string): Promise<Response> {
|
|
140
|
-
const res = await this.send(method, path, {});
|
|
141
|
-
if (!res.ok) {
|
|
142
|
-
let message = res.statusText;
|
|
143
|
-
try {
|
|
144
|
-
const data = (await res.clone().json()) as { error?: string };
|
|
145
|
-
if (data.error) message = data.error;
|
|
146
|
-
} catch {
|
|
147
|
-
// ignore
|
|
148
|
-
}
|
|
149
|
-
throw new Error(message);
|
|
150
|
-
}
|
|
151
162
|
return res;
|
|
152
163
|
}
|
|
153
164
|
|
|
@@ -206,18 +217,34 @@ export class HttpClient {
|
|
|
206
217
|
const data = isJson ? await res.json() : await res.text();
|
|
207
218
|
|
|
208
219
|
if (!res.ok) {
|
|
209
|
-
|
|
210
|
-
typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
|
211
|
-
const message =
|
|
212
|
-
"error" in body ? String(body.error) : `Request failed with status ${res.status}`;
|
|
213
|
-
const code = "code" in body ? String(body.code) : undefined;
|
|
214
|
-
const field = "field" in body ? String(body.field) : undefined;
|
|
215
|
-
throw new BunBaseError(message, res.status, data, code, field);
|
|
220
|
+
throw this.buildError(res.status, data);
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
return data as T;
|
|
219
224
|
}
|
|
220
225
|
|
|
226
|
+
// Builds a BunBaseError from a non-ok Response. Used by requestRaw, where
|
|
227
|
+
// the body is never parsed by the caller, so we read it here.
|
|
228
|
+
private async toError(res: Response): Promise<BunBaseError> {
|
|
229
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
230
|
+
const isJson = contentType.includes("application/json");
|
|
231
|
+
let data: unknown = null;
|
|
232
|
+
try {
|
|
233
|
+
data = isJson ? await res.clone().json() : await res.clone().text();
|
|
234
|
+
} catch {
|
|
235
|
+
// Body was empty or unreadable — fall back to status-based message.
|
|
236
|
+
}
|
|
237
|
+
return this.buildError(res.status, data);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private buildError(status: number, data: unknown): BunBaseError {
|
|
241
|
+
const body = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
|
242
|
+
const message = "error" in body ? String(body.error) : `Request failed with status ${status}`;
|
|
243
|
+
const code = "code" in body ? String(body.code) : undefined;
|
|
244
|
+
const field = "field" in body ? String(body.field) : undefined;
|
|
245
|
+
return new BunBaseError(message, status, data, code, field);
|
|
246
|
+
}
|
|
247
|
+
|
|
221
248
|
// Deduplicated token refresh — multiple concurrent callers share one in-flight request.
|
|
222
249
|
private doRefresh(): Promise<void> {
|
|
223
250
|
if (this.refreshPromise) return this.refreshPromise;
|