@hot-updater/aws 1.1.3 → 1.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +181 -0
- package/dist/index.d.cts +49 -2
- package/dist/index.d.mts +49 -2
- package/dist/index.mjs +181 -1
- package/dist/init/index.cjs +12 -1
- package/dist/init/index.mjs +12 -1
- package/dist/lambda/index.cjs +998 -487
- package/package.json +8 -7
package/dist/index.cjs
CHANGED
|
@@ -7,6 +7,7 @@ let _aws_sdk_client_ssm = require("@aws-sdk/client-ssm");
|
|
|
7
7
|
let _aws_sdk_cloudfront_signer = require("@aws-sdk/cloudfront-signer");
|
|
8
8
|
let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
|
|
9
9
|
let _aws_sdk_lib_storage = require("@aws-sdk/lib-storage");
|
|
10
|
+
let node_crypto = require("node:crypto");
|
|
10
11
|
//#region src/cloudFrontInvalidation.ts
|
|
11
12
|
const DEFAULT_POLL_INTERVAL_MS = 2e3;
|
|
12
13
|
const DEFAULT_TIMEOUT_MS = 3e5;
|
|
@@ -338,6 +339,14 @@ const migrateDynamoDB = (config, plugins = []) => {
|
|
|
338
339
|
return (0, _hot_updater_plugin_core.migrateCoreSchema)(adapter, "dynamoDB", plugins).finally(() => client.destroy());
|
|
339
340
|
};
|
|
340
341
|
/**
|
|
342
|
+
* The resource this database reaches, without credentials, under the key
|
|
343
|
+
* `hot-updater doctor` reads to tell the database `hot-updater init` saved
|
|
344
|
+
* from another (a convention CLAUDE.md documents).
|
|
345
|
+
*/
|
|
346
|
+
const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
|
|
347
|
+
/** `target` under {@link DATABASE_TARGET}, to spread into the database. */
|
|
348
|
+
const marked = (target) => ({ [DATABASE_TARGET]: target });
|
|
349
|
+
/**
|
|
341
350
|
* Hot Updater's database on one DynamoDB table, through the storage engine.
|
|
342
351
|
* With a distribution, it gives core the CloudFront invalidation core runs
|
|
343
352
|
* after a write that changes what the update-check routes answer.
|
|
@@ -352,6 +361,7 @@ const dynamoDB = (config) => {
|
|
|
352
361
|
onCachedRoutesChange: cloudFront?.invalidate,
|
|
353
362
|
aggregateBatching: config.aggregateBatching ?? {}
|
|
354
363
|
}),
|
|
364
|
+
...marked(`dynamodb/${config.tableName}`),
|
|
355
365
|
async dispose() {
|
|
356
366
|
await adapter.dispose?.();
|
|
357
367
|
client.destroy();
|
|
@@ -636,7 +646,178 @@ function s3Storage(config) {
|
|
|
636
646
|
});
|
|
637
647
|
}
|
|
638
648
|
//#endregion
|
|
649
|
+
//#region src/secretsManager.ts
|
|
650
|
+
const NAME = "secretsManager";
|
|
651
|
+
const REGION_PATTERN = /^[a-z]{2}(?:-[a-z]+)+-\d+$/u;
|
|
652
|
+
/** The characters AWS takes in a secret's name. */
|
|
653
|
+
const SECRET_ID_PATTERN = /^[A-Za-z0-9/_+=.@-]+$/u;
|
|
654
|
+
const MAX_SECRET_ID_LENGTH = 512;
|
|
655
|
+
/** AWS's limit on a `SecretString`, in bytes. */
|
|
656
|
+
const MAX_SECRET_BYTES = 65536;
|
|
657
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
658
|
+
/** Errors of credentials AWS no longer takes: signing in again fixes them. */
|
|
659
|
+
const EXPIRED_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set([
|
|
660
|
+
"ExpiredToken",
|
|
661
|
+
"ExpiredTokenException",
|
|
662
|
+
"IncompleteSignature",
|
|
663
|
+
"InvalidClientTokenId",
|
|
664
|
+
"InvalidSignatureException",
|
|
665
|
+
"MissingAuthenticationToken",
|
|
666
|
+
"UnrecognizedClientException"
|
|
667
|
+
]);
|
|
668
|
+
/** Errors of the SDK finding no credentials, or an expired SSO session. */
|
|
669
|
+
const NO_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set(["CredentialsProviderError", "TokenProviderError"]);
|
|
670
|
+
const SIGN_IN_HINT = "In CI, set them with aws-actions/configure-aws-credentials; on your machine, run aws sso login --profile <profile> and set AWS_PROFILE=<profile>";
|
|
671
|
+
const errorName = (error) => error instanceof Error ? error.name : "";
|
|
672
|
+
const errorMessage = (error) => (error instanceof Error ? error.message : String(error)).replace(/\.+$/u, "");
|
|
673
|
+
const resolveEndpoint = (endpoint) => {
|
|
674
|
+
let url;
|
|
675
|
+
try {
|
|
676
|
+
url = new URL(endpoint.trim());
|
|
677
|
+
} catch {
|
|
678
|
+
throw new Error(`${NAME} endpoint must be a valid URL.`);
|
|
679
|
+
}
|
|
680
|
+
const loopback = (/* @__PURE__ */ new Set([
|
|
681
|
+
"localhost",
|
|
682
|
+
"127.0.0.1",
|
|
683
|
+
"[::1]"
|
|
684
|
+
])).has(url.hostname);
|
|
685
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.search || url.hash) throw new Error(`${NAME} endpoint must use HTTPS, or HTTP on a loopback address, without credentials, a query, or a fragment.`);
|
|
686
|
+
return url.toString();
|
|
687
|
+
};
|
|
688
|
+
/** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
|
|
689
|
+
const mask = async (values) => {
|
|
690
|
+
const { registerSecretValue } = await import("@hot-updater/cli-tools");
|
|
691
|
+
for (const value of values) if (value) registerSecretValue(value);
|
|
692
|
+
};
|
|
693
|
+
const checkCredentials = async (credentials) => {
|
|
694
|
+
if (!credentials?.accessKeyId || !credentials.secretAccessKey) throw new Error(`${NAME} credentials need an accessKeyId and a secretAccessKey.`);
|
|
695
|
+
await mask([credentials.secretAccessKey, credentials.sessionToken]);
|
|
696
|
+
return credentials;
|
|
697
|
+
};
|
|
698
|
+
/**
|
|
699
|
+
* Secrets from AWS Secrets Manager: each name is the secret
|
|
700
|
+
* `<prefix><name>`, its `SecretString`, or its `SecretBinary` as base64.
|
|
701
|
+
* `set` puts a new version of the secret, and creates the secret when it
|
|
702
|
+
* does not exist; AWS keeps the versions labeled `AWSCURRENT` and
|
|
703
|
+
* `AWSPREVIOUS`, and removes the older ones itself. The AWS SDK loads on
|
|
704
|
+
* the first read or write, so a config that only uses `s3Storage` never
|
|
705
|
+
* loads it.
|
|
706
|
+
*
|
|
707
|
+
* @example
|
|
708
|
+
* secrets: secretsManager({ region: "us-east-1" })
|
|
709
|
+
*/
|
|
710
|
+
const secretsManager = ({ credentials, endpoint, prefix = "hot-updater/", region }) => {
|
|
711
|
+
const checkSettings = () => {
|
|
712
|
+
if (typeof region !== "string" || !REGION_PATTERN.test(region)) throw new Error(`${NAME} needs an AWS region, such as "us-east-1", and ${JSON.stringify(region)} is not one.`);
|
|
713
|
+
if (prefix !== "" && !SECRET_ID_PATTERN.test(prefix)) throw new Error(`${NAME}'s prefix takes letters, digits, and /_+=.@- only.`);
|
|
714
|
+
};
|
|
715
|
+
const secretId = (name) => {
|
|
716
|
+
checkSettings();
|
|
717
|
+
const id = `${prefix}${name}`;
|
|
718
|
+
if (!SECRET_ID_PATTERN.test(name) || id.length > MAX_SECRET_ID_LENGTH) throw new Error(`${NAME} cannot name a secret ${JSON.stringify(id)}: AWS takes up to ${MAX_SECRET_ID_LENGTH} letters, digits, and /_+=.@- characters.`);
|
|
719
|
+
return id;
|
|
720
|
+
};
|
|
721
|
+
const resolveCredentials = () => {
|
|
722
|
+
if (credentials !== void 0) return async () => checkCredentials(typeof credentials === "function" ? await credentials() : credentials);
|
|
723
|
+
const accessKeyId = process.env["AWS_ACCESS_KEY_ID"];
|
|
724
|
+
const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"];
|
|
725
|
+
if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) throw new Error(`${NAME} found ${accessKeyId ? "AWS_ACCESS_KEY_ID" : "AWS_SECRET_ACCESS_KEY"} without ${accessKeyId ? "AWS_SECRET_ACCESS_KEY" : "AWS_ACCESS_KEY_ID"} in the environment: set both, or neither to use a profile.`);
|
|
726
|
+
};
|
|
727
|
+
let opened;
|
|
728
|
+
const open = () => {
|
|
729
|
+
checkSettings();
|
|
730
|
+
const signWith = resolveCredentials();
|
|
731
|
+
opened ??= import("@aws-sdk/client-secrets-manager").then((sdk) => ({
|
|
732
|
+
sdk,
|
|
733
|
+
client: new sdk.SecretsManagerClient({
|
|
734
|
+
region,
|
|
735
|
+
...signWith ? { credentials: signWith } : {},
|
|
736
|
+
...endpoint === void 0 ? {} : { endpoint: resolveEndpoint(endpoint) },
|
|
737
|
+
requestHandler: { requestTimeout: REQUEST_TIMEOUT_MS }
|
|
738
|
+
})
|
|
739
|
+
})).catch((error) => {
|
|
740
|
+
opened = void 0;
|
|
741
|
+
throw error;
|
|
742
|
+
});
|
|
743
|
+
return opened;
|
|
744
|
+
};
|
|
745
|
+
const describeFailure = (verb, id, error, permissions) => {
|
|
746
|
+
const name = errorName(error);
|
|
747
|
+
const status = error?.$metadata?.httpStatusCode;
|
|
748
|
+
const detail = `${name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}`;
|
|
749
|
+
if (NO_CREDENTIAL_ERRORS.has(name)) return new Error(`${NAME} found no usable AWS credentials to ${verb} ${JSON.stringify(id)} (${errorMessage(error)}). ${SIGN_IN_HINT}.`, { cause: error });
|
|
750
|
+
if (EXPIRED_CREDENTIAL_ERRORS.has(name)) return new Error(`AWS Secrets Manager refused the credentials to ${verb} ${JSON.stringify(id)}, ${detail}. They are expired or invalid: sign in again. ${SIGN_IN_HINT}.`, { cause: error });
|
|
751
|
+
if (name === "AccessDeniedException") return new Error(`AWS Secrets Manager refused to ${verb} ${JSON.stringify(id)}, ${detail}. The AWS identity needs ${permissions} on it, and on a secret encrypted with a customer managed KMS key, kms:Decrypt to read and kms:GenerateDataKey to store.`, { cause: error });
|
|
752
|
+
if (/marked for deletion|scheduled for deletion/iu.test(errorMessage(error))) return new Error(`AWS Secrets Manager cannot ${verb} ${JSON.stringify(id)}: it is scheduled for deletion. Restore it with aws secretsmanager restore-secret --secret-id ${id}, or wait until it is deleted.`, { cause: error });
|
|
753
|
+
return new Error(`AWS Secrets Manager failed to ${verb} ${JSON.stringify(id)}, ${detail}.`, { cause: error });
|
|
754
|
+
};
|
|
755
|
+
const read = async ({ sdk, client }, id) => {
|
|
756
|
+
let output;
|
|
757
|
+
try {
|
|
758
|
+
output = await client.send(new sdk.GetSecretValueCommand({ SecretId: id }));
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (error instanceof sdk.ResourceNotFoundException) return void 0;
|
|
761
|
+
throw describeFailure("read", id, error, "secretsmanager:GetSecretValue");
|
|
762
|
+
}
|
|
763
|
+
const value = output.SecretString ?? (output.SecretBinary === void 0 ? void 0 : Buffer.from(output.SecretBinary).toString("base64"));
|
|
764
|
+
await mask([value]);
|
|
765
|
+
return value;
|
|
766
|
+
};
|
|
767
|
+
return {
|
|
768
|
+
name: NAME,
|
|
769
|
+
get: async (names) => {
|
|
770
|
+
const unique = [...new Set(names)];
|
|
771
|
+
const ids = unique.map(secretId);
|
|
772
|
+
const result = Object.create(null);
|
|
773
|
+
if (unique.length === 0) return result;
|
|
774
|
+
const client = await open();
|
|
775
|
+
const values = await Promise.all(ids.map((id) => read(client, id)));
|
|
776
|
+
unique.forEach((name, index) => {
|
|
777
|
+
result[name] = values[index];
|
|
778
|
+
});
|
|
779
|
+
return result;
|
|
780
|
+
},
|
|
781
|
+
set: async (name, value) => {
|
|
782
|
+
const id = secretId(name);
|
|
783
|
+
if (typeof value !== "string" || value === "") throw new Error(`${NAME} cannot store an empty ${id}.`);
|
|
784
|
+
if (Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) throw new Error(`${NAME} cannot store ${id}: AWS takes up to ${MAX_SECRET_BYTES} bytes per secret.`);
|
|
785
|
+
await mask([value]);
|
|
786
|
+
const { sdk, client } = await open();
|
|
787
|
+
const token = (0, node_crypto.randomUUID)();
|
|
788
|
+
const put = () => client.send(new sdk.PutSecretValueCommand({
|
|
789
|
+
ClientRequestToken: token,
|
|
790
|
+
SecretId: id,
|
|
791
|
+
SecretString: value
|
|
792
|
+
}));
|
|
793
|
+
try {
|
|
794
|
+
await put();
|
|
795
|
+
return;
|
|
796
|
+
} catch (error) {
|
|
797
|
+
if (!(error instanceof sdk.ResourceNotFoundException)) throw describeFailure("write", id, error, "secretsmanager:PutSecretValue");
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
await client.send(new sdk.CreateSecretCommand({
|
|
801
|
+
ClientRequestToken: token,
|
|
802
|
+
Description: "Stored by Hot Updater.",
|
|
803
|
+
Name: id,
|
|
804
|
+
SecretString: value
|
|
805
|
+
}));
|
|
806
|
+
} catch (error) {
|
|
807
|
+
if (!(error instanceof sdk.ResourceExistsException)) throw describeFailure("create", id, error, "secretsmanager:CreateSecret");
|
|
808
|
+
try {
|
|
809
|
+
await put();
|
|
810
|
+
} catch (retryError) {
|
|
811
|
+
throw describeFailure("write", id, retryError, "secretsmanager:PutSecretValue");
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
describe: (name) => `the secret ${JSON.stringify(`${prefix}${name}`)} in AWS Secrets Manager (${region})`
|
|
816
|
+
};
|
|
817
|
+
};
|
|
818
|
+
//#endregion
|
|
639
819
|
exports.cloudFrontDownloadUrl = cloudFrontDownloadUrl;
|
|
640
820
|
exports.dynamoDB = dynamoDB;
|
|
641
821
|
exports.migrateDynamoDB = migrateDynamoDB;
|
|
642
822
|
exports.s3Storage = s3Storage;
|
|
823
|
+
exports.secretsManager = secretsManager;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DynamoDBClientConfig } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import { EngineDatabase, PluginTables, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
|
|
2
|
+
import { EngineDatabase, PluginTables, SecretsAdapter, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
|
|
3
3
|
import { S3ClientConfig } from "@aws-sdk/client-s3";
|
|
4
4
|
//#region src/dynamoDB.d.ts
|
|
5
5
|
interface DynamoDBConfig extends DynamoDBClientConfig {
|
|
@@ -73,4 +73,51 @@ interface S3StorageConfig extends S3ClientConfig {
|
|
|
73
73
|
type S3StorageOperations = "put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects";
|
|
74
74
|
declare function s3Storage(config: S3StorageConfig): StorageAdapterWith<S3StorageOperations>;
|
|
75
75
|
//#endregion
|
|
76
|
-
|
|
76
|
+
//#region src/secretsManager.d.ts
|
|
77
|
+
/** AWS credentials, as the AWS SDK takes them. */
|
|
78
|
+
interface SecretsManagerCredentials {
|
|
79
|
+
readonly accessKeyId: string;
|
|
80
|
+
readonly secretAccessKey: string;
|
|
81
|
+
readonly sessionToken?: string;
|
|
82
|
+
readonly expiration?: Date;
|
|
83
|
+
}
|
|
84
|
+
interface SecretsManagerOptions {
|
|
85
|
+
/** The AWS region the secrets are in, such as `"us-east-1"`. */
|
|
86
|
+
readonly region: string;
|
|
87
|
+
/**
|
|
88
|
+
* Put before every name to make its secret's name, so `secret:asc-api-key`
|
|
89
|
+
* reads the secret `hot-updater/asc-api-key` and one IAM resource,
|
|
90
|
+
* `secret:hot-updater/*`, covers them all. Letters, digits, and
|
|
91
|
+
* `/_+=.@-`.
|
|
92
|
+
*
|
|
93
|
+
* @default "hot-updater/"
|
|
94
|
+
*/
|
|
95
|
+
readonly prefix?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The credentials to sign with, or a function that returns them. Without
|
|
98
|
+
* them, the AWS SDK's default chain: the environment, as
|
|
99
|
+
* `aws-actions/configure-aws-credentials` sets it in CI, then a profile,
|
|
100
|
+
* AWS SSO, or an instance role.
|
|
101
|
+
*/
|
|
102
|
+
readonly credentials?: SecretsManagerCredentials | (() => Promise<SecretsManagerCredentials>);
|
|
103
|
+
/**
|
|
104
|
+
* An HTTPS endpoint to call instead of the region's, such as a VPC
|
|
105
|
+
* endpoint, or an HTTP loopback one, such as LocalStack's.
|
|
106
|
+
*/
|
|
107
|
+
readonly endpoint?: string;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Secrets from AWS Secrets Manager: each name is the secret
|
|
111
|
+
* `<prefix><name>`, its `SecretString`, or its `SecretBinary` as base64.
|
|
112
|
+
* `set` puts a new version of the secret, and creates the secret when it
|
|
113
|
+
* does not exist; AWS keeps the versions labeled `AWSCURRENT` and
|
|
114
|
+
* `AWSPREVIOUS`, and removes the older ones itself. The AWS SDK loads on
|
|
115
|
+
* the first read or write, so a config that only uses `s3Storage` never
|
|
116
|
+
* loads it.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* secrets: secretsManager({ region: "us-east-1" })
|
|
120
|
+
*/
|
|
121
|
+
declare const secretsManager: ({ credentials, endpoint, prefix, region }: SecretsManagerOptions) => SecretsAdapter;
|
|
122
|
+
//#endregion
|
|
123
|
+
export { CloudFrontDownloadUrlOptions, type DynamoDBConfig, S3StorageConfig, SecretsManagerCredentials, SecretsManagerOptions, cloudFrontDownloadUrl, dynamoDB, migrateDynamoDB, s3Storage, secretsManager };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DynamoDBClientConfig } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import { EngineDatabase, PluginTables, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
|
|
2
|
+
import { EngineDatabase, PluginTables, SecretsAdapter, StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
|
|
3
3
|
import { S3ClientConfig } from "@aws-sdk/client-s3";
|
|
4
4
|
//#region src/dynamoDB.d.ts
|
|
5
5
|
interface DynamoDBConfig extends DynamoDBClientConfig {
|
|
@@ -73,4 +73,51 @@ interface S3StorageConfig extends S3ClientConfig {
|
|
|
73
73
|
type S3StorageOperations = "put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects";
|
|
74
74
|
declare function s3Storage(config: S3StorageConfig): StorageAdapterWith<S3StorageOperations>;
|
|
75
75
|
//#endregion
|
|
76
|
-
|
|
76
|
+
//#region src/secretsManager.d.ts
|
|
77
|
+
/** AWS credentials, as the AWS SDK takes them. */
|
|
78
|
+
interface SecretsManagerCredentials {
|
|
79
|
+
readonly accessKeyId: string;
|
|
80
|
+
readonly secretAccessKey: string;
|
|
81
|
+
readonly sessionToken?: string;
|
|
82
|
+
readonly expiration?: Date;
|
|
83
|
+
}
|
|
84
|
+
interface SecretsManagerOptions {
|
|
85
|
+
/** The AWS region the secrets are in, such as `"us-east-1"`. */
|
|
86
|
+
readonly region: string;
|
|
87
|
+
/**
|
|
88
|
+
* Put before every name to make its secret's name, so `secret:asc-api-key`
|
|
89
|
+
* reads the secret `hot-updater/asc-api-key` and one IAM resource,
|
|
90
|
+
* `secret:hot-updater/*`, covers them all. Letters, digits, and
|
|
91
|
+
* `/_+=.@-`.
|
|
92
|
+
*
|
|
93
|
+
* @default "hot-updater/"
|
|
94
|
+
*/
|
|
95
|
+
readonly prefix?: string;
|
|
96
|
+
/**
|
|
97
|
+
* The credentials to sign with, or a function that returns them. Without
|
|
98
|
+
* them, the AWS SDK's default chain: the environment, as
|
|
99
|
+
* `aws-actions/configure-aws-credentials` sets it in CI, then a profile,
|
|
100
|
+
* AWS SSO, or an instance role.
|
|
101
|
+
*/
|
|
102
|
+
readonly credentials?: SecretsManagerCredentials | (() => Promise<SecretsManagerCredentials>);
|
|
103
|
+
/**
|
|
104
|
+
* An HTTPS endpoint to call instead of the region's, such as a VPC
|
|
105
|
+
* endpoint, or an HTTP loopback one, such as LocalStack's.
|
|
106
|
+
*/
|
|
107
|
+
readonly endpoint?: string;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Secrets from AWS Secrets Manager: each name is the secret
|
|
111
|
+
* `<prefix><name>`, its `SecretString`, or its `SecretBinary` as base64.
|
|
112
|
+
* `set` puts a new version of the secret, and creates the secret when it
|
|
113
|
+
* does not exist; AWS keeps the versions labeled `AWSCURRENT` and
|
|
114
|
+
* `AWSPREVIOUS`, and removes the older ones itself. The AWS SDK loads on
|
|
115
|
+
* the first read or write, so a config that only uses `s3Storage` never
|
|
116
|
+
* loads it.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* secrets: secretsManager({ region: "us-east-1" })
|
|
120
|
+
*/
|
|
121
|
+
declare const secretsManager: ({ credentials, endpoint, prefix, region }: SecretsManagerOptions) => SecretsAdapter;
|
|
122
|
+
//#endregion
|
|
123
|
+
export { CloudFrontDownloadUrlOptions, type DynamoDBConfig, S3StorageConfig, SecretsManagerCredentials, SecretsManagerOptions, cloudFrontDownloadUrl, dynamoDB, migrateDynamoDB, s3Storage, secretsManager };
|
package/dist/index.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { SSM } from "@aws-sdk/client-ssm";
|
|
|
6
6
|
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
|
|
7
7
|
import { DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
|
|
8
8
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
9
10
|
//#region src/cloudFrontInvalidation.ts
|
|
10
11
|
const DEFAULT_POLL_INTERVAL_MS = 2e3;
|
|
11
12
|
const DEFAULT_TIMEOUT_MS = 3e5;
|
|
@@ -337,6 +338,14 @@ const migrateDynamoDB = (config, plugins = []) => {
|
|
|
337
338
|
return migrateCoreSchema(adapter, "dynamoDB", plugins).finally(() => client.destroy());
|
|
338
339
|
};
|
|
339
340
|
/**
|
|
341
|
+
* The resource this database reaches, without credentials, under the key
|
|
342
|
+
* `hot-updater doctor` reads to tell the database `hot-updater init` saved
|
|
343
|
+
* from another (a convention CLAUDE.md documents).
|
|
344
|
+
*/
|
|
345
|
+
const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
|
|
346
|
+
/** `target` under {@link DATABASE_TARGET}, to spread into the database. */
|
|
347
|
+
const marked = (target) => ({ [DATABASE_TARGET]: target });
|
|
348
|
+
/**
|
|
340
349
|
* Hot Updater's database on one DynamoDB table, through the storage engine.
|
|
341
350
|
* With a distribution, it gives core the CloudFront invalidation core runs
|
|
342
351
|
* after a write that changes what the update-check routes answer.
|
|
@@ -351,6 +360,7 @@ const dynamoDB = (config) => {
|
|
|
351
360
|
onCachedRoutesChange: cloudFront?.invalidate,
|
|
352
361
|
aggregateBatching: config.aggregateBatching ?? {}
|
|
353
362
|
}),
|
|
363
|
+
...marked(`dynamodb/${config.tableName}`),
|
|
354
364
|
async dispose() {
|
|
355
365
|
await adapter.dispose?.();
|
|
356
366
|
client.destroy();
|
|
@@ -635,4 +645,174 @@ function s3Storage(config) {
|
|
|
635
645
|
});
|
|
636
646
|
}
|
|
637
647
|
//#endregion
|
|
638
|
-
|
|
648
|
+
//#region src/secretsManager.ts
|
|
649
|
+
const NAME = "secretsManager";
|
|
650
|
+
const REGION_PATTERN = /^[a-z]{2}(?:-[a-z]+)+-\d+$/u;
|
|
651
|
+
/** The characters AWS takes in a secret's name. */
|
|
652
|
+
const SECRET_ID_PATTERN = /^[A-Za-z0-9/_+=.@-]+$/u;
|
|
653
|
+
const MAX_SECRET_ID_LENGTH = 512;
|
|
654
|
+
/** AWS's limit on a `SecretString`, in bytes. */
|
|
655
|
+
const MAX_SECRET_BYTES = 65536;
|
|
656
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
657
|
+
/** Errors of credentials AWS no longer takes: signing in again fixes them. */
|
|
658
|
+
const EXPIRED_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set([
|
|
659
|
+
"ExpiredToken",
|
|
660
|
+
"ExpiredTokenException",
|
|
661
|
+
"IncompleteSignature",
|
|
662
|
+
"InvalidClientTokenId",
|
|
663
|
+
"InvalidSignatureException",
|
|
664
|
+
"MissingAuthenticationToken",
|
|
665
|
+
"UnrecognizedClientException"
|
|
666
|
+
]);
|
|
667
|
+
/** Errors of the SDK finding no credentials, or an expired SSO session. */
|
|
668
|
+
const NO_CREDENTIAL_ERRORS = /* @__PURE__ */ new Set(["CredentialsProviderError", "TokenProviderError"]);
|
|
669
|
+
const SIGN_IN_HINT = "In CI, set them with aws-actions/configure-aws-credentials; on your machine, run aws sso login --profile <profile> and set AWS_PROFILE=<profile>";
|
|
670
|
+
const errorName = (error) => error instanceof Error ? error.name : "";
|
|
671
|
+
const errorMessage = (error) => (error instanceof Error ? error.message : String(error)).replace(/\.+$/u, "");
|
|
672
|
+
const resolveEndpoint = (endpoint) => {
|
|
673
|
+
let url;
|
|
674
|
+
try {
|
|
675
|
+
url = new URL(endpoint.trim());
|
|
676
|
+
} catch {
|
|
677
|
+
throw new Error(`${NAME} endpoint must be a valid URL.`);
|
|
678
|
+
}
|
|
679
|
+
const loopback = (/* @__PURE__ */ new Set([
|
|
680
|
+
"localhost",
|
|
681
|
+
"127.0.0.1",
|
|
682
|
+
"[::1]"
|
|
683
|
+
])).has(url.hostname);
|
|
684
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.search || url.hash) throw new Error(`${NAME} endpoint must use HTTPS, or HTTP on a loopback address, without credentials, a query, or a fragment.`);
|
|
685
|
+
return url.toString();
|
|
686
|
+
};
|
|
687
|
+
/** Masks a value in the CLI's output, which loads `@hot-updater/cli-tools`. */
|
|
688
|
+
const mask = async (values) => {
|
|
689
|
+
const { registerSecretValue } = await import("@hot-updater/cli-tools");
|
|
690
|
+
for (const value of values) if (value) registerSecretValue(value);
|
|
691
|
+
};
|
|
692
|
+
const checkCredentials = async (credentials) => {
|
|
693
|
+
if (!credentials?.accessKeyId || !credentials.secretAccessKey) throw new Error(`${NAME} credentials need an accessKeyId and a secretAccessKey.`);
|
|
694
|
+
await mask([credentials.secretAccessKey, credentials.sessionToken]);
|
|
695
|
+
return credentials;
|
|
696
|
+
};
|
|
697
|
+
/**
|
|
698
|
+
* Secrets from AWS Secrets Manager: each name is the secret
|
|
699
|
+
* `<prefix><name>`, its `SecretString`, or its `SecretBinary` as base64.
|
|
700
|
+
* `set` puts a new version of the secret, and creates the secret when it
|
|
701
|
+
* does not exist; AWS keeps the versions labeled `AWSCURRENT` and
|
|
702
|
+
* `AWSPREVIOUS`, and removes the older ones itself. The AWS SDK loads on
|
|
703
|
+
* the first read or write, so a config that only uses `s3Storage` never
|
|
704
|
+
* loads it.
|
|
705
|
+
*
|
|
706
|
+
* @example
|
|
707
|
+
* secrets: secretsManager({ region: "us-east-1" })
|
|
708
|
+
*/
|
|
709
|
+
const secretsManager = ({ credentials, endpoint, prefix = "hot-updater/", region }) => {
|
|
710
|
+
const checkSettings = () => {
|
|
711
|
+
if (typeof region !== "string" || !REGION_PATTERN.test(region)) throw new Error(`${NAME} needs an AWS region, such as "us-east-1", and ${JSON.stringify(region)} is not one.`);
|
|
712
|
+
if (prefix !== "" && !SECRET_ID_PATTERN.test(prefix)) throw new Error(`${NAME}'s prefix takes letters, digits, and /_+=.@- only.`);
|
|
713
|
+
};
|
|
714
|
+
const secretId = (name) => {
|
|
715
|
+
checkSettings();
|
|
716
|
+
const id = `${prefix}${name}`;
|
|
717
|
+
if (!SECRET_ID_PATTERN.test(name) || id.length > MAX_SECRET_ID_LENGTH) throw new Error(`${NAME} cannot name a secret ${JSON.stringify(id)}: AWS takes up to ${MAX_SECRET_ID_LENGTH} letters, digits, and /_+=.@- characters.`);
|
|
718
|
+
return id;
|
|
719
|
+
};
|
|
720
|
+
const resolveCredentials = () => {
|
|
721
|
+
if (credentials !== void 0) return async () => checkCredentials(typeof credentials === "function" ? await credentials() : credentials);
|
|
722
|
+
const accessKeyId = process.env["AWS_ACCESS_KEY_ID"];
|
|
723
|
+
const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"];
|
|
724
|
+
if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) throw new Error(`${NAME} found ${accessKeyId ? "AWS_ACCESS_KEY_ID" : "AWS_SECRET_ACCESS_KEY"} without ${accessKeyId ? "AWS_SECRET_ACCESS_KEY" : "AWS_ACCESS_KEY_ID"} in the environment: set both, or neither to use a profile.`);
|
|
725
|
+
};
|
|
726
|
+
let opened;
|
|
727
|
+
const open = () => {
|
|
728
|
+
checkSettings();
|
|
729
|
+
const signWith = resolveCredentials();
|
|
730
|
+
opened ??= import("@aws-sdk/client-secrets-manager").then((sdk) => ({
|
|
731
|
+
sdk,
|
|
732
|
+
client: new sdk.SecretsManagerClient({
|
|
733
|
+
region,
|
|
734
|
+
...signWith ? { credentials: signWith } : {},
|
|
735
|
+
...endpoint === void 0 ? {} : { endpoint: resolveEndpoint(endpoint) },
|
|
736
|
+
requestHandler: { requestTimeout: REQUEST_TIMEOUT_MS }
|
|
737
|
+
})
|
|
738
|
+
})).catch((error) => {
|
|
739
|
+
opened = void 0;
|
|
740
|
+
throw error;
|
|
741
|
+
});
|
|
742
|
+
return opened;
|
|
743
|
+
};
|
|
744
|
+
const describeFailure = (verb, id, error, permissions) => {
|
|
745
|
+
const name = errorName(error);
|
|
746
|
+
const status = error?.$metadata?.httpStatusCode;
|
|
747
|
+
const detail = `${name || "Error"}${status ? ` (${status})` : ""}: ${errorMessage(error)}`;
|
|
748
|
+
if (NO_CREDENTIAL_ERRORS.has(name)) return new Error(`${NAME} found no usable AWS credentials to ${verb} ${JSON.stringify(id)} (${errorMessage(error)}). ${SIGN_IN_HINT}.`, { cause: error });
|
|
749
|
+
if (EXPIRED_CREDENTIAL_ERRORS.has(name)) return new Error(`AWS Secrets Manager refused the credentials to ${verb} ${JSON.stringify(id)}, ${detail}. They are expired or invalid: sign in again. ${SIGN_IN_HINT}.`, { cause: error });
|
|
750
|
+
if (name === "AccessDeniedException") return new Error(`AWS Secrets Manager refused to ${verb} ${JSON.stringify(id)}, ${detail}. The AWS identity needs ${permissions} on it, and on a secret encrypted with a customer managed KMS key, kms:Decrypt to read and kms:GenerateDataKey to store.`, { cause: error });
|
|
751
|
+
if (/marked for deletion|scheduled for deletion/iu.test(errorMessage(error))) return new Error(`AWS Secrets Manager cannot ${verb} ${JSON.stringify(id)}: it is scheduled for deletion. Restore it with aws secretsmanager restore-secret --secret-id ${id}, or wait until it is deleted.`, { cause: error });
|
|
752
|
+
return new Error(`AWS Secrets Manager failed to ${verb} ${JSON.stringify(id)}, ${detail}.`, { cause: error });
|
|
753
|
+
};
|
|
754
|
+
const read = async ({ sdk, client }, id) => {
|
|
755
|
+
let output;
|
|
756
|
+
try {
|
|
757
|
+
output = await client.send(new sdk.GetSecretValueCommand({ SecretId: id }));
|
|
758
|
+
} catch (error) {
|
|
759
|
+
if (error instanceof sdk.ResourceNotFoundException) return void 0;
|
|
760
|
+
throw describeFailure("read", id, error, "secretsmanager:GetSecretValue");
|
|
761
|
+
}
|
|
762
|
+
const value = output.SecretString ?? (output.SecretBinary === void 0 ? void 0 : Buffer.from(output.SecretBinary).toString("base64"));
|
|
763
|
+
await mask([value]);
|
|
764
|
+
return value;
|
|
765
|
+
};
|
|
766
|
+
return {
|
|
767
|
+
name: NAME,
|
|
768
|
+
get: async (names) => {
|
|
769
|
+
const unique = [...new Set(names)];
|
|
770
|
+
const ids = unique.map(secretId);
|
|
771
|
+
const result = Object.create(null);
|
|
772
|
+
if (unique.length === 0) return result;
|
|
773
|
+
const client = await open();
|
|
774
|
+
const values = await Promise.all(ids.map((id) => read(client, id)));
|
|
775
|
+
unique.forEach((name, index) => {
|
|
776
|
+
result[name] = values[index];
|
|
777
|
+
});
|
|
778
|
+
return result;
|
|
779
|
+
},
|
|
780
|
+
set: async (name, value) => {
|
|
781
|
+
const id = secretId(name);
|
|
782
|
+
if (typeof value !== "string" || value === "") throw new Error(`${NAME} cannot store an empty ${id}.`);
|
|
783
|
+
if (Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) throw new Error(`${NAME} cannot store ${id}: AWS takes up to ${MAX_SECRET_BYTES} bytes per secret.`);
|
|
784
|
+
await mask([value]);
|
|
785
|
+
const { sdk, client } = await open();
|
|
786
|
+
const token = randomUUID();
|
|
787
|
+
const put = () => client.send(new sdk.PutSecretValueCommand({
|
|
788
|
+
ClientRequestToken: token,
|
|
789
|
+
SecretId: id,
|
|
790
|
+
SecretString: value
|
|
791
|
+
}));
|
|
792
|
+
try {
|
|
793
|
+
await put();
|
|
794
|
+
return;
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (!(error instanceof sdk.ResourceNotFoundException)) throw describeFailure("write", id, error, "secretsmanager:PutSecretValue");
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
await client.send(new sdk.CreateSecretCommand({
|
|
800
|
+
ClientRequestToken: token,
|
|
801
|
+
Description: "Stored by Hot Updater.",
|
|
802
|
+
Name: id,
|
|
803
|
+
SecretString: value
|
|
804
|
+
}));
|
|
805
|
+
} catch (error) {
|
|
806
|
+
if (!(error instanceof sdk.ResourceExistsException)) throw describeFailure("create", id, error, "secretsmanager:CreateSecret");
|
|
807
|
+
try {
|
|
808
|
+
await put();
|
|
809
|
+
} catch (retryError) {
|
|
810
|
+
throw describeFailure("write", id, retryError, "secretsmanager:PutSecretValue");
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
describe: (name) => `the secret ${JSON.stringify(`${prefix}${name}`)} in AWS Secrets Manager (${region})`
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
//#endregion
|
|
818
|
+
export { cloudFrontDownloadUrl, dynamoDB, migrateDynamoDB, s3Storage, secretsManager };
|
package/dist/init/index.cjs
CHANGED
|
@@ -6603,6 +6603,14 @@ const migrateDynamoDB = (config, plugins = []) => {
|
|
|
6603
6603
|
return (0, _hot_updater_plugin_core.migrateCoreSchema)(adapter, "dynamoDB", plugins).finally(() => client.destroy());
|
|
6604
6604
|
};
|
|
6605
6605
|
/**
|
|
6606
|
+
* The resource this database reaches, without credentials, under the key
|
|
6607
|
+
* `hot-updater doctor` reads to tell the database `hot-updater init` saved
|
|
6608
|
+
* from another (a convention CLAUDE.md documents).
|
|
6609
|
+
*/
|
|
6610
|
+
const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
|
|
6611
|
+
/** `target` under {@link DATABASE_TARGET}, to spread into the database. */
|
|
6612
|
+
const marked = (target) => ({ [DATABASE_TARGET]: target });
|
|
6613
|
+
/**
|
|
6606
6614
|
* Hot Updater's database on one DynamoDB table, through the storage engine.
|
|
6607
6615
|
* With a distribution, it gives core the CloudFront invalidation core runs
|
|
6608
6616
|
* after a write that changes what the update-check routes answer.
|
|
@@ -6617,6 +6625,7 @@ const dynamoDB = (config) => {
|
|
|
6617
6625
|
onCachedRoutesChange: cloudFront?.invalidate,
|
|
6618
6626
|
aggregateBatching: config.aggregateBatching ?? {}
|
|
6619
6627
|
}),
|
|
6628
|
+
...marked(`dynamodb/${config.tableName}`),
|
|
6620
6629
|
async dispose() {
|
|
6621
6630
|
await adapter.dispose?.();
|
|
6622
6631
|
client.destroy();
|
|
@@ -7961,7 +7970,9 @@ var DynamoDBManager = class {
|
|
|
7961
7970
|
/**
|
|
7962
7971
|
* The partitions the managed server's items use: each table's rows of core
|
|
7963
7972
|
* and its plugins, and its index items after `#`, the log and lease tables
|
|
7964
|
-
* of batched aggregates and the settings rows included.
|
|
7973
|
+
* of batched aggregates and the settings rows included. A table a later
|
|
7974
|
+
* release adds reaches an existing deployment's policy when init runs again,
|
|
7975
|
+
* which writes the table's settings rows and puts this policy.
|
|
7965
7976
|
*/
|
|
7966
7977
|
const dynamoDBLeadingKeys = () => [
|
|
7967
7978
|
...(0, _hot_updater_plugin_core.toolingTargetOf)([
|
package/dist/init/index.mjs
CHANGED
|
@@ -6598,6 +6598,14 @@ const migrateDynamoDB = (config, plugins = []) => {
|
|
|
6598
6598
|
return migrateCoreSchema(adapter, "dynamoDB", plugins).finally(() => client.destroy());
|
|
6599
6599
|
};
|
|
6600
6600
|
/**
|
|
6601
|
+
* The resource this database reaches, without credentials, under the key
|
|
6602
|
+
* `hot-updater doctor` reads to tell the database `hot-updater init` saved
|
|
6603
|
+
* from another (a convention CLAUDE.md documents).
|
|
6604
|
+
*/
|
|
6605
|
+
const DATABASE_TARGET = Symbol.for("@hot-updater/database-target");
|
|
6606
|
+
/** `target` under {@link DATABASE_TARGET}, to spread into the database. */
|
|
6607
|
+
const marked = (target) => ({ [DATABASE_TARGET]: target });
|
|
6608
|
+
/**
|
|
6601
6609
|
* Hot Updater's database on one DynamoDB table, through the storage engine.
|
|
6602
6610
|
* With a distribution, it gives core the CloudFront invalidation core runs
|
|
6603
6611
|
* after a write that changes what the update-check routes answer.
|
|
@@ -6612,6 +6620,7 @@ const dynamoDB = (config) => {
|
|
|
6612
6620
|
onCachedRoutesChange: cloudFront?.invalidate,
|
|
6613
6621
|
aggregateBatching: config.aggregateBatching ?? {}
|
|
6614
6622
|
}),
|
|
6623
|
+
...marked(`dynamodb/${config.tableName}`),
|
|
6615
6624
|
async dispose() {
|
|
6616
6625
|
await adapter.dispose?.();
|
|
6617
6626
|
client.destroy();
|
|
@@ -7956,7 +7965,9 @@ var DynamoDBManager = class {
|
|
|
7956
7965
|
/**
|
|
7957
7966
|
* The partitions the managed server's items use: each table's rows of core
|
|
7958
7967
|
* and its plugins, and its index items after `#`, the log and lease tables
|
|
7959
|
-
* of batched aggregates and the settings rows included.
|
|
7968
|
+
* of batched aggregates and the settings rows included. A table a later
|
|
7969
|
+
* release adds reaches an existing deployment's policy when init runs again,
|
|
7970
|
+
* which writes the table's settings rows and puts this policy.
|
|
7960
7971
|
*/
|
|
7961
7972
|
const dynamoDBLeadingKeys = () => [
|
|
7962
7973
|
...toolingTargetOf([
|