@hot-updater/firebase 1.2.0-rc.7 → 1.2.0-rc.9
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/dist/firebase/functions/index.cjs +37 -1
- package/dist/index.cjs +123 -26
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +123 -26
- package/package.json +7 -7
|
@@ -4683,7 +4683,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
|
|
|
4683
4683
|
};
|
|
4684
4684
|
//#endregion
|
|
4685
4685
|
//#region ../../packages/server/dist/version.mjs
|
|
4686
|
-
const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.
|
|
4686
|
+
const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.9";
|
|
4687
4687
|
//#endregion
|
|
4688
4688
|
//#region ../../packages/server/dist/handlerVersionRoutes.mjs
|
|
4689
4689
|
/**
|
|
@@ -12139,11 +12139,14 @@ const firebaseDatabase = (config) => ({
|
|
|
12139
12139
|
//#region src/firebaseStorage.ts
|
|
12140
12140
|
/** Signed URLs kept per adapter, enough for a few Bundles' assets. */
|
|
12141
12141
|
const MAX_CACHED_SIGNED_URLS = 1e4;
|
|
12142
|
+
/** Objects deleted at once: Cloud Storage deletes one object a request. */
|
|
12143
|
+
const DELETE_CONCURRENCY = 16;
|
|
12142
12144
|
const firebaseStorage = (config) => {
|
|
12143
12145
|
const app = (0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config);
|
|
12144
12146
|
let opened;
|
|
12145
12147
|
const bucket = () => opened ??= (0, firebase_admin_storage.getStorage)(app).bucket(config.storageBucket);
|
|
12146
12148
|
const getStorageKey = createStorageKeyBuilder(config.basePath);
|
|
12149
|
+
const basePrefix = getStorageKey() ? `${getStorageKey()}/` : "";
|
|
12147
12150
|
/**
|
|
12148
12151
|
* Signed URLs by object. Without a private key, as in Cloud Functions, each
|
|
12149
12152
|
* signature is an IAM signBlob request, and an update check returns a URL
|
|
@@ -12232,6 +12235,39 @@ const firebaseStorage = (config) => {
|
|
|
12232
12235
|
const { key } = parseAndValidate(storageUri);
|
|
12233
12236
|
await bucket().file(key).delete({ ignoreNotFound: true });
|
|
12234
12237
|
return { deleted: true };
|
|
12238
|
+
},
|
|
12239
|
+
async listObjects(prefix) {
|
|
12240
|
+
const folder = getStorageKey(prefix ?? "");
|
|
12241
|
+
const [files] = await bucket().getFiles({
|
|
12242
|
+
autoPaginate: true,
|
|
12243
|
+
prefix: folder ? `${folder}/` : ""
|
|
12244
|
+
});
|
|
12245
|
+
const objects = [];
|
|
12246
|
+
for (const file of files) {
|
|
12247
|
+
if (!file.name.startsWith(basePrefix)) continue;
|
|
12248
|
+
let storageUri;
|
|
12249
|
+
try {
|
|
12250
|
+
storageUri = createStorageUri({
|
|
12251
|
+
bucket: config.storageBucket,
|
|
12252
|
+
key: file.name,
|
|
12253
|
+
protocol: "gs"
|
|
12254
|
+
});
|
|
12255
|
+
} catch {
|
|
12256
|
+
continue;
|
|
12257
|
+
}
|
|
12258
|
+
const { size, updated } = file.metadata;
|
|
12259
|
+
objects.push({
|
|
12260
|
+
key: file.name.slice(basePrefix.length),
|
|
12261
|
+
...updated ? { lastModifiedAt: new Date(updated) } : {},
|
|
12262
|
+
size: Number(size ?? 0),
|
|
12263
|
+
storageUri
|
|
12264
|
+
});
|
|
12265
|
+
}
|
|
12266
|
+
return objects;
|
|
12267
|
+
},
|
|
12268
|
+
async deleteObjects(relativeKeys) {
|
|
12269
|
+
const keys = relativeKeys.map((key) => getStorageKey(key));
|
|
12270
|
+
for (let offset = 0; offset < keys.length; offset += DELETE_CONCURRENCY) await Promise.all(keys.slice(offset, offset + DELETE_CONCURRENCY).map((key) => bucket().file(key).delete({ ignoreNotFound: true })));
|
|
12235
12271
|
}
|
|
12236
12272
|
});
|
|
12237
12273
|
};
|
package/dist/index.cjs
CHANGED
|
@@ -202,11 +202,14 @@ const firebaseDatabase = (config) => ({
|
|
|
202
202
|
//#region src/firebaseStorage.ts
|
|
203
203
|
/** Signed URLs kept per adapter, enough for a few Bundles' assets. */
|
|
204
204
|
const MAX_CACHED_SIGNED_URLS = 1e4;
|
|
205
|
+
/** Objects deleted at once: Cloud Storage deletes one object a request. */
|
|
206
|
+
const DELETE_CONCURRENCY = 16;
|
|
205
207
|
const firebaseStorage = (config) => {
|
|
206
208
|
const app = (0, firebase_admin_app.getApps)().length ? (0, firebase_admin_app.getApp)() : (0, firebase_admin_app.initializeApp)(config);
|
|
207
209
|
let opened;
|
|
208
210
|
const bucket = () => opened ??= (0, firebase_admin_storage.getStorage)(app).bucket(config.storageBucket);
|
|
209
211
|
const getStorageKey = (0, _hot_updater_plugin_core.createStorageKeyBuilder)(config.basePath);
|
|
212
|
+
const basePrefix = getStorageKey() ? `${getStorageKey()}/` : "";
|
|
210
213
|
/**
|
|
211
214
|
* Signed URLs by object. Without a private key, as in Cloud Functions, each
|
|
212
215
|
* signature is an IAM signBlob request, and an update check returns a URL
|
|
@@ -295,6 +298,39 @@ const firebaseStorage = (config) => {
|
|
|
295
298
|
const { key } = parseAndValidate(storageUri);
|
|
296
299
|
await bucket().file(key).delete({ ignoreNotFound: true });
|
|
297
300
|
return { deleted: true };
|
|
301
|
+
},
|
|
302
|
+
async listObjects(prefix) {
|
|
303
|
+
const folder = getStorageKey(prefix ?? "");
|
|
304
|
+
const [files] = await bucket().getFiles({
|
|
305
|
+
autoPaginate: true,
|
|
306
|
+
prefix: folder ? `${folder}/` : ""
|
|
307
|
+
});
|
|
308
|
+
const objects = [];
|
|
309
|
+
for (const file of files) {
|
|
310
|
+
if (!file.name.startsWith(basePrefix)) continue;
|
|
311
|
+
let storageUri;
|
|
312
|
+
try {
|
|
313
|
+
storageUri = (0, _hot_updater_plugin_core.createStorageUri)({
|
|
314
|
+
bucket: config.storageBucket,
|
|
315
|
+
key: file.name,
|
|
316
|
+
protocol: "gs"
|
|
317
|
+
});
|
|
318
|
+
} catch {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const { size, updated } = file.metadata;
|
|
322
|
+
objects.push({
|
|
323
|
+
key: file.name.slice(basePrefix.length),
|
|
324
|
+
...updated ? { lastModifiedAt: new Date(updated) } : {},
|
|
325
|
+
size: Number(size ?? 0),
|
|
326
|
+
storageUri
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return objects;
|
|
330
|
+
},
|
|
331
|
+
async deleteObjects(relativeKeys) {
|
|
332
|
+
const keys = relativeKeys.map((key) => getStorageKey(key));
|
|
333
|
+
for (let offset = 0; offset < keys.length; offset += DELETE_CONCURRENCY) await Promise.all(keys.slice(offset, offset + DELETE_CONCURRENCY).map((key) => bucket().file(key).delete({ ignoreNotFound: true })));
|
|
298
334
|
}
|
|
299
335
|
});
|
|
300
336
|
};
|
|
@@ -356,23 +392,51 @@ const crc32c = (bytes) => {
|
|
|
356
392
|
/** The version IDs the secret's annotation lists. */
|
|
357
393
|
const trackedVersions = (secret) => (secret.annotations?.[VERSIONS_ANNOTATION] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => VERSION_ID_PATTERN.test(entry));
|
|
358
394
|
const versionOf = (name) => typeof name === "string" ? name.slice(name.lastIndexOf("/") + 1) : "";
|
|
395
|
+
/** The widest line a message has: the CLI prints no wider. */
|
|
396
|
+
const LINE_WIDTH = 76;
|
|
397
|
+
/** `text`, such as Google's own message, in lines that fit, broken at spaces. */
|
|
398
|
+
const wrap = (text) => {
|
|
399
|
+
const lines = [];
|
|
400
|
+
let line = "";
|
|
401
|
+
for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
|
|
402
|
+
lines.push(line);
|
|
403
|
+
line = word;
|
|
404
|
+
} else line = line ? `${line} ${word}` : word;
|
|
405
|
+
if (line) lines.push(line);
|
|
406
|
+
return lines;
|
|
407
|
+
};
|
|
408
|
+
/** An error of one line each, for a terminal. */
|
|
409
|
+
const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
|
|
359
410
|
const hint = (response, permission) => {
|
|
360
411
|
const { status, error } = response;
|
|
361
|
-
if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return "
|
|
362
|
-
if (status === 401) return
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
412
|
+
if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return ["Enable the Secret Manager API in the project:", "gcloud services enable secretmanager.googleapis.com --project <project>"];
|
|
413
|
+
if (status === 401) return [
|
|
414
|
+
"Google rejected the access token.",
|
|
415
|
+
"Sign in again with gcloud auth application-default login.",
|
|
416
|
+
"In CI, check the job's Workload Identity Federation."
|
|
417
|
+
];
|
|
418
|
+
if (status === 403) return wrap(`The Google identity needs ${permission}.`);
|
|
419
|
+
if (status === 429) return ["Secret Manager's quota is exhausted: try again later."];
|
|
420
|
+
return [];
|
|
366
421
|
};
|
|
367
422
|
/**
|
|
368
|
-
* An error for a failed request
|
|
369
|
-
*
|
|
423
|
+
* An error for a failed request on `resource`, a line each: the HTTP
|
|
424
|
+
* status, the resource, Google's status and message, which name resources
|
|
425
|
+
* but never a value, `notes`, and what to fix.
|
|
370
426
|
*/
|
|
371
|
-
const requestError = (action, response, permission) => {
|
|
427
|
+
const requestError = (action, resource, response, permission, notes = []) => {
|
|
372
428
|
const { status, message } = response.error;
|
|
373
429
|
const detail = [status, message?.slice(0, 300)].filter(Boolean).join(": ");
|
|
374
|
-
return
|
|
430
|
+
return failure([
|
|
431
|
+
`${ADAPTER_NAME} couldn't ${action} (${response.status}).`,
|
|
432
|
+
resourceLine(resource),
|
|
433
|
+
...wrap(detail),
|
|
434
|
+
...notes,
|
|
435
|
+
...hint(response, permission)
|
|
436
|
+
]);
|
|
375
437
|
};
|
|
438
|
+
/** The line that names a secret: its resource name is a line's worth. */
|
|
439
|
+
const resourceLine = (secret) => `Secret: ${secret}`;
|
|
376
440
|
/** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
|
|
377
441
|
const mask = async (value) => {
|
|
378
442
|
const { registerSecretValue } = await import("@hot-updater/cli-tools");
|
|
@@ -411,7 +475,14 @@ const createAccessToken = (credentials, projectId) => {
|
|
|
411
475
|
try {
|
|
412
476
|
return await client.getAccessToken() ?? void 0;
|
|
413
477
|
} catch (error) {
|
|
414
|
-
throw
|
|
478
|
+
throw failure([
|
|
479
|
+
`${ADAPTER_NAME} found no Google credentials.`,
|
|
480
|
+
...wrap(error instanceof Error ? error.message.split("\n")[0] : String(error)),
|
|
481
|
+
"On your machine, run gcloud auth application-default login.",
|
|
482
|
+
"In CI, use google-github-actions/auth.",
|
|
483
|
+
"Or set GOOGLE_APPLICATION_CREDENTIALS to a service account key.",
|
|
484
|
+
`Or set ${ACCESS_TOKEN_ENV_NAMES.join(" or ")} to a token.`
|
|
485
|
+
], error);
|
|
415
486
|
}
|
|
416
487
|
};
|
|
417
488
|
return async () => {
|
|
@@ -434,13 +505,17 @@ const createAccessToken = (credentials, projectId) => {
|
|
|
434
505
|
const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
435
506
|
const accessToken = createAccessToken(credentials, projectId);
|
|
436
507
|
const projectPath = () => {
|
|
437
|
-
if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw
|
|
438
|
-
if (!PREFIX_PATTERN.test(prefix)) throw
|
|
508
|
+
if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw failure([`${ADAPTER_NAME} can't use the projectId ${JSON.stringify(projectId)}.`, "Set the Google Cloud project's ID or number."]);
|
|
509
|
+
if (!PREFIX_PATTERN.test(prefix)) throw failure([`${ADAPTER_NAME} can't use the prefix ${JSON.stringify(prefix)}.`, "It takes letters, digits, \"-\", and \"_\"."]);
|
|
439
510
|
return `projects/${projectId}`;
|
|
440
511
|
};
|
|
441
512
|
const secretId = (name) => {
|
|
442
513
|
const id = `${prefix}${name}`;
|
|
443
|
-
if (!SECRET_ID_PATTERN.test(id)) throw
|
|
514
|
+
if (!SECRET_ID_PATTERN.test(id)) throw failure([
|
|
515
|
+
`${ADAPTER_NAME} can't name a secret ${JSON.stringify(name)}.`,
|
|
516
|
+
`Secret: ${id}`,
|
|
517
|
+
"Secret Manager's IDs take up to 255 letters, digits, \"-\", and \"_\"."
|
|
518
|
+
]);
|
|
444
519
|
return id;
|
|
445
520
|
};
|
|
446
521
|
const request = async (token, method, path, body) => {
|
|
@@ -475,14 +550,18 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
475
550
|
const accessLatest = async (token, secret) => {
|
|
476
551
|
const response = await request(token, "GET", `${secret}/versions/latest:access`);
|
|
477
552
|
if (response.status === 404) return void 0;
|
|
478
|
-
if (!response.ok) throw requestError(
|
|
553
|
+
if (!response.ok) throw requestError("read", secret, response, "roles/secretmanager.secretAccessor on the secret");
|
|
479
554
|
const body = isRecord(response.body) ? response.body : void 0;
|
|
480
555
|
const payload = body?.payload;
|
|
481
556
|
const data = isRecord(payload) ? payload.data : void 0;
|
|
482
|
-
if (typeof data !== "string") throw
|
|
557
|
+
if (typeof data !== "string") throw failure([`${ADAPTER_NAME} read no payload.`, resourceLine(secret)]);
|
|
483
558
|
const bytes = Buffer.from(data, "base64");
|
|
484
559
|
const checksum = isRecord(payload) ? payload.dataCrc32c : void 0;
|
|
485
|
-
if (checksum !== void 0 && Number(checksum) !== crc32c(bytes)) throw
|
|
560
|
+
if (checksum !== void 0 && Number(checksum) !== crc32c(bytes)) throw failure([
|
|
561
|
+
`${ADAPTER_NAME} can't trust what it read.`,
|
|
562
|
+
resourceLine(secret),
|
|
563
|
+
"Its checksum doesn't match its payload."
|
|
564
|
+
]);
|
|
486
565
|
const value = bytes.toString("utf8");
|
|
487
566
|
await mask(value);
|
|
488
567
|
return {
|
|
@@ -515,7 +594,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
515
594
|
const readSecret = async (token, secret) => {
|
|
516
595
|
const response = await request(token, "GET", secret);
|
|
517
596
|
if (response.status === 404) return void 0;
|
|
518
|
-
if (!response.ok) throw requestError(
|
|
597
|
+
if (!response.ok) throw requestError("read", secret, response, "secretmanager.secrets.get on the secret");
|
|
519
598
|
return response.body;
|
|
520
599
|
};
|
|
521
600
|
const getOrCreateSecret = async (token, parent, id) => {
|
|
@@ -528,14 +607,21 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
528
607
|
const raced = await readSecret(token, secret);
|
|
529
608
|
if (raced) return raced;
|
|
530
609
|
}
|
|
531
|
-
throw requestError(
|
|
610
|
+
throw requestError("create", secret, created, `secretmanager.secrets.create on ${parent}`);
|
|
532
611
|
};
|
|
533
612
|
const assertCanSet = async (token, secret) => {
|
|
534
613
|
const response = await request(token, "POST", `${secret}:testIamPermissions`, { permissions: SET_PERMISSIONS });
|
|
535
|
-
if (!response.ok) throw requestError(
|
|
614
|
+
if (!response.ok) throw requestError("check its permissions", secret, response, SET_PERMISSIONS.join(", "));
|
|
536
615
|
const held = isRecord(response.body) && Array.isArray(response.body.permissions) ? response.body.permissions : [];
|
|
537
616
|
const missing = SET_PERMISSIONS.filter((name) => !held.includes(name));
|
|
538
|
-
if (missing.length > 0) throw
|
|
617
|
+
if (missing.length > 0) throw failure([
|
|
618
|
+
`${ADAPTER_NAME} can't store a value.`,
|
|
619
|
+
resourceLine(secret),
|
|
620
|
+
...wrap(`The Google identity lacks ${missing.join(", ")} on it.`),
|
|
621
|
+
"It adds a version, then destroys the versions it added before.",
|
|
622
|
+
"Grant roles/secretmanager.admin, or a custom role with:",
|
|
623
|
+
...wrap("secretmanager.secrets.get, secretmanager.secrets.update, secretmanager.versions.add, secretmanager.versions.destroy, secretmanager.versions.access")
|
|
624
|
+
]);
|
|
539
625
|
};
|
|
540
626
|
/**
|
|
541
627
|
* Destroys `version` of `secret`: true once it is destroyed, or already
|
|
@@ -582,7 +668,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
582
668
|
const parent = projectPath();
|
|
583
669
|
const id = secretId(name);
|
|
584
670
|
const secret = `${parent}/secrets/${id}`;
|
|
585
|
-
if (typeof value !== "string" || value === "") throw
|
|
671
|
+
if (typeof value !== "string" || value === "") throw failure([`${ADAPTER_NAME} can't store an empty value.`, resourceLine(secret)]);
|
|
586
672
|
await mask(value);
|
|
587
673
|
const token = await accessToken();
|
|
588
674
|
const current = await getOrCreateSecret(token, parent, id);
|
|
@@ -593,7 +679,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
593
679
|
dataCrc32c: String(crc32c(data))
|
|
594
680
|
} });
|
|
595
681
|
const version = versionOf(isRecord(added.body) ? added.body.name : void 0);
|
|
596
|
-
if (!added.ok || !VERSION_ID_PATTERN.test(version)) throw requestError(
|
|
682
|
+
if (!added.ok || !VERSION_ID_PATTERN.test(version)) throw requestError("add a version", secret, added, "secretmanager.versions.add on the secret");
|
|
597
683
|
const visible = await waitForLatest(token, secret, version);
|
|
598
684
|
const destroyed = /* @__PURE__ */ new Set();
|
|
599
685
|
if (visible) {
|
|
@@ -603,7 +689,8 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
603
689
|
if (results[index]) destroyed.add(entry);
|
|
604
690
|
});
|
|
605
691
|
}
|
|
606
|
-
const notListed = `
|
|
692
|
+
const notListed = `record version ${version}, which it stored`;
|
|
693
|
+
const notDestroyed = `The next set won't destroy it: ${VERSIONS_ANNOTATION} misses it.`;
|
|
607
694
|
let base = current;
|
|
608
695
|
let updated;
|
|
609
696
|
try {
|
|
@@ -623,10 +710,20 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
623
710
|
if (updated.error.status !== "FAILED_PRECONDITION") break;
|
|
624
711
|
}
|
|
625
712
|
} catch (error) {
|
|
626
|
-
throw
|
|
713
|
+
throw failure([
|
|
714
|
+
`${ADAPTER_NAME} couldn't ${notListed}.`,
|
|
715
|
+
resourceLine(secret),
|
|
716
|
+
...wrap(error instanceof Error ? error.message : String(error)),
|
|
717
|
+
notDestroyed
|
|
718
|
+
], error);
|
|
627
719
|
}
|
|
628
|
-
if (!updated?.ok) throw requestError(notListed, updated, "secretmanager.secrets.update on the secret");
|
|
629
|
-
if (!visible) throw
|
|
720
|
+
if (!updated?.ok) throw requestError(notListed, secret, updated, "secretmanager.secrets.update on the secret", [notDestroyed]);
|
|
721
|
+
if (!visible) throw failure([
|
|
722
|
+
`${ADAPTER_NAME} stored version ${version}.`,
|
|
723
|
+
resourceLine(secret),
|
|
724
|
+
"Its latest alias doesn't resolve to that version yet.",
|
|
725
|
+
"It kept the versions it added before: the next set destroys them."
|
|
726
|
+
]);
|
|
630
727
|
},
|
|
631
728
|
describe: (name) => {
|
|
632
729
|
const id = `${prefix}${name}`;
|
package/dist/index.d.cts
CHANGED
|
@@ -37,7 +37,7 @@ interface FirebaseStorageConfig extends AppOptions {
|
|
|
37
37
|
/** Firebase signed URL lifetime in seconds. @default 3600 */
|
|
38
38
|
signedUrlExpiresIn?: number;
|
|
39
39
|
}
|
|
40
|
-
declare const firebaseStorage: (config: FirebaseStorageConfig) => StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete">;
|
|
40
|
+
declare const firebaseStorage: (config: FirebaseStorageConfig) => StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects">;
|
|
41
41
|
//#endregion
|
|
42
42
|
//#region src/secretManager.d.ts
|
|
43
43
|
/**
|
|
@@ -56,7 +56,7 @@ interface SecretManagerOptions {
|
|
|
56
56
|
/** The Google Cloud project ID or number that holds the secrets. */
|
|
57
57
|
readonly projectId: string;
|
|
58
58
|
/**
|
|
59
|
-
* Put before each name to make its secret ID, so `secret
|
|
59
|
+
* Put before each name to make its secret ID, so `secret("asc-api-key")`
|
|
60
60
|
* reads `hot-updater-asc-api-key`. Letters, digits, `-`, and `_`.
|
|
61
61
|
*
|
|
62
62
|
* @default "hot-updater-"
|
package/dist/index.d.mts
CHANGED
|
@@ -37,7 +37,7 @@ interface FirebaseStorageConfig extends AppOptions {
|
|
|
37
37
|
/** Firebase signed URL lifetime in seconds. @default 3600 */
|
|
38
38
|
signedUrlExpiresIn?: number;
|
|
39
39
|
}
|
|
40
|
-
declare const firebaseStorage: (config: FirebaseStorageConfig) => StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete">;
|
|
40
|
+
declare const firebaseStorage: (config: FirebaseStorageConfig) => StorageAdapterWith<"put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects">;
|
|
41
41
|
//#endregion
|
|
42
42
|
//#region src/secretManager.d.ts
|
|
43
43
|
/**
|
|
@@ -56,7 +56,7 @@ interface SecretManagerOptions {
|
|
|
56
56
|
/** The Google Cloud project ID or number that holds the secrets. */
|
|
57
57
|
readonly projectId: string;
|
|
58
58
|
/**
|
|
59
|
-
* Put before each name to make its secret ID, so `secret
|
|
59
|
+
* Put before each name to make its secret ID, so `secret("asc-api-key")`
|
|
60
60
|
* reads `hot-updater-asc-api-key`. Letters, digits, `-`, and `_`.
|
|
61
61
|
*
|
|
62
62
|
* @default "hot-updater-"
|
package/dist/index.mjs
CHANGED
|
@@ -201,11 +201,14 @@ const firebaseDatabase = (config) => ({
|
|
|
201
201
|
//#region src/firebaseStorage.ts
|
|
202
202
|
/** Signed URLs kept per adapter, enough for a few Bundles' assets. */
|
|
203
203
|
const MAX_CACHED_SIGNED_URLS = 1e4;
|
|
204
|
+
/** Objects deleted at once: Cloud Storage deletes one object a request. */
|
|
205
|
+
const DELETE_CONCURRENCY = 16;
|
|
204
206
|
const firebaseStorage = (config) => {
|
|
205
207
|
const app = getApps().length ? getApp() : initializeApp(config);
|
|
206
208
|
let opened;
|
|
207
209
|
const bucket = () => opened ??= getStorage(app).bucket(config.storageBucket);
|
|
208
210
|
const getStorageKey = createStorageKeyBuilder(config.basePath);
|
|
211
|
+
const basePrefix = getStorageKey() ? `${getStorageKey()}/` : "";
|
|
209
212
|
/**
|
|
210
213
|
* Signed URLs by object. Without a private key, as in Cloud Functions, each
|
|
211
214
|
* signature is an IAM signBlob request, and an update check returns a URL
|
|
@@ -294,6 +297,39 @@ const firebaseStorage = (config) => {
|
|
|
294
297
|
const { key } = parseAndValidate(storageUri);
|
|
295
298
|
await bucket().file(key).delete({ ignoreNotFound: true });
|
|
296
299
|
return { deleted: true };
|
|
300
|
+
},
|
|
301
|
+
async listObjects(prefix) {
|
|
302
|
+
const folder = getStorageKey(prefix ?? "");
|
|
303
|
+
const [files] = await bucket().getFiles({
|
|
304
|
+
autoPaginate: true,
|
|
305
|
+
prefix: folder ? `${folder}/` : ""
|
|
306
|
+
});
|
|
307
|
+
const objects = [];
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
if (!file.name.startsWith(basePrefix)) continue;
|
|
310
|
+
let storageUri;
|
|
311
|
+
try {
|
|
312
|
+
storageUri = createStorageUri({
|
|
313
|
+
bucket: config.storageBucket,
|
|
314
|
+
key: file.name,
|
|
315
|
+
protocol: "gs"
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const { size, updated } = file.metadata;
|
|
321
|
+
objects.push({
|
|
322
|
+
key: file.name.slice(basePrefix.length),
|
|
323
|
+
...updated ? { lastModifiedAt: new Date(updated) } : {},
|
|
324
|
+
size: Number(size ?? 0),
|
|
325
|
+
storageUri
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return objects;
|
|
329
|
+
},
|
|
330
|
+
async deleteObjects(relativeKeys) {
|
|
331
|
+
const keys = relativeKeys.map((key) => getStorageKey(key));
|
|
332
|
+
for (let offset = 0; offset < keys.length; offset += DELETE_CONCURRENCY) await Promise.all(keys.slice(offset, offset + DELETE_CONCURRENCY).map((key) => bucket().file(key).delete({ ignoreNotFound: true })));
|
|
297
333
|
}
|
|
298
334
|
});
|
|
299
335
|
};
|
|
@@ -355,23 +391,51 @@ const crc32c = (bytes) => {
|
|
|
355
391
|
/** The version IDs the secret's annotation lists. */
|
|
356
392
|
const trackedVersions = (secret) => (secret.annotations?.[VERSIONS_ANNOTATION] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => VERSION_ID_PATTERN.test(entry));
|
|
357
393
|
const versionOf = (name) => typeof name === "string" ? name.slice(name.lastIndexOf("/") + 1) : "";
|
|
394
|
+
/** The widest line a message has: the CLI prints no wider. */
|
|
395
|
+
const LINE_WIDTH = 76;
|
|
396
|
+
/** `text`, such as Google's own message, in lines that fit, broken at spaces. */
|
|
397
|
+
const wrap = (text) => {
|
|
398
|
+
const lines = [];
|
|
399
|
+
let line = "";
|
|
400
|
+
for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
|
|
401
|
+
lines.push(line);
|
|
402
|
+
line = word;
|
|
403
|
+
} else line = line ? `${line} ${word}` : word;
|
|
404
|
+
if (line) lines.push(line);
|
|
405
|
+
return lines;
|
|
406
|
+
};
|
|
407
|
+
/** An error of one line each, for a terminal. */
|
|
408
|
+
const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
|
|
358
409
|
const hint = (response, permission) => {
|
|
359
410
|
const { status, error } = response;
|
|
360
|
-
if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return "
|
|
361
|
-
if (status === 401) return
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
411
|
+
if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return ["Enable the Secret Manager API in the project:", "gcloud services enable secretmanager.googleapis.com --project <project>"];
|
|
412
|
+
if (status === 401) return [
|
|
413
|
+
"Google rejected the access token.",
|
|
414
|
+
"Sign in again with gcloud auth application-default login.",
|
|
415
|
+
"In CI, check the job's Workload Identity Federation."
|
|
416
|
+
];
|
|
417
|
+
if (status === 403) return wrap(`The Google identity needs ${permission}.`);
|
|
418
|
+
if (status === 429) return ["Secret Manager's quota is exhausted: try again later."];
|
|
419
|
+
return [];
|
|
365
420
|
};
|
|
366
421
|
/**
|
|
367
|
-
* An error for a failed request
|
|
368
|
-
*
|
|
422
|
+
* An error for a failed request on `resource`, a line each: the HTTP
|
|
423
|
+
* status, the resource, Google's status and message, which name resources
|
|
424
|
+
* but never a value, `notes`, and what to fix.
|
|
369
425
|
*/
|
|
370
|
-
const requestError = (action, response, permission) => {
|
|
426
|
+
const requestError = (action, resource, response, permission, notes = []) => {
|
|
371
427
|
const { status, message } = response.error;
|
|
372
428
|
const detail = [status, message?.slice(0, 300)].filter(Boolean).join(": ");
|
|
373
|
-
return
|
|
429
|
+
return failure([
|
|
430
|
+
`${ADAPTER_NAME} couldn't ${action} (${response.status}).`,
|
|
431
|
+
resourceLine(resource),
|
|
432
|
+
...wrap(detail),
|
|
433
|
+
...notes,
|
|
434
|
+
...hint(response, permission)
|
|
435
|
+
]);
|
|
374
436
|
};
|
|
437
|
+
/** The line that names a secret: its resource name is a line's worth. */
|
|
438
|
+
const resourceLine = (secret) => `Secret: ${secret}`;
|
|
375
439
|
/** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
|
|
376
440
|
const mask = async (value) => {
|
|
377
441
|
const { registerSecretValue } = await import("@hot-updater/cli-tools");
|
|
@@ -410,7 +474,14 @@ const createAccessToken = (credentials, projectId) => {
|
|
|
410
474
|
try {
|
|
411
475
|
return await client.getAccessToken() ?? void 0;
|
|
412
476
|
} catch (error) {
|
|
413
|
-
throw
|
|
477
|
+
throw failure([
|
|
478
|
+
`${ADAPTER_NAME} found no Google credentials.`,
|
|
479
|
+
...wrap(error instanceof Error ? error.message.split("\n")[0] : String(error)),
|
|
480
|
+
"On your machine, run gcloud auth application-default login.",
|
|
481
|
+
"In CI, use google-github-actions/auth.",
|
|
482
|
+
"Or set GOOGLE_APPLICATION_CREDENTIALS to a service account key.",
|
|
483
|
+
`Or set ${ACCESS_TOKEN_ENV_NAMES.join(" or ")} to a token.`
|
|
484
|
+
], error);
|
|
414
485
|
}
|
|
415
486
|
};
|
|
416
487
|
return async () => {
|
|
@@ -433,13 +504,17 @@ const createAccessToken = (credentials, projectId) => {
|
|
|
433
504
|
const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
434
505
|
const accessToken = createAccessToken(credentials, projectId);
|
|
435
506
|
const projectPath = () => {
|
|
436
|
-
if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw
|
|
437
|
-
if (!PREFIX_PATTERN.test(prefix)) throw
|
|
507
|
+
if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw failure([`${ADAPTER_NAME} can't use the projectId ${JSON.stringify(projectId)}.`, "Set the Google Cloud project's ID or number."]);
|
|
508
|
+
if (!PREFIX_PATTERN.test(prefix)) throw failure([`${ADAPTER_NAME} can't use the prefix ${JSON.stringify(prefix)}.`, "It takes letters, digits, \"-\", and \"_\"."]);
|
|
438
509
|
return `projects/${projectId}`;
|
|
439
510
|
};
|
|
440
511
|
const secretId = (name) => {
|
|
441
512
|
const id = `${prefix}${name}`;
|
|
442
|
-
if (!SECRET_ID_PATTERN.test(id)) throw
|
|
513
|
+
if (!SECRET_ID_PATTERN.test(id)) throw failure([
|
|
514
|
+
`${ADAPTER_NAME} can't name a secret ${JSON.stringify(name)}.`,
|
|
515
|
+
`Secret: ${id}`,
|
|
516
|
+
"Secret Manager's IDs take up to 255 letters, digits, \"-\", and \"_\"."
|
|
517
|
+
]);
|
|
443
518
|
return id;
|
|
444
519
|
};
|
|
445
520
|
const request = async (token, method, path, body) => {
|
|
@@ -474,14 +549,18 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
474
549
|
const accessLatest = async (token, secret) => {
|
|
475
550
|
const response = await request(token, "GET", `${secret}/versions/latest:access`);
|
|
476
551
|
if (response.status === 404) return void 0;
|
|
477
|
-
if (!response.ok) throw requestError(
|
|
552
|
+
if (!response.ok) throw requestError("read", secret, response, "roles/secretmanager.secretAccessor on the secret");
|
|
478
553
|
const body = isRecord(response.body) ? response.body : void 0;
|
|
479
554
|
const payload = body?.payload;
|
|
480
555
|
const data = isRecord(payload) ? payload.data : void 0;
|
|
481
|
-
if (typeof data !== "string") throw
|
|
556
|
+
if (typeof data !== "string") throw failure([`${ADAPTER_NAME} read no payload.`, resourceLine(secret)]);
|
|
482
557
|
const bytes = Buffer.from(data, "base64");
|
|
483
558
|
const checksum = isRecord(payload) ? payload.dataCrc32c : void 0;
|
|
484
|
-
if (checksum !== void 0 && Number(checksum) !== crc32c(bytes)) throw
|
|
559
|
+
if (checksum !== void 0 && Number(checksum) !== crc32c(bytes)) throw failure([
|
|
560
|
+
`${ADAPTER_NAME} can't trust what it read.`,
|
|
561
|
+
resourceLine(secret),
|
|
562
|
+
"Its checksum doesn't match its payload."
|
|
563
|
+
]);
|
|
485
564
|
const value = bytes.toString("utf8");
|
|
486
565
|
await mask(value);
|
|
487
566
|
return {
|
|
@@ -514,7 +593,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
514
593
|
const readSecret = async (token, secret) => {
|
|
515
594
|
const response = await request(token, "GET", secret);
|
|
516
595
|
if (response.status === 404) return void 0;
|
|
517
|
-
if (!response.ok) throw requestError(
|
|
596
|
+
if (!response.ok) throw requestError("read", secret, response, "secretmanager.secrets.get on the secret");
|
|
518
597
|
return response.body;
|
|
519
598
|
};
|
|
520
599
|
const getOrCreateSecret = async (token, parent, id) => {
|
|
@@ -527,14 +606,21 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
527
606
|
const raced = await readSecret(token, secret);
|
|
528
607
|
if (raced) return raced;
|
|
529
608
|
}
|
|
530
|
-
throw requestError(
|
|
609
|
+
throw requestError("create", secret, created, `secretmanager.secrets.create on ${parent}`);
|
|
531
610
|
};
|
|
532
611
|
const assertCanSet = async (token, secret) => {
|
|
533
612
|
const response = await request(token, "POST", `${secret}:testIamPermissions`, { permissions: SET_PERMISSIONS });
|
|
534
|
-
if (!response.ok) throw requestError(
|
|
613
|
+
if (!response.ok) throw requestError("check its permissions", secret, response, SET_PERMISSIONS.join(", "));
|
|
535
614
|
const held = isRecord(response.body) && Array.isArray(response.body.permissions) ? response.body.permissions : [];
|
|
536
615
|
const missing = SET_PERMISSIONS.filter((name) => !held.includes(name));
|
|
537
|
-
if (missing.length > 0) throw
|
|
616
|
+
if (missing.length > 0) throw failure([
|
|
617
|
+
`${ADAPTER_NAME} can't store a value.`,
|
|
618
|
+
resourceLine(secret),
|
|
619
|
+
...wrap(`The Google identity lacks ${missing.join(", ")} on it.`),
|
|
620
|
+
"It adds a version, then destroys the versions it added before.",
|
|
621
|
+
"Grant roles/secretmanager.admin, or a custom role with:",
|
|
622
|
+
...wrap("secretmanager.secrets.get, secretmanager.secrets.update, secretmanager.versions.add, secretmanager.versions.destroy, secretmanager.versions.access")
|
|
623
|
+
]);
|
|
538
624
|
};
|
|
539
625
|
/**
|
|
540
626
|
* Destroys `version` of `secret`: true once it is destroyed, or already
|
|
@@ -581,7 +667,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
581
667
|
const parent = projectPath();
|
|
582
668
|
const id = secretId(name);
|
|
583
669
|
const secret = `${parent}/secrets/${id}`;
|
|
584
|
-
if (typeof value !== "string" || value === "") throw
|
|
670
|
+
if (typeof value !== "string" || value === "") throw failure([`${ADAPTER_NAME} can't store an empty value.`, resourceLine(secret)]);
|
|
585
671
|
await mask(value);
|
|
586
672
|
const token = await accessToken();
|
|
587
673
|
const current = await getOrCreateSecret(token, parent, id);
|
|
@@ -592,7 +678,7 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
592
678
|
dataCrc32c: String(crc32c(data))
|
|
593
679
|
} });
|
|
594
680
|
const version = versionOf(isRecord(added.body) ? added.body.name : void 0);
|
|
595
|
-
if (!added.ok || !VERSION_ID_PATTERN.test(version)) throw requestError(
|
|
681
|
+
if (!added.ok || !VERSION_ID_PATTERN.test(version)) throw requestError("add a version", secret, added, "secretmanager.versions.add on the secret");
|
|
596
682
|
const visible = await waitForLatest(token, secret, version);
|
|
597
683
|
const destroyed = /* @__PURE__ */ new Set();
|
|
598
684
|
if (visible) {
|
|
@@ -602,7 +688,8 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
602
688
|
if (results[index]) destroyed.add(entry);
|
|
603
689
|
});
|
|
604
690
|
}
|
|
605
|
-
const notListed = `
|
|
691
|
+
const notListed = `record version ${version}, which it stored`;
|
|
692
|
+
const notDestroyed = `The next set won't destroy it: ${VERSIONS_ANNOTATION} misses it.`;
|
|
606
693
|
let base = current;
|
|
607
694
|
let updated;
|
|
608
695
|
try {
|
|
@@ -622,10 +709,20 @@ const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
|
622
709
|
if (updated.error.status !== "FAILED_PRECONDITION") break;
|
|
623
710
|
}
|
|
624
711
|
} catch (error) {
|
|
625
|
-
throw
|
|
712
|
+
throw failure([
|
|
713
|
+
`${ADAPTER_NAME} couldn't ${notListed}.`,
|
|
714
|
+
resourceLine(secret),
|
|
715
|
+
...wrap(error instanceof Error ? error.message : String(error)),
|
|
716
|
+
notDestroyed
|
|
717
|
+
], error);
|
|
626
718
|
}
|
|
627
|
-
if (!updated?.ok) throw requestError(notListed, updated, "secretmanager.secrets.update on the secret");
|
|
628
|
-
if (!visible) throw
|
|
719
|
+
if (!updated?.ok) throw requestError(notListed, secret, updated, "secretmanager.secrets.update on the secret", [notDestroyed]);
|
|
720
|
+
if (!visible) throw failure([
|
|
721
|
+
`${ADAPTER_NAME} stored version ${version}.`,
|
|
722
|
+
resourceLine(secret),
|
|
723
|
+
"Its latest alias doesn't resolve to that version yet.",
|
|
724
|
+
"It kept the versions it added before: the next set destroys them."
|
|
725
|
+
]);
|
|
629
726
|
},
|
|
630
727
|
describe: (name) => {
|
|
631
728
|
const id = `${prefix}${name}`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/firebase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.2.0-rc.
|
|
4
|
+
"version": "1.2.0-rc.9",
|
|
5
5
|
"description": "React Native OTA solution for self-hosted",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
7
7
|
"types": "dist/index.d.cts",
|
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"hono": "4.12.34",
|
|
40
40
|
"google-auth-library": "10.7.0",
|
|
41
|
-
"@hot-updater/
|
|
42
|
-
"@hot-updater/
|
|
43
|
-
"@hot-updater/
|
|
44
|
-
"@hot-updater/
|
|
41
|
+
"@hot-updater/protocol": "1.2.0-rc.9",
|
|
42
|
+
"@hot-updater/plugin-core": "1.2.0-rc.9",
|
|
43
|
+
"@hot-updater/server": "1.2.0-rc.9",
|
|
44
|
+
"@hot-updater/cli-tools": "1.2.0-rc.9"
|
|
45
45
|
},
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"access": "public"
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"firebase-tools": "15.29.0",
|
|
57
57
|
"fkill": "^9.0.0",
|
|
58
58
|
"mime": "^4.0.4",
|
|
59
|
-
"@hot-updater/
|
|
60
|
-
"@hot-updater/
|
|
59
|
+
"@hot-updater/mock": "1.2.0-rc.9",
|
|
60
|
+
"@hot-updater/test-utils": "1.2.0-rc.9"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"firebase-admin": ">=11.10.0",
|