@hot-updater/firebase 1.1.4 → 1.2.0-rc.1

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/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) => (0, _hot_updater_plugin_core.createEngineDatabase)({
188
- name: "firebaseDatabase",
189
- adapter: adapterOf(config),
190
- aggregateBatching: config.aggregateBatching ?? {}
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
- const bucket = (0, firebase_admin_storage.getStorage)(app).bucket(config.storageBucket);
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 bytes = new Uint8Array(await new Response(body).arrayBuffer());
243
- await bucket.file(storageKey).save(bytes, { metadata: {
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
- export { FirebaseDatabaseConfig, FirebaseStorageConfig, firebaseDatabase, firebaseStorage, migrateFirebaseDatabase };
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
- export { FirebaseDatabaseConfig, FirebaseStorageConfig, firebaseDatabase, firebaseStorage, migrateFirebaseDatabase };
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 };