@hot-updater/firebase 1.1.3 → 1.2.0-rc.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/dist/firebase/functions/index.cjs +920 -408
- package/dist/index.cjs +359 -12
- package/dist/index.d.cts +47 -2
- package/dist/index.d.mts +47 -2
- package/dist/index.mjs +359 -13
- package/dist/init/index.cjs +13 -4
- package/dist/init/index.mjs +13 -4
- package/package.json +8 -7
package/dist/index.mjs
CHANGED
|
@@ -182,11 +182,20 @@ const adapterOf = ({ databaseId, collection = FIREBASE_V1_COLLECTION, aggregateB
|
|
|
182
182
|
* after deploying the indexes.
|
|
183
183
|
*/
|
|
184
184
|
const migrateFirebaseDatabase = (config, plugins = []) => migrateCoreSchema(adapterOf(config), "firebaseDatabase", plugins);
|
|
185
|
+
/**
|
|
186
|
+
* The resource this database reaches, without credentials, under the key
|
|
187
|
+
* `hot-updater doctor` reads to tell the database `hot-updater init` saved
|
|
188
|
+
* from another (a convention CLAUDE.md documents).
|
|
189
|
+
*/
|
|
190
|
+
const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
|
|
185
191
|
/** Hot Updater's database in one Firestore collection, through the storage engine. */
|
|
186
|
-
const firebaseDatabase = (config) =>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
192
|
+
const firebaseDatabase = (config) => ({
|
|
193
|
+
...createEngineDatabase({
|
|
194
|
+
name: "firebaseDatabase",
|
|
195
|
+
adapter: adapterOf(config),
|
|
196
|
+
aggregateBatching: config.aggregateBatching ?? {}
|
|
197
|
+
}),
|
|
198
|
+
...config.projectId === void 0 ? {} : { [DATABASE_TARGET]: `firestore/${config.projectId}/${config.databaseId ?? "(default)"}` }
|
|
190
199
|
});
|
|
191
200
|
//#endregion
|
|
192
201
|
//#region src/firebaseStorage.ts
|
|
@@ -194,7 +203,8 @@ const firebaseDatabase = (config) => createEngineDatabase({
|
|
|
194
203
|
const MAX_CACHED_SIGNED_URLS = 1e4;
|
|
195
204
|
const firebaseStorage = (config) => {
|
|
196
205
|
const app = getApps().length ? getApp() : initializeApp(config);
|
|
197
|
-
|
|
206
|
+
let opened;
|
|
207
|
+
const bucket = () => opened ??= getStorage(app).bucket(config.storageBucket);
|
|
198
208
|
const getStorageKey = createStorageKeyBuilder(config.basePath);
|
|
199
209
|
/**
|
|
200
210
|
* Signed URLs by object. Without a private key, as in Cloud Functions, each
|
|
@@ -205,7 +215,7 @@ const firebaseStorage = (config) => {
|
|
|
205
215
|
const signedUrls = /* @__PURE__ */ new Map();
|
|
206
216
|
const signUrl = async (key) => {
|
|
207
217
|
const expiresAt = Date.now() + (config.signedUrlExpiresIn ?? 3600) * 1e3;
|
|
208
|
-
const [url] = await bucket.file(key).getSignedUrl({
|
|
218
|
+
const [url] = await bucket().file(key).getSignedUrl({
|
|
209
219
|
action: "read",
|
|
210
220
|
expires: expiresAt
|
|
211
221
|
});
|
|
@@ -238,11 +248,11 @@ const firebaseStorage = (config) => {
|
|
|
238
248
|
protocol: "gs",
|
|
239
249
|
async put({ key, body, contentType }) {
|
|
240
250
|
const storageKey = getStorageKey(key);
|
|
241
|
-
const
|
|
242
|
-
await bucket.file(storageKey).
|
|
251
|
+
const [{ Readable }, { pipeline }] = await Promise.all([import("node:stream"), import("node:stream/promises")]);
|
|
252
|
+
await pipeline(Readable.fromWeb(body), bucket().file(storageKey).createWriteStream({ metadata: {
|
|
243
253
|
contentType,
|
|
244
254
|
cacheControl: "public, max-age=31536000, immutable"
|
|
245
|
-
} });
|
|
255
|
+
} }));
|
|
246
256
|
return { storageUri: createStorageUri({
|
|
247
257
|
bucket: config.storageBucket,
|
|
248
258
|
key: storageKey,
|
|
@@ -252,7 +262,7 @@ const firebaseStorage = (config) => {
|
|
|
252
262
|
async get({ storageUri }) {
|
|
253
263
|
const { key } = parseAndValidate(storageUri);
|
|
254
264
|
try {
|
|
255
|
-
const file = bucket.file(key);
|
|
265
|
+
const file = bucket().file(key);
|
|
256
266
|
const [[body], [metadata]] = await Promise.all([file.download(), file.getMetadata()]);
|
|
257
267
|
const headers = new Headers();
|
|
258
268
|
if (metadata.contentType) headers.set("content-type", metadata.contentType);
|
|
@@ -277,15 +287,351 @@ const firebaseStorage = (config) => {
|
|
|
277
287
|
},
|
|
278
288
|
async exists({ storageUri }) {
|
|
279
289
|
const { key } = parseAndValidate(storageUri);
|
|
280
|
-
const [exists] = await bucket.file(key).exists();
|
|
290
|
+
const [exists] = await bucket().file(key).exists();
|
|
281
291
|
return { exists };
|
|
282
292
|
},
|
|
283
293
|
async delete({ storageUri }) {
|
|
284
294
|
const { key } = parseAndValidate(storageUri);
|
|
285
|
-
await bucket.file(key).delete({ ignoreNotFound: true });
|
|
295
|
+
await bucket().file(key).delete({ ignoreNotFound: true });
|
|
286
296
|
return { deleted: true };
|
|
287
297
|
}
|
|
288
298
|
});
|
|
289
299
|
};
|
|
290
300
|
//#endregion
|
|
291
|
-
|
|
301
|
+
//#region src/secretManager.ts
|
|
302
|
+
const ADAPTER_NAME = "secretManager";
|
|
303
|
+
const API_URL = "https://secretmanager.googleapis.com/v1";
|
|
304
|
+
const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
305
|
+
/**
|
|
306
|
+
* Variables an access token is read from as it is: gcloud's, and the one
|
|
307
|
+
* Terraform's Google provider reads, which a job sets from
|
|
308
|
+
* `google-github-actions/auth`'s `access_token` output.
|
|
309
|
+
*/
|
|
310
|
+
const ACCESS_TOKEN_ENV_NAMES = ["CLOUDSDK_AUTH_ACCESS_TOKEN", "GOOGLE_OAUTH_ACCESS_TOKEN"];
|
|
311
|
+
const PROJECT_PATTERN = /^[a-z0-9][a-z0-9.:-]*$/u;
|
|
312
|
+
const SECRET_ID_PATTERN = /^[A-Za-z0-9_-]{1,255}$/u;
|
|
313
|
+
const PREFIX_PATTERN = /^[A-Za-z0-9_-]*$/u;
|
|
314
|
+
const VERSION_ID_PATTERN = /^[1-9]\d*$/u;
|
|
315
|
+
/**
|
|
316
|
+
* The secret's annotation that lists the versions this adapter added and
|
|
317
|
+
* has not destroyed yet: the only versions it ever destroys.
|
|
318
|
+
*/
|
|
319
|
+
const VERSIONS_ANNOTATION = "hot-updater-versions";
|
|
320
|
+
/** What `set` needs on the secret, checked before it adds a version. */
|
|
321
|
+
const SET_PERMISSIONS = [
|
|
322
|
+
"secretmanager.versions.add",
|
|
323
|
+
"secretmanager.versions.access",
|
|
324
|
+
"secretmanager.versions.destroy",
|
|
325
|
+
"secretmanager.secrets.update"
|
|
326
|
+
];
|
|
327
|
+
/**
|
|
328
|
+
* How long `set` waits, between reads, for the `latest` alias to resolve to
|
|
329
|
+
* the version it added: `latest` is eventually consistent. About 30 seconds.
|
|
330
|
+
*/
|
|
331
|
+
const LATEST_RETRY_DELAYS_MS = [
|
|
332
|
+
250,
|
|
333
|
+
500,
|
|
334
|
+
1e3,
|
|
335
|
+
2e3,
|
|
336
|
+
4e3,
|
|
337
|
+
8e3,
|
|
338
|
+
15e3
|
|
339
|
+
];
|
|
340
|
+
/** How often `set` re-reads the secret when another writer changed it. */
|
|
341
|
+
const ANNOTATION_ATTEMPTS = 5;
|
|
342
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
343
|
+
const delay = (ms) => new Promise((resolve) => {
|
|
344
|
+
setTimeout(resolve, ms);
|
|
345
|
+
});
|
|
346
|
+
/** The CRC32C (Castagnoli) checksum Secret Manager verifies payloads with. */
|
|
347
|
+
const crc32c = (bytes) => {
|
|
348
|
+
let crc = 4294967295;
|
|
349
|
+
for (const byte of bytes) {
|
|
350
|
+
crc ^= byte;
|
|
351
|
+
for (let bit = 0; bit < 8; bit += 1) crc = crc >>> 1 ^ (crc & 1 ? 2197175160 : 0);
|
|
352
|
+
}
|
|
353
|
+
return (crc ^ 4294967295) >>> 0;
|
|
354
|
+
};
|
|
355
|
+
/** The version IDs the secret's annotation lists. */
|
|
356
|
+
const trackedVersions = (secret) => (secret.annotations?.[VERSIONS_ANNOTATION] ?? "").split(",").map((entry) => entry.trim()).filter((entry) => VERSION_ID_PATTERN.test(entry));
|
|
357
|
+
const versionOf = (name) => typeof name === "string" ? name.slice(name.lastIndexOf("/") + 1) : "";
|
|
358
|
+
const hint = (response, permission) => {
|
|
359
|
+
const { status, error } = response;
|
|
360
|
+
if (error.details?.some((detail) => detail.reason === "SERVICE_DISABLED")) return " Enable the Secret Manager API (secretmanager.googleapis.com) in the project: gcloud services enable secretmanager.googleapis.com --project <project>.";
|
|
361
|
+
if (status === 401) return " Google rejected the access token: sign in again with `gcloud auth application-default login`, or check the job's Workload Identity Federation.";
|
|
362
|
+
if (status === 403) return ` The Google identity needs ${permission}.`;
|
|
363
|
+
if (status === 429) return " Secret Manager's quota is exhausted: try again later.";
|
|
364
|
+
return "";
|
|
365
|
+
};
|
|
366
|
+
/**
|
|
367
|
+
* An error for a failed request: the HTTP status, Google's status and
|
|
368
|
+
* message, which name resources but never a value, and what to fix.
|
|
369
|
+
*/
|
|
370
|
+
const requestError = (action, response, permission) => {
|
|
371
|
+
const { status, message } = response.error;
|
|
372
|
+
const detail = [status, message?.slice(0, 300)].filter(Boolean).join(": ");
|
|
373
|
+
return /* @__PURE__ */ new Error(`${ADAPTER_NAME} could not ${action} (${response.status}${detail ? ` ${detail}` : ""}).${hint(response, permission)}`);
|
|
374
|
+
};
|
|
375
|
+
/** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
|
|
376
|
+
const mask = async (value) => {
|
|
377
|
+
const { registerSecretValue } = await import("@hot-updater/cli-tools");
|
|
378
|
+
registerSecretValue(value);
|
|
379
|
+
};
|
|
380
|
+
const tokenOf = (result) => {
|
|
381
|
+
if (typeof result === "string") return result;
|
|
382
|
+
if (!result) return void 0;
|
|
383
|
+
if ("token" in result) return result.token ?? void 0;
|
|
384
|
+
if ("access_token" in result) return result.access_token;
|
|
385
|
+
};
|
|
386
|
+
/**
|
|
387
|
+
* Returns a function that resolves an access token with the
|
|
388
|
+
* `cloud-platform` scope: from `credentials`, else from an access token
|
|
389
|
+
* variable, else from Application Default Credentials through
|
|
390
|
+
* `google-auth-library`, which loads on first use. Each token is masked in
|
|
391
|
+
* the CLI's output.
|
|
392
|
+
*/
|
|
393
|
+
const createAccessToken = (credentials, projectId) => {
|
|
394
|
+
let auth;
|
|
395
|
+
const resolve = async () => {
|
|
396
|
+
if (typeof credentials === "function") return credentials();
|
|
397
|
+
if (credentials) return tokenOf(await credentials.getAccessToken());
|
|
398
|
+
for (const name of ACCESS_TOKEN_ENV_NAMES) {
|
|
399
|
+
const token = process.env[name]?.trim();
|
|
400
|
+
if (token) return token;
|
|
401
|
+
}
|
|
402
|
+
auth ??= import("google-auth-library").then(({ GoogleAuth }) => new GoogleAuth({
|
|
403
|
+
scopes: [CLOUD_PLATFORM_SCOPE],
|
|
404
|
+
projectId
|
|
405
|
+
})).catch((error) => {
|
|
406
|
+
auth = void 0;
|
|
407
|
+
throw error;
|
|
408
|
+
});
|
|
409
|
+
const client = await auth;
|
|
410
|
+
try {
|
|
411
|
+
return await client.getAccessToken() ?? void 0;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
throw new Error(`${ADAPTER_NAME} found no Google credentials (${error instanceof Error ? error.message.split("\n")[0] : String(error)}). Sign in with \`gcloud auth application-default login\`, use google-github-actions/auth in CI, set GOOGLE_APPLICATION_CREDENTIALS, or set ${ACCESS_TOKEN_ENV_NAMES.join(" or ")} to an access token.`, { cause: error });
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
return async () => {
|
|
417
|
+
const token = (await resolve())?.trim();
|
|
418
|
+
if (!token) throw new Error(`${ADAPTER_NAME} got no Google access token.`);
|
|
419
|
+
await mask(token);
|
|
420
|
+
return token;
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* Secrets from Google Cloud Secret Manager: each name is the secret
|
|
425
|
+
* `<prefix><name>`, read at its `latest` version. Requests go to the REST
|
|
426
|
+
* API, signed by `google-auth-library`, which firebase-admin already
|
|
427
|
+
* installs and which loads on the first read or write, so a config that
|
|
428
|
+
* only uses `firebaseStorage` never loads it.
|
|
429
|
+
*
|
|
430
|
+
* @example
|
|
431
|
+
* secrets: secretManager({ projectId: "my-project" })
|
|
432
|
+
*/
|
|
433
|
+
const secretManager = ({ projectId, prefix = "hot-updater-", credentials }) => {
|
|
434
|
+
const accessToken = createAccessToken(credentials, projectId);
|
|
435
|
+
const projectPath = () => {
|
|
436
|
+
if (typeof projectId !== "string" || !PROJECT_PATTERN.test(projectId)) throw new Error(`${ADAPTER_NAME} needs the Google Cloud project ID or number as projectId, and ${JSON.stringify(projectId)} is not one.`);
|
|
437
|
+
if (!PREFIX_PATTERN.test(prefix)) throw new Error(`${ADAPTER_NAME}'s prefix takes letters, digits, "-", and "_", and ${JSON.stringify(prefix)} has others.`);
|
|
438
|
+
return `projects/${projectId}`;
|
|
439
|
+
};
|
|
440
|
+
const secretId = (name) => {
|
|
441
|
+
const id = `${prefix}${name}`;
|
|
442
|
+
if (!SECRET_ID_PATTERN.test(id)) throw new Error(`${ADAPTER_NAME} keeps ${JSON.stringify(name)} as the secret ID ${JSON.stringify(id)}, and Secret Manager's IDs take only letters, digits, "-", and "_", up to 255 characters.`);
|
|
443
|
+
return id;
|
|
444
|
+
};
|
|
445
|
+
const request = async (token, method, path, body) => {
|
|
446
|
+
const authorization = `Bearer ${token}`;
|
|
447
|
+
const response = await fetch(`${API_URL}/${path}`, body === void 0 ? {
|
|
448
|
+
method,
|
|
449
|
+
headers: { authorization },
|
|
450
|
+
redirect: "error"
|
|
451
|
+
} : {
|
|
452
|
+
method,
|
|
453
|
+
headers: {
|
|
454
|
+
authorization,
|
|
455
|
+
"content-type": "application/json"
|
|
456
|
+
},
|
|
457
|
+
body: JSON.stringify(body),
|
|
458
|
+
redirect: "error"
|
|
459
|
+
});
|
|
460
|
+
let parsed;
|
|
461
|
+
try {
|
|
462
|
+
parsed = await response.json();
|
|
463
|
+
} catch {
|
|
464
|
+
parsed = void 0;
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
status: response.status,
|
|
468
|
+
ok: response.ok,
|
|
469
|
+
body: parsed,
|
|
470
|
+
error: isRecord(parsed) && isRecord(parsed.error) ? parsed.error : {}
|
|
471
|
+
};
|
|
472
|
+
};
|
|
473
|
+
/** The `latest` version's ID and value, or undefined when there is none. */
|
|
474
|
+
const accessLatest = async (token, secret) => {
|
|
475
|
+
const response = await request(token, "GET", `${secret}/versions/latest:access`);
|
|
476
|
+
if (response.status === 404) return void 0;
|
|
477
|
+
if (!response.ok) throw requestError(`read ${secret}`, response, "roles/secretmanager.secretAccessor on the secret");
|
|
478
|
+
const body = isRecord(response.body) ? response.body : void 0;
|
|
479
|
+
const payload = body?.payload;
|
|
480
|
+
const data = isRecord(payload) ? payload.data : void 0;
|
|
481
|
+
if (typeof data !== "string") throw new Error(`${ADAPTER_NAME} read no payload for ${secret}.`);
|
|
482
|
+
const bytes = Buffer.from(data, "base64");
|
|
483
|
+
const checksum = isRecord(payload) ? payload.dataCrc32c : void 0;
|
|
484
|
+
if (checksum !== void 0 && Number(checksum) !== crc32c(bytes)) throw new Error(`${ADAPTER_NAME} read ${secret} with a checksum that does not match its payload.`);
|
|
485
|
+
const value = bytes.toString("utf8");
|
|
486
|
+
await mask(value);
|
|
487
|
+
return {
|
|
488
|
+
version: versionOf(body?.name),
|
|
489
|
+
value
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
/**
|
|
493
|
+
* Whether `latest` resolves to `version`, or a newer one another writer
|
|
494
|
+
* added, within about 30 seconds. While `latest` still names an older
|
|
495
|
+
* version, which may be missing or disabled, it reads again; it never
|
|
496
|
+
* throws, so the version `set` stored is always listed.
|
|
497
|
+
*/
|
|
498
|
+
const waitForLatest = async (token, secret, version) => {
|
|
499
|
+
for (const ms of [0, ...LATEST_RETRY_DELAYS_MS]) {
|
|
500
|
+
if (ms > 0) await delay(ms);
|
|
501
|
+
let response;
|
|
502
|
+
try {
|
|
503
|
+
response = await request(token, "GET", `${secret}/versions/latest:access`);
|
|
504
|
+
} catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
if (response.ok) {
|
|
508
|
+
const latest = versionOf(isRecord(response.body) ? response.body.name : void 0);
|
|
509
|
+
if (Number(latest) >= Number(version)) return true;
|
|
510
|
+
} else if (response.status !== 404 && response.error.status !== "FAILED_PRECONDITION") return false;
|
|
511
|
+
}
|
|
512
|
+
return false;
|
|
513
|
+
};
|
|
514
|
+
const readSecret = async (token, secret) => {
|
|
515
|
+
const response = await request(token, "GET", secret);
|
|
516
|
+
if (response.status === 404) return void 0;
|
|
517
|
+
if (!response.ok) throw requestError(`read ${secret}`, response, "secretmanager.secrets.get on the secret");
|
|
518
|
+
return response.body;
|
|
519
|
+
};
|
|
520
|
+
const getOrCreateSecret = async (token, parent, id) => {
|
|
521
|
+
const secret = `${parent}/secrets/${id}`;
|
|
522
|
+
const existing = await readSecret(token, secret);
|
|
523
|
+
if (existing) return existing;
|
|
524
|
+
const created = await request(token, "POST", `${parent}/secrets?secretId=${encodeURIComponent(id)}`, { replication: { automatic: {} } });
|
|
525
|
+
if (created.ok) return created.body;
|
|
526
|
+
if (created.status === 409) {
|
|
527
|
+
const raced = await readSecret(token, secret);
|
|
528
|
+
if (raced) return raced;
|
|
529
|
+
}
|
|
530
|
+
throw requestError(`create ${secret}`, created, `secretmanager.secrets.create on ${parent}`);
|
|
531
|
+
};
|
|
532
|
+
const assertCanSet = async (token, secret) => {
|
|
533
|
+
const response = await request(token, "POST", `${secret}:testIamPermissions`, { permissions: SET_PERMISSIONS });
|
|
534
|
+
if (!response.ok) throw requestError(`check its permissions on ${secret}`, response, SET_PERMISSIONS.join(", "));
|
|
535
|
+
const held = isRecord(response.body) && Array.isArray(response.body.permissions) ? response.body.permissions : [];
|
|
536
|
+
const missing = SET_PERMISSIONS.filter((name) => !held.includes(name));
|
|
537
|
+
if (missing.length > 0) throw new Error(`${ADAPTER_NAME} stores a value as a new version of ${secret} and destroys the versions it added before, and the Google identity lacks ${missing.join(", ")} on it: grant a custom role with secretmanager.secrets.get, secretmanager.secrets.update, secretmanager.versions.add, secretmanager.versions.destroy, and secretmanager.versions.access on it, or roles/secretmanager.admin.`);
|
|
538
|
+
};
|
|
539
|
+
/**
|
|
540
|
+
* Destroys `version` of `secret`: true once it is destroyed, or already
|
|
541
|
+
* was, false when it could not be, even for a network failure, so it
|
|
542
|
+
* stays listed for the next `set`.
|
|
543
|
+
*/
|
|
544
|
+
const destroy = async (token, secret, version) => {
|
|
545
|
+
let response;
|
|
546
|
+
try {
|
|
547
|
+
response = await request(token, "POST", `${secret}/versions/${version}:destroy`, {});
|
|
548
|
+
} catch {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
return response.ok || response.status === 404 || response.error.status === "FAILED_PRECONDITION";
|
|
552
|
+
};
|
|
553
|
+
return {
|
|
554
|
+
name: ADAPTER_NAME,
|
|
555
|
+
get: async (names) => {
|
|
556
|
+
const parent = projectPath();
|
|
557
|
+
const secrets = names.map((name) => `${parent}/secrets/${secretId(name)}`);
|
|
558
|
+
const values = Object.create(null);
|
|
559
|
+
if (names.length === 0) return values;
|
|
560
|
+
const token = await accessToken();
|
|
561
|
+
const read = await Promise.all(secrets.map((secret) => accessLatest(token, secret)));
|
|
562
|
+
names.forEach((name, index) => {
|
|
563
|
+
values[name] = read[index]?.value;
|
|
564
|
+
});
|
|
565
|
+
return values;
|
|
566
|
+
},
|
|
567
|
+
/**
|
|
568
|
+
* Adds `value` as the secret's newest version, creating the secret when
|
|
569
|
+
* it is missing, and waits until the `latest` alias resolves to it. Then
|
|
570
|
+
* it destroys the older versions this adapter added, which the secret's
|
|
571
|
+
* `hot-updater-versions` annotation lists: versions added any other way
|
|
572
|
+
* are never touched. The permissions this needs are checked before
|
|
573
|
+
* anything is written. A version that could not be destroyed stays
|
|
574
|
+
* listed for the next `set`. The annotation is updated against the
|
|
575
|
+
* secret's etag, so a concurrent `set` loses no listed version. When
|
|
576
|
+
* `latest` does not resolve to the new version in time, or the
|
|
577
|
+
* annotation cannot be updated, `set` throws, naming the version it
|
|
578
|
+
* stored.
|
|
579
|
+
*/
|
|
580
|
+
set: async (name, value) => {
|
|
581
|
+
const parent = projectPath();
|
|
582
|
+
const id = secretId(name);
|
|
583
|
+
const secret = `${parent}/secrets/${id}`;
|
|
584
|
+
if (typeof value !== "string" || value === "") throw new Error(`${ADAPTER_NAME} cannot store an empty ${secret}.`);
|
|
585
|
+
await mask(value);
|
|
586
|
+
const token = await accessToken();
|
|
587
|
+
const current = await getOrCreateSecret(token, parent, id);
|
|
588
|
+
await assertCanSet(token, secret);
|
|
589
|
+
const data = Buffer.from(value, "utf8");
|
|
590
|
+
const added = await request(token, "POST", `${secret}:addVersion`, { payload: {
|
|
591
|
+
data: data.toString("base64"),
|
|
592
|
+
dataCrc32c: String(crc32c(data))
|
|
593
|
+
} });
|
|
594
|
+
const version = versionOf(isRecord(added.body) ? added.body.name : void 0);
|
|
595
|
+
if (!added.ok || !VERSION_ID_PATTERN.test(version)) throw requestError(`add a version to ${secret}`, added, "secretmanager.versions.add on the secret");
|
|
596
|
+
const visible = await waitForLatest(token, secret, version);
|
|
597
|
+
const destroyed = /* @__PURE__ */ new Set();
|
|
598
|
+
if (visible) {
|
|
599
|
+
const older = trackedVersions(current).filter((entry) => Number(entry) < Number(version));
|
|
600
|
+
const results = await Promise.all(older.map((entry) => destroy(token, secret, entry)));
|
|
601
|
+
older.forEach((entry, index) => {
|
|
602
|
+
if (results[index]) destroyed.add(entry);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
const notListed = `list version ${version}, which it stored, in ${secret}'s ${VERSIONS_ANNOTATION} annotation, so the next set will not destroy it`;
|
|
606
|
+
let base = current;
|
|
607
|
+
let updated;
|
|
608
|
+
try {
|
|
609
|
+
for (let attempt = 0; attempt < ANNOTATION_ATTEMPTS; attempt += 1) {
|
|
610
|
+
if (attempt > 0) {
|
|
611
|
+
await delay(LATEST_RETRY_DELAYS_MS[attempt - 1] ?? 0);
|
|
612
|
+
base = await readSecret(token, secret) ?? {};
|
|
613
|
+
}
|
|
614
|
+
const listed = [.../* @__PURE__ */ new Set([version, ...trackedVersions(base)])].filter((entry) => !destroyed.has(entry));
|
|
615
|
+
updated = await request(token, "PATCH", `${secret}?updateMask=annotations`, {
|
|
616
|
+
...base.etag === void 0 ? {} : { etag: base.etag },
|
|
617
|
+
annotations: {
|
|
618
|
+
...base.annotations,
|
|
619
|
+
[VERSIONS_ANNOTATION]: listed.join(",")
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
if (updated.error.status !== "FAILED_PRECONDITION") break;
|
|
623
|
+
}
|
|
624
|
+
} catch (error) {
|
|
625
|
+
throw new Error(`${ADAPTER_NAME} could not ${notListed} (${error instanceof Error ? error.message : String(error)}).`, { cause: error });
|
|
626
|
+
}
|
|
627
|
+
if (!updated?.ok) throw requestError(notListed, updated, "secretmanager.secrets.update on the secret");
|
|
628
|
+
if (!visible) throw new Error(`${ADAPTER_NAME} stored ${secret} as version ${version}, and its latest alias does not resolve to that version yet, so it kept the versions it added before; the next set destroys them.`);
|
|
629
|
+
},
|
|
630
|
+
describe: (name) => {
|
|
631
|
+
const id = `${prefix}${name}`;
|
|
632
|
+
return `projects/${projectId}/secrets/${id} (https://console.cloud.google.com/security/secret-manager/secret/${encodeURIComponent(id)}/versions?project=${encodeURIComponent(projectId)})`;
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
};
|
|
636
|
+
//#endregion
|
|
637
|
+
export { firebaseDatabase, firebaseStorage, migrateFirebaseDatabase, secretManager };
|
package/dist/init/index.cjs
CHANGED
|
@@ -6879,11 +6879,20 @@ const adapterOf = ({ databaseId, collection = FIREBASE_V1_COLLECTION, aggregateB
|
|
|
6879
6879
|
* after deploying the indexes.
|
|
6880
6880
|
*/
|
|
6881
6881
|
const migrateFirebaseDatabase = (config, plugins = []) => (0, _hot_updater_plugin_core.migrateCoreSchema)(adapterOf(config), "firebaseDatabase", plugins);
|
|
6882
|
+
/**
|
|
6883
|
+
* The resource this database reaches, without credentials, under the key
|
|
6884
|
+
* `hot-updater doctor` reads to tell the database `hot-updater init` saved
|
|
6885
|
+
* from another (a convention CLAUDE.md documents).
|
|
6886
|
+
*/
|
|
6887
|
+
const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
|
|
6882
6888
|
/** Hot Updater's database in one Firestore collection, through the storage engine. */
|
|
6883
|
-
const firebaseDatabase = (config) => (
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6889
|
+
const firebaseDatabase = (config) => ({
|
|
6890
|
+
...(0, _hot_updater_plugin_core.createEngineDatabase)({
|
|
6891
|
+
name: "firebaseDatabase",
|
|
6892
|
+
adapter: adapterOf(config),
|
|
6893
|
+
aggregateBatching: config.aggregateBatching ?? {}
|
|
6894
|
+
}),
|
|
6895
|
+
...config.projectId === void 0 ? {} : { [DATABASE_TARGET]: `firestore/${config.projectId}/${config.databaseId ?? "(default)"}` }
|
|
6887
6896
|
});
|
|
6888
6897
|
//#endregion
|
|
6889
6898
|
//#region iac/init/index.ts
|
package/dist/init/index.mjs
CHANGED
|
@@ -6872,11 +6872,20 @@ const adapterOf = ({ databaseId, collection = FIREBASE_V1_COLLECTION, aggregateB
|
|
|
6872
6872
|
* after deploying the indexes.
|
|
6873
6873
|
*/
|
|
6874
6874
|
const migrateFirebaseDatabase = (config, plugins = []) => migrateCoreSchema(adapterOf(config), "firebaseDatabase", plugins);
|
|
6875
|
+
/**
|
|
6876
|
+
* The resource this database reaches, without credentials, under the key
|
|
6877
|
+
* `hot-updater doctor` reads to tell the database `hot-updater init` saved
|
|
6878
|
+
* from another (a convention CLAUDE.md documents).
|
|
6879
|
+
*/
|
|
6880
|
+
const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
|
|
6875
6881
|
/** Hot Updater's database in one Firestore collection, through the storage engine. */
|
|
6876
|
-
const firebaseDatabase = (config) =>
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6882
|
+
const firebaseDatabase = (config) => ({
|
|
6883
|
+
...createEngineDatabase({
|
|
6884
|
+
name: "firebaseDatabase",
|
|
6885
|
+
adapter: adapterOf(config),
|
|
6886
|
+
aggregateBatching: config.aggregateBatching ?? {}
|
|
6887
|
+
}),
|
|
6888
|
+
...config.projectId === void 0 ? {} : { [DATABASE_TARGET]: `firestore/${config.projectId}/${config.databaseId ?? "(default)"}` }
|
|
6880
6889
|
});
|
|
6881
6890
|
//#endregion
|
|
6882
6891
|
//#region iac/init/index.ts
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/firebase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0-rc.0",
|
|
5
5
|
"description": "React Native OTA solution for self-hosted",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
7
7
|
"types": "dist/index.d.cts",
|
|
@@ -37,10 +37,11 @@
|
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"hono": "4.12.34",
|
|
40
|
-
"
|
|
41
|
-
"@hot-updater/
|
|
42
|
-
"@hot-updater/plugin-core": "1.
|
|
43
|
-
"@hot-updater/
|
|
40
|
+
"google-auth-library": "10.7.0",
|
|
41
|
+
"@hot-updater/cli-tools": "1.2.0-rc.0",
|
|
42
|
+
"@hot-updater/plugin-core": "1.2.0-rc.0",
|
|
43
|
+
"@hot-updater/protocol": "1.2.0-rc.0",
|
|
44
|
+
"@hot-updater/server": "1.2.0-rc.0"
|
|
44
45
|
},
|
|
45
46
|
"publishConfig": {
|
|
46
47
|
"access": "public"
|
|
@@ -55,8 +56,8 @@
|
|
|
55
56
|
"firebase-tools": "15.29.0",
|
|
56
57
|
"fkill": "^9.0.0",
|
|
57
58
|
"mime": "^4.0.4",
|
|
58
|
-
"@hot-updater/mock": "1.
|
|
59
|
-
"@hot-updater/test-utils": "1.
|
|
59
|
+
"@hot-updater/mock": "1.2.0-rc.0",
|
|
60
|
+
"@hot-updater/test-utils": "1.2.0-rc.0"
|
|
60
61
|
},
|
|
61
62
|
"peerDependencies": {
|
|
62
63
|
"firebase-admin": ">=11.10.0",
|