unoverse 0.1.108 → 0.1.110

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
 
@@ -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.108",
3
+ "version": "0.1.110",
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",