unoverse 0.1.74 → 0.1.76

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.
@@ -261,8 +261,18 @@ resource "digitalocean_database_connection_pool" "universe" {
261
261
  user = digitalocean_database_user.universe[0].name
262
262
  }
263
263
 
264
+ # ONLY A CLUSTER THIS STACK CREATED. `digitalocean_database_firewall` is AUTHORITATIVE:
265
+ # it replaces the whole trusted-sources list rather than adding to it. Applied to an
266
+ # ADOPTED cluster it therefore deleted every rule the account already had — the
267
+ # operator's own IP, their other droplets, their App Platform apps — and locked them out
268
+ # of a database this universe merely borrows. It happened, on 2026-08-01.
269
+ #
270
+ # So terraform owns the firewall only when it owns the cluster (`provision_pg`). For an
271
+ # adopted cluster the CLI appends this droplet to the existing rules instead
272
+ # (scripts/lib/deploy.sh), which is additive and leaves everything else alone. A universe
273
+ # never takes ownership of a database it did not create.
264
274
  resource "digitalocean_database_firewall" "pg" {
265
- count = local.pg_managed ? 1 : 0
275
+ count = local.provision_pg ? 1 : 0
266
276
  cluster_id = local.pg_cluster_id
267
277
  rule {
268
278
  type = "droplet"
@@ -14,6 +14,59 @@
14
14
  # Terraform is one static binary: install it from the official release rather than
15
15
  # sending anyone to brew, whose compile chain can demand Xcode Command Line Tools
16
16
  # updates for a tool that needs no compiling.
17
+ # Give this universe's droplet access to an ADOPTED database, additively.
18
+ #
19
+ # Terraform cannot do this safely: `digitalocean_database_firewall` replaces the whole
20
+ # trusted-sources list, so pointing it at a borrowed cluster deletes the operator's own
21
+ # IP, their other droplets and their apps. Read the rules, add ours if missing, write
22
+ # them back. Nothing else moves, and running it twice changes nothing.
23
+ _grant_droplet_to_adopted_db() {
24
+ local cloud="$1" dir="$ROOT/infra/$cloud" cluster droplet_id
25
+ cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
26
+ [ -n "$cluster" ] || return 0
27
+ [ -n "${DIGITALOCEAN_TOKEN:-}" ] || return 0
28
+ droplet_id=$(terraform -chdir="$dir" state show digitalocean_droplet.app 2>/dev/null | awk -F'"' '/^ *id *=/{print $2; exit}')
29
+ [ -n "$droplet_id" ] || return 0
30
+
31
+ node - "$cluster" "$droplet_id" <<'NODE'
32
+ const [cluster, droplet] = process.argv.slice(2);
33
+ const T = process.env.DIGITALOCEAN_TOKEN;
34
+ const H = { Authorization: `Bearer ${T}`, "Content-Type": "application/json" };
35
+ const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { headers: H, ...o });
36
+ (async () => {
37
+ const list = await (await api("/databases")).json();
38
+ const db = (list.databases || []).find((d) => d.name === cluster);
39
+ if (!db) return;
40
+ const fw = await (await api(`/databases/${db.id}/firewall`)).json();
41
+ const rules = (fw.rules || []).map((r) => ({ type: r.type, value: r.value }));
42
+ if (rules.some((r) => r.type === "droplet" && String(r.value) === String(droplet))) return;
43
+ rules.push({ type: "droplet", value: String(droplet) });
44
+ const res = await api(`/databases/${db.id}/firewall`, { method: "PUT", body: JSON.stringify({ rules }) });
45
+ console.log(res.ok
46
+ ? ` \x1b[32m✓\x1b[0m This universe may reach ${cluster} \x1b[2m(added to its trusted sources, nothing else changed)\x1b[0m`
47
+ : ` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — add droplet ${droplet} by hand`);
48
+ })().catch(() => {});
49
+ NODE
50
+ }
51
+
52
+ # The cloud credential terraform needs, from where the CLI already put it.
53
+ #
54
+ # By design the DO token lives in doctl's config and never in a repo file (main.tf: an
55
+ # empty var falls through to DIGITALOCEAN_TOKEN). This used to sit INSIDE cmd_deploy, so
56
+ # `unoverse destroy` ran with no credential at all and every API call came back 401 —
57
+ # the same class of bug as the Postgres question living in one branch of two. Anything
58
+ # that talks to a ground calls this first.
59
+ _ground_credentials() {
60
+ [ -n "${DIGITALOCEAN_TOKEN:-}" ] && return 0
61
+ local _docfg
62
+ for _docfg in "$HOME/Library/Application Support/doctl/config.yaml" "$HOME/.config/doctl/config.yaml"; do
63
+ [ -f "$_docfg" ] || continue
64
+ DIGITALOCEAN_TOKEN=$(awk '/access-token:/{print $2; exit}' "$_docfg")
65
+ if [ -n "$DIGITALOCEAN_TOKEN" ]; then export DIGITALOCEAN_TOKEN; return 0; fi
66
+ done
67
+ return 0
68
+ }
69
+
17
70
  _ensure_terraform() {
18
71
  command -v terraform >/dev/null 2>&1 && return 0
19
72
  local REPLY tf_arch tf_os tf_v="1.9.8" tf_dir
@@ -114,6 +167,26 @@ _ensure_ground_config() {
114
167
  cur_size=$(grep -E '^size[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
115
168
  [ -n "$cur_size" ] && info "Size: ${BOLD}${cur_size}${NC} ${DIM}(small · medium · large — change it in terraform.tfvars and deploy again whenever you outgrow it)${NC}"
116
169
 
170
+ # REGION MUST MATCH, or the platform cannot reach its own database. DigitalOcean's
171
+ # private networking is per-region: the connection string uses the cluster's PRIVATE
172
+ # host, which a droplet in another region cannot resolve. Everything provisions
173
+ # perfectly and then the platform fails to start, which is the worst way to find out.
174
+ local reuse_name ground_region db_region
175
+ reuse_name=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
176
+ ground_region=$(grep -E '^region[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
177
+ if [ -n "$reuse_name" ] && [ -n "$ground_region" ] && command -v doctl >/dev/null 2>&1; then
178
+ db_region=$(doctl databases list --format Name,Region --no-header 2>/dev/null | awk -v n="$reuse_name" '$1==n{print $2}')
179
+ if [ -n "$db_region" ] && [ "$db_region" != "$ground_region" ]; then
180
+ echo ""
181
+ fail "Region mismatch: this universe is in ${BOLD}$ground_region${NC}, ${BOLD}$reuse_name${NC} is in ${BOLD}$db_region${NC}"
182
+ info "Private networking does not cross regions, so the platform could not reach it."
183
+ info "Either set ${BOLD}region = \"$db_region\"${NC} in infra/$cloud/terraform.tfvars,"
184
+ info "or comment out existing_pg_cluster_name to create a database in $ground_region."
185
+ echo ""
186
+ return 1
187
+ fi
188
+ fi
189
+
117
190
  # THE DATABASE DECISION, STATED EVERY RUN. It is asked once and then recorded in
118
191
  # terraform.tfvars, so later deploys correctly do not re-ask — but silence reads
119
192
  # exactly like never having been asked. Say what is in force, the same way Size does.
@@ -189,18 +262,7 @@ cmd_deploy() {
189
262
 
190
263
  local env_prod="$ROOT/.env.production"
191
264
 
192
- # HAND TERRAFORM ITS TOKEN. By design the DO token lives in doctl's config and never
193
- # in a repo file (main.tf: empty var falls through to DIGITALOCEAN_TOKEN). deploy
194
- # authenticated doctl, so deploy passes the token on — process env only, no file.
195
- # Without this, apply planned 6 resources and then 401'd on an empty credential.
196
- if [ -z "${DIGITALOCEAN_TOKEN:-}" ]; then
197
- local _docfg
198
- for _docfg in "$HOME/Library/Application Support/doctl/config.yaml" "$HOME/.config/doctl/config.yaml"; do
199
- [ -f "$_docfg" ] || continue
200
- DIGITALOCEAN_TOKEN=$(awk '/access-token:/{print $2; exit}' "$_docfg")
201
- if [ -n "$DIGITALOCEAN_TOKEN" ]; then export DIGITALOCEAN_TOKEN; break; fi
202
- done
203
- fi
265
+ _ground_credentials
204
266
 
205
267
  # ── ONE FLOW ────────────────────────────────────────────────────────────────
206
268
  # Which ground, is it configured, plan it, apply it, ship it. In that order, every
@@ -249,6 +311,8 @@ cmd_deploy() {
249
311
  exit 1
250
312
  fi
251
313
 
314
+ _grant_droplet_to_adopted_db "$cloud"
315
+
252
316
 
253
317
  # Read deploy target from .env.production
254
318
  local deploy_host deploy_user
@@ -34,6 +34,7 @@ cmd_destroy() {
34
34
  return 1
35
35
  fi
36
36
 
37
+ _ground_credentials
37
38
  _ensure_terraform || return 1
38
39
 
39
40
  local pretty
@@ -72,7 +73,15 @@ cmd_destroy() {
72
73
  name="${name:-universe}"
73
74
 
74
75
  echo ""
75
- echo -e " ${RED}This deletes the server and its data. It cannot be undone.${NC}"
76
+ # Say what is actually lost. With a reused cluster the DATABASE survives, and claiming
77
+ # otherwise makes the warning untrue in the commonest case — which teaches people to
78
+ # skim warnings.
79
+ if [ -n "$kept" ]; then
80
+ echo -e " ${RED}This deletes the server and everything on its disk. It cannot be undone.${NC}"
81
+ echo -e " ${DIM}Your database and its contents survive in $kept.${NC}"
82
+ else
83
+ echo -e " ${RED}This deletes the server, the database and all their data. It cannot be undone.${NC}"
84
+ fi
76
85
  echo ""
77
86
  local typed
78
87
  read -r -p " Type the universe name to confirm ($name): " typed
@@ -48,7 +48,10 @@ function spec(size) {
48
48
  * and listing them separately turns one decision into eleven.
49
49
  */
50
50
  function describe(r) {
51
- const a = r.change?.after ?? {};
51
+ // BEFORE, when there is no after. A destroy plan has `after: null` — every resource
52
+ // is being removed — so reading only `after` printed "Server · " with no size or
53
+ // region: the summary of a teardown, describing nothing.
54
+ const a = r.change?.after ?? r.change?.before ?? {};
52
55
  switch (r.type) {
53
56
  case "digitalocean_droplet":
54
57
  return { what: "Server", detail: `${spec(a.size)} · ${a.region ?? ""}`, price: PRICE[a.size] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",