unoverse 0.1.109 → 0.1.111

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/lib/urls.mjs CHANGED
@@ -60,20 +60,30 @@ function findUniverseRoot() {
60
60
  }
61
61
  }
62
62
 
63
- /** The ground's terraform outputs, when a state is present. Best-effort. */
64
- function terraformOutputs(root) {
65
- for (const ground of ["infra/digitalocean", "infra/aws"]) {
66
- const dir = join(root, ground);
63
+ /**
64
+ * EVERY ground with state, not the first one found.
65
+ *
66
+ * This looped ["infra/digitalocean", "infra/aws"] and RETURNED on the first match, so an
67
+ * operator running both clouds was shown DigitalOcean and told nothing about AWS — the same
68
+ * first-match bug that let `unoverse destroy` aim at the wrong cloud. "Where is my universe"
69
+ * has two answers when there are two universes.
70
+ *
71
+ * Returns [{ ground, outputs }], in the order the grounds are listed.
72
+ */
73
+ function allGroundOutputs(root) {
74
+ const found = [];
75
+ for (const ground of ["digitalocean", "aws"]) {
76
+ const dir = join(root, "infra", ground);
67
77
  if (!existsSync(join(dir, "terraform.tfstate")) && !existsSync(join(dir, ".terraform"))) continue;
68
78
  const r = spawnSync("terraform", ["output", "-json"], { cwd: dir, encoding: "utf8" });
69
- if (r.status === 0) {
70
- try {
71
- const out = JSON.parse(r.stdout);
72
- return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, v.value]));
73
- } catch { /* fall through */ }
74
- }
79
+ if (r.status !== 0) continue;
80
+ try {
81
+ const out = JSON.parse(r.stdout);
82
+ const values = Object.fromEntries(Object.entries(out).map(([k, v]) => [k, v.value]));
83
+ if (Object.keys(values).length) found.push({ ground, outputs: values });
84
+ } catch { /* a ground that cannot be read is simply not shown */ }
75
85
  }
76
- return {};
86
+ return found;
77
87
  }
78
88
 
79
89
  /**
@@ -388,6 +388,10 @@ resource "time_sleep" "iam_propagation" {
388
388
 
389
389
  resource "aws_lambda_function" "pretoken" {
390
390
  depends_on = [time_sleep.iam_propagation]
391
+ # The role → permission map, so the Lambda can emit both claims from one group list.
392
+ environment {
393
+ variables = { ROLE_PERMISSIONS = jsonencode(local.all_roles) }
394
+ }
391
395
  function_name = "${var.name}-pretoken"
392
396
  role = aws_iam_role.pretoken.arn
393
397
  runtime = "nodejs20.x"
@@ -459,18 +463,39 @@ resource "aws_cognito_user_pool" "pool" {
459
463
  # comes from var.roles — the deployment's own `noun:verb` role list, matched by
460
464
  # node manifests' `requires.role` (DECLARATIVE_NODES.md §9.13).
461
465
  locals {
462
- platform_roles = {
463
- "workflow:author" = "May use the hosted workflow builder"
464
- "marketplace:publish" = "May publish items to this universe"
465
- }
466
- all_roles = merge(local.platform_roles, { for r in var.roles : r => "requires.role gate: ${r}" })
466
+ # TWO LEVELS, BECAUSE THE PLATFORM READS TWO CLAIMS.
467
+ #
468
+ # Auth0 (the DigitalOcean ground) models this properly and is the standing contract:
469
+ #
470
+ # ROLE admin → permission admin:access
471
+ # ROLE developer → permissions marketplace:publish, workflow:author, workflow:promote
472
+ #
473
+ # Canvas gates on `roles` containing "admin" (apps/canvas/src/App.jsx). The publish and
474
+ # builder gates read `permissions` for marketplace:publish and workflow:author
475
+ # (auth/publishGate.ts, auth/auth.ts). Roles are who somebody is; permissions are what
476
+ # they may do, and the platform never confuses them.
477
+ #
478
+ # Cognito has ONE level — groups — so the mapping lives here and the Lambda applies it.
479
+ # This ground previously made GROUPS the permissions and emitted them as both claims, so
480
+ # a pool had workflow:author and no admin: every gate passed except the one that decides
481
+ # whether you may open Canvas at all.
482
+ role_permissions = {
483
+ admin = ["admin:access", "workflow:author", "marketplace:publish", "workflow:promote"]
484
+ developer = ["workflow:author", "marketplace:publish", "workflow:promote"]
485
+ }
486
+ # Extra roles a deployment invents carry themselves as their own permission, which is what
487
+ # a node's `requires.role` matches.
488
+ all_roles = merge(
489
+ local.role_permissions,
490
+ { for r in var.roles : r => [r] },
491
+ )
467
492
  }
468
493
 
469
494
  resource "aws_cognito_user_group" "roles" {
470
495
  for_each = local.all_roles
471
496
  name = each.key
472
497
  user_pool_id = aws_cognito_user_pool.pool.id
473
- description = each.value
498
+ description = "Grants: ${join(", ", each.value)}"
474
499
  }
475
500
 
476
501
  # The initial ADMIN — the first human in the universe. Without this, a fresh pool
@@ -13,13 +13,31 @@
13
13
  * `permissions`: the platform's builder/publish gates read permissions, node
14
14
  * requires.role matches either.
15
15
  */
