@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.
- package/dist/functions/index.mjs +2 -3
- package/dist/index.cjs +255 -7
- package/dist/index.d.cts +56 -9
- package/dist/index.d.mts +56 -9
- package/dist/index.mjs +254 -7
- package/dist/init/index.cjs +9 -14
- package/dist/init/index.mjs +10 -15
- package/package.json +8 -6
package/dist/functions/index.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
50
|
-
* config loads without it. `endpoint` beside a function
|
|
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?:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
50
|
-
* config loads without it. `endpoint` beside a function
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/init/index.cjs
CHANGED
|
@@ -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
|
|
7464
|
-
* from the environment and `.env.hotupdater`, then the config's
|
|
7465
|
-
* adapter.
|
|
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:
|
|
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:
|
|
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: ["
|
|
7506
|
-
})
|
|
7507
|
-
return (0, _hot_updater_cli_tools.createHotUpdaterConfigScaffoldFromBuilder)(builder
|
|
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
|
package/dist/init/index.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
7459
|
-
* from the environment and `.env.hotupdater`, then the config's
|
|
7460
|
-
* adapter.
|
|
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:
|
|
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:
|
|
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: ["
|
|
7501
|
-
})
|
|
7502
|
-
return createHotUpdaterConfigScaffoldFromBuilder(builder
|
|
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.
|
|
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.
|
|
46
|
-
"@hot-updater/
|
|
47
|
-
"@hot-updater/
|
|
48
|
-
"@hot-updater/
|
|
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.
|
|
58
|
+
"@hot-updater/test-utils": "1.2.0-rc.9"
|
|
57
59
|
},
|
|
58
60
|
"scripts": {
|
|
59
61
|
"build": "tsdown",
|