@hot-updater/azure 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.
@@ -1,5 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { EventEmitter } from "events";
3
+ import Stream, { Duplex, Readable, Transform } from "node:stream";
3
4
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
4
5
  import process$1 from "node:process";
5
6
  import os, { EOL } from "node:os";
@@ -7,7 +8,6 @@ import util, { inspect } from "node:util";
7
8
  import http from "node:http";
8
9
  import https from "node:https";
9
10
  import zlib from "node:zlib";
10
- import Stream, { Duplex, Readable, Transform } from "node:stream";
11
11
  import { Readable as Readable$1 } from "stream";
12
12
  import buffer from "node:buffer";
13
13
  import { Buffer as Buffer$1 } from "buffer";
@@ -8118,7 +8118,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
8118
8118
  };
8119
8119
  //#endregion
8120
8120
  //#region ../../packages/server/dist/version.mjs
8121
- const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.7";
8121
+ const HOT_UPDATER_SERVER_VERSION = "1.2.0-rc.9";
8122
8122
  //#endregion
8123
8123
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
8124
8124
  const createVersionRouteHandlers = (plugins = []) => {
@@ -57555,7 +57555,6 @@ function azureBlobStorage(config) {
57555
57555
  const headers = new Headers();
57556
57556
  if (download.contentType) headers.set("content-type", download.contentType);
57557
57557
  if (download.contentLength !== void 0) headers.set("content-length", String(download.contentLength));
57558
- const { Readable } = await import("node:stream");
57559
57558
  return { response: new Response(Readable.toWeb(body), { headers }) };
57560
57559
  } catch (error) {
57561
57560
  if (isNotFound(error)) return { response: null };
package/dist/index.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_stream = require("node:stream");
2
3
  let _azure_core_auth = require("@azure/core-auth");
3
4
  let _azure_storage_blob = require("@azure/storage-blob");
4
5
  let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
@@ -158,8 +159,7 @@ function azureBlobStorage(config) {
158
159
  const headers = new Headers();
159
160
  if (download.contentType) headers.set("content-type", download.contentType);
160
161
  if (download.contentLength !== void 0) headers.set("content-length", String(download.contentLength));
161
- const { Readable } = await import("node:stream");
162
- return { response: new Response(Readable.toWeb(body), { headers }) };
162
+ return { response: new Response(node_stream.Readable.toWeb(body), { headers }) };
163
163
  } catch (error) {
164
164
  if (isNotFound(error)) return { response: null };
165
165
  throw error;
@@ -293,7 +293,7 @@ const TRANSIENT = /* @__PURE__ */ new Set([
293
293
  503
294
294
  ]);
295
295
  /** An error's HTTP status, or its cause's: the SDK wraps some answers, such as a failed batch's. */
296
- const statusOf = (error) => {
296
+ const statusOf$1 = (error) => {
297
297
  for (let at = error; at !== null && typeof at === "object"; at = at.cause) {
298
298
  const { statusCode, code } = at;
299
299
  const status = statusCode ?? code;
@@ -302,7 +302,7 @@ const statusOf = (error) => {
302
302
  };
303
303
  /** A missing database or container reads as a missing schema, like SQL's 42P01. */
304
304
  const missingContainer = (error) => {
305
- throw statusOf(error) === 404 ? Object.assign(new Error(error.message, { cause: error }), { code: "42P01" }) : error;
305
+ throw statusOf$1(error) === 404 ? Object.assign(new Error(error.message, { cause: error }), { code: "42P01" }) : error;
306
306
  };
307
307
  /** The header of a container read that reports its index transformation, in percent. */
308
308
  const INDEX_TRANSFORMATION_PROGRESS = "x-ms-documentdb-collection-index-transformation-progress";
@@ -380,7 +380,7 @@ const isWideAdd = (op) => op.type === "add" && Object.keys(op.by).length + (op.e
380
380
  * another one with these errors, having written nothing. Cosmos DB itself
381
381
  * runs the batches of a partition one at a time and never reports them.
382
382
  */
383
- const emulatorAborted = (error) => statusOf(error) === 500 && /PostgresError\(SqlState\(E(25P02|40001|40P01)\)/.test(String(error instanceof Error ? error.message : error));
383
+ const emulatorAborted = (error) => statusOf$1(error) === 500 && /PostgresError\(SqlState\(E(25P02|40001|40P01)\)/.test(String(error instanceof Error ? error.message : error));
384
384
  /**
385
385
  * The key-value store over one Cosmos DB container of `{ pk, sk, row }`
386
386
  * items in one logical partition, read through the composite index on
@@ -641,7 +641,7 @@ const createCosmosStore = ({ client, databaseId, containerId, engine, nativePage
641
641
  results = response.result;
642
642
  } catch (error) {
643
643
  if (emulatorAborted(error)) continue;
644
- if (TRANSIENT.has(statusOf(error))) return {
644
+ if (TRANSIENT.has(statusOf$1(error))) return {
645
645
  ok: false,
646
646
  retry: true
647
647
  };
@@ -675,7 +675,7 @@ const createCosmosStore = ({ client, databaseId, containerId, engine, nativePage
675
675
  id,
676
676
  partitionKey: COSMOS_PARTITION
677
677
  }))).catch(missingContainer)).flatMap((result, at) => {
678
- const status = result.response?.statusCode ?? statusOf(result.error);
678
+ const status = result.response?.statusCode ?? statusOf$1(result.error);
679
679
  return typeof status === "number" && status < 400 || status === 404 ? [] : [{
680
680
  id: remaining[at],
681
681
  error: result.error,
@@ -793,6 +793,254 @@ const cosmosDatabase = (config) => {
793
793
  };
794
794
  };
795
795
  //#endregion
796
+ //#region src/keyVault.ts
797
+ const NAME = "keyVault";
798
+ const VAULT_NAME_PATTERN = /^[A-Za-z](?!.*--)[A-Za-z0-9-]{1,22}[A-Za-z0-9]$/u;
799
+ /** The characters Key Vault takes in a secret's name. */
800
+ const PREFIX_PATTERN = /^[A-Za-z0-9-]*$/u;
801
+ /** Names Key Vault keeps as they are: lower case, digits, single hyphens. */
802
+ const PLAIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
803
+ const MAX_SECRET_NAME_LENGTH = 127;
804
+ /** Key Vault's limit on a value, in characters: it refuses one more. */
805
+ const MAX_SECRET_LENGTH = 25600;
806
+ /** The tag that keeps the name a secret holds, which a mapped name hides. */
807
+ const NAME_TAG = "hot-updater-name";
808
+ /** Errors of `@azure/identity` finding no identity, or failing to sign in. */
809
+ const NO_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set([
810
+ "AggregateAuthenticationError",
811
+ "AuthenticationRequiredError",
812
+ "CredentialUnavailableError"
813
+ ]);
814
+ const SIGN_IN_HINT = [
815
+ "In CI, sign in with azure/login.",
816
+ "On your machine, run az login.",
817
+ "Or set AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET."
818
+ ];
819
+ /** The widest line a message has: the CLI prints no wider. */
820
+ const LINE_WIDTH = 76;
821
+ /** `text`, such as Azure's own message, in lines that fit, broken at spaces. */
822
+ const wrap = (text) => {
823
+ const lines = [];
824
+ let line = "";
825
+ for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
826
+ lines.push(line);
827
+ line = word;
828
+ } else line = line ? `${line} ${word}` : word;
829
+ if (line) lines.push(line);
830
+ return lines;
831
+ };
832
+ /** An error of one line each, for a terminal. */
833
+ const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
834
+ const READ_ROLE = "Key Vault Secrets User";
835
+ const WRITE_ROLE = "Key Vault Secrets Officer";
836
+ const errorName = (error) => error instanceof Error ? error.name : "";
837
+ /** The message's first line: Key Vault's next lines name the caller's IDs. */
838
+ const errorMessage = (error) => ((error instanceof Error ? error.message : String(error)).split(/\r?\n/u, 1)[0] ?? "").replace(/\.+$/u, "");
839
+ const innerCode = (error) => {
840
+ const inner = error?.details?.error;
841
+ return inner?.innerError?.code ?? inner?.innererror?.code;
842
+ };
843
+ const statusOf = (error) => error?.statusCode;
844
+ const isLoopback = (hostname) => (/* @__PURE__ */ new Set([
845
+ "localhost",
846
+ "127.0.0.1",
847
+ "[::1]"
848
+ ])).has(hostname);
849
+ const resolveVaultUrl = (vaultName, vaultUrl) => {
850
+ if (vaultName === void 0 === (vaultUrl === void 0)) throw new Error(`${NAME} needs a vaultName or a vaultUrl, and not both.`);
851
+ if (vaultName !== void 0) {
852
+ if (typeof vaultName !== "string" || !VAULT_NAME_PATTERN.test(vaultName)) throw failure([`${NAME} can't use the vault name ${JSON.stringify(vaultName)}.`, "Use 3 to 24 letters, digits, and single hyphens; start with a letter."]);
853
+ return new URL(`https://${vaultName.toLowerCase()}.vault.azure.net`);
854
+ }
855
+ let url;
856
+ try {
857
+ url = new URL(String(vaultUrl).trim());
858
+ } catch {
859
+ throw new Error(`${NAME} vaultUrl must be a valid URL.`);
860
+ }
861
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "") throw failure([
862
+ `${NAME} can't use its vaultUrl.`,
863
+ "Use an HTTPS origin, such as https://my-vault.vault.azure.net.",
864
+ "Leave out credentials, a path, a query, and a fragment."
865
+ ]);
866
+ return url;
867
+ };
868
+ /** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
869
+ const mask = async (values) => {
870
+ const { registerSecretValue } = await import("@hot-updater/cli-tools");
871
+ for (const value of values) if (value) registerSecretValue(value);
872
+ };
873
+ /** `credential`, with each access token it returns masked. */
874
+ const maskTokens = (credential) => ({ getToken: async (scopes, options) => {
875
+ const token = await credential.getToken(scopes, options);
876
+ await mask([token?.token]);
877
+ return token;
878
+ } });
879
+ /**
880
+ * The Key Vault secret name of `name`, after `prefix`. Key Vault takes
881
+ * only letters, digits, and `-` in a name, and matches names without
882
+ * regard to case, so:
883
+ *
884
+ * - a name of lower-case letters and digits in words joined by single
885
+ * hyphens, such as `asc-api-key`, is kept as it is;
886
+ * - any other name, such as `CLOUDFLARE_API_TOKEN` or `upload.keystore`,
887
+ * becomes its lower-cased words joined by `-`, then `--` and the first 10
888
+ * hex digits of the SHA-256 of the exact name:
889
+ * `cloudflare-api-token--<hash>`.
890
+ *
891
+ * A kept name never holds `--` and a mapped one always does, so no two
892
+ * names share a secret, and `CLOUDFLARE_API_TOKEN` and
893
+ * `cloudflare-api-token` stay apart. Each secret `set` stores is tagged
894
+ * `hot-updater-name` with the name it holds.
895
+ */
896
+ const keyVaultSecretName = (name, prefix = "hot-updater-") => {
897
+ if (PLAIN_NAME_PATTERN.test(name)) return `${prefix}${name}`;
898
+ return `${prefix}${name.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "")}--${(0, node_crypto.createHash)("sha256").update(name).digest("hex").slice(0, 10)}`;
899
+ };
900
+ /**
901
+ * Secrets from Azure Key Vault: each name is the secret `<prefix><name>`
902
+ * of the vault, `hot-updater-asc-api-key` for `asc-api-key`, with any
903
+ * other name mapped as `keyVaultSecretName` says, read at its current
904
+ * version. `set` adds a new version of
905
+ * the secret, which creates it when it does not exist; Key Vault keeps the
906
+ * older versions. A name whose secret was deleted and is still recoverable
907
+ * reads as missing, and `set` on it fails with the `az` commands that
908
+ * recover or purge it. `@azure/keyvault-secrets`, and `@azure/identity`
909
+ * without a `credential`, load on the first read or write, so a config
910
+ * that only uses `azureBlobStorage` never loads them.
911
+ *
912
+ * @example
913
+ * secrets: keyVault({ vaultName: "my-vault" })
914
+ */
915
+ const keyVault = ({ credential, prefix = "hot-updater-", vaultName, vaultUrl }) => {
916
+ const vault = () => {
917
+ const url = resolveVaultUrl(vaultName, vaultUrl);
918
+ if (typeof prefix !== "string" || !PREFIX_PATTERN.test(prefix)) throw new Error(`${NAME}'s prefix takes letters, digits, and - only.`);
919
+ return url;
920
+ };
921
+ const secretName = (name) => {
922
+ vault();
923
+ const id = keyVaultSecretName(name, prefix);
924
+ if (id.length > MAX_SECRET_NAME_LENGTH) throw failure([`${NAME} can't name a secret ${JSON.stringify(id)} for ${JSON.stringify(name)}.`, `Key Vault takes up to ${MAX_SECRET_NAME_LENGTH} characters.`]);
925
+ return id;
926
+ };
927
+ /** The vault's name, for the az commands messages suggest. */
928
+ const vaultLabel = () => vault().hostname.split(".")[0];
929
+ let opened;
930
+ const open = () => {
931
+ const url = vault();
932
+ if (credential !== void 0 && !(0, _azure_core_auth.isTokenCredential)(credential)) throw new Error(`${NAME} credential must be a TokenCredential, such as one from @azure/identity.`);
933
+ opened ??= Promise.all([import("@azure/keyvault-secrets"), credential ?? import("@azure/identity").then(({ DefaultAzureCredential }) => new DefaultAzureCredential())]).then(([sdk, signWith]) => new sdk.SecretClient(url.origin, maskTokens(signWith), { disableChallengeResourceVerification: isLoopback(url.hostname) })).catch((error) => {
934
+ opened = void 0;
935
+ throw error;
936
+ });
937
+ return opened;
938
+ };
939
+ const describeFailure = (verb, id, error, role) => {
940
+ const name = errorName(error);
941
+ const status = statusOf(error);
942
+ const code = innerCode(error) ?? error?.code;
943
+ const detail = wrap(`${code || name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}.`);
944
+ const secret = `Secret: ${id}`;
945
+ if (NO_CREDENTIAL_ERRORS.has(name)) return failure([
946
+ `${NAME} found no Azure identity to ${verb} a secret.`,
947
+ secret,
948
+ ...wrap(`${errorMessage(error)}.`),
949
+ ...SIGN_IN_HINT
950
+ ], error);
951
+ if (status === 401) return failure([
952
+ `Azure Key Vault refused the sign-in to ${verb} a secret.`,
953
+ secret,
954
+ ...detail,
955
+ "Sign in again.",
956
+ ...SIGN_IN_HINT
957
+ ], error);
958
+ if (code === "SecretDisabled") return failure([
959
+ `Azure Key Vault can't ${verb} a secret.`,
960
+ secret,
961
+ "Its current version is disabled. Enable it, or store a new value."
962
+ ], error);
963
+ if (status === 403) return failure([
964
+ `Azure Key Vault refused to ${verb} a secret.`,
965
+ secret,
966
+ ...detail,
967
+ `The Azure identity needs the ${role} role on the vault.`,
968
+ `With access policies, it needs the secret permissions ${role === READ_ROLE ? "get" : "get and set"}.`,
969
+ "A firewall may also refuse this network."
970
+ ], error);
971
+ if (code === "ObjectIsBeingDeleted" || /being deleted/iu.test(errorMessage(error))) return failure([
972
+ `Azure Key Vault can't ${verb} a secret: it is being deleted.`,
973
+ secret,
974
+ "Try again in a minute."
975
+ ], error);
976
+ if (status === 409 || code === "ObjectIsDeletedButRecoverable") {
977
+ const vaultName = vaultLabel();
978
+ return failure([
979
+ `Azure Key Vault can't ${verb} a secret: it was deleted.`,
980
+ secret,
981
+ "Recover it, or purge it:",
982
+ `az keyvault secret recover --vault-name ${vaultName} \\`,
983
+ ` --name ${id}`,
984
+ `az keyvault secret purge --vault-name ${vaultName} \\`,
985
+ ` --name ${id}`
986
+ ], error);
987
+ }
988
+ return failure([
989
+ `Azure Key Vault failed to ${verb} a secret.`,
990
+ secret,
991
+ ...detail
992
+ ], error);
993
+ };
994
+ const read = async (client, id) => {
995
+ let value;
996
+ try {
997
+ ({value} = await client.getSecret(id));
998
+ } catch (error) {
999
+ if (statusOf(error) === 404 && /NotFound/u.test(error.code ?? "") && !/disabled/iu.test(errorMessage(error))) return;
1000
+ throw describeFailure("read", id, error, READ_ROLE);
1001
+ }
1002
+ await mask([value]);
1003
+ return value;
1004
+ };
1005
+ return {
1006
+ name: NAME,
1007
+ get: async (names) => {
1008
+ const unique = [...new Set(names)];
1009
+ const ids = unique.map(secretName);
1010
+ const result = Object.create(null);
1011
+ if (unique.length === 0) return result;
1012
+ const client = await open();
1013
+ const values = await Promise.all(ids.map((id) => read(client, id)));
1014
+ unique.forEach((name, index) => {
1015
+ result[name] = values[index];
1016
+ });
1017
+ return result;
1018
+ },
1019
+ set: async (name, value) => {
1020
+ const id = secretName(name);
1021
+ if (typeof value !== "string" || value === "") throw new Error(`${NAME} can't store an empty ${id}.`);
1022
+ if (value.length > MAX_SECRET_LENGTH) throw failure([`${NAME} can't store ${id}: it has ${value.length} characters.`, `Key Vault takes up to ${MAX_SECRET_LENGTH} characters per secret.`]);
1023
+ await mask([value]);
1024
+ const client = await open();
1025
+ try {
1026
+ await client.setSecret(id, value, { tags: { [NAME_TAG]: name } });
1027
+ } catch (error) {
1028
+ throw describeFailure("write", id, error, WRITE_ROLE);
1029
+ }
1030
+ },
1031
+ describe: (name) => {
1032
+ let url;
1033
+ try {
1034
+ url = vault().origin;
1035
+ } catch {
1036
+ url = String(vaultUrl ?? vaultName);
1037
+ }
1038
+ return `the secret ${JSON.stringify(keyVaultSecretName(name, prefix))} in Azure Key Vault (${url})`;
1039
+ }
1040
+ };
1041
+ };
1042
+ //#endregion
796
1043
  exports.azureBlobStorage = azureBlobStorage;
797
1044
  exports.cosmosDatabase = cosmosDatabase;
1045
+ exports.keyVault = keyVault;
798
1046
  exports.migrateCosmosDatabase = migrateCosmosDatabase;
package/dist/index.d.cts CHANGED
@@ -1,16 +1,16 @@
1
1
  import { TokenCredential } from "@azure/core-auth";
2
2
  import { StorageSharedKeyCredential } from "@azure/storage-blob";
3
- import { EngineDatabase, PluginTables, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
3
+ import { EngineDatabase, Lazy, PluginTables, SecretsAdapter, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
4
4
  import { CosmosClientOptions } from "@azure/cosmos";
5
5
  //#region src/azureBlobStorage.d.ts
6
6
  /** The account to connect to: its connection string, or its Blob endpoint and a credential. */
7
7
  type AzureBlobConnection = {
8
8
  /**
9
9
  * The account's connection string, or a function that returns it,
10
- * such as one that reads it with `secretEnv`: called on first use, so
11
- * a config loads without it.
10
+ * such as `secret("<name>")` in `hot-updater.config.ts`: called on
11
+ * first use, so a config loads without it.
12
12
  */
13
- readonly connectionString: string | (() => string | Promise<string>);
13
+ readonly connectionString: Lazy<string>;
14
14
  } | {
15
15
  /** The Blob service endpoint, such as `https://<account>.blob.core.windows.net`. */
16
16
  readonly accountUrl: string;
@@ -46,11 +46,11 @@ declare function azureBlobStorage(config: AzureBlobStorageConfig): StorageAdapte
46
46
  type CosmosDatabaseConfig = Omit<CosmosClientOptions, "consistencyLevel" | "connectionString"> & {
47
47
  /**
48
48
  * The account's connection string, or a function that returns it, such as
49
- * one that reads it with `secretEnv`: called on the first request, so a
50
- * config loads without it. `endpoint` beside a function names the
51
- * account to `hot-updater doctor`.
49
+ * `secret("<name>")` in `hot-updater.config.ts`: called on the first
50
+ * request, so a config loads without it. `endpoint` beside a function
51
+ * names the account to `hot-updater doctor`.
52
52
  */
53
- readonly connectionString?: string | (() => string | Promise<string>);
53
+ readonly connectionString?: Lazy<string>;
54
54
  readonly consistencyLevel?: "Strong";
55
55
  /** The database the container lives in. @default "hot-updater" */
56
56
  readonly databaseId?: string;
@@ -80,4 +80,51 @@ declare const migrateCosmosDatabase: (config: CosmosDatabaseConfig, plugins?: re
80
80
  */
81
81
  declare const cosmosDatabase: (config: CosmosDatabaseConfig) => EngineDatabase;
82
82
  //#endregion
83
- export { AzureBlobConnection, AzureBlobStorageConfig, CosmosDatabaseConfig, azureBlobStorage, cosmosDatabase, migrateCosmosDatabase };
83
+ //#region src/keyVault.d.ts
84
+ interface KeyVaultOptions {
85
+ /**
86
+ * The vault's name, which reads `https://<vaultName>.vault.azure.net`.
87
+ * Set it or `vaultUrl`.
88
+ */
89
+ readonly vaultName?: string;
90
+ /**
91
+ * The vault's URL, such as `https://my-vault.vault.azure.net`, for a
92
+ * vault outside Azure's public cloud. An HTTPS URL on a loopback address,
93
+ * such as a local emulator's, skips the check that the sign-in challenge
94
+ * names the vault's domain.
95
+ */
96
+ readonly vaultUrl?: string;
97
+ /**
98
+ * Put before each name to make its secret's name, so `secret("asc-api-key")`
99
+ * reads the secret `hot-updater-asc-api-key`. Letters, digits, and `-`.
100
+ *
101
+ * @default "hot-updater-"
102
+ */
103
+ readonly prefix?: string;
104
+ /**
105
+ * The Microsoft Entra identity to sign in with, such as a
106
+ * `ClientSecretCredential` from `@azure/identity`. Without it,
107
+ * `DefaultAzureCredential`: `AZURE_CLIENT_ID` and its siblings, or the
108
+ * federated token `azure/login` leaves in CI, then `az login` on your
109
+ * machine, or a managed identity.
110
+ */
111
+ readonly credential?: TokenCredential;
112
+ }
113
+ /**
114
+ * Secrets from Azure Key Vault: each name is the secret `<prefix><name>`
115
+ * of the vault, `hot-updater-asc-api-key` for `asc-api-key`, with any
116
+ * other name mapped as `keyVaultSecretName` says, read at its current
117
+ * version. `set` adds a new version of
118
+ * the secret, which creates it when it does not exist; Key Vault keeps the
119
+ * older versions. A name whose secret was deleted and is still recoverable
120
+ * reads as missing, and `set` on it fails with the `az` commands that
121
+ * recover or purge it. `@azure/keyvault-secrets`, and `@azure/identity`
122
+ * without a `credential`, load on the first read or write, so a config
123
+ * that only uses `azureBlobStorage` never loads them.
124
+ *
125
+ * @example
126
+ * secrets: keyVault({ vaultName: "my-vault" })
127
+ */
128
+ declare const keyVault: ({ credential, prefix, vaultName, vaultUrl }: KeyVaultOptions) => SecretsAdapter;
129
+ //#endregion
130
+ export { AzureBlobConnection, AzureBlobStorageConfig, CosmosDatabaseConfig, KeyVaultOptions, azureBlobStorage, cosmosDatabase, keyVault, migrateCosmosDatabase };
package/dist/index.d.mts CHANGED
@@ -1,16 +1,16 @@
1
1
  import { TokenCredential } from "@azure/core-auth";
2
2
  import { StorageSharedKeyCredential } from "@azure/storage-blob";
3
- import { EngineDatabase, PluginTables, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
3
+ import { EngineDatabase, Lazy, PluginTables, SecretsAdapter, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
4
4
  import { CosmosClientOptions } from "@azure/cosmos";
5
5
  //#region src/azureBlobStorage.d.ts
6
6
  /** The account to connect to: its connection string, or its Blob endpoint and a credential. */
7
7
  type AzureBlobConnection = {
8
8
  /**
9
9
  * The account's connection string, or a function that returns it,
10
- * such as one that reads it with `secretEnv`: called on first use, so
11
- * a config loads without it.
10
+ * such as `secret("<name>")` in `hot-updater.config.ts`: called on
11
+ * first use, so a config loads without it.
12
12
  */
13
- readonly connectionString: string | (() => string | Promise<string>);
13
+ readonly connectionString: Lazy<string>;
14
14
  } | {
15
15
  /** The Blob service endpoint, such as `https://<account>.blob.core.windows.net`. */
16
16
  readonly accountUrl: string;
@@ -46,11 +46,11 @@ declare function azureBlobStorage(config: AzureBlobStorageConfig): StorageAdapte
46
46
  type CosmosDatabaseConfig = Omit<CosmosClientOptions, "consistencyLevel" | "connectionString"> & {
47
47
  /**
48
48
  * The account's connection string, or a function that returns it, such as
49
- * one that reads it with `secretEnv`: called on the first request, so a
50
- * config loads without it. `endpoint` beside a function names the
51
- * account to `hot-updater doctor`.
49
+ * `secret("<name>")` in `hot-updater.config.ts`: called on the first
50
+ * request, so a config loads without it. `endpoint` beside a function
51
+ * names the account to `hot-updater doctor`.
52
52
  */
53
- readonly connectionString?: string | (() => string | Promise<string>);
53
+ readonly connectionString?: Lazy<string>;
54
54
  readonly consistencyLevel?: "Strong";
55
55
  /** The database the container lives in. @default "hot-updater" */
56
56
  readonly databaseId?: string;
@@ -80,4 +80,51 @@ declare const migrateCosmosDatabase: (config: CosmosDatabaseConfig, plugins?: re
80
80
  */
81
81
  declare const cosmosDatabase: (config: CosmosDatabaseConfig) => EngineDatabase;
82
82
  //#endregion
83
- export { AzureBlobConnection, AzureBlobStorageConfig, CosmosDatabaseConfig, azureBlobStorage, cosmosDatabase, migrateCosmosDatabase };
83
+ //#region src/keyVault.d.ts
84
+ interface KeyVaultOptions {
85
+ /**
86
+ * The vault's name, which reads `https://<vaultName>.vault.azure.net`.
87
+ * Set it or `vaultUrl`.
88
+ */
89
+ readonly vaultName?: string;
90
+ /**
91
+ * The vault's URL, such as `https://my-vault.vault.azure.net`, for a
92
+ * vault outside Azure's public cloud. An HTTPS URL on a loopback address,
93
+ * such as a local emulator's, skips the check that the sign-in challenge
94
+ * names the vault's domain.
95
+ */
96
+ readonly vaultUrl?: string;
97
+ /**
98
+ * Put before each name to make its secret's name, so `secret("asc-api-key")`
99
+ * reads the secret `hot-updater-asc-api-key`. Letters, digits, and `-`.
100
+ *
101
+ * @default "hot-updater-"
102
+ */
103
+ readonly prefix?: string;
104
+ /**
105
+ * The Microsoft Entra identity to sign in with, such as a
106
+ * `ClientSecretCredential` from `@azure/identity`. Without it,
107
+ * `DefaultAzureCredential`: `AZURE_CLIENT_ID` and its siblings, or the
108
+ * federated token `azure/login` leaves in CI, then `az login` on your
109
+ * machine, or a managed identity.
110
+ */
111
+ readonly credential?: TokenCredential;
112
+ }
113
+ /**
114
+ * Secrets from Azure Key Vault: each name is the secret `<prefix><name>`
115
+ * of the vault, `hot-updater-asc-api-key` for `asc-api-key`, with any
116
+ * other name mapped as `keyVaultSecretName` says, read at its current
117
+ * version. `set` adds a new version of
118
+ * the secret, which creates it when it does not exist; Key Vault keeps the
119
+ * older versions. A name whose secret was deleted and is still recoverable
120
+ * reads as missing, and `set` on it fails with the `az` commands that
121
+ * recover or purge it. `@azure/keyvault-secrets`, and `@azure/identity`
122
+ * without a `credential`, load on the first read or write, so a config
123
+ * that only uses `azureBlobStorage` never loads them.
124
+ *
125
+ * @example
126
+ * secrets: keyVault({ vaultName: "my-vault" })
127
+ */
128
+ declare const keyVault: ({ credential, prefix, vaultName, vaultUrl }: KeyVaultOptions) => SecretsAdapter;
129
+ //#endregion
130
+ export { AzureBlobConnection, AzureBlobStorageConfig, CosmosDatabaseConfig, KeyVaultOptions, azureBlobStorage, cosmosDatabase, keyVault, migrateCosmosDatabase };
package/dist/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { Readable } from "node:stream";
1
2
  import { isTokenCredential } from "@azure/core-auth";
2
3
  import { BlobSASPermissions, BlobServiceClient, RestError, StorageSharedKeyCredential, generateBlobSASQueryParameters } from "@azure/storage-blob";
3
4
  import { SETTINGS_TABLE, createEngineDatabase, createKvAdapter, createStorageAdapter, createStorageKeyBuilder, createStorageUri, encodeKvKey, migrateCoreSchema, parseStorageUri, toolingTargetOf } from "@hot-updater/plugin-core";
@@ -157,7 +158,6 @@ function azureBlobStorage(config) {
157
158
  const headers = new Headers();
158
159
  if (download.contentType) headers.set("content-type", download.contentType);
159
160
  if (download.contentLength !== void 0) headers.set("content-length", String(download.contentLength));
160
- const { Readable } = await import("node:stream");
161
161
  return { response: new Response(Readable.toWeb(body), { headers }) };
162
162
  } catch (error) {
163
163
  if (isNotFound(error)) return { response: null };
@@ -292,7 +292,7 @@ const TRANSIENT = /* @__PURE__ */ new Set([
292
292
  503
293
293
  ]);
294
294
  /** An error's HTTP status, or its cause's: the SDK wraps some answers, such as a failed batch's. */
295
- const statusOf = (error) => {
295
+ const statusOf$1 = (error) => {
296
296
  for (let at = error; at !== null && typeof at === "object"; at = at.cause) {
297
297
  const { statusCode, code } = at;
298
298
  const status = statusCode ?? code;
@@ -301,7 +301,7 @@ const statusOf = (error) => {
301
301
  };
302
302
  /** A missing database or container reads as a missing schema, like SQL's 42P01. */
303
303
  const missingContainer = (error) => {
304
- throw statusOf(error) === 404 ? Object.assign(new Error(error.message, { cause: error }), { code: "42P01" }) : error;
304
+ throw statusOf$1(error) === 404 ? Object.assign(new Error(error.message, { cause: error }), { code: "42P01" }) : error;
305
305
  };
306
306
  /** The header of a container read that reports its index transformation, in percent. */
307
307
  const INDEX_TRANSFORMATION_PROGRESS = "x-ms-documentdb-collection-index-transformation-progress";
@@ -379,7 +379,7 @@ const isWideAdd = (op) => op.type === "add" && Object.keys(op.by).length + (op.e
379
379
  * another one with these errors, having written nothing. Cosmos DB itself
380
380
  * runs the batches of a partition one at a time and never reports them.
381
381
  */
382
- const emulatorAborted = (error) => statusOf(error) === 500 && /PostgresError\(SqlState\(E(25P02|40001|40P01)\)/.test(String(error instanceof Error ? error.message : error));
382
+ const emulatorAborted = (error) => statusOf$1(error) === 500 && /PostgresError\(SqlState\(E(25P02|40001|40P01)\)/.test(String(error instanceof Error ? error.message : error));
383
383
  /**
384
384
  * The key-value store over one Cosmos DB container of `{ pk, sk, row }`
385
385
  * items in one logical partition, read through the composite index on
@@ -640,7 +640,7 @@ const createCosmosStore = ({ client, databaseId, containerId, engine, nativePage
640
640
  results = response.result;
641
641
  } catch (error) {
642
642
  if (emulatorAborted(error)) continue;
643
- if (TRANSIENT.has(statusOf(error))) return {
643
+ if (TRANSIENT.has(statusOf$1(error))) return {
644
644
  ok: false,
645
645
  retry: true
646
646
  };
@@ -674,7 +674,7 @@ const createCosmosStore = ({ client, databaseId, containerId, engine, nativePage
674
674
  id,
675
675
  partitionKey: COSMOS_PARTITION
676
676
  }))).catch(missingContainer)).flatMap((result, at) => {
677
- const status = result.response?.statusCode ?? statusOf(result.error);
677
+ const status = result.response?.statusCode ?? statusOf$1(result.error);
678
678
  return typeof status === "number" && status < 400 || status === 404 ? [] : [{
679
679
  id: remaining[at],
680
680
  error: result.error,
@@ -792,4 +792,251 @@ const cosmosDatabase = (config) => {
792
792
  };
793
793
  };
794
794
  //#endregion
795
- export { azureBlobStorage, cosmosDatabase, migrateCosmosDatabase };
795
+ //#region src/keyVault.ts
796
+ const NAME = "keyVault";
797
+ const VAULT_NAME_PATTERN = /^[A-Za-z](?!.*--)[A-Za-z0-9-]{1,22}[A-Za-z0-9]$/u;
798
+ /** The characters Key Vault takes in a secret's name. */
799
+ const PREFIX_PATTERN = /^[A-Za-z0-9-]*$/u;
800
+ /** Names Key Vault keeps as they are: lower case, digits, single hyphens. */
801
+ const PLAIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
802
+ const MAX_SECRET_NAME_LENGTH = 127;
803
+ /** Key Vault's limit on a value, in characters: it refuses one more. */
804
+ const MAX_SECRET_LENGTH = 25600;
805
+ /** The tag that keeps the name a secret holds, which a mapped name hides. */
806
+ const NAME_TAG = "hot-updater-name";
807
+ /** Errors of `@azure/identity` finding no identity, or failing to sign in. */
808
+ const NO_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set([
809
+ "AggregateAuthenticationError",
810
+ "AuthenticationRequiredError",
811
+ "CredentialUnavailableError"
812
+ ]);
813
+ const SIGN_IN_HINT = [
814
+ "In CI, sign in with azure/login.",
815
+ "On your machine, run az login.",
816
+ "Or set AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET."
817
+ ];
818
+ /** The widest line a message has: the CLI prints no wider. */
819
+ const LINE_WIDTH = 76;
820
+ /** `text`, such as Azure's own message, in lines that fit, broken at spaces. */
821
+ const wrap = (text) => {
822
+ const lines = [];
823
+ let line = "";
824
+ for (const word of text.split(/\s+/u).filter(Boolean)) if (line && line.length + 1 + word.length > LINE_WIDTH) {
825
+ lines.push(line);
826
+ line = word;
827
+ } else line = line ? `${line} ${word}` : word;
828
+ if (line) lines.push(line);
829
+ return lines;
830
+ };
831
+ /** An error of one line each, for a terminal. */
832
+ const failure = (lines, cause) => new Error(lines.join("\n"), cause === void 0 ? void 0 : { cause });
833
+ const READ_ROLE = "Key Vault Secrets User";
834
+ const WRITE_ROLE = "Key Vault Secrets Officer";
835
+ const errorName = (error) => error instanceof Error ? error.name : "";
836
+ /** The message's first line: Key Vault's next lines name the caller's IDs. */
837
+ const errorMessage = (error) => ((error instanceof Error ? error.message : String(error)).split(/\r?\n/u, 1)[0] ?? "").replace(/\.+$/u, "");
838
+ const innerCode = (error) => {
839
+ const inner = error?.details?.error;
840
+ return inner?.innerError?.code ?? inner?.innererror?.code;
841
+ };
842
+ const statusOf = (error) => error?.statusCode;
843
+ const isLoopback = (hostname) => (/* @__PURE__ */ new Set([
844
+ "localhost",
845
+ "127.0.0.1",
846
+ "[::1]"
847
+ ])).has(hostname);
848
+ const resolveVaultUrl = (vaultName, vaultUrl) => {
849
+ if (vaultName === void 0 === (vaultUrl === void 0)) throw new Error(`${NAME} needs a vaultName or a vaultUrl, and not both.`);
850
+ if (vaultName !== void 0) {
851
+ if (typeof vaultName !== "string" || !VAULT_NAME_PATTERN.test(vaultName)) throw failure([`${NAME} can't use the vault name ${JSON.stringify(vaultName)}.`, "Use 3 to 24 letters, digits, and single hyphens; start with a letter."]);
852
+ return new URL(`https://${vaultName.toLowerCase()}.vault.azure.net`);
853
+ }
854
+ let url;
855
+ try {
856
+ url = new URL(String(vaultUrl).trim());
857
+ } catch {
858
+ throw new Error(`${NAME} vaultUrl must be a valid URL.`);
859
+ }
860
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/" && url.pathname !== "") throw failure([
861
+ `${NAME} can't use its vaultUrl.`,
862
+ "Use an HTTPS origin, such as https://my-vault.vault.azure.net.",
863
+ "Leave out credentials, a path, a query, and a fragment."
864
+ ]);
865
+ return url;
866
+ };
867
+ /** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
868
+ const mask = async (values) => {
869
+ const { registerSecretValue } = await import("@hot-updater/cli-tools");
870
+ for (const value of values) if (value) registerSecretValue(value);
871
+ };
872
+ /** `credential`, with each access token it returns masked. */
873
+ const maskTokens = (credential) => ({ getToken: async (scopes, options) => {
874
+ const token = await credential.getToken(scopes, options);
875
+ await mask([token?.token]);
876
+ return token;
877
+ } });
878
+ /**
879
+ * The Key Vault secret name of `name`, after `prefix`. Key Vault takes
880
+ * only letters, digits, and `-` in a name, and matches names without
881
+ * regard to case, so:
882
+ *
883
+ * - a name of lower-case letters and digits in words joined by single
884
+ * hyphens, such as `asc-api-key`, is kept as it is;
885
+ * - any other name, such as `CLOUDFLARE_API_TOKEN` or `upload.keystore`,
886
+ * becomes its lower-cased words joined by `-`, then `--` and the first 10
887
+ * hex digits of the SHA-256 of the exact name:
888
+ * `cloudflare-api-token--<hash>`.
889
+ *
890
+ * A kept name never holds `--` and a mapped one always does, so no two
891
+ * names share a secret, and `CLOUDFLARE_API_TOKEN` and
892
+ * `cloudflare-api-token` stay apart. Each secret `set` stores is tagged
893
+ * `hot-updater-name` with the name it holds.
894
+ */
895
+ const keyVaultSecretName = (name, prefix = "hot-updater-") => {
896
+ if (PLAIN_NAME_PATTERN.test(name)) return `${prefix}${name}`;
897
+ return `${prefix}${name.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "")}--${createHash("sha256").update(name).digest("hex").slice(0, 10)}`;
898
+ };
899
+ /**
900
+ * Secrets from Azure Key Vault: each name is the secret `<prefix><name>`
901
+ * of the vault, `hot-updater-asc-api-key` for `asc-api-key`, with any
902
+ * other name mapped as `keyVaultSecretName` says, read at its current
903
+ * version. `set` adds a new version of
904
+ * the secret, which creates it when it does not exist; Key Vault keeps the
905
+ * older versions. A name whose secret was deleted and is still recoverable
906
+ * reads as missing, and `set` on it fails with the `az` commands that
907
+ * recover or purge it. `@azure/keyvault-secrets`, and `@azure/identity`
908
+ * without a `credential`, load on the first read or write, so a config
909
+ * that only uses `azureBlobStorage` never loads them.
910
+ *
911
+ * @example
912
+ * secrets: keyVault({ vaultName: "my-vault" })
913
+ */
914
+ const keyVault = ({ credential, prefix = "hot-updater-", vaultName, vaultUrl }) => {
915
+ const vault = () => {
916
+ const url = resolveVaultUrl(vaultName, vaultUrl);
917
+ if (typeof prefix !== "string" || !PREFIX_PATTERN.test(prefix)) throw new Error(`${NAME}'s prefix takes letters, digits, and - only.`);
918
+ return url;
919
+ };
920
+ const secretName = (name) => {
921
+ vault();
922
+ const id = keyVaultSecretName(name, prefix);
923
+ if (id.length > MAX_SECRET_NAME_LENGTH) throw failure([`${NAME} can't name a secret ${JSON.stringify(id)} for ${JSON.stringify(name)}.`, `Key Vault takes up to ${MAX_SECRET_NAME_LENGTH} characters.`]);
924
+ return id;
925
+ };
926
+ /** The vault's name, for the az commands messages suggest. */
927
+ const vaultLabel = () => vault().hostname.split(".")[0];
928
+ let opened;
929
+ const open = () => {
930
+ const url = vault();
931
+ if (credential !== void 0 && !isTokenCredential(credential)) throw new Error(`${NAME} credential must be a TokenCredential, such as one from @azure/identity.`);
932
+ opened ??= Promise.all([import("@azure/keyvault-secrets"), credential ?? import("@azure/identity").then(({ DefaultAzureCredential }) => new DefaultAzureCredential())]).then(([sdk, signWith]) => new sdk.SecretClient(url.origin, maskTokens(signWith), { disableChallengeResourceVerification: isLoopback(url.hostname) })).catch((error) => {
933
+ opened = void 0;
934
+ throw error;
935
+ });
936
+ return opened;
937
+ };
938
+ const describeFailure = (verb, id, error, role) => {
939
+ const name = errorName(error);
940
+ const status = statusOf(error);
941
+ const code = innerCode(error) ?? error?.code;
942
+ const detail = wrap(`${code || name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}.`);
943
+ const secret = `Secret: ${id}`;
944
+ if (NO_CREDENTIAL_ERRORS.has(name)) return failure([
945
+ `${NAME} found no Azure identity to ${verb} a secret.`,
946
+ secret,
947
+ ...wrap(`${errorMessage(error)}.`),
948
+ ...SIGN_IN_HINT
949
+ ], error);
950
+ if (status === 401) return failure([
951
+ `Azure Key Vault refused the sign-in to ${verb} a secret.`,
952
+ secret,
953
+ ...detail,
954
+ "Sign in again.",
955
+ ...SIGN_IN_HINT
956
+ ], error);
957
+ if (code === "SecretDisabled") return failure([
958
+ `Azure Key Vault can't ${verb} a secret.`,
959
+ secret,
960
+ "Its current version is disabled. Enable it, or store a new value."
961
+ ], error);
962
+ if (status === 403) return failure([
963
+ `Azure Key Vault refused to ${verb} a secret.`,
964
+ secret,
965
+ ...detail,
966
+ `The Azure identity needs the ${role} role on the vault.`,
967
+ `With access policies, it needs the secret permissions ${role === READ_ROLE ? "get" : "get and set"}.`,
968
+ "A firewall may also refuse this network."
969
+ ], error);
970
+ if (code === "ObjectIsBeingDeleted" || /being deleted/iu.test(errorMessage(error))) return failure([
971
+ `Azure Key Vault can't ${verb} a secret: it is being deleted.`,
972
+ secret,
973
+ "Try again in a minute."
974
+ ], error);
975
+ if (status === 409 || code === "ObjectIsDeletedButRecoverable") {
976
+ const vaultName = vaultLabel();
977
+ return failure([
978
+ `Azure Key Vault can't ${verb} a secret: it was deleted.`,
979
+ secret,
980
+ "Recover it, or purge it:",
981
+ `az keyvault secret recover --vault-name ${vaultName} \\`,
982
+ ` --name ${id}`,
983
+ `az keyvault secret purge --vault-name ${vaultName} \\`,
984
+ ` --name ${id}`
985
+ ], error);
986
+ }
987
+ return failure([
988
+ `Azure Key Vault failed to ${verb} a secret.`,
989
+ secret,
990
+ ...detail
991
+ ], error);
992
+ };
993
+ const read = async (client, id) => {
994
+ let value;
995
+ try {
996
+ ({value} = await client.getSecret(id));
997
+ } catch (error) {
998
+ if (statusOf(error) === 404 && /NotFound/u.test(error.code ?? "") && !/disabled/iu.test(errorMessage(error))) return;
999
+ throw describeFailure("read", id, error, READ_ROLE);
1000
+ }
1001
+ await mask([value]);
1002
+ return value;
1003
+ };
1004
+ return {
1005
+ name: NAME,
1006
+ get: async (names) => {
1007
+ const unique = [...new Set(names)];
1008
+ const ids = unique.map(secretName);
1009
+ const result = Object.create(null);
1010
+ if (unique.length === 0) return result;
1011
+ const client = await open();
1012
+ const values = await Promise.all(ids.map((id) => read(client, id)));
1013
+ unique.forEach((name, index) => {
1014
+ result[name] = values[index];
1015
+ });
1016
+ return result;
1017
+ },
1018
+ set: async (name, value) => {
1019
+ const id = secretName(name);
1020
+ if (typeof value !== "string" || value === "") throw new Error(`${NAME} can't store an empty ${id}.`);
1021
+ if (value.length > MAX_SECRET_LENGTH) throw failure([`${NAME} can't store ${id}: it has ${value.length} characters.`, `Key Vault takes up to ${MAX_SECRET_LENGTH} characters per secret.`]);
1022
+ await mask([value]);
1023
+ const client = await open();
1024
+ try {
1025
+ await client.setSecret(id, value, { tags: { [NAME_TAG]: name } });
1026
+ } catch (error) {
1027
+ throw describeFailure("write", id, error, WRITE_ROLE);
1028
+ }
1029
+ },
1030
+ describe: (name) => {
1031
+ let url;
1032
+ try {
1033
+ url = vault().origin;
1034
+ } catch {
1035
+ url = String(vaultUrl ?? vaultName);
1036
+ }
1037
+ return `the secret ${JSON.stringify(keyVaultSecretName(name, prefix))} in Azure Key Vault (${url})`;
1038
+ }
1039
+ };
1040
+ };
1041
+ //#endregion
1042
+ export { azureBlobStorage, cosmosDatabase, keyVault, migrateCosmosDatabase };
@@ -7460,21 +7460,18 @@ const verifyFunction = async (baseURL, { attempts = 60, delayMs = 5e3 } = {}) =>
7460
7460
  const BLOB_CONTAINER = "hot-updater";
7461
7461
  /**
7462
7462
  * The config init writes. With the `resources` init set up, it names the
7463
- * Cosmos DB account, and reads both connection strings with `secretEnv`,
7464
- * from the environment and `.env.hotupdater`, then the config's `secrets`
7465
- * adapter. Without them, as the agent scaffold writes it, it reads them
7466
- * from the environment.
7463
+ * Cosmos DB account. Either way it reads both connection strings with
7464
+ * `secret()`, from the environment and `.env.hotupdater`, then the config's
7465
+ * `secrets` adapter.
7467
7466
  */
7468
7467
  const getConfigScaffold = (build, resources) => {
7469
- const read = (reader, name) => resources === void 0 ? `process.env.${name}!` : `async () =>
7470
- (await ${reader}()).${name}`;
7471
7468
  const storageConfig = {
7472
7469
  imports: [{
7473
7470
  pkg: "@hot-updater/azure",
7474
7471
  named: ["azureBlobStorage"]
7475
7472
  }],
7476
7473
  configString: `azureBlobStorage({
7477
- connectionString: ${read("storageSecrets", "HOT_UPDATER_AZURE_STORAGE_CONNECTION_STRING")},
7474
+ connectionString: secret("HOT_UPDATER_AZURE_STORAGE_CONNECTION_STRING"),
7478
7475
  containerName: "${BLOB_CONTAINER}",
7479
7476
  })`
7480
7477
  };
@@ -7485,10 +7482,9 @@ const getConfigScaffold = (build, resources) => {
7485
7482
  }],
7486
7483
  configString: `cosmosDatabase({${resources?.cosmosEndpoint === void 0 ? "" : `
7487
7484
  endpoint: ${JSON.stringify(resources.cosmosEndpoint)},`}
7488
- connectionString: ${read("databaseSecrets", "HOT_UPDATER_AZURE_COSMOS_CONNECTION_STRING")},
7485
+ connectionString: secret("HOT_UPDATER_AZURE_COSMOS_CONNECTION_STRING"),
7489
7486
  })`
7490
7487
  };
7491
- const helperStatements = resources === void 0 ? [] : [(0, _hot_updater_cli_tools.secretEnvHelper)("storageSecrets", ["HOT_UPDATER_AZURE_STORAGE_CONNECTION_STRING"]), (0, _hot_updater_cli_tools.secretEnvHelper)("databaseSecrets", ["HOT_UPDATER_AZURE_COSMOS_CONNECTION_STRING"])];
7492
7488
  const builder = new _hot_updater_cli_tools.ConfigBuilder().setBuildType(build).setStorage(storageConfig).setDatabase(databaseConfig).setPlugins({
7493
7489
  imports: [{
7494
7490
  pkg: "hot-updater/plugins",
@@ -7499,12 +7495,11 @@ const getConfigScaffold = (build, resources) => {
7499
7495
  ]
7500
7496
  }],
7501
7497
  configString: "[apiKeys(), insights(), remoteConfig()]"
7502
- });
7503
- if (resources !== void 0) builder.addImport({
7498
+ }).addImport({
7504
7499
  pkg: "hot-updater",
7505
- named: ["secretEnv"]
7506
- }).setIntermediateCode(helperStatements.map(({ code }) => code).join("\n\n"));
7507
- return (0, _hot_updater_cli_tools.createHotUpdaterConfigScaffoldFromBuilder)(builder, { helperStatements });
7500
+ named: ["secret"]
7501
+ });
7502
+ return (0, _hot_updater_cli_tools.createHotUpdaterConfigScaffoldFromBuilder)(builder);
7508
7503
  };
7509
7504
  //#endregion
7510
7505
  //#region iac/init/index.ts
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createHash } from "crypto";
3
3
  import path from "path";
4
- import { ConfigBuilder, InitError, assembleServer, createHotUpdaterConfigScaffoldFromBuilder, createZip, getCwd, getHotUpdaterInitInputEnv, getInitProviderEnvVars, getInitProviderTextPromptValues, link, makeEnv, p, printAppSetup, provisionClientCredential, readHotUpdaterInitEnv, secretEnvHelper, shouldAutoSelectOnlyInitResource, writeHotUpdaterFiles } from "@hot-updater/cli-tools";
4
+ import { ConfigBuilder, InitError, assembleServer, createHotUpdaterConfigScaffoldFromBuilder, createZip, getCwd, getHotUpdaterInitInputEnv, getInitProviderEnvVars, getInitProviderTextPromptValues, link, makeEnv, p, printAppSetup, provisionClientCredential, readHotUpdaterInitEnv, shouldAutoSelectOnlyInitResource, writeHotUpdaterFiles } from "@hot-updater/cli-tools";
5
5
  import { apiKeys, insights, remoteConfig } from "@hot-updater/server/plugins";
6
6
  import { CosmosClient } from "@azure/cosmos";
7
7
  import { SETTINGS_TABLE, createEngineDatabase, createKvAdapter, encodeKvKey, migrateCoreSchema, toolingTargetOf } from "@hot-updater/plugin-core";
@@ -7455,21 +7455,18 @@ const verifyFunction = async (baseURL, { attempts = 60, delayMs = 5e3 } = {}) =>
7455
7455
  const BLOB_CONTAINER = "hot-updater";
7456
7456
  /**
7457
7457
  * The config init writes. With the `resources` init set up, it names the
7458
- * Cosmos DB account, and reads both connection strings with `secretEnv`,
7459
- * from the environment and `.env.hotupdater`, then the config's `secrets`
7460
- * adapter. Without them, as the agent scaffold writes it, it reads them
7461
- * from the environment.
7458
+ * Cosmos DB account. Either way it reads both connection strings with
7459
+ * `secret()`, from the environment and `.env.hotupdater`, then the config's
7460
+ * `secrets` adapter.
7462
7461
  */
7463
7462
  const getConfigScaffold = (build, resources) => {
7464
- const read = (reader, name) => resources === void 0 ? `process.env.${name}!` : `async () =>
7465
- (await ${reader}()).${name}`;
7466
7463
  const storageConfig = {
7467
7464
  imports: [{
7468
7465
  pkg: "@hot-updater/azure",
7469
7466
  named: ["azureBlobStorage"]
7470
7467
  }],
7471
7468
  configString: `azureBlobStorage({
7472
- connectionString: ${read("storageSecrets", "HOT_UPDATER_AZURE_STORAGE_CONNECTION_STRING")},
7469
+ connectionString: secret("HOT_UPDATER_AZURE_STORAGE_CONNECTION_STRING"),
7473
7470
  containerName: "${BLOB_CONTAINER}",
7474
7471
  })`
7475
7472
  };
@@ -7480,10 +7477,9 @@ const getConfigScaffold = (build, resources) => {
7480
7477
  }],
7481
7478
  configString: `cosmosDatabase({${resources?.cosmosEndpoint === void 0 ? "" : `
7482
7479
  endpoint: ${JSON.stringify(resources.cosmosEndpoint)},`}
7483
- connectionString: ${read("databaseSecrets", "HOT_UPDATER_AZURE_COSMOS_CONNECTION_STRING")},
7480
+ connectionString: secret("HOT_UPDATER_AZURE_COSMOS_CONNECTION_STRING"),
7484
7481
  })`
7485
7482
  };
7486
- const helperStatements = resources === void 0 ? [] : [secretEnvHelper("storageSecrets", ["HOT_UPDATER_AZURE_STORAGE_CONNECTION_STRING"]), secretEnvHelper("databaseSecrets", ["HOT_UPDATER_AZURE_COSMOS_CONNECTION_STRING"])];
7487
7483
  const builder = new ConfigBuilder().setBuildType(build).setStorage(storageConfig).setDatabase(databaseConfig).setPlugins({
7488
7484
  imports: [{
7489
7485
  pkg: "hot-updater/plugins",
@@ -7494,12 +7490,11 @@ const getConfigScaffold = (build, resources) => {
7494
7490
  ]
7495
7491
  }],
7496
7492
  configString: "[apiKeys(), insights(), remoteConfig()]"
7497
- });
7498
- if (resources !== void 0) builder.addImport({
7493
+ }).addImport({
7499
7494
  pkg: "hot-updater",
7500
- named: ["secretEnv"]
7501
- }).setIntermediateCode(helperStatements.map(({ code }) => code).join("\n\n"));
7502
- return createHotUpdaterConfigScaffoldFromBuilder(builder, { helperStatements });
7495
+ named: ["secret"]
7496
+ });
7497
+ return createHotUpdaterConfigScaffoldFromBuilder(builder);
7503
7498
  };
7504
7499
  //#endregion
7505
7500
  //#region iac/init/index.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/azure",
3
3
  "type": "module",
4
- "version": "1.2.0-rc.7",
4
+ "version": "1.2.0-rc.9",
5
5
  "description": "React Native OTA solution for self-hosted",
6
6
  "engines": {
7
7
  "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
@@ -41,11 +41,13 @@
41
41
  "dependencies": {
42
42
  "@azure/core-auth": "1.11.0",
43
43
  "@azure/cosmos": "4.10.1",
44
+ "@azure/identity": "4.14.0",
45
+ "@azure/keyvault-secrets": "4.11.2",
44
46
  "@azure/storage-blob": "12.34.0",
45
- "@hot-updater/cli-tools": "1.2.0-rc.7",
46
- "@hot-updater/server": "1.2.0-rc.7",
47
- "@hot-updater/plugin-core": "1.2.0-rc.7",
48
- "@hot-updater/protocol": "1.2.0-rc.7"
47
+ "@hot-updater/cli-tools": "1.2.0-rc.9",
48
+ "@hot-updater/protocol": "1.2.0-rc.9",
49
+ "@hot-updater/server": "1.2.0-rc.9",
50
+ "@hot-updater/plugin-core": "1.2.0-rc.9"
49
51
  },
50
52
  "devDependencies": {
51
53
  "@azure/functions": "4.16.5",
@@ -53,7 +55,7 @@
53
55
  "execa": "9.5.2",
54
56
  "jszip": "^3.10.1",
55
57
  "vitest": "4.1.4",
56
- "@hot-updater/test-utils": "1.2.0-rc.7"
58
+ "@hot-updater/test-utils": "1.2.0-rc.9"
57
59
  },
58
60
  "scripts": {
59
61
  "build": "tsdown",