@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.
- package/dist/firebase/functions/index.cjs +1005 -106
- package/dist/index.cjs +8 -3
- package/dist/index.d.cts +239 -3
- package/dist/index.d.mts +239 -3
- package/dist/index.mjs +8 -3
- package/dist/init/index.cjs +8 -3
- package/dist/init/index.mjs +8 -3
- package/package.json +7 -7
|
@@ -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
|
-
|
|
930
|
-
|
|
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.
|
|
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$
|
|
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([
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
6245
|
+
const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6165
6246
|
const checkPlugin = (value, at) => {
|
|
6166
|
-
if (!isRecord$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
6304
|
-
const notFound = () => json$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
6403
|
-
const isReleasePolicy = (release) => isRecord$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
6564
|
+
return row === null ? notFound() : json$2({ data: row });
|
|
6484
6565
|
}),
|
|
6485
|
-
updateRelease: onCore(async (core, request, params) => json$
|
|
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$
|
|
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$
|
|
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$
|
|
6587
|
+
return row === null ? notFound() : json$2({ data: row });
|
|
6507
6588
|
}),
|
|
6508
|
-
rebuildReleaseCatalog: onCore(async (core, _request, params) => json$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
7064
|
-
const isAdapter = (value) => isRecord$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
8294
|
-
const updateStrategyOf = (row) => isRecord$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
8380
|
-
const isReleaseReference = (value) => isRecord$
|
|
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$
|
|
8467
|
+
const result = isRecord$3(value) ? value : invalidResult();
|
|
8387
8468
|
if (!isTimestamp(result.measuredAtMs)) invalidResult();
|
|
8388
8469
|
const coverage = result.coverage;
|
|
8389
|
-
if (!isRecord$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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,
|
|
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 = [
|
|
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 = "/";
|