@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/build.js CHANGED
@@ -27625,7 +27625,7 @@ __export(exports_config, {
27625
27625
  import { readFileSync as readFileSync35 } from "fs";
27626
27626
  import { resolve as resolve44 } from "path";
27627
27627
  import { createHash as createHash5, createPublicKey, X509Certificate } from "crypto";
27628
- 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) => {
27628
+ 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) => {
27629
27629
  const root = resolve44(projectRoot);
27630
27630
  const path = resolve44(root, value);
27631
27631
  if (path !== root && !path.startsWith(`${root}/`)) {
@@ -27819,6 +27819,56 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
27819
27819
  if (!ENVIRONMENT_NAME_PATTERN.test(expoPrivateKeyEnv))
27820
27820
  throw new TypeError("mobile.updates.server.expoPrivateKeyEnv must be a valid environment variable name.");
27821
27821
  const autoMount = config.updates.server?.autoMount ?? true;
27822
+ const configuredHealth = config.updates.server?.health;
27823
+ let health;
27824
+ if (configuredHealth !== false) {
27825
+ const failureRate = configuredHealth?.failureRate ?? DEFAULT_UPDATE_HEALTH_FAILURE_RATE;
27826
+ const minimumReports = configuredHealth?.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
27827
+ const secretEnv = requireText(configuredHealth?.secretEnv ?? "ABSOLUTE_MOBILE_UPDATE_HEALTH_SECRET", "mobile.updates.server.health.secretEnv");
27828
+ if (!Number.isFinite(failureRate) || failureRate <= 0 || failureRate > 1)
27829
+ throw new TypeError("mobile.updates.server.health.failureRate must be greater than 0 and at most 1.");
27830
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < 1)
27831
+ throw new TypeError("mobile.updates.server.health.minimumReports must be a positive integer.");
27832
+ if (!ENVIRONMENT_NAME_PATTERN.test(secretEnv))
27833
+ throw new TypeError("mobile.updates.server.health.secretEnv must be a valid environment variable name.");
27834
+ health = { failureRate, minimumReports, secretEnv };
27835
+ }
27836
+ const configuredRollout = config.updates.server?.rollout;
27837
+ let rollout;
27838
+ if (configuredRollout !== undefined && configuredRollout !== false) {
27839
+ if (!health)
27840
+ throw new TypeError("mobile.updates.server.rollout requires fleet health to be enabled.");
27841
+ if (configuredRollout.automatic !== undefined && typeof configuredRollout.automatic !== "boolean")
27842
+ throw new TypeError("mobile.updates.server.rollout.automatic must be boolean.");
27843
+ const configuredStages = configuredRollout.stages ?? DEFAULT_UPDATE_ROLLOUT_STAGES;
27844
+ const stages = configuredStages.map((stage, index) => {
27845
+ const previousStage = configuredStages[index - 1];
27846
+ const maximumFailureRate = stage.maximumFailureRate ?? DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE;
27847
+ const minimumReports = stage.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
27848
+ const observationMinutes = stage.observationMinutes ?? DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES;
27849
+ const observationMs = observationMinutes * MINUTE_MS;
27850
+ if (!Number.isFinite(stage.rollout) || stage.rollout <= 0 || stage.rollout > 1 || previousStage !== undefined && stage.rollout <= previousStage.rollout)
27851
+ throw new TypeError("mobile.updates.server.rollout stages must be strictly increasing fractions greater than 0 and at most 1.");
27852
+ if (!Number.isFinite(maximumFailureRate) || maximumFailureRate < 0 || maximumFailureRate >= health.failureRate)
27853
+ throw new TypeError("mobile.updates.server.rollout maximumFailureRate must be non-negative and lower than the fleet-health pause rate.");
27854
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < health.minimumReports)
27855
+ throw new TypeError("mobile.updates.server.rollout minimumReports must be an integer at least as large as the fleet-health minimumReports.");
27856
+ if (!Number.isFinite(observationMinutes) || observationMinutes < 0 || !Number.isSafeInteger(observationMs))
27857
+ throw new TypeError("mobile.updates.server.rollout observationMinutes must produce a non-negative whole number of milliseconds.");
27858
+ return {
27859
+ maximumFailureRate,
27860
+ minimumReports,
27861
+ observationMs,
27862
+ rollout: stage.rollout
27863
+ };
27864
+ });
27865
+ if (stages.length === 0 || stages.at(-1)?.rollout !== 1)
27866
+ throw new TypeError("mobile.updates.server.rollout stages must end at rollout 1.");
27867
+ rollout = {
27868
+ automatic: configuredRollout.automatic ?? false,
27869
+ stages
27870
+ };
27871
+ }
27822
27872
  if (autoMount) {
27823
27873
  const manifest = new URL(updates.manifestUrl);
27824
27874
  if (manifest.origin !== productionOrigin)
@@ -27851,7 +27901,13 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
27851
27901
  throw new TypeError(`mobile.updates.server.expoCodeSigningKeys.${keyId} certificate is not currently valid.`);
27852
27902
  expoCodeSigningKeys[keyId] = { certificatePem, privateKeyEnv };
27853
27903
  }
27854
- return { autoMount, expoCodeSigningKeys, registryModule };
27904
+ return {
27905
+ autoMount,
27906
+ expoCodeSigningKeys,
27907
+ ...health ? { health } : {},
27908
+ ...rollout ? { rollout } : {},
27909
+ registryModule
27910
+ };
27855
27911
  }, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
