@k2b/cloud 0.10.2 → 0.11.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -98,9 +98,9 @@
|
|
|
98
98
|
"@tailwindcss/typography": "0.5.20",
|
|
99
99
|
"@k2b/nessi": "0.12.1",
|
|
100
100
|
"@k2b/ssr": "0.14.0",
|
|
101
|
-
"@k2b/ui": "0.5.
|
|
101
|
+
"@k2b/ui": "0.5.1",
|
|
102
102
|
"@k2b/stdlib": "0.25.0",
|
|
103
|
-
"@k2b/sync": "6.
|
|
103
|
+
"@k2b/sync": "6.5.0",
|
|
104
104
|
"@nats-io/transport-node": "3.4.0",
|
|
105
105
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
106
106
|
"bun-plugin-tailwind": "0.1.2",
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CapabilityActionManifest, CapabilityQueryManifest } from "./capabilities";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A consumer-owned expectation of one provider capability: its kind, the input
|
|
6
|
+
* the consumer sends, and the minimum result data it reads.
|
|
7
|
+
*/
|
|
8
|
+
export type CapabilityContract = {
|
|
9
|
+
kind: "query" | "action";
|
|
10
|
+
idempotency?: "required";
|
|
11
|
+
input: z.ZodType;
|
|
12
|
+
data: z.ZodType;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type CapabilityContractCandidate =
|
|
16
|
+
| { kind: "query"; operation: CapabilityQueryManifest }
|
|
17
|
+
| { kind: "action"; operation: CapabilityActionManifest };
|
|
18
|
+
|
|
19
|
+
export type CapabilityContractIssue = {
|
|
20
|
+
/** `kind`, `idempotency`, and `stream` concern the operation; `input` and `data` its published schemas. */
|
|
21
|
+
code: "kind" | "idempotency" | "stream" | "input" | "data";
|
|
22
|
+
/** JSON path inside the input or data schema, `$` for the root. */
|
|
23
|
+
path: string;
|
|
24
|
+
message: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type Json = Record<string, unknown>;
|
|
28
|
+
type Side = { schema: unknown; root: Json };
|
|
29
|
+
type Mode = "input" | "data";
|
|
30
|
+
type PathIssue = { path: string; message: string };
|
|
31
|
+
const add = (issues: PathIssue[], path: string, message: string) => {
|
|
32
|
+
issues.push({ path, message });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const MAX_DEPTH = 32;
|
|
36
|
+
const IGNORED_KEYWORDS = new Set(["$schema", "$id", "$defs", "definitions", "title", "description", "default", "examples", "readOnly"]);
|
|
37
|
+
const SUPPORTED_KEYWORDS = new Set([
|
|
38
|
+
...IGNORED_KEYWORDS,
|
|
39
|
+
"type",
|
|
40
|
+
"const",
|
|
41
|
+
"enum",
|
|
42
|
+
"anyOf",
|
|
43
|
+
"oneOf",
|
|
44
|
+
"allOf",
|
|
45
|
+
"$ref",
|
|
46
|
+
"properties",
|
|
47
|
+
"required",
|
|
48
|
+
"additionalProperties",
|
|
49
|
+
"items",
|
|
50
|
+
"minItems",
|
|
51
|
+
"maxItems",
|
|
52
|
+
"minLength",
|
|
53
|
+
"maxLength",
|
|
54
|
+
"pattern",
|
|
55
|
+
"format",
|
|
56
|
+
"minimum",
|
|
57
|
+
"maximum",
|
|
58
|
+
"exclusiveMinimum",
|
|
59
|
+
"exclusiveMaximum",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const isObject = (value: unknown): value is Json => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
63
|
+
|
|
64
|
+
const deref = (side: Side, depth: number): Side | null => {
|
|
65
|
+
let current = side.schema;
|
|
66
|
+
for (let hops = 0; isObject(current) && typeof current.$ref === "string"; hops++) {
|
|
67
|
+
if (hops > depth) return null;
|
|
68
|
+
const ref = current.$ref;
|
|
69
|
+
if (ref === "#") current = side.root;
|
|
70
|
+
else if (ref.startsWith("#/$defs/")) current = (side.root.$defs as Json | undefined)?.[ref.slice(8)];
|
|
71
|
+
else return null;
|
|
72
|
+
}
|
|
73
|
+
return { schema: current, root: side.root };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** `true`, `{}`, and a schema with only annotations accept every value. */
|
|
77
|
+
const acceptsAnything = (schema: unknown): boolean =>
|
|
78
|
+
schema === true || (isObject(schema) && Object.keys(schema).every((key) => IGNORED_KEYWORDS.has(key)));
|
|
79
|
+
|
|
80
|
+
const typeSet = (schema: Json): Set<string> | null => {
|
|
81
|
+
if (typeof schema.type === "string") return new Set([schema.type]);
|
|
82
|
+
if (Array.isArray(schema.type)) return new Set(schema.type.filter((entry): entry is string => typeof entry === "string"));
|
|
83
|
+
return null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const valuesOf = (schema: Json): unknown[] | null => {
|
|
87
|
+
if ("const" in schema) return [schema.const];
|
|
88
|
+
if (Array.isArray(schema.enum)) return schema.enum;
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const jsonType = (value: unknown): string =>
|
|
93
|
+
value === null ? "null" : Array.isArray(value) ? "array" : Number.isInteger(value) ? "integer" : typeof value;
|
|
94
|
+
|
|
95
|
+
const typeAllows = (allowed: Set<string>, type: string): boolean => allowed.has(type) || (type === "integer" && allowed.has("number"));
|
|
96
|
+
|
|
97
|
+
const patternMatches = (pattern: string, value: string): boolean => {
|
|
98
|
+
try {
|
|
99
|
+
return new RegExp(pattern, "u").test(value);
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Checks one literal value against a provider or contract schema. Unknown keywords never pass. */
|
|
106
|
+
const valueIssue = (value: unknown, sup: Side, depth: number): string | null => {
|
|
107
|
+
const resolved = deref(sup, depth);
|
|
108
|
+
if (!resolved) return "uses an unsupported reference";
|
|
109
|
+
const schema = resolved.schema;
|
|
110
|
+
if (acceptsAnything(schema)) return null;
|
|
111
|
+
if (!isObject(schema)) return "is not a JSON Schema object";
|
|
112
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
113
|
+
const branches = schema[keyword];
|
|
114
|
+
if (Array.isArray(branches) && !branches.some((branch) => valueIssue(value, { schema: branch, root: sup.root }, depth + 1) === null)) {
|
|
115
|
+
return `value ${JSON.stringify(value)} is not accepted`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const values = valuesOf(schema);
|
|
119
|
+
if (values && !values.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) {
|
|
120
|
+
return `value ${JSON.stringify(value)} is not accepted`;
|
|
121
|
+
}
|
|
122
|
+
const types = typeSet(schema);
|
|
123
|
+
if (types && !typeAllows(types, jsonType(value))) return `value ${JSON.stringify(value)} has the wrong type`;
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) return "value is too short";
|
|
126
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return "value is too long";
|
|
127
|
+
if (typeof schema.pattern === "string" && !patternMatches(schema.pattern, value)) return "value does not match the pattern";
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type Bound = { value: number; exclusive: boolean };
|
|
133
|
+
const lowerBound = (schema: Json): Bound | null =>
|
|
134
|
+
typeof schema.exclusiveMinimum === "number"
|
|
135
|
+
? { value: schema.exclusiveMinimum, exclusive: true }
|
|
136
|
+
: typeof schema.minimum === "number"
|
|
137
|
+
? { value: schema.minimum, exclusive: false }
|
|
138
|
+
: null;
|
|
139
|
+
const upperBound = (schema: Json): Bound | null =>
|
|
140
|
+
typeof schema.exclusiveMaximum === "number"
|
|
141
|
+
? { value: schema.exclusiveMaximum, exclusive: true }
|
|
142
|
+
: typeof schema.maximum === "number"
|
|
143
|
+
? { value: schema.maximum, exclusive: false }
|
|
144
|
+
: null;
|
|
145
|
+
const withinLower = (sub: Bound | null, sup: Bound | null): boolean =>
|
|
146
|
+
!sup || (sub !== null && (sub.value > sup.value || (sub.value === sup.value && (sub.exclusive || !sup.exclusive))));
|
|
147
|
+
const withinUpper = (sub: Bound | null, sup: Bound | null): boolean =>
|
|
148
|
+
!sup || (sub !== null && (sub.value < sup.value || (sub.value === sup.value && (sub.exclusive || !sup.exclusive))));
|
|
149
|
+
|
|
150
|
+
const withinMin = (sub: unknown, sup: unknown): boolean => typeof sup !== "number" || (typeof sub === "number" && sub >= sup);
|
|
151
|
+
const withinMax = (sub: unknown, sup: unknown): boolean => typeof sup !== "number" || (typeof sub === "number" && sub <= sup);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Collects why `sub` may contain a value that `sup` rejects. The check is
|
|
155
|
+
* deliberately conservative: an unsupported keyword is an issue, never a pass.
|
|
156
|
+
*/
|
|
157
|
+
const subsetIssues = (sub: Side, sup: Side, path: string, mode: Mode, depth: number, issues: PathIssue[]): void => {
|
|
158
|
+
if (issues.length >= 5) return;
|
|
159
|
+
if (depth > MAX_DEPTH) {
|
|
160
|
+
add(issues, path, `schema is nested too deeply`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const subResolved = deref(sub, depth);
|
|
164
|
+
const supResolved = deref(sup, depth);
|
|
165
|
+
if (!subResolved || !supResolved) {
|
|
166
|
+
add(issues, path, `uses an unsupported reference`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const subSchema = subResolved.schema;
|
|
170
|
+
const supSchema = supResolved.schema;
|
|
171
|
+
if (acceptsAnything(supSchema)) return;
|
|
172
|
+
if (!isObject(supSchema)) {
|
|
173
|
+
if (supSchema === false) add(issues, path, `is not accepted`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (acceptsAnything(subSchema)) {
|
|
177
|
+
add(issues, path, `is unconstrained but must match the contract`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!isObject(subSchema)) return;
|
|
181
|
+
const unsupported = [...Object.keys(subSchema), ...Object.keys(supSchema)].find((key) => !SUPPORTED_KEYWORDS.has(key));
|
|
182
|
+
if (unsupported) {
|
|
183
|
+
add(issues, path, `uses the unsupported keyword ${unsupported}`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const next = (schema: unknown, side: Side): Side => ({ schema, root: side.root });
|
|
187
|
+
|
|
188
|
+
// A union on the narrower side must fit branch by branch.
|
|
189
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
190
|
+
const branches = subSchema[keyword];
|
|
191
|
+
if (!Array.isArray(branches)) continue;
|
|
192
|
+
const { [keyword]: _branches, ...rest } = subSchema;
|
|
193
|
+
for (const branch of branches) {
|
|
194
|
+
const merged = isObject(branch) ? { ...rest, ...branch } : branch;
|
|
195
|
+
subsetIssues(next(merged, subResolved), supResolved, path, mode, depth + 1, issues);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (Array.isArray(subSchema.allOf)) {
|
|
200
|
+
// An intersection is narrower than each part; one fitting part is enough.
|
|
201
|
+
const { allOf, ...rest } = subSchema;
|
|
202
|
+
const fits = allOf.some((part) => {
|
|
203
|
+
const found: PathIssue[] = [];
|
|
204
|
+
subsetIssues(next(isObject(part) ? { ...rest, ...part } : part, subResolved), supResolved, path, mode, depth + 1, found);
|
|
205
|
+
return found.length === 0;
|
|
206
|
+
});
|
|
207
|
+
if (!fits) add(issues, path, `no part of the intersection matches the contract`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
for (const keyword of ["anyOf", "oneOf"] as const) {
|
|
211
|
+
const branches = supSchema[keyword];
|
|
212
|
+
if (!Array.isArray(branches)) continue;
|
|
213
|
+
const { [keyword]: _branches, ...rest } = supSchema;
|
|
214
|
+
const fits = branches.some((branch) => {
|
|
215
|
+
const found: PathIssue[] = [];
|
|
216
|
+
subsetIssues(subResolved, next(isObject(branch) ? { ...rest, ...branch } : branch, supResolved), path, mode, depth + 1, found);
|
|
217
|
+
return found.length === 0;
|
|
218
|
+
});
|
|
219
|
+
if (!fits) add(issues, path, `does not match any allowed shape`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(supSchema.allOf)) {
|
|
223
|
+
const { allOf, ...rest } = supSchema;
|
|
224
|
+
for (const part of allOf)
|
|
225
|
+
subsetIssues(subResolved, next(isObject(part) ? { ...rest, ...part } : part, supResolved), path, mode, depth + 1, issues);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const subValues = valuesOf(subSchema);
|
|
230
|
+
if (subValues) {
|
|
231
|
+
for (const value of subValues) {
|
|
232
|
+
const issue = valueIssue(value, supResolved, depth + 1);
|
|
233
|
+
if (issue) add(issues, path, `${issue}`);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (valuesOf(supSchema)) {
|
|
238
|
+
add(issues, path, `must be limited to the contract's allowed values`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const supTypes = typeSet(supSchema);
|
|
243
|
+
const subTypes = typeSet(subSchema);
|
|
244
|
+
if (supTypes) {
|
|
245
|
+
if (!subTypes) {
|
|
246
|
+
add(issues, path, `must declare a type`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const extra = [...subTypes].filter((type) => !typeAllows(supTypes, type));
|
|
250
|
+
if (extra.length > 0) {
|
|
251
|
+
add(issues, path, `allows ${extra.join(", ")} but the contract expects ${[...supTypes].join(", ")}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const types = subTypes ?? new Set<string>();
|
|
256
|
+
|
|
257
|
+
// Providers own their identifier and text formats; input checks compare shape, not string syntax.
|
|
258
|
+
if (types.has("string") && mode === "data") {
|
|
259
|
+
if (typeof supSchema.format === "string" && subSchema.format !== supSchema.format) {
|
|
260
|
+
add(issues, path, `must use the ${supSchema.format} format`);
|
|
261
|
+
}
|
|
262
|
+
const formatCoversPattern = typeof supSchema.format === "string" && subSchema.format === supSchema.format;
|
|
263
|
+
if (typeof supSchema.pattern === "string" && !formatCoversPattern && subSchema.pattern !== supSchema.pattern) {
|
|
264
|
+
add(issues, path, `must use the contract pattern`);
|
|
265
|
+
}
|
|
266
|
+
if (!withinMin(subSchema.minLength, supSchema.minLength)) add(issues, path, `must be at least ${supSchema.minLength} characters`);
|
|
267
|
+
if (!withinMax(subSchema.maxLength, supSchema.maxLength)) add(issues, path, `must be at most ${supSchema.maxLength} characters`);
|
|
268
|
+
}
|
|
269
|
+
if (types.has("number") || types.has("integer")) {
|
|
270
|
+
if (!withinLower(lowerBound(subSchema), lowerBound(supSchema)) || !withinUpper(upperBound(subSchema), upperBound(supSchema))) {
|
|
271
|
+
add(issues, path, `allows numbers outside the contract range`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (types.has("array")) {
|
|
275
|
+
if (!withinMin(subSchema.minItems, supSchema.minItems)) add(issues, path, `must contain at least ${supSchema.minItems} items`);
|
|
276
|
+
if (!withinMax(subSchema.maxItems, supSchema.maxItems)) add(issues, path, `must contain at most ${supSchema.maxItems} items`);
|
|
277
|
+
if (supSchema.items !== undefined) {
|
|
278
|
+
subsetIssues(next(subSchema.items ?? true, subResolved), next(supSchema.items, supResolved), `${path}[]`, mode, depth + 1, issues);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (types.has("object")) objectIssues(subSchema, subResolved, supSchema, supResolved, path, mode, depth, issues);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const objectIssues = (
|
|
285
|
+
subSchema: Json,
|
|
286
|
+
sub: Side,
|
|
287
|
+
supSchema: Json,
|
|
288
|
+
sup: Side,
|
|
289
|
+
path: string,
|
|
290
|
+
mode: Mode,
|
|
291
|
+
depth: number,
|
|
292
|
+
issues: PathIssue[],
|
|
293
|
+
) => {
|
|
294
|
+
const subProperties = isObject(subSchema.properties) ? subSchema.properties : {};
|
|
295
|
+
const supProperties = isObject(supSchema.properties) ? supSchema.properties : {};
|
|
296
|
+
const subRequired = new Set(Array.isArray(subSchema.required) ? subSchema.required : []);
|
|
297
|
+
const supRequired = Array.isArray(supSchema.required) ? supSchema.required : [];
|
|
298
|
+
const subAdditional = subSchema.additionalProperties ?? true;
|
|
299
|
+
const supAdditional = supSchema.additionalProperties ?? true;
|
|
300
|
+
const field = (name: string) => `${path}.${name}`;
|
|
301
|
+
|
|
302
|
+
for (const name of supRequired) {
|
|
303
|
+
if (typeof name === "string" && !subRequired.has(name)) {
|
|
304
|
+
add(
|
|
305
|
+
issues,
|
|
306
|
+
field(name),
|
|
307
|
+
`${mode === "data" ? "must always be returned" : "is required by the provider but optional in the contract"}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const [name, schema] of Object.entries(subProperties)) {
|
|
312
|
+
if (name in supProperties) {
|
|
313
|
+
subsetIssues({ schema, root: sub.root }, { schema: supProperties[name], root: sup.root }, field(name), mode, depth + 1, issues);
|
|
314
|
+
} else if (supAdditional === false) {
|
|
315
|
+
add(issues, field(name), `is not accepted`);
|
|
316
|
+
} else {
|
|
317
|
+
subsetIssues({ schema, root: sub.root }, { schema: supAdditional, root: sup.root }, field(name), mode, depth + 1, issues);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (subAdditional === false) return;
|
|
321
|
+
for (const [name, schema] of Object.entries(supProperties)) {
|
|
322
|
+
if (!(name in subProperties)) {
|
|
323
|
+
subsetIssues({ schema: subAdditional, root: sub.root }, { schema, root: sup.root }, field(name), mode, depth + 1, issues);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (supAdditional === false) add(issues, path, `must not allow undeclared properties`);
|
|
327
|
+
else
|
|
328
|
+
subsetIssues(
|
|
329
|
+
{ schema: subAdditional, root: sub.root },
|
|
330
|
+
{ schema: supAdditional, root: sup.root },
|
|
331
|
+
`${path}.*`,
|
|
332
|
+
mode,
|
|
333
|
+
depth + 1,
|
|
334
|
+
issues,
|
|
335
|
+
);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const schemaIssues = (sub: Json, sup: Json, mode: Mode): PathIssue[] => {
|
|
339
|
+
const issues: PathIssue[] = [];
|
|
340
|
+
subsetIssues({ schema: sub, root: sub }, { schema: sup, root: sup }, "$", mode, 0, issues);
|
|
341
|
+
return issues;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Explains why a published capability cannot serve a consumer contract.
|
|
346
|
+
* An empty list means every contract input is accepted by the provider and
|
|
347
|
+
* every result the provider may return satisfies the contract data schema.
|
|
348
|
+
* Input checks compare shape, required fields, values, and numeric bounds;
|
|
349
|
+
* string syntax such as identifier patterns stays with the provider.
|
|
350
|
+
*/
|
|
351
|
+
export const capabilityContractIssues = (
|
|
352
|
+
contract: CapabilityContract,
|
|
353
|
+
candidate: CapabilityContractCandidate,
|
|
354
|
+
): CapabilityContractIssue[] => {
|
|
355
|
+
if (candidate.kind !== contract.kind) {
|
|
356
|
+
return [{ code: "kind", path: "$", message: `Expected ${contract.kind === "query" ? "a Query" : "an Action"}` }];
|
|
357
|
+
}
|
|
358
|
+
const issues: CapabilityContractIssue[] = [];
|
|
359
|
+
if (candidate.operation.stream) issues.push({ code: "stream", path: "$", message: "Streaming capabilities are not supported" });
|
|
360
|
+
if (contract.idempotency === "required" && candidate.kind === "action" && candidate.operation.idempotency !== "required") {
|
|
361
|
+
issues.push({ code: "idempotency", path: "$", message: "The Action must require an idempotency key" });
|
|
362
|
+
}
|
|
363
|
+
const contractInput = z.toJSONSchema(contract.input, { io: "input" }) as Json;
|
|
364
|
+
const contractData = z.toJSONSchema(contract.data, { io: "output" }) as Json;
|
|
365
|
+
for (const issue of schemaIssues(contractInput, candidate.operation.inputSchema, "input")) issues.push({ code: "input", ...issue });
|
|
366
|
+
for (const issue of schemaIssues(candidate.operation.dataSchema, contractData, "data")) issues.push({ code: "data", ...issue });
|
|
367
|
+
return issues;
|
|
368
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CapabilityQualifiedIdSchema, CapabilitySemanticLinkSchema } from "./capabilities";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provider-neutral contact-directory contract.
|
|
6
|
+
*
|
|
7
|
+
* A consumer such as Mail maps each function to one Query or Action of one
|
|
8
|
+
* provider application. Providers keep their own identifiers, permissions,
|
|
9
|
+
* and extra result fields; the schemas below are the minimum both sides share.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Identifiers are opaque provider strings; each provider owns their syntax and length.
|
|
13
|
+
const ContactDirectoryIdSchema = z.string();
|
|
14
|
+
const TimestampSchema = z.string().datetime({ offset: true });
|
|
15
|
+
const NullableTextSchema = z.string().nullable();
|
|
16
|
+
const CursorSchema = z.string().min(1).max(256);
|
|
17
|
+
const EmailSchema = z.email().max(320);
|
|
18
|
+
const LinksSchema = z.array(CapabilitySemanticLinkSchema).min(1).max(10).optional();
|
|
19
|
+
|
|
20
|
+
const ContactDirectoryEmailSchema = z.object({ label: NullableTextSchema, email: z.email() }).loose();
|
|
21
|
+
const ContactDirectoryPhoneSchema = z.object({ label: NullableTextSchema, phone: z.string().min(1) }).loose();
|
|
22
|
+
|
|
23
|
+
const contactFacts = {
|
|
24
|
+
contactId: ContactDirectoryIdSchema,
|
|
25
|
+
bookId: ContactDirectoryIdSchema,
|
|
26
|
+
displayName: z.string().min(1),
|
|
27
|
+
companyName: NullableTextSchema,
|
|
28
|
+
jobTitle: NullableTextSchema,
|
|
29
|
+
contactPointsTruncated: z.boolean(),
|
|
30
|
+
links: LinksSchema,
|
|
31
|
+
updatedAt: TimestampSchema,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const ContactDirectorySuggestInputSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
query: z.string().min(2).max(500).describe("Text typed by the user, matched against names, organizations, and email addresses."),
|
|
37
|
+
cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page."),
|
|
38
|
+
limit: z.number().int().min(1).max(25).optional().describe("Maximum number of suggestions to return."),
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
|
|
42
|
+
export const ContactDirectorySuggestionSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
...contactFacts,
|
|
45
|
+
emails: z.array(ContactDirectoryEmailSchema).min(1).max(20),
|
|
46
|
+
phones: z.array(ContactDirectoryPhoneSchema).max(20),
|
|
47
|
+
})
|
|
48
|
+
.loose();
|
|
49
|
+
export const ContactDirectorySuggestDataSchema = z.array(ContactDirectorySuggestionSchema).max(25);
|
|
50
|
+
|
|
51
|
+
export const ContactDirectoryResolveInputSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
emails: z.array(EmailSchema).min(1).max(100).describe("Normalized email addresses to resolve to every readable matching contact."),
|
|
54
|
+
contactIds: z
|
|
55
|
+
.array(ContactDirectoryIdSchema)
|
|
56
|
+
.max(20)
|
|
57
|
+
.optional()
|
|
58
|
+
.describe("Optional contact IDs returned by this provider that further restrict the matches."),
|
|
59
|
+
cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page."),
|
|
60
|
+
limit: z.number().int().min(1).max(50).optional().describe("Maximum number of exact contact matches to return."),
|
|
61
|
+
})
|
|
62
|
+
.strict();
|
|
63
|
+
|
|
64
|
+
export const ContactDirectoryMatchSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
ref: z.object({ type: CapabilityQualifiedIdSchema, id: ContactDirectoryIdSchema }).strict(),
|
|
67
|
+
...contactFacts,
|
|
68
|
+
bookName: z.string().min(1),
|
|
69
|
+
matchedEmails: z.array(EmailSchema).min(1).max(100),
|
|
70
|
+
emails: z.array(ContactDirectoryEmailSchema).max(20),
|
|
71
|
+
phones: z.array(ContactDirectoryPhoneSchema).max(20),
|
|
72
|
+
})
|
|
73
|
+
.loose();
|
|
74
|
+
export const ContactDirectoryResolveDataSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
items: z.array(ContactDirectoryMatchSchema).max(50),
|
|
77
|
+
matchedEmails: z.array(EmailSchema).max(100),
|
|
78
|
+
})
|
|
79
|
+
.loose();
|
|
80
|
+
|
|
81
|
+
export const ContactDirectoryReadInputSchema = z
|
|
82
|
+
.object({ id: ContactDirectoryIdSchema.describe("Contact ID returned by this provider.") })
|
|
83
|
+
.strict();
|
|
84
|
+
|
|
85
|
+
export const ContactDirectoryContactSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
id: ContactDirectoryIdSchema,
|
|
88
|
+
bookId: ContactDirectoryIdSchema,
|
|
89
|
+
displayName: z.string().min(1),
|
|
90
|
+
companyName: NullableTextSchema,
|
|
91
|
+
jobTitle: NullableTextSchema,
|
|
92
|
+
emails: z.array(ContactDirectoryEmailSchema).max(100),
|
|
93
|
+
phones: z.array(ContactDirectoryPhoneSchema).max(100),
|
|
94
|
+
updatedAt: TimestampSchema,
|
|
95
|
+
})
|
|
96
|
+
.loose();
|
|
97
|
+
|
|
98
|
+
export const ContactDirectoryBookListInputSchema = z
|
|
99
|
+
.object({
|
|
100
|
+
query: z.string().max(500).optional().describe("Optional book name or description search."),
|
|
101
|
+
minimumPermission: z.literal("write").describe("Only books in which the caller can create contacts."),
|
|
102
|
+
cursor: CursorSchema.optional().describe("Opaque cursor returned by the previous page."),
|
|
103
|
+
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of books to return."),
|
|
104
|
+
})
|
|
105
|
+
.strict();
|
|
106
|
+
|
|
107
|
+
export const ContactDirectoryBookSchema = z
|
|
108
|
+
.object({ id: ContactDirectoryIdSchema, name: z.string().min(1), description: NullableTextSchema })
|
|
109
|
+
.loose();
|
|
110
|
+
export const ContactDirectoryBookListDataSchema = z.array(ContactDirectoryBookSchema).max(100);
|
|
111
|
+
|
|
112
|
+
const ContactPointLabelSchema = z.string().max(100).nullable().optional().describe("Optional label.");
|
|
113
|
+
const ContactNameFieldSchema = z.string().max(200).nullable().optional();
|
|
114
|
+
|
|
115
|
+
export const ContactDirectoryCreateInputSchema = z
|
|
116
|
+
.object({
|
|
117
|
+
bookId: ContactDirectoryIdSchema.describe("Writable book ID returned by the book list."),
|
|
118
|
+
label: ContactNameFieldSchema.describe("Optional display label."),
|
|
119
|
+
firstName: ContactNameFieldSchema.describe("Person first name."),
|
|
120
|
+
lastName: ContactNameFieldSchema.describe("Person last name."),
|
|
121
|
+
companyName: ContactNameFieldSchema.describe("Organization name."),
|
|
122
|
+
emails: z
|
|
123
|
+
.array(z.object({ label: ContactPointLabelSchema, email: EmailSchema.describe("Email address.") }).strict())
|
|
124
|
+
.max(20)
|
|
125
|
+
.optional()
|
|
126
|
+
.describe("Email addresses."),
|
|
127
|
+
phones: z
|
|
128
|
+
.array(z.object({ label: ContactPointLabelSchema, phone: z.string().min(1).max(100).describe("Phone number.") }).strict())
|
|
129
|
+
.max(20)
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("Phone numbers."),
|
|
132
|
+
})
|
|
133
|
+
.strict();
|
|
134
|
+
export const ContactDirectoryCreateDataSchema = z.object({ contact: ContactDirectoryContactSchema }).loose();
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The five contact-directory functions. `kind` and `idempotency` are part of
|
|
138
|
+
* the contract: a provider capability must match them to be selectable.
|
|
139
|
+
*/
|
|
140
|
+
export const contactDirectory = {
|
|
141
|
+
suggest: { kind: "query", input: ContactDirectorySuggestInputSchema, data: ContactDirectorySuggestDataSchema },
|
|
142
|
+
resolve: { kind: "query", input: ContactDirectoryResolveInputSchema, data: ContactDirectoryResolveDataSchema },
|
|
143
|
+
read: { kind: "query", input: ContactDirectoryReadInputSchema, data: ContactDirectoryContactSchema },
|
|
144
|
+
listWritableBooks: { kind: "query", input: ContactDirectoryBookListInputSchema, data: ContactDirectoryBookListDataSchema },
|
|
145
|
+
create: {
|
|
146
|
+
kind: "action",
|
|
147
|
+
idempotency: "required",
|
|
148
|
+
input: ContactDirectoryCreateInputSchema,
|
|
149
|
+
data: ContactDirectoryCreateDataSchema,
|
|
150
|
+
},
|
|
151
|
+
} as const;
|
|
152
|
+
|
|
153
|
+
export type ContactDirectoryFunction = keyof typeof contactDirectory;
|
|
154
|
+
export const CONTACT_DIRECTORY_FUNCTIONS = [
|
|
155
|
+
"suggest",
|
|
156
|
+
"resolve",
|
|
157
|
+
"read",
|
|
158
|
+
"listWritableBooks",
|
|
159
|
+
"create",
|
|
160
|
+
] as const satisfies readonly ContactDirectoryFunction[];
|
|
161
|
+
|
|
162
|
+
export type ContactDirectorySuggestion = z.output<typeof ContactDirectorySuggestionSchema>;
|
|
163
|
+
export type ContactDirectoryMatch = z.output<typeof ContactDirectoryMatchSchema>;
|
|
164
|
+
export type ContactDirectoryContact = z.output<typeof ContactDirectoryContactSchema>;
|
|
165
|
+
export type ContactDirectoryBook = z.output<typeof ContactDirectoryBookSchema>;
|
package/src/contracts/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ export * from "./announcements";
|
|
|
3
3
|
export * from "./app";
|
|
4
4
|
export * from "./app-approval";
|
|
5
5
|
export * from "./capabilities";
|
|
6
|
+
export * from "./capability-compatibility";
|
|
6
7
|
export * from "./commands";
|
|
8
|
+
export * from "./contact-directory";
|
|
7
9
|
export * from "./notification-types";
|
|
8
10
|
export * from "./posix";
|
|
9
11
|
export * from "./profile";
|