@proveanything/smartlinks 1.9.17 → 1.9.20

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/dist/openapi.yaml CHANGED
@@ -12417,6 +12417,54 @@ paths:
12417
12417
  description: Unauthorized
12418
12418
  404:
12419
12419
  description: Not found
12420
+ /{zone}/collection/{collectionId}/app/{appId}/recordse)}/{recordId}:
12421
+ patch:
12422
+ tags:
12423
+ - records
12424
+ summary: records.updateWithToken
12425
+ operationId: records_updateWithToken
12426
+ security: []
12427
+ parameters:
12428
+ - name: zone
12429
+ in: path
12430
+ required: true
12431
+ schema:
12432
+ type: string
12433
+ - name: collectionId
12434
+ in: path
12435
+ required: true
12436
+ schema:
12437
+ type: string
12438
+ - name: appId
12439
+ in: path
12440
+ required: true
12441
+ schema:
12442
+ type: string
12443
+ - name: recordId
12444
+ in: path
12445
+ required: true
12446
+ schema:
12447
+ type: string
12448
+ responses:
12449
+ 200:
12450
+ description: Success
12451
+ content:
12452
+ application/json:
12453
+ schema:
12454
+ $ref: "#/components/schemas/AppRecord"
12455
+ 400:
12456
+ description: Bad request
12457
+ 401:
12458
+ description: Unauthorized
12459
+ 404:
12460
+ description: Not found
12461
+ requestBody:
12462
+ required: true
12463
+ content:
12464
+ application/json:
12465
+ schema:
12466
+ type: object
12467
+ additionalProperties: true
12420
12468
  /{zone}/collection/{collectionId}/app/{appId}/recordsn)}/aggregate:
12421
12469
  post:
12422
12470
  tags:
@@ -12550,7 +12598,7 @@ paths:
12550
12598
  get:
12551
12599
  tags:
12552
12600
  - records
12553
- summary: "Create a new record POST /records / export async function create( collectionId: string, appId: string, input: CreateReco"
12601
+ summary: "List records with optional query parameters GET /records / export async function list( collectionId: string, appId: stri"
12554
12602
  operationId: records_get
12555
12603
  security: []
12556
12604
  parameters:
@@ -15602,26 +15650,49 @@ components:
15602
15650
  type: object
15603
15651
  properties:
15604
15652
  cases:
15605
- $ref: "#/components/schemas/PublicCreateRule"
15653
+ $ref: "#/components/schemas/PublicCreateObjectRule"
15606
15654
  threads:
15607
- $ref: "#/components/schemas/PublicCreateRule"
15655
+ $ref: "#/components/schemas/PublicCreateObjectRule"
15608
15656
  records:
15609
- $ref: "#/components/schemas/PublicCreateRule"
15610
- PublicCreateRule:
15657
+ $ref: "#/components/schemas/PublicCreateObjectRule"
15658
+ PublicCreateObjectRule:
15611
15659
  type: object
15612
15660
  properties:
15613
- allow:
15614
- type: object
15615
- additionalProperties: true
15616
15661
  anonymous:
15617
- $ref: "#/components/schemas/CreateCaseInput"
15662
+ $ref: "#/components/schemas/PublicCreateBranch"
15618
15663
  authenticated:
15619
- $ref: "#/components/schemas/CreateCaseInput"
15664
+ $ref: "#/components/schemas/PublicCreateBranch"
15665
+ PublicCreateBranch:
15666
+ type: object
15667
+ properties:
15668
+ allow:
15669
+ type: boolean
15620
15670
  enforce:
15621
15671
  type: object
15622
15672
  additionalProperties: true
15673
+ visibility:
15674
+ type: string
15675
+ enum:
15676
+ - public
15677
+ - owner
15678
+ - admin
15679
+ status:
15680
+ type: string
15681
+ edit:
15682
+ type: object
15683
+ additionalProperties: true
15684
+ editToken:
15685
+ type: boolean
15686
+ windowMinutes:
15687
+ type: number
15623
15688
  required:
15624
15689
  - allow
15690
+ - editToken
15691
+ CreateRecordResponse:
15692
+ type: object
15693
+ properties:
15694
+ editToken:
15695
+ type: string
15625
15696
  Asset:
15626
15697
  type: object
15627
15698
  properties:
@@ -22544,6 +22615,66 @@ components:
22544
22615
  metadata:
22545
22616
  type: object
22546
22617
  additionalProperties: true