27856
27912
  if (segment === "*" && (index !== count - 1 || count === 1)) {
27857
27913
  throw new TypeError(`mobile.routes.native route ${path} must use * once, as the final segment after a static or parameterized prefix.`);
@@ -27943,6 +27999,12 @@ var init_config = __esm(() => {
27943
27999
  UPDATE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
27944
28000
  UPDATE_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u;
27945
28001
  ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
28002
+ MINUTE_MS = 60 * 1000;
28003
+ DEFAULT_UPDATE_ROLLOUT_STAGES = [
28004
+ { minimumReports: 20, observationMinutes: 60, rollout: 0.05 },
28005
+ { minimumReports: 100, observationMinutes: 360, rollout: 0.25 },
28006
+ { minimumReports: 100, observationMinutes: 0, rollout: 1 }
28007
+ ];
27946
28008
  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])?))*$/;
27947
28009
  EXPO_RESERVED_ROUTE_PREFIXES = new Set([
27948
28010
  "_expo",
@@ -28472,15 +28534,18 @@ __export(exports_mobileUpdate, {
28472
28534
  });
28473
28535
  import {
28474
28536
  createHash as createHash6,
28537
+ createHmac,
28475
28538
  createPrivateKey,
28476
28539
  createPublicKey as createPublicKey2,
28540
+ randomUUID,
28477
28541
  sign,
28542
+ timingSafeEqual,
28478
28543
  verify,
28479
28544
  X509Certificate as X509Certificate2
28480
28545
  } from "crypto";
28481
28546
  import { readFile as readFile9, stat as stat3 } from "fs/promises";
28482
28547
  import path from "path";
28483
- 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) => {
28548
+ 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) => {
28484
28549
  if (typeof value !== "string" || value.length === 0)
28485
28550
  throw new MobileUpdateRegistryError(`Mobile update ${field} is invalid`);
28486
28551
  return value;
@@ -28609,7 +28674,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28609
28674
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
28610
28675
  const appId = text2(value.appId, "channel appId");
28611
28676
  const channel = text2(value.channel, "channel");
28612
- 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))
28677
+ 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))
28613
28678
  throw new MobileUpdateRegistryError("Mobile update channel is invalid");
28614
28679
  return {
28615
28680
  ...value.activationId ? { activationId: value.activationId } : {},
@@ -28618,6 +28683,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28618
28683
  channel,
28619
28684
  ...value.fallbackReleaseId ? { fallbackReleaseId: value.fallbackReleaseId } : {},
28620
28685
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
28686
+ ...value.promotionId ? { promotionId: value.promotionId } : {},
28621
28687
  promotedAt: value.promotedAt,
28622
28688
  ...value.releaseId ? { releaseId: value.releaseId } : {},
28623
28689
  rollout: value.rollout
@@ -28640,15 +28706,50 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28640
28706
  return true;
28641
28707
  const value = createHash6("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
28642
28708
  return value / 4294967296 < input.rollout;
28709
+ }, 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) => {
28710
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
28711
+ throw new MobileUpdateRegistryError(`Mobile update health ${field} is invalid`);
28712
+ return value;
28713
+ }, parseHealthTransfer = (value) => {
28714
+ if (value === undefined)
28715
+ return;
28716
+ if (!object3(value))
28717
+ throw new MobileUpdateRegistryError("Mobile update health transfer is invalid");
28718
+ return {
28719
+ avoidedBytes: finiteMetric(value.avoidedBytes, "avoidedBytes"),
28720
+ downloadedBytes: finiteMetric(value.downloadedBytes, "downloadedBytes"),
28721
+ durationMs: finiteMetric(value.durationMs, "durationMs"),
28722
+ resumedBytes: finiteMetric(value.resumedBytes, "resumedBytes"),
28723
+ reusedBytes: finiteMetric(value.reusedBytes, "reusedBytes"),
28724
+ throughputBytesPerSecond: finiteMetric(value.throughputBytesPerSecond, "throughputBytesPerSecond")
28725
+ };
28643
28726
  }, createMobileUpdateRegistry = (options) => {
28644
28727
  const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
28645
28728
  const clock = options.clock ?? (() => new Date);
28729
+ const health = options.health;
28730
+ const rollout = options.rollout;
28731
+ if (health && health.secret.length < 32)
28732
+ throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
28733
+ if (health && !options.store.list)
28734
+ throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
28735
+ if (rollout && (!health || !options.store.list))
28736
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration requires fleet health and storage lifecycle listing");
28737
+ 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)))
28738
+ throw new MobileUpdateRegistryError("Mobile update rollout stages are invalid");
28739
+ const minimumReports = health?.autoPause?.minimumReports ?? 20;
28740
+ const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
28741
+ if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
28742
+ throw new MobileUpdateRegistryError("Mobile update health auto-pause policy is invalid");
28646
28743
  const root = (appId) => `${prefix}/${appHash(appId)}`;
28647
28744
  const releaseRoot = (manifest) => `${root(manifest.appId)}/releases/${manifest.releaseId}`;
28648
28745
  const manifestKey = (manifest) => `${releaseRoot(manifest)}/update.json`;
28649
28746
  const fileKey = (manifest, file5) => `${releaseRoot(manifest)}/files/${file5.path}`;
28650
28747
  const contentBlobKey = (appId, sha256) => `${root(appId)}/blobs/${sha256}`;
28651
28748
  const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
