unoverse 0.1.84 → 0.1.86

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.
@@ -127,9 +127,33 @@
127
127
  register: migrate_result
128
128
  ignore_errors: yes
129
129
 
130
+ # THE WHOLE FILE IS NOT THE ANSWER TO "DID IT WORK". This printed every line of the
131
+ # baseline — several hundred lines of CREATE TABLE nobody asked to read, burying the one
132
+ # line that says whether anything ran. Keep the names and the verdict.
130
133
  - name: "[4/5] Migration output"
131
134
  debug:
132
- msg: "{{ migrate_result.stdout_lines | default(['No output']) }}"
135
+ msg: >-
136
+ {{ (migrate_result.stdout_lines | default([]) | select('match', '^> - ') | list)
137
+ + (migrate_result.stdout_lines | default([]) | select('search', 'Migrations complete|No migrations') | list)
138
+ | default(['No output'], true) }}
139
+
140
+ # SERVICES BOOTED BEFORE THIS SCHEMA EXISTED. install.yml has to start the containers
141
+ # first — migrations run THROUGH the unoverse container, so there is nothing to run them
142
+ # in until it is up. The cost is that every service that touches the database at boot
143
+ # does so against an empty one: memory logged "permission denied for schema public" and
144
+ # then 'relation "memories" does not exist', and stayed in that state until something
145
+ # restarted it. Restarting here is the missing half of that ordering, and it only ever
146
+ # happens on a first setup.
147
+ - name: "[4b/5] Restart services against the migrated schema"
148
+ command: docker compose restart
149
+ args:
150
+ chdir: /opt/gravity
151
+ when: migrate_result.rc | default(1) == 0
152
+
153
+ - name: "[4b/5] Settle"
154
+ pause:
155
+ seconds: 15
156
+ when: migrate_result.rc | default(1) == 0
133
157
 
134
158
  - name: "[5/5] Verify database connectivity"
135
159
  uri:
@@ -134,36 +134,38 @@
134
134
  register: api_write_check
135
135
  ignore_errors: yes
136
136
 
137
- - name: "[10/14] Check unoverse node catalog loaded"
137
+ # AN EMPTY CATALOGUE IS NOT A BROKEN ONE. Nodes arrive as rows — installed from the
138
+ # marketplace or published from Studio — so a universe on its first day has none, and
139
+ # that is the correct state, not a fault. This used to fail outright on zero and advise
140
+ # "run deploy.sh rebuild", a command that does not exist, which turned every first
141
+ # deploy into a red line pointing at nothing.
142
+ #
143
+ # What IS worth failing on is the runtime not answering at all: that means the catalogue
144
+ # could not be read, which is a different fact from it being empty.
145
+ - name: "[10/13] Check the node catalogue is readable"
138
146
  shell: |
139
147
  cd /opt/gravity
140
148
  # The internal :4106 runtime is Docker-network-only (never published), and the
141
149
  # public :4105 routes are JWT-gated — so read the catalog from INSIDE the
142
150
  # container. node:20-slim has no curl; use node fetch.
143
151
  NODE_COUNT=$(docker compose exec -T unoverse node -e \
144
- "fetch('http://127.0.0.1:4106/nodes').then(r=>r.json()).then(d=>console.log((d.nodes||[]).length)).catch(()=>console.log(0))" 2>/dev/null | tr -d ' \r')
152
+ "fetch('http://127.0.0.1:4106/nodes').then(r=>r.json()).then(d=>console.log((d.nodes||[]).length)).catch(()=>console.log('unreachable'))" 2>/dev/null | tr -d ' \r')
153
+ if [ "$NODE_COUNT" = "unreachable" ]; then
154
+ echo "nodes=unreachable"
155
+ echo "The runtime did not answer on :4106 — the catalogue could not be read"
156
+ exit 1
157
+ fi
145
158
  echo "nodes=${NODE_COUNT}"