16
+ // The role → permission map, from the ground (ROLE_PERMISSIONS, set by terraform). Its
17
+ // absence is not an error: without it a group grants itself, which is the old behaviour.
18
+ const ROLE_PERMISSIONS = (() => {
19
+ try { return JSON.parse(process.env.ROLE_PERMISSIONS ?? "{}"); } catch { return {}; }
20
+ })();
21
+
16
22
  export const handler = async (event) => {
17
23
  const groups = event.request.groupConfiguration?.groupsToOverride ?? [];
18
24
  const email = event.request.userAttributes?.email;
19
25
 
26
+ // ROLES ARE WHO YOU ARE; PERMISSIONS ARE WHAT YOU MAY DO. Cognito has one level — groups
27
+ // — and the platform reads two claims: Canvas gates on `roles` containing "admin", while
28
+ // the publish and builder gates read `permissions` for marketplace:publish and
29
+ // workflow:author. Emitting groups as BOTH made every group a role and a permission at
30
+ // once, so a pool built from permission-shaped groups had no "admin" and Canvas refused
31
+ // an administrator who held every permission it grants.
32
+ //
33
+ // Groups are the roles. Permissions are what those roles map to, unioned.
34
+ const permissions = [...new Set(
35
+ groups.flatMap((g) => ROLE_PERMISSIONS[g] ?? [g]),
36
+ )];
37
+
20
38
  const claims = {
21
39
  roles: groups,
22
- permissions: groups,
40
+ permissions,
23
41
  ...(email ? { email } : {}),
24
42
  };
25
43
 
@@ -54,6 +54,6 @@ roles = [
54
54
  # can bring them back. Never commit it; keep a safe copy with your database backups.
55
55
  # To change any value: edit this file and re-render. Never hand-edit the output.
56
56
 
57
- # Marketplace catalogue to install from (docs/architecture/MARKETPLACE.md).
58
- # Leave unset for local items only.
59
- # marketplace_url = "https://marketplace.example.com"
57
+ # Marketplace catalogue. Defaults to the official one — set this only to point somewhere
58
+ # else, or to "" for local items only.
59
+ # marketplace_url = "https://your-own-marketplace.example.com"
@@ -92,15 +92,20 @@ variable "hyperbrowser_api_key" {
92
92
  sensitive = true
93
93
  }
94
94
 
95
- # The catalogue this universe installs from (MARKETPLACE.md §5). NO DEFAULT, deliberately:
96
- # a URL is never hardcoded and never a fallback, and absent config means no remote
97
- # catalogue — the universe serves what it has on disk and in its rows, which is the correct
98
- # state for development and for air-gapped estates. It reaches the marketplace at install
99
- # time and at no other time.
95
+ # The catalogue this universe installs from (MARKETPLACE.md §5).
96
+ #
97
+ # DEFAULTS TO THE OFFICIAL MARKETPLACE (decided 2026-08-03). It used to have none, on the
98
+ # rule that a URL is never hardcoded — right for a URL only the operator can know, wrong for
99
+ # this one: the official catalogue is the platform's own address, the same for every
100
+ # universe, and requiring each developer to paste it made "install from the marketplace" a
101
+ # setup step instead of a thing that works.
102
+ #
103
+ # Set it to your own to point elsewhere, or to "" for local items only — which is still the
104
+ # correct state for an air-gapped estate, and still not an error.
100
105
  variable "marketplace_url" {
101
106
  description = "Base URL of the marketplace catalogue. Empty = local items only."
102
107
  type = string
103
- default = ""
108
+ default = "https://unoverse-marketplace-4hlb9.ondigitalocean.app"
104
109
  }
105
110
 
106
111
  # YOUR public key, uploaded as this universe's EC2 key pair. Deploying means ssh-ing from
@@ -51,6 +51,6 @@ openai_api_key = "sk-..." # memory server + OpenAI nodes
51
51
  # can bring them back. Never commit it; keep a safe copy with your database backups.
52
52
  # To change any value: edit this file and re-render. Never hand-edit the output.
53
53
 
54
- # Marketplace catalogue to install from (docs/architecture/MARKETPLACE.md).
55
- # Leave unset for local items only.
56
- # marketplace_url = "https://marketplace.example.com"
54
+ # Marketplace catalogue. Defaults to the official one — set this only to point somewhere
55
+ # else, or to "" for local items only.
56
+ # marketplace_url = "https://your-own-marketplace.example.com"
@@ -126,13 +126,18 @@ variable "hyperbrowser_api_key" {
126
126
  sensitive = true
127
127
  }
128
128
 
129
- # The catalogue this universe installs from (MARKETPLACE.md §5). NO DEFAULT, deliberately:
130
- # a URL is never hardcoded and never a fallback, and absent config means no remote
131
- # catalogue — the universe serves what it has on disk and in its rows, which is the correct
132
- # state for development and for air-gapped estates. It reaches the marketplace at install
133
- # time and at no other time.
129
+ # The catalogue this universe installs from (MARKETPLACE.md §5).
130
+ #
131
+ # DEFAULTS TO THE OFFICIAL MARKETPLACE (decided 2026-08-03). It used to have none, on the
132
+ # rule that a URL is never hardcoded — right for a URL only the operator can know, wrong for
133
+ # this one: the official catalogue is the platform's own address, the same for every
134
+ # universe, and requiring each developer to paste it made "install from the marketplace" a
135
+ # setup step instead of a thing that works.
136
+ #
137
+ # Set it to your own to point elsewhere, or to "" for local items only — which is still the
138
+ # correct state for an air-gapped estate, and still not an error.
134
139
  variable "marketplace_url" {
135
140
  description = "Base URL of the marketplace catalogue. Empty = local items only."
136
141
  type = string
137
- default = ""
142
+ default = "https://unoverse-marketplace-4hlb9.ondigitalocean.app"
138
143
  }
@@ -322,7 +322,6 @@ roles = [
322
322
  "workflow:author", # build and test workflows (builder gate — ENFORCED)
323
323
  "marketplace:publish", # publish assets to this universe (publish gate — ENFORCED)
324
324
  "workflow:promote", # promote a draft to active / go-live (declared only)
325
- "admin:access", # admin to Unoverse Canvas (declared only)
326
325
  ]
327
326
 
328
327
  # The rendered output (terraform output -raw env_production > ../../.env.production)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.109",
3
+ "version": "0.1.111",
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",