@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 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
- export { CloudFrontDownloadUrlOptions, type DynamoDBConfig, S3StorageConfig, cloudFrontDownloadUrl, dynamoDB, migrateDynamoDB, s3Storage };
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
- export { CloudFrontDownloadUrlOptions, type DynamoDBConfig, S3StorageConfig, cloudFrontDownloadUrl, dynamoDB, migrateDynamoDB, s3Storage };
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
- export { cloudFrontDownloadUrl, dynamoDB, migrateDynamoDB, s3Storage };
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 };
@@ -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)([
@@ -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([