@unoverse-platform/studio 0.1.90 → 0.1.91

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.
Files changed (130) hide show
  1. package/local/plugin.ts +20 -12
  2. package/package.json +13 -3
  3. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  4. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  5. package/vendor/base/src/agent-mcp/index.ts +44 -0
  6. package/vendor/base/src/agent-mcp/invoke.ts +140 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +111 -0
  8. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  9. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  10. package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +130 -0
  13. package/vendor/base/src/auth/auth.ts +278 -0
  14. package/vendor/base/src/auth/authConfig.ts +73 -0
  15. package/vendor/base/src/boot.ts +245 -0
  16. package/vendor/base/src/definitions/definitions.ts +18 -0
  17. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  18. package/vendor/base/src/definitions/fsCache.ts +128 -0
  19. package/vendor/base/src/definitions/index.ts +19 -0
  20. package/vendor/base/src/definitions/inputs.ts +30 -0
  21. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  22. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  23. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  24. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  25. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  26. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  27. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  28. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  29. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  30. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  31. package/vendor/base/src/definitions/load/load.ts +487 -0
  32. package/vendor/base/src/definitions/model.ts +238 -0
  33. package/vendor/base/src/definitions/registry/catalog.ts +643 -0
  34. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  35. package/vendor/base/src/definitions/theme.ts +383 -0
  36. package/vendor/base/src/executor.ts +284 -0
  37. package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +199 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +198 -0
  42. package/vendor/base/src/lint/context.mjs +56 -0
  43. package/vendor/base/src/lint/design/defs.mjs +46 -0
  44. package/vendor/base/src/lint/design/file.mjs +737 -0
  45. package/vendor/base/src/lint/design/index.d.mts +30 -0
  46. package/vendor/base/src/lint/design/index.mjs +996 -0
  47. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  48. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  49. package/vendor/base/src/lint/design/walk.mjs +444 -0
  50. package/vendor/base/src/lint/index.mjs +28 -0
  51. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  52. package/vendor/base/src/lint/nodes/index.mjs +64 -0
  53. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  54. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  55. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  56. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  57. package/vendor/base/src/logger.ts +33 -0
  58. package/vendor/base/src/manifests/compose.ts +539 -0
  59. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  60. package/vendor/base/src/manifests/executor/context.ts +107 -0
  61. package/vendor/base/src/manifests/executor/index.ts +47 -0
  62. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  63. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  64. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  65. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  66. package/vendor/base/src/manifests/executor/send.ts +65 -0
  67. package/vendor/base/src/manifests/executor/session.ts +46 -0
  68. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  69. package/vendor/base/src/manifests/index.ts +280 -0
  70. package/vendor/base/src/manifests/integrity.ts +93 -0
  71. package/vendor/base/src/manifests/rows.ts +33 -0
  72. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  73. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  74. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  75. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  76. package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
  77. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  78. package/vendor/base/src/manifests/runtime/context.ts +150 -0
  79. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  80. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  81. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  82. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  83. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  84. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  85. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  86. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  87. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  88. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  89. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  90. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  91. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  92. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  93. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  94. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  95. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  96. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  97. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  98. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  99. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  100. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
  101. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  102. package/vendor/base/src/manifests/source.ts +187 -0
  103. package/vendor/base/src/manifests/watch.ts +67 -0
  104. package/vendor/base/src/markdown/document.ts +141 -0
  105. package/vendor/base/src/paths.ts +180 -0
  106. package/vendor/base/src/platform/audioManager.ts +100 -0
  107. package/vendor/base/src/platform/clientTransport.ts +65 -0
  108. package/vendor/base/src/platform/executionContext.ts +154 -0
  109. package/vendor/base/src/platform/index.ts +10 -0
  110. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  111. package/vendor/base/src/platform/redis.ts +54 -0
  112. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  113. package/vendor/base/src/pluginBase.ts +248 -0
  114. package/vendor/base/src/plugins/README.md +57 -0
  115. package/vendor/base/src/plugins/discovery.ts +87 -0
  116. package/vendor/base/src/plugins/index.ts +16 -0
  117. package/vendor/base/src/plugins/loader.ts +179 -0
  118. package/vendor/base/src/plugins/redis.ts +36 -0
  119. package/vendor/base/src/plugins/state.ts +53 -0
  120. package/vendor/base/src/registry.ts +95 -0
  121. package/vendor/base/src/security/permissions.ts +93 -0
  122. package/vendor/base/src/security/rules.ts +73 -0
  123. package/vendor/base/src/template/SafeExpression.ts +556 -0
  124. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  125. package/vendor/base/src/template/index.ts +22 -0
  126. package/vendor/base/src/types.ts +50 -0
  127. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  128. package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
  129. package/vendor/base/src/workflow/routing.ts +122 -0
  130. package/vendor/sdk/lib/core/store.ts +26 -6
