@unoverse-platform/studio 0.1.90 → 0.1.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/local/plugin.ts +20 -12
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +128 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +643 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.mjs +64 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +107 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +154 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,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
|
+
}
|