@ucptools/validator 1.0.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/CLAUDE.md +109 -0
- package/CONTRIBUTING.md +113 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/api/analyze-feed.js +140 -0
- package/api/badge.js +185 -0
- package/api/benchmark.js +177 -0
- package/api/directory-stats.ts +29 -0
- package/api/directory.ts +73 -0
- package/api/generate-compliance.js +143 -0
- package/api/generate-schema.js +457 -0
- package/api/generate.js +132 -0
- package/api/security-scan.js +133 -0
- package/api/simulate.js +187 -0
- package/api/tsconfig.json +10 -0
- package/api/validate.js +1351 -0
- package/apify-actor/.actor/actor.json +68 -0
- package/apify-actor/.actor/input_schema.json +32 -0
- package/apify-actor/APIFY-STORE-LISTING.md +412 -0
- package/apify-actor/Dockerfile +8 -0
- package/apify-actor/README.md +166 -0
- package/apify-actor/main.ts +111 -0
- package/apify-actor/package.json +17 -0
- package/apify-actor/src/main.js +199 -0
- package/docs/BRAND-IDENTITY.md +238 -0
- package/docs/BRAND-STYLE-GUIDE.md +356 -0
- package/drizzle/0000_black_king_cobra.sql +39 -0
- package/drizzle/meta/0000_snapshot.json +309 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/examples/full-profile.json +70 -0
- package/examples/minimal-profile.json +23 -0
- package/package.json +69 -0
- package/public/.well-known/ucp +25 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/brand.css +321 -0
- package/public/directory.html +701 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/guides/bigcommerce.html +743 -0
- package/public/guides/fastucp.html +838 -0
- package/public/guides/magento.html +779 -0
- package/public/guides/shopify.html +726 -0
- package/public/guides/squarespace.html +749 -0
- package/public/guides/wix.html +747 -0
- package/public/guides/woocommerce.html +733 -0
- package/public/index.html +3835 -0
- package/public/learn.html +396 -0
- package/public/logo.jpeg +0 -0
- package/public/og-image-icon.png +0 -0
- package/public/og-image.png +0 -0
- package/public/robots.txt +6 -0
- package/public/site.webmanifest +31 -0
- package/public/sitemap.xml +69 -0
- package/public/social/linkedin-banner-1128x191.png +0 -0
- package/public/social/temp.PNG +0 -0
- package/public/social/x-header-1500x500.png +0 -0
- package/public/verify.html +410 -0
- package/scripts/generate-favicons.js +44 -0
- package/scripts/generate-ico.js +23 -0
- package/scripts/generate-og-image.js +45 -0
- package/scripts/reset-db.ts +77 -0
- package/scripts/seed-db.ts +71 -0
- package/scripts/setup-benchmark-db.js +70 -0
- package/src/api/server.ts +266 -0
- package/src/cli/index.ts +302 -0
- package/src/compliance/compliance-generator.ts +452 -0
- package/src/compliance/index.ts +28 -0
- package/src/compliance/templates.ts +338 -0
- package/src/compliance/types.ts +170 -0
- package/src/db/index.ts +28 -0
- package/src/db/schema.ts +84 -0
- package/src/feed-analyzer/feed-analyzer.ts +726 -0
- package/src/feed-analyzer/index.ts +34 -0
- package/src/feed-analyzer/types.ts +354 -0
- package/src/generator/index.ts +7 -0
- package/src/generator/key-generator.ts +124 -0
- package/src/generator/profile-builder.ts +402 -0
- package/src/hosting/artifacts-generator.ts +679 -0
- package/src/hosting/index.ts +6 -0
- package/src/index.ts +105 -0
- package/src/security/index.ts +15 -0
- package/src/security/security-scanner.ts +604 -0
- package/src/security/types.ts +55 -0
- package/src/services/directory.ts +434 -0
- package/src/simulator/agent-simulator.ts +941 -0
- package/src/simulator/index.ts +7 -0
- package/src/simulator/types.ts +170 -0
- package/src/types/generator.ts +140 -0
- package/src/types/index.ts +7 -0
- package/src/types/ucp-profile.ts +140 -0
- package/src/types/validation.ts +89 -0
- package/src/validator/index.ts +194 -0
- package/src/validator/network-validator.ts +417 -0
- package/src/validator/rules-validator.ts +297 -0
- package/src/validator/sdk-validator.ts +330 -0
- package/src/validator/structural-validator.ts +476 -0
- package/tests/fixtures/non-compliant-profile.json +25 -0
- package/tests/fixtures/official-sample-profile.json +75 -0
- package/tests/integration/benchmark.test.ts +207 -0
- package/tests/integration/database.test.ts +163 -0
- package/tests/integration/directory-api.test.ts +268 -0
- package/tests/integration/simulate-api.test.ts +230 -0
- package/tests/integration/validate-api.test.ts +269 -0
- package/tests/setup.ts +15 -0
- package/tests/unit/agent-simulator.test.ts +575 -0
- package/tests/unit/compliance-generator.test.ts +374 -0
- package/tests/unit/directory-service.test.ts +272 -0
- package/tests/unit/feed-analyzer.test.ts +517 -0
- package/tests/unit/lint-suggestions.test.ts +423 -0
- package/tests/unit/official-samples.test.ts +211 -0
- package/tests/unit/pdf-report.test.ts +390 -0
- package/tests/unit/sdk-validator.test.ts +531 -0
- package/tests/unit/security-scanner.test.ts +410 -0
- package/tests/unit/validation.test.ts +390 -0
- package/tsconfig.json +20 -0
- package/vercel.json +34 -0
- package/vitest.config.ts +22 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UCP Rules Validator
|
|
3
|
+
* Validates UCP-specific business rules (no network calls)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { UcpProfile, UcpCapability } from '../types/ucp-profile.js';
|
|
7
|
+
import type { ValidationIssue } from '../types/validation.js';
|
|
8
|
+
import { ValidationErrorCodes } from '../types/validation.js';
|
|
9
|
+
import { CAPABILITY_NAMESPACES, KNOWN_CAPABILITIES } from '../types/ucp-profile.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate UCP business rules
|
|
13
|
+
*/
|
|
14
|
+
export function validateRules(profile: UcpProfile): ValidationIssue[] {
|
|
15
|
+
const issues: ValidationIssue[] = [];
|
|
16
|
+
|
|
17
|
+
// Validate namespace/origin binding for capabilities
|
|
18
|
+
issues.push(...validateNamespaceOrigins(profile));
|
|
19
|
+
|
|
20
|
+
// Validate extension chains (no orphaned extends)
|
|
21
|
+
issues.push(...validateExtensions(profile));
|
|
22
|
+
|
|
23
|
+
// Validate endpoint rules
|
|
24
|
+
issues.push(...validateEndpoints(profile));
|
|
25
|
+
|
|
26
|
+
// Validate signing keys if Order capability is present
|
|
27
|
+
issues.push(...validateSigningKeysRequirement(profile));
|
|
28
|
+
|
|
29
|
+
return issues;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate namespace and URL origin binding
|
|
34
|
+
* - dev.ucp.* capabilities must have spec/schema from ucp.dev
|
|
35
|
+
* - com.vendor.* capabilities must have spec/schema from vendor's domain
|
|
36
|
+
*/
|
|
37
|
+
function validateNamespaceOrigins(profile: UcpProfile): ValidationIssue[] {
|
|
38
|
+
const issues: ValidationIssue[] = [];
|
|
39
|
+
const capabilities = profile.ucp.capabilities || [];
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < capabilities.length; i++) {
|
|
42
|
+
const cap = capabilities[i];
|
|
43
|
+
const path = `$.ucp.capabilities[${i}]`;
|
|
44
|
+
|
|
45
|
+
// Check dev.ucp.* namespace
|
|
46
|
+
if (cap.name.startsWith(CAPABILITY_NAMESPACES.UCP_OFFICIAL)) {
|
|
47
|
+
// Spec must be from ucp.dev
|
|
48
|
+
if (cap.spec && !isUcpDevOrigin(cap.spec)) {
|
|
49
|
+
issues.push({
|
|
50
|
+
severity: 'error',
|
|
51
|
+
code: ValidationErrorCodes.NS_ORIGIN_MISMATCH,
|
|
52
|
+
path: `${path}.spec`,
|
|
53
|
+
message: `dev.ucp.* capability spec must be hosted on ucp.dev`,
|
|
54
|
+
hint: `Use https://ucp.dev/specification/... instead of "${cap.spec}"`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Schema must be from ucp.dev
|
|
59
|
+
if (cap.schema && !isUcpDevOrigin(cap.schema)) {
|
|
60
|
+
issues.push({
|
|
61
|
+
severity: 'error',
|
|
62
|
+
code: ValidationErrorCodes.NS_ORIGIN_MISMATCH,
|
|
63
|
+
path: `${path}.schema`,
|
|
64
|
+
message: `dev.ucp.* capability schema must be hosted on ucp.dev`,
|
|
65
|
+
hint: `Use https://ucp.dev/schemas/... instead of "${cap.schema}"`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check vendor namespace (com.vendor.*)
|
|
71
|
+
if (cap.name.startsWith(CAPABILITY_NAMESPACES.VENDOR_PREFIX)) {
|
|
72
|
+
const vendorDomain = extractVendorDomain(cap.name);
|
|
73
|
+
if (vendorDomain) {
|
|
74
|
+
// Spec origin should match vendor domain
|
|
75
|
+
if (cap.spec && !isOriginFromDomain(cap.spec, vendorDomain)) {
|
|
76
|
+
issues.push({
|
|
77
|
+
severity: 'warn',
|
|
78
|
+
code: ValidationErrorCodes.NS_ORIGIN_MISMATCH,
|
|
79
|
+
path: `${path}.spec`,
|
|
80
|
+
message: `Vendor capability spec should be hosted on vendor's domain (${vendorDomain})`,
|
|
81
|
+
hint: `Consider hosting spec at https://${vendorDomain}/...`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Schema origin should match vendor domain
|
|
86
|
+
if (cap.schema && !isOriginFromDomain(cap.schema, vendorDomain)) {
|
|
87
|
+
issues.push({
|
|
88
|
+
severity: 'warn',
|
|
89
|
+
code: ValidationErrorCodes.NS_ORIGIN_MISMATCH,
|
|
90
|
+
path: `${path}.schema`,
|
|
91
|
+
message: `Vendor capability schema should be hosted on vendor's domain (${vendorDomain})`,
|
|
92
|
+
hint: `Consider hosting schema at https://${vendorDomain}/...`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return issues;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate extension chains - ensure parent capabilities exist
|
|
104
|
+
*/
|
|
105
|
+
function validateExtensions(profile: UcpProfile): ValidationIssue[] {
|
|
106
|
+
const issues: ValidationIssue[] = [];
|
|
107
|
+
const capabilities = profile.ucp.capabilities || [];
|
|
108
|
+
|
|
109
|
+
// Build set of capability names
|
|
110
|
+
const capabilityNames = new Set(capabilities.map(c => c.name));
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < capabilities.length; i++) {
|
|
113
|
+
const cap = capabilities[i];
|
|
114
|
+
|
|
115
|
+
if (cap.extends) {
|
|
116
|
+
// Check if parent capability exists in this profile
|
|
117
|
+
if (!capabilityNames.has(cap.extends)) {
|
|
118
|
+
issues.push({
|
|
119
|
+
severity: 'error',
|
|
120
|
+
code: ValidationErrorCodes.ORPHANED_EXTENSION,
|
|
121
|
+
path: `$.ucp.capabilities[${i}].extends`,
|
|
122
|
+
message: `Extension "${cap.name}" references non-existent parent capability "${cap.extends}"`,
|
|
123
|
+
hint: `Add "${cap.extends}" to capabilities or remove the extends field`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return issues;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate endpoint rules (https, no trailing slash)
|
|
134
|
+
*/
|
|
135
|
+
function validateEndpoints(profile: UcpProfile): ValidationIssue[] {
|
|
136
|
+
const issues: ValidationIssue[] = [];
|
|
137
|
+
const services = profile.ucp.services || {};
|
|
138
|
+
|
|
139
|
+
for (const [serviceName, service] of Object.entries(services)) {
|
|
140
|
+
const basePath = `$.ucp.services["${serviceName}"]`;
|
|
141
|
+
|
|
142
|
+
// Validate REST endpoint
|
|
143
|
+
if (service.rest?.endpoint) {
|
|
144
|
+
issues.push(...validateEndpoint(service.rest.endpoint, `${basePath}.rest.endpoint`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Validate MCP endpoint
|
|
148
|
+
if (service.mcp?.endpoint) {
|
|
149
|
+
issues.push(...validateEndpoint(service.mcp.endpoint, `${basePath}.mcp.endpoint`));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Validate A2A agent card URL
|
|
153
|
+
if (service.a2a?.agentCard) {
|
|
154
|
+
issues.push(...validateEndpoint(service.a2a.agentCard, `${basePath}.a2a.agentCard`));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Validate a single endpoint URL
|
|
163
|
+
*/
|
|
164
|
+
function validateEndpoint(endpoint: string, path: string): ValidationIssue[] {
|
|
165
|
+
const issues: ValidationIssue[] = [];
|
|
166
|
+
|
|
167
|
+
// Must be HTTPS
|
|
168
|
+
if (!endpoint.startsWith('https://')) {
|
|
169
|
+
issues.push({
|
|
170
|
+
severity: 'error',
|
|
171
|
+
code: ValidationErrorCodes.ENDPOINT_NOT_HTTPS,
|
|
172
|
+
path,
|
|
173
|
+
message: `Endpoint must use HTTPS`,
|
|
174
|
+
hint: `Change "${endpoint}" to use https://`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Should not have trailing slash
|
|
179
|
+
if (endpoint.endsWith('/')) {
|
|
180
|
+
issues.push({
|
|
181
|
+
severity: 'warn',
|
|
182
|
+
code: ValidationErrorCodes.ENDPOINT_TRAILING_SLASH,
|
|
183
|
+
path,
|
|
184
|
+
message: `Endpoint should not have a trailing slash`,
|
|
185
|
+
hint: `Remove trailing slash from "${endpoint}"`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check for private IP ranges (basic check)
|
|
190
|
+
if (isPrivateIpEndpoint(endpoint)) {
|
|
191
|
+
issues.push({
|
|
192
|
+
severity: 'warn',
|
|
193
|
+
code: ValidationErrorCodes.PRIVATE_IP_ENDPOINT,
|
|
194
|
+
path,
|
|
195
|
+
message: `Endpoint appears to use a private IP address`,
|
|
196
|
+
hint: `Use a public domain name for production profiles`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return issues;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Validate signing keys requirement for Order capability
|
|
205
|
+
*/
|
|
206
|
+
function validateSigningKeysRequirement(profile: UcpProfile): ValidationIssue[] {
|
|
207
|
+
const issues: ValidationIssue[] = [];
|
|
208
|
+
const capabilities = profile.ucp.capabilities || [];
|
|
209
|
+
|
|
210
|
+
// Check if Order capability is present
|
|
211
|
+
const hasOrderCapability = capabilities.some(
|
|
212
|
+
c => c.name === KNOWN_CAPABILITIES.ORDER
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (hasOrderCapability) {
|
|
216
|
+
// Signing keys should be present for webhook signing
|
|
217
|
+
if (!profile.signing_keys || profile.signing_keys.length === 0) {
|
|
218
|
+
issues.push({
|
|
219
|
+
severity: 'error',
|
|
220
|
+
code: ValidationErrorCodes.MISSING_SIGNING_KEYS,
|
|
221
|
+
path: '$.signing_keys',
|
|
222
|
+
message: `Order capability requires signing_keys for webhook verification`,
|
|
223
|
+
hint: `Add signing_keys array with at least one JWK public key`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return issues;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Check if URL is from ucp.dev origin
|
|
233
|
+
*/
|
|
234
|
+
function isUcpDevOrigin(url: string): boolean {
|
|
235
|
+
try {
|
|
236
|
+
const parsed = new URL(url);
|
|
237
|
+
return parsed.hostname === 'ucp.dev' || parsed.hostname.endsWith('.ucp.dev');
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Extract vendor domain from capability name
|
|
245
|
+
* e.g., "com.example.feature" -> "example.com"
|
|
246
|
+
*/
|
|
247
|
+
function extractVendorDomain(name: string): string | null {
|
|
248
|
+
if (!name.startsWith('com.')) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const parts = name.split('.');
|
|
253
|
+
if (parts.length < 3) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// "com.example.feature" -> "example.com"
|
|
258
|
+
return `${parts[1]}.com`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Check if URL origin matches expected domain
|
|
263
|
+
*/
|
|
264
|
+
function isOriginFromDomain(url: string, domain: string): boolean {
|
|
265
|
+
try {
|
|
266
|
+
const parsed = new URL(url);
|
|
267
|
+
return parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`);
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if endpoint uses private IP address
|
|
275
|
+
*/
|
|
276
|
+
function isPrivateIpEndpoint(endpoint: string): boolean {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = new URL(endpoint);
|
|
279
|
+
const hostname = parsed.hostname;
|
|
280
|
+
|
|
281
|
+
// Check for localhost
|
|
282
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Check for private IP ranges (simplified)
|
|
287
|
+
if (hostname.startsWith('10.') ||
|
|
288
|
+
hostname.startsWith('192.168.') ||
|
|
289
|
+
hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return false;
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK-based Validator
|
|
3
|
+
* Validates UCP profiles using the official @ucp-js/sdk Zod schemas
|
|
4
|
+
*
|
|
5
|
+
* This provides spec-compliant validation using the official UCP SDK,
|
|
6
|
+
* ensuring alignment with the latest UCP specification.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
UcpDiscoveryProfileSchema,
|
|
11
|
+
UcpClassSchema,
|
|
12
|
+
UcpServiceSchema,
|
|
13
|
+
CapabilityDiscoverySchema,
|
|
14
|
+
SigningKeySchema,
|
|
15
|
+
type UcpDiscoveryProfile,
|
|
16
|
+
type UcpClass,
|
|
17
|
+
type UcpService,
|
|
18
|
+
type CapabilityDiscovery,
|
|
19
|
+
type SigningKey,
|
|
20
|
+
} from '@ucp-js/sdk';
|
|
21
|
+
import { z, ZodError, ZodIssue } from 'zod';
|
|
22
|
+
import type { ValidationIssue } from '../types/validation.js';
|
|
23
|
+
import { ValidationErrorCodes } from '../types/validation.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* SDK validation result
|
|
27
|
+
*/
|
|
28
|
+
export interface SdkValidationResult {
|
|
29
|
+
valid: boolean;
|
|
30
|
+
issues: ValidationIssue[];
|
|
31
|
+
sdkVersion: string;
|
|
32
|
+
parsedProfile?: UcpDiscoveryProfile;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the current SDK version
|
|
37
|
+
*/
|
|
38
|
+
export function getSdkVersion(): string {
|
|
39
|
+
// Note: In production, this would be read from package.json
|
|
40
|
+
return '0.1.0';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Map Zod error path to JSON path string
|
|
45
|
+
*/
|
|
46
|
+
function zodPathToJsonPath(path: (string | number)[]): string {
|
|
47
|
+
if (path.length === 0) return '$';
|
|
48
|
+
|
|
49
|
+
return '$.' + path.map((segment) => {
|
|
50
|
+
if (typeof segment === 'number') {
|
|
51
|
+
return `[${segment}]`;
|
|
52
|
+
}
|
|
53
|
+
// Handle keys with special characters
|
|
54
|
+
if (/[.\[\]"]/.test(segment)) {
|
|
55
|
+
return `["${segment}"]`;
|
|
56
|
+
}
|
|
57
|
+
return segment;
|
|
58
|
+
}).join('.').replace(/\.\[/g, '[');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convert Zod issue to ValidationIssue
|
|
63
|
+
*/
|
|
64
|
+
function zodIssueToValidationIssue(issue: ZodIssue): ValidationIssue {
|
|
65
|
+
const path = zodPathToJsonPath(issue.path);
|
|
66
|
+
|
|
67
|
+
// Map Zod error codes to our validation codes
|
|
68
|
+
let code: string = ValidationErrorCodes.INVALID_SERVICE_STRUCTURE;
|
|
69
|
+
let hint: string | undefined;
|
|
70
|
+
|
|
71
|
+
switch (issue.code) {
|
|
72
|
+
case 'invalid_type':
|
|
73
|
+
if (issue.path.includes('version')) {
|
|
74
|
+
code = ValidationErrorCodes.INVALID_VERSION_FORMAT;
|
|
75
|
+
hint = 'Use YYYY-MM-DD format (e.g., "2026-01-11")';
|
|
76
|
+
} else if (issue.path.includes('services')) {
|
|
77
|
+
code = ValidationErrorCodes.INVALID_SERVICE_STRUCTURE;
|
|
78
|
+
} else if (issue.path.includes('capabilities')) {
|
|
79
|
+
code = ValidationErrorCodes.INVALID_CAPABILITY_STRUCTURE;
|
|
80
|
+
} else if (issue.path.includes('signing_keys')) {
|
|
81
|
+
code = ValidationErrorCodes.INVALID_SIGNING_KEY;
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
case 'invalid_string':
|
|
85
|
+
case 'invalid_enum_value':
|
|
86
|
+
if (issue.path.includes('version')) {
|
|
87
|
+
code = ValidationErrorCodes.INVALID_VERSION_FORMAT;
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
case 'unrecognized_keys':
|
|
91
|
+
// SDK allows extra keys, just warn
|
|
92
|
+
return {
|
|
93
|
+
severity: 'warn',
|
|
94
|
+
code: code as any,
|
|
95
|
+
path,
|
|
96
|
+
message: `Unrecognized field(s): ${(issue as any).keys?.join(', ')}`,
|
|
97
|
+
hint: 'Extra fields are allowed but may not be used by UCP clients',
|
|
98
|
+
};
|
|
99
|
+
default:
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
severity: 'error',
|
|
105
|
+
code: code as any,
|
|
106
|
+
path,
|
|
107
|
+
message: issue.message,
|
|
108
|
+
hint,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate a UCP profile using the official SDK schema
|
|
114
|
+
*
|
|
115
|
+
* This uses the UcpDiscoveryProfileSchema from @ucp-js/sdk to validate
|
|
116
|
+
* the entire profile structure against the official UCP specification.
|
|
117
|
+
*/
|
|
118
|
+
export function validateWithSdk(profile: unknown): SdkValidationResult {
|
|
119
|
+
const issues: ValidationIssue[] = [];
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Parse with the official SDK schema (strict mode)
|
|
123
|
+
const parsed = UcpDiscoveryProfileSchema.parse(profile);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
valid: true,
|
|
127
|
+
issues: [],
|
|
128
|
+
sdkVersion: getSdkVersion(),
|
|
129
|
+
parsedProfile: parsed,
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof ZodError) {
|
|
133
|
+
// Convert Zod errors to our validation issues
|
|
134
|
+
for (const issue of error.issues) {
|
|
135
|
+
issues.push(zodIssueToValidationIssue(issue));
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// Unexpected error
|
|
139
|
+
issues.push({
|
|
140
|
+
severity: 'error',
|
|
141
|
+
code: ValidationErrorCodes.MISSING_UCP_OBJECT,
|
|
142
|
+
path: '$',
|
|
143
|
+
message: error instanceof Error ? error.message : 'Unknown validation error',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
valid: false,
|
|
149
|
+
issues,
|
|
150
|
+
sdkVersion: getSdkVersion(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Safe parse - doesn't throw, returns result with errors
|
|
157
|
+
*/
|
|
158
|
+
export function safeValidateWithSdk(profile: unknown): SdkValidationResult {
|
|
159
|
+
const result = UcpDiscoveryProfileSchema.safeParse(profile);
|
|
160
|
+
|
|
161
|
+
if (result.success) {
|
|
162
|
+
return {
|
|
163
|
+
valid: true,
|
|
164
|
+
issues: [],
|
|
165
|
+
sdkVersion: getSdkVersion(),
|
|
166
|
+
parsedProfile: result.data,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const issues = result.error.issues.map(zodIssueToValidationIssue);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
valid: false,
|
|
174
|
+
issues,
|
|
175
|
+
sdkVersion: getSdkVersion(),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Validate only the UCP object (version, services, capabilities)
|
|
181
|
+
*/
|
|
182
|
+
export function validateUcpObject(ucp: unknown): SdkValidationResult {
|
|
183
|
+
const issues: ValidationIssue[] = [];
|
|
184
|
+
|
|
185
|
+
const result = UcpClassSchema.safeParse(ucp);
|
|
186
|
+
|
|
187
|
+
if (result.success) {
|
|
188
|
+
return {
|
|
189
|
+
valid: true,
|
|
190
|
+
issues: [],
|
|
191
|
+
sdkVersion: getSdkVersion(),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const issue of result.error.issues) {
|
|
196
|
+
const validationIssue = zodIssueToValidationIssue(issue);
|
|
197
|
+
// Prefix path with $.ucp
|
|
198
|
+
validationIssue.path = validationIssue.path === '$'
|
|
199
|
+
? '$.ucp'
|
|
200
|
+
: validationIssue.path.replace('$', '$.ucp');
|
|
201
|
+
issues.push(validationIssue);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
valid: false,
|
|
206
|
+
issues,
|
|
207
|
+
sdkVersion: getSdkVersion(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Validate a single service definition
|
|
213
|
+
*/
|
|
214
|
+
export function validateServiceWithSdk(
|
|
215
|
+
serviceName: string,
|
|
216
|
+
service: unknown
|
|
217
|
+
): SdkValidationResult {
|
|
218
|
+
const result = UcpServiceSchema.safeParse(service);
|
|
219
|
+
|
|
220
|
+
if (result.success) {
|
|
221
|
+
return {
|
|
222
|
+
valid: true,
|
|
223
|
+
issues: [],
|
|
224
|
+
sdkVersion: getSdkVersion(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const issues = result.error.issues.map(issue => {
|
|
229
|
+
const validationIssue = zodIssueToValidationIssue(issue);
|
|
230
|
+
validationIssue.path = validationIssue.path === '$'
|
|
231
|
+
? `$.ucp.services["${serviceName}"]`
|
|
232
|
+
: validationIssue.path.replace('$', `$.ucp.services["${serviceName}"]`);
|
|
233
|
+
return validationIssue;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
valid: false,
|
|
238
|
+
issues,
|
|
239
|
+
sdkVersion: getSdkVersion(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Validate a single capability definition
|
|
245
|
+
*/
|
|
246
|
+
export function validateCapabilityWithSdk(
|
|
247
|
+
index: number,
|
|
248
|
+
capability: unknown
|
|
249
|
+
): SdkValidationResult {
|
|
250
|
+
const result = CapabilityDiscoverySchema.safeParse(capability);
|
|
251
|
+
|
|
252
|
+
if (result.success) {
|
|
253
|
+
return {
|
|
254
|
+
valid: true,
|
|
255
|
+
issues: [],
|
|
256
|
+
sdkVersion: getSdkVersion(),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const issues = result.error.issues.map(issue => {
|
|
261
|
+
const validationIssue = zodIssueToValidationIssue(issue);
|
|
262
|
+
validationIssue.path = validationIssue.path === '$'
|
|
263
|
+
? `$.ucp.capabilities[${index}]`
|
|
264
|
+
: validationIssue.path.replace('$', `$.ucp.capabilities[${index}]`);
|
|
265
|
+
return validationIssue;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
valid: false,
|
|
270
|
+
issues,
|
|
271
|
+
sdkVersion: getSdkVersion(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Validate signing keys array
|
|
277
|
+
*/
|
|
278
|
+
export function validateSigningKeysWithSdk(
|
|
279
|
+
signingKeys: unknown
|
|
280
|
+
): SdkValidationResult {
|
|
281
|
+
// SDK expects signing_keys as an array of SigningKey objects
|
|
282
|
+
const SigningKeysArraySchema = z.array(SigningKeySchema);
|
|
283
|
+
const result = SigningKeysArraySchema.safeParse(signingKeys);
|
|
284
|
+
|
|
285
|
+
if (result.success) {
|
|
286
|
+
return {
|
|
287
|
+
valid: true,
|
|
288
|
+
issues: [],
|
|
289
|
+
sdkVersion: getSdkVersion(),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const issues = result.error.issues.map(issue => {
|
|
294
|
+
const validationIssue = zodIssueToValidationIssue(issue);
|
|
295
|
+
validationIssue.path = validationIssue.path === '$'
|
|
296
|
+
? '$.signing_keys'
|
|
297
|
+
: validationIssue.path.replace('$', '$.signing_keys');
|
|
298
|
+
return validationIssue;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
valid: false,
|
|
303
|
+
issues,
|
|
304
|
+
sdkVersion: getSdkVersion(),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Check if a profile passes SDK validation (quick boolean check)
|
|
310
|
+
*/
|
|
311
|
+
export function isSdkCompliant(profile: unknown): boolean {
|
|
312
|
+
const result = UcpDiscoveryProfileSchema.safeParse(profile);
|
|
313
|
+
return result.success;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Export SDK schemas for direct use
|
|
318
|
+
*/
|
|
319
|
+
export {
|
|
320
|
+
UcpDiscoveryProfileSchema,
|
|
321
|
+
UcpClassSchema,
|
|
322
|
+
UcpServiceSchema,
|
|
323
|
+
CapabilityDiscoverySchema,
|
|
324
|
+
SigningKeySchema,
|
|
325
|
+
type UcpDiscoveryProfile,
|
|
326
|
+
type UcpClass,
|
|
327
|
+
type UcpService,
|
|
328
|
+
type CapabilityDiscovery,
|
|
329
|
+
type SigningKey,
|
|
330
|
+
};
|