@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.
package/dist/index.js CHANGED
@@ -10636,7 +10636,7 @@ __export(exports_config, {
10636
10636
  import { readFileSync as readFileSync11 } from "fs";
10637
10637
  import { resolve as resolve12 } from "path";
10638
10638
  import { createHash as createHash4, createPublicKey, X509Certificate } from "crypto";
10639
- 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, field) => {
10639
+ 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, field) => {
10640
10640
  const root = resolve12(projectRoot);
10641
10641
  const path = resolve12(root, value);
10642
10642
  if (path !== root && !path.startsWith(`${root}/`)) {
@@ -10830,6 +10830,56 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
10830
10830
  if (!ENVIRONMENT_NAME_PATTERN.test(expoPrivateKeyEnv))
10831
10831
  throw new TypeError("mobile.updates.server.expoPrivateKeyEnv must be a valid environment variable name.");
10832
10832
  const autoMount = config.updates.server?.autoMount ?? true;
10833
+ const configuredHealth = config.updates.server?.health;
10834
+ let health;
10835
+ if (configuredHealth !== false) {
10836
+ const failureRate = configuredHealth?.failureRate ?? DEFAULT_UPDATE_HEALTH_FAILURE_RATE;
10837
+ const minimumReports = configuredHealth?.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
10838
+ const secretEnv = requireText(configuredHealth?.secretEnv ?? "ABSOLUTE_MOBILE_UPDATE_HEALTH_SECRET", "mobile.updates.server.health.secretEnv");
10839
+ if (!Number.isFinite(failureRate) || failureRate <= 0 || failureRate > 1)
10840
+ throw new TypeError("mobile.updates.server.health.failureRate must be greater than 0 and at most 1.");
10841
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < 1)
10842
+ throw new TypeError("mobile.updates.server.health.minimumReports must be a positive integer.");
10843
+ if (!ENVIRONMENT_NAME_PATTERN.test(secretEnv))
10844
+ throw new TypeError("mobile.updates.server.health.secretEnv must be a valid environment variable name.");
10845
+ health = { failureRate, minimumReports, secretEnv };
10846
+ }
10847
+ const configuredRollout = config.updates.server?.rollout;
10848
+ let rollout;
10849
+ if (configuredRollout !== undefined && configuredRollout !== false) {
10850
+ if (!health)
10851
+ throw new TypeError("mobile.updates.server.rollout requires fleet health to be enabled.");
10852
+ if (configuredRollout.automatic !== undefined && typeof configuredRollout.automatic !== "boolean")
10853
+ throw new TypeError("mobile.updates.server.rollout.automatic must be boolean.");
10854
+ const configuredStages = configuredRollout.stages ?? DEFAULT_UPDATE_ROLLOUT_STAGES;
10855
+ const stages = configuredStages.map((stage, index) => {
10856
+ const previousStage = configuredStages[index - 1];
10857
+ const maximumFailureRate = stage.maximumFailureRate ?? DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE;
10858
+ const minimumReports = stage.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
10859
+ const observationMinutes = stage.observationMinutes ?? DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES;
10860
+ const observationMs = observationMinutes * MINUTE_MS;
10861
+ if (!Number.isFinite(stage.rollout) || stage.rollout <= 0 || stage.rollout > 1 || previousStage !== undefined && stage.rollout <= previousStage.rollout)
10862
+ throw new TypeError("mobile.updates.server.rollout stages must be strictly increasing fractions greater than 0 and at most 1.");
10863
+ if (!Number.isFinite(maximumFailureRate) || maximumFailureRate < 0 || maximumFailureRate >= health.failureRate)
10864
+ throw new TypeError("mobile.updates.server.rollout maximumFailureRate must be non-negative and lower than the fleet-health pause rate.");
10865
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < health.minimumReports)
10866
+ throw new TypeError("mobile.updates.server.rollout minimumReports must be an integer at least as large as the fleet-health minimumReports.");
10867
+ if (!Number.isFinite(observationMinutes) || observationMinutes < 0 || !Number.isSafeInteger(observationMs))
10868
+ throw new TypeError("mobile.updates.server.rollout observationMinutes must produce a non-negative whole number of milliseconds.");
10869
+ return {
10870
+ maximumFailureRate,
10871
+ minimumReports,
10872
+ observationMs,
10873
+ rollout: stage.rollout
10874
+ };
10875
+ });
10876
+ if (stages.length === 0 || stages.at(-1)?.rollout !== 1)
10877
+ throw new TypeError("mobile.updates.server.rollout stages must end at rollout 1.");
10878
+ rollout = {
10879
+ automatic: configuredRollout.automatic ?? false,
10880
+ stages
10881
+ };
10882
+ }
10833
10883
  if (autoMount) {
10834
10884
  const manifest = new URL(updates.manifestUrl);
10835
10885
  if (manifest.origin !== productionOrigin)
@@ -10862,7 +10912,13 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
10862
10912
  throw new TypeError(`mobile.updates.server.expoCodeSigningKeys.${keyId} certificate is not currently valid.`);
10863
10913
  expoCodeSigningKeys[keyId] = { certificatePem, privateKeyEnv };
10864
10914
  }
10865
- return { autoMount, expoCodeSigningKeys, registryModule };
10915
+ return {
10916
+ autoMount,
10917
+ expoCodeSigningKeys,
10918
+ ...health ? { health } : {},
10919
+ ...rollout ? { rollout } : {},
10920
+ registryModule
10921
+ };
10866
10922
  }, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