28749
+ const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
28750
+ const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
28751
+ const rolloutRoot = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/rollout`;
28752
+ const rolloutPlanKey = (appId, promotionId, releaseId) => `${rolloutRoot(appId, promotionId, releaseId)}/plan.json`;
28652
28753
  const channelKey = (appId, channel) => {
28653
28754
  if (!APP_ID.test(appId) || !NAME.test(channel))
28654
28755
  throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
@@ -28677,18 +28778,128 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28677
28778
  throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
28678
28779
  return value;
28679
28780
  };
28781
+ const listPrefix = async (value) => {
28782
+ const objects = [];
28783
+ const cursors = new Set;
28784
+ let cursor;
28785
+ do {
28786
+ const page = await options.store.list({
28787
+ ...cursor ? { cursor } : {},
28788
+ prefix: value
28789
+ });
28790
+ objects.push(...page.objects);
28791
+ if (!page.truncated)
28792
+ break;
28793
+ if (!page.cursor || cursors.has(page.cursor))
28794
+ throw new MobileUpdateRegistryError("Mobile update storage returned an invalid cursor");
28795
+ cursors.add(page.cursor);
28796
+ cursor = page.cursor;
28797
+ } while (true);
28798
+ return objects;
28799
+ };
28800
+ const readVerifiedObject = async (key, label) => {
28801
+ const bytes = await options.store.get(key);
28802
+ if (!bytes)
28803
+ return null;
28804
+ const head = await options.store.head(key);
28805
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
28806
+ throw new MobileUpdateRegistryError(`Stored mobile update ${label} integrity failed`);
28807
+ return decode(bytes);
28808
+ };
28809
+ const parseRolloutPlan = (value, promotionId, releaseId) => {
28810
+ if (!object3(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== releaseId || typeof value.automatic !== "boolean" || !iso(value.createdAt) || !Array.isArray(value.stages))
28811
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
28812
+ const stages = value.stages.map((stage) => {
28813
+ if (!object3(stage) || typeof stage.rollout !== "number" || typeof stage.maximumFailureRate !== "number" || !Number.isSafeInteger(stage.minimumReports) || !Number.isSafeInteger(stage.observationMs))
28814
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
28815
+ return {
28816
+ maximumFailureRate: stage.maximumFailureRate,
28817
+ minimumReports: stage.minimumReports,
28818
+ observationMs: stage.observationMs,
28819
+ rollout: stage.rollout
28820
+ };
28821
+ });
28822
+ 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))
28823
+ throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
28824
+ return {
28825
+ automatic: value.automatic,
28826
+ createdAt: value.createdAt,
28827
+ format: 1,
28828
+ promotionId,
28829
+ releaseId,
28830
+ stages
28831
+ };
28832
+ };
28833
+ const initializeRollout = async (channel, signal) => {
28834
+ if (!rollout || !channel.releaseId)
28835
+ return;
28836
+ if (!rollout.stages.some((stage) => stage.rollout === channel.rollout))
28837
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
28838
+ const promotionId = healthPromotionId(channel);
28839
+ const plan = {
28840
+ automatic: rollout.automatic ?? false,
28841
+ createdAt: channel.promotedAt,
28842
+ format: 1,
28843
+ promotionId,
28844
+ releaseId: channel.releaseId,
28845
+ stages: rollout.stages.map((stage) => ({ ...stage }))
28846
+ };
28847
+ const bytes = json(plan);
28848
+ await options.store.put(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), bytes, {
28849
+ cacheControl: "no-store",
28850
+ contentType: "application/json",
28851
+ maxBytes: bytes.byteLength,
28852
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
28853
+ signal
28854
+ });
28855
+ };
28856
+ const signHealthToken = (payload) => {
28857
+ if (!health)
28858
+ return null;
28859
+ const encoded = base64Url(JSON.stringify(payload));
28860
+ const signature = createHmac("sha256", health.secret).update(encoded).digest("base64url");
28861
+ return `${encoded}.${signature}`;
28862
+ };
28863
+ const verifyHealthToken = (token) => {
28864
+ if (!health)
28865
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
28866
+ const [encoded, provided, extra] = token.split(".");
28867
+ if (!encoded || !provided || extra)
28868
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
28869
+ const expected = createHmac("sha256", health.secret).update(encoded).digest();
28870
+ let actual;
28871
+ try {
28872
+ actual = Buffer.from(provided, "base64url");
28873
+ } catch {
28874
+ actual = Buffer.alloc(0);
28875
+ }
28876
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected))
28877
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
28878
+ let value;
28879
+ try {
28880
+ value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
28881
+ } catch {
28882
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
28883
+ }
28884
+ 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")
28885
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
28886
+ return value;
28887
+ };
28680
28888
  const assertNotMarked = async (appId, releaseId) => {
28681
28889
  if (!APP_ID.test(appId) || !RELEASE.test(releaseId))
28682
28890
  throw new MobileUpdateRegistryError("Mobile update release identity is invalid");
28683
28891
  if (await options.store.head(tombstoneKey(appId, releaseId)))
28684
28892
  throw new MobileUpdateRegistryError("Mobile update release is marked for collection. Increase retention and apply garbage collection to restore it before promotion");
28685
28893
  };
28686
- const writeChannel = async (input, signal) => {
28894
+ const writeChannel = async (input, signal, beforeWrite) => {
28895
+ const promotedAt = clock().toISOString();
28687
28896
  const value = {
28688
28897
  ...input,
28689
28898
  format: MOBILE_UPDATE_REGISTRY_FORMAT,
28690
- promotedAt: clock().toISOString()
28899
+ promotedAt,
28900
+ promotionId: digest(new TextEncoder().encode(`${input.appId}\x00${input.channel}\x00${input.releaseId ?? "embedded"}\x00${promotedAt}\x00${randomUUID()}`))
28691
28901
  };
28902
+ await beforeWrite?.(value);
28692
28903
  const bytes = json(value);
28693
28904
  await options.store.put(channelKey(value.appId, value.channel), bytes, {
28694
28905
  cacheControl: "no-cache",
@@ -28703,10 +28914,92 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28703
28914
  });
28704
28915
  return value;
28705
28916
  };
28917
+ const rolloutContext = async (channel) => {
28918
+ if (!options.store.list || !channel.releaseId)
28919
+ return null;
28920
+ const promotionId = healthPromotionId(channel);
28921
+ const storedPlan = await readVerifiedObject(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), "rollout plan");
28922
+ if (storedPlan === null)
28923
+ return null;
28924
+ const plan = parseRolloutPlan(storedPlan, promotionId, channel.releaseId);
28925
+ const initialStage = plan.stages.findIndex((stage) => stage.rollout === channel.rollout);
28926
+ if (initialStage < 0)
28927
+ throw new MobileUpdateRegistryError("Stored mobile update rollout does not match its plan");
28928
+ let currentStage = initialStage;
28929
+ let enteredAt = plan.createdAt;
28930
+ const advances = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/advances/`);
28931
+ for (const item of advances) {
28932
+ const value = await readVerifiedObject(item.key, "rollout advancement");
28933
+ 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))
28934
+ throw new MobileUpdateRegistryError("Stored mobile update rollout advancement is invalid");
28935
+ if (Number(value.stage) >= currentStage) {
28936
+ currentStage = Number(value.stage);
28937
+ enteredAt = value.createdAt;
28938
+ }
28939
+ }
28940
+ const controls = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/controls/`);
28941
+ const parsedControls = [];
28942
+ for (const item of controls) {
28943
+ const value = await readVerifiedObject(item.key, "rollout control");
28944
+ 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")))
28945
+ throw new MobileUpdateRegistryError("Stored mobile update rollout control is invalid");
28946
+ parsedControls.push(value);
28947
+ }
28948
+ const cancelled = parsedControls.some(({ action }) => action === "cancel");
28949
+ const resumedPauseIds = new Set(parsedControls.flatMap((control) => control.resumedPauseIds ?? []));
28950
+ const activePauseIds = parsedControls.filter(({ action, id }) => action === "pause" && !resumedPauseIds.has(id)).map(({ id }) => id);
28951
+ const operatorPaused = activePauseIds.length > 0;
28952
+ const fleetPaused = Boolean(await options.store.head(pauseKey(channel.appId, promotionId, channel.releaseId)));
28953
+ return {
28954
+ cancelled,
28955
+ activePauseIds,
28956
+ channel,
28957
+ currentStage,
28958
+ enteredAt,
28959
+ fleetPaused,
28960
+ operatorPaused,
28961
+ plan,
28962
+ promotionId,
28963
+ rollout: plan.stages[currentStage].rollout
28964
+ };
28965
+ };
28966
+ const writeRolloutControl = async (channel, action, signal) => {
28967
+ const context = await rolloutContext(channel);
28968
+ if (!context)
28969
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
28970
+ if (context.cancelled)
28971
+ throw new MobileUpdateRegistryError("Mobile update rollout was already cancelled");
28972
+ if (action === "resume" && context.fleetPaused)
28973
+ throw new MobileUpdateRegistryError("A fleet-health pause requires an explicit re-promotion");
28974
+ const releaseId = channel.releaseId;
28975
+ if (!releaseId)
28976
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
28977
+ const createdAt = clock().toISOString();
28978
+ const id = randomUUID();
28979
+ const event = {
28980
+ action,
28981
+ createdAt,
28982
+ format: 1,
28983
+ id,
28984
+ promotionId: context.promotionId,
28985
+ releaseId,
28986
+ ...action === "resume" ? { resumedPauseIds: context.activePauseIds } : {}
28987
+ };
28988
+ const bytes = json(event);
28989
+ await options.store.put(`${rolloutRoot(channel.appId, context.promotionId, releaseId)}/controls/${createdAt}-${id}-${action}.json`, bytes, {
28990
+ cacheControl: "no-store",
28991
+ contentType: "application/json",
28992
+ maxBytes: bytes.byteLength,
28993
+ metadata: { action, sha256: digest(bytes) },
28994
+ signal
28995
+ });
28996
+ };
28706
28997
  const promoteUpdate = async (input) => {
28707
28998
  input.signal?.throwIfAborted();
28708
28999
  if (input.rollout <= 0 || input.rollout > 1)
28709
29000
  throw new MobileUpdateRegistryError("Mobile update rollout is invalid");
29001
+ if (rollout && !rollout.stages.some((stage) => stage.rollout === input.rollout))
29002
+ throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
28710
29003
  await assertNotMarked(input.appId, input.releaseId);
28711
29004
  const release = await readManifest2(input.appId, input.releaseId);
28712
29005
  if (!release || release.manifest.channel !== input.channel)
@@ -28718,7 +29011,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28718
29011
  ...existing?.releaseId && existing.releaseId !== input.releaseId ? { fallbackReleaseId: existing.releaseId } : existing?.fallbackReleaseId ? { fallbackReleaseId: existing.fallbackReleaseId } : {},
28719
29012
  releaseId: input.releaseId,
28720
29013
  rollout: input.rollout
28721
- }, input.signal);
29014
+ }, input.signal, (channel) => initializeRollout(channel, input.signal));
28722
29015
  return {
28723
29016
  appId: input.appId,
28724
29017
  channel: input.channel,
@@ -28731,13 +29024,16 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28731
29024
  const channel = await readChannel(input.appId, input.channel);
28732
29025
  if (!channel?.releaseId)
28733
29026
  return { status: "empty" };
28734
- const selected = rolloutMember({
29027
+ const rolloutState = await rolloutContext(channel);
29028
+ let selected = rolloutMember({
28735
29029
  appId: input.appId,
28736
29030
  channel: input.channel,
28737
29031
  installationId: input.installationId,
28738
29032
  releaseId: channel.releaseId,
28739
- rollout: channel.rollout
29033
+ rollout: rolloutState?.rollout ?? channel.rollout
28740
29034
  }) ? channel.releaseId : channel.fallbackReleaseId;
29035
+ if (selected === channel.releaseId && (rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || health && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId))))
29036
+ selected = channel.fallbackReleaseId;
28741
29037
  if (!selected)
28742
29038
  return { status: "empty" };
28743
29039
  const release = await readManifest2(input.appId, selected);
@@ -28753,6 +29049,243 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28753
29049
  status: "selected"
28754
29050
  };
28755
29051
  };
29052
+ const issueUpdateHealthToken = async (input) => {
29053
+ if (!health)
29054
+ return null;
29055
+ const channel = await readChannel(input.appId, input.channel);
29056
+ if (!channel?.releaseId || channel.releaseId !== input.releaseId)
29057
+ return null;
29058
+ const resolution = await resolveUpdateState(input);
29059
+ if (resolution.status !== "selected" || resolution.manifest.releaseId !== input.releaseId)
29060
+ return null;
29061
+ return signHealthToken({
29062
+ appId: input.appId,
29063
+ channel: input.channel,
29064
+ format: HEALTH_TOKEN_VERSION,
29065
+ installationId: input.installationId,
29066
+ promotionId: healthPromotionId(channel),
29067
+ releaseId: input.releaseId,
29068
+ runtimeFingerprint: input.runtimeFingerprint
29069
+ });
29070
+ };
29071
+ const inspectUpdateHealth = async (input) => {
29072
+ if (!health)
29073
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
29074
+ const channel = await readChannel(input.appId, input.channel);
29075
+ const releaseId = input.releaseId ?? channel?.releaseId;
29076
+ if (!channel || !releaseId || channel.releaseId !== releaseId)
29077
+ return null;
29078
+ const promotionId = healthPromotionId(channel);
29079
+ const objects = await listPrefix(`${healthRoot(input.appId, promotionId, releaseId)}/events/`);
29080
+ const installations = new Set;
29081
+ const byKind = new Map([...HEALTH_KINDS].map((kind) => [
29082
+ kind,
29083
+ new Set
29084
+ ]));
29085
+ const transfer = {
29086
+ avoidedBytes: 0,
29087
+ downloadedBytes: 0,
29088
+ durationMs: 0,
29089
+ resumedBytes: 0,
29090
+ reusedBytes: 0,
29091
+ throughputBytesPerSecond: 0
29092
+ };
29093
+ for (const item of objects) {
29094
+ const bytes = await options.store.get(item.key);
29095
+ if (!bytes)
29096
+ continue;
29097
+ const head = await options.store.head(item.key);
29098
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
29099
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence integrity failed");
29100
+ const value = decode(bytes);
29101
+ if (!object3(value) || typeof value.installationHash !== "string" || !HEALTH_KINDS.has(String(value.kind)))
29102
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence is invalid");
29103
+ const kind = value.kind;
29104
+ installations.add(value.installationHash);
29105
+ byKind.get(kind).add(value.installationHash);
29106
+ if (kind === "downloaded" && object3(value.transfer)) {
29107
+ const parsed = parseHealthTransfer(value.transfer);
29108
+ for (const key of Object.keys(transfer))
29109
+ transfer[key] += parsed[key];
29110
+ }
29111
+ }
29112
+ const failures = new Set([
29113
+ ...byKind.get("quarantined"),
29114
+ ...byKind.get("rolled-back")
29115
+ ]);
29116
+ const terminals = new Set([...byKind.get("activated"), ...failures]);
29117
+ const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
29118
+ const rolloutState = await rolloutContext(channel);
29119
+ return {
29120
+ activated: byKind.get("activated").size,
29121
+ appId: input.appId,
29122
+ channel: input.channel,
29123
+ downloaded: byKind.get("downloaded").size,
29124
+ downloadFailed: byKind.get("download-failed").size,
29125
+ failureRate,
29126
+ failures: failures.size,
29127
+ paused: Boolean(rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
29128
+ promotionId,
29129
+ quarantined: byKind.get("quarantined").size,
29130
+ releaseId,
29131
+ reportedInstallations: installations.size,
29132
+ rolledBack: byKind.get("rolled-back").size,
29133
+ rollout: rolloutState?.rollout ?? channel.rollout,
29134
+ terminalReports: terminals.size,
29135
+ transfer
29136
+ };
29137
+ };
29138
+ const inspectUpdateRollout = async (input) => {
29139
+ const channel = await readChannel(input.appId, input.channel);
29140
+ if (!channel?.releaseId)
29141
+ return null;
29142
+ const context = await rolloutContext(channel);
29143
+ if (!context)
29144
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
29145
+ const healthReport = await inspectUpdateHealth(input);
29146
+ if (!healthReport)
29147
+ return null;
29148
+ const paused = context.fleetPaused || context.operatorPaused;
29149
+ const complete = context.currentStage === context.plan.stages.length - 1;
29150
+ return {
29151
+ ...healthReport,
29152
+ automatic: context.plan.automatic,
29153
+ currentStage: context.currentStage,
29154
+ enteredAt: context.enteredAt,
29155
+ ...!complete ? { nextStage: context.plan.stages[context.currentStage + 1] } : {},
29156
+ ...context.fleetPaused ? { pausedBy: "fleet-health" } : context.operatorPaused ? { pausedBy: "operator" } : {},
29157
+ status: context.cancelled ? "cancelled" : paused ? "paused" : complete ? "complete" : "active"
29158
+ };
29159
+ };
29160
+ const advanceRollout = async (input, strict) => {
29161
+ input.signal?.throwIfAborted();
29162
+ const channel = await readChannel(input.appId, input.channel);
29163
+ if (!channel?.releaseId)
29164
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
29165
+ const context = await rolloutContext(channel);
29166
+ if (!context)
29167
+ throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
29168
+ const report = await inspectUpdateRollout(input);
29169
+ if (!report)
29170
+ throw new MobileUpdateRegistryError("Mobile update rollout report is unavailable");
29171
+ const nextStage = context.plan.stages[context.currentStage + 1];
29172
+ if (!nextStage)
29173
+ return report;
29174
+ if (input.rollout !== undefined && input.rollout !== nextStage.rollout)
29175
+ throw new MobileUpdateRegistryError("Mobile update rollout can advance only to the next configured stage");
29176
+ if (report.status !== "active") {
29177
+ if (strict)
29178
+ throw new MobileUpdateRegistryError(`Mobile update rollout cannot advance while ${report.status}`);
29179
+ return report;
29180
+ }
29181
+ const gate = context.plan.stages[context.currentStage];
29182
+ const observedMs = clock().getTime() - Date.parse(context.enteredAt);
29183
+ const blocked = report.terminalReports < gate.minimumReports || report.failureRate > gate.maximumFailureRate || observedMs < gate.observationMs;
29184
+ if (blocked) {
29185
+ if (strict)
29186
+ 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`);
29187
+ return report;
29188
+ }
29189
+ const stage = context.currentStage + 1;
29190
+ const event = {
29191
+ createdAt: clock().toISOString(),
29192
+ failureRate: report.failureRate,
29193
+ format: 1,
29194
+ promotionId: context.promotionId,
29195
+ releaseId: channel.releaseId,
29196
+ rollout: nextStage.rollout,
29197
+ stage,
29198
+ terminalReports: report.terminalReports
29199
+ };
29200
+ const bytes = json(event);
29201
+ await options.store.put(`${rolloutRoot(input.appId, context.promotionId, channel.releaseId)}/advances/${String(stage).padStart(4, "0")}.json`, bytes, {
29202
+ cacheControl: "no-store",
29203
+ contentType: "application/json",
29204
+ maxBytes: bytes.byteLength,
29205
+ metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
29206
+ signal: input.signal
29207
+ });
29208
+ return await inspectUpdateRollout(input);
29209
+ };
29210
+ const advanceUpdateRollout = (input) => advanceRollout(input, true);
29211
+ const reconcileUpdateRollout = async (input) => {
29212
+ const report = await inspectUpdateRollout(input);
29213
+ if (!report || !report.automatic)
29214
+ return report;
29215
+ return advanceRollout(input, false);
29216
+ };
29217
+ const rolloutControl = (action) => async (input) => {
29218
+ input.signal?.throwIfAborted();
29219
+ const channel = await readChannel(input.appId, input.channel);
29220
+ if (!channel?.releaseId)
29221
+ throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
29222
+ await writeRolloutControl(channel, action, input.signal);
29223
+ return await inspectUpdateRollout(input);
29224
+ };
29225
+ const pauseUpdateRollout = rolloutControl("pause");
29226
+ const resumeUpdateRollout = rolloutControl("resume");
29227
+ const cancelUpdateRollout = rolloutControl("cancel");
29228
+ const recordUpdateHealth = async (input) => {
29229
+ const payload = verifyHealthToken(input.token);
29230
+ 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")
29231
+ throw new MobileUpdateRegistryError("Mobile update health evidence does not match its token");
29232
+ const release = await readManifest2(input.appId, input.releaseId);
29233
+ if (!release || release.manifest.runtimeFingerprint !== input.runtimeFingerprint)
29234
+ throw new MobileUpdateRegistryError("Mobile update health release is invalid");
29235
+ const activeChannel = await readChannel(input.appId, input.channel);
29236
+ if (!activeChannel || activeChannel.releaseId !== input.releaseId || healthPromotionId(activeChannel) !== payload.promotionId)
29237
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
29238
+ const transfer = parseHealthTransfer(input.transfer);
29239
+ const installationHash = createHmac("sha256", health.secret).update(input.installationId).digest("hex");
29240
+ const evidence = {
29241
+ format: 1,
29242
+ installationHash,
29243
+ kind: input.kind,
29244
+ observedAt: clock().toISOString(),
29245
+ ...input.reason ? { reason: input.reason } : {},
29246
+ ...transfer ? { transfer } : {}
29247
+ };
29248
+ const bytes = json(evidence);
29249
+ await options.store.put(`${healthRoot(input.appId, payload.promotionId, input.releaseId)}/events/${installationHash}/${input.kind}.json`, bytes, {
29250
+ cacheControl: "no-store",
29251
+ contentType: "application/json",
29252
+ maxBytes: bytes.byteLength,
29253
+ metadata: { kind: input.kind, sha256: digest(bytes) }
29254
+ });
29255
+ let report = await inspectUpdateHealth({
29256
+ appId: input.appId,
29257
+ channel: input.channel,
29258
+ releaseId: input.releaseId
29259
+ });
29260
+ if (!report)
29261
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
29262
+ if (FAILURE_HEALTH_KINDS.has(input.kind) && report.terminalReports >= minimumReports && report.failureRate >= failureThreshold && !report.paused) {
29263
+ const marker = json({
29264
+ appId: input.appId,
29265
+ channel: input.channel,
29266
+ failureRate: report.failureRate,
29267
+ failures: report.failures,
29268
+ format: 1,
29269
+ pausedAt: clock().toISOString(),
29270
+ promotionId: payload.promotionId,
29271
+ releaseId: input.releaseId,
29272
+ reports: report.terminalReports
29273
+ });
29274
+ await options.store.put(pauseKey(input.appId, payload.promotionId, input.releaseId), marker, {
29275
+ cacheControl: "no-store",
29276
+ contentType: "application/json",
29277
+ maxBytes: marker.byteLength,
29278
+ metadata: { releaseid: input.releaseId, sha256: digest(marker) }
29279
+ });
29280
+ report = { ...report, paused: true };
29281
+ }
29282
+ if (rollout && (input.kind === "activated" || FAILURE_HEALTH_KINDS.has(input.kind)))
29283
+ return await reconcileUpdateRollout({
29284
+ appId: input.appId,
29285
+ channel: input.channel
29286
+ }) ?? report;
29287
+ return report;
29288
+ };
28756
29289
  const retentionValues = (input) => {
28757
29290
  if (!APP_ID.test(input.appId))
28758
29291
  throw new MobileUpdateRegistryError("Mobile update appId is invalid");
@@ -28995,6 +29528,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
28995
29528
  return result;
28996
29529
  };
