@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.
Files changed (132) hide show
  1. package/local/plugin.ts +20 -12
  2. package/package.json +13 -3
  3. package/screens/TemplatesLibraryView.tsx +31 -6
  4. package/screens/TemplatesView.tsx +1 -1
  5. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  6. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  7. package/vendor/base/src/agent-mcp/index.ts +44 -0
  8. package/vendor/base/src/agent-mcp/invoke.ts +140 -0
  9. package/vendor/base/src/agent-mcp/lean.ts +111 -0
  10. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  11. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  12. package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
  13. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  14. package/vendor/base/src/agent-mcp/types.ts +130 -0
  15. package/vendor/base/src/auth/auth.ts +278 -0
  16. package/vendor/base/src/auth/authConfig.ts +73 -0
  17. package/vendor/base/src/boot.ts +245 -0
  18. package/vendor/base/src/definitions/definitions.ts +18 -0
  19. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  20. package/vendor/base/src/definitions/fsCache.ts +128 -0
  21. package/vendor/base/src/definitions/index.ts +19 -0
  22. package/vendor/base/src/definitions/inputs.ts +30 -0
  23. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  24. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  25. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  26. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  27. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  28. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  29. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  30. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  31. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  32. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  33. package/vendor/base/src/definitions/load/load.ts +487 -0
  34. package/vendor/base/src/definitions/model.ts +238 -0
  35. package/vendor/base/src/definitions/registry/catalog.ts +643 -0
  36. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  37. package/vendor/base/src/definitions/theme.ts +383 -0
  38. package/vendor/base/src/executor.ts +284 -0
  39. package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
  40. package/vendor/base/src/items/baseVersion.ts +96 -0
  41. package/vendor/base/src/items/collect.ts +199 -0
  42. package/vendor/base/src/items/fingerprint.ts +34 -0
  43. package/vendor/base/src/items/publish.ts +198 -0
  44. package/vendor/base/src/lint/context.mjs +56 -0
  45. package/vendor/base/src/lint/design/defs.mjs +46 -0
  46. package/vendor/base/src/lint/design/file.mjs +737 -0
  47. package/vendor/base/src/lint/design/index.d.mts +30 -0
  48. package/vendor/base/src/lint/design/index.mjs +996 -0
  49. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  50. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  51. package/vendor/base/src/lint/design/walk.mjs +444 -0
  52. package/vendor/base/src/lint/index.mjs +28 -0
  53. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  54. package/vendor/base/src/lint/nodes/index.mjs +64 -0
  55. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  56. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  57. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  58. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  59. package/vendor/base/src/logger.ts +33 -0
  60. package/vendor/base/src/manifests/compose.ts +539 -0
  61. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  62. package/vendor/base/src/manifests/executor/context.ts +107 -0
  63. package/vendor/base/src/manifests/executor/index.ts +47 -0
  64. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  65. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  66. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  67. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  68. package/vendor/base/src/manifests/executor/send.ts +65 -0
  69. package/vendor/base/src/manifests/executor/session.ts +46 -0
  70. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  71. package/vendor/base/src/manifests/index.ts +280 -0
  72. package/vendor/base/src/manifests/integrity.ts +93 -0
  73. package/vendor/base/src/manifests/rows.ts +33 -0
  74. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  75. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  76. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  77. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  78. package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
  79. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  80. package/vendor/base/src/manifests/runtime/context.ts +150 -0
  81. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  82. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  83. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  84. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  85. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  86. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  87. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  88. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  89. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  90. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  91. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  92. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  93. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  94. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  95. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  96. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  97. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  98. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  99. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  100. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  101. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  102. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
  103. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  104. package/vendor/base/src/manifests/source.ts +187 -0
  105. package/vendor/base/src/manifests/watch.ts +67 -0
  106. package/vendor/base/src/markdown/document.ts +141 -0
  107. package/vendor/base/src/paths.ts +180 -0
  108. package/vendor/base/src/platform/audioManager.ts +100 -0
  109. package/vendor/base/src/platform/clientTransport.ts +65 -0
  110. package/vendor/base/src/platform/executionContext.ts +154 -0
  111. package/vendor/base/src/platform/index.ts +10 -0
  112. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  113. package/vendor/base/src/platform/redis.ts +54 -0
  114. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  115. package/vendor/base/src/pluginBase.ts +248 -0
  116. package/vendor/base/src/plugins/README.md +57 -0
  117. package/vendor/base/src/plugins/discovery.ts +87 -0
  118. package/vendor/base/src/plugins/index.ts +16 -0
  119. package/vendor/base/src/plugins/loader.ts +179 -0
  120. package/vendor/base/src/plugins/redis.ts +36 -0
  121. package/vendor/base/src/plugins/state.ts +53 -0
  122. package/vendor/base/src/registry.ts +95 -0
  123. package/vendor/base/src/security/permissions.ts +93 -0
  124. package/vendor/base/src/security/rules.ts +73 -0
  125. package/vendor/base/src/template/SafeExpression.ts +556 -0
  126. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  127. package/vendor/base/src/template/index.ts +22 -0
  128. package/vendor/base/src/types.ts +50 -0
  129. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  130. package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
  131. package/vendor/base/src/workflow/routing.ts +122 -0
  132. package/vendor/sdk/lib/core/store.ts +26 -6
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Build the tool bridge from the platform's own harness.
3
+ *
4
+ * Every MCP judgement lives in ../../agent-mcp and is reached from here: what a
5
+ * tool is, what a discovery result unlocks, when a turn ends, how much of a result the
6
+ * model needs. The loop only owns the wire format. That split is what stops each agent
7
+ * family re-making these decisions and drifting apart, which is how GLM and Grok ended
8
+ * up unable to launch spatial apps.
9
+ *
10
+ * Returns undefined when nothing granted tools, and the node then runs as a single request.
11
+ */
12
+ import type { ToolBridge } from "../runtime/index.js";
13
+
14
+ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge | undefined> {
15
+ const api = executionContext?.api;
16
+ if (!api?.callService) return undefined;
17
+
18
+ const harness: any = await import("../../agent-mcp/index.js");
19
+
20
+ let schema: any;
21
+ try {
22
+ schema = await api.callService("getSchema", {}, executionContext);
23
+ } catch {
24
+ return undefined; // no MCP provider wired
25
+ }
26
+ if (!schema?.methods) return undefined;
27
+
28
+ const core = new Set<string>(Object.keys(schema.methods));
29
+
30
+ // Identity for a HELD app call: it renders on the caller's live session and elicits
31
+ // from that human, so it needs the conversation, the turn, and the caller's token.
32
+ // Using auth HERE is correct and different from exposing it to a manifest: this is the
33
+ // platform calling its own internal /mcp on the user's behalf, not data deciding where
34
+ // a secret goes.
35
+ const pub = executionContext?.publishingContext;
36
+ const identity = {
37
+ conversationId: pub?.conversationId,
38
+ userId: pub?.userId,
39
+ chatId: pub?.chatId,
40
+ accessToken: executionContext?.auth?.accessToken,
41
+ };
42
+
43
+ /**
44
+ * Discovered PATH-B component apps, by tool name.
45
+ *
46
+ * These are NOT ordinary service calls. UNOVERSE_MCP_APP_PROTOCOL §3.3/§4b: a
47
+ * bindingless component app is invoked through the held `/mcp` call so the server
48
+ * renders it, elicits from the user, and returns their answers AND the
49
+ * post-elicitation guidance to the model. Routing one through callService instead
50
+ * would silently drop elicitation — the app would render and the model would never
51
+ * learn what the human said.
52
+ */
53
+ const appInvokers = new Map<string, (input: unknown) => Promise<unknown>>();
54
+
55
+ /**
56
+ * Which apps this run has already unlocked, by component name.
57
+ *
58
+ * The harness keys the shell-open off this set: an app opens its page ONCE per run, and a
59
+ * later search that surfaces the same row again must not fire a second skeleton over a page
60
+ * the guest is already reading. Owned per bridge, so two agents in one workflow do not
61
+ * share each other's unlocks.
62
+ */
63
+ const discoveredApps = new Set<string>();
64
+
65
+ /**
66
+ * THE GUEST'S OWN WORDS, from the search that is about to unlock a page.
67
+ *
68
+ * A page's `query:` places ask half a sentence each (§9e rule 4) — the half true of every
69
+ * guest — and the platform joins the half that is different for each. This is where that
70
+ * half is picked up: the model wrote the guest's ask into its search args a moment ago, so
71
+ * nothing has to be re-derived or, worse, re-invented.
72
+ *
73
+ * Captured on the CALL and read on the ABSORB, because `absorb` receives only the result.
74
+ * Whoever knows the situation supplies it (§9d) — the shell-open passes it on rather than
75
+ * the server guessing what the conversation was about.
76
+ */
77
+ let lastAsk = "";
78
+
79
+ const stringify = (r: unknown): string => (typeof r === "string" ? r : JSON.stringify(r));
80
+
81
+ /**
82
+ * PAGES ALREADY OPEN IN THIS CONVERSATION (the latch's other half, 2026-09-02).
83
+ *
84
+ * A follow-up send addressed at an open page starts a FRESH agent, and the page's tool
85
+ * was minted in an earlier run and died with it — so the agent could only chat at the
86
+ * page (observed live: three consecutive turns of clarifying questions at a latched
87
+ * page it had no tool to edit). At bridge build the open pages are read from the
88
+ * server's open-apps resource and their tools minted exactly like an unlock mint,
89
+ * minus the shell-open — the page is already on screen. The same resource read RE-ARMS
90
+ * read-first server-side, so this new agent's first authored call is answered with the
91
+ * current page rather than rendered blind. Fail-soft: any error means nothing
92
+ * pre-minted, which is exactly the old behaviour.
93
+ */
94
+ const preMinted: any[] = [];
95
+ if (identity.conversationId) {
96
+ try {
97
+ const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
98
+ try {
99
+ const res = (await client.readResource({ uri: `unoverse://conversations/${identity.conversationId}/open-apps` })) as {
100
+ contents?: Array<{ text?: string }>;
101
+ };
102
+ const open = JSON.parse(res?.contents?.[0]?.text ?? "[]") as Array<{ methodName: string; appUri: string; title?: string; description?: string }>;
103
+ for (const o of open) {
104
+ if (!o?.methodName || !o?.appUri) continue;
105
+ const mcp = {
106
+ id: o.appUri,
107
+ title: o.title ?? "",
108
+ description: `${o.description || o.title || o.methodName} ${harness.APP_ETIQUETTE}`,
109
+ workflowId: "",
110
+ nodeId: "",
111
+ methodName: o.methodName,
112
+ component: harness.componentTypeFromAppUri(o.appUri),
113
+ appUri: o.appUri,
114
+ schema: { methods: {} },
115
+ };
116
+ const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
117
+ appInvokers.set(def.name, harness.componentAppInvoker(mcp, identity));
118
+ discoveredApps.add(mcp.component);
119
+ preMinted.push({
120
+ ...def,
121
+ description:
122
+ `${def.description}\n\nTHIS PAGE IS ALREADY OPEN in this conversation — the user is looking at it now. ` +
123
+ `To act on their message, call this tool with the parts to change; your first call is answered with the current page so you read before editing.`,
124
+ });
125
+ console.log(`[manifests] open page re-minted for this turn: ${def.name}`);
126
+ }
127
+ } finally {
128
+ await close();
129
+ }
130
+ } catch {
131
+ /* no open pages, or a server without the resource — nothing pre-minted */
132
+ }
133
+ }
134
+
135
+ return {
136
+ async discover() {
137
+ return [
138
+ ...Object.entries<any>(schema.methods).map(([name, m]) => ({
139
+ name,
140
+ description: m.description || `Execute ${name}`,
141
+ parameters: m.input || { type: "object", properties: {} },
142
+ })),
143
+ ...preMinted,
144
+ ];
145
+ },
146
+
147
+ async call(name, args) {
148
+ const app = appInvokers.get(name);
149
+ if (app) return stringify(await app(args));
150
+ if (harness.DISCOVERY_TOOL_NAMES.includes(name)) {
151
+ const a = (args ?? {}) as Record<string, unknown>;
152
+ const q = typeof a.query === "string" ? a.query : Array.isArray(a.queries) ? String(a.queries[0] ?? "") : "";
153
+ if (q.trim()) lastAsk = q.trim();
154
+ }
155
+ // THE ARGUMENTS GO OUT AS THE MODEL WROTE THEM. A wire-level rewrite used to strip
156
+ // queries the conversation had already searched; it was removed 2026-08-16 because it
157
+ // silently competed with the tool descriptions for the same decision, and the
158
+ // descriptions are where it belongs. What a turn should search, and whether that turn
159
+ // wants app cards, is the MCP prompt's call — see shared/schema.yaml.
160
+ return stringify(await api.callService(name, args, executionContext));
161
+ },
162
+
163
+ /**
164
+ * THE HARNESS'S OWN ABSORBER, called once — which is what every legacy agent did
165
+ * (`runOpenAIAgent.ts:580`) and what this bridge stopped doing.
166
+ *
167
+ * It was re-implemented here as two halves, a mint and a lean, and the step BETWEEN them
168
+ * was simply absent: the shell-open. `handleDiscoveryResult` says so at the top of itself
169
+ * — "never re-implemented in an adapter" — and the reason is this exact failure. Nothing
170
+ * errored. The tool was minted, the model answered, and the page never appeared until the
171
+ * model composed it, with none of the `__draftRefs` that let the server paint the hero
172
+ * and galleries from the rows the search had already returned.
173
+ *
174
+ * `onAppUnlocked` is where the mint goes now: the harness decides WHEN an app is newly
175
+ * unlocked, and this decides what a tool looks like to the model. Awaited, so the tool is
176
+ * registered before the model's next turn reads it.
177
+ */
178
+ async absorb(toolName, resultContent) {
179
+ const minted: any[] = [];
180
+ const content = await harness.handleDiscoveryResult(
181
+ toolName,
182
+ resultContent,
183
+ discoveredApps,
184
+ identity,
185
+ (m: string) => console.log(`[manifests] ${m}`),
186
+ async (mcp: any) => {
187
+ // The app's input schema is read LIVE from its URI at mint time; spatial rows
188
+ // carry no schema copy. For a BRIEFED component that schema is the instruction
189
+ // channel, so the model hydrates real fields instead of following prose.
190
+ const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
191
+ const invoke = harness.componentAppInvoker(mcp, identity);
192
+ appInvokers.set(def.name, invoke);
193
+
194
+ /**
195
+ * THE PAGE FILLS BEFORE THE WRITER GETS THE PEN (owner ruling 2026-09-01 night).
196
+ *
197
+ * The shell-open renders the page skeleton and runs the page's OWN fill — its
198
+ * searches, the director's picks, the tiles (§9e). It used to be fire-and-forget
199
+ * while the tool was minted immediately, so the model composed captions for a page
200
+ * it had never seen; a read-first bounce tried to repair that and depended on the
201
+ * model re-sending, which it sometimes simply did not (observed 15:45: bounce, no
202
+ * resend, ghost headings shipped). So the ORDER is now physical: the shell call is
203
+ * AWAITED, and only then is the tool minted — with the page's chosen places in the
204
+ * tool's own description. The writer cannot write blind, and nothing needs bouncing.
205
+ *
206
+ * The guest loses nothing: the skeleton and the tiles render during this await; only
207
+ * the model's captions start those few seconds later. A failed shell mints the tool
208
+ * with its plain description — a page that arrives the old way, never a dead turn.
209
+ *
210
+ * `__draftRefs` carries the UNLOCKING SEARCH'S ranked row ids (the server drafts
211
+ * ref-only parts from them); the execution ids trace the page's searches into the
212
+ * run's own timeline.
213
+ */
214
+ const draftRefs = harness.rankedRowIds(resultContent);
215
+ let chosenNote = "";
216
+ try {
217
+ const opened = await invoke({
218
+ __draftRefs: draftRefs,
219
+ ...(lastAsk ? { __ask: lastAsk } : {}),
220
+ ...(executionContext?.workflowId ? { __workflowId: executionContext.workflowId } : {}),
221
+ ...(executionContext?.executionId ? { __executionId: executionContext.executionId } : {}),
222
+ ...(executionContext?.nodeId ? { __nodeId: executionContext.nodeId } : {}),
223
+ });
224
+ const msg = typeof (opened as { message?: unknown })?.message === "string" ? (opened as { message: string }).message : "";
225
+ const chosen = msg.match(/THE PAGE'S CONTENT IS ALREADY CHOSEN — [^\n]*?never a different plan\./)?.[0];
226
+ if (chosen) chosenNote = `\n\n${chosen}`;
227
+ console.log(`[manifests] shell opened: ${def.name}${draftRefs.length ? ` (${draftRefs.length} draft refs)` : ""}${lastAsk ? ` ask="${lastAsk}"` : ""}${chosen ? " — chosen places ride the tool description" : ""}`);
228
+ } catch (e: any) {
229
+ console.log(`[manifests] shell open failed for ${def.name} — ${e?.message ?? e}`);
230
+ }
231
+ minted.push({ name: def.name, description: `${def.description}${chosenNote}`, parameters: def.parameters });
232
+ },
233
+ );
234
+
235
+ /**
236
+ * PATH-A apps, minted here because the absorber's callback does not cover them.
237
+ *
238
+ * `onAppUnlocked` fires only for a bindingless PATH-B component app — the kind that
239
+ * needs a shell-open and a live schema read. A workflow-bound app carries its contract
240
+ * on the row and needs neither, so the harness has nothing to unlock and never calls
241
+ * back. It still has to become a tool, which is what this does.
242
+ *
243
+ * Deduped downstream by tool name, so re-minting on a later search is harmless.
244
+ */
245
+ if (harness.DISCOVERY_TOOL_NAMES.includes(toolName))
246
+ for (const mcp of harness.parseDiscoveredMCPs([{ name: toolName, resultContent }]))
247
+ if (!harness.isBindinglessComponentApp(mcp)) {
248
+ const def = harness.toolDefFromDiscoveredMCP(mcp);
249
+ minted.push({ name: def.name, description: def.description, parameters: def.parameters });
250
+ }
251
+
252
+ /**
253
+ * SKILLS IN FORCE, lifted before the lean drops them. Handed back beside `minted`
254
+ * because they are the same kind of fact: something the reply UNLOCKED that the loop
255
+ * must act on, rather than anything the model asked for.
256
+ */
257
+ const skills = harness.parseRegionSkills(resultContent);
258
+
259
+ return { content, minted, skills };
260
+ },
261
+
262
+ endsTurn(calls) {
263
+ return harness.isTurnEndingHandoff(calls, core);
264
+ },
265
+ };
266
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Manifest node loading.
3
+ *
4
+ * Reads declarative node manifests from a SOURCE, composes each folder into one
5
+ * definition, and registers it through exactly the same path a code plugin uses. A
6
+ * manifest node is therefore indistinguishable downstream: same registry, same
7
+ * /nodes payload, same catalog ranking, same canvas.
8
+ *
9
+ * RELOADABLE BY DESIGN. `loadManifests` can be called again at any time and will
10
+ * replace what it loaded before. That is the point of the format: installing or
11
+ * updating a node should never restart a universe. When the source becomes Postgres,
12
+ * an install is a row write followed by this call.
13
+ *
14
+ * See docs/architecture/DECLARATIVE_NODES.md.
15
+ */
16
+ import {
17
+ getNodeRegistry,
18
+ getPluginNodeMap,
19
+ getCredentialRegistry,
20
+ replaceCredentialType,
21
+ unregisterCredentialType,
22
+ deleteNode,
23
+ } from "../registry.js";
24
+ import { composeNode, composeCredentials, packageVersion, packageMeta, ManifestError, type ComposedNode, type PackageMeta } from "./compose.js";
25
+ import { hashNode, assertUnchanged, IntegrityError } from "./integrity.js";
26
+ import { executorForKind, setManifestLookup } from "./executor/index.js";
27
+ import { diskSource, rowsSource, type ManifestSource, type RawPackage } from "./source.js";
28
+ import { fetchNodeRows } from "./rows.js";
29
+ import { isPluginEnabled } from "../plugins/state.js";
30
+ import { NODES_HOME, databaseOnly } from "../paths.js";
31
+ import { boot } from "../boot.js";
32
+
33
+ /** Marketplace name for a manifest package, so it shares one row with its code half. */
34
+ export const packageIdFor = (name: string) => `@unoverse-platform/${name}`;
35
+
36
+ /** Packages the current source offers, whether enabled or not. */
37
+ export async function listManifestPackages(source: ManifestSource = diskSource(NODES_HOME)): Promise<string[]> {
38
+ return (await source.listPackages()).map((p) => packageIdFor(p.name));
39
+ }
40
+
41
+ export async function isManifestPackage(name: string, source?: ManifestSource): Promise<boolean> {
42
+ return (await listManifestPackages(source)).includes(name);
43
+ }
44
+
45
+ /**
46
+ * Display metadata per manifest package, keyed by marketplace name.
47
+ *
48
+ * A manifest package has no plugin-state row, so /plugins had nothing to show for it.
49
+ * Populated on every load and read by routePlugins.
50
+ */
51
+ const packageMetas = new Map<string, PackageMeta>();
52
+
53
+ export function getManifestPackageMeta(name: string): PackageMeta | undefined {
54
+ return packageMetas.get(name);
55
+ }
56
+
57
+ /** Node types this loader owns, so a reload can retract exactly what it added. */
58
+ const loadedTypes = new Map<string, ComposedNode>();
59
+
60
+ /**
61
+ * Credential types this loader owns.
62
+ *
63
+ * Tracked separately from the registry because retraction must be SURGICAL: a
64
+ * credential registered by a code plugin is not ours to remove, and clearing the whole
65
+ * registry on reload would take those with it. Only names in here are ever replaced or
66
+ * deleted.
67
+ */
68
+ const loadedCredentials = new Set<string>();
69
+
70
+ export function getLoadedManifestCredentials(): string[] {
71
+ return [...loadedCredentials];
72
+ }
73
+
74
+ // The two shared executors resolve their manifest by node type through this, which is
75
+ // what keeps them at two classes rather than one per node, and lets a reload swap a
76
+ // node's behaviour without re-registering anything.
77
+ setManifestLookup((type) => loadedTypes.get(type));
78
+
79
+ export function getManifestNode(type: string): ComposedNode | undefined {
80
+ return loadedTypes.get(type);
81
+ }
82
+
83
+ export function getLoadedManifestTypes(): string[] {
84
+ return [...loadedTypes.keys()];
85
+ }
86
+
87
+ export interface LoadResult {
88
+ nodes: number;
89
+ credentials: number;
90
+ errors: string[];
91
+ }
92
+
93
+ /**
94
+ * Where each loaded manifest package came from. Derived, never stored.
95
+ *
96
+ * A stored row goes stale: a package that moved from npm to manifests on disk kept
97
+ * rendering as installed, with a Remove button that would have disabled the manifest.
98
+ * `postgres:` origin means installed and removable; anything else is a folder on disk.
99
+ */
100
+ export function manifestPackageOrigins(): Map<string, { source: "local" | "npm"; version: string }> {
101
+ const out = new Map<string, { source: "local" | "npm"; version: string }>();
102
+ for (const node of loadedTypes.values())
103
+ out.set(packageIdFor(node.packageName), {
104
+ source: node.origin.startsWith("postgres:") ? "npm" : "local",
105
+ version: node.packageVersion ?? "1.0.0",
106
+ });
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Load (or reload) every manifest node from `source`.
112
+ *
113
+ * A broken node is skipped with a named error and never takes the others down: one
114
+ * bad YAML file must not cost a universe its whole catalog.
115
+ */
116
+ export async function loadManifests(source?: ManifestSource): Promise<LoadResult> {
117
+ const registry = getNodeRegistry();
118
+ const pluginNodeMap = getPluginNodeMap();
119
+ const result: LoadResult = { nodes: 0, credentials: 0, errors: [] };
120
+
121
+ // TWO sources, in precedence order, FIRST ONE WINS. An explicit source (tests, a
122
+ // one-off load) wins outright; otherwise DISK beats rows, because a manifest present
123
+ // in the monorepo is being authored right now and must beat whatever was installed.
124
+ // Same local-wins first-principle the node loader already applies to npm copies.
125
+ // Under UNOVERSE_DATABASE_ONLY the disk half is dropped, so a developer sees the node
126
+ // set a deployed universe would have: whatever is installed, and nothing else.
127
+ const sources: ManifestSource[] = source
128
+ ? [source]
129
+ : databaseOnly()
130
+ ? [rowsSource(fetchNodeRows)]
131
+ : [diskSource(NODES_HOME), rowsSource(fetchNodeRows)];
132
+
133
+ const packages: RawPackage[] = [];
134
+ const seen = new Set<string>();
135
+ for (const s of sources) {
136
+ let batch: RawPackage[];
137
+ try {
138
+ batch = await s.listPackages();
139
+ } catch (err: any) {
140
+ result.errors.push(`source "${s.name}" failed: ${err?.message ?? err}`);
141
+ continue;
142
+ }
143
+ for (const pkg of batch) {
144
+ // Merge by node type rather than by package: a MIXED package can have some nodes
145
+ // on disk and others only installed, and dropping the whole package because its
146
+ // name was seen would lose the ones that are only in the database.
147
+ const nodes = pkg.nodes.filter((n) => !seen.has(n.dir));
148
+ for (const n of nodes) seen.add(n.dir);
149
+ const existing = packages.find((p) => p.name === pkg.name);
150
+ if (existing) existing.nodes.push(...nodes);
151
+ else if (nodes.length || Object.keys(pkg.credentials).length) packages.push({ ...pkg, nodes });
152
+ }
153
+ }
154
+ if (!packages.length) return result;
155
+
156
+ // Retract what a previous load added, so a reload cannot leave a deleted node or
157
+ // credential behind. Anything no longer present simply disappears.
158
+ const previous = new Set(loadedTypes.keys());
159
+ const previousCredentials = new Set(loadedCredentials);
160
+ loadedTypes.clear();
161
+ loadedCredentials.clear();
162
+
163
+ const credentialRegistry = getCredentialRegistry();
164
+ const disabledPackages: string[] = [];
165
+
166
+ packageMetas.clear();
167
+ for (const pkg of packages) {
168
+ packageMetas.set(packageIdFor(pkg.name), packageMeta(pkg));
169
+ // Disabled in the marketplace means GONE, not hidden: its nodes and credential
170
+ // types are retracted below exactly as if the package had been removed from the
171
+ // source. That is what makes uninstall work without a restart.
172
+ if (!isPluginEnabled(packageIdFor(pkg.name))) {
173
+ disabledPackages.push(pkg.name);
174
+ continue;
175
+ }
176
+
177
+ try {
178
+ for (const cred of composeCredentials(pkg)) {
179
+ // A credential a CODE plugin registered is not ours to overwrite: local source
180
+ // wins over a manifest, the same rule as for nodes.
181
+ if (credentialRegistry.has(cred.name) && !previousCredentials.has(cred.name)) {
182
+ result.errors.push(`${pkg.name}/credentials/${cred.name}: already registered by code — manifest skipped`);
183
+ continue;
184
+ }
185
+ // REPLACE, not register: a package that adds a field to its credential must be
186
+ // able to land that change without a restart, and the first-wins path would
187
+ // keep the old shape forever.
188
+ replaceCredentialType(cred);
189
+ loadedCredentials.add(cred.name);
190
+ result.credentials++;
191
+ }
192
+ } catch (err: any) {
193
+ result.errors.push(err instanceof ManifestError ? err.message : `${pkg.name}/credentials: ${err?.message ?? err}`);
194
+ }
195
+
196
+ const types: string[] = [];
197
+ for (const raw of pkg.nodes) {
198
+ try {
199
+ const node = composeNode(raw, pkg);
200
+ node.hash = hashNode(node);
201
+ node.packageVersion = packageVersion(pkg);
202
+
203
+ // TAMPER CHECK, before the node reaches the registry. A source that recorded a
204
+ // hash at publish gets it verified now; disk records none and is governed by git
205
+ // instead. Failing here rather than at first execution is deliberate: a node that
206
+ // does not match what was approved must never become callable, not even once.
207
+ assertUnchanged(node, raw.hash, `${pkg.name}/${raw.dir}`);
208
+
209
+ // A code node of the same type already loaded. Never override it: local
210
+ // source wins over a manifest exactly as it wins over an npm copy, so a
211
+ // half-migrated package cannot silently swap a working executor for one that
212
+ // refuses to run.
213
+ if (registry.has(node.type) && !previous.has(node.type)) {
214
+ result.errors.push(`${pkg.name}/${raw.dir}: type "${node.type}" is already registered by code — manifest skipped`);
215
+ continue;
216
+ }
217
+
218
+ registry.set(node.type, {
219
+ ...(node.definition as any),
220
+ // ONE of two shared classes, never a class per node.
221
+ executor: executorForKind(node.kind),
222
+ executionMode: node.kind === "CallbackNode" ? "generator" : "single",
223
+ });
224
+ loadedTypes.set(node.type, node);
225
+ types.push(node.type);
226
+ result.nodes++;
227
+ } catch (err: any) {
228
+ // An integrity failure is not the same class of problem as a malformed manifest,
229
+ // so it says so and is never folded into the quiet "skipped" count below.
230
+ if (err instanceof IntegrityError) console.error(`[unoverse:manifests] TAMPER: ${err.message}`);
231
+ result.errors.push(
232
+ err instanceof ManifestError || err instanceof IntegrityError
233
+ ? err.message
234
+ : `${pkg.name}/${raw.dir}: ${err?.message ?? err}`,
235
+ );
236
+ }
237
+ }
238
+
239
+ if (types.length) {
240
+ // Merge rather than replace: a MIXED package has code nodes here already, and
241
+ // dropping them would lose the package→types provenance /plugins reads.
242
+ const existing = pluginNodeMap.get(`@unoverse-platform/${pkg.name}`) ?? [];
243
+ pluginNodeMap.set(`@unoverse-platform/${pkg.name}`, [...new Set([...existing, ...types])]);
244
+
245
+ }
246
+ }
247
+
248
+ // Anything this loader owned last time and no longer produces is gone. Surgical on
249
+ // purpose: only names this loader registered are ever retracted, so uninstalling a
250
+ // manifest package cannot take a code plugin's node or credential with it.
251
+ for (const stale of previous) if (!loadedTypes.has(stale)) deleteNode(stale);
252
+ for (const stale of previousCredentials)
253
+ if (!loadedCredentials.has(stale) && unregisterCredentialType(stale))
254
+ console.log(`[unoverse:manifests] retracted credential type ${stale}`); // a real change of state
255
+
256
+ // At boot this is one row in the readout. A LATER reload (the watcher, an install,
257
+ // an uninstall) is an event on an otherwise quiet screen, so it still speaks.
258
+ if (boot.isBooting()) {
259
+ boot.manifests({
260
+ nodes: result.nodes,
261
+ credentials: result.credentials,
262
+ // "already registered by code" is the local-wins rule working, not a fault, so it
263
+ // is reported as a shadow with its type named rather than as an error.
264
+ shadowed: result.errors.flatMap((e) => {
265
+ const m = /type "([^"]+)" is already registered by code/.exec(e);
266
+ return m ? [m[1]] : [];
267
+ }),
268
+ disabled: disabledPackages,
269
+ });
270
+ for (const e of result.errors)
271
+ if (!/already registered by code/.test(e)) boot.notice("warn", `manifest skipped: ${e}`);
272
+ } else {
273
+ console.log(
274
+ `[unoverse:manifests] ${result.nodes} node(s), ${result.credentials} credential type(s) from ${sources.map((s) => s.name).join(" + ")}` +
275
+ (result.errors.length ? `, ${result.errors.length} skipped` : ""),
276
+ );
277
+ for (const e of result.errors) console.warn(` ! ${e}`);
278
+ }
279
+ return result;
280
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * THE CONTENT HASH. Its own file, like allowedHosts.ts, because it is a security control.
3
+ *
4
+ * AllowedHosts answers "what can this node do?". It does NOT answer "is this the node I
5
+ * approved?", and only the second question is about tampering. See SECURITY.md
6
+ * §"Manifest integrity — phase 1, the content hash".
7
+ *
8
+ * WHAT IS HASHED: the COMPOSED document, which includes the package envelope.
9
+ *
10
+ * Not the node's own files alone, and this is the whole point rather than a detail.
11
+ * `allowedHosts` is declared in package.yaml, so a hash over only api/run.yaml would leave the
12
+ * allowlist that bounds the node editable by anyone who can edit the node. Hashing the
13
+ * composition is what makes the allowedHosts declaration itself tamper-evident.
14
+ *
15
+ * Canonical JSON, not the YAML bytes, so reformatting or editing a comment does not break
16
+ * the seal while everything that actually EXECUTES stays covered. A comment cannot change
17
+ * behaviour; a changed URL, credential reference or expression always can.
18
+ *
19
+ * WHEN: at load, and only at load. Verification is a boot-time cost of milliseconds across
20
+ * a package. Nothing is hashed while a node is running, and nothing ever should be: a
21
+ * per-call check would be the one design that actually slows nodes down.
22
+ *
23
+ * WHAT THIS IS NOT: a hash stored beside the thing it protects is tamper EVIDENCE, not
24
+ * tamper PREVENTION. Someone with database write access can change the manifest and the
25
+ * hash in one transaction. It catches a hand-edited row, a stray script, drift between git
26
+ * and a universe, and corruption. Defeating an attacker who already owns the database is
27
+ * what signing is for, and that is phase 2.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import type { ComposedNode } from "./compose.js";
31
+
32
+ /** Prefixed so a stored value says what produced it, and a future scheme can coexist. */
33
+ const PREFIX = "sha256";
34
+
35
+ /**
36
+ * Canonical JSON: object keys sorted at every depth, arrays left alone.
37
+ *
38
+ * Sorting keys makes the hash independent of the order YAML happened to be written in, so
39
+ * moving `transport` above `auth` in a file is not a tamper. Array order is PRESERVED and
40
+ * must be: the order of `run` is the order the calls happen, and the order of `events` is
41
+ * the connector order lint enforces. Sorting those would erase real meaning.
42
+ */
43
+ function canonical(value: unknown): unknown {
44
+ if (Array.isArray(value)) return value.map(canonical);
45
+ if (value && typeof value === "object") {
46
+ const out: Record<string, unknown> = {};
47
+ for (const key of Object.keys(value as Record<string, unknown>).sort())
48
+ out[key] = canonical((value as Record<string, unknown>)[key]);
49
+ return out;
50
+ }
51
+ return value;
52
+ }
53
+
54
+ /**
55
+ * The hash of a composed node: `sha256:<hex>`.
56
+ *
57
+ * `origin` is deliberately EXCLUDED. It is the absolute path on disk or a `postgres:` id,
58
+ * so including it would give the same node a different hash on every machine and make the
59
+ * value useless for comparing what a developer reviewed against what a universe is running.
60
+ * Everything else about the node is in scope, including `allowedHosts`.
61
+ */
62
+ export function hashNode(node: ComposedNode): string {
63
+ const subject = {
64
+ type: node.type,
65
+ kind: node.kind,
66
+ packageName: node.packageName,
67
+ allowedHosts: node.allowedHosts,
68
+ definition: node.definition,
69
+ api: node.api,
70
+ };
71
+ return `${PREFIX}:${createHash("sha256").update(JSON.stringify(canonical(subject))).digest("hex")}`;
72
+ }
73
+
74
+ export class IntegrityError extends Error {}
75
+
76
+ /**
77
+ * Refuse a node whose content does not match the hash its source recorded.
78
+ *
79
+ * A source with no expected hash is NOT an error: manifests on disk are governed by git,
80
+ * which is a better integrity control than a hash file sitting next to the thing it
81
+ * protects, and demanding one would make local authoring impossible. The check applies
82
+ * where a hash was recorded, which in practice means rows.
83
+ */
84
+ export function assertUnchanged(node: ComposedNode, expected: string | undefined, where: string): void {
85
+ if (!expected) return;
86
+ const actual = hashNode(node);
87
+ if (actual === expected) return;
88
+ throw new IntegrityError(
89
+ `${where}: content hash mismatch. Recorded ${expected}, computed ${actual}. ` +
90
+ `This node's manifest has changed since it was published, so it will NOT be registered. ` +
91
+ `Re-deploy it from source rather than editing the stored copy (SECURITY.md §Manifest integrity).`,
92
+ );
93
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reading installed node rows.
3
+ *
4
+ * The engine owns database access (this server has no Postgres client), so rows come
5
+ * over the same internal hop plugin state already uses. Kept in its own file so
6
+ * `source.ts` stays a pure shape-conversion with no transport in it.
7
+ *
8
+ * A failure here THROWS. It used to answer [] instead, which is indistinguishable from
9
+ * a universe that owns nothing, and on a deployed universe rows are the only node
10
+ * source, so a boot that lost the race to the engine silently served an empty catalog.
11
+ * The loader records a thrown source and carries on, so one dead source still cannot
12
+ * lose the other.
13
+ */
14
+ import type { ItemRow } from "./source.js";
15
+
16
+ const ENGINE = process.env.WORKFLOW_SERVICE_URL || "http://localhost:4101";
17
+
18
+ export async function fetchNodeRows(): Promise<ItemRow[]> {
19
+ let res: Response;
20
+ try {
21
+ res = await fetch(`${ENGINE}/items?kind=node`);
22
+ } catch (error: any) {
23
+ // Unreachable: the engine is not up yet, or not where we think it is. Both are worth
24
+ // retrying, and neither means this universe has no nodes.
25
+ throw new Error(`could not reach the engine at ${ENGINE} to read node rows: ${error?.message ?? error}`);
26
+ }
27
+ if (!res.ok) throw new Error(`the engine answered ${res.status} reading node rows from ${ENGINE}/items?kind=node`);
28
+
29
+ const data = (await res.json()) as { items?: Array<{ name: string; definition: any; enabled?: boolean }> };
30
+ // A disabled row is retracted, not hidden: it must not register, exactly as an
31
+ // uninstalled one would not. Enabled is the running state, presence is possession.
32
+ return (data.items ?? []).filter((r) => r.enabled !== false).map((r) => ({ name: r.name, definition: r.definition }));
33
+ }