@doneisbetter/gds-compliance 2.6.6 → 3.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 (2) hide show
  1. package/index.js +276 -1
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -15,6 +15,9 @@ const STRICT_COMPLIANCE_FIELDS = [
15
15
  'approvedDetailPrimitives',
16
16
  'approvedListingPrimitives',
17
17
  'approvedActionPrimitives',
18
+ 'approvedMediaPrimitives',
19
+ 'approvedReportingPrimitives',
20
+ 'approvedAccessPrimitives',
18
21
  'approvedTemporaryExceptions',
19
22
  'approvedThemeLanes',
20
23
  'themeOwnershipPaths',
@@ -47,7 +50,14 @@ const PRODUCT_AUTHORED_REQUIRED_FIELDS = [
47
50
  'testingRequirements',
48
51
  'observabilityRequirements',
49
52
  ];
50
-
53
+ const IDENTITY_PROVIDER_BRANDING_FIELDS = [
54
+ 'approvedProviders',
55
+ 'forbiddenCustomizations',
56
+ 'allowedVariants',
57
+ 'colorAuthority',
58
+ 'minTouchTargetPx',
59
+ 'policyDocument',
60
+ ];
51
61
  export function validateManifest(manifest) {
52
62
  const findings = [];
53
63
 
@@ -100,6 +110,87 @@ export function validateManifest(manifest) {
100
110
  }
101
111
  }
102
112
 
113
+ const identityProviderBranding = manifest.compliance?.identityProviderBranding;
114
+ if (identityProviderBranding != null) {
115
+ if (typeof identityProviderBranding !== 'object') {
116
+ findings.push({
117
+ rule: 'manifest.invalidComplianceConfig',
118
+ severity: 'error',
119
+ message: 'compliance.identityProviderBranding must be an object when provided.',
120
+ });
121
+ } else {
122
+ if (!Array.isArray(identityProviderBranding.approvedProviders) || identityProviderBranding.approvedProviders.length === 0) {
123
+ findings.push({
124
+ rule: 'manifest.invalidComplianceConfig',
125
+ severity: 'error',
126
+ message: 'compliance.identityProviderBranding.approvedProviders must be a non-empty array.',
127
+ });
128
+ }
129
+
130
+ for (const field of IDENTITY_PROVIDER_BRANDING_FIELDS) {
131
+ const value = identityProviderBranding[field];
132
+ if (value == null) {
133
+ continue;
134
+ }
135
+
136
+ if ((field === 'forbiddenCustomizations' || field === 'allowedVariants') && !Array.isArray(value)) {
137
+ findings.push({
138
+ rule: 'manifest.invalidComplianceConfig',
139
+ severity: 'error',
140
+ message: `compliance.identityProviderBranding.${field} must be an array when provided.`,
141
+ });
142
+ }
143
+ }
144
+
145
+ if (Array.isArray(identityProviderBranding.allowedVariants)) {
146
+ const invalidVariants = identityProviderBranding.allowedVariants.filter((value) => !['solid', 'outline', 'neutral'].includes(String(value).trim().toLowerCase()));
147
+ if (invalidVariants.length > 0) {
148
+ findings.push({
149
+ rule: 'manifest.invalidComplianceConfig',
150
+ severity: 'error',
151
+ message: `compliance.identityProviderBranding.allowedVariants contains invalid values: ${invalidVariants.join(', ')}.`,
152
+ });
153
+ }
154
+ }
155
+
156
+ if (Array.isArray(identityProviderBranding.forbiddenCustomizations)) {
157
+ const hasNonStringCustomization = identityProviderBranding.forbiddenCustomizations.some((value) => typeof value !== 'string');
158
+ if (hasNonStringCustomization) {
159
+ findings.push({
160
+ rule: 'manifest.invalidComplianceConfig',
161
+ severity: 'error',
162
+ message: 'compliance.identityProviderBranding.forbiddenCustomizations may only contain strings.',
163
+ });
164
+ }
165
+ }
166
+
167
+ if (identityProviderBranding.minTouchTargetPx != null &&
168
+ (typeof identityProviderBranding.minTouchTargetPx !== 'number' || identityProviderBranding.minTouchTargetPx < 24)) {
169
+ findings.push({
170
+ rule: 'manifest.invalidComplianceConfig',
171
+ severity: 'error',
172
+ message: 'compliance.identityProviderBranding.minTouchTargetPx must be >= 24 when provided.',
173
+ });
174
+ }
175
+
176
+ if (identityProviderBranding.colorAuthority != null && !['provider', 'gds-outline', 'gds-neutral'].includes(identityProviderBranding.colorAuthority)) {
177
+ findings.push({
178
+ rule: 'manifest.invalidComplianceConfig',
179
+ severity: 'error',
180
+ message: 'compliance.identityProviderBranding.colorAuthority must be one of: provider, gds-outline, gds-neutral.',
181
+ });
182
+ }
183
+
184
+ if (identityProviderBranding.policyDocument != null && typeof identityProviderBranding.policyDocument !== 'string') {
185
+ findings.push({
186
+ rule: 'manifest.invalidComplianceConfig',
187
+ severity: 'error',
188
+ message: 'compliance.identityProviderBranding.policyDocument must be a string when provided.',
189
+ });
190
+ }
191
+ }
192
+ }
193
+
103
194
  if (manifest.compliance?.strictMode != null && typeof manifest.compliance.strictMode !== 'boolean') {
104
195
  findings.push({
105
196
  rule: 'manifest.invalidComplianceConfig',
@@ -303,6 +394,9 @@ function inferStrictSurface(contract) {
303
394
  if (normalized.includes('detail') || normalized.includes('profile')) return 'detail';
304
395
  if (normalized.includes('card') || normalized.includes('listing')) return 'listing';
305
396
  if (normalized.includes('action') || normalized.includes('button')) return 'action';
397
+ if (normalized.includes('media') || normalized.includes('upload') || normalized.includes('asset')) return 'media';
398
+ if (normalized.includes('report') || normalized.includes('chart') || normalized.includes('evidence') || normalized.includes('metric')) return 'reporting';
399
+ if (normalized.includes('auth') || normalized.includes('access') || normalized.includes('identity') || normalized.includes('login')) return 'access';
306
400
  return null;
307
401
  }
308
402
 
@@ -314,6 +408,9 @@ function runStrictCompliance({ manifest, manifestRoot, sourceFiles }) {
314
408
  detail: new Set(strict.approvedDetailPrimitives ?? []),
315
409
  listing: new Set(strict.approvedListingPrimitives ?? []),
316
410
  action: new Set(strict.approvedActionPrimitives ?? []),
411
+ media: new Set(strict.approvedMediaPrimitives ?? []),
412
+ reporting: new Set(strict.approvedReportingPrimitives ?? []),
413
+ access: new Set(strict.approvedAccessPrimitives ?? []),
317
414
  };
318
415
  const approvedTemporaryExceptions = new Set(strict.approvedTemporaryExceptions ?? []);
319
416
 
@@ -361,6 +458,50 @@ function runStrictCompliance({ manifest, manifestRoot, sourceFiles }) {
361
458
  message: 'Strict mode forbids local button/action wrapper implementations. Use the canonical GDS ActionBar and semantic actions.',
362
459
  });
363
460
  }
461
+
462
+ if (/export function \w*(Listing|Venue|Event|Community|Product|Card)\w*\s*\(/.test(content)
463
+ && /from\s+['"]@mantine\/core['"][\s\S]{0,240}\bCard\b/.test(content)
464
+ && !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,260}\b(ListingCard|PublicProductCard|PublicFoodCard|MediaCard)\b/.test(content)) {
465
+ findings.push({
466
+ rule: 'strict.listing.local-card-wrapper',
467
+ severity: 'error',
468
+ file: filePath,
469
+ message: 'Strict mode forbids local listing/card wrappers backed by Mantine Card. Use ListingCard, PublicProductCard, PublicFoodCard, or MediaCard.',
470
+ });
471
+ }
472
+
473
+ if (/export function \w*(Upload|Media|Asset|ImagePicker|Dropzone)\w*\s*\(/.test(content)
474
+ && (/<input[^>]+type=["']file["']/.test(content) || /\bDropzone\b/.test(content))
475
+ && !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,260}\b(MediaField|UploadDropzone)\b/.test(content)) {
476
+ findings.push({
477
+ rule: 'strict.media.local-upload-wrapper',
478
+ severity: 'error',
479
+ file: filePath,
480
+ message: 'Strict mode forbids local media/upload wrappers. Use MediaField and UploadDropzone while keeping storage logic product-owned.',
481
+ });
482
+ }
483
+
484
+ if (/export function \w*(Report|Chart|Evidence|Analytics|Metric)\w*\s*\(/.test(content)
485
+ && (/\bChart\b|recharts|chart\.js|<canvas\b|svg\s+role=["']img["']/.test(content))
486
+ && !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,320}\b(ReportingSection|PeriodSelector|EvidencePanel|ChartTokenPanel|StatsSection|MetricCard)\b/.test(content)) {
487
+ findings.push({
488
+ rule: 'strict.reporting.local-chart-wrapper',
489
+ severity: 'error',
490
+ file: filePath,
491
+ message: 'Strict mode forbids local reporting/chart wrappers without the GDS reporting contract. Use ReportingSection, EvidencePanel, PeriodSelector, and ChartTokenPanel.',
492
+ });
493
+ }
494
+
495
+ if (/export function \w*(Auth|Login|Social|AccessDenied|Protected|Recovery)\w*\s*\(/.test(content)
496
+ && (/\b(google|apple|github|microsoft|facebook)\b/i.test(content) || /Access denied|Sign in required|Session expired/i.test(content))
497
+ && !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,360}\b(AuthShell|ProviderIdentityButton|ProviderIdentityButtonGroup|SocialAuthButtons|AccessSummary|AccessRecoveryPanel)\b/.test(content)) {
498
+ findings.push({
499
+ rule: 'strict.access.local-auth-wrapper',
500
+ severity: 'error',
501
+ file: filePath,
502
+ message: 'Strict mode forbids local auth/access wrappers. Use AuthShell, provider identity controls, AccessSummary, and AccessRecoveryPanel.',
503
+ });
504
+ }
364
505
  }
365
506
 
366
507
  return findings;
@@ -417,6 +558,139 @@ function isCoveredByApprovedException(relativePath, approvedExceptions = []) {
417
558
  return approvedExceptions.some((exception) => matchesScope(relativePath, exception.scope ?? []));
418
559
  }
419
560
 
561
+ function normalizeProviderId(value) {
562
+ return String(value).trim().toLowerCase();
563
+ }
564
+
565
+ function parseProviderIdsFromSocialAuthUsage(usageChunk) {
566
+ const providers = new Set();
567
+ const providerObjectRegex = /\b(?:id|provider)\s*:\s*['"]([^'"]+)['"]/g;
568
+ const providerAttributeRegex = /provider\s*=\s*['"]([^'"]+)['"]/g;
569
+ const providerArrayRegex = /providers\s*=\s*\[(.*?)\]/s;
570
+
571
+ for (const match of usageChunk.matchAll(providerObjectRegex)) {
572
+ providers.add(normalizeProviderId(match[1]));
573
+ }
574
+
575
+ for (const match of usageChunk.matchAll(providerAttributeRegex)) {
576
+ providers.add(normalizeProviderId(match[1]));
577
+ }
578
+
579
+ if (providers.size > 0) {
580
+ return providers;
581
+ }
582
+
583
+ const arrayMatch = usageChunk.match(providerArrayRegex);
584
+ if (!arrayMatch) {
585
+ return providers;
586
+ }
587
+
588
+ const idRegex = /['"]([^'"]+)['"]/g;
589
+ for (const match of arrayMatch[1].matchAll(idRegex)) {
590
+ providers.add(normalizeProviderId(match[1]));
591
+ }
592
+
593
+ return providers;
594
+ }
595
+
596
+ function hasForbiddenCustomization(usageChunk, forbiddenCustomizations = []) {
597
+ if (!forbiddenCustomizations.length) {
598
+ return [];
599
+ }
600
+
601
+ return forbiddenCustomizations
602
+ .map((customization) => normalizeProviderId(customization))
603
+ .filter((customization) => new RegExp(`\\b${escapeRegex(customization)}\\s*[:=]`, 'i').test(usageChunk));
604
+ }
605
+
606
+ function scanIdentityProviderBranding({ manifest, manifestRoot, sourceFiles }) {
607
+ const findings = [];
608
+ const policy = manifest.compliance?.identityProviderBranding;
609
+ if (!policy || !Array.isArray(policy.approvedProviders) || !policy.approvedProviders.length) {
610
+ return findings;
611
+ }
612
+
613
+ const approvedProviders = new Set(policy.approvedProviders.map((provider) => normalizeProviderId(provider)));
614
+ const forbiddenCustomizations = Array.isArray(policy.forbiddenCustomizations)
615
+ ? policy.forbiddenCustomizations
616
+ : [];
617
+ const allowedVariants = Array.isArray(policy.allowedVariants)
618
+ ? new Set(policy.allowedVariants.map((variant) => normalizeProviderId(variant)))
619
+ : null;
620
+ const socialAuthUsages = /<(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)[\s\S]*?(?:\/\s*>|>[\s\S]*?<\/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)>)/g;
621
+ const providerTextRegex = /\b(google|apple|facebook|github|microsoft|linkedin|discord|\bx\b|email)\b/i;
622
+ const mantineButtonImportRegex = /from\s+['"]@mantine\/core['"][\s\S]{0,240}\bButton\b/;
623
+ const sourceRoot = normalizePath(manifestRoot).replace(/\/$/, '');
624
+
625
+ for (const filePath of sourceFiles) {
626
+ const content = readFileSync(filePath, 'utf8');
627
+ const relativePath = normalizePath(filePath).replace(`${sourceRoot}/`, '');
628
+
629
+ if (!/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)/.test(content) && (/\bSocialAuth\b/i.test(content) || providerTextRegex.test(content))) {
630
+ if (mantineButtonImportRegex.test(content) && providerTextRegex.test(content)) {
631
+ findings.push({
632
+ rule: 'identity.provider.custom-controls.warn',
633
+ severity: 'warn',
634
+ file: relativePath,
635
+ message: 'Social identity controls appear to use Mantine primitives directly. Consider using SocialAuthButtons or ProviderIdentityButton/ProviderIdentityButtonGroup and policy-conformant provider rendering.',
636
+ });
637
+ }
638
+ continue;
639
+ }
640
+
641
+ const usages = [...content.matchAll(socialAuthUsages)];
642
+ for (const usage of usages) {
643
+ const providerIds = parseProviderIdsFromSocialAuthUsage(usage[0]);
644
+ const forbiddenInUsage = hasForbiddenCustomization(usage[0], forbiddenCustomizations);
645
+
646
+ for (const forbidden of forbiddenInUsage) {
647
+ findings.push({
648
+ rule: 'identity.provider.forbidden-customization',
649
+ severity: 'error',
650
+ file: relativePath,
651
+ message: `Social identity usage in ${relativePath} sets forbidden customization "${forbidden}". Use ProviderIdentityButton/ProviderIdentityButtonGroup or SocialAuthButtons instead.`,
652
+ });
653
+ }
654
+
655
+ if (allowedVariants) {
656
+ for (const match of usage[0].matchAll(/\bvariant\s*[:=]\s*['"]([^'"]+)['"]/g)) {
657
+ const variant = normalizeProviderId(match[1]);
658
+ if (!allowedVariants.has(variant)) {
659
+ findings.push({
660
+ rule: 'identity.provider.disallowed-variant',
661
+ severity: 'error',
662
+ file: relativePath,
663
+ message: `Social identity usage sets variant "${variant}" outside compliance.identityProviderBranding.allowedVariants.`,
664
+ });
665
+ }
666
+ }
667
+ }
668
+
669
+ for (const providerId of providerIds) {
670
+ if (!approvedProviders.has(providerId)) {
671
+ findings.push({
672
+ rule: 'identity.provider.unapproved-id',
673
+ severity: 'error',
674
+ file: relativePath,
675
+ message: `Social identity usage uses provider "${providerId}" not listed in compliance.identityProviderBranding.approvedProviders.`,
676
+ });
677
+ }
678
+ }
679
+ }
680
+
681
+ if (/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)/.test(content) && !usages.length && providerTextRegex.test(content)) {
682
+ findings.push({
683
+ rule: 'identity.provider.missing-provider-list',
684
+ severity: 'warn',
685
+ file: relativePath,
686
+ message: 'Social identity controls are present but provider ids could not be parsed for policy validation. Keep provider ids explicit and canonical.',
687
+ });
688
+ }
689
+ }
690
+
691
+ return findings;
692
+ }
693
+
420
694
  function findThemeOwnershipFiles({ manifestRoot, sourceFiles, themeOwnershipPaths = [] }) {
421
695
  if (!themeOwnershipPaths.length) {
422
696
  return [];
@@ -542,6 +816,7 @@ export function runComplianceCheck({ manifestPath }) {
542
816
  findings.push(...validateApprovedExceptions(manifest));
543
817
  findings.push(...validateApprovedExceptionsAgainstRepo({ manifestRoot, manifest, sourceFiles }));
544
818
  findings.push(...scanThemeGovernance({ manifestRoot, manifest, sourceFiles }));
819
+ findings.push(...scanIdentityProviderBranding({ manifest, manifestRoot, sourceFiles }));
545
820
 
546
821
  if (protectedSurfacePaths.length) {
547
822
  const normalizedProtectedSurfacePaths = protectedSurfacePaths.map((value) => normalizePath(resolve(manifestRoot, value)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doneisbetter/gds-compliance",
3
- "version": "2.6.6",
3
+ "version": "3.0.0",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "bin": {