146
159
  if [ "${NODE_COUNT:-0}" -eq 0 ] 2>/dev/null; then
147
- echo "WARNING: No nodes loaded run deploy.sh rebuild or unoverse update"
148
- exit 1
160
+ echo "(none installed yetinstall from the marketplace or publish from Studio)"
149
161
  fi
150
162
  register: plugin_check
151
163
  ignore_errors: yes
152
164
 
153
- - name: "[11/14] Check packages directory mounted"
154
- shell: |
155
- cd /opt/gravity
156
- if docker compose exec -T unoverse ls /app/host_packages 2>/dev/null | head -5; then
157
- PKG_COUNT=$(docker compose exec -T unoverse ls /app/host_packages 2>/dev/null | wc -l | tr -d ' ')
158
- echo "packages_mounted=${PKG_COUNT}"
159
- [ "$PKG_COUNT" -gt 0 ]
160
- else
161
- echo "packages_mounted=0"
162
- echo "WARNING: No packages directory — run deploy.sh or unoverse update"
163
- exit 1
164
- fi
165
- register: packages_check
166
- ignore_errors: yes
165
+ # NO PACKAGES CHECK. It looked for /app/host_packages, a bind mount that was retired
166
+ # when nodes moved to npm at runtime plus the marketplace. Nothing declares
167
+ # PACKAGES_PATH any more and docker-compose.yml has no such volume, so the check could
168
+ # only ever report 0 and tell the operator to run a command that would not fix it.
167
169
 
168
170
  - name: "[12/14] Check for errors in service logs"
169
171
  shell: |
@@ -245,11 +247,9 @@
245
247
  ── Prompt Blocks (internal :4106) ──
246
248
  {{ prompt_blocks_check.stdout_lines[0] | default('(skipped)') }}
247
249
 
248
- ── Plugins & Packages ──
249
- Unoverse: {{ plugin_check.stdout_lines[0] | default('(skipped)') }}
250
+ ── Node catalogue ──
251
+ {{ plugin_check.stdout_lines[0] | default('(skipped)') }}
250
252
  {{ plugin_check.stdout_lines[1] | default('') }}
251
- Packages: {{ packages_check.stdout_lines[0] | default('(skipped)') }}
252
- {{ packages_check.stdout_lines[1] | default('') }}
253
253
 
254
254
  ── Recent Errors in Logs ──
255
255
  {{ log_errors.stdout | default('(skipped)') }}