22618
+ NavigationRequest:
22619
+ type: object
22620
+ properties:
22621
+ appId:
22622
+ type: string
22623
+ deepLink:
22624
+ type: string
22625
+ params:
22626
+ type: object
22627
+ additionalProperties:
22628
+ type: string
22629
+ productId:
22630
+ type: string
22631
+ proofId:
22632
+ type: string
22633
+ required:
22634
+ - appId
22635
+ SmartLinksWidgetProps:
22636
+ type: object
22637
+ properties:
22638
+ collectionId:
22639
+ type: string
22640
+ appId:
22641
+ type: string
22642
+ productId:
22643
+ type: string
22644
+ proofId:
22645
+ type: string
22646
+ user:
22647
+ type: object
22648
+ additionalProperties: true
22649
+ id:
22650
+ type: string
22651
+ email:
22652
+ type: string
22653
+ name:
22654
+ type: string
22655
+ admin:
22656
+ type: boolean
22657
+ SL:
22658
+ type: object
22659
+ additionalProperties: true
22660
+ publicPortalUrl:
22661
+ type: string
22662
+ size:
22663
+ type: string
22664
+ enum:
22665
+ - compact
22666
+ - standard
22667
+ - large
22668
+ lang:
22669
+ type: string
22670
+ translations:
22671
+ type: object
22672
+ additionalProperties:
22673
+ type: string
22674
+ required:
22675
+ - collectionId
22676
+ - appId
22677
+ - SL
22547
22678
  AppConfigOptions:
22548
22679
  type: object
22549
22680
  properties:
@@ -369,23 +369,116 @@ export interface RelatedResponse {
369
369
  records: AppRecord[];
370
370
  }
371
371
  /**
372
- * Public create policy configuration
372
+ * Top-level public-create policy stored under the `publicCreate` key of an
373
+ * app config document. Controls which caller types may create objects on
374
+ * **public** App Objects endpoints.
375
+ *
376
+ * Set via `POST /api/v1/admin/collection/:collectionId/apps/:appId` with the
377
+ * policy as the request body (merged over any existing config).
378
+ *
379
+ * The server reads this document at request time — no cache invalidation or
380
+ * service restart is required after changing it.
373
381
  */
374
382
  export interface PublicCreatePolicy {
375
- cases?: PublicCreateRule;
376
- threads?: PublicCreateRule;
377
- records?: PublicCreateRule;
383
+ cases?: PublicCreateObjectRule;
384
+ threads?: PublicCreateObjectRule;
385
+ records?: PublicCreateObjectRule;
378
386
  }
379
387
  /**
380
- * Rule for public create operations
388
+ * Per-object-type rule within a {@link PublicCreatePolicy}.
389
+ * Each caller class (`anonymous`, `authenticated`) has its own independent
390
+ * branch so you can apply different enforcement for each.
381
391
  */
