unoverse 0.1.87 → 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
  ]);
@@ -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
 
@@ -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
@@ -59,6 +59,13 @@ output "api_url" {
59
59
  value = local.has_domain ? "https://${local.api_host}" : "http://${digitalocean_loadbalancer.public.ip}"
60
60
  }
61
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
+
62
69
  # Read by deploy for the one-time schema grant, never written to the server. Empty when
63
70
  # the database is BYO: somebody else's cluster, whose permissions are theirs to run.
64
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.87",
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",