@unoverse-platform/studio 0.1.90 → 0.1.92

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 (132) hide show
  1. package/local/plugin.ts +21 -65
  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 +153 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +114 -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 +234 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +138 -0
  13. package/vendor/base/src/auth/auth.ts +307 -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 +155 -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 +659 -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 +397 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +246 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +212 -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.d.mts +28 -0
  53. package/vendor/base/src/lint/nodes/index.mjs +82 -0
  54. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  55. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  56. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  57. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  58. package/vendor/base/src/logger.ts +33 -0
  59. package/vendor/base/src/manifests/compose.ts +539 -0
  60. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  61. package/vendor/base/src/manifests/executor/context.ts +110 -0
  62. package/vendor/base/src/manifests/executor/index.ts +47 -0
  63. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  64. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  65. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  66. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  67. package/vendor/base/src/manifests/executor/send.ts +65 -0
  68. package/vendor/base/src/manifests/executor/session.ts +46 -0
  69. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  70. package/vendor/base/src/manifests/index.ts +280 -0
  71. package/vendor/base/src/manifests/integrity.ts +93 -0
  72. package/vendor/base/src/manifests/rows.ts +33 -0
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  74. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  75. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  79. package/vendor/base/src/manifests/runtime/context.ts +156 -0
  80. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  81. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  82. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  83. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  84. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  85. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  86. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  87. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  88. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  89. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  90. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  91. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  92. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  93. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  94. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  95. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  96. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  97. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  98. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  99. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  100. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  101. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
  102. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  103. package/vendor/base/src/manifests/source.ts +196 -0
  104. package/vendor/base/src/manifests/watch.ts +67 -0
  105. package/vendor/base/src/markdown/document.ts +141 -0
  106. package/vendor/base/src/paths.ts +180 -0
  107. package/vendor/base/src/platform/audioManager.ts +100 -0
  108. package/vendor/base/src/platform/clientTransport.ts +65 -0
  109. package/vendor/base/src/platform/executionContext.ts +157 -0
  110. package/vendor/base/src/platform/index.ts +10 -0
  111. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  112. package/vendor/base/src/platform/redis.ts +54 -0
  113. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  114. package/vendor/base/src/pluginBase.ts +248 -0
  115. package/vendor/base/src/plugins/README.md +57 -0
  116. package/vendor/base/src/plugins/discovery.ts +87 -0
  117. package/vendor/base/src/plugins/index.ts +16 -0
  118. package/vendor/base/src/plugins/loader.ts +179 -0
  119. package/vendor/base/src/plugins/redis.ts +36 -0
  120. package/vendor/base/src/plugins/state.ts +53 -0
  121. package/vendor/base/src/registry.ts +95 -0
  122. package/vendor/base/src/security/permissions.ts +93 -0
  123. package/vendor/base/src/security/rules.ts +73 -0
  124. package/vendor/base/src/template/SafeExpression.ts +556 -0
  125. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  126. package/vendor/base/src/template/index.ts +22 -0
  127. package/vendor/base/src/types.ts +50 -0
  128. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  129. package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
  130. package/vendor/base/src/workflow/routing.ts +122 -0
  131. package/vendor/sdk/lib/core/store.ts +26 -6
  132. package/local/mcp.ts +0 -265
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The shapes the harness passes around. No behaviour, so every other file here can import
3
+ * it without importing anything else.
4
+ */
5
+
6
+ /**
7
+ * AGENT MCP HARNESS — the model-agnostic half of a conversational agent's tool loop.
8
+ *
9
+ * Every agent family (OpenAI, GLM, Grok, …) shares the SAME platform behavior:
10
+ * - which spatial-search results become callable tools (node-MCP rows + Unoverse
11
+ * app rows carrying the manifest call contract),
12
+ * - how an interactive APP tool presents itself to the model (the etiquette lives
13
+ * in the tool DESCRIPTION — the only channel that reaches a model for a tool
14
+ * minted MID-conversation; instructions are built once at loop start),
15
+ * - when a tool call ENDS the agent's turn (provenance + result shape, never a
16
+ * hardcoded name list).
17
+ *
18
+ * This module is that behavior, once — `@unoverse-platform/base/agent-mcp`.
19
+ * Agent families keep only their streaming adapters (wire shapes, chunk handling,
20
+ * token accounting) and map their tool-call objects into `AgentToolExchange` to
21
+ * consume it. Fixes land here and reach every family — the previous state
22
+ * (openai-only, GLM unable to discover apps at all, Grok forked) is exactly what
23
+ * this exists to prevent. A conformance guard fails the build if an agent family
24
+ * re-implements this logic instead of importing it.
25
+ *
26
+ * THE ONE-WIRE-CALL RULE — where our code ends and native MCP begins. Everything here
27
+ * is pure interpretation/routing and never touches the MCP wire, EXCEPT
28
+ * `invokeComponentAppNative` — the single function that opens an MCP client and makes a
29
+ * real `tools/call`. From that call onward it is all native MCP (the server renders +
30
+ * elicits; the answers resolve the call). Node-MCP rows and path-A workflow apps route
31
+ * in-process via the caller's `api.callService` because they have a node/workflow to run;
32
+ * only path-B component apps (nothing local to call) ride the wire. Full map:
33
+ * docs/MCP_COMPLETE_GUIDE.md → "where YOUR code ends and native MCP begins".
34
+ */
35
+
36
+ /** One executed tool call, reduced to what the harness needs: its name and the
37
+ * raw result content string the model will see. */
38
+ export interface AgentToolExchange {
39
+ name: string;
40
+ resultContent: string;
41
+ }
42
+
43
+ /** A spatial-discovered MCP, normalized: node-MCP rows and Unoverse app rows both
44
+ * come out in this shape, ready to mint as a model tool via `toolDefFromDiscoveredMCP`. */
45
+ export interface DiscoveredMCP {
46
+ id: string;
47
+ title: string;
48
+ description: string;
49
+ workflowId: string;
50
+ nodeId: string;
51
+ methodName: string;
52
+ /** Full method schema ({ methods: { [name]: { description, input } } }). */
53
+ schema?: any;
54
+ /**
55
+ * PATH B (UNOVERSE_MCP_APP_PROTOCOL §0.1 / §4b): a self-contained component app —
56
+ * an `app` URI but NO workflow. Holds the app-tool id the adapter hands to
57
+ * `invokeComponentAppNative` (the held native `/mcp` call) instead of firing
58
+ * `workflow@trigger`; `workflowId`/`nodeId` are empty. Undefined for node-MCP + path-A apps.
59
+ */
60
+ component?: string;
61
+ /** PATH B: the app's resource URI (`unoverse://components/[<org>/]<name>`) — the ONLY
62
+ * schema handle. The tool's input schema is read LIVE from `<appUri>/schema` at mint
63
+ * time (`resolveToolDefNative`); spatial rows carry no schema copy. */
64
+ appUri?: string;
65
+ }
66
+
67
+ /** A neutral function-tool definition; each family maps this to its wire format. */
68
+ export interface AgentToolDef {
69
+ name: string;
70
+ description: string;
71
+ parameters: Record<string, unknown>;
72
+ }
73
+
74
+ /** Mint the model-facing tool definition for a discovered MCP. */
75
+
76
+ /** Context a held app call needs to reach the human on the live session. */
77
+ export interface AppInvocationContext {
78
+ /** The caller's stream session — where the wizard renders and elicitation is answered. */
79
+ conversationId?: string;
80
+ userId?: string;
81
+ /** The current TURN id — the component renders in this turn's bubble. */
82
+ chatId?: string;
83
+ /** Seeds the elicitation prompt; the app tool schema requires the field (may be ""). */
84
+ message?: string;
85
+ /** Content-tree app rows: the row's content, seeded as-is into the component's state. */
86
+ props?: Record<string, unknown>;
87
+ /** Content-tree app rows: the ROW id — keys the render `<component>:<instanceId>` so multiple rows sharing a component don't collide. */
88
+ instanceId?: string;
89
+ /** These props are ROW DATA, not a model's composition — skip the brief referee. */
90
+ fromRow?: boolean;
91
+ /** Deliver the component's SHELL only: render it in its declared state and return
92
+ * at once — never collect its outputs (§12e "Discovery delivers; it never invokes"). */
93
+ shell?: boolean;
94
+ /** The delivery batch this render belongs to (UNOVERSE_TEMPLATE_MODEL §12e): the
95
+ * server's director judges the WHOLE batch before anything renders. `size` is how
96
+ * many siblings to wait for; `index` is this render's arrival position. */
97
+ batch?: { id: string; size: number; index: number };
98
+ /** Forwarded as a Bearer token so the internal `/mcp` call rides the gated lane too. */
99
+ accessToken?: string;
100
+ /** Internal MCP base; defaults to UNOVERSE_SERVICE_URL or http://localhost:4106. */
101
+ baseUrl?: string;
102
+ }
103
+
104
+ /**
105
+ * NATIVE MCP APP INVOCATION (UNOVERSE_MCP_APP_PROTOCOL §3.3 / §4b) — the held
106
+ * slow-call that renders a component app and returns the user's submitted answers to
107
+ * the CALLING model.
108
+ *
109
+ * A bindingless component app has no workflow to fire. Calling it is ONE held
110
+ * `tools/call` to the platform's internal MCP server (`/mcp`): the server registered the
111
+ * app as a tool (mcpServer `listApps()`), renders the component on the caller's live
112
+ * session and ELICITS its declared `outputs` block, then resolves the call with
113
+ * `{ output }`. No polling and no timeout by design — an abandoned form is a tool call
114
+ * that never returns (the SDK's 60s default is lifted to 24h). The answers come back as
115
+ * the tool result so the model continues in-turn.
116
+ *
117
+ * This is the modern replacement for the legacy engine's
118
+ * `MCPInvoker.awaitAppOutputNative`: same native slow-call, but driven from this shared
119
+ * harness (every agent family) using the app id it parsed at mint time — so it needs NO
120
+ * server-side (XState) schema cache.
121
+ */
122
+ /** Open an MCP client on the platform's internal `/mcp` — the ONE wire-connect shared by
123
+ * every native call this harness makes (app invocation, live schema read). */
124
+
125
+ /** A content-tree row's attached COMPONENT, ready to render — see rowComponentsFromResults. */
126
+ export interface RowComponent {
127
+ /** The row's identity — dedupe key (one render per row, ever). */
128
+ id: string;
129
+ /** The component app-tool id (`unoverse://components/<id>` → `<id>`). */
130
+ component: string;
131
+ /** The row's content AS-IS — the component's state (binds resolve by name). */
132
+ props: Record<string, unknown>;
133
+ /** A SHELL delivery (TEMPLATE_MODEL §12e "One ladder, one delivery"): the row is a
134
+ * component that takes input (a manifest, no workflow). It arrives in the same
135
+ * delivery as the cards, in its declared state, with its own defaults and NO row
136
+ * props; it is never invoked here — its form opens only on the model's call. */
137
+ shell?: boolean;
138
+ }
@@ -0,0 +1,307 @@
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
+ /**
87
+ * VERIFIED TOKENS, REMEMBERED UNTIL THEY EXPIRE. A token is immutable: the same string
88
+ * verifies to the same user every time until `exp`, and nothing here checks revocation
89
+ * on the way (it never did). Yet every request on /mcp, /stream, /api/* and every
90
+ * per-click user_action paid a full RS256 verify, and the proxy and trigger paths paid
91
+ * it TWICE (gate, then enrichment). Bounded and insertion-evicted; an entry is trusted
92
+ * only while `now < exp`, so an expired token is re-verified and refused exactly as
93
+ * before. The JWKS fetch was already cached by jose; this caches the verify.
94
+ */
95
+ const VERIFIED_MAX = 1000;
96
+ /** Cache life for a token that carries no `exp` (jose still verified it). */
97
+ const VERIFIED_NO_EXP_MS = 60_000;
98
+ const verified = new Map<string, { user: AuthUser; until: number }>();
99
+
100
+ /** Validate a bearer token and extract the user, or null if invalid/unconfigured. */
101
+ export async function validateJWT(token: string): Promise<AuthUser | null> {
102
+ const hit = verified.get(token);
103
+ if (hit) {
104
+ if (Date.now() < hit.until) return hit.user;
105
+ verified.delete(token);
106
+ }
107
+ const user = await verifyJWT(token);
108
+ if (user) {
109
+ const exp = typeof user.raw.exp === "number" ? user.raw.exp * 1000 : Date.now() + VERIFIED_NO_EXP_MS;
110
+ if (verified.size >= VERIFIED_MAX) verified.delete(verified.keys().next().value as string);
111
+ verified.set(token, { user, until: exp });
112
+ }
113
+ return user;
114
+ }
115
+
116
+ async function verifyJWT(token: string): Promise<AuthUser | null> {
117
+ const jwksSet = getJWKS();
118
+ const issuers = getIssuerCandidates();
119
+ if (!jwksSet || issuers.length === 0) {
120
+ console.warn("[auth] OIDC not configured (AUTH_ISSUER missing)");
121
+ return null;
122
+ }
123
+ try {
124
+ // AUDIENCE IS CHECKED HERE, NOT BY jose, BECAUSE PROVIDERS NAME IT DIFFERENTLY.
125
+ //
126
+ // Auth0 access tokens carry the API identifier in `aud`. Cognito access tokens have NO
127
+ // `aud` at all — the same value lives in `client_id`, and `aud` appears only on its ID
128
+ // tokens. Verified 2026-08-03 against a real token: {iss, client_id, token_use:"access",
129
+ // ...} with no aud. So `jwtVerify(..., { audience })` can never pass on AWS, and every
130
+ // authenticated request there failed with `missing required "aud" claim` on a
131
+ // deployment that was otherwise entirely correct.
132
+ //
133
+ // Signature, issuer and expiry are still jose's and unchanged. Only the comparison moves
134
+ // out, so it can read whichever claim the provider used. A token matching neither is
135
+ // rejected exactly as before — this looks in two places, it does not accept fewer.
136
+ const { payload } = await jwtVerify(token, jwksSet, { issuer: issuers });
137
+ const expected = getAudience();
138
+ if (expected) {
139
+ const claimed = payload.aud ?? (payload as Record<string, unknown>).client_id;
140
+ const values = Array.isArray(claimed) ? claimed.map(String) : claimed ? [String(claimed)] : [];
141
+ if (!values.includes(expected)) {
142
+ console.error(
143
+ `[auth] JWT validation failed: audience mismatch (expected ${expected}, token had ${values.join(", ") || "none"})`,
144
+ );
145
+ return null;
146
+ }
147
+ }
148
+ return {
149
+ id: payload.sub || "unknown",
150
+ email: parseStringClaim(payload, "email"),
151
+ roles: parseArrayClaim(payload, "roles"),
152
+ permissions: parseArrayClaim(payload, "permissions"),
153
+ raw: payload,
154
+ };
155
+ } catch (error) {
156
+ console.error("[auth] JWT validation failed:", error);
157
+ return null;
158
+ }
159
+ }
160
+
161
+ export type AuthResult = { ok: true; userId: string } | { ok: false };
162
+
163
+ /**
164
+ * Default-deny gate over the Authorization header.
165
+ * - auth DISABLED (dev) → pass as the fixed dev identity (never undefined).
166
+ * - auth ENABLED → require a valid bearer for AUTH_ISSUER/AUTH_AUDIENCE.
167
+ */
168
+ export async function authorize(authHeader: string | null | undefined): Promise<AuthResult> {
169
+ if (!isAuthEnabled()) return { ok: true, userId: devIdentity().id };
170
+ if (!authHeader) return { ok: false };
171
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
172
+ if (!token) return { ok: false };
173
+ const user = await validateJWT(token);
174
+ return user ? { ok: true, userId: user.id } : { ok: false };
175
+ }
176
+
177
+ /**
178
+ * The permission a token must carry to reach the HOSTED workflow builder. Gated on
179
+ * this exact string (Auth0 API permission on the gravity-api audience), NOT on a
180
+ * role name — end-user chat tokens are valid on the same audience, so "authenticated"
181
+ * is not enough; only "authenticated AND may author workflows" passes. This is the
182
+ * platform's first authZ check (docs/AUTH_REMEDIATION.md Phase 1b).
183
+ */
184
+ export const BUILDER_PERMISSION = "workflow:author";
185
+
186
+ export type BuilderAuthResult = { ok: true; user: AuthUser } | { ok: false; reason: "unauthenticated" | "forbidden" };
187
+
188
+ /**
189
+ * Gate for the hosted builder route (public :4105/mcp-builder). Stricter than
190
+ * `authorize` in two ways:
191
+ * - FAIL-CLOSED when auth is disabled: this route must never be open, so an
192
+ * AUTH_ENABLED=false deployment simply cannot serve it (returns forbidden).
193
+ * Local dev uses the ungated LOOPBACK route (:4106) instead — never this one.
194
+ * - Requires the BUILDER_PERMISSION claim, not just a valid bearer.
195
+ * Returns the full user so a later step can bind the session to a workflow grant.
196
+ */
197
+ export async function authorizeBuilder(authHeader: string | null | undefined): Promise<BuilderAuthResult> {
198
+ // No fail-open: unlike `authorize`, disabled auth does NOT admit a dev identity here.
199
+ if (!isAuthEnabled()) return { ok: false, reason: "forbidden" };
200
+ if (!authHeader) return { ok: false, reason: "unauthenticated" };
201
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
202
+ if (!token) return { ok: false, reason: "unauthenticated" };
203
+ const user = await validateJWT(token);
204
+ if (!user) return { ok: false, reason: "unauthenticated" };
205
+ if (!user.permissions.includes(BUILDER_PERMISSION)) return { ok: false, reason: "forbidden" };
206
+ return { ok: true, user };
207
+ }
208
+
209
+ /**
210
+ * NO ADMIN GATE HERE, DELIBERATELY. `ADMIN_ROLE` and `authorizeAdmin` lived at this spot for
211
+ * one day. They were the right fix for the credential store and the wrong shape for the
212
+ * platform: a second bespoke gate beside `authorizeBuilder`, which is how five modules ended
213
+ * up holding five different answers to one question.
214
+ *
215
+ * Authorization now lives in ONE place — `apps/unoverse/server/src/security/` — where a
216
+ * table names every route's requirement and one function reads it. This module keeps what is
217
+ * genuinely shared: token validation, the builder gate that predates the table, and the
218
+ * discovery metadata. See security/policy.ts.
219
+ */
220
+
221
+ /**
222
+ * Public base URL for advertised OAuth metadata. Prefer the configured canonical URL
223
+ * (UNOVERSE_PUBLIC_URL) over the request `Host` header — the header is client-controlled,
224
+ * so reflecting it into discovery metadata is a (minor) spoofing vector.
225
+ */
226
+ function resourceBase(host: string): string {
227
+ const configured = process.env.UNOVERSE_PUBLIC_URL?.replace(/\/$/, "");
228
+ if (configured) return configured;
229
+ const protocol = host.includes("localhost") || host.startsWith("127.") ? "http" : "https";
230
+ return `${protocol}://${host}`;
231
+ }
232
+
233
+ /** The 401 challenge — points clients at the RFC 9728 protected-resource-metadata. */
234
+ export function unauthorizedResponse(host: string): Response {
235
+ const resourceMetadataUrl = `${resourceBase(host)}/.well-known/oauth-protected-resource`;
236
+ return new Response(
237
+ JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized" }, id: null }),
238
+ {
239
+ status: 401,
240
+ headers: {
241
+ "content-type": "application/json",
242
+ "WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`,
243
+ },
244
+ },
245
+ );
246
+ }
247
+
248
+ /** OAuth Authorization Server Metadata (for MCP Inspector / discovery). */
249
+ export function getOAuthAuthorizationServerMetadata(): object {
250
+ const issuer = process.env.AUTH_ISSUER;
251
+ const audience = process.env.AUTH_AUDIENCE;
252
+ return {
253
+ issuer,
254
+ authorization_endpoint: audience
255
+ ? `${issuer}/authorize?audience=${encodeURIComponent(audience)}`
256
+ : `${issuer}/authorize`,
257
+ token_endpoint: `${issuer}/oauth/token`,
258
+ registration_endpoint: `${issuer}/oidc/register`,
259
+ scopes_supported: ["openid", "profile", "email", "offline_access"],
260
+ response_types_supported: ["code"],
261
+ grant_types_supported: ["authorization_code", "refresh_token"],
262
+ code_challenge_methods_supported: ["S256"],
263
+ };
264
+ }
265
+
266
+ /**
267
+ * What a REMOTE client needs to log in to this universe, served publicly.
268
+ *
269
+ * Canvas gets this from `window.__GRAVITY_CONFIG__`, injected into its own page
270
+ * (`apps/canvas/src/App.jsx:14`). A developer's local Studio is a different origin on a
271
+ * different machine, so it cannot read that. It has to ask the universe.
272
+ *
273
+ * This is the whole reason the tooling stays provider-agnostic: Studio hardcodes no
274
+ * issuer and no provider. It asks a universe who authenticates it and follows the answer,
275
+ * so Auth0, Okta, Entra or Keycloak all work with no change here or in Studio.
276
+ *
277
+ * NOTHING SECRET. `clientId` is a public OAuth client identifier by definition, it is
278
+ * already shipped inside Canvas's JavaScript, and it authorises nothing on its own.
279
+ * There is deliberately no client secret: a desktop tool cannot keep one.
280
+ *
281
+ * `authEnabled: false` is a real answer, not a failure. A universe with auth off tells a
282
+ * client not to bother logging in, rather than sending it to an issuer that is not there.
283
+ */
284
+ export function getPublicAuthConfig(): object {
285
+ const issuer = process.env.AUTH_ISSUER;
286
+ const clientId = process.env.AUTH_CLIENT_ID;
287
+ return {
288
+ authEnabled: isAuthEnabled(),
289
+ issuer: issuer ?? null,
290
+ clientId: clientId ?? null,
291
+ audience: process.env.AUTH_AUDIENCE ?? null,
292
+ // The permission a developer's token needs before this universe will accept a
293
+ // publish. Named here so Studio can say "you are logged in but not allowed to
294
+ // publish" instead of failing at the first push.
295
+ publishPermission: "marketplace:publish",
296
+ };
297
+ }
298
+
299
+ /** OAuth 2.0 Protected Resource Metadata (RFC 9728); canonical URL preferred over Host. */
300
+ export function getOAuthProtectedResourceMetadata(host: string): object {
301
+ return {
302
+ resource: resourceBase(host),
303
+ authorization_servers: [process.env.AUTH_ISSUER],
304
+ scopes_supported: ["openid", "profile", "email"],
305
+ bearer_methods_supported: ["header"],
306
+ };
307
+ }
@@ -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
+ }