@@ -493,7 +493,7 @@ resource "aws_iam_access_key" "bedrock" {
493
493
  resource "aws_acm_certificate" "public" {
494
494
  count = local.has_domain ? 1 : 0
495
495
  domain_name = local.api_host
496
- subject_alternative_names = var.canvas_public ? ["unoverse.${var.domain}"] : []
496
+ subject_alternative_names = var.canvas_public ? ["canvas.${var.domain}"] : []
497
497
  validation_method = "DNS"
498
498
 
499
499
  lifecycle {
@@ -625,7 +625,7 @@ resource "aws_lb_listener_rule" "canvas" {
625
625
  }
626
626
  condition {
627
627
  host_header {
628
- values = ["unoverse.${var.domain}"]
628
+ values = ["canvas.${var.domain}"]
629
629
  }
630
630
  }
631
631
  }
@@ -661,7 +661,7 @@ resource "aws_route53_record" "api" {
661
661
  resource "aws_route53_record" "unoverse" {
662
662
  count = local.dns_auto && var.canvas_public ? 1 : 0
663
663
  zone_id = var.route53_zone_id
664
- name = "unoverse.${var.domain}"
664
+ name = "canvas.${var.domain}"
665
665
  type = "A"
666
666
 
667
667
  alias {
@@ -9,7 +9,7 @@ output "deploy_host" {
9
9
  }
10
10
 
11
11
  output "alb_dns_name" {
12
- description = "Point api.<domain> (and unoverse.<domain> when canvas_public) at this — CNAME/ALIAS. Created automatically when route53_zone_id is set."
12
+ description = "Point api.<domain> (and canvas.<domain> when canvas_public) at this — CNAME/ALIAS. Created automatically when route53_zone_id is set."
13
13
  value = aws_lb.public.dns_name
14
14
  }
15
15
 
@@ -24,7 +24,7 @@ output "acm_validation_records" {
24
24
 
25
25
  output "canvas_url" {
26
26
  description = "Public Canvas URL (canvas_public = true only) — add it to the client origins."
27
- value = var.canvas_public ? (local.has_domain ? "https://unoverse.${var.domain}" : "http://${aws_lb.public.dns_name}:3001") : "canvas is admin-only (direct http://<instance-ip>:3001 from admin_cidr)"
27
+ value = var.canvas_public ? (local.has_domain ? "https://canvas.${var.domain}" : "http://${aws_lb.public.dns_name}:3001") : "canvas is admin-only (direct http://<instance-ip>:3001 from admin_cidr)"
28
28
  }
29
29
 
30
30
  output "api_url" {
@@ -116,17 +116,18 @@ resource "digitalocean_firewall" "app" {
116
116
  port_range = "4105"
117
117
  source_load_balancer_uids = [digitalocean_loadbalancer.public.id]
118
118
  }
119
- # Canvas (operator UI): the DO LB has NO host-based routing (Caddy did), so the
120
- # public LB carries ONLY the api host :4105. Canvas is an OPERATOR tool —
121
- # admin-only direct access, same trust ring as SSH and Dozzle. It still calls
122
- # the platform at https://api.<domain> like any client.
123
- # POC (canvas_public = true): :3001 ALSO accepts the public LB, which serves
124
- # Canvas at https://api.<domain>:3001. Direct-IP access stays admin-only either way.
119
+ # Canvas (operator UI). Direct access to :3001 on the droplet stays admin-only, same
120
+ # trust ring as SSH and Dozzle, whatever else is true.
121
+ # With canvas_public: whichever load balancer fronts it may reach :3001 too the canvas
122
+ # LB when there is a domain, the main one when there is not. Naming both here would open
123
+ # 3001 to an LB that is not serving Canvas in that mode.
125
124
  inbound_rule {
126
- protocol = "tcp"
127
- port_range = "3001"
128
- source_addresses = [var.admin_cidr]
129
- source_load_balancer_uids = var.canvas_public ? [digitalocean_loadbalancer.public.id] : []
125
+ protocol = "tcp"
126
+ port_range = "3001"
127
+ source_addresses = [var.admin_cidr]
128
+ source_load_balancer_uids = var.canvas_public ? (
129
+ local.has_domain ? [digitalocean_loadbalancer.canvas[0].id] : [digitalocean_loadbalancer.public.id]
130
+ ) : []
130
131
  }
131
132
 
132
133
  outbound_rule {
@@ -183,18 +184,16 @@ resource "digitalocean_loadbalancer" "public" {
183
184
  }
184
185
  }
185
186
 
186
- # POC (canvas_public = true, DECIDED 2026-07-29): Canvas rides the SAME LB on a
187
- # second port — https://canvas.<domain>:3001 (a SAN on the api certificate, an A record to this same LB). ONE load balancer total: DO LBs can't
188
- # host-route, and a clean second hostname would cost a second LB; the POC takes
189
- # the port in the URL instead. Domainless: same second port, plain HTTP.
187
+ # DOMAINLESS ONLY. Without a hostname there is nothing to route by, so Canvas takes a
188
+ # second port on this LB's IP. With a domain it gets its own load balancer below and a
189
+ # clean https://canvas.<domain> see the note there.
190
190
  dynamic "forwarding_rule" {
191
- for_each = var.canvas_public ? [1] : []
191
+ for_each = var.canvas_public && !local.has_domain ? [1] : []
192
192
  content {
193
- entry_port = 3001
194
- entry_protocol = local.has_domain ? "https" : "http"
195
- target_port = 3001
196
- target_protocol = "http"
197
- certificate_name = one(digitalocean_certificate.api[*].name)
193
+ entry_port = 3001
194
+ entry_protocol = "http"
195
+ target_port = 3001
196
+ target_protocol = "http"
198
197
  }
199
198
  }
200
199
 
@@ -206,12 +205,49 @@ resource "digitalocean_loadbalancer" "public" {
206
205
  }
207
206
  }
208
207
 
208
+ # CANVAS GETS ITS OWN LOAD BALANCER, so its URL carries no port.
209
+ #
210
+ # Supersedes the 2026-07-29 decision to run one LB and put :3001 in the URL. A port in a
211
+ # hostname is not a URL anyone ships: it breaks the expectation that https means 443, it
212
+ # has to be explained to every operator, and it leaks an implementation detail of the
213
+ # ingress into the address bar. The reason for it was real — DigitalOcean load balancers
214
+ # route on PORT only, never on the Host header, so one LB genuinely cannot serve two
215
+ # hostnames — but the conclusion was wrong. The right answer to "the product cannot do
216
+ # this" is a second load balancer, not a worse address.
217
+ #
218
+ # ~$12/month, and only when a domain is set AND Canvas is public. Domainless universes
219
+ # keep the second port on the main LB above: with no hostname there is nothing to route by,
220
+ # so a second LB would buy nothing.
221
+ resource "digitalocean_loadbalancer" "canvas" {
222
+ count = local.has_domain && var.canvas_public ? 1 : 0
223
+ name = "${var.name}-canvas-lb"
224
+ region = var.region
225
+ droplet_ids = [digitalocean_droplet.app.id]
226
+ redirect_http_to_https = true
227
+ http_idle_timeout_seconds = 600
228
+
229
+ forwarding_rule {
230
+ entry_port = 443
231
+ entry_protocol = "https"
232
+ target_port = 3001
233
+ target_protocol = "http"
234
+ certificate_name = one(digitalocean_certificate.api[*].name)
235
+ }
236
+
237
+ # Canvas has no /health of its own; the root answering is the liveness signal.
238
+ healthcheck {
239
+ port = 3001
240
+ protocol = "http"
241
+ path = "/"
242
+ }
243
+ }
244
+
209
245
  resource "digitalocean_record" "canvas" {
210
246
  count = local.has_domain && var.canvas_public && var.manage_dns ? 1 : 0
211
247
  domain = var.domain
212
248
  type = "A"
213
249
  name = "canvas"
214
- value = digitalocean_loadbalancer.public.ip
250
+ value = digitalocean_loadbalancer.canvas[0].ip
215
251
  ttl = 300
216
252
  }
217
253
 
@@ -44,7 +44,7 @@ output "lb_ip" {
44
44
 
45
45
  output "canvas_url" {
46
46
  description = "Public Canvas URL (canvas_public = true only) — add it to the IdP's allowed origins."
47
- value = var.canvas_public ? (local.has_domain ? "https://canvas.${var.domain}:3001" : "http://${digitalocean_loadbalancer.public.ip}:3001") : "canvas is admin-only (direct http://<droplet-ip>:3001 from admin_cidr)"
47
+ 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)"
48
48
  }
49
49
 
50
50
  output "api_url" {
@@ -35,6 +35,9 @@ locals {
35
35
  local.provision_pg ? lookup(local.prices, local.s.pg, 0) : 0,
36
36
  lookup(local.prices, local.s.redis, 0),
37
37
  local.prices["lb"],
38
+ # Canvas's own load balancer, so its URL needs no port. Only with a domain: without
39
+ # one it shares the main LB's IP on a second port and costs nothing extra.
40
+ local.has_domain && var.canvas_public ? local.prices["lb"] : 0,
38
41
  ])
39
42
  }
40
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
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",