unoverse 0.1.87 → 0.1.89

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"
@@ -143,28 +260,68 @@ _ensure_ground_config() {
143
260
  fi
144
261
  fi
145
262
 
146
- # POSTGRES: ASK, DO NOT ASSUME. The ground discovers an existing cluster and writes
147
- # it as a COMMENTED line, so the default silently provisions a SECOND database next
148
- # to one the account already pays for. A choice with a monthly bill attached is a
149
- # question, not a line to notice in a file.
263
+ # PRODUCTION'S DATABASE IS NOT THE ONE IN .env, AND THE QUESTION MUST SAY SO.
264
+ #
265
+ # `.env` holds the DEVELOPMENT database what `npm run dev` talks to on the laptop. The
266
+ # deployed universe gets its own, so a developer cannot break production by experimenting
267
+ # locally. That separation is right and is the default.
268
+ #
269
+ # What was wrong was the words. This asked "You already have a database — use it?", where
270
+ # "it" meant the CLUSTER and "use" meant "create a new database inside it". A developer
271
+ # who had typed a DATABASE_URL during setup read that as "use the database I gave you",
272
+ # answered yes, and got an empty one — then could not find their 21 workflows and
273
+ # reasonably concluded the deploy had lost them. Nothing was lost; the sentence was.
274
+ #
275
+ # So: name the cluster, say a NEW database goes in it, and say what happens to the one in
276
+ # .env. Anyone wanting production to share the development database sets byo_postgres_url
277
+ # deliberately, which is a different and much louder act.
150
278
  if [ "$cloud" = "digitalocean" ] \
151
- && grep -q '^#[[:space:]]*existing_pg_cluster_name' "$tfv" 2>/dev/null \
152
279
  && ! grep -q '^existing_pg_cluster_name' "$tfv" 2>/dev/null \
153
280
  && ! grep -q '^byo_postgres_url' "$tfv" 2>/dev/null; then
154
- local found
155
- found=$(grep '^#[[:space:]]*existing_pg_cluster_name' "$tfv" | sed -E 's/.*"([^"]+)".*/\1/')
156
- if [ -n "$found" ]; then
157
- echo ""
158
- echo -e " ${CYAN}${BOLD}You already have a database.${NC} ${DIM}$found${NC}"
159
- echo ""
160
- pick_option "Use it" "Create a new one ~\$15/month"
161
- if [ "$PICKED" = "0" ]; then
162
- node -e 'const fs=require("fs");const[f,c]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^#\s*(existing_pg_cluster_name\s*=.*)$/m,"$1");fs.writeFileSync(f,s)' "$tfv" "$found"
163
- ok "Reusing $found"
164
- else
165
- ok "A new database will be created"
166
- fi
281
+ local found env_db db_name
282
+ found=$(grep '^#[[:space:]]*existing_pg_cluster_name' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
283
+ env_db=$(grep -E '^DATABASE_URL=' "$ROOT/.env" 2>/dev/null | head -1 | cut -d= -f2-)
284
+ db_name=$(echo "$env_db" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
285
+
286
+ echo ""
287
+ echo -e " ${CYAN}${BOLD}Which database should the DEPLOYED universe use?${NC}"
288
+ echo ""
289
+ echo -e " ${DIM}Your .env is your local development database and does not change.${NC}"
290
+ echo ""
291
+ local opt_new_db="" opt_share=""
292
+ [ -n "$found" ] && opt_new_db="Its own new database on ${found} free"
293
+ [ -n "$db_name" ] && opt_share="The same one you develop against ${db_name}"
294
+
295
+ # Order is the recommendation. Its own database first: production and development stay
296
+ # independent, which is what almost everyone wants and what nobody regrets.
297
+ if [ -n "$opt_new_db" ] && [ -n "$opt_share" ]; then
298
+ pick_option "$opt_new_db" "$opt_share" "A new cluster of its own ~\$15/month"
299
+ elif [ -n "$opt_new_db" ]; then
300
+ pick_option "$opt_new_db" "A new cluster of its own ~\$15/month"
301
+ elif [ -n "$opt_share" ]; then
302
+ pick_option "$opt_share" "A new cluster of its own ~\$15/month"
303
+ else
304
+ PICKED=99 # nothing to reuse: the ground provisions a cluster, no question worth asking
167
305
  fi
306
+
307
+ local choice=""
308
+ case "$PICKED" in
309
+ 0) [ -n "$opt_new_db" ] && choice="own" || choice="share" ;;
310
+ 1) [ -n "$opt_new_db" ] && [ -n "$opt_share" ] && choice="share" || choice="fresh" ;;
311
+ *) choice="fresh" ;;
312
+ esac
313
+
314
+ case "$choice" in
315
+ own)
316
+ node -e 'const fs=require("fs");const[f]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^#\s*(existing_pg_cluster_name\s*=.*)$/m,"$1");fs.writeFileSync(f,s)' "$tfv"
317
+ ok "Production gets its own database on $found ${DIM}(your development data is untouched)${NC}"
318
+ ;;
319
+ share)
320
+ node -e 'const fs=require("fs");const[f,v]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^#?\s*byo_postgres_url\s*=.*$/m,"byo_postgres_url = "+JSON.stringify(v));fs.writeFileSync(f,s)' "$tfv" "$env_db"
321
+ ok "Production reads ${BOLD}$db_name${NC} ${DIM}(the same database you develop against — local changes are live)${NC}"
322
+ ;;
323
+ *) ok "A new cluster will be created for production" ;;
324
+ esac
168
325
  fi
169
326
 
170
327
  _tf_fill docr_token DOCR_TOKEN "Registry access token (from your Unoverse admin)"
@@ -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.89",
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",