@@ -0,0 +1,278 @@
1
+ /**
2
+ * JWT authentication for the Unoverse MCP server.
3
+ *
4
+ * Mirrors apps/mcp-server/src/server/auth/auth.ts — same OIDC/JWT flow as the rest
5
+ * of the platform (docs/AUTH_TOKEN_FLOW.md): validate the access token against the
6
+ * shared AUTH_ISSUER/AUTH_AUDIENCE via JWKS. The only difference is transport: this
7
+ * server speaks MCP over uWS + Web `Request`/`Response`, so the gate is a pure
8
+ * function over the Authorization header rather than node req/res.
9
+ *
10
+ * Secure-MCP per the MCP best-practices/authorization specs: default-deny, 401 +
11
+ * `WWW-Authenticate` pointing at RFC 9728 protected-resource-metadata.
12
+ */
13
+
14
+ import { jwtVerify, createRemoteJWKSet, JWTPayload } from "jose";
15
+ import { isAuthEnabled, devIdentity } from "./authConfig.js";
16
+
17
+ // Read env lazily (not at module load) so it's correct regardless of when .env loads.
18
+ //
19
+ // THE ISSUER SPELLING IS PROVIDER-SPECIFIC AND jose COMPARES `iss` EXACTLY.
20
+ //
21
+ // Auth0 mints `iss` WITH a trailing slash; Cognito mints it WITHOUT one (verified against
22
+ // the pool's own discovery document: issuer =
23
+ // https://cognito-idp.<region>.amazonaws.com/<poolId>, no slash). This used to append a
24
+ // slash unconditionally, which matches Auth0 and can therefore NEVER match Cognito: every
25
+ // authenticated request on an AWS universe failed with `unexpected "iss" claim value` while
26
+ // sign-in, the pool, the client and the token were all correct (BPP, 2026-08-17).
27
+ //
28
+ // So accept BOTH spellings of the one configured issuer rather than one provider's
29
+ // convention. This is the same shape as the `aud`/`client_id` comparison below: it looks in
30
+ // two places, it does not accept fewer. A token from any other issuer is rejected exactly as
31
+ // before, because both candidates are built from AUTH_ISSUER itself.
32
+ const getIssuerBase = (): string => {
33
+ const raw = process.env.AUTH_ISSUER;
34
+ if (!raw || raw === "undefined") return "";
35
+ return raw.endsWith("/") ? raw.slice(0, -1) : raw;
36
+ };
37
+ /** Both spellings of the configured issuer, for jose's exact `iss` comparison. */
38
+ const getIssuerCandidates = (): string[] => {
39
+ const base = getIssuerBase();
40
+ return base ? [base, `${base}/`] : [];
41
+ };
42
+ const getAudience = (): string | undefined => process.env.AUTH_AUDIENCE;
43
+
44
+ let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
45
+ function getJWKS() {
46
+ const base = getIssuerBase();
47
+ if (!jwks && base) {
48
+ jwks = createRemoteJWKSet(new URL(`${base}/.well-known/jwks.json`));
49
+ }
50
+ return jwks;
51
+ }
52
+
53
+ export interface AuthUser {
54
+ id: string;
55
+ email?: string;
56
+ roles: string[];
57
+ permissions: string[];
58
+ raw: JWTPayload;
59
+ }
60
+
61
+ /** Parse array claims, handling Auth0-style namespaced claims (e.g. https://…/roles). */
62
+ function parseArrayClaim(payload: JWTPayload, claimName: string): string[] {
63
+ const direct = payload[claimName];
64
+ if (Array.isArray(direct)) return direct as string[];
65
+ const namespaced = Object.keys(payload).find(
66
+ (k) => k.endsWith(`/${claimName}`) || k.endsWith(`/claims/${claimName}`),
67
+ );
68
+ if (namespaced && Array.isArray(payload[namespaced])) return payload[namespaced] as string[];
69
+ return [];
70
+ }
71
+
72
+ /** Parse a string claim, handling Auth0-style namespaced claims (e.g. https://…/email).
73
+ * Auth0 emits custom claims (email on the access token) ONLY namespaced, so a plain
74
+ * `payload.email` read returns undefined and silently no-ops every email-keyed feature
75
+ * (CRM resolve). Same namespace-aware read as parseArrayClaim. See docs/AUTH_TOKEN_FLOW.md. */
76
+ function parseStringClaim(payload: JWTPayload, claimName: string): string | undefined {
77
+ const direct = payload[claimName];
78
+ if (typeof direct === "string") return direct;
79
+ const namespaced = Object.keys(payload).find(
80
+ (k) => k.endsWith(`/${claimName}`) || k.endsWith(`/claims/${claimName}`),
81
+ );
82
+ if (namespaced && typeof payload[namespaced] === "string") return payload[namespaced] as string;
83
+ return undefined;
84
+ }
85
+
86
+ /** Validate a bearer token and extract the user, or null if invalid/unconfigured. */
87
+ export async function validateJWT(token: string): Promise<AuthUser | null> {
88
+ const jwksSet = getJWKS();
89
+ const issuers = getIssuerCandidates();
90
+ if (!jwksSet || issuers.length === 0) {
91
+ console.warn("[auth] OIDC not configured (AUTH_ISSUER missing)");
92
+ return null;
93
+ }
94
+ try {
95
+ // AUDIENCE IS CHECKED HERE, NOT BY jose, BECAUSE PROVIDERS NAME IT DIFFERENTLY.
96
+ //
97
+ // Auth0 access tokens carry the API identifier in `aud`. Cognito access tokens have NO
98
+ // `aud` at all — the same value lives in `client_id`, and `aud` appears only on its ID
99
+ // tokens. Verified 2026-08-03 against a real token: {iss, client_id, token_use:"access",
100
+ // ...} with no aud. So `jwtVerify(..., { audience })` can never pass on AWS, and every
101
+ // authenticated request there failed with `missing required "aud" claim` on a
102
+ // deployment that was otherwise entirely correct.
103
+ //
104
+ // Signature, issuer and expiry are still jose's and unchanged. Only the comparison moves
105
+ // out, so it can read whichever claim the provider used. A token matching neither is
106
+ // rejected exactly as before — this looks in two places, it does not accept fewer.
107
+ const { payload } = await jwtVerify(token, jwksSet, { issuer: issuers });
108
+ const expected = getAudience();
109
+ if (expected) {
110
+ const claimed = payload.aud ?? (payload as Record<string, unknown>).client_id;
111
+ const values = Array.isArray(claimed) ? claimed.map(String) : claimed ? [String(claimed)] : [];
112
+ if (!values.includes(expected)) {
113
+ console.error(
114
+ `[auth] JWT validation failed: audience mismatch (expected ${expected}, token had ${values.join(", ") || "none"})`,
115
+ );
116
+ return null;
117
+ }
118
+ }
119
+ return {
120
+ id: payload.sub || "unknown",
121
+ email: parseStringClaim(payload, "email"),
122
+ roles: parseArrayClaim(payload, "roles"),
123
+ permissions: parseArrayClaim(payload, "permissions"),
124
+ raw: payload,
125
+ };
126
+ } catch (error) {
127
+ console.error("[auth] JWT validation failed:", error);
128
+ return null;
129
+ }
130
+ }
131
+
132
+ export type AuthResult = { ok: true; userId: string } | { ok: false };
133
+
134
+ /**
135
+ * Default-deny gate over the Authorization header.
136
+ * - auth DISABLED (dev) → pass as the fixed dev identity (never undefined).
137
+ * - auth ENABLED → require a valid bearer for AUTH_ISSUER/AUTH_AUDIENCE.
138
+ */
139
+ export async function authorize(authHeader: string | null | undefined): Promise<AuthResult> {
140
+ if (!isAuthEnabled()) return { ok: true, userId: devIdentity().id };
141
+ if (!authHeader) return { ok: false };
142
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
143
+ if (!token) return { ok: false };
144
+ const user = await validateJWT(token);
145
+ return user ? { ok: true, userId: user.id } : { ok: false };
146
+ }
147
+
148
+ /**
149
+ * The permission a token must carry to reach the HOSTED workflow builder. Gated on
150
+ * this exact string (Auth0 API permission on the gravity-api audience), NOT on a
151
+ * role name — end-user chat tokens are valid on the same audience, so "authenticated"
152
+ * is not enough; only "authenticated AND may author workflows" passes. This is the
153
+ * platform's first authZ check (docs/AUTH_REMEDIATION.md Phase 1b).
154
+ */
155
+ export const BUILDER_PERMISSION = "workflow:author";
156
+
157
+ export type BuilderAuthResult = { ok: true; user: AuthUser } | { ok: false; reason: "unauthenticated" | "forbidden" };
158
+
159
+ /**
160
+ * Gate for the hosted builder route (public :4105/mcp-builder). Stricter than
161
+ * `authorize` in two ways:
162
+ * - FAIL-CLOSED when auth is disabled: this route must never be open, so an
163
+ * AUTH_ENABLED=false deployment simply cannot serve it (returns forbidden).
164
+ * Local dev uses the ungated LOOPBACK route (:4106) instead — never this one.
165
+ * - Requires the BUILDER_PERMISSION claim, not just a valid bearer.
166
+ * Returns the full user so a later step can bind the session to a workflow grant.
167
+ */
168
+ export async function authorizeBuilder(authHeader: string | null | undefined): Promise<BuilderAuthResult> {
169
+ // No fail-open: unlike `authorize`, disabled auth does NOT admit a dev identity here.
170
+ if (!isAuthEnabled()) return { ok: false, reason: "forbidden" };
171
+ if (!authHeader) return { ok: false, reason: "unauthenticated" };
172
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
173
+ if (!token) return { ok: false, reason: "unauthenticated" };
174
+ const user = await validateJWT(token);
175
+ if (!user) return { ok: false, reason: "unauthenticated" };
176
+ if (!user.permissions.includes(BUILDER_PERMISSION)) return { ok: false, reason: "forbidden" };
177
+ return { ok: true, user };
178
+ }
179
+
180
+ /**
181
+ * NO ADMIN GATE HERE, DELIBERATELY. `ADMIN_ROLE` and `authorizeAdmin` lived at this spot for
182
+ * one day. They were the right fix for the credential store and the wrong shape for the
183
+ * platform: a second bespoke gate beside `authorizeBuilder`, which is how five modules ended
184
+ * up holding five different answers to one question.
185
+ *
186
+ * Authorization now lives in ONE place — `apps/unoverse/server/src/security/` — where a
187
+ * table names every route's requirement and one function reads it. This module keeps what is
188
+ * genuinely shared: token validation, the builder gate that predates the table, and the
189
+ * discovery metadata. See security/policy.ts.
190
+ */
191
+
192
+ /**
193
+ * Public base URL for advertised OAuth metadata. Prefer the configured canonical URL
194
+ * (UNOVERSE_PUBLIC_URL) over the request `Host` header — the header is client-controlled,
195
+ * so reflecting it into discovery metadata is a (minor) spoofing vector.
196
+ */
197
+ function resourceBase(host: string): string {
198
+ const configured = process.env.UNOVERSE_PUBLIC_URL?.replace(/\/$/, "");
199
+ if (configured) return configured;
200
+ const protocol = host.includes("localhost") || host.startsWith("127.") ? "http" : "https";
201
+ return `${protocol}://${host}`;
202
+ }
203
+
204
+ /** The 401 challenge — points clients at the RFC 9728 protected-resource-metadata. */
205
+ export function unauthorizedResponse(host: string): Response {
206
+ const resourceMetadataUrl = `${resourceBase(host)}/.well-known/oauth-protected-resource`;
207
+ return new Response(
208
+ JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized" }, id: null }),
209
+ {
210
+ status: 401,
211
+ headers: {
212
+ "content-type": "application/json",
213
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
214
+ },
215
+ },
216
+ );
217
+ }
218
+
219
+ /** OAuth Authorization Server Metadata (for MCP Inspector / discovery). */
220
+ export function getOAuthAuthorizationServerMetadata(): object {
221
+ const issuer = process.env.AUTH_ISSUER;
222
+ const audience = process.env.AUTH_AUDIENCE;
223
+ return {
224
+ issuer,
225
+ authorization_endpoint: audience
226
+ ? `${issuer}/authorize?audience=${encodeURIComponent(audience)}`
227
+ : `${issuer}/authorize`,
228
+ token_endpoint: `${issuer}/oauth/token`,
229
+ registration_endpoint: `${issuer}/oidc/register`,
230
+ scopes_supported: ["openid", "profile", "email", "offline_access"],
231
+ response_types_supported: ["code"],
232
+ grant_types_supported: ["authorization_code", "refresh_token"],
233
+ code_challenge_methods_supported: ["S256"],
234
+ };
235
+ }
236
+
237
+ /**
238
+ * What a REMOTE client needs to log in to this universe, served publicly.
239
+ *
240
+ * Canvas gets this from `window.__GRAVITY_CONFIG__`, injected into its own page
241
+ * (`apps/canvas/src/App.jsx:14`). A developer's local Studio is a different origin on a
242
+ * different machine, so it cannot read that. It has to ask the universe.
243
+ *
244
+ * This is the whole reason the tooling stays provider-agnostic: Studio hardcodes no
245
+ * issuer and no provider. It asks a universe who authenticates it and follows the answer,
246
+ * so Auth0, Okta, Entra or Keycloak all work with no change here or in Studio.
247
+ *
248
+ * NOTHING SECRET. `clientId` is a public OAuth client identifier by definition, it is
249
+ * already shipped inside Canvas's JavaScript, and it authorises nothing on its own.
250
+ * There is deliberately no client secret: a desktop tool cannot keep one.
251
+ *
252
+ * `authEnabled: false` is a real answer, not a failure. A universe with auth off tells a
253
+ * client not to bother logging in, rather than sending it to an issuer that is not there.
254
+ */
255
+ export function getPublicAuthConfig(): object {
256
+ const issuer = process.env.AUTH_ISSUER;
257
+ const clientId = process.env.AUTH_CLIENT_ID;
258
+ return {
259
+ authEnabled: isAuthEnabled(),
260
+ issuer: issuer ?? null,
261
+ clientId: clientId ?? null,
262
+ audience: process.env.AUTH_AUDIENCE ?? null,
263
+ // The permission a developer's token needs before this universe will accept a
264
+ // publish. Named here so Studio can say "you are logged in but not allowed to
265
+ // publish" instead of failing at the first push.
266
+ publishPermission: "marketplace:publish",
267
+ };
268
+ }
269
+
270
+ /** OAuth 2.0 Protected Resource Metadata (RFC 9728); canonical URL preferred over Host. */
271
+ export function getOAuthProtectedResourceMetadata(host: string): object {
272
+ return {
273
+ resource: resourceBase(host),
274
+ authorization_servers: [process.env.AUTH_ISSUER],
275
+ scopes_supported: ["openid", "profile", "email"],
276
+ bearer_methods_supported: ["header"],
277
+ };
278
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Canonical auth flag + startup contract.
3
+ *
4
+ * MIRRORED across services (apps/server, apps/mcp-server, apps/memory-server, and
5
+ * now apps/unoverse) — keep the copies in sync. Single source of truth:
6
+ * docs/AUTH_REMEDIATION.md (Phase 0).
7
+ *
8
+ * Rules:
9
+ * - Default: auth ENABLED. Only an explicit AUTH_ENABLED=false (or the deprecated
10
+ * DISABLE_AUTH=true) turns it off. Missing/typo'd flags read as ENABLED.
11
+ * - "Not configured" is NOT "disabled": if auth is enabled but OIDC is missing,
12
+ * the service refuses to boot (assertAuthStartup) rather than silently allow.
13
+ * - Disabled: a fixed dev identity is injected so downstream code always has auth.
14
+ * - Disabled + NODE_ENV=production: refuse to boot.
15
+ */
16
+
17
+ export interface DevIdentity {
18
+ id: string;
19
+ email: string;
20
+ roles: string[];
21
+ permissions: string[];
22
+ }
23
+
24
+ const isTrue = (v: string | undefined): boolean => v?.toLowerCase() === "true";
25
+
26
+ /** Single source of truth: is JWT enforcement on? Default ON; only explicit opt-out disables. */
27
+ export function isAuthEnabled(): boolean {
28
+ if (isTrue(process.env.DISABLE_AUTH)) return false; // deprecated alias, still honored
29
+ return (process.env.AUTH_ENABLED ?? "true").toLowerCase() !== "false";
30
+ }
31
+
32
+ /** Is an OIDC provider configured (issuer + audience present)? */
33
+ export function isOidcConfigured(): boolean {
34
+ const issuer = process.env.AUTH_ISSUER;
35
+ return !!issuer && issuer !== "undefined" && !!process.env.AUTH_AUDIENCE;
36
+ }
37
+
38
+ /** Fixed identity injected on every request when auth is DISABLED (dev only).
39
+ * Deliberately carries NO roles and NO permissions: any gate accidentally built on
40
+ * `authorize()` instead of a fail-closed wrapper then denies rather than escalates. */
41
+ export function devIdentity(): DevIdentity {
42
+ return { id: "dev-user", email: "dev@local", roles: [], permissions: [] };
43
+ }
44
+
45
+ /**
46
+ * Validate auth configuration at boot. Fails closed:
47
+ * - enabled + OIDC missing → throw (refuse to start)
48
+ * - disabled + production → throw (refuse to start)
49
+ * Returns the resolved mode and logs the true state.
50
+ */
51
+ export function assertAuthStartup(log: (msg: string) => void = console.warn): "enabled" | "disabled" {
52
+ const enabled = isAuthEnabled();
53
+ const isProd = process.env.NODE_ENV === "production";
54
+
55
+ if (enabled && !isOidcConfigured()) {
56
+ throw new Error(
57
+ "[auth] Auth is ENABLED but OIDC is not configured (AUTH_ISSUER/AUTH_AUDIENCE missing). " +
58
+ "Refusing to start. For local dev without auth, set AUTH_ENABLED=false.",
59
+ );
60
+ }
61
+ if (!enabled && isProd) {
62
+ throw new Error(
63
+ "[auth] Auth is DISABLED (AUTH_ENABLED=false / DISABLE_AUTH=true) while NODE_ENV=production. Refusing to start.",
64
+ );
65
+ }
66
+
67
+ log(
68
+ enabled
69
+ ? "[auth] ENABLED — JWT enforced on /mcp + /dev"
70
+ : "[auth] DISABLED — injecting dev identity (non-production only)",
71
+ );
72
+ return enabled ? "enabled" : "disabled";
73
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Boot report — ONE readout at the end of startup, instead of a line per thing loaded.
3
+ *
4
+ * Boot used to narrate itself: 84 node ticks, 24 credential ticks, 27 "local wins"
5
+ * lines, each registration announcing itself as it happened. Two problems with that.
6
+ * It reads as noise rather than as a product, and a real failure (the plugin-state
7
+ * fetch that fails silently below) sits in the middle of it styled exactly like a
8
+ * success tick, so nobody sees it.
9
+ *
10
+ * The rule here: COUNT the healthy, NAME the anomalous. A node that loaded is a
11
+ * number; a node that was skipped, shadowed, disabled or missing metadata is a line
12
+ * with its name on it. Nothing diagnostic is lost, because the per-item detail is
13
+ * still emitted at debug level (LOG_LEVEL=debug) and the full catalog is one
14
+ * `GET /nodes` away.
15
+ *
16
+ * Collect from anywhere during boot, print once from index.ts.
17
+ */
18
+
19
+ const DEBUG = (process.env.LOG_LEVEL ?? "").toLowerCase() === "debug";
20
+
21
+ // The readout is a product surface: labels recede, values read, addresses invite the
22
+ // click. One accent, no boxes, and everything still greps clean because whole segments
23
+ // are wrapped rather than words. NO_COLOR is the standard opt-out.
24
+ const COLOR = !process.env.NO_COLOR;
25
+ const paint = (code: string) => (s: string) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
26
+ const bold = paint("1");
27
+ const dim = paint("2");
28
+ const cyan = paint("36");
29
+ const yellow = paint("33");
30
+ const red = paint("31");
31
+
32
+ type Notice = { level: "warn" | "error"; text: string };
33
+ type Endpoint = { label: string; url: string; note?: string };
34
+
35
+ const state = {
36
+ startedAt: Date.now(),
37
+ packages: 0,
38
+ nodes: [] as string[],
39
+ credentials: [] as string[],
40
+ credentialsSkipped: 0,
41
+ components: null as { node: string; count: number } | null,
42
+ manifests: null as { nodes: number; credentials: number; shadowed: string[]; disabled: string[] } | null,
43
+ pluginStates: 0,
44
+ pluginStateError: null as string | null,
45
+ pluginsLocalWin: [] as string[],
46
+ pluginsFromNpm: [] as string[],
47
+ pluginsDisabled: [] as string[],
48
+ auth: null as string | null,
49
+ redis: null as string | null,
50
+ engine: null as string | null,
51
+ watching: null as string | null,
52
+ endpoints: [] as Endpoint[],
53
+ notices: [] as Notice[],
54
+ printed: false,
55
+ };
56
+
57
+ /** Per-item detail, kept for LOG_LEVEL=debug. Silent otherwise. */
58
+ function detail(line: string): void {
59
+ if (DEBUG) console.log(line);
60
+ }
61
+
62
+ export const boot = {
63
+ packages(n: number) {
64
+ state.packages = n;
65
+ detail(`[boot] discovered ${n} node packages`);
66
+ },
67
+
68
+ node(type: string) {
69
+ state.nodes.push(type);
70
+ detail(`[boot] node ${type}`);
71
+ },
72
+
73
+ credential(name: string, skipped = false) {
74
+ if (skipped) {
75
+ // First-wins is the DESIGNED behaviour for a shared credential (several AWS
76
+ // packages legitimately declare awsCredential), so it is not a warning and
77
+ // never belonged in the readout. It was 48 warning lines a boot.
78
+ state.credentialsSkipped++;
79
+ detail(`[boot] credential ${name} already registered, kept the first`);
80
+ return;
81
+ }
82
+ state.credentials.push(name);
83
+ detail(`[boot] credential ${name}`);
84
+ },
85
+
86
+ components(node: string, count: number) {
87
+ state.components = { node, count };
88
+ },
89
+
90
+ manifests(m: { nodes: number; credentials: number; shadowed: string[]; disabled: string[] }) {
91
+ state.manifests = m;
92
+ },
93
+
94
+ pluginStates(n: number) {
95
+ // Loaded once at boot and again after the engine comes up; the count is the same
96
+ // fact both times, so the report holds one of it rather than printing twice.
97
+ state.pluginStates = n;
98
+ },
99
+
100
+ /**
101
+ * A failed read of the plugin state table. Not a notice on its own: in merged mode the
102
+ * first read races the engine binding :4101 and is retried. It becomes a notice only
103
+ * if no state was ever loaded, which is the case where plugins really are missing.
104
+ */
105
+ pluginStateFetchFailed(message: string) {
106
+ state.pluginStateError = message;
107
+ detail(`[boot] plugin state read failed: ${message}`);
108
+ },
109
+
110
+ pluginLocalWin(name: string, installedVersion?: string) {
111
+ state.pluginsLocalWin.push(name);
112
+ detail(`[boot] ${name}: local source wins over npm${installedVersion ? ` (npm has ${installedVersion})` : ""}`);
113
+ },
114
+
115
+ pluginFromNpm(name: string, source = "npm") {
116
+ state.pluginsFromNpm.push(name);
117
+ detail(`[boot] ${name}: loaded from ${source}`);
118
+ },
119
+
120
+ pluginDisabled(name: string) {
121
+ state.pluginsDisabled.push(name);
122
+ },
123
+
124
+ auth(posture: string) {
125
+ state.auth = posture;
126
+ },
127
+
128
+ redis(status: string) {
129
+ state.redis = status;
130
+ },
131
+
132
+ engine(where: string) {
133
+ state.engine = where;
134
+ },
135
+
136
+ /** Manifest hot reload is a dev affordance worth one line, because it changes what a save does. */
137
+ watching(dir: string) {
138
+ state.watching = dir;
139
+ },
140
+
141
+ endpoint(label: string, url: string, note?: string) {
142
+ state.endpoints.push({ label, url, note });
143
+ },
144
+
145
+ /**
146
+ * Addresses are listed in the order someone reaches for them, not the order the
147
+ * listeners happened to bind. Studio is what a person opens; the internal runtime is
148
+ * what a machine calls.
149
+ */
150
+ endpointOrder: ["Studio", "MCP", "Runtime", "Builder MCP"],
151
+
152
+ notice(level: "warn" | "error", text: string) {
153
+ state.notices.push({ level, text });
154
+ },
155
+
156
+ /**
157
+ * True until the report prints. Loaders that run BOTH at boot and later (the manifest
158
+ * watcher, install/uninstall) ask this: during boot they hand their result to the
159
+ * report, afterwards they log it themselves, because a reload with nothing else on
160
+ * screen is a real event and should say so.
161
+ */
162
+ isBooting() {
163
+ return !state.printed;
164
+ },
165
+
166
+ /** Everything the report knows, for a status route or a test. */
167
+ snapshot() {
168
+ return { ...state };
169
+ },
170
+
171
+ print() {
172
+ if (state.printed) return;
173
+ state.printed = true;
174
+ const ready = ((Date.now() - state.startedAt) / 1000).toFixed(1);
175
+
176
+ const rows: Array<[string, string]> = [];
177
+ rows.push(["Nodes", `${state.nodes.length} registered from ${state.packages} packages`]);
178
+ if (state.manifests) {
179
+ const m = state.manifests;
180
+ const parts = [`${m.nodes} loaded`];
181
+ if (m.shadowed.length) parts.push(`${m.shadowed.length} shadowed by code`);
182
+ if (m.disabled.length) parts.push(`${m.disabled.length} disabled`);
183
+ rows.push(["Manifests", parts.join(", ")]);
184
+ }
185
+ // The skipped count is every re-declaration of a credential that already exists,
186
+ // which is the shared-credential design working. It is a debug fact, not a headline.
187
+ rows.push(["Credentials", `${state.credentials.length} types`]);
188
+ if (state.components) rows.push(["Components", `${state.components.count} in one ${state.components.node} node`]);
189
+ if (state.pluginStates) {
190
+ const parts = [`${state.pluginStates} states`];
191
+ if (state.pluginsLocalWin.length) parts.push(`${state.pluginsLocalWin.length} local over npm`);
192
+ if (state.pluginsFromNpm.length) parts.push(`${state.pluginsFromNpm.length} from npm`);
193
+ if (state.pluginsDisabled.length) parts.push(`${state.pluginsDisabled.length} disabled`);
194
+ rows.push(["Plugins", parts.join(", ")]);
195
+ }
196
+ if (state.engine) rows.push(["Engine", state.engine]);
197
+ if (state.redis) rows.push(["Redis", state.redis]);
198
+ // Last row before the addresses, because it qualifies every one of them.
199
+ if (state.auth) rows.push(["Auth", state.auth]);
200
+ // Trimmed to the repo-relative path: absolute would be the longest string in the
201
+ // block, and the identifying part is the tail. cwd is not usable for this because
202
+ // the server is started from several directories (turbo, docker, the CLI).
203
+ if (state.watching)
204
+ rows.push(["Watching", `${state.watching.replace(/^.*?(?=apps\/)/, "")}, manifests reload on save`]);
205
+
206
+ const labelWidth = Math.max(...rows.map((r) => r[0].length), ...state.endpoints.map((e) => e.label.length));
207
+ const urlWidth = Math.max(0, ...state.endpoints.map((e) => e.url.length));
208
+ // Pad BEFORE painting: ANSI codes have width in padEnd's eyes and none on screen.
209
+ const out: string[] = ["", ` ${bold("unoverse")} ${dim(`ready in ${ready}s`)}`, ""];
210
+ for (const [label, value] of rows) out.push(` ${dim(label.padEnd(labelWidth))} ${value}`);
211
+ if (state.endpoints.length) {
212
+ const rank = (l: string) => {
213
+ const i = boot.endpointOrder.indexOf(l);
214
+ return i === -1 ? boot.endpointOrder.length : i;
215
+ };
216
+ state.endpoints.sort((a, b) => rank(a.label) - rank(b.label));
217
+ out.push("");
218
+ for (const e of state.endpoints) {
219
+ out.push(` ${dim(e.label.padEnd(labelWidth))} ${cyan(e.note ? e.url.padEnd(urlWidth) : e.url)}${e.note ? ` ${dim(e.note)}` : ""}`);
220
+ }
221
+ }
222
+
223
+ // Anomalies are the point of the readout, so they sit at the bottom where the eye
224
+ // lands and they are the only thing here that is not a count.
225
+ if (state.pluginStateError && !state.pluginStates)
226
+ state.notices.push({
227
+ level: "error",
228
+ text: `no plugin state could be read (${state.pluginStateError}), so npm-installed packages did not load`,
229
+ });
230
+
231
+ const anomalies = [
232
+ ...state.notices.map((n) => ` ${n.level === "error" ? red("✗") : yellow("!")} ${n.text}`),
233
+ ...(state.manifests?.shadowed.length
234
+ ? [` ${yellow("!")} code wins over manifest for: ${state.manifests.shadowed.join(", ")}`]
235
+ : []),
236
+ ...(state.pluginsDisabled.length
237
+ ? [` ${yellow("!")} disabled, so not loaded: ${state.pluginsDisabled.join(", ")}`]
238
+ : []),
239
+ ];
240
+ if (anomalies.length) out.push("", ...anomalies);
241
+ out.push("");
242
+ if (!DEBUG) out.push(dim(" LOG_LEVEL=debug lists every node, credential and package as it loads."), "");
243
+ console.log(out.join("\n"));
244
+ },
245
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Unoverse definition store — the FAÇADE. Split 2026-08-30 into organised
3
+ * subfolders (owner: files stay ~500-800 lines, folders group by concern);
4
+ * every existing import of `definitions/definitions.js` keeps working.
5
+ *
6
+ * registry/homes.ts where every kind lives; ref → folder resolution
7
+ * registry/catalog.ts listing, app manifests, state order, workbench views
8
+ * model.ts the served shapes + kind/word normalizers
9
+ * load/load.ts loadDefinition — the load core
10
+ * load/expand.ts Ref/atom composition, $include, state inheritance
11
+ * load/compile/ the component-tree and template compilers, one file per job
12
+ */
13
+ export * from "./registry/homes.js";
14
+ export * from "./registry/catalog.js";
15
+ export * from "./model.js";
16
+ export * from "./load/load.js";
17
+ export * from "./load/expand/index.js";
18
+ export * from "./load/compile/index.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The design system as an INSTALLED PACKAGE, wherever the HOST put it.
3
+ *
4
+ * Every other tier in `definitions.ts` and `theme.ts` is a path under the content home,
5
+ * which is the developer's own project. That is right for a universe, where the design
6
+ * system is installed INTO the deployment, and wrong for Studio, which is a TOOL run
7
+ * against somebody else's folder. Studio depends on `@unoverse-platform/marketplace`, so
8
+ * it already carries the design system in its own node_modules, and then went looking for
9
+ * it in a project that will never have one.
10
+ *
11
+ * A new Studio project therefore had NO design system at all: zero components, zero atoms,
12
+ * no foundation styles (measured against an empty home: 17 components and 30 atoms resolve
13
+ * with this, none without). Primitives still render, so the scaffolded Welcome component
14
+ * looks fine and hides it; the failure surfaces later, on the first `Card` or token. And it
15
+ * is silent either way, because both callers fall through to a path rather than raising.
16
+ *
17
+ * ASKED OF NODE rather than walked, so it is found from wherever the host is installed.
18
+ * NOT a dependency: `marketplace` depends on THIS package, so declaring it would be a
19
+ * cycle. Absent is a normal answer (a universe resolves from a tier above), hence the
20
+ * catch, and the answer is memoized because a failed resolve is the expensive case.
21
+ *
22
+ * ITS OWN MODULE so `definitions.ts` and `theme.ts` share one implementation without
23
+ * importing each other. They resolve different kinds out of the same bundle
24
+ * (components/atoms, and styles), and two copies of this would drift into two answers
25
+ * about where the design system is.
26
+ *
27
+ * SEARCHED LAST by both callers, after every project-local tier, so nothing changes for a
28
+ * host that already resolves: this fires only where the alternative was nothing at all.
29
+ */
30
+ import { join, dirname } from "path";
31
+ import { createRequire } from "module";
32
+
33
+ let cached: string | null | undefined;
34
+
35
+ /** The bundle's `definitions/` folder, or null when the package is not installed. */
36
+ export function packagedDesignSystem(): string | null {
37
+ if (cached !== undefined) return cached;
38
+ try {
39
+ const pkg = createRequire(import.meta.url).resolve("@unoverse-platform/marketplace/package.json");
40
+ cached = join(dirname(pkg), "definitions");
41
+ } catch {
42
+ cached = null;
43
+ }
44
+ return cached;
45
+ }