@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,240 @@
1
+ /**
2
+ * AWS Signature Version 4.
3
+ *
4
+ * THE CANONICAL CASE FOR THE WHOLE FORMAT (DECLARATIVE_NODES.md §2). Signing is
5
+ * computation over the request — it hashes the exact bytes being sent — so it belongs to
6
+ * the executor. What service to call, at what URL, with what body, is description, so it
7
+ * belongs to the manifest. Implement this once and DynamoDB, Bedrock, Textract and
8
+ * Comprehend stop being TypeScript packages.
9
+ *
10
+ * It is also why AWS "needs an SDK". Take the signer out of @aws-sdk/client-dynamodb and
11
+ * what remains is a JSON POST to one endpoint with a header naming the operation.
12
+ *
13
+ * NOT HAND-ROLLED, deliberately. `@smithy/signature-v4` is the same signer every AWS
14
+ * customer runs, and the edge cases are where this bites: canonical URI encoding differs by
15
+ * service (S3 does not double-encode paths, everything else does), headers must be trimmed
16
+ * and sorted just so, and temporary credentials add a token header that is itself signed.
17
+ * Getting any of them subtly wrong returns a 403, which reads as a bad credential and sends
18
+ * whoever sees it hunting IAM for a problem that is in here.
19
+ *
20
+ * NOTHING HERE IS PER-VENDOR IN THE AWS SENSE EITHER: this signs a request for whatever
21
+ * `service` the manifest names. There is no DynamoDB in this file, and there must not be.
22
+ */
23
+ import { SignatureV4 } from "@smithy/signature-v4";
24
+ import { HttpRequest } from "@smithy/protocol-http";
25
+ import { Sha256 } from "@aws-crypto/sha256-js";
26
+ import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
27
+
28
+ export interface AwsSigning {
29
+ region: string;
30
+ service: string;
31
+ accessKeyId: string;
32
+ secretAccessKey: string;
33
+ /** Present only for temporary credentials (STS / assumed roles). */
34
+ sessionToken?: string;
35
+ /**
36
+ * Send and sign `x-amz-content-sha256`, the payload hash. Defaults to ON.
37
+ *
38
+ * S3 REQUIRES it. Every other service accepts it, and signing the payload hash
39
+ * explicitly is strictly safer than leaving it implicit, so ON is the right default
40
+ * rather than a per-service opt-in nobody would remember to set.
41
+ *
42
+ * Off exists because AWS's own published test vectors are written without it, and a
43
+ * signer that cannot be compared against a known-good value is a signer nobody can check.
44
+ */
45
+ applyChecksum?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Sign a fully-built request, returning the headers to send.
50
+ *
51
+ * TAKES THE FINAL URL AND THE FINAL BODY, and that ordering is the whole subtlety. Every
52
+ * other auth scheme resolves before the request is assembled, because a bearer token does
53
+ * not depend on what it is attached to. A signature does: it covers the method, the path,
54
+ * the query, the headers and a hash of the body, so it cannot be computed until all of
55
+ * them are settled. Signing early would produce a valid-looking Authorization header for a
56
+ * request that was never sent.
57
+ */
58
+ /**
59
+ * AWS-CANONICAL PATH ENCODING, the residue encodeURIComponent leaves behind.
60
+ *
61
+ * `encodeURIComponent` deliberately skips `!'()*` — legal in a URL path — but SigV4's
62
+ * canonical URI requires every byte outside ALPHA/DIGIT/`-._~` percent-encoded. A key like
63
+ * `report (final).pdf` therefore signs one string while S3 canonicalises another, and the
64
+ * answer is a bare 403 that a HEAD cannot even explain (no body). Encoding these five here
65
+ * changes nothing for keys without them and makes the signature agree for keys with them —
66
+ * S3 resolves `%28` and `(` to the same object either way.
67
+ */
68
+ const awsCanonicalPath = (pathname: string): string =>
69
+ pathname.replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
70
+
71
+ export async function signAwsRequest(
72
+ url: string,
73
+ method: string,
74
+ headers: Record<string, string>,
75
+ body: string | undefined,
76
+ signing: AwsSigning,
77
+ ): Promise<Record<string, string>> {
78
+ const parsed = new URL(url);
79
+
80
+ const query: Record<string, string> = {};
81
+ parsed.searchParams.forEach((value, key) => {
82
+ query[key] = value;
83
+ });
84
+
85
+ const signer = new SignatureV4({
86
+ service: signing.service,
87
+ region: signing.region,
88
+ credentials: {
89
+ accessKeyId: signing.accessKeyId,
90
+ secretAccessKey: signing.secretAccessKey,
91
+ sessionToken: signing.sessionToken,
92
+ },
93
+ sha256: Sha256,
94
+ applyChecksum: signing.applyChecksum !== false,
95
+ /**
96
+ * S3 IS THE EXCEPTION, and it is the classic SigV4 bug.
97
+ *
98
+ * Every other service double-encodes the path when canonicalising it; S3 does not,
99
+ * because an S3 key may legitimately contain characters that a second pass would
100
+ * mangle. Sign an S3 request the ordinary way and any key with a space or a slash in it
101
+ * returns 403 while simple keys work — which reads as a permissions problem on some
102
+ * objects and not others.
103
+ */
104
+ uriEscapePath: signing.service !== "s3",
105
+ });
106
+
107
+ const signed = await signer.sign(
108
+ new HttpRequest({
109
+ protocol: parsed.protocol,
110
+ hostname: parsed.hostname,
111
+ port: parsed.port ? Number(parsed.port) : undefined,
112
+ path: awsCanonicalPath(parsed.pathname),
113
+ query,
114
+ method: method.toUpperCase(),
115
+ // `host` MUST be present and MUST match what is dialled: it is a signed header, and a
116
+ // mismatch between the signature and the Host the server sees is a 403 with a message
117
+ // that does not mention hosts.
118
+ headers: { ...headers, host: parsed.host },
119
+ body,
120
+ }),
121
+ );
122
+
123
+ return signed.headers as Record<string, string>;
124
+ }
125
+
126
+ /**
127
+ * A PRESIGNED URL: authority to fetch one object, handed to someone who has no credentials.
128
+ *
129
+ * The signature moves out of the Authorization HEADER and into the QUERY STRING, with an
130
+ * expiry, so the URL alone is enough. That is what makes it shareable — a browser, an email,
131
+ * an LLM's answer — and also why the expiry matters: anyone holding the link has the access
132
+ * until it lapses.
133
+ *
134
+ * NO NETWORK. This computes a string from the credentials and the clock, which is why a
135
+ * manifest reaches it as a call entry that never goes out, the same way `state` does.
136
+ */
137
+ export async function presignAwsUrl(
138
+ url: string,
139
+ signing: AwsSigning,
140
+ expiresIn: number,
141
+ ): Promise<string> {
142
+ const parsed = new URL(url);
143
+
144
+ const query: Record<string, string> = {};
145
+ parsed.searchParams.forEach((value, key) => {
146
+ query[key] = value;
147
+ });
148
+
149
+ const signer = new SignatureV4({
150
+ service: signing.service,
151
+ region: signing.region,
152
+ credentials: {
153
+ accessKeyId: signing.accessKeyId,
154
+ secretAccessKey: signing.secretAccessKey,
155
+ sessionToken: signing.sessionToken,
156
+ },
157
+ sha256: Sha256,
158
+ uriEscapePath: signing.service !== "s3",
159
+ });
160
+
161
+ const signed = await signer.presign(
162
+ new HttpRequest({
163
+ protocol: parsed.protocol,
164
+ hostname: parsed.hostname,
165
+ port: parsed.port ? Number(parsed.port) : undefined,
166
+ path: awsCanonicalPath(parsed.pathname),
167
+ query,
168
+ method: "GET",
169
+ // UNSIGNED-PAYLOAD, declared as the sha256 header so the signer hoists it into the
170
+ // query and signs THAT. Without it the signer hashes the (absent) body to the
171
+ // empty-string sha, while S3 verifies a query-auth GET against UNSIGNED-PAYLOAD —
172
+ // a SignatureDoesNotMatch whose error mentions neither body nor payload. This is
173
+ // what @aws-sdk/s3-request-presigner does; proved against live S3 rather than added
174
+ // as a dependency for one header.
175
+ headers: { host: parsed.host, "x-amz-content-sha256": "UNSIGNED-PAYLOAD" },
176
+ }),
177
+ { expiresIn },
178
+ );
179
+
180
+ const out = new URL(`${signed.protocol}//${signed.hostname}${signed.path}`);
181
+ for (const [k, v] of Object.entries(signed.query ?? {})) {
182
+ if (typeof v === "string") out.searchParams.set(k, v);
183
+ }
184
+ return out.toString();
185
+ }
186
+
187
+ /**
188
+ * DynamoDB's type-tagged encoding, in and out.
189
+ *
190
+ * DynamoDB does not carry `{ name: "Ada" }`. It carries `{ name: { S: "Ada" } }` — every
191
+ * value tagged with its type, nested arbitrarily. Something has to translate, and it cannot
192
+ * be an expression: the walk is RECURSIVE and the sandbox has no way to define a function
193
+ * that calls itself.
194
+ *
195
+ * `@aws-sdk/util-dynamodb` is AWS's own translator for exactly this, and it is what the
196
+ * DocumentClient uses internally. Same judgement as the signer above: a published,
197
+ * widely-run implementation beats one written here, because the edge cases (binary, sets,
198
+ * empty strings, numeric precision) are where a hand-rolled version quietly differs.
199
+ *
200
+ * Marked ENCODING rather than transport: transport is how a reply is FRAMED (json, sse),
201
+ * this is how the values inside it are SPELLED. A manifest writes plain JSON either way.
202
+ */
203
+ export function encodeDynamoJson(value: unknown): unknown {
204
+ if (value === undefined || value === null) return value;
205
+ if (Array.isArray(value)) return value.map(encodeDynamoJson);
206
+ if (typeof value !== "object") return value;
207
+
208
+ const out: Record<string, unknown> = {};
209
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
210
+ // Only the keys that HOLD ITEM DATA are translated. The rest of a DynamoDB request is
211
+ // ordinary JSON — TableName is a string, Limit is a number — and marshalling those
212
+ // would turn `TableName: "Users"` into `{ S: "Users" }` and 400 every call.
213
+ out[key] = ITEM_KEYS.has(key) && v && typeof v === "object" && !Array.isArray(v)
214
+ ? marshall(v as Record<string, unknown>, { removeUndefinedValues: true })
215
+ : encodeDynamoJson(v);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /** Request keys whose value is item data, so the only ones that get type tags. */
221
+ const ITEM_KEYS = new Set(["Item", "Key", "ExpressionAttributeValues", "ExclusiveStartKey"]);
222
+
223
+ /** Reply keys that come back type-tagged. `Items` is a list, `Item` a single map. */
224
+ const REPLY_KEYS = new Set(["Item", "Items", "Attributes", "LastEvaluatedKey", "Responses"]);
225
+
226
+ export function decodeDynamoJson(value: unknown): unknown {
227
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
228
+
229
+ const out: Record<string, unknown> = {};
230
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
231
+ if (!REPLY_KEYS.has(key) || !v || typeof v !== "object") {
232
+ out[key] = v;
233
+ continue;
234
+ }
235
+ out[key] = Array.isArray(v)
236
+ ? v.map((row) => unmarshall(row as Record<string, any>))
237
+ : unmarshall(v as Record<string, any>);
238
+ }
239
+ return out;
240
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * OAUTH2 CLIENT CREDENTIALS. An auth scheme, so it is written once here rather than in
3
+ * every node that needs it (DECLARATIVE_NODES.md §2).
4
+ *
5
+ * Unlike a bearer key, this is not a value the manifest can template: the credential is a
6
+ * client id and secret that must be EXCHANGED for a short-lived token, over a form-encoded
7
+ * POST, and the result cached until it expires. That is computation over the request, and
8
+ * a manifest that tried to express it would need a request before its request.
9
+ *
10
+ * The token response is put back into scope as `token`, because some vendors return where
11
+ * to talk as well as how. Salesforce returns `instance_url` and the org's API lives there,
12
+ * not at the login host you authenticated against.
13
+ */
14
+
15
+ interface Minted {
16
+ accessToken: string;
17
+ instanceUrl?: string;
18
+ expiresAt: number;
19
+ }
20
+
21
+ /**
22
+ * Cached per connected-app identity, not per node or per run.
23
+ *
24
+ * A workflow that makes six calls must not mint six tokens, and a vendor will rate-limit or
25
+ * refuse if it does. Keyed by client id AND token url so two orgs never share an entry.
26
+ */
27
+ const CACHE = new Map<string, Minted>();
28
+
29
+ /** Default life. Salesforce client-credentials sessions are ~2h; refresh well before. */
30
+ const DEFAULT_TTL_MS = 110 * 60 * 1000;
31
+
32
+ /** Drop a cached token, so the next call mints a fresh one. */
33
+ export function invalidateToken(tokenUrl: string, clientId: string): void {
34
+ CACHE.delete(`${clientId}@${tokenUrl}`);
35
+ }
36
+
37
+ export async function mintClientCredentials(
38
+ tokenUrl: string,
39
+ clientId: string,
40
+ clientSecret: string,
41
+ scope: string | undefined,
42
+ force = false,
43
+ ): Promise<Minted> {
44
+ const key = `${clientId}@${tokenUrl}`;
45
+ const hit = CACHE.get(key);
46
+ if (!force && hit && hit.expiresAt > Date.now()) return hit;
47
+
48
+ // FORM ENCODED, not JSON. The OAuth2 spec says so and vendors enforce it, which is why
49
+ // this cannot ride the ordinary body path: that one always sends JSON.
50
+ const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
51
+ if (scope) form.set("scope", scope);
52
+
53
+ const res = await fetch(tokenUrl, {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
56
+ body: form.toString(),
57
+ });
58
+
59
+ const data: any = await res.json().catch(() => null);
60
+ if (!res.ok || !data?.access_token) {
61
+ // The description, not just the status: "invalid_client" tells you which of the two
62
+ // secrets is wrong, and a bare 400 does not.
63
+ const detail = data?.error_description ?? data?.error ?? res.statusText;
64
+ throw new Error(`oauth2ClientCredentials: token request to ${tokenUrl} failed (${res.status}): ${detail}`);
65
+ }
66
+
67
+ const minted: Minted = {
68
+ accessToken: data.access_token,
69
+ instanceUrl: typeof data.instance_url === "string" ? data.instance_url.replace(/\/+$/, "") : undefined,
70
+ // Honour the vendor's own expiry when it sends one, minus a minute of slack so a token
71
+ // cannot expire in flight.
72
+ expiresAt: Date.now() + (Number(data.expires_in) ? Number(data.expires_in) * 1000 - 60_000 : DEFAULT_TTL_MS),
73
+ };
74
+ CACHE.set(key, minted);
75
+ return minted;
76
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * WHO MAY RUN THIS NODE, enforced at node start (DECLARATIVE_NODES.md §9.13).
3
+ *
4
+ * The inbound question, about the CALLER. A call's `credential` is the outbound one: how the
5
+ * node proves itself to a vendor. Both were spelled `auth` until 2026-07-28, which is the
6
+ * reason the two were so easy to mistake for each other.
7
+ *
8
+ * WHY THIS FILE EXISTS. `requires.role` was in the node schema and checked by lint from the
9
+ * day the format shipped, and read by no runtime code at all: `compose.ts` never carried the
10
+ * field onto the composed node, so the executor could not have enforced it even if someone
11
+ * had written the check. Every doc said "schema + lint exist, the executor check does not".
12
+ * A security field that lints clean and does nothing is worse than no field, because a
13
+ * reviewer reads it as a decision that was made and is holding.
14
+ *
15
+ * WHAT THIS IS NOT. This does not admit anyone. The platform gate at the edge decides who
16
+ * gets in at all (default-deny JWT, `auth/publicEntry.ts` for the anonymous case). By the
17
+ * time a node runs the caller is already whoever they are going to be, and this only ever
18
+ * NARROWS: a node may demand more than the trigger did and can never waive what it demanded.
19
+ *
20
+ * FAILS LOUDLY, and that is a deliberate choice over the alternative. A node that no-ops on
21
+ * missing identity looks fine in testing and silently does nothing in production, and nobody
22
+ * finds out for a week. Throwing names the claim that was missing, in the run's error, on
23
+ * the node that wanted it.
24
+ */
25
+
26
+ import { grantedTo } from "../../security/permissions.js";
27
+
28
+ /** What the platform gate put on the run. Guests carry an id and nothing else. */
29
+ interface Caller {
30
+ id?: string;
31
+ sub?: string;
32
+ email?: string;
33
+ roles?: string[];
34
+ permissions?: string[];
35
+ }
36
+
37
+ export class NodeAuthorizationError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "NodeAuthorizationError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * A guest is an ADMITTED anonymous visitor, not an absent one, and the two must not be
46
+ * conflated: a guest legitimately reached a public workflow, while `undefined` means no
47
+ * identity travelled at all. Both fail `required: true`, but only the first is expected.
48
+ *
49
+ * The id shape is the gate's contract (`publicEntry.ts` mints `guest:<uuid>` and refuses a
50
+ * session claiming any other shape), and the retired `guest-` spelling still counts because
51
+ * clients mint and keep these across sessions.
52
+ */
53
+ function isGuest(user: Caller | undefined): boolean {
54
+ const id = String(user?.id ?? user?.sub ?? "");
55
+ return id.startsWith("guest:") || id.startsWith("guest-");
56
+ }
57
+
58
+ /**
59
+ * Roles and permissions are ONE namespace here, deliberately. Both are `noun:verb` claims
60
+ * parsed off the same token (`auth.ts` reads each from its own claim, Auth0-namespaced or
61
+ * not), and `marketplace:publish` is already checked as a permission while reading exactly
62
+ * like a role. Making an author know which claim their identity provider happened to put a
63
+ * string in would be a footgun with no upside: the question is whether the caller carries
64
+ * the claim, not which list it arrived in.
65
+ */
66
+ /**
67
+ * Does this caller carry the claim a node asked for?
68
+ *
69
+ * THREE PLACES TO LOOK, AND THE THIRD IS THE ONE THAT WAS MISSING. Raw `roles` and raw
70
+ * `permissions` are a deployment's own vocabulary: a builder naming `finance:approve` means
71
+ * the exact claim their identity provider mints, which the platform neither knows nor should
72
+ * invent. That part is unchanged.
73
+ *
74
+ * What is added is the PLATFORM's grant map (security/permissions.ts). Without it one string
75
+ * meant two things in two places: a ROUTE requiring `credentials:manage` admitted an admin,
76
+ * while a NODE requiring `credentials:manage` refused the same person, because that
77
+ * permission is granted by their role rather than stamped on their token. Same question,
78
+ * same answer, wherever it is asked.
79
+ *
80
+ * It widens only for permissions the platform itself defines, and only for holders of a role
81
+ * that already grants them elsewhere. A deployment-specific claim must still be on the token.
82
+ */
83
+ function carries(user: Caller | undefined, role: string): boolean {
84
+ if ([...(user?.roles ?? []), ...(user?.permissions ?? [])].includes(role)) return true;
85
+ return (grantedTo(user) as Set<string>).has(role);
86
+ }
87
+
88
+ /**
89
+ * TWO SOURCES, AND THE STRICTER WINS.
90
+ *
91
+ * The manifest's `auth` is the node AUTHOR's floor: "this node is inherently privileged",
92
+ * true of every copy of it everywhere. The instance's `authRequired` / `authRole` are the
93
+ * WORKFLOW BUILDER's, set per box on the canvas, because only they know that this
94
+ * particular box faces customers rather than staff.
95
+ *
96
+ * A role in particular has to be able to come from the instance. `finance:approve` is a
97
+ * claim ONE deployment's identity provider mints, so a node published to the marketplace
98
+ * cannot name it: the author does not know the role vocabulary of the universes that
99
+ * install their node, and the builder does.
100
+ *
101
+ * Neither source can loosen the other. Requirements only narrow, which is the same law the
102
+ * trigger's public toggle obeys, so both roles are demanded when both exist rather than one
103
+ * overriding the other.
104
+ */
105
+ function combine(
106
+ manifest: { required: boolean; role?: string },
107
+ config: any,
108
+ ): { required: boolean; roles: string[] } {
109
+ // Read STRAIGHT off the instance config and never through the template resolver. A gate
110
+ // that resolved `{{ ... }}` would let the thing being gated choose its own answer.
111
+ const instanceRequired = config?.authRequired === true;
112
+ const instanceRole = typeof config?.authRole === "string" && config.authRole.trim() ? config.authRole.trim() : undefined;
113
+
114
+ const roles = [manifest.role, instanceRole].filter((r): r is string => !!r);
115
+ // A role implies a signed-in caller wherever it came from, so an instance role that
116
+ // someone set while leaving the toggle off still demands identity rather than being
117
+ // quietly ignored.
118
+ return { required: manifest.required || instanceRequired || roles.length > 0, roles };
119
+ }
120
+
121
+ /**
122
+ * Throws unless this caller may run this node. Called once per node start, before any call
123
+ * is made, so a refused run costs no vendor request and no side effect.
124
+ *
125
+ * `nodeAuth` in compose.ts has already collapsed the unsatisfiable `role` + `required: false`
126
+ * pair in the manifest, so a manifest role always implies `required: true`.
127
+ */
128
+ export function assertAuthorized(
129
+ node: { type: string; auth?: { required: boolean; role?: string } },
130
+ executionContext: any,
131
+ config?: any,
132
+ ): void {
133
+ // Absent means a manifest composed before this field existed. Treated as "signed in
134
+ // required" for the same reason compose.ts does: the safe reading of a node that never
135
+ // said, rather than the convenient one.
136
+ const want = combine(node.auth ?? { required: true }, config);
137
+ if (!want.required) return;
138
+
139
+ const user: Caller | undefined = executionContext?.auth?.user;
140
+ const identified = !!(user?.id ?? user?.sub) && !isGuest(user);
141
+
142
+ if (!identified)
143
+ throw new NodeAuthorizationError(
144
+ `${node.type} requires a signed-in caller${want.roles.length ? ` carrying ${want.roles.map((r) => `"${r}"`).join(" and ")}` : ""}, and this run has ` +
145
+ (isGuest(user) ? "an anonymous guest" : "no identity") +
146
+ `. The workflow's trigger admitted the run; this step asks for more than the trigger did, ` +
147
+ `set either on the node itself (node.yaml auth) or on this box in the workflow (Require sign-in).`,
148
+ );
149
+
150
+ // NAMES THE CLAIM. A bare "forbidden" sends whoever hit it to read the manifest to find
151
+ // out what they lacked, and an operator granting it needs the exact string anyway.
152
+ const missing = want.roles.filter((r) => !carries(user, r));
153
+ if (missing.length)
154
+ throw new NodeAuthorizationError(
155
+ `${node.type} requires the role${missing.length > 1 ? "s" : ""} ${missing.map((r) => `"${r}"`).join(" and ")}, ` +
156
+ `which this caller does not carry. ` +
157
+ `Present claims: ${[...(user?.roles ?? []), ...(user?.permissions ?? [])].join(", ") || "none"}.`,
158
+ );
159
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * THE LAST CALL — the one whose reply becomes the node's answer.
3
+ *
4
+ * Separate from `runCalls` because it is the only call that may STREAM. Every earlier call
5
+ * settles and exists so a later one can read it; this one is framed onto the output connectors,
6
+ * so it is the single place that has to know about every shape a reply can take: a settled body,
7
+ * an SSE stream, a duplex session, a walked pagination, a polled job, a chunked write, a state
8
+ * write, a loop step. A node that makes one call is just this, with nothing before it.
9
+ *
10
+ * ONE DISPATCH, in this order, and the order matters in one place: `transport: ws` is tested
11
+ * BEFORE `sendRequest`, because a socket handshake is not an HTTP request and issuing one would
12
+ * open a second connection to the vendor and pay for it.
13
+ *
14
+ * `base` is the emitter's shared scope, and every branch records its reply into it under the
15
+ * call's own name BEFORE any row fires. Without that, `calls.<name>` held the earlier calls only
16
+ * and the last one was reachable as `response` alone. That asymmetry was silent and it bit
17
+ * immediately: a service method and a graph run sharing ONE $ref'd expression behaved
18
+ * differently, because the service channel puts every call through `runCalls` and had the name
19
+ * while the graph run did not. An expression written once must mean one thing.
20
+ */
21
+ import type { ComposedNode } from "../../compose.js";
22
+ import type { RunContext } from "../context.js";
23
+ import { evaluate, render } from "../templating.js";
24
+ import { sendRequest, buildRequest, performPresign } from "../http/request.js";
25
+ import { readSse, readSettled, assertOk } from "../http/response.js";
26
+ import { runDuplexSession } from "../duplex/session.js";
27
+ import type { AudioLane } from "../duplex/audioLane.js";
28
+ import { performState, type StateStore } from "../state.js";
29
+ import { fetchPaginated } from "../loops/paginate.js";
30
+ import { fetchPolled } from "../loops/poll.js";
31
+ import { sendChunked } from "../loops/chunk.js";
32
+ import { performLoop } from "../loops/loop.js";
33
+ import { performDoc } from "../docstore/index.js";
34
+ import type { Emitter } from "../events.js";
35
+ import type { ToolBridge } from "../tools/toolloop.js";
36
+ import { makeUsageCollector } from "../usage.js";
37
+
38
+ export async function runFinal(
39
+ node: ComposedNode,
40
+ call: any,
41
+ ctx: RunContext,
42
+ emitter: Emitter,
43
+ base: Record<string, unknown>,
44
+ store?: StateStore,
45
+ lane?: AudioLane | null,
46
+ tools?: ToolBridge,
47
+ ): Promise<number> {
48
+ /** Every branch settles the same way: name the reply, then let the events table shape it. */
49
+ const settle = async (payload: unknown): Promise<number> => {
50
+ (base.calls as Record<string, unknown>)[call.name] = payload;
51
+ await emitter.response(payload);
52
+ return 200;
53
+ };
54
+
55
+ // A DOCSTORE op as the last (often only) call — SmartDocument's whole workflow channel is
56
+ // one `docstore: render`. Same defaulting as the runCalls branch: arguments are the
57
+ // caller's params unless the call names its own, and the doc's key derives from the run's
58
+ // ids inside performDoc.
59
+ if (call.docstore) {
60
+ const args = call.params
61
+ ? ((await evaluate(call.params, ctx as unknown as Record<string, unknown>)) as Record<string, any>)
62
+ : ctx.params;
63
+ return settle(await performDoc(String(call.docstore), args ?? {}, store?.raw, ctx.scope, ctx.config));
64
+ }
65
+
66
+ // A node whose LAST act is a state write settles on that write, so the events table can
67
+ // emit what was stored.
68
+ if (call.state) {
69
+ const value = call.value ? await evaluate(call.value, ctx as unknown as Record<string, unknown>) : undefined;
70
+ return settle(await performState(call.state, String(render(call.key, ctx)), value, call.max, store, ctx.scope));
71
+ }
72
+
73
+ // A LOOP step. LoopStart settles on the pass it opened or read, LoopEnd on the step it
74
+ // advanced, and each one's events table shapes its connectors from that single payload.
75
+ if (call.loop) {
76
+ const value = call.value ? await evaluate(call.value, ctx as unknown as Record<string, unknown>) : undefined;
77
+ return settle(await performLoop(call.loop, ctx.scope?.executionId, String(render(call.key, ctx)), value, store?.loop));
78
+ }
79
+
80
+ // Presign: no request at all — the node settles on the minted urls, always a list.
81
+ // S3Files ends on one when links are asked for: list, then mint a link per object found.
82
+ if (call.presign) return settle(await performPresign(node, call, ctx));
83
+
84
+ // Chunked: many requests over one collection, one summary reply.
85
+ if (call.chunk) return settle(await sendChunked(node, call, ctx, node.type));
86
+
87
+ // Paginated: many requests, one accumulated reply, so the events table sees the whole set.
88
+ if (call.paginate) return settle(await fetchPaginated(node, call, ctx, node.type));
89
+
90
+ // Polled: a job, started and waited on, settling as the final status payload.
91
+ if (call.poll) return settle(await fetchPolled(node, call, ctx, node.type));
92
+
93
+ // DUPLEX, and it must come BEFORE sendRequest, because a socket handshake is not an HTTP
94
+ // request and issuing one would open a second connection to the vendor and pay for it.
95
+ //
96
+ // `buildRequest` is reused only to RESOLVE the url and the credential headers: a ws
97
+ // handshake carries the credential in a header exactly as a request does, so the schemes
98
+ // and the template resolution should be the same code rather than a second implementation
99
+ // that drifts. Its body and method are discarded, because a session has neither.
100
+ if (call.transport === "ws") {
101
+ const built = await buildRequest({ request: call }, ctx);
102
+ const headers = Object.fromEntries(
103
+ Object.entries((built.init.headers ?? {}) as Record<string, string>).filter(([k]) => k.toLowerCase() !== "content-type"),
104
+ );
105
+ // The audio lane keys everything by conversation, and the events already carry it. Falls
106
+ // back to the execution id so a node with no conversation still holds a valid session and
107
+ // still emits transcripts — it simply has nowhere to send audio.
108
+ const conversationId = String(
109
+ (ctx as any).scope?.conversationId ?? (ctx as any).config?.conversationId ?? (ctx as any).scope?.executionId ?? node.type,
110
+ );
111
+ // Every other transport builds one of these; the socket built none, so voice calls reported
112
+ // no tokens at all. `usage.save()` sits BEFORE the throw: a call that ended badly still
113
+ // burned what it spoke.
114
+ const usage = makeUsageCollector(node, ctx);
115
+ const result = await runDuplexSession({
116
+ node,
117
+ call,
118
+ ctx,
119
+ emitter,
120
+ url: built.url,
121
+ headers,
122
+ lane: lane ?? null,
123
+ conversationId,
124
+ tools,
125
+ usage,
126
+ onError: (message) => {
127
+ // Surfaced rather than thrown: one bad frame mid-call must not end a conversation the
128
+ // person is still having.
129
+ void emitter.narrator(message);
130
+ },
131
+ });
132
+ usage.save();
133
+ if (result.reason === "error") throw new Error(`${node.type}: duplex session failed: ${result.error}`);
134
+ return 200;
135
+ }
136
+
137
+ const res = await sendRequest(node, call, ctx, node.type);
138
+ const transport = call.transport;
139
+
140
+ if (transport === "json" || transport === "text") {
141
+ const payload = await readSettled(res, transport, call.encoding);
142
+ await assertOk(node, call, payload, node.type);
143
+ // Token usage: a settled vendor body carries it inline (Chat Completions puts it
144
+ // top-level). One payload, so see + save in one breath.
145
+ const usage = makeUsageCollector(node, ctx);
146
+ usage.see(payload);
147
+ usage.save();
148
+ // Named BEFORE emitting, so a row reading calls.<name> sees it.
149
+ //
150
+ // Settling only. A streaming call has no single reply to name: it is a sequence, and
151
+ // each event arrives as `response`. Inventing a value for `calls.<name>` there would be
152
+ // a guess about which event counted.
153
+ (base.calls as Record<string, unknown>)[call.name] = payload;
154
+ // No `match`: a settled body is one event, so every `from: response` row fires over it.
155
+ await emitter.response(payload);
156
+ return res.status;
157
+ }
158
+
159
+ if (transport === "sse") {
160
+ const usage = makeUsageCollector(node, ctx);
161
+ await readSse(res, call.terminator, async (payload) => {
162
+ await assertOk(node, call, payload, node.type);
163
+ usage.see(payload);
164
+ await emitter.response(payload, payload.type);
165
+ });
166
+ usage.save();
167
+ return res.status;
168
+ }
169
+
170
+ throw new Error(`transport "${transport}" is declared in the schema but not implemented in the executor`);
171
+ }