10867
10923
  if (segment === "*" && (index !== count - 1 || count === 1)) {
10868
10924
  throw new TypeError(`mobile.routes.native route ${path} must use * once, as the final segment after a static or parameterized prefix.`);
@@ -10954,6 +11010,12 @@ var init_config = __esm(() => {
10954
11010
  UPDATE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
10955
11011
  UPDATE_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u;
10956
11012
  ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
11013
+ MINUTE_MS = 60 * 1000;
11014
+ DEFAULT_UPDATE_ROLLOUT_STAGES = [
11015
+ { minimumReports: 20, observationMinutes: 60, rollout: 0.05 },
11016
+ { minimumReports: 100, observationMinutes: 360, rollout: 0.25 },
11017
+ { minimumReports: 100, observationMinutes: 0, rollout: 1 }
11018
+ ];
10957
11019
  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])?))*$/;
10958
11020
  EXPO_RESERVED_ROUTE_PREFIXES = new Set([
10959
11021
  "_expo",
@@ -12280,15 +12342,18 @@ __export(exports_mobileUpdate, {
12280
12342
  });
12281
12343
  import {
12282
12344
  createHash as createHash5,
12345
+ createHmac,
12283
12346
  createPrivateKey,
12284
12347
  createPublicKey as createPublicKey2,
12348
+ randomUUID,
12285
12349
  sign,
12350
+ timingSafeEqual,
12286
12351
  verify,
12287
12352
  X509Certificate as X509Certificate2
12288
12353
  } from "crypto";
12289
12354
  import { readFile as readFile4, stat as stat2 } from "fs/promises";
12290
12355
  import path from "path";
12291
- 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, object3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field) => {
12356
+ 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, object3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field) => {
12292
12357
  if (typeof value !== "string" || value.length === 0)
12293
12358
  throw new MobileUpdateRegistryError(`Mobile update ${field} is invalid`);
12294
12359
  return value;
@@ -12417,7 +12482,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12417
12482
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
12418
12483
  const appId = text2(value.appId, "channel appId");
12419
12484
  const channel = text2(value.channel, "channel");
12420
- 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))
12485
+ 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))
12421
12486
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
12422
12487
  return {
12423
12488
  ...value.activationId ? { activationId: value.activationId } : {},
@@ -12426,6 +12491,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12426
12491
  channel,
12427
12492
  ...value.fallbackReleaseId ? { fallbackReleaseId: value.fallbackReleaseId } : {},
12428
12493
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
12494
+ ...value.promotionId ? { promotionId: value.promotionId } : {},
12429
12495
  promotedAt: value.promotedAt,
12430
12496
  ...value.releaseId ? { releaseId: value.releaseId } : {},
12431
12497
  rollout: value.rollout
@@ -12448,15 +12514,50 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12448
12514
  return true;
12449
12515
  const value = createHash5("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
12450
12516
  return value / 4294967296 < input.rollout;
12517
+ }, 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, field) => {
12518
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
12519
+ throw new MobileUpdateRegistryError(`Mobile update health ${field} is invalid`);
12520
+ return value;
12521
+ }, parseHealthTransfer = (value) => {
12522
+ if (value === undefined)
12523
+ return;
12524
+ if (!object3(value))
12525
+ throw new MobileUpdateRegistryError("Mobile update health transfer is invalid");
12526
+ return {
12527
+ avoidedBytes: finiteMetric(value.avoidedBytes, "avoidedBytes"),
12528
+ downloadedBytes: finiteMetric(value.downloadedBytes, "downloadedBytes"),
12529
+ durationMs: finiteMetric(value.durationMs, "durationMs"),
12530
+ resumedBytes: finiteMetric(value.resumedBytes, "resumedBytes"),
12531
+ reusedBytes: finiteMetric(value.reusedBytes, "reusedBytes"),
12532
+ throughputBytesPerSecond: finiteMetric(value.throughputBytesPerSecond, "throughputBytesPerSecond")
12533
+ };
12451
12534
  }, createMobileUpdateRegistry = (options) => {
12452
12535
  const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
12453
12536
  const clock = options.clock ?? (() => new Date);
12537
+ const health = options.health;
12538
+ const rollout = options.rollout;
12539
+ if (health && health.secret.length < 32)
12540
+ throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
12541
+ if (health && !options.store.list)
12542
+ throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
12543
+ if (rollout && (!health || !options.store.list))
12544
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration requires fleet health and storage lifecycle listing");
12545
+ 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)))
12546
+ throw new MobileUpdateRegistryError("Mobile update rollout stages are invalid");
12547
+ const minimumReports = health?.autoPause?.minimumReports ?? 20;
12548
+ const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
12549
+ if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
12550
+ throw new MobileUpdateRegistryError("Mobile update health auto-pause policy is invalid");
12454
12551
  const root = (appId) => `${prefix}/${appHash(appId)}`;
12455
12552
  const releaseRoot = (manifest) => `${root(manifest.appId)}/releases/${manifest.releaseId}`;
12456
12553
  const manifestKey = (manifest) => `${releaseRoot(manifest)}/update.json`;
12457
12554
  const fileKey = (manifest, file2) => `${releaseRoot(manifest)}/files/${file2.path}`;
12458
12555
  const contentBlobKey = (appId, sha256) => `${root(appId)}/blobs/${sha256}`;
12459
12556
  const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
