@unoverse-platform/studio 0.1.98 → 0.1.99

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.98",
3
+ "version": "0.1.99",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -41,11 +41,33 @@ const getIssuerCandidates = (): string[] => {
41
41
  };
42
42
  const getAudience = (): string | undefined => process.env.AUTH_AUDIENCE;
43
43
 
44
- let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
45
- function getJWKS() {
44
+ // WHERE THE SIGNING KEYS ARE IS THE PROVIDER'S TO SAY, in its discovery document.
45
+ //
46
+ // This read `${issuer}/.well-known/jwks.json`, which is where Auth0 and Cognito happen to keep
47
+ // them. Microsoft Entra keeps them under /discovery/v2.0/keys, Keycloak under
48
+ // /protocol/openid-connect/certs, WorkOS AuthKit under /oauth2/jwks, so a bank's Entra or a
49
+ // WorkOS login was refused on every request with a key that could not be found. OIDC
50
+ // discovery names the place (`jwks_uri`). With no discovery document the old location is
51
+ // used, and not remembered, so a transient failure is retried on the next request.
52
+ type KeySet = ReturnType<typeof createRemoteJWKSet>;
53
+ let jwks: Promise<KeySet> | null = null;
54
+ async function loadJWKS(base: string): Promise<{ set: KeySet; discovered: boolean }> {
55
+ try {
56
+ const res = await fetch(`${base}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(5000) });
57
+ const doc: any = res.ok ? await res.json() : null;
58
+ if (typeof doc?.jwks_uri === "string") return { set: createRemoteJWKSet(new URL(doc.jwks_uri)), discovered: true };
59
+ } catch {
60
+ // fall through to the conventional location
61
+ }
62
+ return { set: createRemoteJWKSet(new URL(`${base}/.well-known/jwks.json`)), discovered: false };
63
+ }
64
+ function getJWKS(): Promise<KeySet> | null {
46
65
  const base = getIssuerBase();
47
66
  if (!jwks && base) {
48
- jwks = createRemoteJWKSet(new URL(`${base}/.well-known/jwks.json`));
67
+ jwks = loadJWKS(base).then(({ set, discovered }) => {
68
+ if (!discovered) jwks = null;
69
+ return set;
70
+ });
49
71
  }
50
72
  return jwks;
51
73
  }
@@ -69,6 +91,14 @@ function parseArrayClaim(payload: JWTPayload, claimName: string): string[] {
69
91
  return [];
70
92
  }
71
93
 
94
+ /** Roles as a list. Most providers send a `roles` list (Auth0 namespaced, Entra app roles);
95
+ * WorkOS sends one `role`, the role of the organization membership signed in with. */
96
+ export function parseRoles(payload: JWTPayload): string[] {
97
+ const roles = parseArrayClaim(payload, "roles");
98
+ const one = payload.role;
99
+ return typeof one === "string" && one && !roles.includes(one) ? [...roles, one] : roles;
100
+ }
101
+
72
102
  /** Parse a string claim, handling Auth0-style namespaced claims (e.g. https://…/email).
73
103
  * Auth0 emits custom claims (email on the access token) ONLY namespaced, so a plain
74
104
  * `payload.email` read returns undefined and silently no-ops every email-keyed feature
@@ -114,7 +144,7 @@ export async function validateJWT(token: string): Promise<AuthUser | null> {
114
144
  }
115
145
 
116
146
  async function verifyJWT(token: string): Promise<AuthUser | null> {
117
- const jwksSet = getJWKS();
147
+ const jwksSet = await getJWKS();
118
148
  const issuers = getIssuerCandidates();
119
149
  if (!jwksSet || issuers.length === 0) {
120
150
  console.warn("[auth] OIDC not configured (AUTH_ISSUER missing)");
@@ -148,7 +178,7 @@ async function verifyJWT(token: string): Promise<AuthUser | null> {
148
178
  return {
149
179
  id: payload.sub || "unknown",
150
180
  email: parseStringClaim(payload, "email"),
151
- roles: parseArrayClaim(payload, "roles"),
181
+ roles: parseRoles(payload),
152
182
  permissions: parseArrayClaim(payload, "permissions"),
153
183
  raw: payload,
154
184
  };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * AN ORG IS A WALL (docs/unoverse/UNOVERSE_ORGANISATION.md §11, part 1).
3
+ *
4
+ * Anything built from many orgs' definitions (an MCP session's tools, the spatial
5
+ * registry, the Studio lists) builds each item through here. One that throws is left
6
+ * out and named, `org/item: reason`, and every other org is still served. Observed live
7
+ * 2026-09-23: one org's stale template threw inside MCP session construction and every
8
+ * signed-in session, for every org, answered -32603.
9
+ *
10
+ * The one door, so no builder writes its own try and no builder forgets one.
11
+ */
12
+
13
+ /** Name what was left out and why. The label is `org/item` (or `kind org/item`). */
14
+ export function reportLeftOut(label: string, err: unknown): void {
15
+ const reason = err instanceof Error ? err.message : String(err);
16
+ console.warn(`[unoverse] left out ${label}: ${reason}`);
17
+ }
18
+
19
+ /** Build one item on its own. Undefined when it failed; the failure is reported. */
20
+ export function isolated<T>(label: string, build: () => T): T | undefined {
21
+ try {
22
+ return build();
23
+ } catch (err) {
24
+ reportLeftOut(label, err);
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ /** The same, for a build that awaits. */
30
+ export async function isolatedAsync<T>(label: string, build: () => Promise<T>): Promise<T | undefined> {
31
+ try {
32
+ return await build();
33
+ } catch (err) {
34
+ reportLeftOut(label, err);
35
+ return undefined;
36
+ }
37
+ }
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { parse } from "yaml";
21
21
  import { listOrgs, projectDir } from "./homes.js";
22
+ import { isolated } from "../isolated.js";
22
23
 
23
24
  export const AGENT_CARD = "unoverse://components/agent-card";
24
25
 
@@ -88,7 +89,12 @@ export function listAgentsIn(root: string, org: string): AgentFile[] {
88
89
  if (!existsSync(dir)) return [];
89
90
  return readdirSync(dir, { withFileTypes: true })
90
91
  .filter((e) => e.isFile() && isDef(e.name))
91
- .map((e) => readAgent(root, org, e.name.replace(/\.ya?ml$/, "")))
92
+ // One malformed Agent file leaves out that Agent, never the org's others or any other
93
+ // org's: this list is read inside MCP session construction (UNOVERSE_ORGANISATION.md §11).
94
+ .map((e) => {
95
+ const id = e.name.replace(/\.ya?ml$/, "");
96
+ return isolated(`agent ${org}/${id}`, () => readAgent(root, org, id)) ?? null;
97
+ })
92
98
  .filter((a): a is AgentFile => a !== null)
93
99
  .sort((a, b) => a.name.localeCompare(b.name));
94
100
  }
@@ -13,6 +13,7 @@ import type { UnoverseDefinition } from "../model.js";
13
13
  import { normalizeDeclaredKind, defaultLayoutOf } from "../model.js";
14
14
  import { loadDefinition } from "../load/load.js";
15
15
  import { expandNode } from "../load/expand/index.js";
16
+ import { isolated } from "../isolated.js";
16
17
 
17
18
  /** List definitions of a kind. Atoms come from the ONE shared home. Components come
18
19
  * from the marketplace PLUS the org packs — every org's when `org` is omitted
@@ -404,12 +405,8 @@ function walkApps(org: string | undefined, orgsToWalk: string[]): AppManifest[]
404
405
  // dropped the whole universe, 2026-08-20). One broken app degrades to a skipped
405
406
  // entry with a warning, never a dead platform.
406
407
  const push = (load: () => AppManifest | null, label: string) => {
407
- try {
408
- const m = load();
409
- if (m) out.push(m);
410
- } catch (err) {
411
- console.warn(`[unoverse] skipping app '${label}' — failed to load: ${(err as Error)?.message ?? err}`);
412
- }
408
+ const m = isolated(`app ${label}`, load);
409
+ if (m) out.push(m);
413
410
  };
414
411
  // APP homes, not template homes: the 2026-08-28 rename split templateDirs (the new
415
412
  // Template Model kind) from appDirs (the org apps/ folders) — this walk kept calling
@@ -657,13 +654,16 @@ export function listAppsForWorkbench(org?: string): (UnoverseDefinition & {
657
654
  app?: AppManifest;
658
655
  })[] {
659
656
  return listDefinitions("app", org)
660
- .map((def) => {
657
+ .flatMap((def) => {
661
658
  const ref = `${def.org}/${def.name}`; // folder id = lowercased template name, org-scoped
662
- const app = loadAppManifest(ref);
663
- const states = listStates("app", ref);
664
- return app
665
- ? { ...def, states, binding: app.binding, appName: app.name, autoTrigger: app.autoTrigger, previewComponents: app.previewComponents, fluidHeight: app.fluidHeight, app }
666
- : { ...def, states };
659
+ const entry = isolated(`app ${ref}`, () => {
660
+ const app = loadAppManifest(ref);
661
+ const states = listStates("app", ref);
662
+ return app
663
+ ? { ...def, states, binding: app.binding, appName: app.name, autoTrigger: app.autoTrigger, previewComponents: app.previewComponents, fluidHeight: app.fluidHeight, app }
664
+ : { ...def, states };
665
+ });
666
+ return entry ? [entry] : [];
667
667
  })
668
668
  // Templates view shows only apps that ARE surfaces (manifest defaultState
669
669
  // "template", or a plain layout with no manifest). Component apps (focus /