unoverse 0.1.132 → 0.1.133

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.
@@ -59,9 +59,51 @@ const H = { Authorization: `Bearer ${T}`, "Content-Type": "application/json" };
59
59
  const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { headers: H, ...o });
60
60
  const read = () => { try { return fs.readFileSync(ledgerPath, "utf8").split("\n").map(s => s.trim()).filter(Boolean); } catch { return []; } };
61
61
 
62
+ /**
63
+ * A MACHINE IS A SET OF ADDRESSES, NOT ONE.
64
+ *
65
+ * This asked "what is my IP?" once and registered the answer, deleting whatever it had
66
+ * registered before. That is correct for one stable address and actively harmful without
67
+ * one: a connection that leaves through a different egress than the probe did arrives
68
+ * from an address nobody trusted. Observed live 2026-08-10 on a laptop whose ISP
69
+ * alternates two addresses roughly evenly — six probes returned three of each. Each run
70
+ * registered whichever it drew and REMOVED the other, so running the command again
71
+ * (exactly what a locked-out developer does) deleted the rule that was working, and the
72
+ * database stayed unreachable through both.
73
+ *
74
+ * So probe several times and register the DISTINCT SET. Replacement is unchanged: the
75
+ * previous set still goes, so rules never pile up. One address for a normal machine, two
76
+ * for a flapping one, and the developer never has to know which they are.
77
+ */
78
+ /**
79
+ * EACH PROBE MUST BE ITS OWN CONNECTION, or the loop is theatre. `fetch` keeps the
80
+ * connection alive, so six calls to one host ride ONE socket and one egress, and the
81
+ * probe reports a single address on the very machine that has two. Measured: six
82
+ * keep-alive probes returned the same address six times, while six separate `curl`
83
+ * processes returned three of each. `Connection: close` retires the socket, and rotating
84
+ * the endpoint keeps a single provider's own routing from deciding the answer.
85
+ */
86
+ const ENDPOINTS = ["https://api.ipify.org", "https://icanhazip.com", "https://ifconfig.me/ip"];
87
+ const PROBES = 9;
88
+ const egress = async () => {
89
+ const seen = new Set();
90
+ for (let i = 0; i < PROBES; i++) {
91
+ try {
92
+ const res = await fetch(ENDPOINTS[i % ENDPOINTS.length], {
93
+ headers: { connection: "close" },
94
+ signal: AbortSignal.timeout(5000),
95
+ });
96
+ const v = (await res.text()).trim();
97
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(v)) seen.add(v);
98
+ } catch {}
99
+ }
100
+ return [...seen];
101
+ };
102
+
62
103
  (async () => {
63
- const ip = (await (await fetch("https://api.ipify.org", { signal: AbortSignal.timeout(5000) })).text()).trim();
64
- if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return;
104
+ const ips = await egress();
105
+ if (!ips.length) return;
106
+ const isOurs = (v) => ips.includes(v);
65
107
 
66
108
  const list = await (await api("/databases")).json();
67
109
  const db = (list.databases || []).find((d) => d.name === cluster);
@@ -71,26 +113,28 @@ const read = () => { try { return fs.readFileSync(ledgerPath, "utf8").split("\n"
71
113
  let rules = (fw.rules || []).map((r) => ({ type: r.type, value: r.value }));
72
114
 
73
115
  const ours = read(); // only these may be removed
74
- const stale = ours.filter((v) => v !== ip);
75
- const had = rules.some((r) => r.type === "ip_addr" && r.value === ip);
116
+ const stale = ours.filter((v) => !isOurs(v));
117
+ const missing = ips.filter((v) => !rules.some((r) => r.type === "ip_addr" && r.value === v));
118
+ const label = ips.join(", ");
76
119
  // SAY SO EVEN WHEN NOTHING CHANGES. Returning silently made the command look like it had
77
120
  // failed: the developer typed it because they could not connect, and got a blank line.
78
- if (had && stale.length === 0) {
79
- fs.writeFileSync(ledgerPath, ip + "\n");
80
- console.log(` \x1b[32m✓\x1b[0m This machine (${ip}) can already reach ${cluster} \x1b[2m(nothing changed)\x1b[0m`);
121
+ if (!missing.length && !stale.length) {
122
+ fs.writeFileSync(ledgerPath, ips.join("\n") + "\n");
123
+ console.log(` \x1b[32m✓\x1b[0m This machine (${label}) can already reach ${cluster} \x1b[2m(nothing changed)\x1b[0m`);
81
124
  return;
82
125
  }
83
126
 
84
127
  rules = rules.filter((r) => !(r.type === "ip_addr" && stale.includes(r.value)));
85
- if (!had) rules.push({ type: "ip_addr", value: ip });
128
+ for (const v of missing) rules.push({ type: "ip_addr", value: v });
86
129
 
87
130
  const res = await api(`/databases/${db.id}/firewall`, { method: "PUT", body: JSON.stringify({ rules }) });
88
131
  if (!res.ok) {
89
- console.log(` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — add ${ip} by hand`);
132
+ console.log(` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — add ${label} by hand`);
90
133
  return;
91
134
  }
92
- fs.writeFileSync(ledgerPath, ip + "\n");
93
- console.log(` \x1b[32m✓\x1b[0m This machine (${ip}) may reach ${cluster} \x1b[2m(${rules.length} trusted sources, only ours changed)\x1b[0m`);
135
+ fs.writeFileSync(ledgerPath, ips.join("\n") + "\n");
136
+ const changed = ips.length > 1 ? `${ips.length} addresses for this machine` : "only ours changed";
137
+ console.log(` \x1b[32m✓\x1b[0m This machine (${label}) may reach ${cluster} \x1b[2m(${rules.length} trusted sources, ${changed})\x1b[0m`);
94
138
  })().catch(() => {});
95
139
  NODE
96
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.132",
3
+ "version": "0.1.133",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",