28997
29530
  return {
29531
+ ...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
29532
+ ...rollout ? {
29533
+ advanceUpdateRollout,
29534
+ cancelUpdateRollout,
29535
+ inspectUpdateRollout,
29536
+ pauseUpdateRollout,
29537
+ reconcileUpdateRollout,
29538
+ resumeUpdateRollout
29539
+ } : {},
28998
29540
  inspectUpdateStorage: async (input) => (await inventory(input)).report,
28999
29541
  pruneUpdates,
29000
29542
  publishUpdate: async (input) => {
@@ -29272,7 +29814,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
29272
29814
  const origin = request.headers.get("origin");
29273
29815
  const cors = origin && allowedOrigins.has(origin) ? {
29274
29816
  "access-control-allow-origin": origin,
29275
- "access-control-expose-headers": "content-range,etag",
29817
+ "access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
29276
29818
  vary: "Origin"
29277
29819
  } : {};
29278
29820
  if (request.method === "OPTIONS") {
@@ -29281,17 +29823,58 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
29281
29823
  return new Response(null, {
29282
29824
  headers: {
29283
29825
  ...cors,
29284
- "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",
29285
- "access-control-allow-methods": "GET,OPTIONS",
29826
+ "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",
29827
+ "access-control-allow-methods": "GET,POST,OPTIONS",
29286
29828
  "access-control-max-age": "600"
29287
29829
  },
29288
29830
  status: 204
29289
29831
  });
29290
29832
  }
29291
- if (request.method !== "GET")
29292
- return new Response(null, { status: 405 });
29293
29833
  const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
29294
29834
  const relative19 = pathname.startsWith(`${route}/`) ? pathname.slice(route.length + 1) : "";
29835
+ if (request.method === "POST" && relative19 === "health") {
29836
+ if (!options.registry.recordUpdateHealth)
29837
+ return new Response(null, { status: 404 });
29838
+ const appId = request.headers.get("x-absolute-mobile-app");
29839
+ const channel = request.headers.get("x-absolute-mobile-channel");
29840
+ const installationId = request.headers.get("x-absolute-mobile-installation");
29841
+ const runtimeFingerprint = request.headers.get("x-absolute-mobile-runtime");
29842
+ const token = request.headers.get("x-absolute-mobile-health-token");
29843
+ const declared = Number(request.headers.get("content-length"));
29844
+ if (appId !== options.appId || channel !== options.channel || !installationId || !runtimeFingerprint || !token || Number.isFinite(declared) && declared > 4096)
29845
+ return new Response(null, { status: 400 });
29846
+ const bodyBytes = new Uint8Array(await request.arrayBuffer());
29847
+ if (bodyBytes.byteLength > 4096)
29848
+ return new Response(null, { status: 413 });
29849
+ let body;
29850
+ try {
29851
+ body = JSON.parse(new TextDecoder().decode(bodyBytes));
29852
+ } catch {
29853
+ return new Response(null, { status: 400 });
29854
+ }
29855
+ if (!object3(body) || typeof body.releaseId !== "string" || typeof body.kind !== "string")
29856
+ return new Response(null, { status: 400 });
29857
+ try {
29858
+ const report = await options.registry.recordUpdateHealth({
29859
+ appId,
29860
+ channel,
29861
+ installationId,
29862
+ kind: body.kind,
29863
+ ...body.reason === "boot-interrupted" || body.reason === "boot-timeout" ? { reason: body.reason } : {},
29864
+ releaseId: body.releaseId,
29865
+ runtimeFingerprint,
29866
+ token,
29867
+ ...object3(body.transfer) ? { transfer: body.transfer } : {}
29868
+ });
29869
+ return Response.json({ paused: report.paused }, { headers: { ...cors, "cache-control": "no-store" }, status: 202 });
29870
+ } catch (error) {
29871
+ if (error instanceof MobileUpdateRegistryError)
29872
+ return new Response(null, { status: 403 });
29873
+ throw error;
29874
+ }
29875
+ }
29876
+ if (request.method !== "GET")
29877
+ return new Response(null, { status: 405 });
29295
29878
  if (relative19 === "update.json") {
29296
29879
  const expoProtocolVersion = request.headers.get("expo-protocol-version");
29297
29880
  const expoProtocol = expoProtocolVersion !== null;
@@ -29317,6 +29900,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
29317
29900
  installationId,
29318
29901
  runtimeFingerprint
29319
29902
  });
29903
+ const healthToken = selected && options.registry.issueUpdateHealthToken ? await options.registry.issueUpdateHealthToken({
29904
+ appId,
29905
+ channel,
29906
+ installationId,
29907
+ releaseId: selected.manifest.releaseId,
29908
+ runtimeFingerprint
29909
+ }) : null;
29320
29910
  if (expoProtocol) {
29321
29911
  let requestedCodeSigning;
29322
29912
  try {
@@ -29368,6 +29958,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
29368
29958
  extra: {
29369
29959
  absolutejs: {
29370
29960
  channel: selected.manifest.channel,
29961
+ ...healthToken ? { healthToken } : {},
29371
29962
  releaseId: selected.manifest.releaseId
29372
29963
  },
29373
29964
  expoClient: descriptor.expoConfig
@@ -29395,7 +29986,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
29395
29986
  headers: {
29396
29987
  ...cors,
29397
29988
  "cache-control": "no-store",
29398
- etag: `"${selected.manifest.releaseId}"`
29989
+ etag: `"${selected.manifest.releaseId}"`,
29990
+ ...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
29399
29991
  }
29400
29992
  });
29401
29993
  }
@@ -29458,6 +30050,14 @@ var init_mobileUpdate = __esm(() => {
29458
30050
  RELEASE = /^amu_[a-f0-9]{64}$/;
29459
30051
  APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
29460
30052
  NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
30053
+ HEALTH_KINDS = new Set([
30054
+ "activated",
30055
+ "downloaded",
30056
+ "download-failed",
30057
+ "quarantined",
30058
+ "rolled-back"
30059
+ ]);
30060
+ FAILURE_HEALTH_KINDS = new Set(["quarantined", "rolled-back"]);
29461
30061
  MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
29462
30062
  };
29463
30063
  expoProtocolHeaders = {
@@ -29545,6 +30145,16 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
29545
30145
  } catch (error) {
29546
30146
  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 });
29547
30147
  }
