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