@le-space/ui 0.4.2 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/ui",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Floating Aleph sponsor relay UI components for Svelte and React.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,8 +36,8 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@le-space/browser": "0.4.2",
40
- "@le-space/core": "0.4.2",
39
+ "@le-space/browser": "0.4.4",
40
+ "@le-space/core": "0.4.4",
41
41
  "viem": "^2.38.5"
42
42
  },
43
43
  "peerDependencies": {
package/react/index.d.ts CHANGED
@@ -256,6 +256,8 @@ interface CompactBootstrapRegistrationRecord {
256
256
  registrationId?: string;
257
257
  multiaddrs: string[];
258
258
  browserMultiaddrs?: string[];
259
+ ownerAddress?: string;
260
+ publisherAddress?: string;
259
261
  updatedAt: number;
260
262
  } | null;
261
263
  }
@@ -320,6 +322,7 @@ declare class SponsorRelayController {
320
322
  private patch;
321
323
  private emitProgress;
322
324
  private configureRelayBootstrapRegistration;
325
+ private cleanupStaleVmBootstrapConfigHistory;
323
326
  private syncLatestDeploymentProgress;
324
327
  private canSkipRuntimeRefresh;
325
328
  private noteRuntimeRefreshResult;
package/react/index.js CHANGED
@@ -28,7 +28,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
28
28
  var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
29
29
 
30
30
  // src/shared/package-version.ts
31
- var UI_PACKAGE_VERSION = "0.4.2";
31
+ var UI_PACKAGE_VERSION = "0.4.4";
32
32
 
33
33
  // src/shared/format.ts
34
34
  function shortHash(value, head = 8, tail = 6) {
@@ -1741,14 +1741,15 @@ function describeRuntimeAvailability(runtime) {
1741
1741
  const mappedPorts2 = runtime.mappedPorts ?? {};
1742
1742
  const hostIpv4 = runtime.hostIpv4 ?? null;
1743
1743
  const ipv6 = runtime.ipv6 ?? null;
1744
+ const executionGuestIpv6 = execution?.networking?.ipv6_ip ?? null;
1744
1745
  const proxyUrl = runtime.proxyUrl ?? null;
1745
1746
  const schedulerSource = runtime.allocation?.source ?? null;
1746
1747
  const webAccessActive = runtime.webAccess?.active ?? null;
1747
1748
  const mappedPortCount = Object.keys(mappedPorts2).length;
1748
- if (hostIpv4 && mappedPortCount > 0 && runtime.requirePublicGuestIpv6ForProxy === true && proxyUrl && !isLikelyGlobalIpv6(ipv6)) {
1749
+ if (hostIpv4 && mappedPortCount > 0 && runtime.requirePublicGuestIpv6ForProxy === true && proxyUrl && executionGuestIpv6 && !isLikelyGlobalIpv6(executionGuestIpv6)) {
1749
1750
  return {
1750
1751
  state: "execution-invalid-public-ipv6",
1751
- reason: `The CRN exposed runtime networking, but the guest IPv6${ipv6 ? ` (${ipv6})` : ""} is not globally routable. Proxy-backed HTTPS activation cannot succeed until the CRN reports a public guest IPv6.`,
1752
+ reason: `The CRN exposed runtime networking, but the guest IPv6 (${executionGuestIpv6}) is not globally routable. Proxy-backed HTTPS activation cannot succeed until the CRN reports a public guest IPv6.`,
1752
1753
  schedulerSource,
1753
1754
  executionSeen: Boolean(execution),
1754
1755
  webAccessActive,
@@ -2698,6 +2699,7 @@ async function publishRelayBootstrapRegistration(args) {
2698
2699
  var VM_BOOTSTRAP_CONFIG_AGGREGATE_KEY = "vm-bootstrap-config";
2699
2700
  var VM_BOOTSTRAP_CONFIG_SIGNAL_REF = "vm-bootstrap-config";
2700
2701
  var VM_BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE = "vm-bootstrap-config-status";
2702
+ var VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS = 6 * 60 * 60 * 1e3;
2701
2703
  function parseJsonObject(value) {
2702
2704
  try {
2703
2705
  const parsed = JSON.parse(value);
@@ -2706,6 +2708,44 @@ function parseJsonObject(value) {
2706
2708
  return null;
2707
2709
  }
2708
2710
  }
2711
+ function parseTimestampMs(value) {
2712
+ if (typeof value === "number" && Number.isFinite(value)) {
2713
+ return value > 1e12 ? value : value * 1e3;
2714
+ }
2715
+ if (typeof value === "string" && value.trim()) {
2716
+ const numeric = Number(value);
2717
+ if (Number.isFinite(numeric)) {
2718
+ return numeric > 1e12 ? numeric : numeric * 1e3;
2719
+ }
2720
+ const parsed = Date.parse(value);
2721
+ return Number.isFinite(parsed) ? parsed : null;
2722
+ }
2723
+ return null;
2724
+ }
2725
+ function normalizeVmBootstrapConfigRecord(deploymentTokenKey, value, options = {}) {
2726
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2727
+ const candidate = value;
2728
+ const deploymentToken = typeof candidate.deploymentToken === "string" ? candidate.deploymentToken.trim() : "";
2729
+ const profile = typeof candidate.profile === "string" ? candidate.profile.trim() : "";
2730
+ const ownerAddress = typeof candidate.ownerAddress === "string" ? candidate.ownerAddress.trim() : "";
2731
+ const instanceItemHash = typeof candidate.instanceItemHash === "string" ? candidate.instanceItemHash.trim() : "";
2732
+ const createdAt = typeof candidate.createdAt === "string" ? candidate.createdAt.trim() : "";
2733
+ const expiresAt = typeof candidate.expiresAt === "string" ? candidate.expiresAt.trim() : "";
2734
+ if (!deploymentTokenKey.trim() || !deploymentToken || deploymentToken !== deploymentTokenKey.trim() || !profile || !ownerAddress || !instanceItemHash || !createdAt || !expiresAt) {
2735
+ return null;
2736
+ }
2737
+ const nowMs = Math.max(0, Number(options.nowMs ?? Date.now()));
2738
+ const maxRecordAgeMs = Math.max(
2739
+ 6e4,
2740
+ Number(options.maxRecordAgeMs ?? VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS)
2741
+ );
2742
+ const createdAtMs = parseTimestampMs(createdAt);
2743
+ const expiresAtMs = parseTimestampMs(expiresAt);
2744
+ if (createdAtMs == null || expiresAtMs == null) return null;
2745
+ if (expiresAtMs <= nowMs) return null;
2746
+ if (createdAtMs < nowMs - maxRecordAgeMs) return null;
2747
+ return candidate;
2748
+ }
2709
2749
  async function fetchVmBootstrapConfigAggregate(address, options) {
2710
2750
  const requestUrl = new URL(`/api/v0/aggregates/${address}.json`, options.apiHost ?? DEFAULT_ALEPH_API_HOST2);
2711
2751
  requestUrl.searchParams.set("keys", VM_BOOTSTRAP_CONFIG_AGGREGATE_KEY);
@@ -2719,16 +2759,21 @@ async function fetchVmBootstrapConfigAggregate(address, options) {
2719
2759
  if (!aggregate || typeof aggregate !== "object" || Array.isArray(aggregate)) return {};
2720
2760
  return aggregate;
2721
2761
  }
2722
- function normalizeVmBootstrapConfigAggregate(aggregate) {
2762
+ function normalizeVmBootstrapConfigAggregate(aggregate, options = {}) {
2723
2763
  if (!aggregate || typeof aggregate !== "object") return {};
2724
2764
  return Object.fromEntries(
2725
- Object.entries(aggregate).filter(
2726
- (entry) => Boolean(entry[1] && typeof entry[1] === "object" && !Array.isArray(entry[1]))
2727
- )
2765
+ Object.entries(aggregate).map(([deploymentToken, record]) => [
2766
+ deploymentToken,
2767
+ normalizeVmBootstrapConfigRecord(deploymentToken, record, options)
2768
+ ]).filter((entry) => entry[1] != null)
2728
2769
  );
2729
2770
  }
2730
2771
  function createVmBootstrapConfigAggregateContent(args) {
2731
- const content = normalizeVmBootstrapConfigAggregate(args.existingAggregate);
2772
+ const nowMs = Math.max(0, Number(args.now ?? Date.now() / 1e3) * 1e3);
2773
+ const content = normalizeVmBootstrapConfigAggregate(args.existingAggregate, {
2774
+ nowMs,
2775
+ maxRecordAgeMs: args.maxRecordAgeMs
2776
+ });
2732
2777
  content[args.record.deploymentToken] = args.record;
2733
2778
  return {
2734
2779
  address: args.sender,
@@ -2773,6 +2818,55 @@ async function publishVmBootstrapConfigAggregate(args) {
2773
2818
  aggregateStatus
2774
2819
  };
2775
2820
  }
2821
+ function normalizeAggregateMessageHashRecord(value) {
2822
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2823
+ const candidate = value;
2824
+ const itemHash = typeof candidate.item_hash === "string" ? candidate.item_hash.trim() : "";
2825
+ if (!itemHash) return null;
2826
+ const content = candidate.content && typeof candidate.content === "object" && !Array.isArray(candidate.content) ? candidate.content : typeof candidate.item_content === "string" ? parseJsonObject(candidate.item_content) : null;
2827
+ if (!content || content.key !== VM_BOOTSTRAP_CONFIG_AGGREGATE_KEY) return null;
2828
+ const timeMs = parseTimestampMs(candidate.time);
2829
+ if (timeMs == null) return null;
2830
+ return { itemHash, timeMs };
2831
+ }
2832
+ async function listStaleVmBootstrapConfigAggregateMessageHashes(args) {
2833
+ const address = args.address.trim();
2834
+ if (!address) return [];
2835
+ const currentAggregateItemHash = args.currentAggregateItemHash?.trim() ?? "";
2836
+ const olderThanMs = Math.max(
2837
+ 6e4,
2838
+ Number(args.olderThanMs ?? VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS)
2839
+ );
2840
+ const cutoffMs = Math.max(0, Number(args.nowMs ?? Date.now()) - olderThanMs);
2841
+ const pagination = Math.max(1, Number(args.pagination ?? 100));
2842
+ const maxPages = Math.max(1, Number(args.maxPages ?? 10));
2843
+ const hashes = /* @__PURE__ */ new Set();
2844
+ for (let page = 1; page <= maxPages; page += 1) {
2845
+ const requestUrl = new URL("/api/v0/messages.json", args.apiHost ?? DEFAULT_ALEPH_API_HOST2);
2846
+ requestUrl.searchParams.set("msgType", "AGGREGATE");
2847
+ requestUrl.searchParams.set("addresses", address);
2848
+ requestUrl.searchParams.set("message_statuses", "processed,pending,rejected,removing");
2849
+ requestUrl.searchParams.set("pagination", String(pagination));
2850
+ requestUrl.searchParams.set("page", String(page));
2851
+ requestUrl.searchParams.set("sortOrder", "-1");
2852
+ const response = await args.fetch(requestUrl.toString(), { cache: "no-cache" });
2853
+ if (!response.ok) {
2854
+ throw new Error(`VM bootstrap config aggregate history request failed: ${response.status}`);
2855
+ }
2856
+ const payload = await response.json();
2857
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
2858
+ if (messages.length === 0) break;
2859
+ for (const message of messages) {
2860
+ const normalized = normalizeAggregateMessageHashRecord(message);
2861
+ if (!normalized) continue;
2862
+ if (normalized.itemHash === currentAggregateItemHash) continue;
2863
+ if (normalized.timeMs > cutoffMs) continue;
2864
+ hashes.add(normalized.itemHash);
2865
+ }
2866
+ if (messages.length < pagination) break;
2867
+ }
2868
+ return [...hashes];
2869
+ }
2776
2870
  async function publishVmBootstrapConfig(args) {
2777
2871
  const aggregate = await fetchVmBootstrapConfigAggregate(args.sender, {
2778
2872
  apiHost: args.apiHost,
@@ -2781,7 +2875,8 @@ async function publishVmBootstrapConfig(args) {
2781
2875
  const content = createVmBootstrapConfigAggregateContent({
2782
2876
  sender: args.sender,
2783
2877
  record: args.record,
2784
- existingAggregate: aggregate
2878
+ existingAggregate: aggregate,
2879
+ maxRecordAgeMs: VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS
2785
2880
  });
2786
2881
  const result = await publishVmBootstrapConfigAggregate({
2787
2882
  sender: args.sender,
@@ -2803,7 +2898,9 @@ async function deleteVmBootstrapConfig(args) {
2803
2898
  apiHost: args.apiHost,
2804
2899
  fetch: args.fetch
2805
2900
  });
2806
- const content = normalizeVmBootstrapConfigAggregate(aggregate);
2901
+ const content = normalizeVmBootstrapConfigAggregate(aggregate, {
2902
+ maxRecordAgeMs: VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS
2903
+ });
2807
2904
  delete content[args.deploymentToken];
2808
2905
  const publishResult = await publishVmBootstrapConfigAggregate({
2809
2906
  sender: args.sender,
@@ -3104,7 +3201,7 @@ function rootfsHealth(args) {
3104
3201
  return {
3105
3202
  tone: "error",
3106
3203
  label: "not found on Aleph",
3107
- detail: "The referenced rootfs STORE message is not available yet."
3204
+ detail: "The manifest points to a rootfs STORE message that Aleph cannot find. Check that the manifest URL is correct and that the referenced rootfs was not forgotten."
3108
3205
  };
3109
3206
  }
3110
3207
  if (!args.resolution) {
@@ -3137,8 +3234,8 @@ function rootfsHealth(args) {
3137
3234
  }
3138
3235
  return {
3139
3236
  tone: "error",
3140
- label: "not deployable",
3141
- detail: args.resolution.rejectionReason ?? "Aleph rejected the rootfs reference."
3237
+ label: "manifest rootfs not deployable",
3238
+ detail: args.resolution.rejectionReason ?? (args.resolution.messageType === "STORE" ? "This manifest points to a rootfs STORE message that exists but is not deployable anymore, for example because it was forgotten or replaced. Double-check the manifest URL." : "Aleph rejected the rootfs reference. Double-check that the manifest URL points to the intended current rootfs.")
3142
3239
  };
3143
3240
  }
3144
3241
  async function resolveManifest(args) {
@@ -3167,6 +3264,22 @@ function relayBootstrapInstanceItemHash(entry) {
3167
3264
  }
3168
3265
  return registrationIdInstanceItemHash(entry?.content?.registrationId);
3169
3266
  }
3267
+ function relayBootstrapMatchesCurrentWallet(entry, normalizedWalletAddress, activeInstanceHashes) {
3268
+ const instanceItemHash = relayBootstrapInstanceItemHash(entry);
3269
+ if (instanceItemHash && activeInstanceHashes.has(instanceItemHash)) {
3270
+ return true;
3271
+ }
3272
+ const candidateAddresses = [
3273
+ entry?.address,
3274
+ entry?.content?.ownerAddress,
3275
+ entry?.content?.authorization?.payload?.ownerAddress,
3276
+ entry?.content?.publisherAddress,
3277
+ entry?.content?.authorization?.payload?.publisherAddress
3278
+ ];
3279
+ return candidateAddresses.some(
3280
+ (value) => typeof value === "string" && value.trim().toLowerCase() === normalizedWalletAddress
3281
+ );
3282
+ }
3170
3283
  async function inspectInstanceRuntime(args) {
3171
3284
  const details = {
3172
3285
  messageStatus: String(
@@ -3682,6 +3795,16 @@ var SponsorRelayController = class {
3682
3795
  });
3683
3796
  }
3684
3797
  if (!relayMetadata) {
3798
+ await this.refresh().catch(() => void 0);
3799
+ const confirmedAfterRefresh = this.state.bootstrapRegistrations.some(
3800
+ (entry) => entry.instanceItemHash === args.itemHash && entry.confirmed
3801
+ );
3802
+ if (confirmedAfterRefresh) {
3803
+ this.trace("deploy:relay-metadata-recovered-from-refresh", {
3804
+ itemHash: args.itemHash
3805
+ });
3806
+ return;
3807
+ }
3685
3808
  throw new Error("Relay metadata did not include a peer ID and public multiaddrs.");
3686
3809
  }
3687
3810
  const { peerId } = relayMetadata;
@@ -3790,6 +3913,7 @@ var SponsorRelayController = class {
3790
3913
  }
3791
3914
  }
3792
3915
  if (profile === "uc-go-peer" && args.deploymentToken) {
3916
+ let cleanupAggregateItemHash = null;
3793
3917
  await deleteVmBootstrapConfig({
3794
3918
  sender: this.state.wallet.address,
3795
3919
  deploymentToken: args.deploymentToken,
@@ -3798,6 +3922,14 @@ var SponsorRelayController = class {
3798
3922
  fetch: asJsonFetch,
3799
3923
  apiHost: this.client.apiHost,
3800
3924
  sync: true
3925
+ }).then((cleanupResult) => {
3926
+ cleanupAggregateItemHash = cleanupResult.aggregateItemHash;
3927
+ this.trace("deploy:bootstrap-config-cleanup-complete", {
3928
+ itemHash: args.itemHash,
3929
+ deploymentToken: args.deploymentToken,
3930
+ aggregateItemHash: cleanupResult.aggregateItemHash,
3931
+ aggregateStatus: cleanupResult.aggregateStatus
3932
+ });
3801
3933
  }).catch((error) => {
3802
3934
  this.trace("deploy:bootstrap-config-cleanup-error", {
3803
3935
  itemHash: args.itemHash,
@@ -3805,8 +3937,75 @@ var SponsorRelayController = class {
3805
3937
  error: error instanceof Error ? error.message : String(error)
3806
3938
  });
3807
3939
  });
3940
+ await this.cleanupStaleVmBootstrapConfigHistory({
3941
+ itemHash: args.itemHash,
3942
+ currentAggregateItemHash: cleanupAggregateItemHash,
3943
+ stage: "publishing-bootstrap",
3944
+ progress: 99
3945
+ });
3808
3946
  }
3809
3947
  }
3948
+ async cleanupStaleVmBootstrapConfigHistory(args) {
3949
+ if (!this.state.wallet.address || !args.currentAggregateItemHash) {
3950
+ return;
3951
+ }
3952
+ const staleHashes = await listStaleVmBootstrapConfigAggregateMessageHashes({
3953
+ address: this.state.wallet.address,
3954
+ currentAggregateItemHash: args.currentAggregateItemHash,
3955
+ olderThanMs: VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS,
3956
+ fetch: asJsonFetch,
3957
+ apiHost: this.client.apiHost
3958
+ }).catch((error) => {
3959
+ this.trace("deploy:bootstrap-config-history-scan-error", {
3960
+ itemHash: args.itemHash,
3961
+ currentAggregateItemHash: args.currentAggregateItemHash,
3962
+ error: error instanceof Error ? error.message : String(error)
3963
+ });
3964
+ return [];
3965
+ });
3966
+ if (staleHashes.length === 0) {
3967
+ return;
3968
+ }
3969
+ const staleCount = staleHashes.length;
3970
+ this.emitProgress({
3971
+ stage: args.stage,
3972
+ label: "Cleaning up stale handoff history",
3973
+ progress: args.progress,
3974
+ status: "warning",
3975
+ itemHash: args.itemHash,
3976
+ detail: `Forgetting ${staleCount} superseded Aleph handoff aggregate message${staleCount === 1 ? "" : "s"} older than 6 hours. MetaMask may request another cleanup signature.`
3977
+ });
3978
+ this.trace("deploy:bootstrap-config-history-cleanup-start", {
3979
+ itemHash: args.itemHash,
3980
+ currentAggregateItemHash: args.currentAggregateItemHash,
3981
+ staleHashes
3982
+ });
3983
+ await forgetAlephMessages({
3984
+ sender: this.state.wallet.address,
3985
+ hashes: staleHashes,
3986
+ reason: "Prune stale vm-bootstrap-config aggregate history older than 6 hours",
3987
+ signer: personalSign,
3988
+ hasher: sha256Hex,
3989
+ fetch: asJsonFetch,
3990
+ apiHost: this.client.apiHost,
3991
+ sync: true
3992
+ }).then((cleanupResult) => {
3993
+ this.trace("deploy:bootstrap-config-history-cleanup-complete", {
3994
+ itemHash: args.itemHash,
3995
+ currentAggregateItemHash: args.currentAggregateItemHash,
3996
+ staleHashes,
3997
+ forgetItemHash: cleanupResult.itemHash,
3998
+ forgetStatus: cleanupResult.status
3999
+ });
4000
+ }).catch((error) => {
4001
+ this.trace("deploy:bootstrap-config-history-cleanup-error", {
4002
+ itemHash: args.itemHash,
4003
+ currentAggregateItemHash: args.currentAggregateItemHash,
4004
+ staleHashes,
4005
+ error: error instanceof Error ? error.message : String(error)
4006
+ });
4007
+ });
4008
+ }
3810
4009
  syncLatestDeploymentProgress(instances) {
3811
4010
  const itemHash = this.state.lastDeploymentHash;
3812
4011
  if (!itemHash) {
@@ -3882,6 +4081,20 @@ var SponsorRelayController = class {
3882
4081
  });
3883
4082
  return;
3884
4083
  }
4084
+ const hasConfirmedBootstrapRegistration = this.state.bootstrapRegistrations.some(
4085
+ (entry) => entry.instanceItemHash === itemHash && entry.confirmed
4086
+ );
4087
+ if (hasConfirmedBootstrapRegistration) {
4088
+ this.emitProgress({
4089
+ stage: "completed",
4090
+ label: "Relay ready",
4091
+ progress: 100,
4092
+ status: "success",
4093
+ itemHash,
4094
+ detail: "Runtime networking is available and the relay bootstrap registration is confirmed on Aleph."
4095
+ });
4096
+ return;
4097
+ }
3885
4098
  this.emitProgress({
3886
4099
  stage: "deployment-confirmed",
3887
4100
  label: "Runtime ready",
@@ -4182,7 +4395,11 @@ var SponsorRelayController = class {
4182
4395
  instances.map((entry) => entry.instance.item_hash)
4183
4396
  );
4184
4397
  bootstrapRegistrations = selectCurrentRelayBootstrapPosts(rawBootstrapPosts).filter(
4185
- (entry) => entry.address?.toLowerCase() === normalizedWalletAddress
4398
+ (entry) => relayBootstrapMatchesCurrentWallet(
4399
+ entry,
4400
+ normalizedWalletAddress,
4401
+ activeInstanceHashes
4402
+ )
4186
4403
  ).map((entry) => {
4187
4404
  const instanceItemHash = relayBootstrapInstanceItemHash(entry);
4188
4405
  const messageHash = entry.itemHash ?? entry.hash ?? null;
@@ -4201,6 +4418,8 @@ var SponsorRelayController = class {
4201
4418
  registrationId: entry.content.registrationId,
4202
4419
  multiaddrs: Array.isArray(entry.content.multiaddrs) ? entry.content.multiaddrs : [],
4203
4420
  browserMultiaddrs: Array.isArray(entry.content.browserMultiaddrs) ? entry.content.browserMultiaddrs : [],
4421
+ ownerAddress: typeof entry.content.ownerAddress === "string" ? entry.content.ownerAddress : void 0,
4422
+ publisherAddress: typeof entry.content.publisherAddress === "string" ? entry.content.publisherAddress : void 0,
4204
4423
  updatedAt: entry.content.updatedAt
4205
4424
  } : null
4206
4425
  };
@@ -4272,9 +4491,9 @@ var SponsorRelayController = class {
4272
4491
  this.patch({ errorText: "Connect MetaMask before deploying." });
4273
4492
  return;
4274
4493
  }
4275
- if (!this.state.manifest || !this.state.rootfsVerified || !this.state.pricingSummary.pricing || !this.state.pricingSummary.tier) {
4494
+ if (!this.state.manifest || this.state.rootfsHealth.tone !== "ok" || !this.state.pricingSummary.pricing || !this.state.pricingSummary.tier) {
4276
4495
  this.patch({
4277
- errorText: "Manifest, rootfs, and pricing must be ready before deploying."
4496
+ errorText: this.state.rootfsHealth.tone === "error" ? `Cannot deploy: ${this.state.rootfsHealth.detail ?? this.state.rootfsHealth.label}` : "Manifest, rootfs, and pricing must be ready before deploying."
4278
4497
  });
4279
4498
  return;
4280
4499
  }
@@ -4316,6 +4535,7 @@ var SponsorRelayController = class {
4316
4535
  for (const [candidateIndex, candidateCrn] of orderedCrns.entries()) {
4317
4536
  let attemptItemHash = null;
4318
4537
  let attemptDeploymentToken = null;
4538
+ let attemptProfile = null;
4319
4539
  const crnAttemptLabel = `CRN ${candidateIndex + 1}/${orderedCrns.length}`;
4320
4540
  const crnDisplayName = candidateCrn.name ?? candidateCrn.hash;
4321
4541
  this.emitProgress({
@@ -4327,6 +4547,7 @@ var SponsorRelayController = class {
4327
4547
  });
4328
4548
  try {
4329
4549
  const profile = relayProfileForManifest(this.state.manifest);
4550
+ attemptProfile = profile;
4330
4551
  const sshPublicKey = profile === "uc-go-peer" ? appendDeploymentTokenToSshPublicKey(
4331
4552
  this.state.sshPublicKey.trim(),
4332
4553
  this.state.wallet.address,
@@ -4562,6 +4783,106 @@ var SponsorRelayController = class {
4562
4783
  itemHash: attemptItemHash,
4563
4784
  error: lastError.message
4564
4785
  });
4786
+ if (attemptItemHash && attemptDeploymentToken && attemptProfile && !failedDueToInvalidIpv6) {
4787
+ try {
4788
+ const latestRuntime = await fetchVmRuntime({
4789
+ itemHash: attemptItemHash,
4790
+ fetch: (url, init) => fetch(url, init),
4791
+ crnHash: candidateCrn.hash,
4792
+ crns: this.state.crns,
4793
+ crnListUrl: this.props.crnListUrl,
4794
+ requirePublicGuestIpv6ForProxy: true
4795
+ });
4796
+ if (latestRuntime.diagnostics?.state === "ready") {
4797
+ const reconciledRelayMetadata = await fetchRelayMetadataForRuntime({
4798
+ runtime: latestRuntime,
4799
+ fetch: (url, init) => fetch(url, init),
4800
+ preferSecureMetadata: attemptProfile === "uc-go-peer" && Boolean(latestRuntime.proxyUrl),
4801
+ attempts: 3,
4802
+ delayMs: 1e3,
4803
+ timeoutMs: 15e3
4804
+ }).catch(() => null);
4805
+ if (reconciledRelayMetadata) {
4806
+ const registrationId = createRelayBootstrapRegistrationId(
4807
+ attemptProfile,
4808
+ this.state.instanceName.trim(),
4809
+ attemptItemHash
4810
+ );
4811
+ const visibleRegistration = await waitForRelayBootstrapRegistration({
4812
+ registrationId,
4813
+ peerId: reconciledRelayMetadata.peerId,
4814
+ fetch: asJsonFetch,
4815
+ apiHost: this.client.apiHost,
4816
+ attempts: 3,
4817
+ delayMs: 1e3
4818
+ }).catch(() => null);
4819
+ if (visibleRegistration) {
4820
+ this.trace("deploy:recovered-after-error", {
4821
+ itemHash: attemptItemHash,
4822
+ registrationId,
4823
+ peerId: reconciledRelayMetadata.peerId,
4824
+ error: lastError.message
4825
+ });
4826
+ this.rememberRuntimeDetails(attemptItemHash, {
4827
+ messageStatus: "processed",
4828
+ allocationSource: latestRuntime.allocation?.source ?? null,
4829
+ crnUrl: latestRuntime.allocation?.crnUrl ?? latestRuntime.execution?.crnUrl ?? null,
4830
+ hostIpv4: latestRuntime.hostIpv4 ?? null,
4831
+ ipv6: latestRuntime.ipv6 ?? null,
4832
+ vmIpv4: latestRuntime.execution?.networking?.ipv4_ip ?? null,
4833
+ webUrl: latestRuntime.webAccessUrl ?? latestRuntime.proxyUrl ?? latestRuntime.execution?.networking?.proxy_url ?? null,
4834
+ sshCommand: latestRuntime.sshCommand ?? null,
4835
+ mappedPorts: latestRuntime.execution != null ? mappedPorts(latestRuntime.execution) : runtimeMappedPorts(latestRuntime.mappedPorts),
4836
+ execution: latestRuntime.execution ?? null,
4837
+ error: null
4838
+ });
4839
+ this.patch({
4840
+ busy: { deploying: false }
4841
+ });
4842
+ await this.refresh();
4843
+ this.lastCompletedDeploymentHash = attemptItemHash;
4844
+ this.emitProgress({
4845
+ stage: "completed",
4846
+ label: "Relay ready",
4847
+ progress: 100,
4848
+ status: "success",
4849
+ itemHash: attemptItemHash,
4850
+ detail: "The relay completed startup after a transient UI polling error. Runtime and bootstrap registration are confirmed."
4851
+ });
4852
+ return;
4853
+ }
4854
+ }
4855
+ }
4856
+ } catch (reconcileError) {
4857
+ this.trace("deploy:recovery-check-error", {
4858
+ itemHash: attemptItemHash,
4859
+ error: reconcileError instanceof Error ? reconcileError.message : String(reconcileError)
4860
+ });
4861
+ }
4862
+ await this.refresh().catch(() => void 0);
4863
+ const recoveredRegistration = this.state.bootstrapRegistrations.some(
4864
+ (entry) => entry.instanceItemHash === attemptItemHash && entry.confirmed
4865
+ );
4866
+ if (recoveredRegistration) {
4867
+ this.trace("deploy:recovered-from-refresh-state", {
4868
+ itemHash: attemptItemHash,
4869
+ error: lastError.message
4870
+ });
4871
+ this.patch({
4872
+ busy: { deploying: false }
4873
+ });
4874
+ this.lastCompletedDeploymentHash = attemptItemHash;
4875
+ this.emitProgress({
4876
+ stage: "completed",
4877
+ label: "Relay ready",
4878
+ progress: 100,
4879
+ status: "success",
4880
+ itemHash: attemptItemHash,
4881
+ detail: "The relay completed startup after a transient browser-side polling error. Runtime and bootstrap registration are confirmed."
4882
+ });
4883
+ return;
4884
+ }
4885
+ }
4565
4886
  if (attemptItemHash) {
4566
4887
  this.emitProgress({
4567
4888
  stage: "deployment-confirmed",
@@ -4572,6 +4893,7 @@ var SponsorRelayController = class {
4572
4893
  detail: failedDueToInvalidIpv6 ? `${attemptLabel} exposed runtime networking that cannot support proxy-backed HTTPS. Cleaning up this attempt before retrying another CRN.` : `${attemptLabel} failed: ${lastError.message} Cleaning up this attempt before retrying another CRN.`
4573
4894
  });
4574
4895
  if (attemptDeploymentToken) {
4896
+ let cleanupAggregateItemHash = null;
4575
4897
  this.emitProgress({
4576
4898
  stage: "deployment-confirmed",
4577
4899
  label: "Cleaning up failed attempt",
@@ -4593,6 +4915,7 @@ var SponsorRelayController = class {
4593
4915
  apiHost: this.client.apiHost,
4594
4916
  sync: true
4595
4917
  }).then((cleanupResult) => {
4918
+ cleanupAggregateItemHash = cleanupResult.aggregateItemHash;
4596
4919
  this.trace("deploy:bootstrap-config-cleanup-complete", {
4597
4920
  itemHash: attemptItemHash,
4598
4921
  deploymentToken: attemptDeploymentToken,
@@ -4606,6 +4929,12 @@ var SponsorRelayController = class {
4606
4929
  error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
4607
4930
  });
4608
4931
  });
4932
+ await this.cleanupStaleVmBootstrapConfigHistory({
4933
+ itemHash: attemptItemHash,
4934
+ currentAggregateItemHash: cleanupAggregateItemHash,
4935
+ stage: "deployment-confirmed",
4936
+ progress: 18
4937
+ });
4609
4938
  }
4610
4939
  this.emitProgress({
4611
4940
  stage: "deployment-confirmed",
@@ -5709,6 +6038,7 @@ function SponsorRelayFab(props) {
5709
6038
  {
5710
6039
  type: "button",
5711
6040
  onClick: () => void (state.wallet.connected ? controller.deploy() : controller.connectWallet()),
6041
+ disabled: state.wallet.connected ? state.busy.deploying || state.rootfsHealth.tone !== "ok" : state.busy.connectingWallet,
5712
6042
  style: { ...primaryButtonStyle, width: "100%", marginTop: "1rem" },
5713
6043
  children: state.wallet.connected ? state.busy.deploying ? "Deploying\u2026" : "Deploy Relay" : "Connect MetaMask"
5714
6044
  }
package/shared/index.d.ts CHANGED
@@ -254,6 +254,8 @@ interface CompactBootstrapRegistrationRecord {
254
254
  registrationId?: string;
255
255
  multiaddrs: string[];
256
256
  browserMultiaddrs?: string[];
257
+ ownerAddress?: string;
258
+ publisherAddress?: string;
257
259
  updatedAt: number;
258
260
  } | null;
259
261
  }
@@ -309,7 +311,7 @@ declare const STALE_INSTANCE_ALLOCATION_COOLDOWN_MS: number;
309
311
  declare const RECENT_INSTANCE_RUNTIME_GRACE_MS: number;
310
312
  declare const DEPLOYMENT_PENDING_WARNING_MS: number;
311
313
 
312
- declare const UI_PACKAGE_VERSION = "0.4.2";
314
+ declare const UI_PACKAGE_VERSION = "0.4.4";
313
315
 
314
316
  interface RootfsRequiredPortForward {
315
317
  port: number;
@@ -368,6 +370,7 @@ declare class SponsorRelayController {
368
370
  private patch;
369
371
  private emitProgress;
370
372
  private configureRelayBootstrapRegistration;
373
+ private cleanupStaleVmBootstrapConfigHistory;
371
374
  private syncLatestDeploymentProgress;
372
375
  private canSkipRuntimeRefresh;
373
376
  private noteRuntimeRefreshResult;
package/shared/index.js CHANGED
@@ -27,7 +27,7 @@ var RECENT_INSTANCE_RUNTIME_GRACE_MS = 10 * 60 * 1e3;
27
27
  var DEPLOYMENT_PENDING_WARNING_MS = 3 * 60 * 1e3;
28
28
 
29
29
  // src/shared/package-version.ts
30
- var UI_PACKAGE_VERSION = "0.4.2";
30
+ var UI_PACKAGE_VERSION = "0.4.4";
31
31
 
32
32
  // src/shared/format.ts
33
33
  function shortHash(value, head = 8, tail = 6) {
@@ -1740,14 +1740,15 @@ function describeRuntimeAvailability(runtime) {
1740
1740
  const mappedPorts2 = runtime.mappedPorts ?? {};
1741
1741
  const hostIpv4 = runtime.hostIpv4 ?? null;
1742
1742
  const ipv6 = runtime.ipv6 ?? null;
1743
+ const executionGuestIpv6 = execution?.networking?.ipv6_ip ?? null;
1743
1744
  const proxyUrl = runtime.proxyUrl ?? null;
1744
1745
  const schedulerSource = runtime.allocation?.source ?? null;
1745
1746
  const webAccessActive = runtime.webAccess?.active ?? null;
1746
1747
  const mappedPortCount = Object.keys(mappedPorts2).length;
1747
- if (hostIpv4 && mappedPortCount > 0 && runtime.requirePublicGuestIpv6ForProxy === true && proxyUrl && !isLikelyGlobalIpv6(ipv6)) {
1748
+ if (hostIpv4 && mappedPortCount > 0 && runtime.requirePublicGuestIpv6ForProxy === true && proxyUrl && executionGuestIpv6 && !isLikelyGlobalIpv6(executionGuestIpv6)) {
1748
1749
  return {
1749
1750
  state: "execution-invalid-public-ipv6",
1750
- reason: `The CRN exposed runtime networking, but the guest IPv6${ipv6 ? ` (${ipv6})` : ""} is not globally routable. Proxy-backed HTTPS activation cannot succeed until the CRN reports a public guest IPv6.`,
1751
+ reason: `The CRN exposed runtime networking, but the guest IPv6 (${executionGuestIpv6}) is not globally routable. Proxy-backed HTTPS activation cannot succeed until the CRN reports a public guest IPv6.`,
1751
1752
  schedulerSource,
1752
1753
  executionSeen: Boolean(execution),
1753
1754
  webAccessActive,
@@ -2697,6 +2698,7 @@ async function publishRelayBootstrapRegistration(args) {
2697
2698
  var VM_BOOTSTRAP_CONFIG_AGGREGATE_KEY = "vm-bootstrap-config";
2698
2699
  var VM_BOOTSTRAP_CONFIG_SIGNAL_REF = "vm-bootstrap-config";
2699
2700
  var VM_BOOTSTRAP_CONFIG_SIGNAL_POST_TYPE = "vm-bootstrap-config-status";
2701
+ var VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS = 6 * 60 * 60 * 1e3;
2700
2702
  function parseJsonObject(value) {
2701
2703
  try {
2702
2704
  const parsed = JSON.parse(value);
@@ -2705,6 +2707,44 @@ function parseJsonObject(value) {
2705
2707
  return null;
2706
2708
  }
2707
2709
  }
2710
+ function parseTimestampMs(value) {
2711
+ if (typeof value === "number" && Number.isFinite(value)) {
2712
+ return value > 1e12 ? value : value * 1e3;
2713
+ }
2714
+ if (typeof value === "string" && value.trim()) {
2715
+ const numeric = Number(value);
2716
+ if (Number.isFinite(numeric)) {
2717
+ return numeric > 1e12 ? numeric : numeric * 1e3;
2718
+ }
2719
+ const parsed = Date.parse(value);
2720
+ return Number.isFinite(parsed) ? parsed : null;
2721
+ }
2722
+ return null;
2723
+ }
2724
+ function normalizeVmBootstrapConfigRecord(deploymentTokenKey, value, options = {}) {
2725
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2726
+ const candidate = value;
2727
+ const deploymentToken = typeof candidate.deploymentToken === "string" ? candidate.deploymentToken.trim() : "";
2728
+ const profile = typeof candidate.profile === "string" ? candidate.profile.trim() : "";
2729
+ const ownerAddress = typeof candidate.ownerAddress === "string" ? candidate.ownerAddress.trim() : "";
2730
+ const instanceItemHash = typeof candidate.instanceItemHash === "string" ? candidate.instanceItemHash.trim() : "";
2731
+ const createdAt = typeof candidate.createdAt === "string" ? candidate.createdAt.trim() : "";
2732
+ const expiresAt = typeof candidate.expiresAt === "string" ? candidate.expiresAt.trim() : "";
2733
+ if (!deploymentTokenKey.trim() || !deploymentToken || deploymentToken !== deploymentTokenKey.trim() || !profile || !ownerAddress || !instanceItemHash || !createdAt || !expiresAt) {
2734
+ return null;
2735
+ }
2736
+ const nowMs = Math.max(0, Number(options.nowMs ?? Date.now()));
2737
+ const maxRecordAgeMs = Math.max(
2738
+ 6e4,
2739
+ Number(options.maxRecordAgeMs ?? VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS)
2740
+ );
2741
+ const createdAtMs = parseTimestampMs(createdAt);
2742
+ const expiresAtMs = parseTimestampMs(expiresAt);
2743
+ if (createdAtMs == null || expiresAtMs == null) return null;
2744
+ if (expiresAtMs <= nowMs) return null;
2745
+ if (createdAtMs < nowMs - maxRecordAgeMs) return null;
2746
+ return candidate;
2747
+ }
2708
2748
  async function fetchVmBootstrapConfigAggregate(address, options) {
2709
2749
  const requestUrl = new URL(`/api/v0/aggregates/${address}.json`, options.apiHost ?? DEFAULT_ALEPH_API_HOST2);
2710
2750
  requestUrl.searchParams.set("keys", VM_BOOTSTRAP_CONFIG_AGGREGATE_KEY);
@@ -2718,16 +2758,21 @@ async function fetchVmBootstrapConfigAggregate(address, options) {
2718
2758
  if (!aggregate || typeof aggregate !== "object" || Array.isArray(aggregate)) return {};
2719
2759
  return aggregate;
2720
2760
  }
2721
- function normalizeVmBootstrapConfigAggregate(aggregate) {
2761
+ function normalizeVmBootstrapConfigAggregate(aggregate, options = {}) {
2722
2762
  if (!aggregate || typeof aggregate !== "object") return {};
2723
2763
  return Object.fromEntries(
2724
- Object.entries(aggregate).filter(
2725
- (entry) => Boolean(entry[1] && typeof entry[1] === "object" && !Array.isArray(entry[1]))
2726
- )
2764
+ Object.entries(aggregate).map(([deploymentToken, record]) => [
2765
+ deploymentToken,
2766
+ normalizeVmBootstrapConfigRecord(deploymentToken, record, options)
2767
+ ]).filter((entry) => entry[1] != null)
2727
2768
  );
2728
2769
  }
2729
2770
  function createVmBootstrapConfigAggregateContent(args) {
2730
- const content = normalizeVmBootstrapConfigAggregate(args.existingAggregate);
2771
+ const nowMs = Math.max(0, Number(args.now ?? Date.now() / 1e3) * 1e3);
2772
+ const content = normalizeVmBootstrapConfigAggregate(args.existingAggregate, {
2773
+ nowMs,
2774
+ maxRecordAgeMs: args.maxRecordAgeMs
2775
+ });
2731
2776
  content[args.record.deploymentToken] = args.record;
2732
2777
  return {
2733
2778
  address: args.sender,
@@ -2772,6 +2817,55 @@ async function publishVmBootstrapConfigAggregate(args) {
2772
2817
  aggregateStatus
2773
2818
  };
2774
2819
  }
2820
+ function normalizeAggregateMessageHashRecord(value) {
2821
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2822
+ const candidate = value;
2823
+ const itemHash = typeof candidate.item_hash === "string" ? candidate.item_hash.trim() : "";
2824
+ if (!itemHash) return null;
2825
+ const content = candidate.content && typeof candidate.content === "object" && !Array.isArray(candidate.content) ? candidate.content : typeof candidate.item_content === "string" ? parseJsonObject(candidate.item_content) : null;
2826
+ if (!content || content.key !== VM_BOOTSTRAP_CONFIG_AGGREGATE_KEY) return null;
2827
+ const timeMs = parseTimestampMs(candidate.time);
2828
+ if (timeMs == null) return null;
2829
+ return { itemHash, timeMs };
2830
+ }
2831
+ async function listStaleVmBootstrapConfigAggregateMessageHashes(args) {
2832
+ const address = args.address.trim();
2833
+ if (!address) return [];
2834
+ const currentAggregateItemHash = args.currentAggregateItemHash?.trim() ?? "";
2835
+ const olderThanMs = Math.max(
2836
+ 6e4,
2837
+ Number(args.olderThanMs ?? VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS)
2838
+ );
2839
+ const cutoffMs = Math.max(0, Number(args.nowMs ?? Date.now()) - olderThanMs);
2840
+ const pagination = Math.max(1, Number(args.pagination ?? 100));
2841
+ const maxPages = Math.max(1, Number(args.maxPages ?? 10));
2842
+ const hashes = /* @__PURE__ */ new Set();
2843
+ for (let page = 1; page <= maxPages; page += 1) {
2844
+ const requestUrl = new URL("/api/v0/messages.json", args.apiHost ?? DEFAULT_ALEPH_API_HOST2);
2845
+ requestUrl.searchParams.set("msgType", "AGGREGATE");
2846
+ requestUrl.searchParams.set("addresses", address);
2847
+ requestUrl.searchParams.set("message_statuses", "processed,pending,rejected,removing");
2848
+ requestUrl.searchParams.set("pagination", String(pagination));
2849
+ requestUrl.searchParams.set("page", String(page));
2850
+ requestUrl.searchParams.set("sortOrder", "-1");
2851
+ const response = await args.fetch(requestUrl.toString(), { cache: "no-cache" });
2852
+ if (!response.ok) {
2853
+ throw new Error(`VM bootstrap config aggregate history request failed: ${response.status}`);
2854
+ }
2855
+ const payload = await response.json();
2856
+ const messages = Array.isArray(payload.messages) ? payload.messages : [];
2857
+ if (messages.length === 0) break;
2858
+ for (const message of messages) {
2859
+ const normalized = normalizeAggregateMessageHashRecord(message);
2860
+ if (!normalized) continue;
2861
+ if (normalized.itemHash === currentAggregateItemHash) continue;
2862
+ if (normalized.timeMs > cutoffMs) continue;
2863
+ hashes.add(normalized.itemHash);
2864
+ }
2865
+ if (messages.length < pagination) break;
2866
+ }
2867
+ return [...hashes];
2868
+ }
2775
2869
  async function publishVmBootstrapConfig(args) {
2776
2870
  const aggregate = await fetchVmBootstrapConfigAggregate(args.sender, {
2777
2871
  apiHost: args.apiHost,
@@ -2780,7 +2874,8 @@ async function publishVmBootstrapConfig(args) {
2780
2874
  const content = createVmBootstrapConfigAggregateContent({
2781
2875
  sender: args.sender,
2782
2876
  record: args.record,
2783
- existingAggregate: aggregate
2877
+ existingAggregate: aggregate,
2878
+ maxRecordAgeMs: VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS
2784
2879
  });
2785
2880
  const result = await publishVmBootstrapConfigAggregate({
2786
2881
  sender: args.sender,
@@ -2802,7 +2897,9 @@ async function deleteVmBootstrapConfig(args) {
2802
2897
  apiHost: args.apiHost,
2803
2898
  fetch: args.fetch
2804
2899
  });
2805
- const content = normalizeVmBootstrapConfigAggregate(aggregate);
2900
+ const content = normalizeVmBootstrapConfigAggregate(aggregate, {
2901
+ maxRecordAgeMs: VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS
2902
+ });
2806
2903
  delete content[args.deploymentToken];
2807
2904
  const publishResult = await publishVmBootstrapConfigAggregate({
2808
2905
  sender: args.sender,
@@ -3103,7 +3200,7 @@ function rootfsHealth(args) {
3103
3200
  return {
3104
3201
  tone: "error",
3105
3202
  label: "not found on Aleph",
3106
- detail: "The referenced rootfs STORE message is not available yet."
3203
+ detail: "The manifest points to a rootfs STORE message that Aleph cannot find. Check that the manifest URL is correct and that the referenced rootfs was not forgotten."
3107
3204
  };
3108
3205
  }
3109
3206
  if (!args.resolution) {
@@ -3136,8 +3233,8 @@ function rootfsHealth(args) {
3136
3233
  }
3137
3234
  return {
3138
3235
  tone: "error",
3139
- label: "not deployable",
3140
- detail: args.resolution.rejectionReason ?? "Aleph rejected the rootfs reference."
3236
+ label: "manifest rootfs not deployable",
3237
+ detail: args.resolution.rejectionReason ?? (args.resolution.messageType === "STORE" ? "This manifest points to a rootfs STORE message that exists but is not deployable anymore, for example because it was forgotten or replaced. Double-check the manifest URL." : "Aleph rejected the rootfs reference. Double-check that the manifest URL points to the intended current rootfs.")
3141
3238
  };
3142
3239
  }
3143
3240
  async function resolveManifest(args) {
@@ -3166,6 +3263,22 @@ function relayBootstrapInstanceItemHash(entry) {
3166
3263
  }
3167
3264
  return registrationIdInstanceItemHash(entry?.content?.registrationId);
3168
3265
  }
3266
+ function relayBootstrapMatchesCurrentWallet(entry, normalizedWalletAddress, activeInstanceHashes) {
3267
+ const instanceItemHash = relayBootstrapInstanceItemHash(entry);
3268
+ if (instanceItemHash && activeInstanceHashes.has(instanceItemHash)) {
3269
+ return true;
3270
+ }
3271
+ const candidateAddresses = [
3272
+ entry?.address,
3273
+ entry?.content?.ownerAddress,
3274
+ entry?.content?.authorization?.payload?.ownerAddress,
3275
+ entry?.content?.publisherAddress,
3276
+ entry?.content?.authorization?.payload?.publisherAddress
3277
+ ];
3278
+ return candidateAddresses.some(
3279
+ (value) => typeof value === "string" && value.trim().toLowerCase() === normalizedWalletAddress
3280
+ );
3281
+ }
3169
3282
  async function inspectInstanceRuntime(args) {
3170
3283
  const details = {
3171
3284
  messageStatus: String(
@@ -3681,6 +3794,16 @@ var SponsorRelayController = class {
3681
3794
  });
3682
3795
  }
3683
3796
  if (!relayMetadata) {
3797
+ await this.refresh().catch(() => void 0);
3798
+ const confirmedAfterRefresh = this.state.bootstrapRegistrations.some(
3799
+ (entry) => entry.instanceItemHash === args.itemHash && entry.confirmed
3800
+ );
3801
+ if (confirmedAfterRefresh) {
3802
+ this.trace("deploy:relay-metadata-recovered-from-refresh", {
3803
+ itemHash: args.itemHash
3804
+ });
3805
+ return;
3806
+ }
3684
3807
  throw new Error("Relay metadata did not include a peer ID and public multiaddrs.");
3685
3808
  }
3686
3809
  const { peerId } = relayMetadata;
@@ -3789,6 +3912,7 @@ var SponsorRelayController = class {
3789
3912
  }
3790
3913
  }
3791
3914
  if (profile === "uc-go-peer" && args.deploymentToken) {
3915
+ let cleanupAggregateItemHash = null;
3792
3916
  await deleteVmBootstrapConfig({
3793
3917
  sender: this.state.wallet.address,
3794
3918
  deploymentToken: args.deploymentToken,
@@ -3797,6 +3921,14 @@ var SponsorRelayController = class {
3797
3921
  fetch: asJsonFetch,
3798
3922
  apiHost: this.client.apiHost,
3799
3923
  sync: true
3924
+ }).then((cleanupResult) => {
3925
+ cleanupAggregateItemHash = cleanupResult.aggregateItemHash;
3926
+ this.trace("deploy:bootstrap-config-cleanup-complete", {
3927
+ itemHash: args.itemHash,
3928
+ deploymentToken: args.deploymentToken,
3929
+ aggregateItemHash: cleanupResult.aggregateItemHash,
3930
+ aggregateStatus: cleanupResult.aggregateStatus
3931
+ });
3800
3932
  }).catch((error) => {
3801
3933
  this.trace("deploy:bootstrap-config-cleanup-error", {
3802
3934
  itemHash: args.itemHash,
@@ -3804,8 +3936,75 @@ var SponsorRelayController = class {
3804
3936
  error: error instanceof Error ? error.message : String(error)
3805
3937
  });
3806
3938
  });
3939
+ await this.cleanupStaleVmBootstrapConfigHistory({
3940
+ itemHash: args.itemHash,
3941
+ currentAggregateItemHash: cleanupAggregateItemHash,
3942
+ stage: "publishing-bootstrap",
3943
+ progress: 99
3944
+ });
3807
3945
  }
3808
3946
  }
3947
+ async cleanupStaleVmBootstrapConfigHistory(args) {
3948
+ if (!this.state.wallet.address || !args.currentAggregateItemHash) {
3949
+ return;
3950
+ }
3951
+ const staleHashes = await listStaleVmBootstrapConfigAggregateMessageHashes({
3952
+ address: this.state.wallet.address,
3953
+ currentAggregateItemHash: args.currentAggregateItemHash,
3954
+ olderThanMs: VM_BOOTSTRAP_CONFIG_HISTORY_RETENTION_MS,
3955
+ fetch: asJsonFetch,
3956
+ apiHost: this.client.apiHost
3957
+ }).catch((error) => {
3958
+ this.trace("deploy:bootstrap-config-history-scan-error", {
3959
+ itemHash: args.itemHash,
3960
+ currentAggregateItemHash: args.currentAggregateItemHash,
3961
+ error: error instanceof Error ? error.message : String(error)
3962
+ });
3963
+ return [];
3964
+ });
3965
+ if (staleHashes.length === 0) {
3966
+ return;
3967
+ }
3968
+ const staleCount = staleHashes.length;
3969
+ this.emitProgress({
3970
+ stage: args.stage,
3971
+ label: "Cleaning up stale handoff history",
3972
+ progress: args.progress,
3973
+ status: "warning",
3974
+ itemHash: args.itemHash,
3975
+ detail: `Forgetting ${staleCount} superseded Aleph handoff aggregate message${staleCount === 1 ? "" : "s"} older than 6 hours. MetaMask may request another cleanup signature.`
3976
+ });
3977
+ this.trace("deploy:bootstrap-config-history-cleanup-start", {
3978
+ itemHash: args.itemHash,
3979
+ currentAggregateItemHash: args.currentAggregateItemHash,
3980
+ staleHashes
3981
+ });
3982
+ await forgetAlephMessages({
3983
+ sender: this.state.wallet.address,
3984
+ hashes: staleHashes,
3985
+ reason: "Prune stale vm-bootstrap-config aggregate history older than 6 hours",
3986
+ signer: personalSign,
3987
+ hasher: sha256Hex,
3988
+ fetch: asJsonFetch,
3989
+ apiHost: this.client.apiHost,
3990
+ sync: true
3991
+ }).then((cleanupResult) => {
3992
+ this.trace("deploy:bootstrap-config-history-cleanup-complete", {
3993
+ itemHash: args.itemHash,
3994
+ currentAggregateItemHash: args.currentAggregateItemHash,
3995
+ staleHashes,
3996
+ forgetItemHash: cleanupResult.itemHash,
3997
+ forgetStatus: cleanupResult.status
3998
+ });
3999
+ }).catch((error) => {
4000
+ this.trace("deploy:bootstrap-config-history-cleanup-error", {
4001
+ itemHash: args.itemHash,
4002
+ currentAggregateItemHash: args.currentAggregateItemHash,
4003
+ staleHashes,
4004
+ error: error instanceof Error ? error.message : String(error)
4005
+ });
4006
+ });
4007
+ }
3809
4008
  syncLatestDeploymentProgress(instances) {
3810
4009
  const itemHash = this.state.lastDeploymentHash;
3811
4010
  if (!itemHash) {
@@ -3881,6 +4080,20 @@ var SponsorRelayController = class {
3881
4080
  });
3882
4081
  return;
3883
4082
  }
4083
+ const hasConfirmedBootstrapRegistration = this.state.bootstrapRegistrations.some(
4084
+ (entry) => entry.instanceItemHash === itemHash && entry.confirmed
4085
+ );
4086
+ if (hasConfirmedBootstrapRegistration) {
4087
+ this.emitProgress({
4088
+ stage: "completed",
4089
+ label: "Relay ready",
4090
+ progress: 100,
4091
+ status: "success",
4092
+ itemHash,
4093
+ detail: "Runtime networking is available and the relay bootstrap registration is confirmed on Aleph."
4094
+ });
4095
+ return;
4096
+ }
3884
4097
  this.emitProgress({
3885
4098
  stage: "deployment-confirmed",
3886
4099
  label: "Runtime ready",
@@ -4181,7 +4394,11 @@ var SponsorRelayController = class {
4181
4394
  instances.map((entry) => entry.instance.item_hash)
4182
4395
  );
4183
4396
  bootstrapRegistrations = selectCurrentRelayBootstrapPosts(rawBootstrapPosts).filter(
4184
- (entry) => entry.address?.toLowerCase() === normalizedWalletAddress
4397
+ (entry) => relayBootstrapMatchesCurrentWallet(
4398
+ entry,
4399
+ normalizedWalletAddress,
4400
+ activeInstanceHashes
4401
+ )
4185
4402
  ).map((entry) => {
4186
4403
  const instanceItemHash = relayBootstrapInstanceItemHash(entry);
4187
4404
  const messageHash = entry.itemHash ?? entry.hash ?? null;
@@ -4200,6 +4417,8 @@ var SponsorRelayController = class {
4200
4417
  registrationId: entry.content.registrationId,
4201
4418
  multiaddrs: Array.isArray(entry.content.multiaddrs) ? entry.content.multiaddrs : [],
4202
4419
  browserMultiaddrs: Array.isArray(entry.content.browserMultiaddrs) ? entry.content.browserMultiaddrs : [],
4420
+ ownerAddress: typeof entry.content.ownerAddress === "string" ? entry.content.ownerAddress : void 0,
4421
+ publisherAddress: typeof entry.content.publisherAddress === "string" ? entry.content.publisherAddress : void 0,
4203
4422
  updatedAt: entry.content.updatedAt
4204
4423
  } : null
4205
4424
  };
@@ -4271,9 +4490,9 @@ var SponsorRelayController = class {
4271
4490
  this.patch({ errorText: "Connect MetaMask before deploying." });
4272
4491
  return;
4273
4492
  }
4274
- if (!this.state.manifest || !this.state.rootfsVerified || !this.state.pricingSummary.pricing || !this.state.pricingSummary.tier) {
4493
+ if (!this.state.manifest || this.state.rootfsHealth.tone !== "ok" || !this.state.pricingSummary.pricing || !this.state.pricingSummary.tier) {
4275
4494
  this.patch({
4276
- errorText: "Manifest, rootfs, and pricing must be ready before deploying."
4495
+ errorText: this.state.rootfsHealth.tone === "error" ? `Cannot deploy: ${this.state.rootfsHealth.detail ?? this.state.rootfsHealth.label}` : "Manifest, rootfs, and pricing must be ready before deploying."
4277
4496
  });
4278
4497
  return;
4279
4498
  }
@@ -4315,6 +4534,7 @@ var SponsorRelayController = class {
4315
4534
  for (const [candidateIndex, candidateCrn] of orderedCrns.entries()) {
4316
4535
  let attemptItemHash = null;
4317
4536
  let attemptDeploymentToken = null;
4537
+ let attemptProfile = null;
4318
4538
  const crnAttemptLabel = `CRN ${candidateIndex + 1}/${orderedCrns.length}`;
4319
4539
  const crnDisplayName = candidateCrn.name ?? candidateCrn.hash;
4320
4540
  this.emitProgress({
@@ -4326,6 +4546,7 @@ var SponsorRelayController = class {
4326
4546
  });
4327
4547
  try {
4328
4548
  const profile = relayProfileForManifest(this.state.manifest);
4549
+ attemptProfile = profile;
4329
4550
  const sshPublicKey = profile === "uc-go-peer" ? appendDeploymentTokenToSshPublicKey(
4330
4551
  this.state.sshPublicKey.trim(),
4331
4552
  this.state.wallet.address,
@@ -4561,6 +4782,106 @@ var SponsorRelayController = class {
4561
4782
  itemHash: attemptItemHash,
4562
4783
  error: lastError.message
4563
4784
  });
4785
+ if (attemptItemHash && attemptDeploymentToken && attemptProfile && !failedDueToInvalidIpv6) {
4786
+ try {
4787
+ const latestRuntime = await fetchVmRuntime({
4788
+ itemHash: attemptItemHash,
4789
+ fetch: (url, init) => fetch(url, init),
4790
+ crnHash: candidateCrn.hash,
4791
+ crns: this.state.crns,
4792
+ crnListUrl: this.props.crnListUrl,
4793
+ requirePublicGuestIpv6ForProxy: true
4794
+ });
4795
+ if (latestRuntime.diagnostics?.state === "ready") {
4796
+ const reconciledRelayMetadata = await fetchRelayMetadataForRuntime({
4797
+ runtime: latestRuntime,
4798
+ fetch: (url, init) => fetch(url, init),
4799
+ preferSecureMetadata: attemptProfile === "uc-go-peer" && Boolean(latestRuntime.proxyUrl),
4800
+ attempts: 3,
4801
+ delayMs: 1e3,
4802
+ timeoutMs: 15e3
4803
+ }).catch(() => null);
4804
+ if (reconciledRelayMetadata) {
4805
+ const registrationId = createRelayBootstrapRegistrationId(
4806
+ attemptProfile,
4807
+ this.state.instanceName.trim(),
4808
+ attemptItemHash
4809
+ );
4810
+ const visibleRegistration = await waitForRelayBootstrapRegistration({
4811
+ registrationId,
4812
+ peerId: reconciledRelayMetadata.peerId,
4813
+ fetch: asJsonFetch,
4814
+ apiHost: this.client.apiHost,
4815
+ attempts: 3,
4816
+ delayMs: 1e3
4817
+ }).catch(() => null);
4818
+ if (visibleRegistration) {
4819
+ this.trace("deploy:recovered-after-error", {
4820
+ itemHash: attemptItemHash,
4821
+ registrationId,
4822
+ peerId: reconciledRelayMetadata.peerId,
4823
+ error: lastError.message
4824
+ });
4825
+ this.rememberRuntimeDetails(attemptItemHash, {
4826
+ messageStatus: "processed",
4827
+ allocationSource: latestRuntime.allocation?.source ?? null,
4828
+ crnUrl: latestRuntime.allocation?.crnUrl ?? latestRuntime.execution?.crnUrl ?? null,
4829
+ hostIpv4: latestRuntime.hostIpv4 ?? null,
4830
+ ipv6: latestRuntime.ipv6 ?? null,
4831
+ vmIpv4: latestRuntime.execution?.networking?.ipv4_ip ?? null,
4832
+ webUrl: latestRuntime.webAccessUrl ?? latestRuntime.proxyUrl ?? latestRuntime.execution?.networking?.proxy_url ?? null,
4833
+ sshCommand: latestRuntime.sshCommand ?? null,
4834
+ mappedPorts: latestRuntime.execution != null ? mappedPorts(latestRuntime.execution) : runtimeMappedPorts(latestRuntime.mappedPorts),
4835
+ execution: latestRuntime.execution ?? null,
4836
+ error: null
4837
+ });
4838
+ this.patch({
4839
+ busy: { deploying: false }
4840
+ });
4841
+ await this.refresh();
4842
+ this.lastCompletedDeploymentHash = attemptItemHash;
4843
+ this.emitProgress({
4844
+ stage: "completed",
4845
+ label: "Relay ready",
4846
+ progress: 100,
4847
+ status: "success",
4848
+ itemHash: attemptItemHash,
4849
+ detail: "The relay completed startup after a transient UI polling error. Runtime and bootstrap registration are confirmed."
4850
+ });
4851
+ return;
4852
+ }
4853
+ }
4854
+ }
4855
+ } catch (reconcileError) {
4856
+ this.trace("deploy:recovery-check-error", {
4857
+ itemHash: attemptItemHash,
4858
+ error: reconcileError instanceof Error ? reconcileError.message : String(reconcileError)
4859
+ });
4860
+ }
4861
+ await this.refresh().catch(() => void 0);
4862
+ const recoveredRegistration = this.state.bootstrapRegistrations.some(
4863
+ (entry) => entry.instanceItemHash === attemptItemHash && entry.confirmed
4864
+ );
4865
+ if (recoveredRegistration) {
4866
+ this.trace("deploy:recovered-from-refresh-state", {
4867
+ itemHash: attemptItemHash,
4868
+ error: lastError.message
4869
+ });
4870
+ this.patch({
4871
+ busy: { deploying: false }
4872
+ });
4873
+ this.lastCompletedDeploymentHash = attemptItemHash;
4874
+ this.emitProgress({
4875
+ stage: "completed",
4876
+ label: "Relay ready",
4877
+ progress: 100,
4878
+ status: "success",
4879
+ itemHash: attemptItemHash,
4880
+ detail: "The relay completed startup after a transient browser-side polling error. Runtime and bootstrap registration are confirmed."
4881
+ });
4882
+ return;
4883
+ }
4884
+ }
4564
4885
  if (attemptItemHash) {
4565
4886
  this.emitProgress({
4566
4887
  stage: "deployment-confirmed",
@@ -4571,6 +4892,7 @@ var SponsorRelayController = class {
4571
4892
  detail: failedDueToInvalidIpv6 ? `${attemptLabel} exposed runtime networking that cannot support proxy-backed HTTPS. Cleaning up this attempt before retrying another CRN.` : `${attemptLabel} failed: ${lastError.message} Cleaning up this attempt before retrying another CRN.`
4572
4893
  });
4573
4894
  if (attemptDeploymentToken) {
4895
+ let cleanupAggregateItemHash = null;
4574
4896
  this.emitProgress({
4575
4897
  stage: "deployment-confirmed",
4576
4898
  label: "Cleaning up failed attempt",
@@ -4592,6 +4914,7 @@ var SponsorRelayController = class {
4592
4914
  apiHost: this.client.apiHost,
4593
4915
  sync: true
4594
4916
  }).then((cleanupResult) => {
4917
+ cleanupAggregateItemHash = cleanupResult.aggregateItemHash;
4595
4918
  this.trace("deploy:bootstrap-config-cleanup-complete", {
4596
4919
  itemHash: attemptItemHash,
4597
4920
  deploymentToken: attemptDeploymentToken,
@@ -4605,6 +4928,12 @@ var SponsorRelayController = class {
4605
4928
  error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
4606
4929
  });
4607
4930
  });
4931
+ await this.cleanupStaleVmBootstrapConfigHistory({
4932
+ itemHash: attemptItemHash,
4933
+ currentAggregateItemHash: cleanupAggregateItemHash,
4934
+ stage: "deployment-confirmed",
4935
+ progress: 18
4936
+ });
4608
4937
  }
4609
4938
  this.emitProgress({
4610
4939
  stage: "deployment-confirmed",
@@ -193,7 +193,7 @@ export let apiHost = undefined
193
193
 
194
194
  <div class="actions">
195
195
  {#if state.wallet.connected}
196
- <button class="primary" type="button" on:click={() => controller.deploy()} disabled={state.busy.deploying || !state.rootfsVerified}>
196
+ <button class="primary" type="button" on:click={() => controller.deploy()} disabled={state.busy.deploying || state.rootfsHealth.tone !== 'ok'}>
197
197
  {state.busy.deploying ? 'Deploying…' : 'Deploy Relay'}
198
198
  </button>
199
199
  {:else}