12557
+ const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
12558
+ const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
12559
+ const rolloutRoot = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/rollout`;
12560
+ const rolloutPlanKey = (appId, promotionId, releaseId) => `${rolloutRoot(appId, promotionId, releaseId)}/plan.json`;
12460
12561
  const channelKey = (appId, channel) => {
12461
12562
  if (!APP_ID.test(appId) || !NAME.test(channel))
12462
12563
  throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
@@ -12485,18 +12586,128 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12485
12586
  throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
12486
12587
  return value;
12487
12588
  };
12589
+ const listPrefix = async (value) => {
12590
+ const objects = [];
12591
+ const cursors = new Set;
12592
+ let cursor;
12593
+ do {
12594
+ const page = await options.store.list({
12595
+ ...cursor ? { cursor } : {},
12596
+ prefix: value
12597
+ });
12598
+ objects.push(...page.objects);
12599
+ if (!page.truncated)
12600
+ break;
12601
+ if (!page.cursor || cursors.has(page.cursor))
12602
+ throw new MobileUpdateRegistryError("Mobile update storage returned an invalid cursor");
12603
+ cursors.add(page.cursor);
12604
+ cursor = page.cursor;
12605
+ } while (true);
12606
+ return objects;
12607
+ };
12608
+ const readVerifiedObject = async (key, label) => {
12609
+ const bytes = await options.store.get(key);
12610
+ if (!bytes)
12611
+ return null;
12612
+ const head = await options.store.head(key);
12613
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
12614
+ throw new MobileUpdateRegistryError(`Stored mobile update ${label} integrity failed`);
12615
+ return decode(bytes);
12616
+ };
12617
+ const parseRolloutPlan = (value, promotionId, releaseId) => {
12618
+ if (!object3(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== releaseId || typeof value.automatic !== "boolean" || !iso(value.createdAt) || !Array.isArray(value.stages))
12619
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
12620
+ const stages = value.stages.map((stage) => {
12621
+ if (!object3(stage) || typeof stage.rollout !== "number" || typeof stage.maximumFailureRate !== "number" || !Number.isSafeInteger(stage.minimumReports) || !Number.isSafeInteger(stage.observationMs))
12622
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
12623
+ return {
12624
+ maximumFailureRate: stage.maximumFailureRate,
12625
+ minimumReports: stage.minimumReports,
12626
+ observationMs: stage.observationMs,
12627
+ rollout: stage.rollout
12628
+ };
12629
+ });
12630
+ 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))
12631
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
12632
+ return {
12633
+ automatic: value.automatic,
12634
+ createdAt: value.createdAt,
12635
+ format: 1,
12636
+ promotionId,
12637
+ releaseId,
12638
+ stages
12639
+ };
12640
+ };
12641
+ const initializeRollout = async (channel, signal) => {
12642
+ if (!rollout || !channel.releaseId)
12643
+ return;
12644
+ if (!rollout.stages.some((stage) => stage.rollout === channel.rollout))
12645
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
12646
+ const promotionId = healthPromotionId(channel);
12647
+ const plan = {
12648
+ automatic: rollout.automatic ?? false,
12649
+ createdAt: channel.promotedAt,
12650
+ format: 1,
12651
+ promotionId,
12652
+ releaseId: channel.releaseId,
12653
+ stages: rollout.stages.map((stage) => ({ ...stage }))
12654
+ };
12655
+ const bytes = json(plan);
12656
+ await options.store.put(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), bytes, {
12657
+ cacheControl: "no-store",
12658
+ contentType: "application/json",
12659
+ maxBytes: bytes.byteLength,
12660
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
12661
+ signal
12662
+ });
12663
+ };
12664
+ const signHealthToken = (payload) => {
12665
+ if (!health)
12666
+ return null;
12667
+ const encoded = base64Url(JSON.stringify(payload));
12668
+ const signature = createHmac("sha256", health.secret).update(encoded).digest("base64url");
12669
+ return `${encoded}.${signature}`;
12670
+ };
12671
+ const verifyHealthToken = (token) => {
12672
+ if (!health)
12673
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
12674
+ const [encoded, provided, extra] = token.split(".");
12675
+ if (!encoded || !provided || extra)
12676
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12677
+ const expected = createHmac("sha256", health.secret).update(encoded).digest();
12678
+ let actual;
12679
+ try {
12680
+ actual = Buffer.from(provided, "base64url");
12681
+ } catch {
12682
+ actual = Buffer.alloc(0);
12683
+ }
12684
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected))
12685
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12686
+ let value;
12687
+ try {
12688
+ value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
12689
+ } catch {
12690
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12691
+ }
12692
+ if (!object3(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")
12693
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12694
+ return value;
12695
+ };
12488
12696
  const assertNotMarked = async (appId, releaseId) => {
12489
12697
  if (!APP_ID.test(appId) || !RELEASE.test(releaseId))
12490
12698
  throw new MobileUpdateRegistryError("Mobile update release identity is invalid");
12491
12699
  if (await options.store.head(tombstoneKey(appId, releaseId)))
12492
12700
  throw new MobileUpdateRegistryError("Mobile update release is marked for collection. Increase retention and apply garbage collection to restore it before promotion");
12493
12701
  };
12494
- const writeChannel = async (input, signal) => {
12702
+ const writeChannel = async (input, signal, beforeWrite) => {
12703
+ const promotedAt = clock().toISOString();
12495
12704
  const value = {
12496
12705
  ...input,
12497
12706
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
12498
- promotedAt: clock().toISOString()
12707
+ promotedAt,
12708
+ promotionId: digest(new TextEncoder().encode(`${input.appId}\x00${input.channel}\x00${input.releaseId ?? "embedded"}\x00${promotedAt}\x00${randomUUID()}`))
12499
12709
  };
12710
+ await beforeWrite?.(value);
12500
12711
  const bytes = json(value);
12501
12712
  await options.store.put(channelKey(value.appId, value.channel), bytes, {
12502
12713
  cacheControl: "no-cache",
@@ -12511,10 +12722,92 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12511
12722
  });
12512
12723
  return value;
12513
12724
  };
12725
+ const rolloutContext = async (channel) => {
12726
+ if (!options.store.list || !channel.releaseId)
12727
+ return null;
12728
+ const promotionId = healthPromotionId(channel);
12729
+ const storedPlan = await readVerifiedObject(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), "rollout plan");
12730
+ if (storedPlan === null)
12731
+ return null;
12732
+ const plan = parseRolloutPlan(storedPlan, promotionId, channel.releaseId);
12733
+ const initialStage = plan.stages.findIndex((stage) => stage.rollout === channel.rollout);
12734
+ if (initialStage < 0)
12735
+ throw new MobileUpdateRegistryError("Stored mobile update rollout does not match its plan");
12736
+ let currentStage = initialStage;
12737
+ let enteredAt = plan.createdAt;
12738
+ const advances = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/advances/`);
12739
+ for (const item of advances) {
12740
+ const value = await readVerifiedObject(item.key, "rollout advancement");
12741
+ if (!object3(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))
12742
+ throw new MobileUpdateRegistryError("Stored mobile update rollout advancement is invalid");
12743
+ if (Number(value.stage) >= currentStage) {
12744
+ currentStage = Number(value.stage);
12745
+ enteredAt = value.createdAt;
12746
+ }
12747
+ }
12748
+ const controls = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/controls/`);
12749
+ const parsedControls = [];
12750
+ for (const item of controls) {
12751
+ const value = await readVerifiedObject(item.key, "rollout control");
12752
+ if (!object3(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")))
12753
+ throw new MobileUpdateRegistryError("Stored mobile update rollout control is invalid");
12754
+ parsedControls.push(value);
12755
+ }
12756
+ const cancelled = parsedControls.some(({ action }) => action === "cancel");
12757
+ const resumedPauseIds = new Set(parsedControls.flatMap((control) => control.resumedPauseIds ?? []));
12758
+ const activePauseIds = parsedControls.filter(({ action, id }) => action === "pause" && !resumedPauseIds.has(id)).map(({ id }) => id);
12759
+ const operatorPaused = activePauseIds.length > 0;
12760
+ const fleetPaused = Boolean(await options.store.head(pauseKey(channel.appId, promotionId, channel.releaseId)));
12761
+ return {
12762
+ cancelled,
12763
+ activePauseIds,
12764
+ channel,
12765
+ currentStage,
12766
+ enteredAt,
12767
+ fleetPaused,
12768
+ operatorPaused,
12769
+ plan,
12770
+ promotionId,
12771
+ rollout: plan.stages[currentStage].rollout
12772
+ };
12773
+ };
12774
+ const writeRolloutControl = async (channel, action, signal) => {
12775
+ const context = await rolloutContext(channel);
12776
+ if (!context)
12777
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
12778
+ if (context.cancelled)
12779
+ throw new MobileUpdateRegistryError("Mobile update rollout was already cancelled");
12780
+ if (action === "resume" && context.fleetPaused)
12781
+ throw new MobileUpdateRegistryError("A fleet-health pause requires an explicit re-promotion");
12782
+ const releaseId = channel.releaseId;
12783
+ if (!releaseId)
12784
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
12785
+ const createdAt = clock().toISOString();
12786
+ const id = randomUUID();
12787
+ const event = {
12788
+ action,
12789
+ createdAt,
12790
+ format: 1,
12791
+ id,
12792
+ promotionId: context.promotionId,
12793
+ releaseId,
12794
+ ...action === "resume" ? { resumedPauseIds: context.activePauseIds } : {}
12795
+ };
12796
+ const bytes = json(event);
12797
+ await options.store.put(`${rolloutRoot(channel.appId, context.promotionId, releaseId)}/controls/${createdAt}-${id}-${action}.json`, bytes, {
12798
+ cacheControl: "no-store",
12799
+ contentType: "application/json",
12800
+ maxBytes: bytes.byteLength,
12801
+ metadata: { action, sha256: digest(bytes) },
12802
+ signal
12803
+ });
12804
+ };
12514
12805
  const promoteUpdate = async (input) => {
12515
12806
  input.signal?.throwIfAborted();
12516
12807
  if (input.rollout <= 0 || input.rollout > 1)
12517
12808
  throw new MobileUpdateRegistryError("Mobile update rollout is invalid");
12809
+ if (rollout && !rollout.stages.some((stage) => stage.rollout === input.rollout))
12810
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
12518
12811
  await assertNotMarked(input.appId, input.releaseId);
12519
12812
  const release = await readManifest(input.appId, input.releaseId);
12520
12813
  if (!release || release.manifest.channel !== input.channel)
@@ -12526,7 +12819,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12526
12819
  ...existing?.releaseId && existing.releaseId !== input.releaseId ? { fallbackReleaseId: existing.releaseId } : existing?.fallbackReleaseId ? { fallbackReleaseId: existing.fallbackReleaseId } : {},
12527
12820
  releaseId: input.releaseId,
12528
12821
  rollout: input.rollout
12529
- }, input.signal);
12822
+ }, input.signal, (channel) => initializeRollout(channel, input.signal));
12530
12823
  return {
12531
12824
  appId: input.appId,
12532
12825
  channel: input.channel,
@@ -12539,13 +12832,16 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12539
12832
  const channel = await readChannel(input.appId, input.channel);
12540
12833
  if (!channel?.releaseId)
12541
12834
  return { status: "empty" };
12542
- const selected = rolloutMember({
12835
+ const rolloutState = await rolloutContext(channel);
12836
+ let selected = rolloutMember({
12543
12837
  appId: input.appId,
12544
12838
  channel: input.channel,
12545
12839
  installationId: input.installationId,
12546
12840
  releaseId: channel.releaseId,
12547
- rollout: channel.rollout
12841
+ rollout: rolloutState?.rollout ?? channel.rollout
12548
12842
  }) ? channel.releaseId : channel.fallbackReleaseId;
12843
+ if (selected === channel.releaseId && (rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || health && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId))))
12844
+ selected = channel.fallbackReleaseId;
12549
12845
  if (!selected)
12550
12846
  return { status: "empty" };
12551
12847
  const release = await readManifest(input.appId, selected);
@@ -12561,6 +12857,243 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12561
12857
  status: "selected"
12562
12858
  };
12563
12859
  };
12860
+ const issueUpdateHealthToken = async (input) => {
12861
+ if (!health)
12862
+ return null;
12863
+ const channel = await readChannel(input.appId, input.channel);
12864
+ if (!channel?.releaseId || channel.releaseId !== input.releaseId)
12865
+ return null;
12866
+ const resolution = await resolveUpdateState(input);
12867
+ if (resolution.status !== "selected" || resolution.manifest.releaseId !== input.releaseId)
12868
+ return null;
12869
+ return signHealthToken({
12870
+ appId: input.appId,
12871
+ channel: input.channel,
12872
+ format: HEALTH_TOKEN_VERSION,
12873
+ installationId: input.installationId,
12874
+ promotionId: healthPromotionId(channel),
12875
+ releaseId: input.releaseId,
12876
+ runtimeFingerprint: input.runtimeFingerprint
12877
+ });
12878
+ };
12879
+ const inspectUpdateHealth = async (input) => {
12880
+ if (!health)
12881
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
12882
+ const channel = await readChannel(input.appId, input.channel);
12883
+ const releaseId = input.releaseId ?? channel?.releaseId;
12884
+ if (!channel || !releaseId || channel.releaseId !== releaseId)
12885
+ return null;
12886
+ const promotionId = healthPromotionId(channel);
12887
+ const objects = await listPrefix(`${healthRoot(input.appId, promotionId, releaseId)}/events/`);
12888
+ const installations = new Set;
12889
+ const byKind = new Map([...HEALTH_KINDS].map((kind) => [
12890
+ kind,
12891
+ new Set
12892
+ ]));
12893
+ const transfer = {
12894
+ avoidedBytes: 0,
12895
+ downloadedBytes: 0,
12896
+ durationMs: 0,
12897
+ resumedBytes: 0,
12898
+ reusedBytes: 0,
12899
+ throughputBytesPerSecond: 0
12900
+ };
12901
+ for (const item of objects) {
12902
+ const bytes = await options.store.get(item.key);
12903
+ if (!bytes)
12904
+ continue;
12905
+ const head = await options.store.head(item.key);
12906
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
12907
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence integrity failed");
12908
+ const value = decode(bytes);
12909
+ if (!object3(value) || typeof value.installationHash !== "string" || !HEALTH_KINDS.has(String(value.kind)))
12910
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence is invalid");
12911
+ const kind = value.kind;
12912
+ installations.add(value.installationHash);
12913
+ byKind.get(kind).add(value.installationHash);
12914
+ if (kind === "downloaded" && object3(value.transfer)) {
12915
+ const parsed = parseHealthTransfer(value.transfer);
12916
+ for (const key of Object.keys(transfer))
12917
+ transfer[key] += parsed[key];
12918
+ }
12919
+ }
12920
+ const failures = new Set([
12921
+ ...byKind.get("quarantined"),
12922
+ ...byKind.get("rolled-back")
12923
+ ]);
12924
+ const terminals = new Set([...byKind.get("activated"), ...failures]);
12925
+ const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
12926
+ const rolloutState = await rolloutContext(channel);
12927
+ return {
12928
+ activated: byKind.get("activated").size,
12929
+ appId: input.appId,
12930
+ channel: input.channel,
12931
+ downloaded: byKind.get("downloaded").size,
12932
+ downloadFailed: byKind.get("download-failed").size,
12933
+ failureRate,
12934
+ failures: failures.size,
12935
+ paused: Boolean(rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
12936
+ promotionId,
12937
+ quarantined: byKind.get("quarantined").size,
12938
+ releaseId,
12939
+ reportedInstallations: installations.size,
12940
+ rolledBack: byKind.get("rolled-back").size,
12941
+ rollout: rolloutState?.rollout ?? channel.rollout,
12942
+ terminalReports: terminals.size,
12943
+ transfer
12944
+ };
12945
+ };
12946
+ const inspectUpdateRollout = async (input) => {
12947
+ const channel = await readChannel(input.appId, input.channel);
12948
+ if (!channel?.releaseId)
12949
+ return null;
12950
+ const context = await rolloutContext(channel);
12951
+ if (!context)
12952
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
12953
+ const healthReport = await inspectUpdateHealth(input);
12954
+ if (!healthReport)
12955
+ return null;
12956
+ const paused = context.fleetPaused || context.operatorPaused;
12957
+ const complete = context.currentStage === context.plan.stages.length - 1;
12958
+ return {
12959
+ ...healthReport,
12960
+ automatic: context.plan.automatic,
12961
+ currentStage: context.currentStage,
12962
+ enteredAt: context.enteredAt,
12963
+ ...!complete ? { nextStage: context.plan.stages[context.currentStage + 1] } : {},
12964
+ ...context.fleetPaused ? { pausedBy: "fleet-health" } : context.operatorPaused ? { pausedBy: "operator" } : {},
12965
+ status: context.cancelled ? "cancelled" : paused ? "paused" : complete ? "complete" : "active"
12966
+ };
12967
+ };
12968
+ const advanceRollout = async (input, strict) => {
12969
+ input.signal?.throwIfAborted();
12970
+ const channel = await readChannel(input.appId, input.channel);
12971
+ if (!channel?.releaseId)
12972
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
12973
+ const context = await rolloutContext(channel);
12974
+ if (!context)
12975
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
12976
+ const report = await inspectUpdateRollout(input);
12977
+ if (!report)
12978
+ throw new MobileUpdateRegistryError("Mobile update rollout report is unavailable");
12979
+ const nextStage = context.plan.stages[context.currentStage + 1];
12980
+ if (!nextStage)
12981
+ return report;
12982
+ if (input.rollout !== undefined && input.rollout !== nextStage.rollout)
12983
+ throw new MobileUpdateRegistryError("Mobile update rollout can advance only to the next configured stage");
12984
+ if (report.status !== "active") {
12985
+ if (strict)
12986
+ throw new MobileUpdateRegistryError(`Mobile update rollout cannot advance while ${report.status}`);
12987
+ return report;
12988
+ }
12989
+ const gate = context.plan.stages[context.currentStage];
12990
+ const observedMs = clock().getTime() - Date.parse(context.enteredAt);
12991
+ const blocked = report.terminalReports < gate.minimumReports || report.failureRate > gate.maximumFailureRate || observedMs < gate.observationMs;
12992
+ if (blocked) {
12993
+ if (strict)
12994
+ 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`);
12995
+ return report;
12996
+ }
12997
+ const stage = context.currentStage + 1;
12998
+ const event = {
12999
+ createdAt: clock().toISOString(),
13000
+ failureRate: report.failureRate,
13001
+ format: 1,
13002
+ promotionId: context.promotionId,
13003
+ releaseId: channel.releaseId,
13004
+ rollout: nextStage.rollout,
13005
+ stage,
13006
+ terminalReports: report.terminalReports
13007
+ };
13008
+ const bytes = json(event);
13009
+ await options.store.put(`${rolloutRoot(input.appId, context.promotionId, channel.releaseId)}/advances/${String(stage).padStart(4, "0")}.json`, bytes, {
13010
+ cacheControl: "no-store",
13011
+ contentType: "application/json",
13012
+ maxBytes: bytes.byteLength,
13013
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
13014
+ signal: input.signal
13015
+ });
13016
+ return await inspectUpdateRollout(input);
13017
+ };
13018
+ const advanceUpdateRollout = (input) => advanceRollout(input, true);
13019
+ const reconcileUpdateRollout = async (input) => {
13020
+ const report = await inspectUpdateRollout(input);
13021
+ if (!report || !report.automatic)
13022
+ return report;
13023
+ return advanceRollout(input, false);
13024
+ };
13025
+ const rolloutControl = (action) => async (input) => {
13026
+ input.signal?.throwIfAborted();
13027
+ const channel = await readChannel(input.appId, input.channel);
13028
+ if (!channel?.releaseId)
13029
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
13030
+ await writeRolloutControl(channel, action, input.signal);
13031
+ return await inspectUpdateRollout(input);
13032
+ };
13033
+ const pauseUpdateRollout = rolloutControl("pause");
13034
+ const resumeUpdateRollout = rolloutControl("resume");
13035
+ const cancelUpdateRollout = rolloutControl("cancel");
13036
+ const recordUpdateHealth = async (input) => {
13037
+ const payload = verifyHealthToken(input.token);
13038
+ 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")
13039
+ throw new MobileUpdateRegistryError("Mobile update health evidence does not match its token");
13040
+ const release = await readManifest(input.appId, input.releaseId);
13041
+ if (!release || release.manifest.runtimeFingerprint !== input.runtimeFingerprint)
13042
+ throw new MobileUpdateRegistryError("Mobile update health release is invalid");
13043
+ const activeChannel = await readChannel(input.appId, input.channel);
13044
+ if (!activeChannel || activeChannel.releaseId !== input.releaseId || healthPromotionId(activeChannel) !== payload.promotionId)
13045
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
13046
+ const transfer = parseHealthTransfer(input.transfer);
13047
+ const installationHash = createHmac("sha256", health.secret).update(input.installationId).digest("hex");
13048
+ const evidence = {
13049
+ format: 1,
13050
+ installationHash,
13051
+ kind: input.kind,
13052
+ observedAt: clock().toISOString(),
13053
+ ...input.reason ? { reason: input.reason } : {},
13054
+ ...transfer ? { transfer } : {}
13055
+ };
13056
+ const bytes = json(evidence);
13057
+ await options.store.put(`${healthRoot(input.appId, payload.promotionId, input.releaseId)}/events/${installationHash}/${input.kind}.json`, bytes, {
13058
+ cacheControl: "no-store",
13059
+ contentType: "application/json",
13060
+ maxBytes: bytes.byteLength,
13061
+ metadata: { kind: input.kind, sha256: digest(bytes) }
13062
+ });
13063
+ let report = await inspectUpdateHealth({
13064
+ appId: input.appId,
13065
+ channel: input.channel,
13066
+ releaseId: input.releaseId
13067
+ });
13068
+ if (!report)
13069
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
13070
+ if (FAILURE_HEALTH_KINDS.has(input.kind) && report.terminalReports >= minimumReports && report.failureRate >= failureThreshold && !report.paused) {
13071
+ const marker = json({
13072
+ appId: input.appId,
13073
+ channel: input.channel,
13074
+ failureRate: report.failureRate,
13075
+ failures: report.failures,
13076
+ format: 1,
13077
+ pausedAt: clock().toISOString(),
13078
+ promotionId: payload.promotionId,
13079
+ releaseId: input.releaseId,
13080
+ reports: report.terminalReports
13081
+ });
13082
+ await options.store.put(pauseKey(input.appId, payload.promotionId, input.releaseId), marker, {
13083
+ cacheControl: "no-store",
13084
+ contentType: "application/json",
13085
+ maxBytes: marker.byteLength,
13086
+ metadata: { releaseid: input.releaseId, sha256: digest(marker) }
13087
+ });
13088
+ report = { ...report, paused: true };
13089
+ }
13090
+ if (rollout && (input.kind === "activated" || FAILURE_HEALTH_KINDS.has(input.kind)))
13091
+ return await reconcileUpdateRollout({
13092
+ appId: input.appId,
13093
+ channel: input.channel
13094
+ }) ?? report;
13095
+ return report;
13096
+ };
12564
13097
  const retentionValues = (input) => {
12565
13098
  if (!APP_ID.test(input.appId))
12566
13099
  throw new MobileUpdateRegistryError("Mobile update appId is invalid");
@@ -12803,6 +13336,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12803
13336
  return result;
12804
13337
  };
