@unoverse-platform/studio 0.1.89 → 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.
- package/local/plugin.ts +20 -12
- package/package.json +13 -3
- package/screens/TemplatesLibraryView.tsx +31 -6
- package/screens/TemplatesView.tsx +1 -1
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +128 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +643 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.mjs +64 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +107 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +154 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
/** The delivery batch this render belongs to (UNOVERSE_TEMPLATE_MODEL §12e): the
|
|
92
|
+
* server's director judges the WHOLE batch before anything renders. `size` is how
|
|
93
|
+
* many siblings to wait for; `index` is this render's arrival position. */
|
|
94
|
+
batch?: { id: string; size: number; index: number };
|
|
95
|
+
/** Forwarded as a Bearer token so the internal `/mcp` call rides the gated lane too. */
|
|
96
|
+
accessToken?: string;
|
|
97
|
+
/** Internal MCP base; defaults to UNOVERSE_SERVICE_URL or http://localhost:4106. */
|
|
98
|
+
baseUrl?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* NATIVE MCP APP INVOCATION (UNOVERSE_MCP_APP_PROTOCOL §3.3 / §4b) — the held
|
|
103
|
+
* slow-call that renders a component app and returns the user's submitted answers to
|
|
104
|
+
* the CALLING model.
|
|
105
|
+
*
|
|
106
|
+
* A bindingless component app has no workflow to fire. Calling it is ONE held
|
|
107
|
+
* `tools/call` to the platform's internal MCP server (`/mcp`): the server registered the
|
|
108
|
+
* app as a tool (mcpServer `listApps()`), renders the component on the caller's live
|
|
109
|
+
* session and ELICITS its declared `outputs` block, then resolves the call with
|
|
110
|
+
* `{ output }`. No polling and no timeout by design — an abandoned form is a tool call
|
|
111
|
+
* that never returns (the SDK's 60s default is lifted to 24h). The answers come back as
|
|
112
|
+
* the tool result so the model continues in-turn.
|
|
113
|
+
*
|
|
114
|
+
* This is the modern replacement for the legacy engine's
|
|
115
|
+
* `MCPInvoker.awaitAppOutputNative`: same native slow-call, but driven from this shared
|
|
116
|
+
* harness (every agent family) using the app id it parsed at mint time — so it needs NO
|
|
117
|
+
* server-side (XState) schema cache.
|
|
118
|
+
*/
|
|
119
|
+
/** Open an MCP client on the platform's internal `/mcp` — the ONE wire-connect shared by
|
|
120
|
+
* every native call this harness makes (app invocation, live schema read). */
|
|
121
|
+
|
|
122
|
+
/** A content-tree row's attached COMPONENT, ready to render — see rowComponentsFromResults. */
|
|
123
|
+
export interface RowComponent {
|
|
124
|
+
/** The row's identity — dedupe key (one render per row, ever). */
|
|
125
|
+
id: string;
|
|
126
|
+
/** The component app-tool id (`unoverse://components/<id>` → `<id>`). */
|
|
127
|
+
component: string;
|
|
128
|
+
/** The row's content AS-IS — the component's state (binds resolve by name). */
|
|
129
|
+
props: Record<string, unknown>;
|
|
130
|
+
}
|
|
@@ -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
|
+
}
|