382
- export interface PublicCreateRule {
383
- allow: {
384
- anonymous?: boolean;
385
- authenticated?: boolean;
386
- };
392
+ export interface PublicCreateObjectRule {
393
+ /** Rules for unauthenticated (anonymous) callers */
394
+ anonymous?: PublicCreateBranch;
395
+ /** Rules for authenticated (signed-in contact) callers */
396
+ authenticated?: PublicCreateBranch;
397
+ }
398
+ /**
399
+ * Policy branch for a single caller class.
400
+ *
401
+ * ### Visibility enforcement guard-rails
402
+ *
403
+ * The server silently corrects misconfigured visibility values:
404
+ *
405
+ * | Caller type | `enforce.visibility` supplied | Server overrides to |
406
+ * |-----------------|-------------------------------|----------------------|
407
+ * | `anonymous` | `'owner'` | `'admin'` |
408
+ * | `authenticated` | `'public'` | `'owner'` |
409
+ *
410
+ * These guards exist because anonymous callers have no identity to own a
411
+ * record, and `'public'` visibility for authenticated-only objects would be
412
+ * a misconfiguration.
413
+ */
414
+ export interface PublicCreateBranch {
415
+ /** Whether creation is permitted for this caller class */
416
+ allow: boolean;
417
+ /**
418
+ * Field values merged **over** the caller's request body before writing.
419
+ * Use this to lock down `visibility` and `status` regardless of what the
420
+ * client sends.
421
+ */
387
422
  enforce?: {
388
- anonymous?: Partial<CreateCaseInput | CreateThreadInput | CreateRecordInput>;
389
- authenticated?: Partial<CreateCaseInput | CreateThreadInput | CreateRecordInput>;
423
+ visibility?: 'public' | 'owner' | 'admin';
424
+ status?: string;
425
+ };
426
+ /**
427
+ * Anonymous edit-token configuration.
428
+ * **Records only** — ignored for cases and threads.
429
+ *
430
+ * When `editToken: true`, the server generates a one-time 256-bit hex token
431
+ * on anonymous record creation, stores it in `admin.editToken` (never
432
+ * exposed to public / owner responses), and returns it **once** in the
433
+ * creation response under the `editToken` key.
434
+ *
435
+ * The client can then pass that token as the `X-Edit-Token` header on
436
+ * `PATCH /records/:recordId` to amend the `data` zone without
437
+ * authentication.
438
+ *
439
+ * @see {@link CreateRecordResponse} — creation response shape
440
+ * @see {@link records.updateWithToken} — SDK method for the amendment call
441
+ */
442
+ edit?: {
443
+ /** Enable edit-token generation on anonymous record creation */
444
+ editToken: boolean;
445
+ /**
446
+ * Optional expiry window in minutes from `createdAt`.
447
+ * After this many minutes the token is rejected with HTTP 403
448
+ * `EDIT_WINDOW_EXPIRED`. Omit for no expiry.
449
+ */
450
+ windowMinutes?: number;
390
451
  };
391
452
  }
453
+ /**
454
+ * Response from `app.records.create()` when the caller is anonymous and the
455
+ * app's `publicCreate.records.anonymous.edit.editToken` policy is `true`.
456
+ *
457
+ * The `editToken` field is present **only on the creation response** — it is
458
+ * stored in the record's `admin` zone and never returned again. Store it
459
+ * client-side immediately.
460
+ *
461
+ * Use `app.records.updateWithToken()` to amend the record's `data` zone with
462
+ * this token.
463
+ *
464
+ * @example
465
+ * ```ts
466
+ * const response = await app.records.create(collectionId, appId, {
467
+ * recordType: 'payment',
468
+ * visibility: 'public',
469
+ * data: { amount: 9900, currency: 'USD' },
470
+ * })
471
+ * // response.editToken is present when the policy has editToken: true
472
+ * const editToken = response.editToken
473
+ * ```
474
+ */
475
+ export interface CreateRecordResponse extends AppRecord {
476
+ /**
477
+ * Short-lived edit token. Present only when:
478
+ * 1. The caller is anonymous, AND
479
+ * 2. The app policy has `publicCreate.records.anonymous.edit.editToken: true`
480
+ *
481
+ * This value is returned **once** and cannot be retrieved again.
482
+ */
483
+ editToken?: string;
484
+ }
@@ -34,3 +34,4 @@ export * from "./appObjects";
34
34
  export * from "./loyalty";
35
35
  export * from "./translations";
36
36
  export * from "./config";
37
+ export * from "./widgets";
@@ -36,3 +36,4 @@ export * from "./appObjects";
36
36
  export * from "./loyalty";
37
37
  export * from "./translations";
38
38
  export * from "./config";
39
+ export * from "./widgets";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Structured navigation request emitted via the `onNavigate` prop when a
3
+ * widget or container needs to navigate the parent platform shell to another
4
+ * app or to a specific deep-link within an app.
5
+ *
6
+ * The portal orchestrator receives this object and performs the navigation
7
+ * while preserving hierarchy context (`collectionId`, `productId`, etc.).
8
+ *
9
+ * Legacy callers may still pass a plain string path; the `onNavigate`
10
+ * signature accepts both. New widgets and containers should always use the
11
+ * structured form.
12
+ */
13
+ export interface NavigationRequest {
14
+ /** Target app ID to activate */
15
+ appId: string;
16
+ /** Deep link / page within the target app (forwarded as `pageId`) */
17
+ deepLink?: string;
18
+ /** Extra URL params forwarded to the target app */
19
+ params?: Record<string, string>;
20
+ /** Optionally switch to a specific product before showing the app */
21
+ productId?: string;
22
+ /** Optionally switch to a specific proof before showing the app */
23
+ proofId?: string;
24
+ }
25
+ /**
26
+ * Standard props received by every SmartLinks widget and container.
27
+ *
28
+ * These are passed by the parent platform (portal shell, OrchestratedPortal,
29
+ * or a custom host) when mounting a widget or container component.
30
+ *
31
+ * **`SL` type note:** at runtime `SL` is the fully-initialised
32
+ * `@proveanything/smartlinks` SDK instance. It is typed as
33
+ * `Record<string, unknown>` here to avoid a circular self-import; cast to
34
+ * a more specific type in your app code if needed.
35
+ */
36
+ export interface SmartLinksWidgetProps {
37
+ /** Collection context — required */
38
+ collectionId: string;
39
+ /** App identifier — required */
40
+ appId: string;
41
+ /** Product context — present when the portal is scoped to a product */
42
+ productId?: string;
43
+ /** Proof (scan/instance) context */
44
+ proofId?: string;
45
+ /** Authenticated user info, if the viewer is logged in */
46
+ user?: {
47
+ id?: string;
48
+ email?: string;
49
+ name?: string;
50
+ admin?: boolean;
51
+ };
52
+ /**
53
+ * Pre-initialised SmartLinks SDK instance provided by the parent platform.
54
+ * At runtime this is `typeof import('@proveanything/smartlinks')`.
55
+ */
56
+ SL: Record<string, unknown>;
57
+ /**
58
+ * Navigation callback. Emit a `NavigationRequest` to ask the parent
59
+ * platform to navigate to another app. A legacy plain-string path is also
60
+ * accepted for backward compatibility.
61
+ */
62
+ onNavigate?: (request: NavigationRequest | string) => void;
63
+ /** Base URL of the full public portal, used for constructing deep links */
64
+ publicPortalUrl?: string;
65
+ /** Responsive size hint */
66
+ size?: 'compact' | 'standard' | 'large';
67
+ /** BCP-47 language code (e.g. `'en'`, `'fr'`) */
68
+ lang?: string;
69
+ /** Translation key overrides */
70
+ translations?: Record<string, string>;
71
+ }
@@ -0,0 +1,2 @@
1
+ // src/types/widgets.ts
2
+ export {};
@@ -114,10 +114,62 @@ export interface ItemStatusCondition extends BaseCondition {
114
114
  type: 'itemStatus';
115
115
  statusType: 'isClaimable' | 'notClaimable' | 'noProof' | 'hasProof' | 'isVirtual' | 'notVirtual';
116
116
  }
117
+ /**
118
+ * Facet-based condition — gates on the facet values assigned to the current product.
119
+ *
120
+ * The `facetKey` identifies which facet dimension to inspect (e.g. `'material'`, `'region'`,
121
+ * `'certifications'`). The optional `values` array lists the value `key`s to test.
122
+ *
123
+ * ### Match modes (`matchMode`)
124
+ *
125
+ * | Mode | Passes when |
126
+ * |------|-------------|
127
+ * | `'any'` (default) | The product has **at least one** of the listed values on this facet |
128
+ * | `'all'` | The product has **every** listed value (multi-value facets only) |
129
+ * | `'none'` | The product has **none** of the listed values |
130
+ * | `'hasFacet'` | The product has **any** value on this facet (ignores `values`) |
131
+ * | `'notHasFacet'` | The product has **no** values on this facet (ignores `values`) |
132
+ *
133
+ * ### Examples
134
+ *
135
+ * ```typescript
136
+ * // Must carry the 'cotton' or 'linen' value on the 'material' facet
137
+ * { type: 'facet', facetKey: 'material', matchMode: 'any', values: ['cotton', 'linen'] }
138
+ *
139
+ * // Must carry BOTH 'organic' and 'recycled' on the 'certifications' facet
140
+ * { type: 'facet', facetKey: 'certifications', matchMode: 'all', values: ['organic', 'recycled'] }
141
+ *
142
+ * // Must NOT carry 'discontinued' on the 'status' facet
143
+ * { type: 'facet', facetKey: 'status', matchMode: 'none', values: ['discontinued'] }
144
+ *
145
+ * // Product must have at least one value on the 'region' facet
146
+ * { type: 'facet', facetKey: 'region', matchMode: 'hasFacet' }
147
+ * ```
148
+ */
149
+ export interface FacetCondition extends BaseCondition {
150
+ type: 'facet';
151
+ /** The facet dimension key to inspect (e.g. `'material'`, `'region'`) */
152
+ facetKey: string;
153
+ /**
154
+ * How to match against `values`.
155
+ * - `'any'` — pass if the product has at least one of the listed values (default)
156
+ * - `'all'` — pass if the product has every listed value
157
+ * - `'none'` — pass if the product has none of the listed values
158
+ * - `'hasFacet'` — pass if the product has any value on this facet (ignores `values`)
159
+ * - `'notHasFacet'` — pass if the product has no values on this facet (ignores `values`)
160
+ */
161
+ matchMode?: 'any' | 'all' | 'none' | 'hasFacet' | 'notHasFacet';
162
+ /**
163
+ * Facet value keys to test against.
164
+ * Required for `'any'`, `'all'`, and `'none'` match modes.
165
+ * Ignored for `'hasFacet'` and `'notHasFacet'`.
166
+ */
167
+ values?: string[];
168
+ }
117
169
  /**
118
170
  * Union of all condition types
119
171
  */
120
- export type Condition = CountryCondition | VersionCondition | DeviceCondition | NestedCondition | UserCondition | ProductCondition | TagCondition | DateCondition | GeofenceCondition | ValueCondition | ItemStatusCondition;
172
+ export type Condition = CountryCondition | VersionCondition | DeviceCondition | NestedCondition | UserCondition | ProductCondition | TagCondition | FacetCondition | DateCondition | GeofenceCondition | ValueCondition | ItemStatusCondition;
121
173
  /**
122
174
  * Condition set that combines multiple conditions
123
175
  */
@@ -166,6 +218,23 @@ export interface UserInfo {
166
218
  export interface ProductInfo {
167
219
  id: string;
168
220
  tags?: Record<string, any>;
221
+ /**
222
+ * Facet values assigned to this product.
223
+ * Shape mirrors `ProductFacetMap`: a map of facet key → array of value objects.
224
+ * Each value object must have at minimum a `key` string property.
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * {
229
+ * material: [{ key: 'cotton', name: 'Cotton' }],
230
+ * certifications: [{ key: 'organic', name: 'Organic' }, { key: 'recycled', name: 'Recycled' }]
231
+ * }
232
+ * ```
233
+ */
234
+ facets?: Record<string, Array<{
235
+ key: string;
236
+ [k: string]: unknown;
237
+ }>>;
169
238
  }
170
239
  /**
171
240
  * Proof information for condition validation
@@ -234,6 +303,7 @@ export interface ConditionDebugOptions {
234
303
  * - **user** - User authentication status (logged in, owner, admin)
235
304
  * - **product** - Product-specific conditions
236
305
  * - **tag** - Product tag-based conditions
306
+ * - **facet** - Product facet-based conditions (any/all/none of specific facet values)
237
307
  * - **date** - Time-based conditions (before, after, between dates)
238
308
  * - **geofence** - Location-based restrictions
239
309
  * - **value** - Custom field comparisons
@@ -93,7 +93,7 @@ function summarizeConditionSet(condition) {
93
93
  return `${(_a = condition.type) !== null && _a !== void 0 ? _a : 'and'} (${(_c = (_b = condition.conditions) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0} conditions)`;
94
94
  }
95
95
  function summarizeCondition(condition) {
96
- var _a, _b;
96
+ var _a, _b, _c, _d, _e;
97
97
  switch (condition.type) {
98
98
  case 'country':
99
99
  return `country regions=${((_a = condition.regions) === null || _a === void 0 ? void 0 : _a.join(',')) || 'none'} countries=${((_b = condition.countries) === null || _b === void 0 ? void 0 : _b.join(',')) || 'none'} contains=${condition.contains}`;
@@ -115,6 +115,11 @@ function summarizeCondition(condition) {
115
115
  return `geofence contains=${condition.contains}`;
116
116
  case 'value':
117
117
  return `value field=${condition.field} ${condition.validationType} ${String(condition.value)}`;
118
+ case 'facet': {
119
+ const mode = (_c = condition.matchMode) !== null && _c !== void 0 ? _c : 'any';
120
+ const vals = (_e = (_d = condition.values) === null || _d === void 0 ? void 0 : _d.join(', ')) !== null && _e !== void 0 ? _e : '—';
121
+ return `facet key=${condition.facetKey} mode=${mode} values=[${vals}]`;
122
+ }
118
123
  case 'itemStatus':
119
124
  return `itemStatus ${condition.statusType}`;
120
125
  default:
@@ -143,6 +148,8 @@ async function evaluateConditionEntry(condition, params) {
143
148
  return validateGeofence(condition, params);
144
149
  case 'value':
145
150
  return validateValue(condition, params);
151
+ case 'facet':
152
+ return validateFacet(condition, params);
146
153
  case 'itemStatus':
147
154
  return validateItemStatus(condition, params);
148
155
  default:
@@ -162,6 +169,7 @@ async function evaluateConditionEntry(condition, params) {
162
169
  * - **user** - User authentication status (logged in, owner, admin)
163
170
  * - **product** - Product-specific conditions
164
171
  * - **tag** - Product tag-based conditions
172
+ * - **facet** - Product facet-based conditions (any/all/none of specific facet values)
165
173
  * - **date** - Time-based conditions (before, after, between dates)
166
174
  * - **geofence** - Location-based restrictions
167
175
  * - **value** - Custom field comparisons
@@ -730,6 +738,82 @@ async function validateValue(condition, params) {
730
738
  },
731
739
  };
732
740
  }
741
+ /**
742
+ * Validate facet-based condition
743
+ */
744
+ async function validateFacet(condition, params) {
745
+ var _a, _b, _c;
746
+ const { facetKey, matchMode = 'any', values = [] } = condition;
747
+ const facets = (_a = params.product) === null || _a === void 0 ? void 0 : _a.facets;
748
+ // No product
749
+ if (!((_b = params.product) === null || _b === void 0 ? void 0 : _b.id)) {
750
+ return {
751
+ passed: false,
752
+ detail: 'Product ID was not available.',
753
+ context: { facetKey, matchMode },
754
+ };
755
+ }
756
+ const assigned = (_c = facets === null || facets === void 0 ? void 0 : facets[facetKey]) !== null && _c !== void 0 ? _c : [];
757
+ const assignedKeys = assigned.map(v => v.key);
758
+ // Presence-only modes — ignore `values`
759
+ if (matchMode === 'hasFacet') {
760
+ return {
761
+ passed: assignedKeys.length > 0,
762
+ detail: `Product ${assigned.length > 0 ? 'has' : 'does not have'} values on facet '${facetKey}'.`,
763
+ context: { facetKey, assignedKeys },
764
+ };
765
+ }
766
+ if (matchMode === 'notHasFacet') {
767
+ return {
768
+ passed: assignedKeys.length === 0,
769
+ detail: `Product ${assigned.length === 0 ? 'has no' : 'has'} values on facet '${facetKey}'.`,
770
+ context: { facetKey, assignedKeys },
771
+ };
772
+ }
773
+ // Value-matching modes require at least one value to test
774
+ if (values.length === 0) {
775
+ return {
776
+ passed: false,
777
+ detail: `Facet condition for '${facetKey}' with matchMode '${matchMode}' requires at least one value.`,
778
+ context: { facetKey, matchMode },
779
+ };
780
+ }
781
+ if (matchMode === 'any') {
782
+ const matched = values.filter(v => assignedKeys.includes(v));
783
+ return {
784
+ passed: matched.length > 0,
785
+ detail: matched.length > 0
786
+ ? `Product matched facet '${facetKey}' value(s): [${matched.join(', ')}].`
787
+ : `Product did not match any of [${values.join(', ')}] on facet '${facetKey}'.`,
788
+ context: { facetKey, matchMode, testedValues: values, matched, assignedKeys },
789
+ };
790
+ }
791
+ if (matchMode === 'all') {
792
+ const missing = values.filter(v => !assignedKeys.includes(v));
793
+ return {
794
+ passed: missing.length === 0,
795
+ detail: missing.length === 0
796
+ ? `Product has all required values on facet '${facetKey}'.`
797
+ : `Product is missing [${missing.join(', ')}] on facet '${facetKey}'.`,
798
+ context: { facetKey, matchMode, testedValues: values, missing, assignedKeys },
799
+ };
800
+ }
801
+ if (matchMode === 'none') {
802
+ const matched = values.filter(v => assignedKeys.includes(v));
803
+ return {
804
+ passed: matched.length === 0,
805
+ detail: matched.length === 0
806
+ ? `Product correctly has none of [${values.join(', ')}] on facet '${facetKey}'.`
807
+ : `Product has forbidden value(s) [${matched.join(', ')}] on facet '${facetKey}'.`,
808
+ context: { facetKey, matchMode, testedValues: values, matched, assignedKeys },
809
+ };
810
+ }
811
+ return {
812
+ passed: false,
813
+ detail: `Unsupported facet matchMode '${matchMode}'.`,
814
+ context: { facetKey, matchMode },
815
+ };
816
+ }
733
817
  /**
734
818
  * Validate item status condition
735
819
  */