12805
13338
  return {
13339
+ ...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
13340
+ ...rollout ? {
13341
+ advanceUpdateRollout,
13342
+ cancelUpdateRollout,
13343
+ inspectUpdateRollout,
13344
+ pauseUpdateRollout,
13345
+ reconcileUpdateRollout,
13346
+ resumeUpdateRollout
13347
+ } : {},
12806
13348
  inspectUpdateStorage: async (input) => (await inventory(input)).report,
12807
13349
  pruneUpdates,
12808
13350
  publishUpdate: async (input) => {
@@ -13080,7 +13622,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13080
13622
  const origin = request.headers.get("origin");
13081
13623
  const cors = origin && allowedOrigins.has(origin) ? {
13082
13624
  "access-control-allow-origin": origin,
13083
- "access-control-expose-headers": "content-range,etag",
13625
+ "access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
13084
13626
  vary: "Origin"
13085
13627
  } : {};
13086
13628
  if (request.method === "OPTIONS") {
@@ -13089,17 +13631,58 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13089
13631
  return new Response(null, {
13090
13632
  headers: {
13091
13633
  ...cors,
13092
- "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",
13093
- "access-control-allow-methods": "GET,OPTIONS",
13634
+ "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",
13635
+ "access-control-allow-methods": "GET,POST,OPTIONS",
13094
13636
  "access-control-max-age": "600"
13095
13637
  },
13096
13638
  status: 204
13097
13639
  });
13098
13640
  }
13099
- if (request.method !== "GET")
13100
- return new Response(null, { status: 405 });
13101
13641
  const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
13102
13642
  const relative7 = pathname.startsWith(`${route}/`) ? pathname.slice(route.length + 1) : "";
13643
+ if (request.method === "POST" && relative7 === "health") {
13644
+ if (!options.registry.recordUpdateHealth)
13645
+ return new Response(null, { status: 404 });
13646
+ const appId = request.headers.get("x-absolute-mobile-app");
13647
+ const channel = request.headers.get("x-absolute-mobile-channel");
13648
+ const installationId = request.headers.get("x-absolute-mobile-installation");
13649
+ const runtimeFingerprint = request.headers.get("x-absolute-mobile-runtime");
13650
+ const token = request.headers.get("x-absolute-mobile-health-token");
13651
+ const declared = Number(request.headers.get("content-length"));
13652
+ if (appId !== options.appId || channel !== options.channel || !installationId || !runtimeFingerprint || !token || Number.isFinite(declared) && declared > 4096)
13653
+ return new Response(null, { status: 400 });
13654
+ const bodyBytes = new Uint8Array(await request.arrayBuffer());
13655
+ if (bodyBytes.byteLength > 4096)
13656
+ return new Response(null, { status: 413 });
13657
+ let body;
13658
+ try {
13659
+ body = JSON.parse(new TextDecoder().decode(bodyBytes));
13660
+ } catch {
13661
+ return new Response(null, { status: 400 });
13662
+ }
13663
+ if (!object3(body) || typeof body.releaseId !== "string" || typeof body.kind !== "string")
13664
+ return new Response(null, { status: 400 });
13665
+ try {
13666
+ const report = await options.registry.recordUpdateHealth({
13667
+ appId,
13668
+ channel,
13669
+ installationId,
13670
+ kind: body.kind,
13671
+ ...body.reason === "boot-interrupted" || body.reason === "boot-timeout" ? { reason: body.reason } : {},
13672
+ releaseId: body.releaseId,
13673
+ runtimeFingerprint,
13674
+ token,
13675
+ ...object3(body.transfer) ? { transfer: body.transfer } : {}
13676
+ });
13677
+ return Response.json({ paused: report.paused }, { headers: { ...cors, "cache-control": "no-store" }, status: 202 });
13678
+ } catch (error) {
13679
+ if (error instanceof MobileUpdateRegistryError)
13680
+ return new Response(null, { status: 403 });
13681
+ throw error;
13682
+ }
13683
+ }
13684
+ if (request.method !== "GET")
13685
+ return new Response(null, { status: 405 });
13103
13686
  if (relative7 === "update.json") {
13104
13687
  const expoProtocolVersion = request.headers.get("expo-protocol-version");
13105
13688
  const expoProtocol = expoProtocolVersion !== null;
@@ -13125,6 +13708,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13125
13708
  installationId,
13126
13709
  runtimeFingerprint
13127
13710
  });
13711
+ const healthToken = selected && options.registry.issueUpdateHealthToken ? await options.registry.issueUpdateHealthToken({
13712
+ appId,
13713
+ channel,
13714
+ installationId,
13715
+ releaseId: selected.manifest.releaseId,
13716
+ runtimeFingerprint
13717
+ }) : null;
13128
13718
  if (expoProtocol) {
13129
13719
  let requestedCodeSigning;
13130
13720
  try {
@@ -13176,6 +13766,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13176
13766
  extra: {
13177
13767
  absolutejs: {
13178
13768
  channel: selected.manifest.channel,
13769
+ ...healthToken ? { healthToken } : {},
13179
13770
  releaseId: selected.manifest.releaseId
13180
13771
  },
13181
13772
  expoClient: descriptor.expoConfig
@@ -13203,7 +13794,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13203
13794
  headers: {
13204
13795
  ...cors,
13205
13796
  "cache-control": "no-store",
13206
- etag: `"${selected.manifest.releaseId}"`
13797
+ etag: `"${selected.manifest.releaseId}"`,
13798
+ ...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
13207
13799
  }
13208
13800
  });
13209
13801
  }
@@ -13266,6 +13858,14 @@ var init_mobileUpdate = __esm(() => {
13266
13858
  RELEASE = /^amu_[a-f0-9]{64}$/;
13267
13859
  APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
13268
13860
  NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
13861
+ HEALTH_KINDS = new Set([
13862
+ "activated",
13863
+ "downloaded",
13864
+ "download-failed",
13865
+ "quarantined",
13866
+ "rolled-back"
13867
+ ]);
13868
+ FAILURE_HEALTH_KINDS = new Set(["quarantined", "rolled-back"]);
13269
13869
  MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
13270
13870
  };
13271
13871
  expoProtocolHeaders = {
@@ -13353,6 +13953,16 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13353
13953
  } catch (error) {
13354
13954
  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 });
13355
13955
  }
