@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,539 @@
1
+ /**
2
+ * Compose a node folder into ONE definition.
3
+ *
4
+ * A node is authored as up to five files split by rate of change (node, interface,
5
+ * config, api, test). Nothing downstream should know or care: this turns them into
6
+ * the single object the registry already stores, so a manifest node and a code node
7
+ * are indistinguishable to the catalog, the canvas and the engine.
8
+ *
9
+ * Any section may instead be inlined into node.yaml. Defined in BOTH places is an
10
+ * error, never a merge — silently merging would make a stale file invisible.
11
+ *
12
+ * NOTE: the $ref and section rules here are also implemented in
13
+ * scripts/lib/lint-nodes.mjs, which must run without the server (it ships in the
14
+ * starter kit). Keep the two in step; the linter is the one developers see first.
15
+ *
16
+ * See docs/architecture/DECLARATIVE_NODES.md §5.
17
+ */
18
+ import * as YAML from "yaml";
19
+ import type { RawNode, RawPackage } from "./source.js";
20
+
21
+ /** The sections that may live in their own file OR inline in node.yaml. */
22
+ const SECTIONS = ["interface", "config", "api", "test"] as const;
23
+
24
+ /**
25
+ * The builder's run-authorization controls, ONE canonical copy (who-can-run-it.md).
26
+ * Injected into every runnable node's composed config schema; see the chrome comment
27
+ * in composeNode. The wording is the doc's: change it here and the doc together.
28
+ *
29
+ * EXPORTED because a node is built by TWO paths, and for a year only this one injected
30
+ * the controls. `composeNode` builds manifest nodes; the Unoverse server synthesizes a
31
+ * node per design component (runtime/components/meta.ts) and never passed through here,
32
+ * so no Component box has ever carried a Require sign-in toggle while the docs said every
33
+ * runnable node does. The second path imports this constant rather than restating it: two
34
+ * copies of a control that decides who may run something is how they drift.
35
+ */
36
+ export const RUN_AUTH_CHROME = {
37
+ authRequired: {
38
+ type: "boolean",
39
+ title: "Require sign-in",
40
+ description:
41
+ "Only a signed-in caller may run this step. Leave off and it runs for whoever " +
42
+ "the workflow's trigger admitted, which is the usual answer.",
43
+ default: false,
44
+ "ui:widget": "toggle",
45
+ },
46
+ authRole: {
47
+ type: "string",
48
+ title: "Require role",
49
+ description:
50
+ "A claim the caller's account must carry, as noun:verb (finance:approve, " +
51
+ "payments:refund). Leave blank to require only that they are signed in.",
52
+ default: "",
53
+ "ui:dependencies": { authRequired: true },
54
+ },
55
+ } as const;
56
+ type Section = (typeof SECTIONS)[number];
57
+
58
+ /**
59
+ * DEEP for plain objects, so overriding one field does not delete its siblings.
60
+ *
61
+ * A shallow spread made `{ $ref: x, body: { mode: 'intent' } }` REPLACE the imported body
62
+ * entirely: every other key — the filters, the result cap, the query itself — vanished, and
63
+ * the request still went out, valid and wrong. Nothing failed, because a body with fewer
64
+ * keys is a legal body. That is the worst shape of bug this format can produce, and the
65
+ * whole point of a $ref with siblings is "import a block and adjust one field".
66
+ *
67
+ * Arrays REPLACE rather than concatenating: a list is an ordered whole, and merging two
68
+ * would give an author no way to shorten one.
69
+ */
70
+ function deepMerge(base: any, over: any): any {
71
+ if (base === null || typeof base !== "object" || Array.isArray(base)) return over;
72
+ if (over === null || typeof over !== "object" || Array.isArray(over)) return over;
73
+ const out = { ...base };
74
+ for (const [k, v] of Object.entries(over)) out[k] = k in base ? deepMerge(base[k], v) : v;
75
+ return out;
76
+ }
77
+
78
+ export interface ComposedNode {
79
+ type: string;
80
+ kind: "PromiseNode" | "CallbackNode";
81
+ packageName: string;
82
+ /**
83
+ * Hosts this node may call, from its package's `allowedHosts`. Empty means NONE: a
84
+ * package that declares no allowedHosts cannot reach the network at all.
85
+ *
86
+ * SECURITY.md treats a node as trusted code bounded by provenance, and a template
87
+ * expression as untrusted data bounded by having no credentials in scope. A
88
+ * manifest node is neither: it arrives as data yet holds a credential and a URL.
89
+ * This is its boundary.
90
+ */
91
+ allowedHosts: string[];
92
+ /**
93
+ * WHO MAY RUN THIS NODE, from node.yaml (DECLARATIVE_NODES.md §9.13). The inbound
94
+ * question, about the CALLER. Not a call's `credential`, which is outbound.
95
+ *
96
+ * Carried here because it was previously read out of the YAML by lint and then dropped:
97
+ * the executor received a node object with no `auth` field at all, so the check could not
98
+ * have been written even if someone had tried. A declaration nothing can read is worse
99
+ * than no declaration, because it looks decided.
100
+ */
101
+ auth: { required: boolean; role?: string };
102
+ /**
103
+ * NAMED FUNCTIONS the package's expressions may call, collected from `helpers:` in every
104
+ * shared/ file (DECLARATIVE_NODES.md §5.4).
105
+ *
106
+ * PACKAGE-scoped, not node-scoped, for the same reason `shared/` is: two nodes in one
107
+ * package almost always project the same vendor's rows the same way, and the alternative
108
+ * is the copy-paste that `$ref` exists to prevent.
109
+ */
110
+ helpers: Record<string, { args?: string[]; body: string }>;
111
+ origin: string;
112
+ /** The registry-shaped definition. */
113
+ definition: Record<string, any>;
114
+ /** The api block, carried for the executor. Absent means the node does nothing yet. */
115
+ api: Record<string, any> | null;
116
+ /** The version the package declares, so /plugins need not trust a stale row. */
117
+ packageVersion?: string;
118
+ /**
119
+ * `sha256:<hex>` over this composed document, filled in by the loader.
120
+ *
121
+ * Set after composition rather than inside it, because the hash is OVER the composed
122
+ * result and a field cannot be part of what it summarises.
123
+ */
124
+ hash?: string;
125
+ }
126
+
127
+ export class ManifestError extends Error {}
128
+
129
+ function parse(yaml: string, where: string): any {
130
+ let doc: any;
131
+ try {
132
+ doc = YAML.parse(yaml);
133
+ } catch (err: any) {
134
+ throw new ManifestError(`${where} is not valid YAML: ${String(err?.message ?? err).split("\n")[0]}`);
135
+ }
136
+ if (doc && typeof doc === "object") delete doc.$schema;
137
+ return doc ?? {};
138
+ }
139
+
140
+ /**
141
+ * Resolve a `$ref` against the package's `shared/` folder.
142
+ *
143
+ * SHORT FORM, and the one to write:
144
+ * url: { $ref: endpoints#responses }
145
+ * enum: { $ref: models#enum }
146
+ * toolExchange:
147
+ * $ref: tools # whole file
148
+ * maxTurns: 5 # local keys layer on top and win
149
+ *
150
+ * No path, because there was never a real path: shared fragments are package-scoped and
151
+ * the directory part was always thrown away. `../../shared/endpoints.yaml#/responses`
152
+ * still works, but it is arithmetic that buys nothing.
153
+ *
154
+ * A LONE $ref REPLACES. A $ref WITH SIBLINGS MERGES, local winning, which is what lets a
155
+ * node import a whole shared block and adjust one field.
156
+ */
157
+ function resolveRefs(value: any, pkg: RawPackage, where: string, depth = 0): any {
158
+ if (depth > 10) throw new ManifestError(`${where}: $ref nested more than 10 deep, probably a cycle`);
159
+ if (Array.isArray(value)) return value.map((v) => resolveRefs(v, pkg, where, depth));
160
+ if (!value || typeof value !== "object") return value;
161
+
162
+ const keys = Object.keys(value);
163
+ if (typeof value.$ref === "string") {
164
+ const [target, frag = ""] = value.$ref.split("#");
165
+ // `endpoints`, `endpoints.yaml`, or `../../shared/endpoints.yaml` — all the same file.
166
+ const stem = (target.split("/").pop() ?? "").replace(/\.ya?ml$/, "");
167
+ const body = pkg.shared[`${stem}.yaml`] ?? pkg.shared[`${stem}.yml`];
168
+ if (body === undefined)
169
+ throw new ManifestError(`${where}: $ref "${value.$ref}" — no shared/${stem}.yaml in package "${pkg.name}"`);
170
+ const doc = parse(body, `${pkg.name}/shared/${stem}.yaml`);
171
+ const hit = frag
172
+ .replace(/^\//, "")
173
+ .split("/")
174
+ .filter(Boolean)
175
+ .reduce((a: any, k: string) => (a == null ? a : a[k]), doc);
176
+ if (hit === undefined) throw new ManifestError(`${where}: $ref "${value.$ref}" resolved to nothing`);
177
+ const imported = resolveRefs(hit, pkg, where, depth + 1);
178
+ if (keys.length === 1) return imported;
179
+
180
+ // Siblings present: merge, local wins.
181
+ const local = Object.fromEntries(
182
+ keys.filter((k) => k !== "$ref").map((k) => [k, resolveRefs(value[k], pkg, where, depth)]),
183
+ );
184
+ if (imported === null || typeof imported !== "object" || Array.isArray(imported))
185
+ throw new ManifestError(`${where}: $ref "${value.$ref}" is not an object, so it cannot be merged with sibling keys`);
186
+ return deepMerge(imported as any, local) as any;
187
+ }
188
+ return Object.fromEntries(keys.map((k) => [k, resolveRefs(value[k], pkg, where, depth)]));
189
+ }
190
+
191
+ /**
192
+ * Who published the package. Absent means unknown, which the UI must show as third
193
+ * party rather than assume: "we could not tell" and "we vouch for it" are different
194
+ * answers and only one of them is safe to guess.
195
+ */
196
+ function packagePublisher(pkg: RawPackage): string | null {
197
+ if (!pkg.packageFile) return null;
198
+ const doc: any = parse(pkg.packageFile, `${pkg.name}/package.yaml`) ?? {};
199
+ return typeof doc.publisher === "string" && doc.publisher.trim() ? doc.publisher.trim() : null;
200
+ }
201
+
202
+ /**
203
+ * WHO MAY RUN THIS NODE, read off node.yaml (DECLARATIVE_NODES.md §9.13).
204
+ *
205
+ * FAILS CLOSED on a malformed or missing block. Lint requires `auth.required` and rejects a
206
+ * `role` alongside `required: false`, but lint runs at author time and a manifest can arrive
207
+ * as a database row that never met it. Defaulting the absent case to "open" would mean the
208
+ * one path that skipped lint is also the one that skips authorization.
209
+ *
210
+ * The unsatisfiable pair is collapsed rather than thrown on: a role implies an authenticated
211
+ * caller, so `role` with `required: false` is read as `required: true`. Throwing at LOAD
212
+ * would take the whole package down for one bad node, and admitting everyone would be the
213
+ * opposite of what the author asked for.
214
+ */
215
+ export function nodeAuth(node: any): { required: boolean; role?: string } {
216
+ const raw = node?.auth;
217
+ const role = typeof raw?.role === "string" && raw.role.trim() ? raw.role.trim() : undefined;
218
+ // `typeof null === "object"`, so null must be excluded explicitly or reading `.required`
219
+ // off it throws — turning a fail-closed path into a crash, which is a different failure
220
+ // with a much worse error message.
221
+ if (raw === null || typeof raw !== "object")
222
+ // No block at all. Lint makes this impossible in the tree; a pasted manifest is why it
223
+ // is handled here, and "signed in" is the safe reading of a node that never said.
224
+ return { required: true, role };
225
+ if (typeof raw.required !== "boolean") return { required: true, role };
226
+ return { required: raw.required || role !== undefined, role };
227
+ }
228
+
229
+ /**
230
+ * Every `helpers:` declaration across the package's shared/ folder, merged into one bag.
231
+ *
232
+ * Collected from ALL shared files rather than one reserved filename, because a helper belongs
233
+ * next to the thing it serves: the row projection sits in the same file as the search that
234
+ * produces the rows. A dedicated helpers.yaml would separate them again, which is the problem
235
+ * this is fixing.
236
+ *
237
+ * A DUPLICATE NAME THROWS. Two files defining `row` would otherwise resolve by directory
238
+ * order, so which one ran depended on a filename — and the loser would look defined, be
239
+ * readable, and never execute.
240
+ */
241
+ export function packageHelpers(pkg: RawPackage): Record<string, { args?: string[]; body: string }> {
242
+ const out: Record<string, { args?: string[]; body: string }> = {};
243
+ const from: Record<string, string> = {};
244
+ for (const [file, body] of Object.entries(pkg.shared ?? {})) {
245
+ if (!/\.ya?ml$/.test(file)) continue;
246
+ const doc = parse(body, `${pkg.name}/shared/${file}`);
247
+ const declared = doc?.helpers;
248
+ if (declared === undefined) continue;
249
+ if (declared === null || typeof declared !== "object" || Array.isArray(declared))
250
+ throw new ManifestError(`${pkg.name}/shared/${file}: "helpers" must be a map of name → { args, body }`);
251
+ for (const [name, def] of Object.entries(declared as Record<string, any>)) {
252
+ if (name in out)
253
+ throw new ManifestError(
254
+ `${pkg.name}: helper "${name}" is defined in both shared/${from[name]} and shared/${file} — a name may only be declared once`,
255
+ );
256
+ if (!def || typeof def !== "object" || typeof def.body !== "string" || !def.body.trim())
257
+ throw new ManifestError(`${pkg.name}/shared/${file}: helper "${name}" needs a non-empty string "body"`);
258
+ if (def.args !== undefined && (!Array.isArray(def.args) || def.args.some((a: any) => typeof a !== "string")))
259
+ throw new ManifestError(`${pkg.name}/shared/${file}: helper "${name}" — "args" must be a list of names`);
260
+ out[name] = { args: def.args, body: def.body };
261
+ from[name] = file;
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+
267
+ /** A package's declared allowedHosts hosts. Absent or empty means the package cannot call out. */
268
+ export function packageAllowedHosts(pkg: RawPackage): string[] {
269
+ if (!pkg.packageFile) return [];
270
+ const doc = parse(pkg.packageFile, `${pkg.name}/package.yaml`);
271
+ return Array.isArray(doc.allowedHosts) ? doc.allowedHosts.map((h: string) => String(h).toLowerCase()) : [];
272
+ }
273
+
274
+ /** The version a package declares for itself, so state does not keep reporting an old npm one. */
275
+ /**
276
+ * The hosts THIS node may call: its own list when it declares one, else the package's.
277
+ *
278
+ * The package sets the CEILING and a node may only narrow it. A node that named a host its
279
+ * package did not would otherwise be a way to widen the boundary from inside the thing the
280
+ * boundary contains, which is the mistake this codebase has now made twice.
281
+ *
282
+ * Why it matters in practice: `gtm` calls two vendors, so its package list holds both, and
283
+ * without this every Apollo node is also permitted to reach Hunter. One node, one vendor is
284
+ * what a reviewer expects when they read a node's page.
285
+ */
286
+ export function nodeAllowedHosts(node: any, fromPackage: string[], where: string): string[] {
287
+ const own = node.allowedHosts;
288
+ if (!Array.isArray(own) || !own.length) return fromPackage;
289
+
290
+ const narrowed = own.map((h: string) => String(h).toLowerCase());
291
+ const widened = narrowed.filter((h) => !fromPackage.includes(h));
292
+ if (widened.length)
293
+ throw new ManifestError(
294
+ `${where}: node.yaml allows ${widened.map((h) => `"${h}"`).join(", ")}, which package.yaml does not. ` +
295
+ `A node may only NARROW its package's allowedHosts, never widen them.`,
296
+ );
297
+ return narrowed;
298
+ }
299
+
300
+ export function packageVersion(pkg: RawPackage): string | undefined {
301
+ if (!pkg.packageFile) return undefined;
302
+ const doc = parse(pkg.packageFile, `${pkg.name}/package.yaml`);
303
+ return doc.version ? String(doc.version) : undefined;
304
+ }
305
+
306
+ /**
307
+ * A package's display envelope: what the marketplace shows ABOUT the package rather
308
+ * than about its nodes.
309
+ *
310
+ * Manifest packages have no plugin-state row to carry this, so without it the Installed
311
+ * list falls back to the bare folder name and no mark: "airtable" with an empty square
312
+ * instead of "Airtable" with its logo. The information was always in package.yaml; it
313
+ * just had no route out.
314
+ */
315
+ export interface PackageMeta {
316
+ displayName?: string;
317
+ description?: string;
318
+ category?: string;
319
+ logoUrl?: string;
320
+ features?: string[];
321
+ publisher?: string | null;
322
+ version?: string;
323
+ }
324
+
325
+ export function packageMeta(pkg: RawPackage): PackageMeta {
326
+ if (!pkg.packageFile) return {};
327
+ const doc: any = parse(pkg.packageFile, `${pkg.name}/package.yaml`) ?? {};
328
+ return {
329
+ displayName: doc.displayName ? String(doc.displayName) : undefined,
330
+ description: doc.description ? String(doc.description) : undefined,
331
+ category: doc.category ? String(doc.category) : undefined,
332
+ logoUrl: doc.logoUrl ? String(doc.logoUrl) : undefined,
333
+ features: Array.isArray(doc.features) ? doc.features.map(String) : undefined,
334
+ publisher: packagePublisher(pkg),
335
+ version: doc.version ? String(doc.version) : undefined,
336
+ };
337
+ }
338
+
339
+ export function composeNode(raw: RawNode, pkg: RawPackage): ComposedNode {
340
+ const where = `${pkg.name}/${raw.dir}`;
341
+ // The package envelope, so a node can inherit what belongs to the whole package.
342
+ const pkgMeta = packageMeta(pkg);
343
+
344
+ const nodeFile = raw.files["node.yaml"];
345
+ if (!nodeFile) throw new ManifestError(`${where}: no node.yaml`);
346
+ const node = parse(nodeFile, `${where}/node.yaml`);
347
+
348
+ const parts: Partial<Record<Section, any>> = {};
349
+ for (const s of SECTIONS) {
350
+ // Two ways to say the same thing, and exactly one may be used:
351
+ // inline in node.yaml small sections that are not worth a file
352
+ // <s>.yaml the common case
353
+ // <s>/<key>.yaml one file per top-level key
354
+ // The FILENAME IS THE KEY: api/request.yaml is what sat under `request:`. That
355
+ // leaves no merge order to reason about and no way to define a key twice.
356
+ //
357
+ // `api` is ALWAYS the folder form, no exceptions, so every node reads the same way:
358
+ // one file per call, plus the events table. Enforced below and by lint.
359
+ const folder = Object.keys(raw.files).filter((f) => f.startsWith(`${s}/`)).sort();
360
+ const onDisk = raw.files[`${s}.yaml`];
361
+ const inline = node[s];
362
+
363
+ const ways = [folder.length ? `${s}/` : null, onDisk !== undefined ? `${s}.yaml` : null, inline !== undefined ? "node.yaml" : null].filter(Boolean);
364
+ if (ways.length > 1)
365
+ throw new ManifestError(`${where}: "${s}" is defined in ${ways.join(" and ")} — pick one, this is never a merge`);
366
+
367
+ let doc: any;
368
+ if (folder.length) {
369
+ doc = {};
370
+ for (const f of folder) {
371
+ const key = f.slice(s.length + 1).replace(/\.ya?ml$/, "");
372
+ doc[key] = parse(raw.files[f], `${where}/${f}`);
373
+ }
374
+ } else doc = onDisk !== undefined ? parse(onDisk, `${where}/${s}.yaml`) : inline;
375
+
376
+ if (doc !== undefined) parts[s] = resolveRefs(doc, pkg, `${where}/${s}.yaml`);
377
+ delete node[s];
378
+ }
379
+
380
+ if (parts.api !== undefined && !Object.keys(raw.files).some((f) => f.startsWith("api/")))
381
+ throw new ManifestError(
382
+ `${where}: api must be a FOLDER (api/request.yaml, api/events.yaml, ...), not api.yaml or an inline api block — every node has the same shape`,
383
+ );
384
+
385
+ // RETIRED SHAPES, refused at composition.
386
+ //
387
+ // Lint catches these while authoring, but an already-PUBLISHED package does not get
388
+ // linted: it arrives as a row and goes straight here. Without this, an older marketplace
389
+ // version composes to an api block with no `run`, so a PromiseNode registers happily and
390
+ // then dies on first execution with "has only service methods", while a CallbackNode
391
+ // fails on a kind mismatch that names neither the cause nor the fix.
392
+ //
393
+ // Failing here instead says what to do, at the moment the universe loads the package.
394
+ for (const [old, now] of [["request", "run (a LIST of calls)"], ["steps", "run"], ["provides", "service"]] as const)
395
+ if (parts.api && (parts.api as any)[old] !== undefined)
396
+ throw new ManifestError(
397
+ `${where}: api/${old}.yaml is from an older manifest format and cannot be loaded — it is now api/${now}. ` +
398
+ `Re-publish this package from a current source tree (DECLARATIVE_NODES.md §5).`,
399
+ );
400
+
401
+ if (!node.type) throw new ManifestError(`${where}: node.yaml has no type`);
402
+ if (node.kind !== "PromiseNode" && node.kind !== "CallbackNode")
403
+ throw new ManifestError(`${where}: node.yaml kind must be PromiseNode or CallbackNode, got ${JSON.stringify(node.kind)}`);
404
+
405
+ const iface = parts.interface ?? {};
406
+ const config = parts.config ?? {};
407
+ const api = parts.api ?? null;
408
+
409
+ // A tool exchange or a streaming transport makes this a CallbackNode. The manifest
410
+ // DECLARES kind so it is visible on the node; this refuses the declaration when it
411
+ // contradicts what the node actually does, which used to surface only at run time.
412
+ // The LAST step is the one whose reply becomes the node's answer, so it is the only one
413
+ // whose transport can make the node stream. Every earlier step settles by definition.
414
+ const finalCall = api?.run?.[api.run.length - 1];
415
+ // `ws` streams by definition: a duplex session emits for as long as the call lasts. Listed
416
+ // beside sse for the same reason — the node cannot settle once, so it is a CallbackNode.
417
+ const streams = ["sse", "ndjson", "awsEventStream", "ws"].includes(finalCall?.transport);
418
+ /**
419
+ * SPAWN implies an actor. CONTINUE DOES NOT, and this rule used to say it did.
420
+ *
421
+ * The premise was that a re-fired node must be a generator. It is false, and the LoopStart /
422
+ * LoopEnd pair is the counter-example that found it: LoopStart is a settle-once node that runs,
423
+ * returns, and then runs AGAIN because LoopEnd routes a `continue` signal back to it along an
424
+ * ordinary edge. Each pass is a separate execution, which is exactly why the loop's index has to
425
+ * live in Redis rather than in a closure.
426
+ *
427
+ * Getting this wrong is not cosmetic. `ManifestCallbackExecutor` has `initializeState`, and the
428
+ * registry reads the presence of that method to record executionMode "generator" — so forcing
429
+ * CallbackNode here would hand LoopStart an actor and have the engine drive it a different way
430
+ * than the node it replaces. The retired executor is a `PromiseNode` with a CONTINUE input, and
431
+ * that combination has to stay expressible.
432
+ *
433
+ * SPAWN stays, because SPAWN is by definition "initialise an actor" (09-signal-routing.md). What
434
+ * makes a node a CallbackNode is emitting more than once from ONE execution: a streaming
435
+ * transport, or a tool exchange's turns.
436
+ */
437
+ const spawns = (iface.inputs ?? []).some((i: any) => i?.signal === "SPAWN");
438
+ /**
439
+ * RUN-AUTHORIZATION CHROME (UNOVERSE_NODE_ACCESS_CHROME.md, decided 2026-08-21).
440
+ *
441
+ * The builder's two access controls are injected HERE, once, into the schema every
442
+ * host is served, instead of being authored into every node's config.yaml. They used
443
+ * to be compulsory boilerplate in all 76 configs, held byte-identical by a lint rule,
444
+ * which is the signature of a thing the platform should own: a rule whose job is to
445
+ * keep N copies identical means there should be one copy.
446
+ *
447
+ * Injected at COMPOSE rather than by each front end, so no renderer can forget them
448
+ * the way SpatialSearch once shipped without them. The executor is untouched:
449
+ * authorize.ts already reads the raw instance values and takes the stricter of these
450
+ * and node.yaml's `auth` floor.
451
+ *
452
+ * An ANNOTATION (no inputs, no outputs, no api) is never run, so there is nobody to
453
+ * authorize and it gets no section — the same exception lint always made.
454
+ *
455
+ * A manifest still declaring the fields is superseded, not merged: the canonical
456
+ * definition wins, so a stale copy cannot loosen a default or reword the contract.
457
+ * (Lint refuses the names outright; this handles installed packages published before
458
+ * the flip.)
459
+ */
460
+ const annotation = !(iface.outputs ?? []).length && !(iface.inputs ?? []).length && !api;
461
+ const authoredSchema = config.configSchema ?? {};
462
+ const authoredProps = { ...(authoredSchema.properties ?? {}) };
463
+ delete authoredProps.authRequired;
464
+ delete authoredProps.authRole;
465
+ const composedSchema = annotation
466
+ ? authoredSchema
467
+ : { type: "object", ...authoredSchema, properties: { ...authoredProps, ...RUN_AUTH_CHROME } };
468
+ const authoredOrder: string[] | null = config["ui:order"] ?? null;
469
+ const composedOrder =
470
+ annotation || !authoredOrder
471
+ ? authoredOrder
472
+ : [...authoredOrder.filter((f) => f !== "authRequired" && f !== "authRole"), "authRequired", "authRole"];
473
+ const derived = streams || spawns || api?.toolExchange ? "CallbackNode" : "PromiseNode";
474
+ if (node.kind !== derived)
475
+ throw new ManifestError(
476
+ `${where}: declares kind "${node.kind}" but is a ${derived} (` +
477
+ (streams ? `its last step's transport "${finalCall.transport}" streams` : api?.toolExchange ? "it declares a toolExchange" : "an input carries a SPAWN signal") +
478
+ `)`,
479
+ );
480
+
481
+ return {
482
+ type: node.type,
483
+ kind: node.kind,
484
+ packageName: pkg.name,
485
+ origin: raw.origin,
486
+ allowedHosts: nodeAllowedHosts(node, packageAllowedHosts(pkg), where),
487
+ auth: nodeAuth(node),
488
+ helpers: packageHelpers(pkg),
489
+ api,
490
+ // Shaped for the registry (see platform/pluginAPI.ts registerNode), so a manifest
491
+ // node is indistinguishable from a code node everywhere downstream.
492
+ definition: {
493
+ type: node.type,
494
+ name: node.name ?? node.type,
495
+ description: node.description ?? "",
496
+ // The catalog ranker embeds this; losing it here would make the node invisible
497
+ // to the building agent no matter how well it works.
498
+ whenToUse: node.whenToUse ?? "",
499
+ // Trust surface, carried on the definition so it travels with the node into the
500
+ // catalog and needs no second lookup: WHO shipped it, and WHAT it may call.
501
+ // A manifest node arrives as data yet holds a credential and a URL, so these two
502
+ // answer "what am I accepting" without opening the YAML.
503
+ publisher: packagePublisher(pkg),
504
+ allowedHosts: nodeAllowedHosts(node, packageAllowedHosts(pkg), where),
505
+ category: node.category ?? "general",
506
+ version: node.version ?? "1.0.0",
507
+ color: node.color ?? "#6366f1",
508
+ // INHERITED from the package unless the node overrides it. A logo is a property
509
+ // of the vendor, not of one endpoint, so repeating it per node is 25 copies of
510
+ // one string that drift the moment a URL changes (Airtable shipped a dead one in
511
+ // four files). Declare it once in package.yaml; a node only names its own if it
512
+ // genuinely differs.
513
+ logoUrl: node.logoUrl ?? pkgMeta.logoUrl ?? null,
514
+ // How the canvas draws it. Absent means the ordinary node card.
515
+ template: node.template ?? null,
516
+ visibility: node.visibility ?? "public",
517
+ capabilities: node.capabilities ?? null,
518
+ inputs: iface.inputs ?? [],
519
+ outputs: iface.outputs ?? [],
520
+ credentials: iface.credentials ?? [],
521
+ serviceConnectors: iface.serviceConnectors ?? null,
522
+ isService: (iface.serviceConnectors ?? []).some((s: any) => s?.isService === true),
523
+ configSchema: composedSchema,
524
+ // The authored field order for the settings form, with the access chrome appended
525
+ // last (settings about access, not about the job). Null when nothing was authored:
526
+ // the canvas falls back to schema declaration order, which also puts chrome last.
527
+ "ui:order": composedOrder,
528
+ testData: parts.test?.testData ?? null,
529
+ },
530
+ };
531
+ }
532
+
533
+ export function composeCredentials(pkg: RawPackage): Record<string, any>[] {
534
+ return Object.entries(pkg.credentials).map(([file, body]) => {
535
+ const doc = parse(body, `${pkg.name}/credentials/${file}`);
536
+ if (!doc.name) throw new ManifestError(`${pkg.name}/credentials/${file}: no name`);
537
+ return { name: doc.name, displayName: doc.displayName ?? doc.name, description: doc.description ?? "", properties: doc.properties ?? [] };
538
+ });
539
+ }
@@ -0,0 +1,107 @@
1
+ /** Emit-repeatedly. Streaming, iteration, or a tool exchange. */
2
+ import { performApi, performService } from "../runtime/index.js";
3
+ import { assertAuthorized } from "../runtime/authorize.js";
4
+ import { manifestFor } from "./lookup.js";
5
+ import { contextFor } from "./context.js";
6
+ import { sessionFor, stateStoreFor, audioLaneFor } from "./session.js";
7
+ import { refireAfterDocMutation } from "./refire.js";
8
+ import { toolBridgeFor } from "./toolBridge.js";
9
+ import { hydrateMemory, ingestMemoryTurn } from "./memory.js";
10
+
11
+ export class ManifestCallbackExecutor {
12
+ logger: any = console;
13
+ executionContext: any = null;
14
+ constructor(readonly nodeType: string) {}
15
+
16
+ /**
17
+ * The SERVICE channel: a method another node called over a service edge.
18
+ *
19
+ * Independent of the workflow channel. A service call returns a value to the caller
20
+ * and does NOT fire this node's output connectors; a graph trigger fires the outputs
21
+ * and does not answer a caller. The two never cross (08-mcp-services.md).
22
+ */
23
+ async handleServiceCall(method: string, params: any, config: any, executionContext: any): Promise<unknown> {
24
+ const node = manifestFor(this.nodeType);
25
+ // The service channel is gated too, and it is the one that would be missed. A node
26
+ // reached over a service edge never fires its own connectors, so it is easy to think of
27
+ // as an internal detail of the caller. It is not: it runs this node's calls with this
28
+ // node's credentials, and leaving it open would make a service edge the way around a
29
+ // role rather than a way to reuse a capability.
30
+ assertAuthorized(node, executionContext, config);
31
+ const result = await performService(
32
+ node,
33
+ method,
34
+ params ?? {},
35
+ contextFor(node, {}, config, executionContext),
36
+ stateStoreFor(executionContext),
37
+ // The caller's live session, for a method that renders content cards. Assembled HERE
38
+ // because the token is deliberately absent from the manifest's context and must stay
39
+ // that way: the executor holds it and hands it to platform code, never to the manifest.
40
+ sessionFor(executionContext),
41
+ );
42
+ await refireAfterDocMutation(node, method, config, executionContext);
43
+ return result;
44
+ }
45
+
46
+ /** Presence of this method is what makes the registry record executionMode "generator". */
47
+ initializeState(): Record<string, unknown> {
48
+ return { emitted: 0 };
49
+ }
50
+
51
+ /**
52
+ * emit() sends outputs, the RETURN VALUE signals completion. They are separate
53
+ * channels: returning the outputs instead of emitting them loses them, and omitting
54
+ * isComplete leaks the actor and can hang the workflow.
55
+ */
56
+ async handleEvent(
57
+ event: { type: string; inputs?: any; config?: any },
58
+ state: Record<string, unknown>,
59
+ emit: (output: any) => void,
60
+ executionContext?: any,
61
+ ): Promise<Record<string, unknown>> {
62
+ const node = manifestFor(this.nodeType);
63
+ // Same gate on the streaming path. `this.executionContext` is the fallback the callback
64
+ // channel already uses, so the identity checked here is the identity the run carries.
65
+ assertAuthorized(node, executionContext ?? this.executionContext, event.config);
66
+ // The USER-MEMORY lane, before the run: when the node's config declares the toggle,
67
+ // the person's context snapshot rides the prompt (memory.ts — the executor's job,
68
+ // never a manifest's). No toggle, no prompt, headless → passes through untouched.
69
+ const hydratedConfig = await hydrateMemory(event.config, executionContext ?? this.executionContext);
70
+ const ctx = contextFor(node, event.inputs, hydratedConfig, executionContext ?? this.executionContext);
71
+
72
+ let emitted = 0;
73
+ const { outputs } = await performApi(
74
+ node,
75
+ ctx,
76
+ (e) => {
77
+ // A `send` row addresses another NODE, not one of this node's connectors, so it never
78
+ // becomes an output and never counts as one. It is delivered after the run, at the
79
+ // executor boundary, which is the only layer holding the execution context.
80
+ if (!e.emit) return;
81
+ emitted++;
82
+ emit({ __outputs: { [e.emit]: e.value } });
83
+ },
84
+ node.api?.toolExchange ? await toolBridgeFor(executionContext ?? this.executionContext) : undefined,
85
+ stateStoreFor(executionContext ?? this.executionContext),
86
+ // Only this executor gets the lane. A duplex session is always a CallbackNode, so a
87
+ // PromiseNode has no use for it and should not be handed one.
88
+ audioLaneFor(executionContext ?? this.executionContext),
89
+ // The caller's live session, for a node that publishes template data — assembled by
90
+ // the executor and never seen by the manifest, exactly as on the promise path.
91
+ sessionFor(executionContext ?? this.executionContext),
92
+ );
93
+
94
+ // The settled result: whatever `finalize` produced, plus any connector the stream
95
+ // never touched. Emitting the last streamed value again would double it.
96
+ const streamed = new Set(node.api?.response?.events?.map((r: any) => r.emit) ?? []);
97
+ const settled = Object.fromEntries(Object.entries(outputs).filter(([k]) => !streamed.has(k)));
98
+ if (Object.keys(settled).length) emit({ __outputs: settled });
99
+
100
+ // The turn complete — original input + final answer to user memory (fire-and-forget,
101
+ // self-gating on the toggle; the ORIGINAL prompt, not the hydrated one, because the
102
+ // memory block prepended above must not be re-ingested as something the user said).
103
+ await ingestMemoryTurn(node.type, event.config, executionContext ?? this.executionContext, outputs);
104
+
105
+ return { ...state, emitted, isComplete: true };
106
+ }
107
+ }