@absolutejs/absolute 0.20.0-beta.85 → 0.20.0-beta.87

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.
@@ -604,7 +604,7 @@ __export(exports_config, {
604
604
  import { readFileSync as readFileSync2 } from "fs";
605
605
  import { resolve as resolve6 } from "path";
606
606
  import { createHash as createHash8, createPublicKey, X509Certificate as X509Certificate2 } from "crypto";
607
- var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FINGERPRINT_PATTERN, UPDATE_NAME_PATTERN, UPDATE_PUBLIC_KEY_PATTERN, ENVIRONMENT_NAME_PATTERN, DEFAULT_UPDATE_BOOT_TIMEOUT_MS = 20000, MINIMUM_UPDATE_BOOT_TIMEOUT_MS = 5000, MAXIMUM_UPDATE_BOOT_TIMEOUT_MS = 120000, HOSTNAME_PATTERN, EXPO_RESERVED_ROUTE_PREFIXES, resolveProjectPath = (projectRoot, value, field2) => {
607
+ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FINGERPRINT_PATTERN, UPDATE_NAME_PATTERN, UPDATE_PUBLIC_KEY_PATTERN, ENVIRONMENT_NAME_PATTERN, DEFAULT_UPDATE_BOOT_TIMEOUT_MS = 20000, DEFAULT_UPDATE_HEALTH_FAILURE_RATE = 0.2, DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS = 20, DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE = 0.05, DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES = 60, MINUTE_MS, DEFAULT_UPDATE_ROLLOUT_STAGES, MINIMUM_UPDATE_BOOT_TIMEOUT_MS = 5000, MAXIMUM_UPDATE_BOOT_TIMEOUT_MS = 120000, HOSTNAME_PATTERN, EXPO_RESERVED_ROUTE_PREFIXES, resolveProjectPath = (projectRoot, value, field2) => {
608
608
  const root = resolve6(projectRoot);
609
609
  const path = resolve6(root, value);
610
610
  if (path !== root && !path.startsWith(`${root}/`)) {
@@ -798,6 +798,56 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
798
798
  if (!ENVIRONMENT_NAME_PATTERN.test(expoPrivateKeyEnv))
799
799
  throw new TypeError("mobile.updates.server.expoPrivateKeyEnv must be a valid environment variable name.");
800
800
  const autoMount = config.updates.server?.autoMount ?? true;
801
+ const configuredHealth = config.updates.server?.health;
802
+ let health;
803
+ if (configuredHealth !== false) {
804
+ const failureRate = configuredHealth?.failureRate ?? DEFAULT_UPDATE_HEALTH_FAILURE_RATE;
805
+ const minimumReports = configuredHealth?.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
806
+ const secretEnv = requireText(configuredHealth?.secretEnv ?? "ABSOLUTE_MOBILE_UPDATE_HEALTH_SECRET", "mobile.updates.server.health.secretEnv");
807
+ if (!Number.isFinite(failureRate) || failureRate <= 0 || failureRate > 1)
808
+ throw new TypeError("mobile.updates.server.health.failureRate must be greater than 0 and at most 1.");
809
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < 1)
810
+ throw new TypeError("mobile.updates.server.health.minimumReports must be a positive integer.");
811
+ if (!ENVIRONMENT_NAME_PATTERN.test(secretEnv))
812
+ throw new TypeError("mobile.updates.server.health.secretEnv must be a valid environment variable name.");
813
+ health = { failureRate, minimumReports, secretEnv };
814
+ }
815
+ const configuredRollout = config.updates.server?.rollout;
816
+ let rollout;
817
+ if (configuredRollout !== undefined && configuredRollout !== false) {
818
+ if (!health)
819
+ throw new TypeError("mobile.updates.server.rollout requires fleet health to be enabled.");
820
+ if (configuredRollout.automatic !== undefined && typeof configuredRollout.automatic !== "boolean")
821
+ throw new TypeError("mobile.updates.server.rollout.automatic must be boolean.");
822
+ const configuredStages = configuredRollout.stages ?? DEFAULT_UPDATE_ROLLOUT_STAGES;
823
+ const stages = configuredStages.map((stage, index) => {
824
+ const previousStage = configuredStages[index - 1];
825
+ const maximumFailureRate = stage.maximumFailureRate ?? DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE;
826
+ const minimumReports = stage.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
827
+ const observationMinutes = stage.observationMinutes ?? DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES;
828
+ const observationMs = observationMinutes * MINUTE_MS;
829
+ if (!Number.isFinite(stage.rollout) || stage.rollout <= 0 || stage.rollout > 1 || previousStage !== undefined && stage.rollout <= previousStage.rollout)
830
+ throw new TypeError("mobile.updates.server.rollout stages must be strictly increasing fractions greater than 0 and at most 1.");
831
+ if (!Number.isFinite(maximumFailureRate) || maximumFailureRate < 0 || maximumFailureRate >= health.failureRate)
832
+ throw new TypeError("mobile.updates.server.rollout maximumFailureRate must be non-negative and lower than the fleet-health pause rate.");
833
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < health.minimumReports)
834
+ throw new TypeError("mobile.updates.server.rollout minimumReports must be an integer at least as large as the fleet-health minimumReports.");
835
+ if (!Number.isFinite(observationMinutes) || observationMinutes < 0 || !Number.isSafeInteger(observationMs))
836
+ throw new TypeError("mobile.updates.server.rollout observationMinutes must produce a non-negative whole number of milliseconds.");
837
+ return {
838
+ maximumFailureRate,
839
+ minimumReports,
840
+ observationMs,
841
+ rollout: stage.rollout
842
+ };
843
+ });
844
+ if (stages.length === 0 || stages.at(-1)?.rollout !== 1)
845
+ throw new TypeError("mobile.updates.server.rollout stages must end at rollout 1.");
846
+ rollout = {
847
+ automatic: configuredRollout.automatic ?? false,
848
+ stages
849
+ };
850
+ }
801
851
  if (autoMount) {
802
852
  const manifest = new URL(updates.manifestUrl);
803
853
  if (manifest.origin !== productionOrigin)
@@ -830,7 +880,13 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
830
880
  throw new TypeError(`mobile.updates.server.expoCodeSigningKeys.${keyId} certificate is not currently valid.`);
831
881
  expoCodeSigningKeys[keyId] = { certificatePem, privateKeyEnv };
832
882
  }
833
- return { autoMount, expoCodeSigningKeys, registryModule };
883
+ return {
884
+ autoMount,
885
+ expoCodeSigningKeys,
886
+ ...health ? { health } : {},
887
+ ...rollout ? { rollout } : {},
888
+ registryModule
889
+ };
834
890
  }, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
835
891
  if (segment === "*" && (index !== count - 1 || count === 1)) {
836
892
  throw new TypeError(`mobile.routes.native route ${path} must use * once, as the final segment after a static or parameterized prefix.`);
@@ -922,6 +978,12 @@ var init_config = __esm(() => {
922
978
  UPDATE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
923
979
  UPDATE_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u;
924
980
  ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
981
+ MINUTE_MS = 60 * 1000;
982
+ DEFAULT_UPDATE_ROLLOUT_STAGES = [
983
+ { minimumReports: 20, observationMinutes: 60, rollout: 0.05 },
984
+ { minimumReports: 100, observationMinutes: 360, rollout: 0.25 },
985
+ { minimumReports: 100, observationMinutes: 0, rollout: 1 }
986
+ ];
925
987
  HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/;
926
988
  EXPO_RESERVED_ROUTE_PREFIXES = new Set([
927
989
  "_expo",
@@ -19962,15 +20024,18 @@ __export(exports_mobileUpdate, {
19962
20024
  });
19963
20025
  import {
19964
20026
  createHash as createHash15,
20027
+ createHmac,
19965
20028
  createPrivateKey,
19966
20029
  createPublicKey as createPublicKey2,
20030
+ randomUUID as randomUUID5,
19967
20031
  sign as sign2,
20032
+ timingSafeEqual,
19968
20033
  verify as verify2,
19969
20034
  X509Certificate as X509Certificate3
19970
20035
  } from "crypto";
19971
20036
  import { readFile as readFile23, stat as stat5 } from "fs/promises";
19972
20037
  import path from "path";
19973
- var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updates", MAX_FILE_BYTES, MAX_TOTAL_BYTES, DAY_MS, DEFAULT_MIN_AGE_MS, DEFAULT_GRACE_PERIOD_MS, DEFAULT_RETAIN_RECENT = 5, HASH, RELEASE, APP_ID, NAME, EXPO_DESCRIPTOR = "_absolute/expo-update.json", EXPO_CODE_SIGNING_ALGORITHM = "rsa-v1_5-sha256", MobileUpdateRegistryError, object6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field2) => {
20038
+ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updates", MAX_FILE_BYTES, MAX_TOTAL_BYTES, DAY_MS, DEFAULT_MIN_AGE_MS, DEFAULT_GRACE_PERIOD_MS, DEFAULT_RETAIN_RECENT = 5, HASH, RELEASE, APP_ID, NAME, EXPO_DESCRIPTOR = "_absolute/expo-update.json", EXPO_CODE_SIGNING_ALGORITHM = "rsa-v1_5-sha256", HEALTH_TOKEN_VERSION = 1, HEALTH_KINDS, FAILURE_HEALTH_KINDS, MobileUpdateRegistryError, object6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field2) => {
19974
20039
  if (typeof value !== "string" || value.length === 0)
19975
20040
  throw new MobileUpdateRegistryError(`Mobile update ${field2} is invalid`);
19976
20041
  return value;
@@ -20099,7 +20164,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20099
20164
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
20100
20165
  const appId = text2(value.appId, "channel appId");
20101
20166
  const channel = text2(value.channel, "channel");
20102
- if (!APP_ID.test(appId) || !NAME.test(channel) || !iso(value.promotedAt) || typeof value.rollout !== "number" || value.rollout < 0 || value.rollout > 1 || value.releaseId !== undefined && (typeof value.releaseId !== "string" || !RELEASE.test(value.releaseId)) || value.fallbackReleaseId !== undefined && (typeof value.fallbackReleaseId !== "string" || !RELEASE.test(value.fallbackReleaseId)) || value.activationId !== undefined && (typeof value.activationId !== "string" || !HASH.test(value.activationId)) || value.activatedAt !== undefined && !iso(value.activatedAt) || value.activationId === undefined !== (value.activatedAt === undefined))
20167
+ if (!APP_ID.test(appId) || !NAME.test(channel) || !iso(value.promotedAt) || typeof value.rollout !== "number" || value.rollout < 0 || value.rollout > 1 || value.releaseId !== undefined && (typeof value.releaseId !== "string" || !RELEASE.test(value.releaseId)) || value.fallbackReleaseId !== undefined && (typeof value.fallbackReleaseId !== "string" || !RELEASE.test(value.fallbackReleaseId)) || value.promotionId !== undefined && (typeof value.promotionId !== "string" || !HASH.test(value.promotionId)) || value.activationId !== undefined && (typeof value.activationId !== "string" || !HASH.test(value.activationId)) || value.activatedAt !== undefined && !iso(value.activatedAt) || value.activationId === undefined !== (value.activatedAt === undefined))
20103
20168
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
20104
20169
  return {
20105
20170
  ...value.activationId ? { activationId: value.activationId } : {},
@@ -20108,6 +20173,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20108
20173
  channel,
20109
20174
  ...value.fallbackReleaseId ? { fallbackReleaseId: value.fallbackReleaseId } : {},
20110
20175
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
20176
+ ...value.promotionId ? { promotionId: value.promotionId } : {},
20111
20177
  promotedAt: value.promotedAt,
20112
20178
  ...value.releaseId ? { releaseId: value.releaseId } : {},
20113
20179
  rollout: value.rollout
@@ -20130,15 +20196,50 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20130
20196
  return true;
20131
20197
  const value = createHash15("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
20132
20198
  return value / 4294967296 < input.rollout;
20199
+ }, base64Url = (value) => Buffer.from(value).toString("base64url"), healthPromotionId = (channel) => channel.promotionId ?? digest(new TextEncoder().encode(`${channel.appId}\x00${channel.channel}\x00${channel.releaseId ?? "embedded"}\x00${channel.promotedAt}`)), finiteMetric = (value, field2) => {
20200
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
20201
+ throw new MobileUpdateRegistryError(`Mobile update health ${field2} is invalid`);
20202
+ return value;
20203
+ }, parseHealthTransfer = (value) => {
20204
+ if (value === undefined)
20205
+ return;
20206
+ if (!object6(value))
20207
+ throw new MobileUpdateRegistryError("Mobile update health transfer is invalid");
20208
+ return {
20209
+ avoidedBytes: finiteMetric(value.avoidedBytes, "avoidedBytes"),
20210
+ downloadedBytes: finiteMetric(value.downloadedBytes, "downloadedBytes"),
20211
+ durationMs: finiteMetric(value.durationMs, "durationMs"),
20212
+ resumedBytes: finiteMetric(value.resumedBytes, "resumedBytes"),
20213
+ reusedBytes: finiteMetric(value.reusedBytes, "reusedBytes"),
20214
+ throughputBytesPerSecond: finiteMetric(value.throughputBytesPerSecond, "throughputBytesPerSecond")
20215
+ };
20133
20216
  }, createMobileUpdateRegistry = (options) => {
20134
20217
  const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
20135
20218
  const clock = options.clock ?? (() => new Date);
20219
+ const health = options.health;
20220
+ const rollout = options.rollout;
20221
+ if (health && health.secret.length < 32)
20222
+ throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
20223
+ if (health && !options.store.list)
20224
+ throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
20225
+ if (rollout && (!health || !options.store.list))
20226
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration requires fleet health and storage lifecycle listing");
20227
+ if (rollout && (rollout.stages.length === 0 || rollout.stages.some((stage, index) => stage.rollout <= 0 || stage.rollout > 1 || !Number.isSafeInteger(stage.minimumReports) || stage.minimumReports < 1 || !Number.isSafeInteger(stage.observationMs) || stage.observationMs < 0 || stage.maximumFailureRate < 0 || stage.maximumFailureRate >= 1 || index > 0 && stage.rollout <= rollout.stages[index - 1].rollout)))
20228
+ throw new MobileUpdateRegistryError("Mobile update rollout stages are invalid");
20229
+ const minimumReports = health?.autoPause?.minimumReports ?? 20;
20230
+ const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
20231
+ if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
20232
+ throw new MobileUpdateRegistryError("Mobile update health auto-pause policy is invalid");
20136
20233
  const root = (appId) => `${prefix}/${appHash(appId)}`;
20137
20234
  const releaseRoot = (manifest) => `${root(manifest.appId)}/releases/${manifest.releaseId}`;
20138
20235
  const manifestKey = (manifest) => `${releaseRoot(manifest)}/update.json`;
20139
20236
  const fileKey = (manifest, file) => `${releaseRoot(manifest)}/files/${file.path}`;
20140
20237
  const contentBlobKey = (appId, sha2563) => `${root(appId)}/blobs/${sha2563}`;
20141
20238
  const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
20239
+ const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
20240
+ const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
20241
+ const rolloutRoot = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/rollout`;
20242
+ const rolloutPlanKey = (appId, promotionId, releaseId) => `${rolloutRoot(appId, promotionId, releaseId)}/plan.json`;
20142
20243
  const channelKey = (appId, channel) => {
20143
20244
  if (!APP_ID.test(appId) || !NAME.test(channel))
20144
20245
  throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
@@ -20167,18 +20268,128 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20167
20268
  throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
20168
20269
  return value;
20169
20270
  };
20271
+ const listPrefix = async (value) => {
20272
+ const objects = [];
20273
+ const cursors = new Set;
20274
+ let cursor;
20275
+ do {
20276
+ const page = await options.store.list({
20277
+ ...cursor ? { cursor } : {},
20278
+ prefix: value
20279
+ });
20280
+ objects.push(...page.objects);
20281
+ if (!page.truncated)
20282
+ break;
20283
+ if (!page.cursor || cursors.has(page.cursor))
20284
+ throw new MobileUpdateRegistryError("Mobile update storage returned an invalid cursor");
20285
+ cursors.add(page.cursor);
20286
+ cursor = page.cursor;
20287
+ } while (true);
20288
+ return objects;
20289
+ };
20290
+ const readVerifiedObject = async (key, label) => {
20291
+ const bytes = await options.store.get(key);
20292
+ if (!bytes)
20293
+ return null;
20294
+ const head = await options.store.head(key);
20295
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
20296
+ throw new MobileUpdateRegistryError(`Stored mobile update ${label} integrity failed`);
20297
+ return decode2(bytes);
20298
+ };
20299
+ const parseRolloutPlan = (value, promotionId, releaseId) => {
20300
+ if (!object6(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== releaseId || typeof value.automatic !== "boolean" || !iso(value.createdAt) || !Array.isArray(value.stages))
20301
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
20302
+ const stages = value.stages.map((stage) => {
20303
+ if (!object6(stage) || typeof stage.rollout !== "number" || typeof stage.maximumFailureRate !== "number" || !Number.isSafeInteger(stage.minimumReports) || !Number.isSafeInteger(stage.observationMs))
20304
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
20305
+ return {
20306
+ maximumFailureRate: stage.maximumFailureRate,
20307
+ minimumReports: stage.minimumReports,
20308
+ observationMs: stage.observationMs,
20309
+ rollout: stage.rollout
20310
+ };
20311
+ });
20312
+ if (stages.length === 0 || stages.some((stage, index) => stage.rollout <= 0 || stage.rollout > 1 || stage.minimumReports < 1 || stage.observationMs < 0 || stage.maximumFailureRate < 0 || stage.maximumFailureRate >= 1 || index > 0 && stage.rollout <= stages[index - 1].rollout))
20313
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
20314
+ return {
20315
+ automatic: value.automatic,
20316
+ createdAt: value.createdAt,
20317
+ format: 1,
20318
+ promotionId,
20319
+ releaseId,
20320
+ stages
20321
+ };
20322
+ };
20323
+ const initializeRollout = async (channel, signal) => {
20324
+ if (!rollout || !channel.releaseId)
20325
+ return;
20326
+ if (!rollout.stages.some((stage) => stage.rollout === channel.rollout))
20327
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
20328
+ const promotionId = healthPromotionId(channel);
20329
+ const plan = {
20330
+ automatic: rollout.automatic ?? false,
20331
+ createdAt: channel.promotedAt,
20332
+ format: 1,
20333
+ promotionId,
20334
+ releaseId: channel.releaseId,
20335
+ stages: rollout.stages.map((stage) => ({ ...stage }))
20336
+ };
20337
+ const bytes = json(plan);
20338
+ await options.store.put(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), bytes, {
20339
+ cacheControl: "no-store",
20340
+ contentType: "application/json",
20341
+ maxBytes: bytes.byteLength,
20342
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
20343
+ signal
20344
+ });
20345
+ };
20346
+ const signHealthToken = (payload) => {
20347
+ if (!health)
20348
+ return null;
20349
+ const encoded = base64Url(JSON.stringify(payload));
20350
+ const signature = createHmac("sha256", health.secret).update(encoded).digest("base64url");
20351
+ return `${encoded}.${signature}`;
20352
+ };
20353
+ const verifyHealthToken = (token) => {
20354
+ if (!health)
20355
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
20356
+ const [encoded, provided, extra] = token.split(".");
20357
+ if (!encoded || !provided || extra)
20358
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20359
+ const expected = createHmac("sha256", health.secret).update(encoded).digest();
20360
+ let actual;
20361
+ try {
20362
+ actual = Buffer.from(provided, "base64url");
20363
+ } catch {
20364
+ actual = Buffer.alloc(0);
20365
+ }
20366
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected))
20367
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20368
+ let value;
20369
+ try {
20370
+ value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
20371
+ } catch {
20372
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20373
+ }
20374
+ if (!object6(value) || value.format !== HEALTH_TOKEN_VERSION || typeof value.appId !== "string" || typeof value.channel !== "string" || typeof value.installationId !== "string" || typeof value.promotionId !== "string" || typeof value.releaseId !== "string" || typeof value.runtimeFingerprint !== "string")
20375
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20376
+ return value;
20377
+ };
20170
20378
  const assertNotMarked = async (appId, releaseId) => {
20171
20379
  if (!APP_ID.test(appId) || !RELEASE.test(releaseId))
20172
20380
  throw new MobileUpdateRegistryError("Mobile update release identity is invalid");
20173
20381
  if (await options.store.head(tombstoneKey(appId, releaseId)))
20174
20382
  throw new MobileUpdateRegistryError("Mobile update release is marked for collection. Increase retention and apply garbage collection to restore it before promotion");
20175
20383
  };
20176
- const writeChannel = async (input, signal) => {
20384
+ const writeChannel = async (input, signal, beforeWrite) => {
20385
+ const promotedAt = clock().toISOString();
20177
20386
  const value = {
20178
20387
  ...input,
20179
20388
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
20180
- promotedAt: clock().toISOString()
20389
+ promotedAt,
20390
+ promotionId: digest(new TextEncoder().encode(`${input.appId}\x00${input.channel}\x00${input.releaseId ?? "embedded"}\x00${promotedAt}\x00${randomUUID5()}`))
20181
20391
  };
20392
+ await beforeWrite?.(value);
20182
20393
  const bytes = json(value);
20183
20394
  await options.store.put(channelKey(value.appId, value.channel), bytes, {
20184
20395
  cacheControl: "no-cache",
@@ -20193,10 +20404,92 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20193
20404
  });
20194
20405
  return value;
20195
20406
  };
20407
+ const rolloutContext = async (channel) => {
20408
+ if (!options.store.list || !channel.releaseId)
20409
+ return null;
20410
+ const promotionId = healthPromotionId(channel);
20411
+ const storedPlan = await readVerifiedObject(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), "rollout plan");
20412
+ if (storedPlan === null)
20413
+ return null;
20414
+ const plan = parseRolloutPlan(storedPlan, promotionId, channel.releaseId);
20415
+ const initialStage = plan.stages.findIndex((stage) => stage.rollout === channel.rollout);
20416
+ if (initialStage < 0)
20417
+ throw new MobileUpdateRegistryError("Stored mobile update rollout does not match its plan");
20418
+ let currentStage = initialStage;
20419
+ let enteredAt = plan.createdAt;
20420
+ const advances = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/advances/`);
20421
+ for (const item of advances) {
20422
+ const value = await readVerifiedObject(item.key, "rollout advancement");
20423
+ if (!object6(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== channel.releaseId || !Number.isSafeInteger(value.stage) || Number(value.stage) < initialStage || Number(value.stage) >= plan.stages.length || value.rollout !== plan.stages[Number(value.stage)].rollout || !iso(value.createdAt) || typeof value.failureRate !== "number" || !Number.isSafeInteger(value.terminalReports))
20424
+ throw new MobileUpdateRegistryError("Stored mobile update rollout advancement is invalid");
20425
+ if (Number(value.stage) >= currentStage) {
20426
+ currentStage = Number(value.stage);
20427
+ enteredAt = value.createdAt;
20428
+ }
20429
+ }
20430
+ const controls = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/controls/`);
20431
+ const parsedControls = [];
20432
+ for (const item of controls) {
20433
+ const value = await readVerifiedObject(item.key, "rollout control");
20434
+ if (!object6(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== channel.releaseId || typeof value.id !== "string" || value.action !== "pause" && value.action !== "resume" && value.action !== "cancel" || !iso(value.createdAt) || value.resumedPauseIds !== undefined && (!Array.isArray(value.resumedPauseIds) || value.resumedPauseIds.some((id) => typeof id !== "string")))
20435
+ throw new MobileUpdateRegistryError("Stored mobile update rollout control is invalid");
20436
+ parsedControls.push(value);
20437
+ }
20438
+ const cancelled = parsedControls.some(({ action }) => action === "cancel");
20439
+ const resumedPauseIds = new Set(parsedControls.flatMap((control) => control.resumedPauseIds ?? []));
20440
+ const activePauseIds = parsedControls.filter(({ action, id }) => action === "pause" && !resumedPauseIds.has(id)).map(({ id }) => id);
20441
+ const operatorPaused = activePauseIds.length > 0;
20442
+ const fleetPaused = Boolean(await options.store.head(pauseKey(channel.appId, promotionId, channel.releaseId)));
20443
+ return {
20444
+ cancelled,
20445
+ activePauseIds,
20446
+ channel,
20447
+ currentStage,
20448
+ enteredAt,
20449
+ fleetPaused,
20450
+ operatorPaused,
20451
+ plan,
20452
+ promotionId,
20453
+ rollout: plan.stages[currentStage].rollout
20454
+ };
20455
+ };
20456
+ const writeRolloutControl = async (channel, action, signal) => {
20457
+ const context = await rolloutContext(channel);
20458
+ if (!context)
20459
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
20460
+ if (context.cancelled)
20461
+ throw new MobileUpdateRegistryError("Mobile update rollout was already cancelled");
20462
+ if (action === "resume" && context.fleetPaused)
20463
+ throw new MobileUpdateRegistryError("A fleet-health pause requires an explicit re-promotion");
20464
+ const releaseId = channel.releaseId;
20465
+ if (!releaseId)
20466
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
20467
+ const createdAt = clock().toISOString();
20468
+ const id = randomUUID5();
20469
+ const event = {
20470
+ action,
20471
+ createdAt,
20472
+ format: 1,
20473
+ id,
20474
+ promotionId: context.promotionId,
20475
+ releaseId,
20476
+ ...action === "resume" ? { resumedPauseIds: context.activePauseIds } : {}
20477
+ };
20478
+ const bytes = json(event);
20479
+ await options.store.put(`${rolloutRoot(channel.appId, context.promotionId, releaseId)}/controls/${createdAt}-${id}-${action}.json`, bytes, {
20480
+ cacheControl: "no-store",
20481
+ contentType: "application/json",
20482
+ maxBytes: bytes.byteLength,
20483
+ metadata: { action, sha256: digest(bytes) },
20484
+ signal
20485
+ });
20486
+ };
20196
20487
  const promoteUpdate = async (input) => {
20197
20488
  input.signal?.throwIfAborted();
20198
20489
  if (input.rollout <= 0 || input.rollout > 1)
20199
20490
  throw new MobileUpdateRegistryError("Mobile update rollout is invalid");
20491
+ if (rollout && !rollout.stages.some((stage) => stage.rollout === input.rollout))
20492
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
20200
20493
  await assertNotMarked(input.appId, input.releaseId);
20201
20494
  const release = await readManifest(input.appId, input.releaseId);
20202
20495
  if (!release || release.manifest.channel !== input.channel)
@@ -20208,7 +20501,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20208
20501
  ...existing?.releaseId && existing.releaseId !== input.releaseId ? { fallbackReleaseId: existing.releaseId } : existing?.fallbackReleaseId ? { fallbackReleaseId: existing.fallbackReleaseId } : {},
20209
20502
  releaseId: input.releaseId,
20210
20503
  rollout: input.rollout
20211
- }, input.signal);
20504
+ }, input.signal, (channel) => initializeRollout(channel, input.signal));
20212
20505
  return {
20213
20506
  appId: input.appId,
20214
20507
  channel: input.channel,
@@ -20221,13 +20514,16 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20221
20514
  const channel = await readChannel(input.appId, input.channel);
20222
20515
  if (!channel?.releaseId)
20223
20516
  return { status: "empty" };
20224
- const selected = rolloutMember({
20517
+ const rolloutState = await rolloutContext(channel);
20518
+ let selected = rolloutMember({
20225
20519
  appId: input.appId,
20226
20520
  channel: input.channel,
20227
20521
  installationId: input.installationId,
20228
20522
  releaseId: channel.releaseId,
20229
- rollout: channel.rollout
20523
+ rollout: rolloutState?.rollout ?? channel.rollout
20230
20524
  }) ? channel.releaseId : channel.fallbackReleaseId;
20525
+ if (selected === channel.releaseId && (rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || health && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId))))
20526
+ selected = channel.fallbackReleaseId;
20231
20527
  if (!selected)
20232
20528
  return { status: "empty" };
20233
20529
  const release = await readManifest(input.appId, selected);
@@ -20243,6 +20539,243 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20243
20539
  status: "selected"
20244
20540
  };
20245
20541
  };
20542
+ const issueUpdateHealthToken = async (input) => {
20543
+ if (!health)
20544
+ return null;
20545
+ const channel = await readChannel(input.appId, input.channel);
20546
+ if (!channel?.releaseId || channel.releaseId !== input.releaseId)
20547
+ return null;
20548
+ const resolution = await resolveUpdateState(input);
20549
+ if (resolution.status !== "selected" || resolution.manifest.releaseId !== input.releaseId)
20550
+ return null;
20551
+ return signHealthToken({
20552
+ appId: input.appId,
20553
+ channel: input.channel,
20554
+ format: HEALTH_TOKEN_VERSION,
20555
+ installationId: input.installationId,
20556
+ promotionId: healthPromotionId(channel),
20557
+ releaseId: input.releaseId,
20558
+ runtimeFingerprint: input.runtimeFingerprint
20559
+ });
20560
+ };
20561
+ const inspectUpdateHealth = async (input) => {
20562
+ if (!health)
20563
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
20564
+ const channel = await readChannel(input.appId, input.channel);
20565
+ const releaseId = input.releaseId ?? channel?.releaseId;
20566
+ if (!channel || !releaseId || channel.releaseId !== releaseId)
20567
+ return null;
20568
+ const promotionId = healthPromotionId(channel);
20569
+ const objects = await listPrefix(`${healthRoot(input.appId, promotionId, releaseId)}/events/`);
20570
+ const installations = new Set;
20571
+ const byKind = new Map([...HEALTH_KINDS].map((kind) => [
20572
+ kind,
20573
+ new Set
20574
+ ]));
20575
+ const transfer = {
20576
+ avoidedBytes: 0,
20577
+ downloadedBytes: 0,
20578
+ durationMs: 0,
20579
+ resumedBytes: 0,
20580
+ reusedBytes: 0,
20581
+ throughputBytesPerSecond: 0
20582
+ };
20583
+ for (const item of objects) {
20584
+ const bytes = await options.store.get(item.key);
20585
+ if (!bytes)
20586
+ continue;
20587
+ const head = await options.store.head(item.key);
20588
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
20589
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence integrity failed");
20590
+ const value = decode2(bytes);
20591
+ if (!object6(value) || typeof value.installationHash !== "string" || !HEALTH_KINDS.has(String(value.kind)))
20592
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence is invalid");
20593
+ const kind = value.kind;
20594
+ installations.add(value.installationHash);
20595
+ byKind.get(kind).add(value.installationHash);
20596
+ if (kind === "downloaded" && object6(value.transfer)) {
20597
+ const parsed = parseHealthTransfer(value.transfer);
20598
+ for (const key of Object.keys(transfer))
20599
+ transfer[key] += parsed[key];
20600
+ }
20601
+ }
20602
+ const failures = new Set([
20603
+ ...byKind.get("quarantined"),
20604
+ ...byKind.get("rolled-back")
20605
+ ]);
20606
+ const terminals = new Set([...byKind.get("activated"), ...failures]);
20607
+ const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
20608
+ const rolloutState = await rolloutContext(channel);
20609
+ return {
20610
+ activated: byKind.get("activated").size,
20611
+ appId: input.appId,
20612
+ channel: input.channel,
20613
+ downloaded: byKind.get("downloaded").size,
20614
+ downloadFailed: byKind.get("download-failed").size,
20615
+ failureRate,
20616
+ failures: failures.size,
20617
+ paused: Boolean(rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
20618
+ promotionId,
20619
+ quarantined: byKind.get("quarantined").size,
20620
+ releaseId,
20621
+ reportedInstallations: installations.size,
20622
+ rolledBack: byKind.get("rolled-back").size,
20623
+ rollout: rolloutState?.rollout ?? channel.rollout,
20624
+ terminalReports: terminals.size,
20625
+ transfer
20626
+ };
20627
+ };
20628
+ const inspectUpdateRollout = async (input) => {
20629
+ const channel = await readChannel(input.appId, input.channel);
20630
+ if (!channel?.releaseId)
20631
+ return null;
20632
+ const context = await rolloutContext(channel);
20633
+ if (!context)
20634
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
20635
+ const healthReport = await inspectUpdateHealth(input);
20636
+ if (!healthReport)
20637
+ return null;
20638
+ const paused = context.fleetPaused || context.operatorPaused;
20639
+ const complete = context.currentStage === context.plan.stages.length - 1;
20640
+ return {
20641
+ ...healthReport,
20642
+ automatic: context.plan.automatic,
20643
+ currentStage: context.currentStage,
20644
+ enteredAt: context.enteredAt,
20645
+ ...!complete ? { nextStage: context.plan.stages[context.currentStage + 1] } : {},
20646
+ ...context.fleetPaused ? { pausedBy: "fleet-health" } : context.operatorPaused ? { pausedBy: "operator" } : {},
20647
+ status: context.cancelled ? "cancelled" : paused ? "paused" : complete ? "complete" : "active"
20648
+ };
20649
+ };
20650
+ const advanceRollout = async (input, strict) => {
20651
+ input.signal?.throwIfAborted();
20652
+ const channel = await readChannel(input.appId, input.channel);
20653
+ if (!channel?.releaseId)
20654
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
20655
+ const context = await rolloutContext(channel);
20656
+ if (!context)
20657
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
20658
+ const report = await inspectUpdateRollout(input);
20659
+ if (!report)
20660
+ throw new MobileUpdateRegistryError("Mobile update rollout report is unavailable");
20661
+ const nextStage = context.plan.stages[context.currentStage + 1];
20662
+ if (!nextStage)
20663
+ return report;
20664
+ if (input.rollout !== undefined && input.rollout !== nextStage.rollout)
20665
+ throw new MobileUpdateRegistryError("Mobile update rollout can advance only to the next configured stage");
20666
+ if (report.status !== "active") {
20667
+ if (strict)
20668
+ throw new MobileUpdateRegistryError(`Mobile update rollout cannot advance while ${report.status}`);
20669
+ return report;
20670
+ }
20671
+ const gate = context.plan.stages[context.currentStage];
20672
+ const observedMs = clock().getTime() - Date.parse(context.enteredAt);
20673
+ const blocked = report.terminalReports < gate.minimumReports || report.failureRate > gate.maximumFailureRate || observedMs < gate.observationMs;
20674
+ if (blocked) {
20675
+ if (strict)
20676
+ throw new MobileUpdateRegistryError(`Mobile update rollout needs ${gate.minimumReports} terminal reports, at most ${(gate.maximumFailureRate * 100).toFixed(1)}% failures, and ${gate.observationMs}ms observation at the current stage`);
20677
+ return report;
20678
+ }
20679
+ const stage = context.currentStage + 1;
20680
+ const event = {
20681
+ createdAt: clock().toISOString(),
20682
+ failureRate: report.failureRate,
20683
+ format: 1,
20684
+ promotionId: context.promotionId,
20685
+ releaseId: channel.releaseId,
20686
+ rollout: nextStage.rollout,
20687
+ stage,
20688
+ terminalReports: report.terminalReports
20689
+ };
20690
+ const bytes = json(event);
20691
+ await options.store.put(`${rolloutRoot(input.appId, context.promotionId, channel.releaseId)}/advances/${String(stage).padStart(4, "0")}.json`, bytes, {
20692
+ cacheControl: "no-store",
20693
+ contentType: "application/json",
20694
+ maxBytes: bytes.byteLength,
20695
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
20696
+ signal: input.signal
20697
+ });
20698
+ return await inspectUpdateRollout(input);
20699
+ };
20700
+ const advanceUpdateRollout = (input) => advanceRollout(input, true);
20701
+ const reconcileUpdateRollout = async (input) => {
20702
+ const report = await inspectUpdateRollout(input);
20703
+ if (!report || !report.automatic)
20704
+ return report;
20705
+ return advanceRollout(input, false);
20706
+ };
20707
+ const rolloutControl = (action) => async (input) => {
20708
+ input.signal?.throwIfAborted();
20709
+ const channel = await readChannel(input.appId, input.channel);
20710
+ if (!channel?.releaseId)
20711
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
20712
+ await writeRolloutControl(channel, action, input.signal);
20713
+ return await inspectUpdateRollout(input);
20714
+ };
20715
+ const pauseUpdateRollout = rolloutControl("pause");
20716
+ const resumeUpdateRollout = rolloutControl("resume");
20717
+ const cancelUpdateRollout = rolloutControl("cancel");
20718
+ const recordUpdateHealth = async (input) => {
20719
+ const payload = verifyHealthToken(input.token);
20720
+ if (payload.appId !== input.appId || payload.channel !== input.channel || payload.installationId !== input.installationId || payload.releaseId !== input.releaseId || payload.runtimeFingerprint !== input.runtimeFingerprint || !HEALTH_KINDS.has(input.kind) || input.reason !== undefined && input.reason !== "boot-interrupted" && input.reason !== "boot-timeout")
20721
+ throw new MobileUpdateRegistryError("Mobile update health evidence does not match its token");
20722
+ const release = await readManifest(input.appId, input.releaseId);
20723
+ if (!release || release.manifest.runtimeFingerprint !== input.runtimeFingerprint)
20724
+ throw new MobileUpdateRegistryError("Mobile update health release is invalid");
20725
+ const activeChannel = await readChannel(input.appId, input.channel);
20726
+ if (!activeChannel || activeChannel.releaseId !== input.releaseId || healthPromotionId(activeChannel) !== payload.promotionId)
20727
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
20728
+ const transfer = parseHealthTransfer(input.transfer);
20729
+ const installationHash = createHmac("sha256", health.secret).update(input.installationId).digest("hex");
20730
+ const evidence = {
20731
+ format: 1,
20732
+ installationHash,
20733
+ kind: input.kind,
20734
+ observedAt: clock().toISOString(),
20735
+ ...input.reason ? { reason: input.reason } : {},
20736
+ ...transfer ? { transfer } : {}
20737
+ };
20738
+ const bytes = json(evidence);
20739
+ await options.store.put(`${healthRoot(input.appId, payload.promotionId, input.releaseId)}/events/${installationHash}/${input.kind}.json`, bytes, {
20740
+ cacheControl: "no-store",
20741
+ contentType: "application/json",
20742
+ maxBytes: bytes.byteLength,
20743
+ metadata: { kind: input.kind, sha256: digest(bytes) }
20744
+ });
20745
+ let report = await inspectUpdateHealth({
20746
+ appId: input.appId,
20747
+ channel: input.channel,
20748
+ releaseId: input.releaseId
20749
+ });
20750
+ if (!report)
20751
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
20752
+ if (FAILURE_HEALTH_KINDS.has(input.kind) && report.terminalReports >= minimumReports && report.failureRate >= failureThreshold && !report.paused) {
20753
+ const marker = json({
20754
+ appId: input.appId,
20755
+ channel: input.channel,
20756
+ failureRate: report.failureRate,
20757
+ failures: report.failures,
20758
+ format: 1,
20759
+ pausedAt: clock().toISOString(),
20760
+ promotionId: payload.promotionId,
20761
+ releaseId: input.releaseId,
20762
+ reports: report.terminalReports
20763
+ });
20764
+ await options.store.put(pauseKey(input.appId, payload.promotionId, input.releaseId), marker, {
20765
+ cacheControl: "no-store",
20766
+ contentType: "application/json",
20767
+ maxBytes: marker.byteLength,
20768
+ metadata: { releaseid: input.releaseId, sha256: digest(marker) }
20769
+ });
20770
+ report = { ...report, paused: true };
20771
+ }
20772
+ if (rollout && (input.kind === "activated" || FAILURE_HEALTH_KINDS.has(input.kind)))
20773
+ return await reconcileUpdateRollout({
20774
+ appId: input.appId,
20775
+ channel: input.channel
20776
+ }) ?? report;
20777
+ return report;
20778
+ };
20246
20779
  const retentionValues = (input) => {
20247
20780
  if (!APP_ID.test(input.appId))
20248
20781
  throw new MobileUpdateRegistryError("Mobile update appId is invalid");
@@ -20485,6 +21018,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20485
21018
  return result;
20486
21019
  };
20487
21020
  return {
21021
+ ...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
21022
+ ...rollout ? {
21023
+ advanceUpdateRollout,
21024
+ cancelUpdateRollout,
21025
+ inspectUpdateRollout,
21026
+ pauseUpdateRollout,
21027
+ reconcileUpdateRollout,
21028
+ resumeUpdateRollout
21029
+ } : {},
20488
21030
  inspectUpdateStorage: async (input) => (await inventory(input)).report,
20489
21031
  pruneUpdates,
20490
21032
  publishUpdate: async (input) => {
@@ -20762,7 +21304,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20762
21304
  const origin = request.headers.get("origin");
20763
21305
  const cors = origin && allowedOrigins.has(origin) ? {
20764
21306
  "access-control-allow-origin": origin,
20765
- "access-control-expose-headers": "content-range,etag",
21307
+ "access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
20766
21308
  vary: "Origin"
20767
21309
  } : {};
20768
21310
  if (request.method === "OPTIONS") {
@@ -20771,17 +21313,58 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20771
21313
  return new Response(null, {
20772
21314
  headers: {
20773
21315
  ...cors,
20774
- "access-control-allow-headers": "if-range,range,x-absolute-mobile-app,x-absolute-mobile-channel,x-absolute-mobile-installation,x-absolute-mobile-release,x-absolute-mobile-runtime",
20775
- "access-control-allow-methods": "GET,OPTIONS",
21316
+ "access-control-allow-headers": "content-type,if-range,range,x-absolute-mobile-app,x-absolute-mobile-channel,x-absolute-mobile-health-token,x-absolute-mobile-installation,x-absolute-mobile-release,x-absolute-mobile-runtime",
21317
+ "access-control-allow-methods": "GET,POST,OPTIONS",
20776
21318
  "access-control-max-age": "600"
20777
21319
  },
20778
21320
  status: 204
20779
21321
  });
20780
21322
  }
20781
- if (request.method !== "GET")
20782
- return new Response(null, { status: 405 });
20783
21323
  const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
20784
21324
  const relative17 = pathname.startsWith(`${route}/`) ? pathname.slice(route.length + 1) : "";
21325
+ if (request.method === "POST" && relative17 === "health") {
21326
+ if (!options.registry.recordUpdateHealth)
21327
+ return new Response(null, { status: 404 });
21328
+ const appId = request.headers.get("x-absolute-mobile-app");
21329
+ const channel = request.headers.get("x-absolute-mobile-channel");
21330
+ const installationId = request.headers.get("x-absolute-mobile-installation");
21331
+ const runtimeFingerprint = request.headers.get("x-absolute-mobile-runtime");
21332
+ const token = request.headers.get("x-absolute-mobile-health-token");
21333
+ const declared = Number(request.headers.get("content-length"));
21334
+ if (appId !== options.appId || channel !== options.channel || !installationId || !runtimeFingerprint || !token || Number.isFinite(declared) && declared > 4096)
21335
+ return new Response(null, { status: 400 });
21336
+ const bodyBytes = new Uint8Array(await request.arrayBuffer());
21337
+ if (bodyBytes.byteLength > 4096)
21338
+ return new Response(null, { status: 413 });
21339
+ let body;
21340
+ try {
21341
+ body = JSON.parse(new TextDecoder().decode(bodyBytes));
21342
+ } catch {
21343
+ return new Response(null, { status: 400 });
21344
+ }
21345
+ if (!object6(body) || typeof body.releaseId !== "string" || typeof body.kind !== "string")
21346
+ return new Response(null, { status: 400 });
21347
+ try {
21348
+ const report = await options.registry.recordUpdateHealth({
21349
+ appId,
21350
+ channel,
21351
+ installationId,
21352
+ kind: body.kind,
21353
+ ...body.reason === "boot-interrupted" || body.reason === "boot-timeout" ? { reason: body.reason } : {},
21354
+ releaseId: body.releaseId,
21355
+ runtimeFingerprint,
21356
+ token,
21357
+ ...object6(body.transfer) ? { transfer: body.transfer } : {}
21358
+ });
21359
+ return Response.json({ paused: report.paused }, { headers: { ...cors, "cache-control": "no-store" }, status: 202 });
21360
+ } catch (error) {
21361
+ if (error instanceof MobileUpdateRegistryError)
21362
+ return new Response(null, { status: 403 });
21363
+ throw error;
21364
+ }
21365
+ }
21366
+ if (request.method !== "GET")
21367
+ return new Response(null, { status: 405 });
20785
21368
  if (relative17 === "update.json") {
20786
21369
  const expoProtocolVersion = request.headers.get("expo-protocol-version");
20787
21370
  const expoProtocol = expoProtocolVersion !== null;
@@ -20807,6 +21390,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20807
21390
  installationId,
20808
21391
  runtimeFingerprint
20809
21392
  });
21393
+ const healthToken = selected && options.registry.issueUpdateHealthToken ? await options.registry.issueUpdateHealthToken({
21394
+ appId,
21395
+ channel,
21396
+ installationId,
21397
+ releaseId: selected.manifest.releaseId,
21398
+ runtimeFingerprint
21399
+ }) : null;
20810
21400
  if (expoProtocol) {
20811
21401
  let requestedCodeSigning;
20812
21402
  try {
@@ -20858,6 +21448,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20858
21448
  extra: {
20859
21449
  absolutejs: {
20860
21450
  channel: selected.manifest.channel,
21451
+ ...healthToken ? { healthToken } : {},
20861
21452
  releaseId: selected.manifest.releaseId
20862
21453
  },
20863
21454
  expoClient: descriptor.expoConfig
@@ -20885,7 +21476,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20885
21476
  headers: {
20886
21477
  ...cors,
20887
21478
  "cache-control": "no-store",
20888
- etag: `"${selected.manifest.releaseId}"`
21479
+ etag: `"${selected.manifest.releaseId}"`,
21480
+ ...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
20889
21481
  }
20890
21482
  });
20891
21483
  }
@@ -20948,6 +21540,14 @@ var init_mobileUpdate = __esm(() => {
20948
21540
  RELEASE = /^amu_[a-f0-9]{64}$/;
20949
21541
  APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
20950
21542
  NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
21543
+ HEALTH_KINDS = new Set([
21544
+ "activated",
21545
+ "downloaded",
21546
+ "download-failed",
21547
+ "quarantined",
21548
+ "rolled-back"
21549
+ ]);
21550
+ FAILURE_HEALTH_KINDS = new Set(["quarantined", "rolled-back"]);
20951
21551
  MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
20952
21552
  };
20953
21553
  expoProtocolHeaders = {
@@ -21035,6 +21635,16 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21035
21635
  } catch (error) {
21036
21636
  throw new TypeError(`Durable mobile update storage verification failed for ${module.metadata.provider}. Check the bucket, endpoint, credentials, and read/write/delete permissions.`, { cause: error });
21037
21637
  }
21638
+ }, verifyHealthModule = (config, module) => {
21639
+ if (!config.updateServer?.health)
21640
+ return;
21641
+ if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
21642
+ throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
21643
+ }, verifyRolloutModule = (config, module) => {
21644
+ if (!config.updateServer?.rollout)
21645
+ return;
21646
+ if (typeof module.registry.advanceUpdateRollout !== "function" || typeof module.registry.cancelUpdateRollout !== "function" || typeof module.registry.inspectUpdateRollout !== "function" || typeof module.registry.pauseUpdateRollout !== "function" || typeof module.registry.reconcileUpdateRollout !== "function" || typeof module.registry.resumeUpdateRollout !== "function")
21647
+ throw new TypeError("Mobile update rollout orchestration is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
21038
21648
  }, expoSigningOptions = (config) => {
21039
21649
  if (!config.updates?.expoCodeSigning)
21040
21650
  return;
@@ -21066,8 +21676,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21066
21676
  if (!updates || !server?.autoMount)
21067
21677
  return new Elysia4({ name: "absolutejs-mobile-updates-disabled" });
21068
21678
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
21069
- if (options.production)
21679
+ if (options.production) {
21070
21680
  await verifyDurableModule(module);
21681
+ verifyHealthModule(config, module);
21682
+ verifyRolloutModule(config, module);
21683
+ }
21071
21684
  const manifest = new URL(updates.manifestUrl);
21072
21685
  if (!manifest.pathname.endsWith("/update.json"))
21073
21686
  throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
@@ -21086,10 +21699,12 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21086
21699
  return;
21087
21700
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
21088
21701
  await verifyDurableModule(module);
21702
+ verifyHealthModule(config, module);
21703
+ verifyRolloutModule(config, module);
21089
21704
  if (config.engine === "expo")
21090
21705
  expoSigningOptions(config);
21091
21706
  return module.metadata;
21092
- }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistry = (options) => {
21707
+ }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
21093
21708
  const metadata = `export const absoluteMobileUpdateServer = {
21094
21709
  format: 1,
21095
21710
  provider: '${options.storage}',
@@ -21155,6 +21770,27 @@ export default createMobileUpdateRegistry({
21155
21770
  store
21156
21771
  });
21157
21772
  `;
21773
+ }, renderAbsoluteMobileUpdateRegistry = (options) => {
21774
+ const source = renderAbsoluteMobileUpdateRegistryBase(options);
21775
+ let generated = "";
21776
+ if (options.health) {
21777
+ const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
21778
+ generated += ` health: {
21779
+ autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
21780
+ secret: ${secret}
21781
+ },
21782
+ `;
21783
+ }
21784
+ if (options.rollout)
21785
+ generated += ` rollout: ${JSON.stringify(options.rollout, null, "\t").replaceAll(`
21786
+ `, `
21787
+ `)},
21788
+ `;
21789
+ if (!generated)
21790
+ return source;
21791
+ return source.replace(`export default createMobileUpdateRegistry({
21792
+ `, `export default createMobileUpdateRegistry({
21793
+ ${generated}`);
21158
21794
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
21159
21795
  const path2 = projectPath3(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
21160
21796
  if (!options.force) {
@@ -21166,6 +21802,8 @@ export default createMobileUpdateRegistry({
21166
21802
  }
21167
21803
  await mkdir18(dirname19(path2), { recursive: true });
21168
21804
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
21805
+ ...options.health ? { health: options.health } : {},
21806
+ ...options.rollout ? { rollout: options.rollout } : {},
21169
21807
  publicKeys: options.publicKeys,
21170
21808
  storage: options.storage
21171
21809
  }));
@@ -27832,11 +28470,48 @@ export default function AbsoluteLayout() {
27832
28470
  return <Stack screenOptions={{ headerShown: false }} />;
27833
28471
  }
27834
28472
  `;
27835
- var updatesRuntimeSource = () => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
28473
+ var updatesRuntimeSource = (config) => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
27836
28474
  import * as SecureStore from 'expo-secure-store';
27837
28475
  import * as Updates from 'expo-updates';
27838
28476
 
27839
28477
  const INSTALLATION_KEY = 'absolutejs.mobile.update.installation.v1';
28478
+ const PENDING_HEALTH_KEY = 'absolutejs.mobile.update.pending-health.v1';
28479
+ const APP_ID = ${JSON.stringify(config.appId)};
28480
+ const CHANNEL = ${JSON.stringify(config.updates?.channel)};
28481
+ const MANIFEST_URL = ${JSON.stringify(config.updates?.manifestUrl)};
28482
+
28483
+ const updateIdentity = (value: unknown) => {
28484
+ if (!value || typeof value !== 'object') return undefined;
28485
+ const extra = Reflect.get(value, 'extra');
28486
+ if (!extra || typeof extra !== 'object') return undefined;
28487
+ const absolute = Reflect.get(extra, 'absolutejs');
28488
+ if (!absolute || typeof absolute !== 'object') return undefined;
28489
+ const healthToken = Reflect.get(absolute, 'healthToken');
28490
+ const releaseId = Reflect.get(absolute, 'releaseId');
28491
+ return typeof healthToken === 'string' && typeof releaseId === 'string'
28492
+ ? { healthToken, releaseId }
28493
+ : undefined;
28494
+ };
28495
+
28496
+ const report = async (installationId: string, evidence: { healthToken: string; kind: string; releaseId: string }) => {
28497
+ const endpoint = new URL('./health', MANIFEST_URL);
28498
+ await fetch(endpoint.href, {
28499
+ body: JSON.stringify({ kind: evidence.kind, releaseId: evidence.releaseId }),
28500
+ cache: 'no-store',
28501
+ credentials: 'omit',
28502
+ headers: {
28503
+ 'content-type': 'application/json',
28504
+ 'x-absolute-mobile-app': APP_ID,
28505
+ 'x-absolute-mobile-channel': CHANNEL,
28506
+ 'x-absolute-mobile-health-token': evidence.healthToken,
28507
+ 'x-absolute-mobile-installation': installationId,
28508
+ 'x-absolute-mobile-release': updateIdentity(Updates.manifest)?.releaseId ?? 'embedded',
28509
+ 'x-absolute-mobile-runtime': Updates.runtimeVersion ?? ''
28510
+ },
28511
+ method: 'POST',
28512
+ redirect: 'error'
28513
+ });
28514
+ };
27840
28515
 
27841
28516
  let startPromise: Promise<void> | undefined;
27842
28517
  export const startAbsoluteExpoUpdates = () => {
@@ -27848,9 +28523,34 @@ export const startAbsoluteExpoUpdates = () => {
27848
28523
  await SecureStore.setItemAsync(INSTALLATION_KEY, installationId);
27849
28524
  }
27850
28525
  await Updates.setExtraParamAsync('absolute-installation', installationId);
28526
+ const pendingSource = await SecureStore.getItemAsync(PENDING_HEALTH_KEY);
28527
+ if (pendingSource) {
28528
+ try {
28529
+ const pending = JSON.parse(pendingSource);
28530
+ const active = updateIdentity(Updates.manifest);
28531
+ if (typeof pending?.healthToken === 'string' && typeof pending?.releaseId === 'string')
28532
+ await report(installationId, {
28533
+ healthToken: pending.healthToken,
28534
+ kind: active?.releaseId === pending.releaseId ? 'activated' : 'rolled-back',
28535
+ releaseId: pending.releaseId
28536
+ });
28537
+ } catch {}
28538
+ await SecureStore.deleteItemAsync(PENDING_HEALTH_KEY);
28539
+ }
27851
28540
  const result = await Updates.checkForUpdateAsync();
27852
28541
  if (result.isAvailable || result.isRollBackToEmbedded) {
27853
- await Updates.fetchUpdateAsync();
28542
+ const available = updateIdentity(Reflect.get(result, 'manifest'));
28543
+ try {
28544
+ const fetched = await Updates.fetchUpdateAsync();
28545
+ const identity = updateIdentity(Reflect.get(fetched, 'manifest')) ?? available;
28546
+ if (identity) {
28547
+ await SecureStore.setItemAsync(PENDING_HEALTH_KEY, JSON.stringify(identity));
28548
+ await report(installationId, { ...identity, kind: 'downloaded' });
28549
+ }
28550
+ } catch (error) {
28551
+ if (available) await report(installationId, { ...available, kind: 'download-failed' }).catch(() => undefined);
28552
+ throw error;
28553
+ }
27854
28554
  if (result.isRollBackToEmbedded) {
27855
28555
  await Updates.reloadAsync();
27856
28556
  }
@@ -28795,7 +29495,7 @@ node_modules/
28795
29495
  files.set(path, source);
28796
29496
  }
28797
29497
  if (config.updates) {
28798
- files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource());
29498
+ files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource(config));
28799
29499
  }
28800
29500
  const expoCodeSigning = config.updates?.expoCodeSigning;
28801
29501
  if (expoCodeSigning)
@@ -38780,6 +39480,30 @@ var requestHeaders = (config) => ({
38780
39480
  "x-absolute-mobile-release": config.currentReleaseId,
38781
39481
  "x-absolute-mobile-runtime": config.runtimeFingerprint
38782
39482
  });
39483
+ var healthUrl = (manifestUrl) => new URL("./health", manifestUrl);
39484
+ var reportAbsoluteMobileUpdateHealth = async (config, input, request = globalThis.fetch) => {
39485
+ const manifestUrl = exactManifestUrl(config.manifestUrl);
39486
+ const headers = new Headers(requestHeaders(config));
39487
+ headers.set("content-type", "application/json");
39488
+ headers.set("x-absolute-mobile-health-token", input.healthToken);
39489
+ const response = await request(healthUrl(manifestUrl), {
39490
+ body: JSON.stringify({
39491
+ kind: input.kind,
39492
+ ...input.reason ? { reason: input.reason } : {},
39493
+ releaseId: input.releaseId,
39494
+ ...input.transfer ? { transfer: input.transfer } : {}
39495
+ }),
39496
+ cache: "no-store",
39497
+ credentials: "omit",
39498
+ headers,
39499
+ keepalive: true,
39500
+ method: "POST",
39501
+ redirect: "error",
39502
+ signal: AbortSignal.timeout(15000)
39503
+ });
39504
+ if (response.status !== 202)
39505
+ throw new TypeError(`Mobile update health report failed with HTTP ${response.status}.`);
39506
+ };
38783
39507
  var requireCompatible = (manifest, config) => {
38784
39508
  if (manifest.appId !== config.appId)
38785
39509
  throw new TypeError("Mobile update belongs to another app.");
@@ -38975,15 +39699,24 @@ var createAbsoluteMobileUpdateClient = (options) => {
38975
39699
  throw new TypeError("Mobile update manifest is not valid JSON.");
38976
39700
  }
38977
39701
  const manifest = parseAbsoluteMobileUpdateManifest(manifestValue);
39702
+ const healthToken = response.headers.get("x-absolute-mobile-health-token");
38978
39703
  requireCompatible(manifest, options.config);
38979
39704
  if (!await options.verifier.verify(manifest))
38980
39705
  throw new TypeError("Mobile update signature verification failed.");
38981
39706
  if (manifest.releaseId === options.config.currentReleaseId)
38982
39707
  return { kind: "current" };
38983
39708
  if (options.config.blockedReleaseIds?.includes(manifest.releaseId))
38984
- return { kind: "quarantined", releaseId: manifest.releaseId };
39709
+ return {
39710
+ ...healthToken ? { healthToken } : {},
39711
+ kind: "quarantined",
39712
+ releaseId: manifest.releaseId
39713
+ };
38985
39714
  if (!download)
38986
- return { kind: "update-available", manifest };
39715
+ return {
39716
+ ...healthToken ? { healthToken } : {},
39717
+ kind: "update-available",
39718
+ manifest
39719
+ };
38987
39720
  await options.store.begin(manifest);
38988
39721
  let transfer;
38989
39722
  try {
@@ -38994,14 +39727,28 @@ var createAbsoluteMobileUpdateClient = (options) => {
38994
39727
  await options.store.suspend(manifest.releaseId);
38995
39728
  else
38996
39729
  await options.store.abort(manifest.releaseId);
39730
+ if (healthToken)
39731
+ reportAbsoluteMobileUpdateHealth(options.config, {
39732
+ healthToken,
39733
+ kind: "download-failed",
39734
+ releaseId: manifest.releaseId
39735
+ }, request).catch(() => {
39736
+ return;
39737
+ });
38997
39738
  throw error;
38998
39739
  }
38999
- return { kind: "downloaded", manifest, transfer };
39740
+ return {
39741
+ ...healthToken ? { healthToken } : {},
39742
+ kind: "downloaded",
39743
+ manifest,
39744
+ transfer
39745
+ };
39000
39746
  };
39001
39747
  return {
39002
39748
  check,
39003
39749
  activate: (releaseId) => options.store.activate(releaseId),
39004
- download: () => check(true)
39750
+ download: () => check(true),
39751
+ report: (input) => reportAbsoluteMobileUpdateHealth(options.config, input, request)
39005
39752
  };
39006
39753
  };
39007
39754
  // src/mobile/updatePublisher.ts
@@ -39124,6 +39871,66 @@ var lifecycleMethod = (publisher, name) => {
39124
39871
  throw new TypeError(`Mobile update registry does not support ${name}. Re-run \`absolute mobile update provision --force\` after upgrading @absolutejs/deploy.`);
39125
39872
  return method;
39126
39873
  };
39874
+ var validateHealthReport = (report, options) => {
39875
+ if (!object5(report) || report.appId !== options.appId || report.channel !== options.channel || options.releaseId !== undefined && report.releaseId !== options.releaseId || typeof report.paused !== "boolean" || ![
39876
+ report.activated,
39877
+ report.downloaded,
39878
+ report.downloadFailed,
39879
+ report.failures,
39880
+ report.quarantined,
39881
+ report.reportedInstallations,
39882
+ report.rolledBack,
39883
+ report.terminalReports
39884
+ ].every((value) => Number.isSafeInteger(value) && value >= 0) || !Number.isFinite(report.failureRate) || report.failureRate < 0 || report.failureRate > 1 || !Number.isFinite(report.rollout) || report.rollout < 0 || report.rollout > 1 || !object5(report.transfer) || ![
39885
+ report.transfer.avoidedBytes,
39886
+ report.transfer.downloadedBytes,
39887
+ report.transfer.durationMs,
39888
+ report.transfer.resumedBytes,
39889
+ report.transfer.reusedBytes,
39890
+ report.transfer.throughputBytesPerSecond
39891
+ ].every((value) => Number.isFinite(value) && value >= 0))
39892
+ throw new TypeError("Mobile update registry returned an invalid health report.");
39893
+ return report;
39894
+ };
39895
+ var inspectAbsoluteMobileUpdateHealth = async (options) => {
39896
+ const report = await lifecycleMethod(options.publisher, "inspectUpdateHealth")({
39897
+ appId: options.appId,
39898
+ channel: options.channel,
39899
+ ...options.releaseId ? { releaseId: options.releaseId } : {}
39900
+ });
39901
+ if (report === null)
39902
+ return null;
39903
+ return validateHealthReport(report, options);
39904
+ };
39905
+ var validateRolloutReport = (report, options) => {
39906
+ validateHealthReport(report, options);
39907
+ const validStage = (stage) => stage === undefined || object5(stage) && Number.isFinite(stage.rollout) && stage.rollout > 0 && stage.rollout <= 1 && Number.isSafeInteger(stage.minimumReports) && stage.minimumReports > 0 && Number.isSafeInteger(stage.observationMs) && stage.observationMs >= 0 && Number.isFinite(stage.maximumFailureRate) && stage.maximumFailureRate >= 0 && stage.maximumFailureRate < 1;
39908
+ if (typeof report.automatic !== "boolean" || !Number.isSafeInteger(report.currentStage) || report.currentStage < 0 || !Number.isFinite(Date.parse(report.enteredAt)) || !["active", "cancelled", "complete", "paused"].includes(report.status) || report.pausedBy !== undefined && report.pausedBy !== "fleet-health" && report.pausedBy !== "operator" || !validStage(report.nextStage) || report.paused !== (report.status === "paused" || report.status === "cancelled"))
39909
+ throw new TypeError("Mobile update registry returned an invalid rollout report.");
39910
+ return report;
39911
+ };
39912
+ var inspectAbsoluteMobileUpdateRollout = async (options) => {
39913
+ const report = await lifecycleMethod(options.publisher, "inspectUpdateRollout")({ appId: options.appId, channel: options.channel });
39914
+ return report === null ? null : validateRolloutReport(report, options);
39915
+ };
39916
+ var mutateAbsoluteMobileUpdateRollout = async (method, options) => validateRolloutReport(await lifecycleMethod(options.publisher, method)({
39917
+ appId: options.appId,
39918
+ channel: options.channel,
39919
+ ...method === "advanceUpdateRollout" && options.rollout !== undefined ? { rollout: options.rollout } : {},
39920
+ ...options.signal ? { signal: options.signal } : {}
39921
+ }), options);
39922
+ var advanceAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("advanceUpdateRollout", options);
39923
+ var cancelAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("cancelUpdateRollout", options);
39924
+ var pauseAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("pauseUpdateRollout", options);
39925
+ var reconcileAbsoluteMobileUpdateRollout = async (options) => {
39926
+ const report = await lifecycleMethod(options.publisher, "reconcileUpdateRollout")({
39927
+ appId: options.appId,
39928
+ channel: options.channel,
39929
+ ...options.signal ? { signal: options.signal } : {}
39930
+ });
39931
+ return report === null ? null : validateRolloutReport(report, options);
39932
+ };
39933
+ var resumeAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("resumeUpdateRollout", options);
39127
39934
  var validOptionalCounters = (values) => values.every((value) => value === undefined) || values.every((value) => Number.isSafeInteger(value) && (value ?? -1) >= 0);
39128
39935
  var validateStorageIdentity = (result, appId) => {
39129
39936
  if (!object5(result) || result.appId !== appId || !Array.isArray(result.releases) || ![
@@ -39324,6 +40131,7 @@ export {
39324
40131
  acceptsAbsoluteRouteData,
39325
40132
  acquireAbsoluteRemoteMacReleaseLease,
39326
40133
  activateAbsoluteMobilePage,
40134
+ advanceAbsoluteMobileUpdateRollout,
39327
40135
  applyAbsoluteNativeDeepLinks,
39328
40136
  applyAbsoluteNativeDeviceCapabilities,
39329
40137
  applyAbsoluteNativeObservability,
@@ -39335,6 +40143,7 @@ export {
39335
40143
  buildAbsoluteMobileCompatibilityRelease,
39336
40144
  buildAbsoluteMobileUpdate,
39337
40145
  buildAbsoluteRemoteIosRelease,
40146
+ cancelAbsoluteMobileUpdateRollout,
39338
40147
  canonicalizeAbsoluteMobileUpdate,
39339
40148
  captureAbsoluteMobileRouteGraph,
39340
40149
  captureAbsoluteRemoteMacCommand,
@@ -39385,6 +40194,8 @@ export {
39385
40194
  hashAbsoluteMobilePropsSchema,
39386
40195
  inspectAbsoluteAndroidInstalledApp,
39387
40196
  inspectAbsoluteMobileRouteMetadata,
40197
+ inspectAbsoluteMobileUpdateHealth,
40198
+ inspectAbsoluteMobileUpdateRollout,
39388
40199
  inspectAbsoluteMobileUpdateServer,
39389
40200
  inspectAbsoluteMobileUpdateStorage,
39390
40201
  inspectAbsoluteRemoteMac,
@@ -39436,6 +40247,7 @@ export {
39436
40247
  parseIosDeviceTypes,
39437
40248
  parseIosRuntimes,
39438
40249
  parseIosSimulators,
40250
+ pauseAbsoluteMobileUpdateRollout,
39439
40251
  planAbsoluteExpoDevSession,
39440
40252
  prepareAbsoluteAndroidRelease,
39441
40253
  prepareAbsoluteIosDevProject,
@@ -39451,10 +40263,12 @@ export {
39451
40263
  readAbsoluteMobileLinkIntent,
39452
40264
  readAbsoluteMobileMaterializedReleases,
39453
40265
  readAbsoluteMobileUpdate,
40266
+ reconcileAbsoluteMobileUpdateRollout,
39454
40267
  redactAbsoluteIosLog,
39455
40268
  removeAbsoluteRemoteMacProfile,
39456
40269
  renderAbsoluteMobileUpdateRegistry,
39457
40270
  repairAbsoluteIosDevSession,
40271
+ reportAbsoluteMobileUpdateHealth,
39458
40272
  requestAbsoluteMobileBack,
39459
40273
  requireAbsoluteIosReleaseMetadata,
39460
40274
  resolveAbsoluteDeviceCapabilityPlan,
@@ -39465,6 +40279,7 @@ export {
39465
40279
  resolveAbsoluteMobileNavigation,
39466
40280
  resolveAbsoluteMobileRoute,
39467
40281
  resolveAbsoluteMobileUpdateRuntime,
40282
+ resumeAbsoluteMobileUpdateRollout,
39468
40283
  retainAbsoluteMobileCompatibilityArtifacts,
39469
40284
  rollbackAbsoluteMobileUpdate,
39470
40285
  runAbsoluteAndroidUpgradeConformance,
@@ -39493,5 +40308,5 @@ export {
39493
40308
  writeAbsoluteMobileUpdateRegistry
39494
40309
  };
39495
40310
 
39496
- //# debugId=B713A6D1CA49D10364756E2164756E21
40311
+ //# debugId=DB548297ABB3C7C964756E2164756E21
39497
40312
  //# sourceMappingURL=index.js.map