@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.
Files changed (121) hide show
  1. package/CLAUDE.md +109 -0
  2. package/CONTRIBUTING.md +113 -0
  3. package/LICENSE +21 -0
  4. package/README.md +203 -0
  5. package/api/analyze-feed.js +140 -0
  6. package/api/badge.js +185 -0
  7. package/api/benchmark.js +177 -0
  8. package/api/directory-stats.ts +29 -0
  9. package/api/directory.ts +73 -0
  10. package/api/generate-compliance.js +143 -0
  11. package/api/generate-schema.js +457 -0
  12. package/api/generate.js +132 -0
  13. package/api/security-scan.js +133 -0
  14. package/api/simulate.js +187 -0
  15. package/api/tsconfig.json +10 -0
  16. package/api/validate.js +1351 -0
  17. package/apify-actor/.actor/actor.json +68 -0
  18. package/apify-actor/.actor/input_schema.json +32 -0
  19. package/apify-actor/APIFY-STORE-LISTING.md +412 -0
  20. package/apify-actor/Dockerfile +8 -0
  21. package/apify-actor/README.md +166 -0
  22. package/apify-actor/main.ts +111 -0
  23. package/apify-actor/package.json +17 -0
  24. package/apify-actor/src/main.js +199 -0
  25. package/docs/BRAND-IDENTITY.md +238 -0
  26. package/docs/BRAND-STYLE-GUIDE.md +356 -0
  27. package/drizzle/0000_black_king_cobra.sql +39 -0
  28. package/drizzle/meta/0000_snapshot.json +309 -0
  29. package/drizzle/meta/_journal.json +13 -0
  30. package/drizzle.config.ts +10 -0
  31. package/examples/full-profile.json +70 -0
  32. package/examples/minimal-profile.json +23 -0
  33. package/package.json +69 -0
  34. package/public/.well-known/ucp +25 -0
  35. package/public/android-chrome-192x192.png +0 -0
  36. package/public/android-chrome-512x512.png +0 -0
  37. package/public/apple-touch-icon.png +0 -0
  38. package/public/brand.css +321 -0
  39. package/public/directory.html +701 -0
  40. package/public/favicon-16x16.png +0 -0
  41. package/public/favicon-32x32.png +0 -0
  42. package/public/favicon.ico +0 -0
  43. package/public/guides/bigcommerce.html +743 -0
  44. package/public/guides/fastucp.html +838 -0
  45. package/public/guides/magento.html +779 -0
  46. package/public/guides/shopify.html +726 -0
  47. package/public/guides/squarespace.html +749 -0
  48. package/public/guides/wix.html +747 -0
  49. package/public/guides/woocommerce.html +733 -0
  50. package/public/index.html +3835 -0
  51. package/public/learn.html +396 -0
  52. package/public/logo.jpeg +0 -0
  53. package/public/og-image-icon.png +0 -0
  54. package/public/og-image.png +0 -0
  55. package/public/robots.txt +6 -0
  56. package/public/site.webmanifest +31 -0
  57. package/public/sitemap.xml +69 -0
  58. package/public/social/linkedin-banner-1128x191.png +0 -0
  59. package/public/social/temp.PNG +0 -0
  60. package/public/social/x-header-1500x500.png +0 -0
  61. package/public/verify.html +410 -0
  62. package/scripts/generate-favicons.js +44 -0
  63. package/scripts/generate-ico.js +23 -0
  64. package/scripts/generate-og-image.js +45 -0
  65. package/scripts/reset-db.ts +77 -0
  66. package/scripts/seed-db.ts +71 -0
  67. package/scripts/setup-benchmark-db.js +70 -0
  68. package/src/api/server.ts +266 -0
  69. package/src/cli/index.ts +302 -0
  70. package/src/compliance/compliance-generator.ts +452 -0
  71. package/src/compliance/index.ts +28 -0
  72. package/src/compliance/templates.ts +338 -0
  73. package/src/compliance/types.ts +170 -0
  74. package/src/db/index.ts +28 -0
  75. package/src/db/schema.ts +84 -0
  76. package/src/feed-analyzer/feed-analyzer.ts +726 -0
  77. package/src/feed-analyzer/index.ts +34 -0
  78. package/src/feed-analyzer/types.ts +354 -0
  79. package/src/generator/index.ts +7 -0
  80. package/src/generator/key-generator.ts +124 -0
  81. package/src/generator/profile-builder.ts +402 -0
  82. package/src/hosting/artifacts-generator.ts +679 -0
  83. package/src/hosting/index.ts +6 -0
  84. package/src/index.ts +105 -0
  85. package/src/security/index.ts +15 -0
  86. package/src/security/security-scanner.ts +604 -0
  87. package/src/security/types.ts +55 -0
  88. package/src/services/directory.ts +434 -0
  89. package/src/simulator/agent-simulator.ts +941 -0
  90. package/src/simulator/index.ts +7 -0
  91. package/src/simulator/types.ts +170 -0
  92. package/src/types/generator.ts +140 -0
  93. package/src/types/index.ts +7 -0
  94. package/src/types/ucp-profile.ts +140 -0
  95. package/src/types/validation.ts +89 -0
  96. package/src/validator/index.ts +194 -0
  97. package/src/validator/network-validator.ts +417 -0
  98. package/src/validator/rules-validator.ts +297 -0
  99. package/src/validator/sdk-validator.ts +330 -0
  100. package/src/validator/structural-validator.ts +476 -0
  101. package/tests/fixtures/non-compliant-profile.json +25 -0
  102. package/tests/fixtures/official-sample-profile.json +75 -0
  103. package/tests/integration/benchmark.test.ts +207 -0
  104. package/tests/integration/database.test.ts +163 -0
  105. package/tests/integration/directory-api.test.ts +268 -0
  106. package/tests/integration/simulate-api.test.ts +230 -0
  107. package/tests/integration/validate-api.test.ts +269 -0
  108. package/tests/setup.ts +15 -0
  109. package/tests/unit/agent-simulator.test.ts +575 -0
  110. package/tests/unit/compliance-generator.test.ts +374 -0
  111. package/tests/unit/directory-service.test.ts +272 -0
  112. package/tests/unit/feed-analyzer.test.ts +517 -0
  113. package/tests/unit/lint-suggestions.test.ts +423 -0
  114. package/tests/unit/official-samples.test.ts +211 -0
  115. package/tests/unit/pdf-report.test.ts +390 -0
  116. package/tests/unit/sdk-validator.test.ts +531 -0
  117. package/tests/unit/security-scanner.test.ts +410 -0
  118. package/tests/unit/validation.test.ts +390 -0
  119. package/tsconfig.json +20 -0
  120. package/vercel.json +34 -0
  121. package/vitest.config.ts +22 -0
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Directory Service
3
+ * Business logic for the UCP Merchant Directory
4
+ */
5
+
6
+ import { eq, ilike, sql, desc, asc, and, count, SQL } from 'drizzle-orm';
7
+ import { getDb, merchants, type Merchant, type NewMerchant } from '../db/index.js';
8
+
9
+ // Types
10
+ export interface ListMerchantsParams {
11
+ page?: number;
12
+ limit?: number;
13
+ category?: string;
14
+ country?: string;
15
+ search?: string;
16
+ sort?: 'score' | 'domain' | 'displayName' | 'createdAt';
17
+ order?: 'asc' | 'desc';
18
+ }
19
+
20
+ export interface ListMerchantsResult {
21
+ merchants: Merchant[];
22
+ pagination: {
23
+ page: number;
24
+ limit: number;
25
+ total: number;
26
+ totalPages: number;
27
+ };
28
+ filters: {
29
+ categories: { name: string; count: number }[];
30
+ countries: { code: string; count: number }[];
31
+ };
32
+ }
33
+
34
+ export interface SubmitMerchantParams {
35
+ domain: string;
36
+ displayName?: string;
37
+ description?: string;
38
+ logoUrl?: string;
39
+ websiteUrl?: string;
40
+ category?: string;
41
+ countryCode?: string;
42
+ }
43
+
44
+ export interface DirectoryStats {
45
+ totalMerchants: number;
46
+ verifiedMerchants: number;
47
+ avgScore: number;
48
+ totalCategories: number;
49
+ totalCountries: number;
50
+ gradeDistribution: { grade: string; count: number }[];
51
+ topCategories: { name: string; count: number }[];
52
+ recentAdditions: { domain: string; displayName: string; grade: string | null; addedAt: Date }[];
53
+ }
54
+
55
+ export interface ValidationResult {
56
+ valid: boolean;
57
+ error?: string;
58
+ score?: number;
59
+ grade?: string;
60
+ transports?: string;
61
+ ucpVersion?: string;
62
+ }
63
+
64
+ // UCP Profile structure (minimal typing for validation)
65
+ interface UcpProfile {
66
+ ucp?: {
67
+ version?: string;
68
+ services?: Record<string, { rest?: unknown; mcp?: unknown; a2a?: unknown; embedded?: unknown }>;
69
+ capabilities?: Array<{ name: string }>;
70
+ };
71
+ signing_keys?: unknown[];
72
+ }
73
+
74
+ /**
75
+ * Fetch UCP profile from a URL, return null if not valid JSON
76
+ */
77
+ async function tryFetchProfile(url: string): Promise<UcpProfile | null> {
78
+ try {
79
+ const res = await fetch(url, {
80
+ headers: {
81
+ Accept: 'application/json',
82
+ 'User-Agent': 'UCP-Directory/1.0 (https://ucptools.dev)',
83
+ },
84
+ signal: AbortSignal.timeout(10000),
85
+ });
86
+
87
+ if (!res.ok) return null;
88
+
89
+ const text = await res.text();
90
+ // Check if response looks like JSON (not HTML)
91
+ if (text.trim().startsWith('<')) return null;
92
+
93
+ return JSON.parse(text) as UcpProfile;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Validate a domain's UCP profile
101
+ */
102
+ export async function validateDomain(domain: string): Promise<ValidationResult> {
103
+ // Try both /.well-known/ucp and /.well-known/ucp.json
104
+ const urls = [
105
+ `https://${domain}/.well-known/ucp`,
106
+ `https://${domain}/.well-known/ucp.json`,
107
+ ];
108
+
109
+ let profile: UcpProfile | null = null;
110
+
111
+ for (const url of urls) {
112
+ profile = await tryFetchProfile(url);
113
+ if (profile) break;
114
+ }
115
+
116
+ try {
117
+ if (!profile) {
118
+ return { valid: false, error: 'No UCP profile found at /.well-known/ucp or /.well-known/ucp.json' };
119
+ }
120
+
121
+ // Basic validation
122
+ if (!profile?.ucp?.version || !profile?.ucp?.services) {
123
+ return { valid: false, error: 'Invalid UCP profile structure' };
124
+ }
125
+
126
+ // Extract transports
127
+ const transports = new Set<string>();
128
+ for (const svc of Object.values(profile.ucp.services || {})) {
129
+ if (svc.rest) transports.add('REST');
130
+ if (svc.mcp) transports.add('MCP');
131
+ if (svc.a2a) transports.add('A2A');
132
+ if (svc.embedded) transports.add('Embedded');
133
+ }
134
+
135
+ // Simple scoring
136
+ let score = 50;
137
+ const capabilities = profile.ucp.capabilities || [];
138
+ if (capabilities.some((c) => c.name === 'dev.ucp.shopping.checkout')) score += 20;
139
+ if (capabilities.some((c) => c.name === 'dev.ucp.shopping.cart')) score += 10;
140
+ if (capabilities.some((c) => c.name === 'dev.ucp.shopping.order')) score += 10;
141
+ if (profile.signing_keys && profile.signing_keys.length > 0) score += 10;
142
+
143
+ const grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
144
+
145
+ return {
146
+ valid: true,
147
+ score,
148
+ grade,
149
+ transports: Array.from(transports).join(','),
150
+ ucpVersion: profile.ucp.version,
151
+ };
152
+ } catch (e: unknown) {
153
+ const message = e instanceof Error ? e.message : 'Failed to fetch profile';
154
+ return { valid: false, error: message };
155
+ }
156
+ }
157
+
158
+ /**
159
+ * List merchants with pagination and filters
160
+ */
161
+ export async function listMerchants(params: ListMerchantsParams): Promise<ListMerchantsResult> {
162
+ const db = getDb();
163
+
164
+ const page = Math.max(1, params.page || 1);
165
+ const limit = Math.min(100, Math.max(1, params.limit || 20));
166
+ const offset = (page - 1) * limit;
167
+
168
+ // Build where conditions
169
+ const conditions: SQL[] = [eq(merchants.isPublic, true)];
170
+
171
+ if (params.category) {
172
+ conditions.push(eq(merchants.category, params.category));
173
+ }
174
+
175
+ if (params.country) {
176
+ conditions.push(eq(merchants.countryCode, params.country.toUpperCase()));
177
+ }
178
+
179
+ if (params.search) {
180
+ conditions.push(
181
+ sql`(${merchants.domain} ILIKE ${`%${params.search}%`} OR ${merchants.displayName} ILIKE ${`%${params.search}%`})`
182
+ );
183
+ }
184
+
185
+ const whereClause = and(...conditions);
186
+
187
+ // Determine sort column and order
188
+ const sortColumn =
189
+ params.sort === 'domain'
190
+ ? merchants.domain
191
+ : params.sort === 'displayName'
192
+ ? merchants.displayName
193
+ : params.sort === 'createdAt'
194
+ ? merchants.createdAt
195
+ : merchants.ucpScore;
196
+
197
+ const orderFn = params.order === 'asc' ? asc : desc;
198
+
199
+ // Get total count
200
+ const countResult = await db
201
+ .select({ total: count() })
202
+ .from(merchants)
203
+ .where(whereClause);
204
+
205
+ const total = countResult[0]?.total || 0;
206
+
207
+ // Get merchants
208
+ const merchantList = await db
209
+ .select()
210
+ .from(merchants)
211
+ .where(whereClause)
212
+ .orderBy(orderFn(sortColumn))
213
+ .limit(limit)
214
+ .offset(offset);
215
+
216
+ // Get category counts
217
+ const categoryResult = await db
218
+ .select({
219
+ category: merchants.category,
220
+ count: count(),
221
+ })
222
+ .from(merchants)
223
+ .where(and(eq(merchants.isPublic, true), sql`${merchants.category} IS NOT NULL`))
224
+ .groupBy(merchants.category)
225
+ .orderBy(desc(count()));
226
+
227
+ // Get country counts
228
+ const countryResult = await db
229
+ .select({
230
+ countryCode: merchants.countryCode,
231
+ count: count(),
232
+ })
233
+ .from(merchants)
234
+ .where(and(eq(merchants.isPublic, true), sql`${merchants.countryCode} IS NOT NULL`))
235
+ .groupBy(merchants.countryCode)
236
+ .orderBy(desc(count()));
237
+
238
+ return {
239
+ merchants: merchantList,
240
+ pagination: {
241
+ page,
242
+ limit,
243
+ total,
244
+ totalPages: Math.ceil(total / limit),
245
+ },
246
+ filters: {
247
+ categories: categoryResult.map((r) => ({ name: r.category!, count: r.count })),
248
+ countries: countryResult.map((r) => ({ code: r.countryCode!, count: r.count })),
249
+ },
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Get a single merchant by domain
255
+ */
256
+ export async function getMerchantByDomain(domain: string): Promise<Merchant | null> {
257
+ const db = getDb();
258
+ const result = await db.select().from(merchants).where(eq(merchants.domain, domain.toLowerCase())).limit(1);
259
+ return result[0] || null;
260
+ }
261
+
262
+ /**
263
+ * Submit a new merchant to the directory
264
+ */
265
+ export async function submitMerchant(
266
+ params: SubmitMerchantParams
267
+ ): Promise<{ success: boolean; merchant?: Merchant; error?: string; details?: string }> {
268
+ const db = getDb();
269
+
270
+ // Clean domain
271
+ const cleanDomain = params.domain
272
+ .replace(/^https?:\/\//, '')
273
+ .replace(/\/$/, '')
274
+ .split('/')[0]
275
+ .toLowerCase();
276
+
277
+ // Check if domain already exists
278
+ const existing = await getMerchantByDomain(cleanDomain);
279
+ if (existing) {
280
+ return {
281
+ success: false,
282
+ error: 'Domain already registered',
283
+ details: `Merchant ID: ${existing.id}`,
284
+ };
285
+ }
286
+
287
+ // Validate the domain has a valid UCP profile
288
+ const validation = await validateDomain(cleanDomain);
289
+
290
+ if (!validation.valid) {
291
+ return {
292
+ success: false,
293
+ error: 'Invalid UCP profile',
294
+ details: validation.error,
295
+ };
296
+ }
297
+
298
+ // Insert merchant
299
+ const newMerchant: NewMerchant = {
300
+ domain: cleanDomain,
301
+ displayName: params.displayName || cleanDomain,
302
+ description: params.description || null,
303
+ logoUrl: params.logoUrl || null,
304
+ websiteUrl: params.websiteUrl || `https://${cleanDomain}`,
305
+ category: params.category || null,
306
+ countryCode: params.countryCode?.toUpperCase() || null,
307
+ ucpScore: validation.score || null,
308
+ ucpGrade: validation.grade || null,
309
+ transports: validation.transports || null,
310
+ isPublic: true,
311
+ isVerified: false,
312
+ lastValidatedAt: new Date(),
313
+ };
314
+
315
+ const result = await db.insert(merchants).values(newMerchant).returning();
316
+
317
+ return {
318
+ success: true,
319
+ merchant: result[0],
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Get directory statistics
325
+ */
326
+ export async function getDirectoryStats(): Promise<DirectoryStats> {
327
+ const db = getDb();
328
+
329
+ // Overall stats
330
+ const statsResult = await db
331
+ .select({
332
+ totalMerchants: count(),
333
+ verifiedMerchants: sql<number>`COUNT(*) FILTER (WHERE ${merchants.isVerified} = true)`,
334
+ avgScore: sql<number>`COALESCE(AVG(${merchants.ucpScore}), 0)`,
335
+ })
336
+ .from(merchants)
337
+ .where(eq(merchants.isPublic, true));
338
+
339
+ // Count distinct categories and countries
340
+ const categoryCountResult = await db
341
+ .select({ count: sql<number>`COUNT(DISTINCT ${merchants.category})` })
342
+ .from(merchants)
343
+ .where(and(eq(merchants.isPublic, true), sql`${merchants.category} IS NOT NULL`));
344
+
345
+ const countryCountResult = await db
346
+ .select({ count: sql<number>`COUNT(DISTINCT ${merchants.countryCode})` })
347
+ .from(merchants)
348
+ .where(and(eq(merchants.isPublic, true), sql`${merchants.countryCode} IS NOT NULL`));
349
+
350
+ // Grade distribution
351
+ const gradeResult = await db
352
+ .select({
353
+ grade: merchants.ucpGrade,
354
+ count: count(),
355
+ })
356
+ .from(merchants)
357
+ .where(and(eq(merchants.isPublic, true), sql`${merchants.ucpGrade} IS NOT NULL`))
358
+ .groupBy(merchants.ucpGrade)
359
+ .orderBy(merchants.ucpGrade);
360
+
361
+ // Top categories
362
+ const topCategoriesResult = await db
363
+ .select({
364
+ name: merchants.category,
365
+ count: count(),
366
+ })
367
+ .from(merchants)
368
+ .where(and(eq(merchants.isPublic, true), sql`${merchants.category} IS NOT NULL`))
369
+ .groupBy(merchants.category)
370
+ .orderBy(desc(count()))
371
+ .limit(10);
372
+
373
+ // Recent additions
374
+ const recentResult = await db
375
+ .select({
376
+ domain: merchants.domain,
377
+ displayName: merchants.displayName,
378
+ grade: merchants.ucpGrade,
379
+ addedAt: merchants.createdAt,
380
+ })
381
+ .from(merchants)
382
+ .where(eq(merchants.isPublic, true))
383
+ .orderBy(desc(merchants.createdAt))
384
+ .limit(5);
385
+
386
+ const stats = statsResult[0];
387
+
388
+ return {
389
+ totalMerchants: stats?.totalMerchants || 0,
390
+ verifiedMerchants: stats?.verifiedMerchants || 0,
391
+ avgScore: Math.round(stats?.avgScore || 0),
392
+ totalCategories: categoryCountResult[0]?.count || 0,
393
+ totalCountries: countryCountResult[0]?.count || 0,
394
+ gradeDistribution: gradeResult.map((r) => ({ grade: r.grade!, count: r.count })),
395
+ topCategories: topCategoriesResult.map((r) => ({ name: r.name!, count: r.count })),
396
+ recentAdditions: recentResult.map((r) => ({
397
+ domain: r.domain,
398
+ displayName: r.displayName,
399
+ grade: r.grade,
400
+ addedAt: r.addedAt,
401
+ })),
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Re-validate a merchant's UCP profile and update their record
407
+ */
408
+ export async function revalidateMerchant(domain: string): Promise<{ success: boolean; error?: string }> {
409
+ const db = getDb();
410
+
411
+ const merchant = await getMerchantByDomain(domain);
412
+ if (!merchant) {
413
+ return { success: false, error: 'Merchant not found' };
414
+ }
415
+
416
+ const validation = await validateDomain(domain);
417
+
418
+ if (!validation.valid) {
419
+ return { success: false, error: validation.error };
420
+ }
421
+
422
+ await db
423
+ .update(merchants)
424
+ .set({
425
+ ucpScore: validation.score || null,
426
+ ucpGrade: validation.grade || null,
427
+ transports: validation.transports || null,
428
+ lastValidatedAt: new Date(),
429
+ updatedAt: new Date(),
430
+ })
431
+ .where(eq(merchants.domain, domain.toLowerCase()));
432
+
433
+ return { success: true };
434
+ }