13956
+ }, verifyHealthModule = (config, module) => {
13957
+ if (!config.updateServer?.health)
13958
+ return;
13959
+ if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
13960
+ throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
13961
+ }, verifyRolloutModule = (config, module) => {
13962
+ if (!config.updateServer?.rollout)
13963
+ return;
13964
+ 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")
13965
+ throw new TypeError("Mobile update rollout orchestration is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
13356
13966
  }, expoSigningOptions = (config) => {
13357
13967
  if (!config.updates?.expoCodeSigning)
13358
13968
  return;
@@ -13384,8 +13994,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13384
13994
  if (!updates || !server?.autoMount)
13385
13995
  return new Elysia4({ name: "absolutejs-mobile-updates-disabled" });
13386
13996
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
13387
- if (options.production)
13997
+ if (options.production) {
13388
13998
  await verifyDurableModule(module);
13999
+ verifyHealthModule(config, module);
14000
+ verifyRolloutModule(config, module);
14001
+ }
13389
14002
  const manifest = new URL(updates.manifestUrl);
13390
14003
  if (!manifest.pathname.endsWith("/update.json"))
13391
14004
  throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
@@ -13404,10 +14017,12 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13404
14017
  return;
13405
14018
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
13406
14019
  await verifyDurableModule(module);
14020
+ verifyHealthModule(config, module);
14021
+ verifyRolloutModule(config, module);
13407
14022
  if (config.engine === "expo")
13408
14023
  expoSigningOptions(config);
13409
14024
  return module.metadata;
13410
- }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistry = (options) => {
14025
+ }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
13411
14026
  const metadata2 = `export const absoluteMobileUpdateServer = {
13412
14027
  format: 1,
13413
14028
  provider: '${options.storage}',
@@ -13473,6 +14088,27 @@ export default createMobileUpdateRegistry({
13473
14088
  store
13474
14089
  });
13475
14090
  `;
14091
+ }, renderAbsoluteMobileUpdateRegistry = (options) => {
14092
+ const source = renderAbsoluteMobileUpdateRegistryBase(options);
14093
+ let generated = "";
14094
+ if (options.health) {
14095
+ const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
14096
+ generated += ` health: {
14097
+ autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
14098
+ secret: ${secret}
14099
+ },
14100
+ `;
14101
+ }
14102
+ if (options.rollout)
14103
+ generated += ` rollout: ${JSON.stringify(options.rollout, null, "\t").replaceAll(`
14104
+ `, `
14105
+ `)},
14106
+ `;
14107
+ if (!generated)
14108
+ return source;
14109
+ return source.replace(`export default createMobileUpdateRegistry({
14110
+ `, `export default createMobileUpdateRegistry({
14111
+ ${generated}`);
13476
14112
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
13477
14113
  const path2 = projectPath(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
13478
14114
  if (!options.force) {
@@ -13484,6 +14120,8 @@ export default createMobileUpdateRegistry({
13484
14120
  }
13485
14121
  await mkdir6(dirname12(path2), { recursive: true });
13486
14122
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
14123
+ ...options.health ? { health: options.health } : {},
14124
+ ...options.rollout ? { rollout: options.rollout } : {},
13487
14125
  publicKeys: options.publicKeys,
13488
14126
  storage: options.storage
13489
14127
  }));
@@ -44896,5 +45534,5 @@ export {
44896
45534
  wrapPageHandlerWithStreamingSlots
44897
45535
  };
44898
45536
 
44899
- //# debugId=99E9CBD68908A78B64756E2164756E21
45537
+ //# debugId=95E12D596BC2404464756E2164756E21
44900
45538
  //# sourceMappingURL=index.js.map