@nuxtseo/protocol 0.1.5 → 0.2.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.
@@ -1,5 +1,5 @@
1
1
  import { z as schema } from 'zod';
2
- import { defineOperation, defineRegistry, defineResponseObject, defineSuccessEnvelope, publicV1ErrorEnvelope, publicV1StandardRatePolicy, } from './core.js';
2
+ import { defineOperation, defineRegistry, defineResponseObject, defineSuccessEnvelope, publicV1BooleanFlag, publicV1ErrorEnvelope, publicV1StandardRatePolicy, } from './core.js';
3
3
  export const performanceSiteParamsSchema = schema.strictObject({
4
4
  siteId: schema.string().min(1),
5
5
  });
@@ -190,9 +190,651 @@ export const sitePerformanceOverviewOperation = defineOperation({
190
190
  }],
191
191
  },
192
192
  });
193
+ // ---------------------------------------------------------------------------
194
+ // Field vitals (CrUX real-user p75) — the dataset the `crux_vitals` MCP tool
195
+ // answers. Distinct from `page.performance` above, which is the stored
196
+ // Lighthouse LAB overview (ADR-0129: a name names its dataset).
197
+ // ---------------------------------------------------------------------------
198
+ export const fieldVitalsQuerySchema = schema.strictObject({
199
+ url: schema.string().url().optional(),
200
+ formFactor: schema.enum(['PHONE', 'DESKTOP', 'ALL_FORM_FACTORS']).default('PHONE'),
201
+ view: schema.enum(['summary', 'trend']).default('summary'),
202
+ });
203
+ const fieldVitalRatingSchema = schema.enum(['good', 'needs-improvement', 'poor']);
204
+ export const fieldVitalSummarySchema = defineResponseObject({
205
+ p75: schema.number().nullable(),
206
+ status: fieldVitalRatingSchema.nullable(),
207
+ deltaOverPeriod: schema.number().nullable(),
208
+ }, {
209
+ p75: schema.number().nullable(),
210
+ status: fieldVitalRatingSchema.nullable(),
211
+ deltaOverPeriod: schema.number().nullable(),
212
+ });
213
+ export const fieldVitalPointSchema = defineResponseObject({ date: schema.string().min(1), p75: schema.number().nullable() }, { date: schema.string().min(1), p75: schema.number().nullable() });
214
+ /**
215
+ * `available: false` is the "CrUX has no record for this target" answer, not an
216
+ * error: an origin under the CrUX traffic floor is a normal state and a caller
217
+ * should read `message` rather than retry.
218
+ */
219
+ export const fieldVitalsDataSchema = defineResponseObject({
220
+ target: schema.string().min(1),
221
+ mode: schema.enum(['origin', 'url']),
222
+ formFactor: schema.enum(['PHONE', 'DESKTOP', 'ALL_FORM_FACTORS']),
223
+ view: schema.enum(['summary', 'trend']),
224
+ available: schema.boolean(),
225
+ message: schema.string().nullable(),
226
+ summary: schema.strictObject({
227
+ lcp: fieldVitalSummarySchema.producer,
228
+ inp: fieldVitalSummarySchema.producer,
229
+ cls: fieldVitalSummarySchema.producer,
230
+ overall: schema.enum(['pass', 'ni', 'fail']).nullable(),
231
+ asOf: schema.string().min(1).nullable(),
232
+ }).nullable(),
233
+ trend: schema.strictObject({
234
+ lcp: schema.array(fieldVitalPointSchema.producer),
235
+ inp: schema.array(fieldVitalPointSchema.producer),
236
+ cls: schema.array(fieldVitalPointSchema.producer),
237
+ }).nullable(),
238
+ }, {
239
+ target: schema.string().min(1),
240
+ mode: schema.enum(['origin', 'url']),
241
+ formFactor: schema.enum(['PHONE', 'DESKTOP', 'ALL_FORM_FACTORS']),
242
+ view: schema.enum(['summary', 'trend']),
243
+ available: schema.boolean(),
244
+ message: schema.string().nullable(),
245
+ summary: schema.looseObject({
246
+ lcp: fieldVitalSummarySchema.client,
247
+ inp: fieldVitalSummarySchema.client,
248
+ cls: fieldVitalSummarySchema.client,
249
+ overall: schema.enum(['pass', 'ni', 'fail']).nullable(),
250
+ asOf: schema.string().min(1).nullable(),
251
+ }).nullable(),
252
+ trend: schema.looseObject({
253
+ lcp: schema.array(fieldVitalPointSchema.client),
254
+ inp: schema.array(fieldVitalPointSchema.client),
255
+ cls: schema.array(fieldVitalPointSchema.client),
256
+ }).nullable(),
257
+ });
258
+ export const fieldVitalsResponseSchema = defineSuccessEnvelope(fieldVitalsDataSchema);
259
+ export const fieldVitalsOperation = defineOperation({
260
+ id: 'page.vitals',
261
+ method: 'GET',
262
+ path: '/api/v1/sites/{siteId}/vitals',
263
+ visibility: 'public',
264
+ semantics: { kind: 'query', sideEffects: 'none', retry: 'idempotent', readConsistency: 'primary' },
265
+ auth: {
266
+ credentials: ['session', 'api_token'],
267
+ scopes: ['page:read'],
268
+ minimumRole: 'viewer',
269
+ ownership: 'site',
270
+ },
271
+ request: { params: performanceSiteParamsSchema, query: fieldVitalsQuerySchema, headers: null, body: null },
272
+ responses: { 200: fieldVitalsResponseSchema },
273
+ errors: [
274
+ 'invalid_request',
275
+ 'unauthorized',
276
+ 'auth_expired',
277
+ 'forbidden',
278
+ 'not_found',
279
+ 'rate_limited',
280
+ 'contract_violation',
281
+ 'internal_error',
282
+ ],
283
+ errorResponse: publicV1ErrorEnvelope,
284
+ resources: {
285
+ reads: [{ type: 'site.page', idFrom: 'params.siteId' }],
286
+ changes: [],
287
+ },
288
+ policy: {
289
+ rate: publicV1StandardRatePolicy,
290
+ idempotency: { _tag: 'none' },
291
+ entitlement: null,
292
+ meter: null,
293
+ },
294
+ lifecycle: { introduced: '1.2.0' },
295
+ docs: {
296
+ summary: 'Get field Core Web Vitals',
297
+ description: 'Returns Core Web Vitals from CrUX real-user field data for the Site origin, or for one Page when url is given. The summary view returns the latest p75 and its change over the window. The trend view returns the 25-week p75 series. For lab scan scores use the Site performance overview. For the DOM element behind a failing vital use the field vitals findings.',
298
+ tags: ['Pages'],
299
+ examples: [{
300
+ request: { params: { siteId: 'site_01JXYZ' }, query: { formFactor: 'PHONE', view: 'summary' } },
301
+ response: {
302
+ status: 200,
303
+ body: {
304
+ data: {
305
+ target: 'https://example.com',
306
+ mode: 'origin',
307
+ formFactor: 'PHONE',
308
+ view: 'summary',
309
+ available: true,
310
+ message: null,
311
+ summary: {
312
+ lcp: { p75: 2400, status: 'needs-improvement', deltaOverPeriod: -150 },
313
+ inp: { p75: 180, status: 'good', deltaOverPeriod: 10 },
314
+ cls: { p75: 0.08, status: 'good', deltaOverPeriod: null },
315
+ overall: 'ni',
316
+ asOf: '2026-08-24',
317
+ },
318
+ trend: null,
319
+ },
320
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
321
+ },
322
+ },
323
+ }],
324
+ },
325
+ });
326
+ export const fieldVitalFindingsQuerySchema = schema.strictObject({
327
+ metrics: schema.union([
328
+ schema.array(schema.enum(['lcp', 'inp', 'cls'])),
329
+ schema.enum(['lcp', 'inp', 'cls']).transform(one => [one]),
330
+ ]).optional(),
331
+ onlyFailing: publicV1BooleanFlag(true),
332
+ limit: schema.coerce.number().int().min(1).max(50).default(20),
333
+ offset: schema.coerce.number().int().min(0).default(0),
334
+ });
335
+ export const fieldVitalFindingSchema = defineResponseObject({
336
+ metric: schema.enum(['lcp', 'inp', 'cls']),
337
+ severity: fieldVitalRatingSchema,
338
+ p75: schema.number().nullable(),
339
+ route: schema.string(),
340
+ selector: schema.string(),
341
+ estimatedViews: schema.number(),
342
+ fixPrompt: schema.string().min(1),
343
+ }, {
344
+ metric: schema.enum(['lcp', 'inp', 'cls']),
345
+ severity: fieldVitalRatingSchema,
346
+ p75: schema.number().nullable(),
347
+ route: schema.string(),
348
+ selector: schema.string(),
349
+ estimatedViews: schema.number(),
350
+ fixPrompt: schema.string().min(1),
351
+ });
352
+ export const fieldVitalFindingsDataSchema = defineResponseObject({
353
+ source: schema.string().nullable(),
354
+ message: schema.string().nullable(),
355
+ findings: schema.array(fieldVitalFindingSchema.producer),
356
+ total: schema.number().int().nonnegative(),
357
+ limit: schema.number().int().positive(),
358
+ offset: schema.number().int().nonnegative(),
359
+ }, {
360
+ source: schema.string().nullable(),
361
+ message: schema.string().nullable(),
362
+ findings: schema.array(fieldVitalFindingSchema.client),
363
+ total: schema.number().int().nonnegative(),
364
+ limit: schema.number().int().positive(),
365
+ offset: schema.number().int().nonnegative(),
366
+ });
367
+ export const fieldVitalFindingsResponseSchema = defineSuccessEnvelope(fieldVitalFindingsDataSchema);
368
+ export const fieldVitalFindingsOperation = defineOperation({
369
+ id: 'page.vitals.findings',
370
+ method: 'GET',
371
+ path: '/api/v1/sites/{siteId}/vitals/findings',
372
+ visibility: 'public',
373
+ semantics: { kind: 'query', sideEffects: 'none', retry: 'idempotent', readConsistency: 'primary' },
374
+ auth: {
375
+ credentials: ['session', 'api_token'],
376
+ scopes: ['page:read'],
377
+ minimumRole: 'viewer',
378
+ ownership: 'site',
379
+ },
380
+ request: { params: performanceSiteParamsSchema, query: fieldVitalFindingsQuerySchema, headers: null, body: null },
381
+ responses: { 200: fieldVitalFindingsResponseSchema },
382
+ errors: [
383
+ 'invalid_request',
384
+ 'unauthorized',
385
+ 'auth_expired',
386
+ 'forbidden',
387
+ 'not_found',
388
+ 'rate_limited',
389
+ 'contract_violation',
390
+ 'internal_error',
391
+ ],
392
+ errorResponse: publicV1ErrorEnvelope,
393
+ resources: {
394
+ reads: [{ type: 'site.page', idFrom: 'params.siteId' }],
395
+ changes: [],
396
+ },
397
+ policy: {
398
+ rate: publicV1StandardRatePolicy,
399
+ idempotency: { _tag: 'none' },
400
+ entitlement: null,
401
+ meter: null,
402
+ },
403
+ lifecycle: { introduced: '1.2.0' },
404
+ docs: {
405
+ summary: 'List field Core Web Vitals findings',
406
+ description: 'Returns each failing real-user vital attributed to the DOM element behind it, with the p75, the CSS selector, and a ready-to-apply fix prompt. It requires a connected Web Analytics provider. If no provider is connected, findings is empty and message says so.',
407
+ tags: ['Pages'],
408
+ examples: [{
409
+ request: { params: { siteId: 'site_01JXYZ' }, query: { onlyFailing: '1', limit: 20, offset: 0 } },
410
+ response: {
411
+ status: 200,
412
+ body: {
413
+ data: {
414
+ source: 'cloudflare',
415
+ message: null,
416
+ findings: [{
417
+ metric: 'lcp',
418
+ severity: 'poor',
419
+ p75: 4300,
420
+ route: '/blog',
421
+ selector: 'main > img.hero',
422
+ estimatedViews: 1840,
423
+ fixPrompt: 'The LCP element on /blog is main > img.hero at 4300ms.',
424
+ }],
425
+ total: 1,
426
+ limit: 20,
427
+ offset: 0,
428
+ },
429
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
430
+ },
431
+ },
432
+ }],
433
+ },
434
+ });
435
+ // ---------------------------------------------------------------------------
436
+ // Lighthouse drill-downs. `page.performance` above answers the Site median.
437
+ // These three answer the questions behind it: which Pages are monitored, which
438
+ // Scans ran, and what one Scan actually found. Same reads as the
439
+ // `lighthouse_audits` tool's scans / pages / scan_detail actions.
440
+ // ---------------------------------------------------------------------------
441
+ export const performanceScansQuerySchema = schema.strictObject({
442
+ limit: schema.coerce.number().int().min(1).max(100).default(25),
443
+ });
444
+ export const performanceScanParamsSchema = schema.strictObject({
445
+ siteId: schema.string().min(1),
446
+ scanId: schema.string().min(1),
447
+ });
448
+ const scanScoresShape = {
449
+ perf: nullableMetricSchema,
450
+ a11y: nullableMetricSchema,
451
+ bestPractices: nullableMetricSchema,
452
+ seo: nullableMetricSchema,
453
+ };
454
+ export const performanceScanSummarySchema = defineResponseObject({
455
+ id: schema.string().min(1),
456
+ url: schema.string().min(1),
457
+ strategy: schema.string().min(1),
458
+ status: schema.string().min(1),
459
+ trigger: schema.string().nullable(),
460
+ scores: schema.strictObject(scanScoresShape),
461
+ lcp: nullableMetricSchema,
462
+ tbt: nullableMetricSchema,
463
+ cls: nullableMetricSchema,
464
+ completedAt: schema.string().datetime().nullable(),
465
+ error: schema.string().nullable(),
466
+ }, {
467
+ id: schema.string().min(1),
468
+ url: schema.string().min(1),
469
+ strategy: schema.string().min(1),
470
+ status: schema.string().min(1),
471
+ trigger: schema.string().nullable(),
472
+ scores: schema.looseObject(scanScoresShape),
473
+ lcp: nullableMetricSchema,
474
+ tbt: nullableMetricSchema,
475
+ cls: nullableMetricSchema,
476
+ completedAt: schema.string().datetime().nullable(),
477
+ error: schema.string().nullable(),
478
+ });
479
+ export const performanceScansDataSchema = defineResponseObject({
480
+ scans: schema.array(performanceScanSummarySchema.producer),
481
+ total: schema.number().int().nonnegative(),
482
+ limit: schema.number().int().positive(),
483
+ }, {
484
+ scans: schema.array(performanceScanSummarySchema.client),
485
+ total: schema.number().int().nonnegative(),
486
+ limit: schema.number().int().positive(),
487
+ });
488
+ export const performanceScansResponseSchema = defineSuccessEnvelope(performanceScansDataSchema);
489
+ export const performanceScansOperation = defineOperation({
490
+ id: 'page.performance.scans',
491
+ method: 'GET',
492
+ path: '/api/v1/sites/{siteId}/performance/scans',
493
+ visibility: 'public',
494
+ semantics: { kind: 'query', sideEffects: 'none', retry: 'idempotent', readConsistency: 'primary' },
495
+ auth: {
496
+ credentials: ['session', 'api_token'],
497
+ scopes: ['page:read'],
498
+ minimumRole: 'viewer',
499
+ ownership: 'site',
500
+ },
501
+ request: { params: performanceSiteParamsSchema, query: performanceScansQuerySchema, headers: null, body: null },
502
+ responses: { 200: performanceScansResponseSchema },
503
+ errors: [
504
+ 'invalid_request',
505
+ 'unauthorized',
506
+ 'auth_expired',
507
+ 'forbidden',
508
+ 'not_found',
509
+ 'rate_limited',
510
+ 'contract_violation',
511
+ 'internal_error',
512
+ ],
513
+ errorResponse: publicV1ErrorEnvelope,
514
+ resources: { reads: [{ type: 'site.page', idFrom: 'params.siteId' }], changes: [] },
515
+ policy: {
516
+ rate: publicV1StandardRatePolicy,
517
+ idempotency: { _tag: 'none' },
518
+ entitlement: null,
519
+ meter: null,
520
+ },
521
+ lifecycle: { introduced: '1.2.0' },
522
+ docs: {
523
+ summary: 'List Lighthouse Scans',
524
+ description: 'Returns retained Lighthouse Scans for the Site, newest first, with the scores and lab vitals each one recorded. A failed Scan carries its error. Use one Scan id with the Scan read for the failing checks behind a score.',
525
+ tags: ['Pages'],
526
+ examples: [{
527
+ request: { params: { siteId: 'site_01JXYZ' }, query: { limit: 25 } },
528
+ response: {
529
+ status: 200,
530
+ body: {
531
+ data: {
532
+ scans: [{
533
+ id: 'scan_01JXYZ',
534
+ url: 'https://example.com/',
535
+ strategy: 'mobile',
536
+ status: 'complete',
537
+ trigger: 'scheduled',
538
+ scores: { perf: 82, a11y: 96, bestPractices: 92, seo: 98 },
539
+ lcp: 2200,
540
+ tbt: 180,
541
+ cls: 0.08,
542
+ completedAt: '2026-08-10T01:00:00.000Z',
543
+ error: null,
544
+ }],
545
+ total: 1,
546
+ limit: 25,
547
+ },
548
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
549
+ },
550
+ },
551
+ }],
552
+ },
553
+ });
554
+ export const monitoredPageSchema = defineResponseObject({
555
+ id: schema.string().min(1),
556
+ path: schema.string(),
557
+ source: schema.string().nullable(),
558
+ enabled: schema.boolean(),
559
+ lastScanAt: schema.string().datetime().nullable(),
560
+ }, {
561
+ id: schema.string().min(1),
562
+ path: schema.string(),
563
+ source: schema.string().nullable(),
564
+ enabled: schema.boolean(),
565
+ lastScanAt: schema.string().datetime().nullable(),
566
+ });
567
+ export const monitoredPagesDataSchema = defineResponseObject({
568
+ pages: schema.array(monitoredPageSchema.producer),
569
+ total: schema.number().int().nonnegative(),
570
+ }, {
571
+ pages: schema.array(monitoredPageSchema.client),
572
+ total: schema.number().int().nonnegative(),
573
+ });
574
+ export const monitoredPagesResponseSchema = defineSuccessEnvelope(monitoredPagesDataSchema);
575
+ export const monitoredPagesOperation = defineOperation({
576
+ id: 'page.performance.pages',
577
+ method: 'GET',
578
+ path: '/api/v1/sites/{siteId}/performance/pages',
579
+ visibility: 'public',
580
+ semantics: { kind: 'query', sideEffects: 'none', retry: 'idempotent', readConsistency: 'primary' },
581
+ auth: {
582
+ credentials: ['session', 'api_token'],
583
+ scopes: ['page:read'],
584
+ minimumRole: 'viewer',
585
+ ownership: 'site',
586
+ },
587
+ request: { params: performanceSiteParamsSchema, query: null, headers: null, body: null },
588
+ responses: { 200: monitoredPagesResponseSchema },
589
+ errors: [
590
+ 'invalid_request',
591
+ 'unauthorized',
592
+ 'auth_expired',
593
+ 'forbidden',
594
+ 'not_found',
595
+ 'rate_limited',
596
+ 'contract_violation',
597
+ 'internal_error',
598
+ ],
599
+ errorResponse: publicV1ErrorEnvelope,
600
+ resources: { reads: [{ type: 'site.page', idFrom: 'params.siteId' }], changes: [] },
601
+ policy: {
602
+ rate: publicV1StandardRatePolicy,
603
+ idempotency: { _tag: 'none' },
604
+ entitlement: null,
605
+ meter: null,
606
+ },
607
+ lifecycle: { introduced: '1.2.0' },
608
+ docs: {
609
+ summary: 'List monitored Pages',
610
+ description: 'Returns the Pages this Site scans on a schedule, in display order, with the last time each one was scanned. Start a Scan for a new Page with the Page scan operation.',
611
+ tags: ['Pages'],
612
+ examples: [{
613
+ request: { params: { siteId: 'site_01JXYZ' } },
614
+ response: {
615
+ status: 200,
616
+ body: {
617
+ data: {
618
+ pages: [{ id: 'lhp_01JXYZ', path: '/', source: 'homepage', enabled: true, lastScanAt: '2026-08-10T01:00:00.000Z' }],
619
+ total: 1,
620
+ },
621
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
622
+ },
623
+ },
624
+ }],
625
+ },
626
+ });
627
+ const scanElementSampleShape = {
628
+ selector: schema.string().nullable(),
629
+ snippet: schema.string().nullable(),
630
+ message: schema.string().nullable(),
631
+ explanation: schema.string().nullable(),
632
+ sourceUrl: schema.string().nullable(),
633
+ };
634
+ export const scanElementSampleSchema = defineResponseObject(scanElementSampleShape, scanElementSampleShape);
635
+ export const scanFailingCheckSchema = defineResponseObject({
636
+ auditId: schema.string().min(1),
637
+ title: schema.string(),
638
+ severity: schema.string(),
639
+ description: schema.string().nullable(),
640
+ wcag: schema.array(schema.string()),
641
+ wcagLevel: schema.string().nullable(),
642
+ instanceCount: schema.number().int().nonnegative(),
643
+ elements: schema.array(scanElementSampleSchema.producer),
644
+ /** Elements that actually failed, when more than the sample above. */
645
+ elementsTotal: schema.number().int().nonnegative().nullable(),
646
+ }, {
647
+ auditId: schema.string().min(1),
648
+ title: schema.string(),
649
+ severity: schema.string(),
650
+ description: schema.string().nullable(),
651
+ wcag: schema.array(schema.string()),
652
+ wcagLevel: schema.string().nullable(),
653
+ instanceCount: schema.number().int().nonnegative(),
654
+ elements: schema.array(scanElementSampleSchema.client),
655
+ elementsTotal: schema.number().int().nonnegative().nullable(),
656
+ });
657
+ export const scanPerformanceIssueSchema = defineResponseObject({
658
+ type: schema.string(),
659
+ subtype: schema.string().nullable(),
660
+ url: schema.string().nullable(),
661
+ wastedMs: nullableMetricSchema,
662
+ wastedBytes: nullableMetricSchema,
663
+ }, {
664
+ type: schema.string(),
665
+ subtype: schema.string().nullable(),
666
+ url: schema.string().nullable(),
667
+ wastedMs: nullableMetricSchema,
668
+ wastedBytes: nullableMetricSchema,
669
+ });
670
+ export const scanCachePolicySchema = defineResponseObject({
671
+ url: schema.string().nullable(),
672
+ repeatVisitBytes: nullableMetricSchema,
673
+ cacheLifetimeMs: nullableMetricSchema,
674
+ totalBytes: nullableMetricSchema,
675
+ resourceType: schema.string().nullable(),
676
+ mimeType: schema.string().nullable(),
677
+ versioning: schema.enum(['fingerprinted', 'unknown']),
678
+ }, {
679
+ url: schema.string().nullable(),
680
+ repeatVisitBytes: nullableMetricSchema,
681
+ cacheLifetimeMs: nullableMetricSchema,
682
+ totalBytes: nullableMetricSchema,
683
+ resourceType: schema.string().nullable(),
684
+ mimeType: schema.string().nullable(),
685
+ versioning: schema.enum(['fingerprinted', 'unknown']),
686
+ });
687
+ export const scanThirdPartySchema = defineResponseObject({
688
+ entity: schema.string().nullable(),
689
+ blockingTime: nullableMetricSchema,
690
+ mainThreadTime: nullableMetricSchema,
691
+ transferSize: nullableMetricSchema,
692
+ }, {
693
+ entity: schema.string().nullable(),
694
+ blockingTime: nullableMetricSchema,
695
+ mainThreadTime: nullableMetricSchema,
696
+ transferSize: nullableMetricSchema,
697
+ });
698
+ const scanCoverageShape = {
699
+ perCheckElementCap: schema.number().int().positive(),
700
+ perScanCheckCap: schema.number().int().positive(),
701
+ accessibilityChecksTotal: schema.number().int().nonnegative(),
702
+ bestPracticesChecksTotal: schema.number().int().nonnegative(),
703
+ possiblyTruncated: schema.boolean(),
704
+ };
705
+ export const performanceScanDetailDataSchema = defineResponseObject({
706
+ id: schema.string().min(1),
707
+ url: schema.string().min(1),
708
+ strategy: schema.string().min(1),
709
+ status: schema.string().min(1),
710
+ scores: schema.strictObject(scanScoresShape),
711
+ lcp: nullableMetricSchema,
712
+ tbt: nullableMetricSchema,
713
+ cls: nullableMetricSchema,
714
+ fcp: nullableMetricSchema,
715
+ speedIndex: nullableMetricSchema,
716
+ ttfb: nullableMetricSchema,
717
+ completedAt: schema.string().datetime().nullable(),
718
+ performanceIssues: schema.array(scanPerformanceIssueSchema.producer),
719
+ cachePolicy: schema.array(scanCachePolicySchema.producer),
720
+ thirdParties: schema.array(scanThirdPartySchema.producer),
721
+ lcpElement: schema.strictObject({
722
+ selector: schema.string().nullable(),
723
+ elementType: schema.string().nullable(),
724
+ nodeLabel: schema.string().nullable(),
725
+ }).nullable(),
726
+ accessibility: schema.array(scanFailingCheckSchema.producer),
727
+ bestPractices: schema.array(scanFailingCheckSchema.producer),
728
+ coverage: schema.strictObject(scanCoverageShape),
729
+ }, {
730
+ id: schema.string().min(1),
731
+ url: schema.string().min(1),
732
+ strategy: schema.string().min(1),
733
+ status: schema.string().min(1),
734
+ scores: schema.looseObject(scanScoresShape),
735
+ lcp: nullableMetricSchema,
736
+ tbt: nullableMetricSchema,
737
+ cls: nullableMetricSchema,
738
+ fcp: nullableMetricSchema,
739
+ speedIndex: nullableMetricSchema,
740
+ ttfb: nullableMetricSchema,
741
+ completedAt: schema.string().datetime().nullable(),
742
+ performanceIssues: schema.array(scanPerformanceIssueSchema.client),
743
+ cachePolicy: schema.array(scanCachePolicySchema.client),
744
+ thirdParties: schema.array(scanThirdPartySchema.client),
745
+ lcpElement: schema.looseObject({
746
+ selector: schema.string().nullable(),
747
+ elementType: schema.string().nullable(),
748
+ nodeLabel: schema.string().nullable(),
749
+ }).nullable(),
750
+ accessibility: schema.array(scanFailingCheckSchema.client),
751
+ bestPractices: schema.array(scanFailingCheckSchema.client),
752
+ coverage: schema.looseObject(scanCoverageShape),
753
+ });
754
+ export const performanceScanDetailResponseSchema = defineSuccessEnvelope(performanceScanDetailDataSchema);
755
+ export const performanceScanDetailOperation = defineOperation({
756
+ id: 'page.performance.scan',
757
+ method: 'GET',
758
+ path: '/api/v1/sites/{siteId}/performance/scans/{scanId}',
759
+ visibility: 'public',
760
+ semantics: { kind: 'query', sideEffects: 'none', retry: 'idempotent', readConsistency: 'primary' },
761
+ auth: {
762
+ credentials: ['session', 'api_token'],
763
+ scopes: ['page:read'],
764
+ minimumRole: 'viewer',
765
+ ownership: 'site',
766
+ },
767
+ request: { params: performanceScanParamsSchema, query: null, headers: null, body: null },
768
+ responses: { 200: performanceScanDetailResponseSchema },
769
+ errors: [
770
+ 'invalid_request',
771
+ 'unauthorized',
772
+ 'auth_expired',
773
+ 'forbidden',
774
+ 'not_found',
775
+ 'rate_limited',
776
+ 'contract_violation',
777
+ 'internal_error',
778
+ ],
779
+ errorResponse: publicV1ErrorEnvelope,
780
+ resources: { reads: [{ type: 'site.page', idFrom: 'params.siteId' }], changes: [] },
781
+ policy: {
782
+ rate: publicV1StandardRatePolicy,
783
+ idempotency: { _tag: 'none' },
784
+ entitlement: null,
785
+ meter: null,
786
+ },
787
+ lifecycle: { introduced: '1.2.0' },
788
+ docs: {
789
+ summary: 'Get one Lighthouse Scan',
790
+ description: 'Returns one Scan with the evidence behind its scores: the wasteful assets, the repeat-visit cache policy, the third-party scripts, the LCP element, and every failing accessibility and best-practices check with the CSS selectors and DOM snippets that failed. Read coverage before quoting a total: elements and checks are both capped per Scan.',
791
+ tags: ['Pages'],
792
+ examples: [{
793
+ request: { params: { siteId: 'site_01JXYZ', scanId: 'scan_01JXYZ' } },
794
+ response: {
795
+ status: 200,
796
+ body: {
797
+ data: {
798
+ id: 'scan_01JXYZ',
799
+ url: 'https://example.com/',
800
+ strategy: 'mobile',
801
+ status: 'complete',
802
+ scores: { perf: 82, a11y: 96, bestPractices: 92, seo: 98 },
803
+ lcp: 2200,
804
+ tbt: 180,
805
+ cls: 0.08,
806
+ fcp: 1100,
807
+ speedIndex: 2400,
808
+ ttfb: 210,
809
+ completedAt: '2026-08-10T01:00:00.000Z',
810
+ performanceIssues: [],
811
+ cachePolicy: [],
812
+ thirdParties: [],
813
+ lcpElement: { selector: 'main > img.hero', elementType: 'image', nodeLabel: 'Hero' },
814
+ accessibility: [],
815
+ bestPractices: [],
816
+ coverage: {
817
+ perCheckElementCap: 5,
818
+ perScanCheckCap: 12,
819
+ accessibilityChecksTotal: 0,
820
+ bestPracticesChecksTotal: 0,
821
+ possiblyTruncated: false,
822
+ },
823
+ },
824
+ meta: { requestId: 'req_01JXYZ', version: '1.0' },
825
+ },
826
+ },
827
+ }],
828
+ },
829
+ });
193
830
  export const performanceV1Registry = defineRegistry({
194
831
  feature: 'page',
195
832
  operations: {
196
833
  overview: sitePerformanceOverviewOperation,
834
+ vitals: fieldVitalsOperation,
835
+ vitalFindings: fieldVitalFindingsOperation,
836
+ scans: performanceScansOperation,
837
+ monitoredPages: monitoredPagesOperation,
838
+ scanDetail: performanceScanDetailOperation,
197
839
  },
198
840
  });