30148
+ }, verifyHealthModule = (config, module) => {
30149
+ if (!config.updateServer?.health)
30150
+ return;
30151
+ if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
30152
+ throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
30153
+ }, verifyRolloutModule = (config, module) => {
30154
+ if (!config.updateServer?.rollout)
30155
+ return;
30156
+ 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")
30157
+ throw new TypeError("Mobile update rollout orchestration is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
29548
30158
  }, expoSigningOptions = (config) => {
29549
30159
  if (!config.updates?.expoCodeSigning)
29550
30160
  return;
@@ -29576,8 +30186,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
29576
30186
  if (!updates || !server?.autoMount)
29577
30187
  return new Elysia5({ name: "absolutejs-mobile-updates-disabled" });
29578
30188
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
29579
- if (options.production)
30189
+ if (options.production) {
29580
30190
  await verifyDurableModule(module);
30191
+ verifyHealthModule(config, module);
30192
+ verifyRolloutModule(config, module);
30193
+ }
29581
30194
  const manifest = new URL(updates.manifestUrl);
29582
30195
  if (!manifest.pathname.endsWith("/update.json"))
29583
30196
  throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
@@ -29596,10 +30209,12 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
29596
30209
  return;
29597
30210
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
29598
30211
  await verifyDurableModule(module);
30212
+ verifyHealthModule(config, module);
30213
+ verifyRolloutModule(config, module);
29599
30214
  if (config.engine === "expo")
29600
30215
  expoSigningOptions(config);
29601
30216
  return module.metadata;
29602
- }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistry = (options) => {
30217
+ }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
29603
30218
  const metadata = `export const absoluteMobileUpdateServer = {
29604
30219
  format: 1,
29605
30220
  provider: '${options.storage}',
@@ -29665,6 +30280,27 @@ export default createMobileUpdateRegistry({
29665
30280
  store
29666
30281
  });
29667
30282
  `;
30283
+ }, renderAbsoluteMobileUpdateRegistry = (options) => {
30284
+ const source = renderAbsoluteMobileUpdateRegistryBase(options);
30285
+ let generated = "";
30286
+ if (options.health) {
30287
+ const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
30288
+ generated += ` health: {
30289
+ autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
30290
+ secret: ${secret}
30291
+ },
30292
+ `;
30293
+ }
30294
+ if (options.rollout)
30295
+ generated += ` rollout: ${JSON.stringify(options.rollout, null, "\t").replaceAll(`
30296
+ `, `
30297
+ `)},
30298
+ `;
30299
+ if (!generated)
30300
+ return source;
30301
+ return source.replace(`export default createMobileUpdateRegistry({
30302
+ `, `export default createMobileUpdateRegistry({
30303
+ ${generated}`);
29668
30304
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
29669
30305
  const path2 = projectPath(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
29670
30306
  if (!options.force) {
@@ -29676,6 +30312,8 @@ export default createMobileUpdateRegistry({
29676
30312
  }
29677
30313
  await mkdir13(dirname31(path2), { recursive: true });
29678
30314
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
30315
+ ...options.health ? { health: options.health } : {},
30316
+ ...options.rollout ? { rollout: options.rollout } : {},
29679
30317
  publicKeys: options.publicKeys,
29680
30318
  storage: options.storage
29681
30319
  }));
@@ -37278,5 +37916,5 @@ export {
37278
37916
  devBuild
37279
37917
  };
37280
37918
 
37281
- //# debugId=51C2CE65BFD2E24164756E2164756E21
37919
+ //# debugId=EAFB7DB04FFF880464756E2164756E21
37282
37920
  //# sourceMappingURL=build.js.map