@hot-updater/firebase 1.0.0-rc.40 → 1.0.0-rc.42

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.
@@ -303,7 +303,7 @@ function normalizeFull(version, options = {}) {
303
303
  const parsed = tryParse$1(version, options);
304
304
  return parsed ? formatFullVersion(parsed) : null;
305
305
  }
306
- function coerce(value, options = {}) {
306
+ function coerce$1(value, options = {}) {
307
307
  if (typeof value === "object") return value;
308
308
  const input = typeof value === "number" ? String(value) : value;
309
309
  let match = null;
@@ -326,7 +326,7 @@ function coerce(value, options = {}) {
326
326
  * dropped.
327
327
  */
328
328
  function canonicalizeAppVersion(appVersion) {
329
- const version = coerce(appVersion);
329
+ const version = coerce$1(appVersion);
330
330
  return version ? normalizeFull(version) : null;
331
331
  }
332
332
  const stripBundleArtifactMetadata = (metadata) => metadata;
@@ -427,10 +427,22 @@ function normalizeCohortValue(cohort) {
427
427
  function getNumericCohortValue(cohort) {
428
428
  return parseNumericCohortValue(normalizeCohortValue(cohort));
429
429
  }
430
+ function isNumericCohort(cohort) {
431
+ return getNumericCohortValue(cohort) !== null;
432
+ }
430
433
  function isCustomCohort(cohort) {
431
434
  const normalized = normalizeCohortValue(cohort);
432
435
  return normalized.length > 0 && normalized.length <= 64 && !/^\d+$/.test(normalized) && CUSTOM_COHORT_PATTERN.test(normalized);
433
436
  }
437
+ function isValidCohort(cohort) {
438
+ const normalized = normalizeCohortValue(cohort);
439
+ return isNumericCohort(normalized) || isCustomCohort(normalized);
440
+ }
441
+ function getNumericCohortRolloutPosition(bundleId, cohortValue) {
442
+ if (cohortValue < 1 || cohortValue > 1e3) throw new Error(`Invalid numeric cohort: ${cohortValue}`);
443
+ const { offset, inverseMultiplier } = getRolloutShuffleParameters(bundleId);
444
+ return positiveMod(inverseMultiplier * (cohortValue - 1 - offset), NUMERIC_COHORT_SIZE);
445
+ }
434
446
  function getRolledOutNumericCohorts(bundleId, rolloutCohortCount) {
435
447
  const normalizedRolloutCount = normalizeRolloutCohortCount(rolloutCohortCount);
436
448
  if (normalizedRolloutCount <= 0) return [];
@@ -768,6 +780,25 @@ function parseComparator$1(comparator, options = {}) {
768
780
  version
769
781
  };
770
782
  }
783
+ function testParsedComparator$1(comparator, version) {
784
+ if (!comparator.version) return true;
785
+ const comparison = compareParsed$1(version, comparator.version);
786
+ switch (comparator.operator) {
787
+ case "": return comparison === 0;
788
+ case ">": return comparison > 0;
789
+ case ">=": return comparison >= 0;
790
+ case "<": return comparison < 0;
791
+ case "<=": return comparison <= 0;
792
+ }
793
+ }
794
+ function comparatorAllowsPrerelease$1(comparator, version) {
795
+ const allowed = comparator.version;
796
+ return allowed !== null && !!allowed.prerelease?.length && allowed.major === version.major && allowed.minor === version.minor && allowed.patch === version.patch;
797
+ }
798
+ function testComparatorSet$1(set, version, options) {
799
+ if (set.some((comparator) => !testParsedComparator$1(comparator, version))) return false;
800
+ return !version.prerelease?.length || !!options.includePrerelease || set.some((comparator) => comparatorAllowsPrerelease$1(comparator, version));
801
+ }
771
802
  //#endregion
772
803
  //#region ../plugin-core/dist/node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/range-DvX-Y6iv.mjs
773
804
  function formatRange(range) {
@@ -915,25 +946,61 @@ function parseRange$1(range, options = {}) {
915
946
  sets
916
947
  };
917
948
  }
918
- function tryParseRange(range, options = {}) {
949
+ function tryParseRange$1(range, options = {}) {
919
950
  try {
920
951
  return parseRange$1(range, options);
921
952
  } catch {
922
953
  return null;
923
954
  }
924
955
  }
956
+ function testParsedRange(range, version) {
957
+ return range.sets.some((set) => testComparatorSet$1(set, version, range.options));
958
+ }
959
+ function testRangeVersion(range, version) {
960
+ const parsed = tryParse(version, range.options);
961
+ return parsed ? testParsedRange(range, parsed) : false;
962
+ }
925
963
  function normalizeRange(range, options = {}) {
926
- const parsed = tryParseRange(range, options);
964
+ const parsed = tryParseRange$1(range, options);
927
965
  return parsed ? formatRange(parsed) || "*" : null;
928
966
  }
929
- safeRegex$1(COERCE$1);
930
- safeRegex$1(COERCE_FULL$1);
967
+ function satisfies(version, range, options = {}) {
968
+ const parsed = tryParseRange$1(range, options);
969
+ return parsed ? testRangeVersion(parsed, version) : false;
970
+ }
971
+ //#endregion
972
+ //#region ../plugin-core/dist/node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/version-CQ98ZBpL.mjs
973
+ const COERCE_EXACT = safeRegex$1(COERCE$1);
974
+ const COERCE_FULL_EXACT = safeRegex$1(COERCE_FULL$1);
931
975
  safeRegex$1(`^${PRERELEASE$1}$`);
932
976
  safeRegex$1(`^${PRERELEASE_LOOSE$1}$`);
933
977
  function normalize(version, options = {}) {
934
978
  const parsed = tryParse(version, options);
935
979
  return parsed ? formatComparableVersion$1(parsed) : null;
936
980
  }
981
+ function coerce(value, options = {}) {
982
+ if (typeof value === "object") return value;
983
+ const input = typeof value === "number" ? String(value) : value;
984
+ let match = null;
985
+ if (options.rtl) {
986
+ const expression = safeRegex$1(options.includePrerelease ? COERCE_FULL$1 : COERCE$1, "g");
987
+ let next;
988
+ while ((next = expression.exec(input)) && (!match || match.index + match[0].length !== input.length)) {
989
+ if (!match || next.index + next[0].length !== match.index + match[0].length) match = next;
990
+ expression.lastIndex = next.index + next[1].length + next[2].length;
991
+ }
992
+ } else match = (options.includePrerelease ? COERCE_FULL_EXACT : COERCE_EXACT).exec(input);
993
+ if (!match) return null;
994
+ const major = match[2];
995
+ return tryParse(`${major}.${match[3] || "0"}.${match[4] || "0"}${options.includePrerelease && match[5] ? `-${match[5]}` : ""}${options.includePrerelease && match[6] ? `+${match[6]}` : ""}`, options);
996
+ }
997
+ //#endregion
998
+ //#region ../plugin-core/dist/semverSatisfies.mjs
999
+ const semverSatisfies = (targetAppVersion, currentVersion) => {
1000
+ const currentCoerce = coerce(currentVersion);
1001
+ if (!currentCoerce) return false;
1002
+ return satisfies(currentCoerce, targetAppVersion);
1003
+ };
937
1004
  //#endregion
938
1005
  //#region ../plugin-core/dist/uuidv7.mjs
939
1006
  const extractTimestampFromUUIDv7 = (uuid) => {
@@ -4524,7 +4591,7 @@ const createKvAdapter = ({ store, tablePrefix = "" }) => {
4524
4591
  };
4525
4592
  //#endregion
4526
4593
  //#region ../../packages/server/dist/version.mjs
4527
- const HOT_UPDATER_SERVER_VERSION = "1.0.0-rc.40";
4594
+ const HOT_UPDATER_SERVER_VERSION = "1.0.0-rc.42";
4528
4595
  //#endregion
4529
4596
  //#region ../../packages/server/dist/handlerVersionRoutes.mjs
4530
4597
  /**
@@ -4558,14 +4625,18 @@ const createVersionRouteHandlers = (plugins = []) => {
4558
4625
  * another's internals for it. It guards Hot Updater's ids against a
4559
4626
  * clash, not against a determined impostor.
4560
4627
  */
4561
- const OFFICIAL$2 = Symbol.for("@hot-updater/server/official-plugin");
4628
+ const OFFICIAL$3 = Symbol.for("@hot-updater/server/official-plugin");
4562
4629
  /**
4563
4630
  * The ids of Hot Updater's own plugins. The console and tooling tell
4564
4631
  * features apart by plugin id, so no other plugin may take one.
4565
4632
  */
4566
- const OFFICIAL_IDS = /* @__PURE__ */ new Set(["insights", "apiKeys"]);
4633
+ const OFFICIAL_IDS = /* @__PURE__ */ new Set([
4634
+ "insights",
4635
+ "apiKeys",
4636
+ "remoteConfig"
4637
+ ]);
4567
4638
  /** Whether one of Hot Updater's own factories made `plugin`. */
4568
- const isOfficialPlugin = (plugin) => typeof plugin === "object" && plugin !== null && Reflect.get(plugin, OFFICIAL$2) === true;
4639
+ const isOfficialPlugin = (plugin) => typeof plugin === "object" && plugin !== null && Reflect.get(plugin, OFFICIAL$3) === true;
4569
4640
  /**
4570
4641
  * Refuses a plugin that takes one of Hot Updater's own ids without being
4571
4642
  * that plugin: `at` names it in the error, such as `plugins[0]`.
@@ -4633,7 +4704,7 @@ const UNAVAILABLE_NAMES = /* @__PURE__ */ new Set([
4633
4704
  "with",
4634
4705
  "yield"
4635
4706
  ]);
4636
- const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4707
+ const isRecord$7 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4637
4708
  /**
4638
4709
  * The client plugins an app adds for the plugins, each once, in plugin
4639
4710
  * order: what init prints in `HotUpdater.init`'s `plugins`. A malformed one,
@@ -4644,11 +4715,11 @@ const clientPluginsOf = (plugins) => {
4644
4715
  const found = [];
4645
4716
  plugins.forEach((plugin, position) => checkReservedId(plugin, `plugins[${position}]`));
4646
4717
  for (const plugin of plugins) {
4647
- if (!isRecord$6(plugin) || !isRecord$6(plugin.cli)) continue;
4718
+ if (!isRecord$7(plugin) || !isRecord$7(plugin.cli)) continue;
4648
4719
  const value = plugin.cli.clientPlugin;
4649
4720
  if (value === void 0) continue;
4650
4721
  const id = String(plugin.id);
4651
- if (!isRecord$6(value) || typeof value.module !== "string" || value.module === "" || typeof value.name !== "string" || !EXPORT_NAME.test(value.name) || UNAVAILABLE_NAMES.has(value.name)) throw new HotUpdaterConfigError(`Plugin "${id}" cli.clientPlugin needs a module and an export name the app can import: an identifier other than a reserved word, App, or HotUpdater.`);
4722
+ if (!isRecord$7(value) || typeof value.module !== "string" || value.module === "" || typeof value.name !== "string" || !EXPORT_NAME.test(value.name) || UNAVAILABLE_NAMES.has(value.name)) throw new HotUpdaterConfigError(`Plugin "${id}" cli.clientPlugin needs a module and an export name the app can import: an identifier other than a reserved word, App, or HotUpdater.`);
4652
4723
  const { module, name } = value;
4653
4724
  const named = found.find((entry) => entry.name === name);
4654
4725
  if (named === void 0) found.push({
@@ -5144,6 +5215,13 @@ function parseRange(range, options = {}) {
5144
5215
  sets
5145
5216
  };
5146
5217
  }
5218
+ function tryParseRange(range, options = {}) {
5219
+ try {
5220
+ return parseRange(range, options);
5221
+ } catch {
5222
+ return null;
5223
+ }
5224
+ }
5147
5225
  function exactVersion(set) {
5148
5226
  for (const comparator of set) if (comparator.operator === "" && comparator.version) return comparator.version;
5149
5227
  return null;
@@ -5169,6 +5247,9 @@ function setsIntersect(left, right, options) {
5169
5247
  function parsedRangesIntersect(left, right, options = {}) {
5170
5248
  return left.sets.some((leftSet) => isSatisfiable(leftSet, options) && right.sets.some((rightSet) => isSatisfiable(rightSet, options) && setsIntersect(leftSet, rightSet, options)));
5171
5249
  }
5250
+ function isValidRange(range, options = {}) {
5251
+ return tryParseRange(range, options) !== null;
5252
+ }
5172
5253
  function rangesIntersect(left, right, options = {}) {
5173
5254
  const parsedLeft = parseRange(left, options);
5174
5255
  const parsedRight = parseRange(right, options);
@@ -6161,9 +6242,9 @@ const PATH = /^(\/(:?[A-Za-z0-9_.~-]+))+$/u;
6161
6242
  const fail = (message) => {
6162
6243
  throw new HotUpdaterConfigError(message);
6163
6244
  };
6164
- const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6245
+ const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6165
6246
  const checkPlugin = (value, at) => {
6166
- if (!isRecord$5(value)) return fail(`${at} is not a plugin.`);
6247
+ if (!isRecord$6(value)) return fail(`${at} is not a plugin.`);
6167
6248
  if ("kind" in value) fail(`${at} has a kind; core is built in and is never passed as a plugin.`);
6168
6249
  const unknown = Object.keys(value).find((key) => !PLUGIN_KEYS.has(key));
6169
6250
  if (unknown !== void 0) fail(`${at} has an unknown key "${unknown}".`);
@@ -6173,18 +6254,18 @@ const checkPlugin = (value, at) => {
6173
6254
  if (typeof plugin.id !== "string" || !pattern.test(plugin.id)) fail(`${at} needs an id matching ${pattern.source}.`);
6174
6255
  if (plugin.id === "core") fail(`${at} uses the id "core", which is core's own.`);
6175
6256
  checkReservedId(value, at);
6176
- if (typeof plugin.schemaVersion !== "string" || !isRecord$5(plugin.schema) || typeof plugin.init !== "function") fail(`Plugin "${plugin.id}" needs a schemaVersion, a schema, and init.`);
6257
+ if (typeof plugin.schemaVersion !== "string" || !isRecord$6(plugin.schema) || typeof plugin.init !== "function") fail(`Plugin "${plugin.id}" needs a schemaVersion, a schema, and init.`);
6177
6258
  const provides = plugin.provides;
6178
- if (provides !== void 0 && (!isRecord$5(provides) || Object.entries(provides).some(([key, flag]) => key !== "clientAuth" || flag !== true))) fail(`Plugin "${plugin.id}" may only declare provides: { clientAuth: true }.`);
6259
+ if (provides !== void 0 && (!isRecord$6(provides) || Object.entries(provides).some(([key, flag]) => key !== "clientAuth" || flag !== true))) fail(`Plugin "${plugin.id}" may only declare provides: { clientAuth: true }.`);
6179
6260
  const cli = value.cli;
6180
6261
  if (cli !== void 0) {
6181
- if (!isRecord$5(cli) || Object.keys(cli).some((key) => !CLI_KEYS.has(key))) fail(`Plugin "${plugin.id}" cli may only hold clientCredential and clientPlugin.`);
6262
+ if (!isRecord$6(cli) || Object.keys(cli).some((key) => !CLI_KEYS.has(key))) fail(`Plugin "${plugin.id}" cli may only hold clientCredential and clientPlugin.`);
6182
6263
  if (cli.clientCredential !== void 0 && plugin.provides?.clientAuth !== true) fail(`Plugin "${plugin.id}" adds cli.clientCredential without declaring provides: { clientAuth: true }.`);
6183
6264
  }
6184
6265
  return plugin;
6185
6266
  };
6186
6267
  const checkEndpoint = (id, value) => {
6187
- const endpoint = isRecord$5(value) ? value : {};
6268
+ const endpoint = isRecord$6(value) ? value : {};
6188
6269
  if (!METHODS.has(endpoint.method) || typeof endpoint.path !== "string" || !PATH.test(endpoint.path) || endpoint.access !== "client" && endpoint.access !== "admin" || typeof endpoint.handler !== "function") fail(`Plugin "${id}" has an endpoint that needs a method, a path like /name/:param, access "client" or "admin", and a handler.`);
6189
6270
  return {
6190
6271
  ...endpoint,
@@ -6193,7 +6274,7 @@ const checkEndpoint = (id, value) => {
6193
6274
  };
6194
6275
  const checkInstance = (plugin, instance) => {
6195
6276
  const { id } = plugin;
6196
- if (!isRecord$5(instance)) return fail(`Plugin "${id}" init returned no instance.`);
6277
+ if (!isRecord$6(instance)) return fail(`Plugin "${id}" init returned no instance.`);
6197
6278
  if (typeof instance.then === "function") fail(`Plugin "${id}" init returned a promise; init must be synchronous.`);
6198
6279
  const unknown = Object.keys(instance).find((key) => !INSTANCE_KEYS.has(key));
6199
6280
  if (unknown !== void 0 || !("api" in instance)) fail(`Plugin "${id}" init must return { api, endpoints?, clientAuth? }${unknown === void 0 ? "" : `, not "${unknown}"`}.`);
@@ -6300,8 +6381,8 @@ const parseBooleanSearchParam = (url, key) => {
6300
6381
  };
6301
6382
  //#endregion
6302
6383
  //#region ../../packages/server/dist/handlerAdminRoutes.mjs
6303
- const json$1 = (body, status = 200) => Response.json(body, { status });
6304
- const notFound = () => json$1({ error: "Not found" }, 404);
6384
+ const json$2 = (body, status = 200) => Response.json(body, { status });
6385
+ const notFound = () => json$2({ error: "Not found" }, 404);
6305
6386
  const noContent = () => new Response(null, { status: 204 });
6306
6387
  /** Runs `route` on core, answering its refusals. */
6307
6388
  const onCore = (route) => async (params, request, api) => answer(() => route(api.core, request, params));
@@ -6312,16 +6393,16 @@ const answer = async (operation) => {
6312
6393
  } catch (error) {
6313
6394
  if (error instanceof ReleaseManagementError || error instanceof ReleaseCatalogMutationError) {
6314
6395
  const status = error.code === "RELEASE_NOT_FOUND" ? 404 : error.code === "VERSION_CONFLICT" ? 409 : 400;
6315
- return json$1({
6396
+ return json$2({
6316
6397
  code: error.code,
6317
6398
  error: error.message
6318
6399
  }, status);
6319
6400
  }
6320
- if (error instanceof DatabaseBundleNotFoundError) return json$1({
6401
+ if (error instanceof DatabaseBundleNotFoundError) return json$2({
6321
6402
  code: "BUNDLE_NOT_FOUND",
6322
6403
  error: error.message
6323
6404
  }, 404);
6324
- if (error instanceof DatabaseRowReferencedError) return json$1({
6405
+ if (error instanceof DatabaseRowReferencedError) return json$2({
6325
6406
  code: "REFERENCED",
6326
6407
  error: error.message
6327
6408
  }, 409);
@@ -6347,7 +6428,7 @@ const keyset = (url, maxValue = LIMITS.maxValue) => {
6347
6428
  };
6348
6429
  };
6349
6430
  /** A page and the cursor of the next one, when the page is full. */
6350
- const page = (rows, { limit }, keyOf, extra = {}) => json$1({
6431
+ const page = (rows, { limit }, keyOf, extra = {}) => json$2({
6351
6432
  data: rows,
6352
6433
  ...rows.length === limit && rows.length > 0 ? { next: keyOf(rows.at(-1)) } : {},
6353
6434
  ...extra
@@ -6391,18 +6472,18 @@ const releaseFilter = (url) => {
6391
6472
  };
6392
6473
  throw new HandlerBadRequestError("Releases filter by none, channelId and platform (with enabled), bundleId, or scopeKey (with enabled).");
6393
6474
  };
6394
- const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6475
+ const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6395
6476
  const body = async (request) => {
6396
6477
  const value = await request.json();
6397
- if (!isRecord$4(value)) throw new HandlerBadRequestError("Invalid body");
6478
+ if (!isRecord$5(value)) throw new HandlerBadRequestError("Invalid body");
6398
6479
  return value;
6399
6480
  };
6400
6481
  const isText$2 = (value) => typeof value === "string" && value.length > 0;
6401
6482
  const isTextOrNull$1 = (value) => value === null || isText$2(value);
6402
- const isBundle = (bundle) => isRecord$4(bundle) && isText$2(bundle.id) && typeof bundle.platform === "string" && isPlatform(bundle.platform) && isTextOrNull$1(bundle.gitCommitHash) && isText$2(bundle.manifestStorageUri) && isText$2(bundle.manifestFileHash) && isText$2(bundle.assetBaseStorageUri) && (bundle.metadata === void 0 || isRecord$4(bundle.metadata)) && (bundle.patches === void 0 || bundle.patches === null || Array.isArray(bundle.patches));
6403
- const isReleasePolicy = (release) => isRecord$4(release) && isText$2(release.channel) && typeof release.enabled === "boolean" && typeof release.shouldForceUpdate === "boolean" && isTextOrNull$1(release.fingerprintHash) && isTextOrNull$1(release.targetAppVersion) && (release.message === null || typeof release.message === "string") && (release.rolloutCohortCount === void 0 || Number.isSafeInteger(release.rolloutCohortCount) && release.rolloutCohortCount >= 0 && release.rolloutCohortCount <= 1e3) && (release.targetCohorts === void 0 || Array.isArray(release.targetCohorts) && release.targetCohorts.every(isText$2));
6483
+ const isBundle = (bundle) => isRecord$5(bundle) && isText$2(bundle.id) && typeof bundle.platform === "string" && isPlatform(bundle.platform) && isTextOrNull$1(bundle.gitCommitHash) && isText$2(bundle.manifestStorageUri) && isText$2(bundle.manifestFileHash) && isText$2(bundle.assetBaseStorageUri) && (bundle.metadata === void 0 || isRecord$5(bundle.metadata)) && (bundle.patches === void 0 || bundle.patches === null || Array.isArray(bundle.patches));
6484
+ const isReleasePolicy = (release) => isRecord$5(release) && isText$2(release.channel) && typeof release.enabled === "boolean" && typeof release.shouldForceUpdate === "boolean" && isTextOrNull$1(release.fingerprintHash) && isTextOrNull$1(release.targetAppVersion) && (release.message === null || typeof release.message === "string") && (release.rolloutCohortCount === void 0 || Number.isSafeInteger(release.rolloutCohortCount) && release.rolloutCohortCount >= 0 && release.rolloutCohortCount <= 1e3) && (release.targetCohorts === void 0 || Array.isArray(release.targetCohorts) && release.targetCohorts.every(isText$2));
6404
6485
  /** A deployment's shape: a new bundle or a stored bundle's id, and its release policy. */
6405
- const isDeployment = (value) => isRecord$4(value) && isReleasePolicy(value.release) && ("bundleId" in value ? isText$2(value.bundleId) && !("bundle" in value) : isBundle(value.bundle));
6486
+ const isDeployment = (value) => isRecord$5(value) && isReleasePolicy(value.release) && ("bundleId" in value ? isText$2(value.bundleId) && !("bundle" in value) : isBundle(value.bundle));
6406
6487
  /** An expected release revision from a body number or a query string. */
6407
6488
  const revisionOf = (value) => {
6408
6489
  if (value === void 0 || value === null || value === "") return void 0;
@@ -6413,7 +6494,7 @@ const revisionOf = (value) => {
6413
6494
  /** A policy change's body: `{ expectedRevision?, patch }`. */
6414
6495
  const policyInput = async (request) => {
6415
6496
  const input = await body(request);
6416
- if (!isRecord$4(input.patch)) throw new HandlerBadRequestError("Invalid Release policy mutation");
6497
+ if (!isRecord$5(input.patch)) throw new HandlerBadRequestError("Invalid Release policy mutation");
6417
6498
  const expectedRevision = revisionOf(input.expectedRevision);
6418
6499
  return {
6419
6500
  patch: input.patch,
@@ -6422,7 +6503,7 @@ const policyInput = async (request) => {
6422
6503
  };
6423
6504
  /** A bundle update's body: the fields it changes, never another bundle's id. */
6424
6505
  const bundleUpdateOf = (value, bundleId) => {
6425
- if (!isRecord$4(value)) throw new HandlerBadRequestError("Invalid bundle payload");
6506
+ if (!isRecord$5(value)) throw new HandlerBadRequestError("Invalid bundle payload");
6426
6507
  const { id, ...update } = value;
6427
6508
  if (id !== void 0 && id !== bundleId) throw new HandlerBadRequestError("Bundle id mismatch");
6428
6509
  return update;
@@ -6443,7 +6524,7 @@ const createAdminRouteHandlers = () => ({
6443
6524
  }),
6444
6525
  getBundle: onCore(async (core, _request, params) => {
6445
6526
  const detail = await core.getBundle(requireRouteParam(params, "id"));
6446
- return detail === null ? notFound() : json$1({ data: detail });
6527
+ return detail === null ? notFound() : json$2({ data: detail });
6447
6528
  }),
6448
6529
  listReleases: onCore(async (core, request) => {
6449
6530
  const url = new URL(request.url);
@@ -6462,31 +6543,31 @@ const createAdminRouteHandlers = () => ({
6462
6543
  /** With `name`, the one channel of that name: 1 read. */
6463
6544
  getChannels: onCore(async (core, request) => {
6464
6545
  const name = new URL(request.url).searchParams.get("name");
6465
- if (name === null) return json$1({ data: await core.listChannels() });
6546
+ if (name === null) return json$2({ data: await core.listChannels() });
6466
6547
  const channel = await core.findChannelByName(name);
6467
- return channel === null ? notFound() : json$1({ data: channel });
6548
+ return channel === null ? notFound() : json$2({ data: channel });
6468
6549
  }),
6469
6550
  /** The channel named in the body, created when missing. */
6470
6551
  createChannel: onCore(async (core, request) => {
6471
6552
  const { name } = await body(request);
6472
6553
  if (typeof name !== "string" || name.trim().length === 0) throw new HandlerBadRequestError("Invalid channel name");
6473
- return json$1({ data: await core.ensureChannel(name) });
6554
+ return json$2({ data: await core.ensureChannel(name) });
6474
6555
  }),
6475
6556
  /** 204 when deleted; 404 or 409 with the reason otherwise. */
6476
6557
  deleteChannel: onCore(async (core, _request, params) => {
6477
6558
  const result = await core.deleteChannel(decodeMaybe(requireRouteParam(params, "id")) ?? "");
6478
6559
  if (result.deleted) return noContent();
6479
- return json$1({ data: result }, result.reason === "not_found" ? 404 : 409);
6560
+ return json$2({ data: result }, result.reason === "not_found" ? 404 : 409);
6480
6561
  }),
6481
6562
  getRelease: onCore(async (core, _request, params) => {
6482
6563
  const row = await core.getRelease(requireRouteParam(params, "id"));
6483
- return row === null ? notFound() : json$1({ data: row });
6564
+ return row === null ? notFound() : json$2({ data: row });
6484
6565
  }),
6485
- updateRelease: onCore(async (core, request, params) => json$1({ data: await core.updateReleasePolicy({
6566
+ updateRelease: onCore(async (core, request, params) => json$2({ data: await core.updateReleasePolicy({
6486
6567
  ...await policyInput(request),
6487
6568
  releaseId: requireRouteParam(params, "id")
6488
6569
  }) })),
6489
- preflightRelease: onCore(async (core, request, params) => json$1({ data: await core.preflightReleasePolicy({
6570
+ preflightRelease: onCore(async (core, request, params) => json$2({ data: await core.preflightReleasePolicy({
6490
6571
  ...await policyInput(request),
6491
6572
  releaseId: requireRouteParam(params, "id")
6492
6573
  }) })),
@@ -6496,26 +6577,26 @@ const createAdminRouteHandlers = () => ({
6496
6577
  const url = new URL(request.url);
6497
6578
  if (url.searchParams.get("confirm") !== releaseId) throw new HandlerBadRequestError("Release hard deletion requires confirm=<release-id>");
6498
6579
  const expectedRevision = revisionOf(url.searchParams.get("expectedRevision"));
6499
- return json$1({ data: await core.deleteRelease({
6580
+ return json$2({ data: await core.deleteRelease({
6500
6581
  releaseId,
6501
6582
  ...expectedRevision === void 0 ? {} : { expectedRevision }
6502
6583
  }) });
6503
6584
  }),
6504
6585
  getReleaseCatalogRow: onCore(async (core, _request, params) => {
6505
6586
  const row = await core.getReleaseCatalogRow(scopeKeyOf(params));
6506
- return row === null ? notFound() : json$1({ data: row });
6587
+ return row === null ? notFound() : json$2({ data: row });
6507
6588
  }),
6508
- rebuildReleaseCatalog: onCore(async (core, _request, params) => json$1({ data: await core.rebuildReleaseCatalog(scopeKeyOf(params)) })),
6589
+ rebuildReleaseCatalog: onCore(async (core, _request, params) => json$2({ data: await core.rebuildReleaseCatalog(scopeKeyOf(params)) })),
6509
6590
  updateBundle: onCore(async (core, request, params) => {
6510
6591
  const bundleId = requireRouteParam(params, "id");
6511
6592
  await core.updateBundle(bundleId, bundleUpdateOf(await request.json(), bundleId));
6512
6593
  return noContent();
6513
6594
  }),
6514
- countBundles: onCore(async (core, request) => json$1({ data: await core.countBundles(platformOf(new URL(request.url))) })),
6595
+ countBundles: onCore(async (core, request) => json$2({ data: await core.countBundles(platformOf(new URL(request.url))) })),
6515
6596
  countBundleChildren: onCore(async (core, request) => {
6516
6597
  const ids = (new URL(request.url).searchParams.get("ids") ?? "").split(",").filter((id) => id.length > 0);
6517
6598
  if (ids.length === 0 || ids.length > 100) throw new HandlerBadRequestError("Bundle child counts take ids=<bundle id>,... with 1 to 100 ids.");
6518
- return json$1({ data: await core.countBundleChildren(ids) });
6599
+ return json$2({ data: await core.countBundleChildren(ids) });
6519
6600
  }),
6520
6601
  listBundleChildren: onCore(async (core, request, params) => {
6521
6602
  const input = keyset(new URL(request.url));
@@ -6527,25 +6608,25 @@ const createAdminRouteHandlers = () => ({
6527
6608
  const before = url.searchParams.get("before");
6528
6609
  const limit = Number(url.searchParams.get("limit") ?? "3");
6529
6610
  if (before === null || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new HandlerBadRequestError("Base candidates need before=<bundle id> and a limit between 1 and 100.");
6530
- return json$1({ data: await core.findBaseBundleIds(decodeMaybe(requireRouteParam(params, "candidateKey")) ?? "", before, limit) });
6611
+ return json$2({ data: await core.findBaseBundleIds(decodeMaybe(requireRouteParam(params, "candidateKey")) ?? "", before, limit) });
6531
6612
  }),
6532
6613
  deployReleases: onCore(async (core, request) => {
6533
6614
  const { deployments } = await body(request);
6534
6615
  if (!Array.isArray(deployments) || deployments.length === 0 || !deployments.every(isDeployment)) throw new HandlerBadRequestError("Deploy needs deployments, each a bundle and its release policy.");
6535
- return json$1({ data: await core.deploy(deployments) }, 201);
6616
+ return json$2({ data: await core.deploy(deployments) }, 201);
6536
6617
  }),
6537
6618
  promoteRelease: onCore(async (core, request, params) => {
6538
6619
  const input = await body(request);
6539
6620
  if (typeof input.targetChannel !== "string" || input.action !== void 0 && input.action !== "copy" && input.action !== "move") throw new HandlerBadRequestError("Invalid Release promotion");
6540
6621
  const expectedRevision = revisionOf(input.expectedRevision);
6541
- return json$1({ data: await core.promoteRelease({
6622
+ return json$2({ data: await core.promoteRelease({
6542
6623
  releaseId: requireRouteParam(params, "id"),
6543
6624
  targetChannel: input.targetChannel,
6544
6625
  ...input.action === void 0 ? {} : { action: input.action },
6545
6626
  ...expectedRevision === void 0 ? {} : { expectedRevision }
6546
6627
  }) });
6547
6628
  }),
6548
- preflightReleaseCatalogRebuild: onCore(async (core, _request, params) => json$1({ data: await core.preflightReleaseCatalogRebuild(scopeKeyOf(params)) })),
6629
+ preflightReleaseCatalogRebuild: onCore(async (core, _request, params) => json$2({ data: await core.preflightReleaseCatalogRebuild(scopeKeyOf(params)) })),
6549
6630
  deleteBundles: onCore(async (core, request) => {
6550
6631
  const { ids } = await body(request);
6551
6632
  if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && id.length > 0)) throw new HandlerBadRequestError("Bundle deletion needs ids");
@@ -7060,8 +7141,8 @@ const createStorageAccess = (storageAdapters) => {
7060
7141
  };
7061
7142
  //#endregion
7062
7143
  //#region ../../packages/server/dist/createHotUpdaterCore.mjs
7063
- const isRecord$3 = (value) => typeof value === "object" && value !== null;
7064
- const isAdapter = (value) => isRecord$3(value) && [
7144
+ const isRecord$4 = (value) => typeof value === "object" && value !== null;
7145
+ const isAdapter = (value) => isRecord$4(value) && [
7065
7146
  "fits",
7066
7147
  "get",
7067
7148
  "query",
@@ -7069,7 +7150,7 @@ const isAdapter = (value) => isRecord$3(value) && [
7069
7150
  ].every((method) => typeof value[method] === "function");
7070
7151
  /** The configured database, when it runs on the storage engine. */
7071
7152
  const databaseOf = (value) => {
7072
- if (isRecord$3(value) && typeof value.name === "string") {
7153
+ if (isRecord$4(value) && typeof value.name === "string") {
7073
7154
  if (isAdapter(value.adapter)) return value;
7074
7155
  if ("core" in value && "fetchAdmin" in value) throw new HotUpdaterConfigError("standaloneRepository reaches a server's admin API from the CLI and console. createHotUpdater needs the server's own database, such as kyselyAdapter(...).");
7075
7156
  }
@@ -7079,7 +7160,7 @@ const databaseOf = (value) => {
7079
7160
  const isPublic = (value) => {
7080
7161
  if (value === void 0) return false;
7081
7162
  if (value === "public") return true;
7082
- throw new HotUpdaterConfigError(isRecord$3(value) ? "clientAccess objects were removed in 1.0. Set clientAccess: \"public\", or add a plugin that provides clientAuth to plugins." : "clientAccess must be \"public\"; to protect client routes, add a plugin that provides clientAuth to plugins.");
7163
+ throw new HotUpdaterConfigError(isRecord$4(value) ? "clientAccess objects were removed in 1.0. Set clientAccess: \"public\", or add a plugin that provides clientAuth to plugins." : "clientAccess must be \"public\"; to protect client routes, add a plugin that provides clientAuth to plugins.");
7083
7164
  };
7084
7165
  function createHotUpdater(options) {
7085
7166
  for (const key of ["authorityId", "catalogId"]) if (Object.hasOwn(options, key)) throw new TypeError(`Remove ${key} from createHotUpdater options. Catalog identity is managed internally.`);
@@ -7596,9 +7677,9 @@ const createApiKeyModel = (db) => ({
7596
7677
  * Each official plugin package keeps this copy, so none imports another's
7597
7678
  * internals for it.
7598
7679
  */
7599
- const OFFICIAL$1 = Symbol.for("@hot-updater/server/official-plugin");
7680
+ const OFFICIAL$2 = Symbol.for("@hot-updater/server/official-plugin");
7600
7681
  /** Marks a plugin one of Hot Updater's own factories made. */
7601
- const markOfficial$1 = (plugin) => Object.defineProperty(plugin, OFFICIAL$1, { value: true });
7682
+ const markOfficial$2 = (plugin) => Object.defineProperty(plugin, OFFICIAL$2, { value: true });
7602
7683
  //#endregion
7603
7684
  //#region ../api-keys/dist/server/schema.mjs
7604
7685
  /** v1's `api_keys` columns; the plaintext key is never stored, only its SHA-256 digest. */
@@ -7678,7 +7759,7 @@ const apiKeys = (options = {}) => {
7678
7759
  },
7679
7760
  cli: apiKeysCli(headerName)
7680
7761
  });
7681
- return markOfficial$1(plugin);
7762
+ return markOfficial$2(plugin);
7682
7763
  };
7683
7764
  //#endregion
7684
7765
  //#region ../insights/dist/server/schema.mjs
@@ -8260,7 +8341,7 @@ const isDatabaseHttpResponse = (value) => isDatabaseJsonObject(value) && (value.
8260
8341
  /** An `UPDATE_FAILED` report's stored failure: a stage and a reason, and what else the client knew. */
8261
8342
  const isDatabaseBundleEventFailure = (value) => isDatabaseJsonObject(value) && isString(value.stage) && isString(value.reason) && isOptional(value, "resource", isString) && isOptional(value, "http_status", (status) => Number.isSafeInteger(status)) && isOptional(value, "transport", isString) && isOptional(value, "origin_code", isString) && isOptional(value, "error_message", isString) && isOptional(value, "error_stack", isString);
8262
8343
  const isDatabaseBundleEventMetadata = (value) => isDatabaseJsonObject(value) && typeof value.cohort === "string" && (value.update_strategy === null || value.update_strategy === "fingerprint" || value.update_strategy === "appVersion") && (value.fingerprint_hash === null || typeof value.fingerprint_hash === "string") && (value.sdk_version === null || typeof value.sdk_version === "string") && isOptional(value, "min_bundle_id", isString) && isOptional(value, "failure", isDatabaseBundleEventFailure) && isOptional(value, "http_response", isDatabaseHttpResponse) && isOptional(value, "delivery", isString) && isOptional(value, "patch_fallback", (flag) => typeof flag === "boolean") && isOptional(value, "change", isBundleEventChange) && isOptional(value, "late", (flag) => flag === true) && isOptional(value, "implied_download", (flag) => flag === true);
8263
- const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8344
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8264
8345
  /**
8265
8346
  * The built-in bundle a report says its installation runs: the running
8266
8347
  * bundle, which a download leaves at its source, when it is the native
@@ -8269,7 +8350,7 @@ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Ar
8269
8350
  */
8270
8351
  const runningBuiltinBundleId = (row) => {
8271
8352
  const running = row.type === "UPDATE_DOWNLOADED" ? row.from_bundle_id : row.to_bundle_id;
8272
- const builtin = isRecord$2(row.metadata) ? row.metadata.min_bundle_id : null;
8353
+ const builtin = isRecord$3(row.metadata) ? row.metadata.min_bundle_id : null;
8273
8354
  return typeof builtin === "string" && running === builtin ? builtin : null;
8274
8355
  };
8275
8356
  const isIdentity$1 = (value) => typeof value === "string" && value.length > 0 && value.length <= 255;
@@ -8290,21 +8371,21 @@ const BUNDLE_EVENT_FIELDS = {
8290
8371
  metadata: isDatabaseBundleEventMetadata,
8291
8372
  received_at_ms: (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
8292
8373
  };
8293
- const hasMetadata = (row, key) => isRecord$2(row.metadata) && Object.hasOwn(row.metadata, key);
8294
- const updateStrategyOf = (row) => isRecord$2(row.metadata) ? row.metadata.update_strategy : void 0;
8374
+ const hasMetadata = (row, key) => isRecord$3(row.metadata) && Object.hasOwn(row.metadata, key);
8375
+ const updateStrategyOf = (row) => isRecord$3(row.metadata) ? row.metadata.update_strategy : void 0;
8295
8376
  /**
8296
8377
  * A movement, or a failed update, names the bundle it left and how the
8297
8378
  * device updates, and a failed update says what failed; an unchanged report
8298
8379
  * names neither.
8299
8380
  */
8300
- const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord$2(row.metadata) && isRecord$2(row.metadata.failure)) && !hasMetadata(row, "change") && (!hasMetadata(row, "late") || row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED") && (!hasMetadata(row, "implied_download") || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED") || row.type === "UNCHANGED" && !hasMetadata(row, "late") && row.from_bundle_id === null && updateStrategyOf(row) === null;
8381
+ const hasEventInvariants = (row) => (row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED" || row.type === "UPDATE_FAILED") && typeof row.from_bundle_id === "string" && (updateStrategyOf(row) === "fingerprint" || updateStrategyOf(row) === "appVersion") && (row.type !== "UPDATE_FAILED" || isRecord$3(row.metadata) && isRecord$3(row.metadata.failure)) && !hasMetadata(row, "change") && (!hasMetadata(row, "late") || row.type === "UPDATE_DOWNLOADED" || row.type === "UPDATE_APPLIED") && (!hasMetadata(row, "implied_download") || row.type === "UPDATE_APPLIED" || row.type === "RECOVERED") || row.type === "UNCHANGED" && !hasMetadata(row, "late") && row.from_bundle_id === null && updateStrategyOf(row) === null;
8301
8382
  /**
8302
8383
  * Throws unless `row` has exactly the bundle event fields, each of its type,
8303
8384
  * and the invariants of its event type: `invalid-field` for an unknown field,
8304
8385
  * `invalid-data` for anything else.
8305
8386
  */
8306
8387
  const validateBundleEventFields = (row) => {
8307
- if (!isRecord$2(row)) throw new DatabaseAdapterInputError("invalid-data");
8388
+ if (!isRecord$3(row)) throw new DatabaseAdapterInputError("invalid-data");
8308
8389
  for (const field of Object.keys(row)) if (!Object.hasOwn(BUNDLE_EVENT_FIELDS, field)) throw new DatabaseAdapterInputError("invalid-field");
8309
8390
  for (const [field, valid] of Object.entries(BUNDLE_EVENT_FIELDS)) if (!Object.hasOwn(row, field) || !valid(row[field])) throw new DatabaseAdapterInputError("invalid-data");
8310
8391
  if (!hasEventInvariants(row)) throw new DatabaseAdapterInputError("invalid-data");
@@ -8333,7 +8414,7 @@ const isLimit = (value) => isTimestamp(value) && value >= 1 && value <= 101;
8333
8414
  const hasOnlyKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
8334
8415
  const hasScope = (value) => (value.platform === "ios" || value.platform === "android") && isText$1(value.channel);
8335
8416
  const isBundleFilter = (value, withKind = false) => {
8336
- if (!isRecord$2(value) || !hasScope(value)) return false;
8417
+ if (!isRecord$3(value) || !hasScope(value)) return false;
8337
8418
  const keys = [
8338
8419
  "platform",
8339
8420
  "channel",
@@ -8343,7 +8424,7 @@ const isBundleFilter = (value, withKind = false) => {
8343
8424
  return value.type === "RECOVERED" ? isText$1(value.fromBundleId) && hasOnlyKeys(value, [...keys, "fromBundleId"]) : (value.type === "UPDATE_DOWNLOADED" || value.type === "UPDATE_APPLIED" || value.type === "UPDATE_FAILED" || value.type === "UNCHANGED") && isText$1(value.toBundleId) && hasOnlyKeys(value, [...keys, "toBundleId"]);
8344
8425
  };
8345
8426
  const isEventFilter = (value) => {
8346
- if (!isRecord$2(value)) return false;
8427
+ if (!isRecord$3(value)) return false;
8347
8428
  if (value.kind === "all") return hasOnlyKeys(value, ["kind"]);
8348
8429
  if (value.kind === "installationMovement") return isIdentity(value.installId) && hasOnlyKeys(value, ["kind", "installId"]);
8349
8430
  return value.kind === "bundle" && isBundleFilter(value, true);
@@ -8351,7 +8432,7 @@ const isEventFilter = (value) => {
8351
8432
  const validateRow = (model, row, result = false) => {
8352
8433
  try {
8353
8434
  validateBundleEventFields(row);
8354
- if (!isRecord$2(row) || typeof row.id !== "string" || !isUUIDv7(row.id) || [...Object.values(row), ...isRecord$2(row.metadata) ? Object.values(row.metadata) : []].some((value) => typeof value === "string" && !isWellFormedText(value))) throw new DatabaseAdapterInputError("invalid-data");
8435
+ if (!isRecord$3(row) || typeof row.id !== "string" || !isUUIDv7(row.id) || [...Object.values(row), ...isRecord$3(row.metadata) ? Object.values(row.metadata) : []].some((value) => typeof value === "string" && !isWellFormedText(value))) throw new DatabaseAdapterInputError("invalid-data");
8355
8436
  } catch (error) {
8356
8437
  if (result) throw new DatabaseAdapterInputError("invalid-result");
8357
8438
  throw error;
@@ -8376,27 +8457,27 @@ const invalidResult = () => {
8376
8457
  throw new DatabaseAdapterInputError("invalid-result");
8377
8458
  };
8378
8459
  const validateCount = (count) => isTimestamp(count) ? count : invalidResult();
8379
- const isTimeRange = (value) => isRecord$2(value) && hasOnlyKeys(value, ["start", "end"]) && isTimestamp(value.start) && isTimestamp(value.end) && value.start < value.end;
8380
- const isReleaseReference = (value) => isRecord$2(value) && hasOnlyKeys(value, [
8460
+ const isTimeRange = (value) => isRecord$3(value) && hasOnlyKeys(value, ["start", "end"]) && isTimestamp(value.start) && isTimestamp(value.end) && value.start < value.end;
8461
+ const isReleaseReference = (value) => isRecord$3(value) && hasOnlyKeys(value, [
8381
8462
  "releaseId",
8382
8463
  "platform",
8383
8464
  "channel"
8384
8465
  ]) && isIdentity(value.releaseId) && hasScope(value);
8385
8466
  const validateAggregateResult = (value) => {
8386
- const result = isRecord$2(value) ? value : invalidResult();
8467
+ const result = isRecord$3(value) ? value : invalidResult();
8387
8468
  if (!isTimestamp(result.measuredAtMs)) invalidResult();
8388
8469
  const coverage = result.coverage;
8389
- if (!isRecord$2(coverage) || coverage.kind !== "complete" && coverage.kind !== "partial" || coverage.sinceMs !== null && !isTimestamp(coverage.sinceMs)) invalidResult();
8470
+ if (!isRecord$3(coverage) || coverage.kind !== "complete" && coverage.kind !== "partial" || coverage.sinceMs !== null && !isTimestamp(coverage.sinceMs)) invalidResult();
8390
8471
  };
8391
8472
  /** Validate custom and bundled providers at the same public boundary. */
8392
8473
  const createValidatedInsightsModel = (model) => ({
8393
8474
  async recordEvent(input) {
8394
- if (!isRecord$2(input) || !hasOnlyKeys(input, ["event"])) throw new DatabaseAdapterInputError("invalid-data");
8475
+ if (!isRecord$3(input) || !hasOnlyKeys(input, ["event"])) throw new DatabaseAdapterInputError("invalid-data");
8395
8476
  validateRow("bundle_events", input.event);
8396
8477
  await model.recordEvent(input);
8397
8478
  },
8398
8479
  async listEvents(input) {
8399
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
8480
+ if (!isRecord$3(input) || !hasOnlyKeys(input, [
8400
8481
  "filter",
8401
8482
  "sinceMs",
8402
8483
  "beforeReceivedAtMs",
@@ -8404,7 +8485,7 @@ const createValidatedInsightsModel = (model) => ({
8404
8485
  "limit"
8405
8486
  ]) || !isEventFilter(input.filter) || !isLimit(input.limit) || !isTimestamp(input.beforeReceivedAtMs) || input.sinceMs !== void 0 && !isTimestamp(input.sinceMs)) invalidQuery();
8406
8487
  const sinceMs = input.sinceMs ?? 0;
8407
- if (sinceMs > input.beforeReceivedAtMs || input.after !== void 0 && (!isRecord$2(input.after) || !hasOnlyKeys(input.after, ["receivedAtMs", "id"]) || !isTimestamp(input.after.receivedAtMs) || typeof input.after.id !== "string" || !isUUIDv7(input.after.id) || input.after.receivedAtMs < sinceMs || input.after.receivedAtMs >= input.beforeReceivedAtMs)) invalidQuery();
8488
+ if (sinceMs > input.beforeReceivedAtMs || input.after !== void 0 && (!isRecord$3(input.after) || !hasOnlyKeys(input.after, ["receivedAtMs", "id"]) || !isTimestamp(input.after.receivedAtMs) || typeof input.after.id !== "string" || !isUUIDv7(input.after.id) || input.after.receivedAtMs < sinceMs || input.after.receivedAtMs >= input.beforeReceivedAtMs)) invalidQuery();
8408
8489
  const rows = await model.listEvents(input);
8409
8490
  if (!Array.isArray(rows) || rows.length > input.limit) invalidResult();
8410
8491
  let previous = input.after;
@@ -8419,7 +8500,7 @@ const createValidatedInsightsModel = (model) => ({
8419
8500
  return rows;
8420
8501
  },
8421
8502
  async findLatestEvents(input) {
8422
- if (!isRecord$2(input)) invalidQuery();
8503
+ if (!isRecord$3(input)) invalidQuery();
8423
8504
  if ("installId" in input) {
8424
8505
  if (!hasOnlyKeys(input, ["installId"]) || !isIdentity(input.installId)) invalidQuery();
8425
8506
  } else if (!hasOnlyKeys(input, [
@@ -8438,12 +8519,12 @@ const createValidatedInsightsModel = (model) => ({
8438
8519
  return rows;
8439
8520
  },
8440
8521
  async countLatestEvents(input) {
8441
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
8522
+ if (!isRecord$3(input) || !hasOnlyKeys(input, [
8442
8523
  "platform",
8443
8524
  "channel",
8444
8525
  "sinceMs",
8445
8526
  "bundle"
8446
- ]) || !hasScope(input) || !isTimestamp(input.sinceMs) || input.bundle !== void 0 && (!Array.isArray(input.bundle) || input.bundle.length < 1 || input.bundle.length > 2 || input.bundle.some((bundle) => !isRecord$2(bundle) || !hasOnlyKeys(bundle, [
8527
+ ]) || !hasScope(input) || !isTimestamp(input.sinceMs) || input.bundle !== void 0 && (!Array.isArray(input.bundle) || input.bundle.length < 1 || input.bundle.length > 2 || input.bundle.some((bundle) => !isRecord$3(bundle) || !hasOnlyKeys(bundle, [
8447
8528
  "field",
8448
8529
  "value",
8449
8530
  "types"
@@ -8456,7 +8537,7 @@ const createValidatedInsightsModel = (model) => ({
8456
8537
  return validateCount(await model.countLatestEvents(input));
8457
8538
  },
8458
8539
  async countEvents(input) {
8459
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
8540
+ if (!isRecord$3(input) || !hasOnlyKeys(input, [
8460
8541
  "filter",
8461
8542
  "sinceMs",
8462
8543
  "beforeReceivedAtMs"
@@ -8464,7 +8545,7 @@ const createValidatedInsightsModel = (model) => ({
8464
8545
  return validateCount(await model.countEvents(input));
8465
8546
  },
8466
8547
  async countEventSeries(input) {
8467
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
8548
+ if (!isRecord$3(input) || !hasOnlyKeys(input, [
8468
8549
  "filter",
8469
8550
  "timeRange",
8470
8551
  "intervalMs"
@@ -8473,17 +8554,17 @@ const createValidatedInsightsModel = (model) => ({
8473
8554
  const { intervalMs } = input;
8474
8555
  if (intervalMs === 0 || intervalMs % 36e5 !== 0 || start % 36e5 !== 0 || (end - start) % intervalMs !== 0 || end - start > 7776e6) invalidQuery();
8475
8556
  const points = await model.countEventSeries(input);
8476
- if (!Array.isArray(points) || points.length !== (end - start) / intervalMs || points.some((point, index) => !isRecord$2(point) || !hasOnlyKeys(point, ["startMs", "events"]) || point.startMs !== start + index * intervalMs || !isTimestamp(point.events))) invalidResult();
8557
+ if (!Array.isArray(points) || points.length !== (end - start) / intervalMs || points.some((point, index) => !isRecord$3(point) || !hasOnlyKeys(point, ["startMs", "events"]) || point.startMs !== start + index * intervalMs || !isTimestamp(point.events))) invalidResult();
8477
8558
  return points;
8478
8559
  },
8479
8560
  async getReleaseActivity(input) {
8480
- if (!isRecord$2(input) || !hasOnlyKeys(input, ["releases"]) || !Array.isArray(input.releases) || input.releases.length < 1 || input.releases.length > 100 || !input.releases.every(isReleaseReference)) invalidQuery();
8561
+ if (!isRecord$3(input) || !hasOnlyKeys(input, ["releases"]) || !Array.isArray(input.releases) || input.releases.length < 1 || input.releases.length > 100 || !input.releases.every(isReleaseReference)) invalidQuery();
8481
8562
  const result = await model.getReleaseActivity(input);
8482
- if (!isRecord$2(result) || !isTimestamp(result.measuredAtMs) || !Array.isArray(result.data) || result.data.some((item) => !isRecord$2(item) || !isReleaseReference(item.release) || !isRecord$2(item.metrics) || !isTimestamp(item.metrics.downloads) || !isTimestamp(item.metrics.applies) || !isTimestamp(item.metrics.failedLaunches))) invalidResult();
8563
+ if (!isRecord$3(result) || !isTimestamp(result.measuredAtMs) || !Array.isArray(result.data) || result.data.some((item) => !isRecord$3(item) || !isReleaseReference(item.release) || !isRecord$3(item.metrics) || !isTimestamp(item.metrics.downloads) || !isTimestamp(item.metrics.applies) || !isTimestamp(item.metrics.failedLaunches))) invalidResult();
8483
8564
  return result;
8484
8565
  },
8485
8566
  async getAppUsage(input) {
8486
- if (!isRecord$2(input) || !hasOnlyKeys(input, [
8567
+ if (!isRecord$3(input) || !hasOnlyKeys(input, [
8487
8568
  "channel",
8488
8569
  "platform",
8489
8570
  "appVersion",
@@ -8524,9 +8605,9 @@ const createInsightsModel = (api) => createValidatedInsightsModel({
8524
8605
  * Each official plugin package keeps this copy, so none imports another's
8525
8606
  * internals for it.
8526
8607
  */
8527
- const OFFICIAL = Symbol.for("@hot-updater/server/official-plugin");
8608
+ const OFFICIAL$1 = Symbol.for("@hot-updater/server/official-plugin");
8528
8609
  /** Marks a plugin one of Hot Updater's own factories made. */
8529
- const markOfficial = (plugin) => Object.defineProperty(plugin, OFFICIAL, { value: true });
8610
+ const markOfficial$1 = (plugin) => Object.defineProperty(plugin, OFFICIAL$1, { value: true });
8530
8611
  //#endregion
8531
8612
  //#region ../insights/dist/server/errors.mjs
8532
8613
  /**
@@ -8549,7 +8630,7 @@ var InsightsPayloadTooLargeError = class extends Error {
8549
8630
  const MAX_EVENT_STRING_LENGTH = 1024;
8550
8631
  const MAX_IDENTITY_LENGTH$2 = 255;
8551
8632
  const EVENT_BODY_MAX_BYTES = 16384;
8552
- function isRecord$1(value) {
8633
+ function isRecord$2(value) {
8553
8634
  return typeof value === "object" && value !== null && !Array.isArray(value);
8554
8635
  }
8555
8636
  function requireStringField(payload, key) {
@@ -8622,7 +8703,7 @@ const DELIVERIES = [
8622
8703
  /** A storage error code: letters, digits, and `._-`. */
8623
8704
  const CODE = /^[A-Za-z0-9._-]{1,64}$/;
8624
8705
  const readCode = (value) => typeof value === "string" && CODE.test(value) ? value : void 0;
8625
- const readMetadata = (payload) => isRecord$1(payload.metadata) ? payload.metadata : {};
8706
+ const readMetadata = (payload) => isRecord$2(payload.metadata) ? payload.metadata : {};
8626
8707
  /**
8627
8708
  * What an UPDATE_FAILED report says failed. It never refuses the report: an
8628
8709
  * unknown value of a set reads as `unknown`, a missing or malformed failure
@@ -8630,7 +8711,7 @@ const readMetadata = (payload) => isRecord$1(payload.metadata) ? payload.metadat
8630
8711
  * as is a null one.
8631
8712
  */
8632
8713
  function readFailure(value) {
8633
- const failure = isRecord$1(value) ? value : {};
8714
+ const failure = isRecord$2(value) ? value : {};
8634
8715
  const httpStatus = failure.httpStatus;
8635
8716
  const originCode = readCode(failure.originCode);
8636
8717
  const errorMessage = typeof failure.errorMessage === "string" && failure.errorMessage.length <= 2048 ? failure.errorMessage : void 0;
@@ -8647,7 +8728,7 @@ function readFailure(value) {
8647
8728
  };
8648
8729
  }
8649
8730
  function readHttpResponse(value) {
8650
- if (!isRecord$1(value) || value.resource !== "catalog" && value.resource !== "artifact" || typeof value.path !== "string" || value.path.length > 1024 || !Number.isSafeInteger(value.status) || value.status < 100 || value.status > 599 || value.body !== null && (typeof value.body !== "string" || new TextEncoder().encode(JSON.stringify(value.body)).byteLength > 4096) || typeof value.bodyTruncated !== "boolean" || !Number.isSafeInteger(value.receivedAtMs) || value.receivedAtMs < 0) throw new InsightsBadRequestError("Invalid HTTP response details");
8731
+ if (!isRecord$2(value) || value.resource !== "catalog" && value.resource !== "artifact" || typeof value.path !== "string" || value.path.length > 1024 || !Number.isSafeInteger(value.status) || value.status < 100 || value.status > 599 || value.body !== null && (typeof value.body !== "string" || new TextEncoder().encode(JSON.stringify(value.body)).byteLength > 4096) || typeof value.bodyTruncated !== "boolean" || !Number.isSafeInteger(value.receivedAtMs) || value.receivedAtMs < 0) throw new InsightsBadRequestError("Invalid HTTP response details");
8651
8732
  return {
8652
8733
  resource: value.resource,
8653
8734
  path: value.path,
@@ -8688,7 +8769,7 @@ async function parseJson(request) {
8688
8769
  }
8689
8770
  }
8690
8771
  function requireEvent(payload) {
8691
- if (!isRecord$1(payload)) throw new InsightsBadRequestError("Invalid event payload");
8772
+ if (!isRecord$2(payload)) throw new InsightsBadRequestError("Invalid event payload");
8692
8773
  const platform = requireStringField(payload, "platform");
8693
8774
  if (platform !== "ios" && platform !== "android") throw new InsightsBadRequestError("Invalid event field: platform");
8694
8775
  const eventId = readEventId(payload);
@@ -8863,7 +8944,7 @@ const WINDOW_MS = {
8863
8944
  * history is one index range and reads back to `sinceMs`.
8864
8945
  */
8865
8946
  const EVENT_LIST_RANGE_MS = 2160 * HOUR_MS;
8866
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8947
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8867
8948
  const requireString = (value, label, maximumLength) => {
8868
8949
  if (typeof value !== "string" || value.length === 0 || value.length > maximumLength || new TextDecoder("utf-8", { ignoreBOM: true }).decode(new TextEncoder().encode(value)) !== value) throw new InsightsBadRequestError(`Invalid ${label}.`);
8869
8950
  return value;
@@ -8939,7 +9020,7 @@ const bundleFilter = (input) => {
8939
9020
  }
8940
9021
  };
8941
9022
  const sameFilter = (left, right) => {
8942
- if (!isRecord(left) || left.kind !== right.kind) return false;
9023
+ if (!isRecord$1(left) || left.kind !== right.kind) return false;
8943
9024
  switch (right.kind) {
8944
9025
  case "all": return true;
8945
9026
  case "installationMovement": return left.installId === right.installId;
@@ -8948,7 +9029,7 @@ const sameFilter = (left, right) => {
8948
9029
  };
8949
9030
  const readEventCursor = (value, filter) => {
8950
9031
  const cursor = decodeCursor(value);
8951
- if (!isRecord(cursor) || cursor.version !== 2 || cursor.kind !== "events" || !isRecord(cursor.filter) || !isRecord(cursor.after)) throw new InsightsBadRequestError("Invalid Insights cursor.");
9032
+ if (!isRecord$1(cursor) || cursor.version !== 2 || cursor.kind !== "events" || !isRecord$1(cursor.filter) || !isRecord$1(cursor.after)) throw new InsightsBadRequestError("Invalid Insights cursor.");
8952
9033
  if (!sameFilter(cursor.filter, filter)) throw new InsightsBadRequestError("Insights cursor does not match the requested events.");
8953
9034
  if (typeof cursor.after.id !== "string" || !isUUIDv7(cursor.after.id)) throw new InsightsBadRequestError("Invalid Insights event cursor ID.");
8954
9035
  return {
@@ -8965,7 +9046,7 @@ const readEventCursor = (value, filter) => {
8965
9046
  };
8966
9047
  const readUserInstallationCursor = (value, userId) => {
8967
9048
  const cursor = decodeCursor(value);
8968
- if (!isRecord(cursor) || cursor.version !== 1 || cursor.kind !== "user-installations" || cursor.userId !== userId) throw new InsightsBadRequestError("Insights cursor does not match the requested user ID.");
9049
+ if (!isRecord$1(cursor) || cursor.version !== 1 || cursor.kind !== "user-installations" || cursor.userId !== userId) throw new InsightsBadRequestError("Insights cursor does not match the requested user ID.");
8969
9050
  return {
8970
9051
  afterInstallId: requireString(cursor.afterInstallId, "installation cursor", MAX_IDENTITY_LENGTH$1),
8971
9052
  kind: "user-installations",
@@ -10530,8 +10611,8 @@ const parseReportingOverviewInput = (request) => {
10530
10611
  //#endregion
10531
10612
  //#region ../insights/dist/server/routes.mjs
10532
10613
  /** Seconds a client waits before it resends a request the busy database refused. */
10533
- const RETRY_AFTER_SECONDS = 5;
10534
- const json = (body, status, headers = {}) => Response.json(body, {
10614
+ const RETRY_AFTER_SECONDS$1 = 5;
10615
+ const json$1 = (body, status, headers = {}) => Response.json(body, {
10535
10616
  headers: {
10536
10617
  "cache-control": "private, no-store",
10537
10618
  ...headers
@@ -10547,20 +10628,20 @@ const requireParam = (params, key) => {
10547
10628
  throw new InsightsBadRequestError(`Invalid route parameter: ${key}`);
10548
10629
  }
10549
10630
  };
10550
- const run = async (operation) => {
10631
+ const run$1 = async (operation) => {
10551
10632
  try {
10552
10633
  return await operation();
10553
10634
  } catch (error) {
10554
- if (error instanceof InsightsBadRequestError) return json({ error: error.message }, 400);
10555
- if (error instanceof InsightsPayloadTooLargeError) return json({ error: error.message }, 413);
10635
+ if (error instanceof InsightsBadRequestError) return json$1({ error: error.message }, 400);
10636
+ if (error instanceof InsightsPayloadTooLargeError) return json$1({ error: error.message }, 413);
10556
10637
  if (isDatabaseBusyError(error)) {
10557
10638
  console.warn("[hot-updater] Insights answered 503: the database is busy.", error);
10558
- return json({ error: "Service unavailable" }, 503, { "retry-after": String(RETRY_AFTER_SECONDS) });
10639
+ return json$1({ error: "Service unavailable" }, 503, { "retry-after": String(RETRY_AFTER_SECONDS$1) });
10559
10640
  }
10560
10641
  throw error;
10561
10642
  }
10562
10643
  };
10563
- const query = (operation) => run(async () => json(await operation(), 200));
10644
+ const query = (operation) => run$1(async () => json$1(await operation(), 200));
10564
10645
  /** Every Insights route: where it mounts, and the handler it runs. */
10565
10646
  const INSIGHTS_ROUTES = [
10566
10647
  {
@@ -10601,14 +10682,14 @@ const INSIGHTS_ROUTES = [
10601
10682
  }
10602
10683
  ];
10603
10684
  const createInsightsRouteHandlers = (provider) => ({
10604
- appendBundleEvent: async (_params, request) => run(async () => {
10685
+ appendBundleEvent: async (_params, request) => run$1(async () => {
10605
10686
  await provider.appendBundleEvent(await parseBundleEventRequest(request));
10606
10687
  return new Response(null, { status: 204 });
10607
10688
  }),
10608
10689
  getReportingOverview: (_params, request) => query(() => provider.getReportingOverview(parseReportingOverviewInput(request))),
10609
- getInstallation: (params) => run(async () => {
10690
+ getInstallation: (params) => run$1(async () => {
10610
10691
  const installation = await provider.getInstallation({ installId: requireParam(params, "installId") });
10611
- return installation === null ? json({ error: "Installation not found" }, 404) : json(installation, 200);
10692
+ return installation === null ? json$1({ error: "Installation not found" }, 404) : json$1(installation, 200);
10612
10693
  }),
10613
10694
  listEvents: (_params, request) => query(() => provider.listEvents(parseEventPageInput(request))),
10614
10695
  listInstallationEvents: (params, request) => query(() => provider.listInstallationEvents({
@@ -10727,15 +10808,833 @@ const insights = (options = {}) => {
10727
10808
  name: "insights"
10728
10809
  } }
10729
10810
  });
10811
+ return markOfficial$1(plugin);
10812
+ };
10813
+ //#endregion
10814
+ //#region ../remote-config/dist/shared/ownEntry.mjs
10815
+ /**
10816
+ * A record's own entry. Parameter keys and condition names come from users,
10817
+ * so `constructor` or `__proto__` must name theirs, never Object.prototype's.
10818
+ */
10819
+ const ownEntry = (record, key) => record !== void 0 && Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
10820
+ //#endregion
10821
+ //#region ../remote-config/dist/server/template.mjs
10822
+ const EMPTY_REMOTE_CONFIG_TEMPLATE = Object.freeze({
10823
+ conditions: Object.freeze([]),
10824
+ parameters: Object.freeze({})
10825
+ });
10826
+ /**
10827
+ * A template's JSON stays under this many characters, so a device's values
10828
+ * always fit the 64 KB a client plugin may store under one key.
10829
+ */
10830
+ const REMOTE_CONFIG_MAX_TEMPLATE_LENGTH = 6e4;
10831
+ const REMOTE_CONFIG_MAX_PARAMETERS = 2e3;
10832
+ const MAX_RULE_VALUES = 100;
10833
+ const MAX_DESCRIPTION_LENGTH$1 = 256;
10834
+ const MAX_CONDITION_NAME_LENGTH = 100;
10835
+ const MAX_RULE_TEXT_LENGTH = 128;
10836
+ const PARAMETER_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,255}$/u;
10837
+ const SEED_PATTERN = /^[A-Za-z0-9_-]{1,64}$/u;
10838
+ /** A date-time with its time zone, so it names one instant. */
10839
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/u;
10840
+ const VALUE_TYPES = /* @__PURE__ */ new Set([
10841
+ "STRING",
10842
+ "NUMBER",
10843
+ "BOOLEAN",
10844
+ "JSON"
10845
+ ]);
10846
+ const PLATFORMS = /* @__PURE__ */ new Set(["ios", "android"]);
10847
+ /** A template that cannot be published; `issues` lists every problem found. */
10848
+ var RemoteConfigValidationError = class extends Error {
10849
+ issues;
10850
+ name = "RemoteConfigValidationError";
10851
+ constructor(issues) {
10852
+ super(`The Remote Config template is invalid: ${issues.slice(0, 3).map(({ path, message }) => path ? `${path}: ${message}` : message).join("; ")}${issues.length > 3 ? ` (and ${issues.length - 3} more)` : ""}`);
10853
+ this.issues = issues;
10854
+ }
10855
+ };
10856
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10857
+ const visibleText = (value, max) => typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\p{Cc}]/u.test(value);
10858
+ /** Whether a value's text suits its type; the problem otherwise. */
10859
+ const checkValueText = (valueType, text) => {
10860
+ switch (valueType) {
10861
+ case "NUMBER": return text.trim().length > 0 && Number.isFinite(Number(text)) ? null : "must be a number.";
10862
+ case "BOOLEAN": return text === "true" || text === "false" ? null : "must be \"true\" or \"false\".";
10863
+ case "JSON": try {
10864
+ JSON.parse(text);
10865
+ return null;
10866
+ } catch {
10867
+ return "must be valid JSON.";
10868
+ }
10869
+ default: return null;
10870
+ }
10871
+ };
10872
+ /**
10873
+ * Checks a template and returns it with only its declared fields, or throws
10874
+ * `RemoteConfigValidationError` with every problem it finds.
10875
+ */
10876
+ const validateRemoteConfigTemplate = (input) => {
10877
+ const issues = [];
10878
+ const issue = (path, message) => {
10879
+ issues.push({
10880
+ path,
10881
+ message
10882
+ });
10883
+ };
10884
+ const unknownKeys = (value, allowed, path) => {
10885
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) issue(path ? `${path}.${key}` : key, "is not a template field.");
10886
+ };
10887
+ if (!isRecord(input)) throw new RemoteConfigValidationError([{
10888
+ path: "",
10889
+ message: "A template is an object."
10890
+ }]);
10891
+ unknownKeys(input, ["conditions", "parameters"], "");
10892
+ const conditions = [];
10893
+ const conditionNames = /* @__PURE__ */ new Set();
10894
+ const rawConditions = input.conditions ?? [];
10895
+ if (!Array.isArray(rawConditions)) issue("conditions", "must be a list.");
10896
+ else {
10897
+ if (rawConditions.length > 500) issue("conditions", `holds at most 500 conditions.`);
10898
+ rawConditions.forEach((raw, index) => {
10899
+ const path = `conditions[${index}]`;
10900
+ if (!isRecord(raw)) {
10901
+ issue(path, "must be an object.");
10902
+ return;
10903
+ }
10904
+ unknownKeys(raw, ["name", "rules"], path);
10905
+ const { name } = raw;
10906
+ if (!visibleText(name, MAX_CONDITION_NAME_LENGTH)) issue(`${path}.name`, `must be 1-${MAX_CONDITION_NAME_LENGTH} visible characters.`);
10907
+ else if (conditionNames.has(name)) issue(`${path}.name`, `"${name}" names another condition too.`);
10908
+ else conditionNames.add(name);
10909
+ const rules = validateRules(raw.rules, `${path}.rules`, issue);
10910
+ if (typeof name === "string") conditions.push({
10911
+ name,
10912
+ rules
10913
+ });
10914
+ });
10915
+ }
10916
+ const parameters = [];
10917
+ const rawParameters = input.parameters ?? {};
10918
+ if (!isRecord(rawParameters)) issue("parameters", "must be an object keyed by parameter.");
10919
+ else {
10920
+ const keys = Object.keys(rawParameters);
10921
+ if (keys.length > 2e3) issue("parameters", `holds at most ${REMOTE_CONFIG_MAX_PARAMETERS} parameters.`);
10922
+ for (const key of keys) {
10923
+ const path = `parameters.${key}`;
10924
+ if (!PARAMETER_KEY_PATTERN.test(key)) issue(path, "Parameter keys start with a letter or _, and hold up to 256 letters, digits, and _.");
10925
+ const raw = rawParameters[key];
10926
+ if (!isRecord(raw)) {
10927
+ issue(path, "must be an object.");
10928
+ continue;
10929
+ }
10930
+ unknownKeys(raw, [
10931
+ "valueType",
10932
+ "defaultValue",
10933
+ "conditionalValues",
10934
+ "description"
10935
+ ], path);
10936
+ const valueType = raw.valueType;
10937
+ if (typeof valueType !== "string" || !VALUE_TYPES.has(valueType)) {
10938
+ issue(`${path}.valueType`, "must be STRING, NUMBER, BOOLEAN, or JSON.");
10939
+ continue;
10940
+ }
10941
+ const type = valueType;
10942
+ const readValue = (value, valuePath) => {
10943
+ if (!isRecord(value)) {
10944
+ issue(valuePath, "must be { value } or { useInAppDefault: true }.");
10945
+ return null;
10946
+ }
10947
+ if ("useInAppDefault" in value) {
10948
+ unknownKeys(value, ["useInAppDefault"], valuePath);
10949
+ if (value.useInAppDefault !== true) {
10950
+ issue(`${valuePath}.useInAppDefault`, "must be true.");
10951
+ return null;
10952
+ }
10953
+ return { useInAppDefault: true };
10954
+ }
10955
+ unknownKeys(value, ["value"], valuePath);
10956
+ if (typeof value.value !== "string") {
10957
+ issue(`${valuePath}.value`, "must be text.");
10958
+ return null;
10959
+ }
10960
+ const problem = checkValueText(type, value.value);
10961
+ if (problem !== null) {
10962
+ issue(`${valuePath}.value`, problem);
10963
+ return null;
10964
+ }
10965
+ return { value: value.value };
10966
+ };
10967
+ const defaultValue = readValue(raw.defaultValue, `${path}.defaultValue`);
10968
+ const conditionalValues = [];
10969
+ if (raw.conditionalValues !== void 0) {
10970
+ if (!isRecord(raw.conditionalValues)) issue(`${path}.conditionalValues`, "must be an object keyed by condition.");
10971
+ else for (const [conditionName, value] of Object.entries(raw.conditionalValues)) {
10972
+ const valuePath = `${path}.conditionalValues.${conditionName}`;
10973
+ if (!conditionNames.has(conditionName)) {
10974
+ issue(valuePath, `No condition is named "${conditionName}".`);
10975
+ continue;
10976
+ }
10977
+ const read = readValue(value, valuePath);
10978
+ if (read !== null) conditionalValues.push([conditionName, read]);
10979
+ }
10980
+ }
10981
+ let description;
10982
+ if (raw.description !== void 0) {
10983
+ const text = typeof raw.description === "string" ? raw.description.trim() : null;
10984
+ if (text === null || text.length > MAX_DESCRIPTION_LENGTH$1) issue(`${path}.description`, `must be text of up to ${MAX_DESCRIPTION_LENGTH$1} characters.`);
10985
+ else if (text.length > 0) description = text;
10986
+ }
10987
+ if (defaultValue !== null) parameters.push([key, {
10988
+ valueType: type,
10989
+ defaultValue,
10990
+ ...conditionalValues.length === 0 ? {} : { conditionalValues: Object.fromEntries(conditionalValues) },
10991
+ ...description === void 0 ? {} : { description }
10992
+ }]);
10993
+ }
10994
+ }
10995
+ if (issues.length > 0) throw new RemoteConfigValidationError(issues);
10996
+ const template = {
10997
+ conditions,
10998
+ parameters: Object.fromEntries(parameters)
10999
+ };
11000
+ const length = JSON.stringify(template).length;
11001
+ if (length > 6e4) throw new RemoteConfigValidationError([{
11002
+ path: "",
11003
+ message: `The template takes ${length.toLocaleString("en-US")} characters of JSON; the limit is ${REMOTE_CONFIG_MAX_TEMPLATE_LENGTH.toLocaleString("en-US")}.`
11004
+ }]);
11005
+ return template;
11006
+ };
11007
+ const textList = (value, path, issue, check, describe) => {
11008
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_RULE_VALUES) {
11009
+ issue(path, `must list 1-${MAX_RULE_VALUES} values.`);
11010
+ return [];
11011
+ }
11012
+ const list = [];
11013
+ value.forEach((entry, index) => {
11014
+ if (typeof entry !== "string" || !check(entry)) issue(`${path}[${index}]`, describe);
11015
+ else if (!list.includes(entry)) list.push(entry);
11016
+ });
11017
+ return list;
11018
+ };
11019
+ const RULE_FIELDS = {
11020
+ platform: ["type", "platforms"],
11021
+ channel: ["type", "channels"],
11022
+ appVersion: ["type", "range"],
11023
+ cohort: ["type", "cohorts"],
11024
+ percent: [
11025
+ "type",
11026
+ "seed",
11027
+ "from",
11028
+ "to"
11029
+ ],
11030
+ fingerprint: ["type", "hashes"],
11031
+ dateTime: [
11032
+ "type",
11033
+ "from",
11034
+ "to"
11035
+ ]
11036
+ };
11037
+ const validateRules = (value, path, issue) => {
11038
+ if (!Array.isArray(value) || value.length === 0) {
11039
+ issue(path, "must list at least one rule.");
11040
+ return [];
11041
+ }
11042
+ const rules = [];
11043
+ value.forEach((raw, index) => {
11044
+ const rulePath = `${path}[${index}]`;
11045
+ if (!isRecord(raw) || typeof raw.type !== "string") {
11046
+ issue(rulePath, "must be a rule object with a type.");
11047
+ return;
11048
+ }
11049
+ const type = raw.type;
11050
+ const fields = ownEntry(RULE_FIELDS, type);
11051
+ if (fields === void 0) {
11052
+ issue(`${rulePath}.type`, "must be platform, channel, appVersion, cohort, percent, fingerprint, or dateTime.");
11053
+ return;
11054
+ }
11055
+ for (const key of Object.keys(raw)) if (!fields.includes(key)) issue(`${rulePath}.${key}`, `is not a field of a ${type} rule.`);
11056
+ switch (type) {
11057
+ case "platform": {
11058
+ const platforms = textList(raw.platforms, `${rulePath}.platforms`, issue, (text) => PLATFORMS.has(text), "must be \"ios\" or \"android\".");
11059
+ rules.push({
11060
+ type,
11061
+ platforms
11062
+ });
11063
+ return;
11064
+ }
11065
+ case "channel": {
11066
+ const channels = textList(raw.channels, `${rulePath}.channels`, issue, (text) => visibleText(text, MAX_RULE_TEXT_LENGTH), `must be a channel name of 1-${MAX_RULE_TEXT_LENGTH} characters.`);
11067
+ rules.push({
11068
+ type,
11069
+ channels
11070
+ });
11071
+ return;
11072
+ }
11073
+ case "appVersion": {
11074
+ const range = raw.range;
11075
+ if (!visibleText(range, MAX_RULE_TEXT_LENGTH) || !isValidRange(range.trim())) {
11076
+ issue(`${rulePath}.range`, "must be a semver range, such as >=1.4.0 or 2.x.");
11077
+ return;
11078
+ }
11079
+ rules.push({
11080
+ type,
11081
+ range: range.trim()
11082
+ });
11083
+ return;
11084
+ }
11085
+ case "cohort": {
11086
+ const cohorts = textList(raw.cohorts, `${rulePath}.cohorts`, issue, isValidCohort, "must be a cohort from 1 to 1000, or a lowercase slug.").map(normalizeCohortValue);
11087
+ rules.push({
11088
+ type,
11089
+ cohorts: [...new Set(cohorts)]
11090
+ });
11091
+ return;
11092
+ }
11093
+ case "percent": {
11094
+ const { seed, from, to } = raw;
11095
+ if (typeof seed !== "string" || !SEED_PATTERN.test(seed)) {
11096
+ issue(`${rulePath}.seed`, "must be 1-64 letters, digits, _, and -.");
11097
+ return;
11098
+ }
11099
+ const isPercent = (value) => typeof value === "number" && value >= 0 && value <= 100 && Math.abs(value * 10 - Math.round(value * 10)) < 1e-9;
11100
+ if (!isPercent(from) || !isPercent(to) || from >= to) {
11101
+ issue(rulePath, "takes from < to, each a percentage from 0 to 100 in steps of 0.1.");
11102
+ return;
11103
+ }
11104
+ rules.push({
11105
+ type,
11106
+ seed,
11107
+ from,
11108
+ to
11109
+ });
11110
+ return;
11111
+ }
11112
+ case "fingerprint": {
11113
+ const hashes = textList(raw.hashes, `${rulePath}.hashes`, issue, (text) => visibleText(text, MAX_RULE_TEXT_LENGTH), `must be a fingerprint of 1-${MAX_RULE_TEXT_LENGTH} characters.`);
11114
+ rules.push({
11115
+ type,
11116
+ hashes
11117
+ });
11118
+ return;
11119
+ }
11120
+ case "dateTime": {
11121
+ const instant = (field) => {
11122
+ const value = raw[field];
11123
+ if (value === void 0) return void 0;
11124
+ const ms = typeof value === "string" ? Date.parse(value) : NaN;
11125
+ if (typeof value !== "string" || !DATE_TIME_PATTERN.test(value) || !Number.isFinite(ms)) {
11126
+ issue(`${rulePath}.${field}`, "must be an ISO 8601 date-time with a time zone, such as 2026-10-08T10:00:00Z.");
11127
+ return null;
11128
+ }
11129
+ return new Date(ms).toISOString();
11130
+ };
11131
+ const from = instant("from");
11132
+ const to = instant("to");
11133
+ if (from === null || to === null) return;
11134
+ if (from === void 0 && to === void 0) {
11135
+ issue(rulePath, "takes from, to, or both.");
11136
+ return;
11137
+ }
11138
+ if (from !== void 0 && to !== void 0 && from >= to) {
11139
+ issue(rulePath, "takes a from before its to.");
11140
+ return;
11141
+ }
11142
+ rules.push({
11143
+ type,
11144
+ ...from === void 0 ? {} : { from },
11145
+ ...to === void 0 ? {} : { to }
11146
+ });
11147
+ return;
11148
+ }
11149
+ }
11150
+ });
11151
+ return rules;
11152
+ };
11153
+ /** Whether one rule matches a device. */
11154
+ const matchesRemoteConfigRule = (rule, context) => {
11155
+ switch (rule.type) {
11156
+ case "platform": return context.platform !== void 0 && context.platform !== null && rule.platforms.includes(context.platform);
11157
+ case "channel": return typeof context.channel === "string" && rule.channels.includes(context.channel);
11158
+ case "appVersion": return typeof context.appVersion === "string" && semverSatisfies(rule.range, context.appVersion);
11159
+ case "cohort": return typeof context.cohort === "string" && rule.cohorts.includes(normalizeCohortValue(context.cohort));
11160
+ case "percent": {
11161
+ if (typeof context.cohort !== "string") return false;
11162
+ const cohort = getNumericCohortValue(context.cohort);
11163
+ if (cohort === null) return false;
11164
+ const position = getNumericCohortRolloutPosition(rule.seed, cohort);
11165
+ return position >= Math.round(rule.from * 10) && position < Math.round(rule.to * 10);
11166
+ }
11167
+ case "fingerprint": return typeof context.fingerprintHash === "string" && rule.hashes.includes(context.fingerprintHash);
11168
+ case "dateTime": return typeof context.now === "number" && (rule.from === void 0 || context.now >= Date.parse(rule.from)) && (rule.to === void 0 || context.now < Date.parse(rule.to));
11169
+ }
11170
+ };
11171
+ /** Whether every rule of a condition matches a device. */
11172
+ const matchesRemoteConfigCondition = (condition, context) => condition.rules.every((rule) => matchesRemoteConfigRule(rule, context));
11173
+ /**
11174
+ * Every parameter's value for one device: the value of the first condition,
11175
+ * in the template's order, that matches and that the parameter has a value
11176
+ * for; otherwise its default value.
11177
+ */
11178
+ const evaluateRemoteConfig = (template, context) => {
11179
+ const matched = template.conditions.filter((condition) => matchesRemoteConfigCondition(condition, context)).map(({ name }) => name);
11180
+ return Object.fromEntries(Object.entries(template.parameters).map(([key, parameter]) => {
11181
+ let condition = null;
11182
+ let chosen = parameter.defaultValue;
11183
+ for (const name of matched) {
11184
+ const value = ownEntry(parameter.conditionalValues, name);
11185
+ if (value !== void 0) {
11186
+ condition = name;
11187
+ chosen = value;
11188
+ break;
11189
+ }
11190
+ }
11191
+ return [key, {
11192
+ value: "value" in chosen ? chosen.value : null,
11193
+ condition
11194
+ }];
11195
+ }));
11196
+ };
11197
+ /** The values a device receives: each parameter's text, without in-app defaults. */
11198
+ const resolveRemoteConfigValues = (template, context) => {
11199
+ return Object.fromEntries(Object.entries(evaluateRemoteConfig(template, context)).flatMap(([key, { value }]) => value === null ? [] : [[key, value]]));
11200
+ };
11201
+ //#endregion
11202
+ //#region ../remote-config/dist/server/api.mjs
11203
+ const ACTIVE_ID = "active";
11204
+ /** How long a server answers fetches from the active template it read last. */
11205
+ const ACTIVE_CACHE_TTL_MS = 5e3;
11206
+ const MAX_DESCRIPTION_LENGTH = 256;
11207
+ const MAX_VERSIONS_PAGE = 100;
11208
+ /**
11209
+ * An argument the API refuses, such as a negative `baseVersion`; the admin
11210
+ * routes answer it with `400`.
11211
+ */
11212
+ var RemoteConfigInputError = class extends TypeError {
11213
+ name = "RemoteConfigInputError";
11214
+ };
11215
+ const normalizeDescription = (description) => {
11216
+ if (description === void 0 || description === null) return null;
11217
+ if (typeof description !== "string") throw new RemoteConfigInputError("A version description is text.");
11218
+ const trimmed = description.trim();
11219
+ if (trimmed.length > MAX_DESCRIPTION_LENGTH) throw new RemoteConfigInputError(`A version description holds up to ${MAX_DESCRIPTION_LENGTH} characters.`);
11220
+ return trimmed.length === 0 ? null : trimmed;
11221
+ };
11222
+ const requireVersionNumber = (value, name) => {
11223
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new RemoteConfigInputError(`${name} must be a non-negative integer.`);
11224
+ return value;
11225
+ };
11226
+ /** A template as its `json` column stores it; validation made it plain JSON. */
11227
+ const toColumn = (template) => template;
11228
+ const fromColumn = (value) => value;
11229
+ const toVersion = (row) => Object.freeze({
11230
+ version: row.version,
11231
+ description: row.description,
11232
+ updateType: row.update_type === "ROLLBACK" ? "ROLLBACK" : "PUBLISH",
11233
+ rollbackSource: row.rollback_source,
11234
+ createdAtMs: row.created_at_ms
11235
+ });
11236
+ /** Remote Config's API over its tables. */
11237
+ const createRemoteConfigApi = ({ db, now }) => {
11238
+ let cached = null;
11239
+ let loading = null;
11240
+ let generation = 0;
11241
+ const readActive = async () => {
11242
+ const row = await db.findOne("remote_config_active", { id: ACTIVE_ID });
11243
+ return row === null ? {
11244
+ version: 0,
11245
+ template: EMPTY_REMOTE_CONFIG_TEMPLATE,
11246
+ updatedAtMs: null
11247
+ } : {
11248
+ version: row.version,
11249
+ template: fromColumn(row.template),
11250
+ updatedAtMs: row.updated_at_ms
11251
+ };
11252
+ };
11253
+ /** The active template for fetches: one read per server every few seconds at most. */
11254
+ const servedActive = () => {
11255
+ if (cached !== null && cached.expiresAt > now()) return Promise.resolve(cached.active);
11256
+ if (loading !== null) return loading;
11257
+ const started = generation;
11258
+ const read = readActive().then((active) => {
11259
+ if (generation === started) cached = {
11260
+ active,
11261
+ expiresAt: now() + ACTIVE_CACHE_TTL_MS
11262
+ };
11263
+ return active;
11264
+ }).finally(() => {
11265
+ if (loading === read) loading = null;
11266
+ });
11267
+ loading = read;
11268
+ return read;
11269
+ };
11270
+ const commit = async (input) => {
11271
+ const attempt = () => db.transaction(async (tx) => {
11272
+ const active = await tx.findOne("remote_config_active", { id: ACTIVE_ID });
11273
+ const currentVersion = active?.version ?? 0;
11274
+ if (currentVersion !== input.baseVersion) return {
11275
+ status: "conflict",
11276
+ currentVersion
11277
+ };
11278
+ const version = currentVersion + 1;
11279
+ const at = now();
11280
+ const row = {
11281
+ version,
11282
+ template: toColumn(input.template),
11283
+ description: input.description,
11284
+ update_type: input.rollbackSource === null ? "PUBLISH" : "ROLLBACK",
11285
+ rollback_source: input.rollbackSource,
11286
+ created_at_ms: at
11287
+ };
11288
+ tx.create("remote_config_versions", row);
11289
+ if (active === null) tx.create("remote_config_active", {
11290
+ id: ACTIVE_ID,
11291
+ version,
11292
+ template: toColumn(input.template),
11293
+ updated_at_ms: at
11294
+ });
11295
+ else tx.update("remote_config_active", active, {
11296
+ version,
11297
+ template: toColumn(input.template),
11298
+ updated_at_ms: at
11299
+ });
11300
+ return {
11301
+ status: "published",
11302
+ version: toVersion(row)
11303
+ };
11304
+ });
11305
+ try {
11306
+ const result = await attempt();
11307
+ if (result.status === "published") {
11308
+ generation += 1;
11309
+ cached = null;
11310
+ loading = null;
11311
+ }
11312
+ return result;
11313
+ } catch (error) {
11314
+ if (error instanceof DatabaseConstraintError && (error.reason === "exists" || error.reason === "unique")) return {
11315
+ status: "conflict",
11316
+ currentVersion: (await readActive()).version
11317
+ };
11318
+ throw error;
11319
+ }
11320
+ };
11321
+ return Object.freeze({
11322
+ getActive: readActive,
11323
+ publish: async ({ template, baseVersion, description }) => commit({
11324
+ template: validateRemoteConfigTemplate(template),
11325
+ baseVersion: requireVersionNumber(baseVersion, "baseVersion"),
11326
+ description: normalizeDescription(description),
11327
+ rollbackSource: null
11328
+ }),
11329
+ rollback: async ({ version, baseVersion, description }) => {
11330
+ const source = await db.findOne("remote_config_versions", { version: requireVersionNumber(version, "version") });
11331
+ if (source === null) return { status: "not_found" };
11332
+ return commit({
11333
+ template: fromColumn(source.template),
11334
+ baseVersion: requireVersionNumber(baseVersion, "baseVersion"),
11335
+ description: normalizeDescription(description) ?? `Rollback to version ${source.version}`,
11336
+ rollbackSource: source.version
11337
+ });
11338
+ },
11339
+ listVersions: async ({ limit = 20, cursor } = {}) => {
11340
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_VERSIONS_PAGE) throw new RemoteConfigInputError(`limit must be an integer from 1 to ${MAX_VERSIONS_PAGE}.`);
11341
+ const page = await db.findMany("remote_config_versions", {
11342
+ index: "byVersion",
11343
+ where: {},
11344
+ order: "desc",
11345
+ limit,
11346
+ ...cursor === void 0 ? {} : { cursor }
11347
+ });
11348
+ return Object.freeze({
11349
+ versions: Object.freeze(page.rows.map(toVersion)),
11350
+ ...page.next === void 0 ? {} : { next: page.next }
11351
+ });
11352
+ },
11353
+ getVersion: async (version) => {
11354
+ const row = await db.findOne("remote_config_versions", { version: requireVersionNumber(version, "version") });
11355
+ return row === null ? null : Object.freeze({
11356
+ ...toVersion(row),
11357
+ template: fromColumn(row.template)
11358
+ });
11359
+ },
11360
+ resolve: async (context) => {
11361
+ const active = await servedActive();
11362
+ return {
11363
+ version: active.version,
11364
+ values: resolveRemoteConfigValues(active.template, {
11365
+ ...context,
11366
+ now: context.now ?? now()
11367
+ })
11368
+ };
11369
+ }
11370
+ });
11371
+ };
11372
+ //#endregion
11373
+ //#region ../remote-config/dist/server/official.mjs
11374
+ /**
11375
+ * The brand of Hot Updater's own plugins, a registered symbol that
11376
+ * `@hot-updater/server` reads to reserve the id "remoteConfig" for this plugin.
11377
+ * Each official plugin package keeps this copy, so none imports another's
11378
+ * internals for it.
11379
+ */
11380
+ const OFFICIAL = Symbol.for("@hot-updater/server/official-plugin");
11381
+ /** Marks a plugin one of Hot Updater's own factories made. */
11382
+ const markOfficial = (plugin) => Object.defineProperty(plugin, OFFICIAL, { value: true });
11383
+ //#endregion
11384
+ //#region ../remote-config/dist/shared/wire.mjs
11385
+ /**
11386
+ * The exchange between the app's `remoteConfig()` client plugin and the
11387
+ * `remoteConfig()` server plugin: `GET /remote-config`, relative to the
11388
+ * `baseURL` the app passes to `HotUpdater.init`. The app
11389
+ * sends what conditions read as query parameters, and the server answers the
11390
+ * values the published template gives them. Both sides import this module,
11391
+ * which imports nothing, so the app's bundle never reaches server code.
11392
+ */
11393
+ /** The path both sides use, relative to the app's `baseURL`. */
11394
+ const REMOTE_CONFIG_PATH = "remote-config";
11395
+ /** The query parameter each context field travels in. */
11396
+ const REMOTE_CONFIG_QUERY = {
11397
+ platform: "platform",
11398
+ appVersion: "appVersion",
11399
+ channel: "channel",
11400
+ cohort: "cohort",
11401
+ fingerprintHash: "fingerprintHash"
11402
+ };
11403
+ //#endregion
11404
+ //#region ../remote-config/dist/server/routes.mjs
11405
+ /** Seconds a client waits before it resends a request the busy database refused. */
11406
+ const RETRY_AFTER_SECONDS = 5;
11407
+ /** A shared cache keeps a device's values five seconds, as it keeps a Release Catalog. */
11408
+ const FETCH_CACHE_CONTROL = "public, max-age=0, s-maxage=5";
11409
+ const MAX_CONTEXT_LENGTH = 128;
11410
+ var BadRequestError = class extends Error {};
11411
+ const json = (body, status, headers = {}) => Response.json(body, {
11412
+ headers: {
11413
+ "cache-control": "private, no-store",
11414
+ ...headers
11415
+ },
11416
+ status
11417
+ });
11418
+ const run = async (operation) => {
11419
+ try {
11420
+ return await operation();
11421
+ } catch (error) {
11422
+ if (error instanceof RemoteConfigValidationError) return json({
11423
+ error: error.message,
11424
+ issues: error.issues
11425
+ }, 400);
11426
+ if (error instanceof BadRequestError || error instanceof RemoteConfigInputError || error instanceof DatabaseCursorError) return json({ error: error.message }, 400);
11427
+ if (isDatabaseBusyError(error)) {
11428
+ console.warn("[hot-updater] Remote Config answered 503: the database is busy.", error);
11429
+ return json({ error: "Service unavailable" }, 503, { "retry-after": String(RETRY_AFTER_SECONDS) });
11430
+ }
11431
+ throw error;
11432
+ }
11433
+ };
11434
+ const readContext = (request) => {
11435
+ const query = new URL(request.url).searchParams;
11436
+ const read = (field) => {
11437
+ const value = query.get(REMOTE_CONFIG_QUERY[field]);
11438
+ if (value === null || value.length === 0) return null;
11439
+ if (value.length > MAX_CONTEXT_LENGTH) throw new BadRequestError(`${field} is too long.`);
11440
+ return value;
11441
+ };
11442
+ const platform = read("platform");
11443
+ if (platform !== null && platform !== "ios" && platform !== "android") throw new BadRequestError("platform must be \"ios\" or \"android\".");
11444
+ return {
11445
+ platform,
11446
+ appVersion: read("appVersion"),
11447
+ channel: read("channel"),
11448
+ cohort: read("cohort"),
11449
+ fingerprintHash: read("fingerprintHash")
11450
+ };
11451
+ };
11452
+ const bodyHash = async (body) => {
11453
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body));
11454
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
11455
+ };
11456
+ const readJsonBody = async (request) => {
11457
+ const body = await request.json().catch(() => null);
11458
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new BadRequestError("The request body must be a JSON object.");
11459
+ return body;
11460
+ };
11461
+ const versionParam = (params) => {
11462
+ const text = params.version ?? "";
11463
+ if (!/^\d{1,15}$/u.test(text)) throw new BadRequestError("The version must be a positive integer.");
11464
+ return Number(text);
11465
+ };
11466
+ const publishResponse = (result) => result.status === "published" ? json(result.version, 200) : json({
11467
+ error: `Version ${result.currentVersion} was published since; reload the template and publish again.`,
11468
+ currentVersion: result.currentVersion
11469
+ }, 409);
11470
+ /**
11471
+ * Remote Config's routes: the devices' `GET /remote-config` on
11472
+ * `handlers.client`, and template management on `handlers.admin`.
11473
+ */
11474
+ const createRemoteConfigEndpoints = (api) => [
11475
+ {
11476
+ method: "GET",
11477
+ path: `/${REMOTE_CONFIG_PATH}`,
11478
+ access: "client",
11479
+ handler: (request) => run(async () => {
11480
+ const body = JSON.stringify(await api.resolve(readContext(request)));
11481
+ const etag = `"sha256:${await bodyHash(body)}"`;
11482
+ const headers = {
11483
+ "cache-control": FETCH_CACHE_CONTROL,
11484
+ "content-type": "application/json",
11485
+ etag,
11486
+ vary: "Accept-Encoding"
11487
+ };
11488
+ if (request.headers.get("if-none-match") === etag) return new Response(null, {
11489
+ headers,
11490
+ status: 304
11491
+ });
11492
+ return new Response(body, {
11493
+ headers,
11494
+ status: 200
11495
+ });
11496
+ })
11497
+ },
11498
+ {
11499
+ method: "GET",
11500
+ path: `/${REMOTE_CONFIG_PATH}/template`,
11501
+ access: "admin",
11502
+ handler: () => run(async () => json(await api.getActive(), 200))
11503
+ },
11504
+ {
11505
+ method: "PUT",
11506
+ path: `/${REMOTE_CONFIG_PATH}/template`,
11507
+ access: "admin",
11508
+ handler: (request) => run(async () => {
11509
+ const body = await readJsonBody(request);
11510
+ return publishResponse(await api.publish({
11511
+ template: body.template,
11512
+ baseVersion: body.baseVersion,
11513
+ ...body.description === void 0 ? {} : { description: body.description }
11514
+ }));
11515
+ })
11516
+ },
11517
+ {
11518
+ method: "GET",
11519
+ path: `/${REMOTE_CONFIG_PATH}/versions`,
11520
+ access: "admin",
11521
+ handler: (request) => run(async () => {
11522
+ const query = new URL(request.url).searchParams;
11523
+ const limit = query.get("limit");
11524
+ const cursor = query.get("cursor");
11525
+ return json(await api.listVersions({
11526
+ ...limit === null ? {} : { limit: Number(limit) },
11527
+ ...cursor === null ? {} : { cursor }
11528
+ }), 200);
11529
+ })
11530
+ },
11531
+ {
11532
+ method: "GET",
11533
+ path: `/${REMOTE_CONFIG_PATH}/versions/:version`,
11534
+ access: "admin",
11535
+ handler: (_request, params) => run(async () => {
11536
+ const version = await api.getVersion(versionParam(params));
11537
+ return version === null ? json({ error: "Version not found" }, 404) : json(version, 200);
11538
+ })
11539
+ },
11540
+ {
11541
+ method: "POST",
11542
+ path: `/${REMOTE_CONFIG_PATH}/versions/:version/rollback`,
11543
+ access: "admin",
11544
+ handler: (request, params) => run(async () => {
11545
+ const body = await readJsonBody(request);
11546
+ const result = await api.rollback({
11547
+ version: versionParam(params),
11548
+ baseVersion: body.baseVersion,
11549
+ ...body.description === void 0 ? {} : { description: body.description }
11550
+ });
11551
+ return result.status === "not_found" ? json({ error: "Version not found" }, 404) : publishResponse(result);
11552
+ })
11553
+ }
11554
+ ];
11555
+ //#endregion
11556
+ //#region ../remote-config/dist/server/schema.mjs
11557
+ /**
11558
+ * Remote Config's tables: the active template in one row, which a device's
11559
+ * fetch reads by key, and every published version beside it.
11560
+ */
11561
+ const remoteConfigSchema = {
11562
+ /** One row, `id` `"active"`: the template devices receive and its version. */
11563
+ remote_config_active: defineTable({
11564
+ id: {
11565
+ type: "string",
11566
+ maxLength: 16
11567
+ },
11568
+ version: { type: "integer" },
11569
+ template: { type: "json" },
11570
+ updated_at_ms: { type: "integer" }
11571
+ }, { key: ["id"] }),
11572
+ /** Each publish and rollback, kept so the Console can show and restore it. */
11573
+ remote_config_versions: defineTable({
11574
+ version: { type: "integer" },
11575
+ template: { type: "json" },
11576
+ description: {
11577
+ type: "string",
11578
+ maxLength: 256,
11579
+ required: false
11580
+ },
11581
+ /** `PUBLISH`, or `ROLLBACK` for a copy of `rollback_source`. */
11582
+ update_type: {
11583
+ type: "string",
11584
+ maxLength: 16
11585
+ },
11586
+ rollback_source: {
11587
+ type: "integer",
11588
+ required: false
11589
+ },
11590
+ created_at_ms: { type: "integer" }
11591
+ }, {
11592
+ key: ["version"],
11593
+ indexes: { byVersion: {
11594
+ eq: [],
11595
+ sort: ["version"]
11596
+ } }
11597
+ })
11598
+ };
11599
+ //#endregion
11600
+ //#region ../remote-config/dist/server/index.mjs
11601
+ /**
11602
+ * Remote Config: parameters whose values the server picks per device by
11603
+ * conditions, published as versioned templates. Devices fetch their values
11604
+ * from `GET /remote-config` with the `remoteConfig()` client plugin; the
11605
+ * Console and `hotUpdater.api.remoteConfig` edit, publish, and roll back
11606
+ * templates.
11607
+ */
11608
+ const remoteConfig = () => {
11609
+ const plugin = definePlugin({
11610
+ id: "remoteConfig",
11611
+ namespace: false,
11612
+ schemaVersion: "1.0.0",
11613
+ schema: remoteConfigSchema,
11614
+ init: ({ db, now }) => {
11615
+ const api = createRemoteConfigApi({
11616
+ db,
11617
+ now
11618
+ });
11619
+ return {
11620
+ api,
11621
+ endpoints: createRemoteConfigEndpoints(api)
11622
+ };
11623
+ }
11624
+ });
10730
11625
  return markOfficial(plugin);
10731
11626
  };
10732
11627
  //#endregion
10733
11628
  //#region src/plugins.ts
10734
11629
  /**
10735
- * The plugins the managed Firebase server runs: Insights, and API keys on every
10736
- * client route. Its Cloud Function imports this list.
11630
+ * The plugins the managed Firebase server runs: Insights, API keys on every
11631
+ * client route, and Remote Config. Its Cloud Function imports this list.
10737
11632
  */
10738
- const plugins = [insights(), apiKeys()];
11633
+ const plugins = [
11634
+ insights(),
11635
+ apiKeys(),
11636
+ remoteConfig()
11637
+ ];
10739
11638
  //#endregion
10740
11639
  //#region firebase/functions/index.ts
10741
11640
  const HOT_UPDATER_BASE_PATH = "/";