@tangle-network/agent-interface 0.40.0 → 0.42.0
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/README.md +40 -0
- package/dist/agent-candidate-code-schema.d.ts +2 -0
- package/dist/agent-candidate-execution-plan-schema.d.ts +10 -6
- package/dist/agent-candidate-profile-schema.d.ts +2 -0
- package/dist/agent-candidate-promotion-schema.d.ts +54 -20
- package/dist/agent-candidate-receipt-schema.d.ts +6 -4
- package/dist/agent-candidate-schema.d.ts +4 -0
- package/dist/agent-execution-preparation.d.ts +2 -0
- package/dist/environment-provider.d.ts +392 -1
- package/dist/environment-provider.js +214 -1
- package/dist/harness.d.ts +5 -1
- package/dist/harness.js +2 -0
- package/dist/index.d.ts +13 -3
- package/dist/index.js +4 -0
- package/dist/interaction.d.ts +173 -51
- package/dist/interaction.js +377 -36
- package/dist/portable-context.d.ts +752 -0
- package/dist/portable-context.js +754 -0
- package/dist/profile-schema.d.ts +2 -0
- package/dist/runtime-control.d.ts +38 -0
- package/dist/runtime-control.js +161 -0
- package/dist/workspace-branching.d.ts +251 -0
- package/dist/workspace-branching.js +400 -0
- package/package.json +1 -1
package/dist/interaction.js
CHANGED
|
@@ -30,33 +30,34 @@ const FieldBase = {
|
|
|
30
30
|
/** Whether the answer must supply this field to be `accepted`. */
|
|
31
31
|
required: z.boolean().optional(),
|
|
32
32
|
};
|
|
33
|
-
export const InteractionFieldSchema = z
|
|
34
|
-
|
|
33
|
+
export const InteractionFieldSchema = z
|
|
34
|
+
.discriminatedUnion("type", [
|
|
35
|
+
z.strictObject({
|
|
35
36
|
...FieldBase,
|
|
36
37
|
type: z.literal("text"),
|
|
37
38
|
multiline: z.boolean().optional(),
|
|
38
39
|
placeholder: z.string().optional(),
|
|
39
40
|
default: z.string().optional(),
|
|
40
41
|
}),
|
|
41
|
-
z.
|
|
42
|
+
z.strictObject({
|
|
42
43
|
...FieldBase,
|
|
43
44
|
type: z.literal("number"),
|
|
44
|
-
min: z.number().optional(),
|
|
45
|
-
max: z.number().optional(),
|
|
46
|
-
default: z.number().optional(),
|
|
45
|
+
min: z.number().finite().optional(),
|
|
46
|
+
max: z.number().finite().optional(),
|
|
47
|
+
default: z.number().finite().optional(),
|
|
47
48
|
}),
|
|
48
|
-
z.
|
|
49
|
+
z.strictObject({
|
|
49
50
|
...FieldBase,
|
|
50
51
|
type: z.literal("boolean"),
|
|
51
52
|
default: z.boolean().optional(),
|
|
52
53
|
}),
|
|
53
|
-
z.
|
|
54
|
+
z.strictObject({
|
|
54
55
|
...FieldBase,
|
|
55
56
|
type: z.literal("select"),
|
|
56
57
|
options: z
|
|
57
|
-
.array(z.
|
|
58
|
-
value: z.string(),
|
|
59
|
-
label: z.string(),
|
|
58
|
+
.array(z.strictObject({
|
|
59
|
+
value: z.string().min(1),
|
|
60
|
+
label: z.string().min(1),
|
|
60
61
|
description: z.string().optional(),
|
|
61
62
|
}))
|
|
62
63
|
.min(1),
|
|
@@ -68,26 +69,179 @@ export const InteractionFieldSchema = z.discriminatedUnion("type", [
|
|
|
68
69
|
* Write-ins must still be non-empty strings.
|
|
69
70
|
*/
|
|
70
71
|
allowCustom: z.boolean().optional(),
|
|
71
|
-
default: z.array(z.string()).optional(),
|
|
72
|
+
default: z.array(z.string().min(1)).optional(),
|
|
72
73
|
}),
|
|
73
74
|
/** Like `text` but the value is sensitive (token/key) and must be masked. */
|
|
74
|
-
z.
|
|
75
|
+
z.strictObject({
|
|
75
76
|
...FieldBase,
|
|
76
77
|
type: z.literal("secret"),
|
|
77
78
|
placeholder: z.string().optional(),
|
|
78
79
|
}),
|
|
79
|
-
])
|
|
80
|
-
|
|
80
|
+
])
|
|
81
|
+
.superRefine((field, context) => {
|
|
82
|
+
if (field.type === "number") {
|
|
83
|
+
if (field.min !== undefined &&
|
|
84
|
+
field.max !== undefined &&
|
|
85
|
+
field.min > field.max) {
|
|
86
|
+
context.addIssue({
|
|
87
|
+
code: "custom",
|
|
88
|
+
path: ["max"],
|
|
89
|
+
message: "number field max must be greater than or equal to min",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (field.default !== undefined &&
|
|
93
|
+
field.min !== undefined &&
|
|
94
|
+
field.default < field.min) {
|
|
95
|
+
context.addIssue({
|
|
96
|
+
code: "custom",
|
|
97
|
+
path: ["default"],
|
|
98
|
+
message: "number field default must be greater than or equal to min",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (field.default !== undefined &&
|
|
102
|
+
field.max !== undefined &&
|
|
103
|
+
field.default > field.max) {
|
|
104
|
+
context.addIssue({
|
|
105
|
+
code: "custom",
|
|
106
|
+
path: ["default"],
|
|
107
|
+
message: "number field default must be less than or equal to max",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (field.type !== "select")
|
|
113
|
+
return;
|
|
114
|
+
const optionValues = field.options.map((option) => option.value);
|
|
115
|
+
if (new Set(optionValues).size !== optionValues.length) {
|
|
116
|
+
context.addIssue({
|
|
117
|
+
code: "custom",
|
|
118
|
+
path: ["options"],
|
|
119
|
+
message: "select option values must be unique",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (field.default === undefined)
|
|
123
|
+
return;
|
|
124
|
+
if (new Set(field.default).size !== field.default.length) {
|
|
125
|
+
context.addIssue({
|
|
126
|
+
code: "custom",
|
|
127
|
+
path: ["default"],
|
|
128
|
+
message: "select default values must be unique",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (!field.multi && field.default.length > 1) {
|
|
132
|
+
context.addIssue({
|
|
133
|
+
code: "custom",
|
|
134
|
+
path: ["default"],
|
|
135
|
+
message: "single-select default may contain at most one value",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (field.required && field.default.length === 0) {
|
|
139
|
+
context.addIssue({
|
|
140
|
+
code: "custom",
|
|
141
|
+
path: ["default"],
|
|
142
|
+
message: "required select default must contain a value",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const allowed = new Set(optionValues);
|
|
146
|
+
for (const value of field.default) {
|
|
147
|
+
if (allowed.has(value))
|
|
148
|
+
continue;
|
|
149
|
+
if (field.allowCustom === true && value.trim().length > 0)
|
|
150
|
+
continue;
|
|
151
|
+
context.addIssue({
|
|
152
|
+
code: "custom",
|
|
153
|
+
path: ["default"],
|
|
154
|
+
message: `select default contains unknown option "${value}"`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
export const InteractionAnswerSpecSchema = z
|
|
159
|
+
.strictObject({
|
|
81
160
|
fields: z.array(InteractionFieldSchema),
|
|
161
|
+
})
|
|
162
|
+
.superRefine((spec, context) => {
|
|
163
|
+
const names = spec.fields.map((field) => field.name);
|
|
164
|
+
if (new Set(names).size !== names.length) {
|
|
165
|
+
context.addIssue({
|
|
166
|
+
code: "custom",
|
|
167
|
+
path: ["fields"],
|
|
168
|
+
message: "interaction field names must be unique",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
export const InteractionFieldTypeSchema = z.enum([
|
|
173
|
+
"text",
|
|
174
|
+
"number",
|
|
175
|
+
"boolean",
|
|
176
|
+
"select",
|
|
177
|
+
"secret",
|
|
178
|
+
]);
|
|
179
|
+
/** Scope at which an accepted answer may be reused by an explicit policy. */
|
|
180
|
+
export const InteractionResponseScopeSchema = z.enum([
|
|
181
|
+
"interaction",
|
|
182
|
+
"session",
|
|
183
|
+
"persistent",
|
|
184
|
+
]);
|
|
185
|
+
/** Negotiated interaction behavior. Absence means interactions are unsupported. */
|
|
186
|
+
export const InteractionCapabilitiesSchema = z
|
|
187
|
+
.strictObject({
|
|
188
|
+
kinds: z.array(z.string().min(1)).min(1),
|
|
189
|
+
answerFieldTypes: z.array(InteractionFieldTypeSchema).min(1),
|
|
190
|
+
responseScopes: z.array(InteractionResponseScopeSchema).min(1),
|
|
191
|
+
secretAnswers: z.boolean(),
|
|
192
|
+
concurrentRequests: z.boolean(),
|
|
193
|
+
replay: z.boolean(),
|
|
194
|
+
responseIdempotency: z.boolean(),
|
|
195
|
+
})
|
|
196
|
+
.superRefine((capabilities, context) => {
|
|
197
|
+
const advertisesSecret = capabilities.answerFieldTypes.includes("secret");
|
|
198
|
+
if (advertisesSecret !== capabilities.secretAnswers) {
|
|
199
|
+
context.addIssue({
|
|
200
|
+
code: "custom",
|
|
201
|
+
path: ["secretAnswers"],
|
|
202
|
+
message: "secretAnswers must agree with the secret answer field capability",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (new Set(capabilities.kinds).size !== capabilities.kinds.length) {
|
|
206
|
+
context.addIssue({
|
|
207
|
+
code: "custom",
|
|
208
|
+
path: ["kinds"],
|
|
209
|
+
message: "interaction kinds must be unique",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (new Set(capabilities.answerFieldTypes).size !==
|
|
213
|
+
capabilities.answerFieldTypes.length) {
|
|
214
|
+
context.addIssue({
|
|
215
|
+
code: "custom",
|
|
216
|
+
path: ["answerFieldTypes"],
|
|
217
|
+
message: "interaction answer field types must be unique",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (new Set(capabilities.responseScopes).size !==
|
|
221
|
+
capabilities.responseScopes.length) {
|
|
222
|
+
context.addIssue({
|
|
223
|
+
code: "custom",
|
|
224
|
+
path: ["responseScopes"],
|
|
225
|
+
message: "interaction response scopes must be unique",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
82
228
|
});
|
|
83
229
|
// =============================================================================
|
|
84
230
|
// Subject — what the request is about (drives preview/permission UX).
|
|
85
231
|
// =============================================================================
|
|
86
232
|
export const InteractionSubjectSchema = z.discriminatedUnion("type", [
|
|
87
|
-
z.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
233
|
+
z.strictObject({
|
|
234
|
+
type: z.literal("tool"),
|
|
235
|
+
toolName: z.string(),
|
|
236
|
+
input: z.unknown().optional(),
|
|
237
|
+
}),
|
|
238
|
+
z.strictObject({ type: z.literal("command"), command: z.string() }),
|
|
239
|
+
z.strictObject({
|
|
240
|
+
type: z.literal("file"),
|
|
241
|
+
path: z.string(),
|
|
242
|
+
preview: z.string().optional(),
|
|
243
|
+
}),
|
|
244
|
+
z.strictObject({ type: z.literal("resource"), uri: z.string() }),
|
|
91
245
|
]);
|
|
92
246
|
// =============================================================================
|
|
93
247
|
// Outcome + resolution — the answer.
|
|
@@ -95,15 +249,27 @@ export const InteractionSubjectSchema = z.discriminatedUnion("type", [
|
|
|
95
249
|
export const InteractionOutcomeSchema = z.enum(["accepted", "declined", "cancelled"]);
|
|
96
250
|
/** Field values keyed by `InteractionField.name`. Validated against `answerSpec`. */
|
|
97
251
|
export const InteractionDataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]));
|
|
98
|
-
export const InteractionResolutionSchema = z.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
})
|
|
252
|
+
export const InteractionResolutionSchema = z.discriminatedUnion("outcome", [
|
|
253
|
+
z.strictObject({
|
|
254
|
+
outcome: z.literal("accepted"),
|
|
255
|
+
data: InteractionDataSchema.optional(),
|
|
256
|
+
}),
|
|
257
|
+
z.strictObject({
|
|
258
|
+
outcome: z.literal("declined"),
|
|
259
|
+
/** Accepted for wire compatibility; ignored by response validation. */
|
|
260
|
+
data: InteractionDataSchema.optional(),
|
|
261
|
+
}),
|
|
262
|
+
z.strictObject({
|
|
263
|
+
outcome: z.literal("cancelled"),
|
|
264
|
+
/** Accepted for wire compatibility; ignored by response validation. */
|
|
265
|
+
data: InteractionDataSchema.optional(),
|
|
266
|
+
}),
|
|
267
|
+
]);
|
|
103
268
|
// =============================================================================
|
|
104
269
|
// The request envelope.
|
|
105
270
|
// =============================================================================
|
|
106
|
-
export const InteractionRequestSchema = z
|
|
271
|
+
export const InteractionRequestSchema = z
|
|
272
|
+
.strictObject({
|
|
107
273
|
/** Correlation id; unique within a session. The response carries the same id. */
|
|
108
274
|
id: z.string().min(1),
|
|
109
275
|
/**
|
|
@@ -118,15 +284,131 @@ export const InteractionRequestSchema = z.object({
|
|
|
118
284
|
body: z.string().optional(),
|
|
119
285
|
subject: InteractionSubjectSchema.optional(),
|
|
120
286
|
answerSpec: InteractionAnswerSpecSchema,
|
|
287
|
+
/** Omission is fail-closed and permits only this interaction. */
|
|
288
|
+
responseScopes: z.array(InteractionResponseScopeSchema).min(1).optional(),
|
|
121
289
|
/** Resolution applied when unattended or timed out — explicit, not a bypass flag. */
|
|
122
290
|
default: InteractionResolutionSchema.optional(),
|
|
123
291
|
/** Wait this long for a human before applying `onTimeout`. */
|
|
124
292
|
timeoutMs: z.number().int().positive().optional(),
|
|
125
293
|
/** On timeout: apply `default`, `fail` the turn, or keep `wait`ing. Default `wait`. */
|
|
126
294
|
onTimeout: z.enum(["default", "fail", "wait"]).optional(),
|
|
295
|
+
})
|
|
296
|
+
.superRefine((request, context) => {
|
|
297
|
+
if (request.responseScopes &&
|
|
298
|
+
new Set(request.responseScopes).size !== request.responseScopes.length) {
|
|
299
|
+
context.addIssue({
|
|
300
|
+
code: "custom",
|
|
301
|
+
path: ["responseScopes"],
|
|
302
|
+
message: "interaction response scopes must be unique",
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (request.onTimeout === "default" && request.default === undefined) {
|
|
306
|
+
context.addIssue({
|
|
307
|
+
code: "custom",
|
|
308
|
+
path: ["default"],
|
|
309
|
+
message: "onTimeout=default requires a default resolution",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (request.default) {
|
|
313
|
+
for (const error of validateResolutionForRequest(request, request.default)) {
|
|
314
|
+
context.addIssue({ code: "custom", path: ["default"], message: error });
|
|
315
|
+
}
|
|
316
|
+
if (request.default.outcome === "accepted") {
|
|
317
|
+
const secretFields = new Set(request.answerSpec.fields
|
|
318
|
+
.filter((field) => field.type === "secret")
|
|
319
|
+
.map((field) => field.name));
|
|
320
|
+
for (const fieldName of Object.keys(request.default.data ?? {})) {
|
|
321
|
+
if (!secretFields.has(fieldName))
|
|
322
|
+
continue;
|
|
323
|
+
context.addIssue({
|
|
324
|
+
code: "custom",
|
|
325
|
+
path: ["default", "data", fieldName],
|
|
326
|
+
message: "secret answers cannot be embedded in interaction defaults",
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
127
331
|
});
|
|
128
|
-
export const InteractionResponseSchema =
|
|
129
|
-
|
|
332
|
+
export const InteractionResponseSchema = z.discriminatedUnion("outcome", [
|
|
333
|
+
z.strictObject({
|
|
334
|
+
id: z.string().min(1),
|
|
335
|
+
outcome: z.literal("accepted"),
|
|
336
|
+
data: InteractionDataSchema.optional(),
|
|
337
|
+
}),
|
|
338
|
+
z.strictObject({
|
|
339
|
+
id: z.string().min(1),
|
|
340
|
+
outcome: z.literal("declined"),
|
|
341
|
+
/** Accepted for compatibility with the pre-discriminated wire shape. */
|
|
342
|
+
data: InteractionDataSchema.optional(),
|
|
343
|
+
}),
|
|
344
|
+
z.strictObject({
|
|
345
|
+
id: z.string().min(1),
|
|
346
|
+
outcome: z.literal("cancelled"),
|
|
347
|
+
/** Accepted for compatibility with the pre-discriminated wire shape. */
|
|
348
|
+
data: InteractionDataSchema.optional(),
|
|
349
|
+
}),
|
|
350
|
+
]);
|
|
351
|
+
/** A request is unique only within this run and optional provider session. */
|
|
352
|
+
export const InteractionBindingSchema = z.strictObject({
|
|
353
|
+
runId: z.string().min(1),
|
|
354
|
+
environmentId: z.string().min(1),
|
|
355
|
+
sessionId: z.string().min(1).optional(),
|
|
356
|
+
interactionId: z.string().min(1),
|
|
357
|
+
});
|
|
358
|
+
/** Retryable command sent to an environment or retained session. */
|
|
359
|
+
export const InteractionResponseCommandSchema = z
|
|
360
|
+
.strictObject({
|
|
361
|
+
operationId: z.string().min(1),
|
|
362
|
+
binding: InteractionBindingSchema,
|
|
363
|
+
response: InteractionResponseSchema,
|
|
364
|
+
})
|
|
365
|
+
.superRefine((command, context) => {
|
|
366
|
+
if (command.binding.interactionId !== command.response.id) {
|
|
367
|
+
context.addIssue({
|
|
368
|
+
code: "custom",
|
|
369
|
+
path: ["response", "id"],
|
|
370
|
+
message: "response id must match the bound interaction id",
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
export const InteractionAcknowledgementStatusSchema = z.enum([
|
|
375
|
+
"accepted",
|
|
376
|
+
"already_resolved_same",
|
|
377
|
+
"already_resolved_different",
|
|
378
|
+
"expired",
|
|
379
|
+
"cancelled",
|
|
380
|
+
"unknown_interaction",
|
|
381
|
+
"unknown_run",
|
|
382
|
+
"binding_mismatch",
|
|
383
|
+
"invalid_response",
|
|
384
|
+
"transport_failure",
|
|
385
|
+
]);
|
|
386
|
+
/** Durable result of one interaction response operation. */
|
|
387
|
+
export const InteractionAcknowledgementSchema = z
|
|
388
|
+
.strictObject({
|
|
389
|
+
operationId: z.string().min(1),
|
|
390
|
+
binding: InteractionBindingSchema,
|
|
391
|
+
status: InteractionAcknowledgementStatusSchema,
|
|
392
|
+
message: z.string().min(1).optional(),
|
|
393
|
+
retryable: z.boolean().optional(),
|
|
394
|
+
})
|
|
395
|
+
.superRefine((acknowledgement, context) => {
|
|
396
|
+
if (["invalid_response", "transport_failure"].includes(acknowledgement.status) &&
|
|
397
|
+
acknowledgement.message === undefined) {
|
|
398
|
+
context.addIssue({
|
|
399
|
+
code: "custom",
|
|
400
|
+
path: ["message"],
|
|
401
|
+
message: `${acknowledgement.status} must include a message`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
if (acknowledgement.status === "transport_failure" &&
|
|
405
|
+
acknowledgement.retryable === undefined) {
|
|
406
|
+
context.addIssue({
|
|
407
|
+
code: "custom",
|
|
408
|
+
path: ["retryable"],
|
|
409
|
+
message: "transport_failure must state whether retry is safe",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
130
412
|
});
|
|
131
413
|
// =============================================================================
|
|
132
414
|
// Well-known kinds + helpers.
|
|
@@ -150,20 +432,28 @@ export const PermissionGrantSchema = z.enum([
|
|
|
150
432
|
"allow_always",
|
|
151
433
|
"deny",
|
|
152
434
|
]);
|
|
153
|
-
/** Build
|
|
435
|
+
/** Build a permission answer spec that cannot offer a broader reusable grant. */
|
|
154
436
|
export function permissionAnswerSpec(opts) {
|
|
437
|
+
const scopes = new Set(opts?.responseScopes ?? ["interaction"]);
|
|
438
|
+
const options = [
|
|
439
|
+
...(scopes.has("interaction")
|
|
440
|
+
? [{ value: "allow_once", label: "Allow once" }]
|
|
441
|
+
: []),
|
|
442
|
+
...(scopes.has("session")
|
|
443
|
+
? [{ value: "allow_session", label: "Allow for this session" }]
|
|
444
|
+
: []),
|
|
445
|
+
...(scopes.has("persistent")
|
|
446
|
+
? [{ value: "allow_always", label: "Always allow" }]
|
|
447
|
+
: []),
|
|
448
|
+
{ value: "deny", label: "Deny" },
|
|
449
|
+
];
|
|
155
450
|
const fields = [
|
|
156
451
|
{
|
|
157
452
|
type: "select",
|
|
158
453
|
name: PERMISSION_GRANT_FIELD,
|
|
159
454
|
label: "Decision",
|
|
160
455
|
required: true,
|
|
161
|
-
options
|
|
162
|
-
{ value: "allow_once", label: "Allow once" },
|
|
163
|
-
{ value: "allow_session", label: "Allow for this session" },
|
|
164
|
-
{ value: "allow_always", label: "Always allow" },
|
|
165
|
-
{ value: "deny", label: "Deny" },
|
|
166
|
-
],
|
|
456
|
+
options,
|
|
167
457
|
},
|
|
168
458
|
];
|
|
169
459
|
if (opts?.allowFeedback !== false) {
|
|
@@ -183,6 +473,12 @@ export function permissionAnswerSpec(opts) {
|
|
|
183
473
|
export function validateInteractionAnswer(spec, data) {
|
|
184
474
|
const errors = [];
|
|
185
475
|
const d = data ?? {};
|
|
476
|
+
const knownFields = new Set(spec.fields.map((field) => field.name));
|
|
477
|
+
for (const fieldName of Object.keys(d)) {
|
|
478
|
+
if (!knownFields.has(fieldName)) {
|
|
479
|
+
errors.push(`unknown field "${fieldName}"`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
186
482
|
for (const field of spec.fields) {
|
|
187
483
|
const v = d[field.name];
|
|
188
484
|
const present = v !== undefined && v !== null && !(typeof v === "string" && v === "");
|
|
@@ -198,8 +494,8 @@ export function validateInteractionAnswer(spec, data) {
|
|
|
198
494
|
errors.push(`field "${field.name}" must be a string`);
|
|
199
495
|
break;
|
|
200
496
|
case "number":
|
|
201
|
-
if (typeof v !== "number") {
|
|
202
|
-
errors.push(`field "${field.name}" must be a number`);
|
|
497
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
498
|
+
errors.push(`field "${field.name}" must be a finite number`);
|
|
203
499
|
}
|
|
204
500
|
else {
|
|
205
501
|
if (field.min !== undefined && v < field.min)
|
|
@@ -240,3 +536,48 @@ export function validateInteractionAnswer(spec, data) {
|
|
|
240
536
|
}
|
|
241
537
|
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
242
538
|
}
|
|
539
|
+
/** Validate one response against the exact outstanding request. */
|
|
540
|
+
export function validateInteractionResponse(request, response) {
|
|
541
|
+
const parsed = InteractionResponseSchema.safeParse(response);
|
|
542
|
+
if (!parsed.success) {
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
errors: parsed.error.issues.map((issue) => issue.message),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const errors = request.id === parsed.data.id
|
|
549
|
+
? validateResolutionForRequest(request, parsed.data)
|
|
550
|
+
: ["response id does not match the outstanding interaction"];
|
|
551
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
552
|
+
}
|
|
553
|
+
function validateResolutionForRequest(request, resolution) {
|
|
554
|
+
if (resolution.outcome !== "accepted")
|
|
555
|
+
return [];
|
|
556
|
+
const validation = validateInteractionAnswer(request.answerSpec, resolution.data);
|
|
557
|
+
const errors = validation.ok ? [] : [...validation.errors];
|
|
558
|
+
if (request.kind !== InteractionKind.Permission)
|
|
559
|
+
return errors;
|
|
560
|
+
const grant = resolution.data?.[PERMISSION_GRANT_FIELD];
|
|
561
|
+
if (!Array.isArray(grant) || grant.length !== 1) {
|
|
562
|
+
errors.push('permission response must select exactly one "grant" value');
|
|
563
|
+
return errors;
|
|
564
|
+
}
|
|
565
|
+
const parsedGrant = PermissionGrantSchema.safeParse(grant[0]);
|
|
566
|
+
if (!parsedGrant.success) {
|
|
567
|
+
errors.push(`permission response has invalid grant "${String(grant[0])}"`);
|
|
568
|
+
return errors;
|
|
569
|
+
}
|
|
570
|
+
if (parsedGrant.data === "deny")
|
|
571
|
+
return errors;
|
|
572
|
+
const requiredScope = {
|
|
573
|
+
allow_once: "interaction",
|
|
574
|
+
allow_session: "session",
|
|
575
|
+
allow_always: "persistent",
|
|
576
|
+
deny: "interaction",
|
|
577
|
+
};
|
|
578
|
+
const permitted = new Set(request.responseScopes ?? ["interaction"]);
|
|
579
|
+
if (!permitted.has(requiredScope[parsedGrant.data])) {
|
|
580
|
+
errors.push(`permission grant "${parsedGrant.data}" exceeds the request's response scopes`);
|
|
581
|
+
}
|
|
582
|
+
return errors;
|
|
583
|
+
}
|