unoverse 0.1.86 → 0.1.88

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/bin/unoverse.mjs CHANGED
@@ -58,7 +58,7 @@ const UNIVERSE = findUniverse();
58
58
  // type it to update the CLI far more often than to refresh a local universe's images,
59
59
  // and that refresh now belongs to `start --pull`.
60
60
  const OPERATOR_COMMANDS = new Set([
61
- "start", "stop", "check", "logs", "deploy", "destroy",
61
+ "start", "stop", "check", "logs", "deploy", "destroy", "db-allow",
62
62
  // kept working, not advertised
63
63
  "ground", "dev", "build", "publish", "init",
64
64
  ]);
package/lib/urls.mjs CHANGED
@@ -39,6 +39,7 @@ function section(title, rows, subtitle) {
39
39
  import { existsSync } from "node:fs";
40
40
  import { join, resolve, dirname } from "node:path";
41
41
  import { spawnSync } from "node:child_process";
42
+ import { lookup } from "node:dns/promises";
42
43
 
43
44
  import { bold, dim, cyan, green, red } from "./ui.mjs";
44
45
 
@@ -75,11 +76,35 @@ function terraformOutputs(root) {
75
76
  return {};
76
77
  }
77
78
 
78
- async function probe(url) {
79
+ /**
80
+ * "no answer" AND "answering from the wrong machine" ARE DIFFERENT PROBLEMS.
81
+ *
82
+ * A URL that does not respond used to report identically whether the service was down or
83
+ * the hostname resolved somewhere else entirely. That sent an operator to look at
84
+ * infrastructure that was already working: two load balancers up, a verified certificate,
85
+ * and every deployed URL reading "no answer" because a resolver was still holding a
86
+ * deleted A record. The address had a cached answer pointing at a destroyed server, which
87
+ * is not a fact any amount of staring at containers reveals.
88
+ *
89
+ * So when a probe fails, ask what the name actually resolves to and compare it with the
90
+ * address the ground says it should be. Stale DNS then says so in the one place the
91
+ * operator is already looking.
92
+ */
93
+ async function probe(url, expectedIp) {
79
94
  try {
80
95
  const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });
81
96
  return { up: true, status: res.status };
82
97
  } catch {
98
+ if (!expectedIp) return { up: false };
99
+ try {
100
+ const { hostname } = new URL(url);
101
+ const { address } = await lookup(hostname);
102
+ if (address !== expectedIp) {
103
+ return { up: false, note: `DNS says ${address}, should be ${expectedIp} — a resolver is holding an old record` };
104
+ }
105
+ } catch {
106
+ return { up: false, note: "that hostname does not resolve yet" };
107
+ }
83
108
  return { up: false };
84
109
  }
85
110
  }
@@ -103,14 +128,14 @@ export async function urls() {
103
128
  domain = /^https:\/\/api\.(.+)$/.exec(apiBase)?.[1] || "";
104
129
  const candidates = [];
105
130
  if (apiBase) {
106
- candidates.push({ label: "API", url: apiBase, note: "MCP at /mcp" });
107
- candidates.push({ label: "Health", url: `${apiBase}/health` });
131
+ candidates.push({ label: "API", url: apiBase, note: "MCP at /mcp", expect: tf.lb_ip });
132
+ candidates.push({ label: "Health", url: `${apiBase}/health`, expect: tf.lb_ip });
108
133
  }
109
134
  const canvas = tf.canvas_url && tf.canvas_url.startsWith("http") ? tf.canvas_url : null;
110
- if (canvas) candidates.push({ label: "Canvas", url: canvas, note: "add to IdP origins" });
135
+ if (canvas) candidates.push({ label: "Canvas", url: canvas, note: "add to IdP origins", expect: tf.canvas_lb_ip });
111
136
  if (tf.deploy_host) candidates.push({ label: "Logs", url: `http://${tf.deploy_host}:8080`, note: "admin IP only" });
112
137
  if (tf.cognito_hosted_ui) candidates.push({ label: "Login", url: tf.cognito_hosted_ui });
113
- deployed = await Promise.all(candidates.map(async (c) => ({ ...c, ...(await probe(c.url)) })));
138
+ deployed = await Promise.all(candidates.map(async (c) => ({ ...c, ...(await probe(c.url, c.expect)) })));
114
139
  }
115
140
 
116
141
  const anyUp = [...local, ...deployed].some((r) => r.up);
@@ -90,20 +90,21 @@
90
90
  # terraform at deploy time and is never written to the server.
91
91
  - name: "[3b/5] Grant the universe user rights on its own schema"
92
92
  command: >
93
- docker compose exec -T -e NODE_TLS_REJECT_UNAUTHORIZED=0 -e ADMIN_URL={{ pg_admin_url }} unoverse node -e
93
+ docker compose exec -T -e NODE_TLS_REJECT_UNAUTHORIZED=0 -e ADMIN_URL={{ pg_admin_url }} -e PG_USER={{ pg_user | default('universe') }} unoverse node -e
94
94
  "const{Client}=require('pg');
95
95
  const c=new Client({connectionString:process.env.ADMIN_URL,ssl:{rejectUnauthorized:false}});
96
96
  (async()=>{
97
97
  await c.connect();
98
- await c.query('GRANT ALL ON SCHEMA public TO \"universe\"');
99
- await c.query('GRANT ALL ON ALL TABLES IN SCHEMA public TO \"universe\"');
100
- await c.query('GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO \"universe\"');
98
+ const u = JSON.stringify(process.env.PG_USER).replace(/^\"|\"$/g,'');
99
+ await c.query('GRANT ALL ON SCHEMA public TO \"'+u+'\"');
100
+ await c.query('GRANT ALL ON ALL TABLES IN SCHEMA public TO \"'+u+'\"');
101
+ await c.query('GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO \"'+u+'\"');
101
102
  await c.end();
102
103
  console.log('granted');
103
104
  })().catch(e=>{console.error(e.message);process.exit(1)})"
104
105
  args:
105
106
  chdir: /opt/gravity
106
- when: (pg_admin_url | default('')) | length > 0
107
+ when: (pg_admin_url | default('')) | length > 0 and (pg_user | default('')) | length > 0
107
108
  no_log: true
108
109
  register: grant_result
109
110
 
@@ -42,6 +42,25 @@
42
42
  src: "{{ universe_root | default(playbook_dir + '/../..') }}/docker-compose.yml"
43
43
  dest: "{{ gravity_dir }}/docker-compose.yml"
44
44
 
45
+ # THE ENVIRONMENT TRAVELS WITH THE DEPLOY TOO. This step used to be skipped on the
46
+ # grounds that `.env` held per-server values a deploy must not clobber. That stopped
47
+ # being true when the ground started rendering it in full: it is `terraform output -raw
48
+ # env_production`, derived from the same state that built the infrastructure, and
49
+ # nothing on the server edits it.
50
+ #
51
+ # Skipping it meant a change to the ground reached docker-compose.yml and stopped there.
52
+ # Setting a domain updated the compose file, while the environment still said
53
+ # DOMAIN= and API_URL=http://<lb-ip> — so Canvas loaded over HTTPS and called the API
54
+ # over plain HTTP at a raw IP, and the browser blocked it as mixed content. The compose
55
+ # file and the environment describe one deployment; shipping one without the other
56
+ # leaves them disagreeing.
57
+ - name: "[1b/4] Sync .env (the ground renders it, so it travels with the deploy)"
58
+ copy:
59
+ src: "{{ env_file }}"
60
+ dest: /opt/gravity/.env
61
+ owner: "{{ ansible_user }}"
62
+ mode: "0600"
63
+
45
64
  - name: "[2/4] Pull latest platform images"
46
65
  shell: |
47
66
  cd {{ gravity_dir }}
@@ -272,16 +272,20 @@ resource "digitalocean_database_cluster" "pg" {
272
272
  node_count = 1
273
273
  }
274
274
 
275
+ # NAMED AFTER THE UNIVERSE, not "universe". A cluster can host several universes — that
276
+ # is the whole point of adopting one rather than provisioning per stack — and a hardcoded
277
+ # name means the second one either collides with the first or, worse, attaches to it and
278
+ # silently shares its data. Everything else on this ground is already ${var.name}-prefixed.
275
279
  resource "digitalocean_database_db" "universe" {
276
280
  count = local.pg_managed ? 1 : 0
277
281
  cluster_id = local.pg_cluster_id
278
- name = "universe"
282
+ name = var.name
279
283
  }
280
284
 
281
285
  resource "digitalocean_database_user" "universe" {
282
286
  count = local.pg_managed ? 1 : 0
283
287
  cluster_id = local.pg_cluster_id
284
- name = "universe"
288
+ name = var.name
285
289
 
286
290
  # NEVER UPDATE THIS USER IN PLACE. Provider 2.96 grew a `settings` block (Kafka and
287
291
  # OpenSearch ACLs) and an update path that fires whenever it sees any diff on the
@@ -305,7 +309,7 @@ resource "digitalocean_database_user" "universe" {
305
309
  resource "digitalocean_database_connection_pool" "universe" {
306
310
  count = local.pg_managed ? 1 : 0
307
311
  cluster_id = local.pg_cluster_id
308
- name = "universe-pool"
312
+ name = "${var.name}-pool"
309
313
  mode = "transaction"
310
314
  size = local.s.pgbouncer
311
315
  db_name = digitalocean_database_db.universe[0].name
@@ -42,6 +42,13 @@ output "lb_ip" {
42
42
  value = digitalocean_loadbalancer.public.ip
43
43
  }
44
44
 
45
+ # Canvas's own load balancer address. Read by `unoverse where` so a hostname resolving to
46
+ # something else can be reported as stale DNS rather than as a dead service.
47
+ output "canvas_lb_ip" {
48
+ description = "The Canvas load balancer's IP — canvas.<domain>'s A record target. Empty when Canvas is not public or there is no domain."
49
+ value = length(digitalocean_loadbalancer.canvas) > 0 ? digitalocean_loadbalancer.canvas[0].ip : ""
50
+ }
51
+
45
52
  output "canvas_url" {
46
53
  description = "Public Canvas URL (canvas_public = true only) — add it to the IdP's allowed origins."
47
54
  value = var.canvas_public ? (local.has_domain ? "https://canvas.${var.domain}" : "http://${digitalocean_loadbalancer.public.ip}:3001") : "canvas is admin-only (direct http://<droplet-ip>:3001 from admin_cidr)"
@@ -52,6 +59,13 @@ output "api_url" {
52
59
  value = local.has_domain ? "https://${local.api_host}" : "http://${digitalocean_loadbalancer.public.ip}"
53
60
  }
54
61
 
62
+ # The database user this universe owns. db-setup grants it rights on its own schema, and
63
+ # the name follows var.name now, so the playbook can no longer assume "universe".
64
+ output "pg_user" {
65
+ value = length(digitalocean_database_user.universe) > 0 ? digitalocean_database_user.universe[0].name : ""
66
+ description = "This universe's database user, when the ground manages one"
67
+ }
68
+
55
69
  # Read by deploy for the one-time schema grant, never written to the server. Empty when
56
70
  # the database is BYO: somebody else's cluster, whose permissions are theirs to run.
57
71
  output "pg_admin_url" {
@@ -26,6 +26,123 @@
26
26
  # stale rule behind — and worse, terraform's own destroy of an authoritative firewall
27
27
  # resource PUT an EMPTY list and locked the operator out of a database it had borrowed.
28
28
  # Both directions now touch exactly one rule and never the list.
29
+ # Keep the DEVELOPER'S OWN MACHINE able to reach an adopted database, across networks.
30
+ #
31
+ # A managed cluster's trusted sources are a list of IP addresses, and a laptop's address is
32
+ # not a stable thing: a different office, a hotspot, a train, and local `npm run dev` dies
33
+ # on "Connection terminated unexpectedly" ten seconds into boot, with nothing on screen
34
+ # connecting that to the network you joined this morning. The droplet's own firewall already
35
+ # follows the operator around (_ensure_ground_config re-checks admin_cidr every deploy);
36
+ # this is the same idea for the one rule that lets a laptop in.
37
+ #
38
+ # IT ONLY EVER REMOVES ITS OWN. The addresses it added are recorded in .unoverse/trusted-ips,
39
+ # and nothing absent from that file is touched — the operator's other machines, their
40
+ # colleagues, their CI, their other droplets and apps all survive untouched. That rule
41
+ # exists because the opposite mistake is what wiped this exact cluster's firewall once
42
+ # already: an authoritative write that assumed the whole list was ours to own.
43
+ _operator_db_access() {
44
+ local cloud="$1" dir="$ROOT/infra/$cloud" cluster
45
+ cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
46
+ [ -n "$cluster" ] || return 0
47
+ [ -n "${DIGITALOCEAN_TOKEN:-}" ] || return 0
48
+ mkdir -p "$ROOT/.unoverse"
49
+
50
+ node - "$cluster" "$ROOT/.unoverse/trusted-ips" <<'NODE'
51
+ const [cluster, ledgerPath] = process.argv.slice(2);
52
+ const fs = require("fs");
53
+ const T = process.env.DIGITALOCEAN_TOKEN;
54
+ const H = { Authorization: `Bearer ${T}`, "Content-Type": "application/json" };
55
+ const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { headers: H, ...o });
56
+ const read = () => { try { return fs.readFileSync(ledgerPath, "utf8").split("\n").map(s => s.trim()).filter(Boolean); } catch { return []; } };
57
+
58
+ (async () => {
59
+ const ip = (await (await fetch("https://api.ipify.org", { signal: AbortSignal.timeout(5000) })).text()).trim();
60
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return;
61
+
62
+ const list = await (await api("/databases")).json();
63
+ const db = (list.databases || []).find((d) => d.name === cluster);
64
+ if (!db) return;
65
+
66
+ const fw = await (await api(`/databases/${db.id}/firewall`)).json();
67
+ let rules = (fw.rules || []).map((r) => ({ type: r.type, value: r.value }));
68
+
69
+ const ours = read(); // only these may be removed
70
+ const stale = ours.filter((v) => v !== ip);
71
+ const had = rules.some((r) => r.type === "ip_addr" && r.value === ip);
72
+ // SAY SO EVEN WHEN NOTHING CHANGES. Returning silently made the command look like it had
73
+ // failed: the developer typed it because they could not connect, and got a blank line.
74
+ if (had && stale.length === 0) {
75
+ fs.writeFileSync(ledgerPath, ip + "\n");
76
+ console.log(` \x1b[32m✓\x1b[0m This machine (${ip}) can already reach ${cluster} \x1b[2m(nothing changed)\x1b[0m`);
77
+ return;
78
+ }
79
+
80
+ rules = rules.filter((r) => !(r.type === "ip_addr" && stale.includes(r.value)));
81
+ if (!had) rules.push({ type: "ip_addr", value: ip });
82
+
83
+ const res = await api(`/databases/${db.id}/firewall`, { method: "PUT", body: JSON.stringify({ rules }) });
84
+ if (!res.ok) {
85
+ console.log(` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — add ${ip} by hand`);
86
+ return;
87
+ }
88
+ fs.writeFileSync(ledgerPath, ip + "\n");
89
+ console.log(` \x1b[32m✓\x1b[0m This machine (${ip}) may reach ${cluster} \x1b[2m(${rules.length} trusted sources, only ours changed)\x1b[0m`);
90
+ })().catch(() => {});
91
+ NODE
92
+ }
93
+
94
+ # unoverse db-allow — let THIS machine reach this universe's database.
95
+ #
96
+ # Typed, never automatic. It changes a live database's network ACL, and doing that as a
97
+ # side effect of `start` meant a coffee shop's shared address quietly joined a production
98
+ # cluster's trusted sources. Typing it is the consent.
99
+ #
100
+ # What makes it safe to hand a developer: the DigitalOcean token gates it, so nobody
101
+ # without your cloud credential can run it at all, and the database still demands its own
102
+ # password afterwards. This opens a door in the network layer; it does not open the
103
+ # database.
104
+ cmd_db_allow() {
105
+ local cloud="" g
106
+ for g in digitalocean aws; do
107
+ [ -f "$ROOT/infra/$g/terraform.tfvars" ] && { cloud="$g"; break; }
108
+ done
109
+ if [ -z "$cloud" ]; then
110
+ fail "No ground here. There is no database to reach"
111
+ return 1
112
+ fi
113
+
114
+ local cluster
115
+ cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$ROOT/infra/$cloud/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
116
+
117
+ # THE MONOREPO NEEDS THIS TOO, and it has no ground. The platform's own checkout is
118
+ # developed against a managed cluster named only in .env, so a ground-only lookup found
119
+ # nothing and the one place the developer actually types `npm run dev` was the one place
120
+ # this could not help. A cluster host is `<name>-do-user-...`, so the name is right there
121
+ # in DATABASE_URL.
122
+ if [ -z "$cluster" ]; then
123
+ cluster=$(grep -E '^DATABASE_URL=' "$ROOT/.env" 2>/dev/null | head -1 \
124
+ | sed -E 's|.*@([a-z0-9-]+)-do-user-[^.]*\..*|\1|; t; d')
125
+ fi
126
+
127
+ if [ -z "$cluster" ]; then
128
+ ok "This database has no trusted-source list to join"
129
+ info "Nothing to do — you can already reach it"
130
+ return 0
131
+ fi
132
+
133
+ _ground_credentials
134
+ if [ -z "${DIGITALOCEAN_TOKEN:-}" ]; then
135
+ fail "No DigitalOcean credential. Run ${BOLD}unoverse deploy${NC} once, or ${BOLD}doctl auth init${NC}"
136
+ return 1
137
+ fi
138
+
139
+ echo ""
140
+ _operator_db_access "$cloud"
141
+ echo ""
142
+ info "Run this again whenever you change network"
143
+ echo ""
144
+ }
145
+
29
146
  _adopted_db_access() {
30
147
  local cloud="$1" dir="$ROOT/infra/$cloud" cluster droplet_id
31
148
  local mode="$2"
@@ -24,6 +24,7 @@ cmd_help() {
24
24
  echo -e " ${GREEN}logs${NC} What is it doing ${DIM}(unoverse logs <service> for one)${NC}"
25
25
  echo -e " ${GREEN}deploy${NC} Ship it to a server ${DIM}(first run asks which cloud)${NC}"
26
26
  echo -e " ${GREEN}destroy${NC} Take the deployment down ${DIM}(shows what goes, and what stays)${NC}"
27
+ echo -e " ${GREEN}db-allow${NC} Let this machine reach the database ${DIM}(run it when you change network)${NC}"
27
28
  echo ""
28
29
  # Owner-only lane. Printed ONLY when publish.sh is present, so a starter kit never
29
30
  # advertises a command it does not have (sync-starter.sh deletes that file).
@@ -52,6 +52,12 @@ pull_missing_images() {
52
52
  }
53
53
 
54
54
  cmd_start() {
55
+ # NO FIREWALL CHANGE HERE. Starting a dev server briefly did this automatically, so a
56
+ # laptop that had moved could always reach its database. It also meant every `start` on
57
+ # café or hotel Wi-Fi silently added THAT network's shared egress address to a production
58
+ # cluster's trusted sources, where it stayed until the next run. An ACL change is a
59
+ # deliberate act: `unoverse db-allow`, typed, when you move network.
60
+
55
61
  # Login to registry if DOCR_TOKEN is set
56
62
  local docr_token
57
63
  docr_token=$(grep "^DOCR_TOKEN=" "$ROOT/.env" 2>/dev/null | cut -d'=' -f2-)
@@ -66,6 +66,7 @@ case "${1:-}" in
66
66
  dev) cmd_dev ;;
67
67
  ground) shift; cmd_ground "$@" ;;
68
68
  destroy) cmd_destroy ;;
69
+ db-allow) cmd_db_allow ;;
69
70
  refresh-images)
70
71
  # internal: `unoverse update` runs this after updating the CLI. Pull newer images
71
72
  # if the registry has them, and recreate only what is already running — an update
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
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",