@proveanything/smartlinks 1.15.6 → 1.15.10

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.
@@ -0,0 +1,2 @@
1
+ // src/types/itemContext.ts
2
+ export {};
@@ -39,3 +39,41 @@ export interface NfcClaimTagRequest {
39
39
  codeId: string;
40
40
  data: Record<string, any>;
41
41
  }
42
+ /**
43
+ * Outcome of validating the NFC/QR tag that brought the visitor to a
44
+ * SmartLinks app.
45
+ *
46
+ * - `valid` — fresh, genuine tap. SUN counter advanced (or a non-consuming
47
+ * lookup succeeded).
48
+ * - `rescan` — genuine tag, but this exact tap was already seen (refresh,
49
+ * back button, or a non-advancing SUN counter).
50
+ * - `invalid` — validation rejected the payload outright (bad crypto,
51
+ * unknown tag, tampered URL) — i.e. a fake/counterfeit tag or serial.
52
+ * - `error` — network/transport failure; authenticity could not be
53
+ * confirmed either way.
54
+ */
55
+ export type TagStatus = 'valid' | 'rescan' | 'invalid' | 'error';
56
+ /**
57
+ * Result of resolving the tag identifiers on the entry URL, handed to
58
+ * sub-apps so they can render status-appropriate UX instead of a generic
59
+ * error. See docs/tag-context.md for the full contract.
60
+ */
61
+ export interface TagContext {
62
+ /** Always present when a `TagContext` is delivered at all. */
63
+ status: TagStatus;
64
+ /** Identifiers, when known. */
65
+ tagId?: string;
66
+ claimSetId?: string;
67
+ codeId?: string;
68
+ /** SUN counter values, present when validation returned them. */
69
+ count?: number;
70
+ previousCount?: number;
71
+ /** Arbitrary tag-level payload returned by validation/lookup. */
72
+ data?: Record<string, unknown>;
73
+ /** How the identifiers were resolved. */
74
+ source: 'nfc-validate' | 'nfc-lookup' | 'tag-index' | 'none';
75
+ /** Epoch ms at resolution time. */
76
+ validatedAt: number;
77
+ /** Human-readable message for `invalid` / `error`. */
78
+ errorMessage?: string;
79
+ }
@@ -1,3 +1,5 @@
1
+ import type { TagContext } from './nfc';
2
+ import type { ItemContext } from './itemContext';
1
3
  /**
2
4
  * Structured navigation request emitted via the `onNavigate` prop when a
3
5
  * widget or container needs to navigate the parent platform shell to another
@@ -62,6 +64,19 @@ export interface SmartLinksWidgetProps {
62
64
  onNavigate?: (request: NavigationRequest | string) => void;
63
65
  /** Base URL of the full public portal, used for constructing deep links */
64
66
  publicPortalUrl?: string;
67
+ /**
68
+ * Authenticity context for the specific item (proof) the URL points at,
69
+ * resolved via an NFC tap or a serial proof URL. `undefined` for
70
+ * collection- and product-only URLs, where there's no item to verify.
71
+ * See docs/item-context.md.
72
+ */
73
+ itemContext?: ItemContext;
74
+ /**
75
+ * @deprecated Use `itemContext.tag` instead. Kept for one release for
76
+ * backward compatibility with scanner-aware apps that read raw NFC/SUN
77
+ * data directly. See docs/item-context.md.
78
+ */
79
+ tag?: TagContext;
65
80
  /** Responsive size hint */
66
81
  size?: 'compact' | 'standard' | 'large';
67
82
  /** BCP-47 language code (e.g. `'en'`, `'fr'`) */
@@ -1,3 +1,4 @@
1
+ import type { ItemContext } from '../types/itemContext';
1
2
  /**
2
3
  * Geographic region definitions for country-based conditions
3
4
  */
@@ -108,11 +109,36 @@ export interface ValueCondition extends BaseCondition {
108
109
  value: string | number | boolean;
109
110
  }
110
111
  /**
111
- * Item status condition
112
+ * Item status condition — one condition type for everything conditions
113
+ * need to know about "the item," so sub-apps don't have to juggle a
114
+ * separate authenticity condition alongside this one.
115
+ *
116
+ * Two families of `statusType`, kept in one enum on purpose:
117
+ * - **Claim/ownership** (unchanged): `isClaimable`, `notClaimable`,
118
+ * `isVirtual`, `notVirtual`, `hasProof`, `noProof`. These read
119
+ * `params.proof` — whether a resolved proof record was supplied at all,
120
+ * and its claim/virtual flags. `noProof` only means "nothing was even
121
+ * attempted"; it does NOT distinguish that from "an identifier was
122
+ * passed but didn't resolve" — see `invalidProof` below for that case.
123
+ * - **Authenticity** (reads `params.itemContext` — see `ItemContext`,
124
+ * docs/item-context.md): `isAuthentic`, `notAuthentic`, `invalidProof`,
125
+ * `isFirstScan`, `isRescan`.
126
+ * - `invalidProof` is specifically "an identifier was passed and resolution
127
+ * was attempted, but it came back invalid or not-found" — the "someone
128
+ * scanned a fake tag / typed a bad serial" case, as opposed to `noProof`
129
+ * ("nothing was on the URL to check at all").
130
+ * - `isAuthentic` is true for both a fresh tap and a rescan — use
131
+ * `isFirstScan` / `isRescan` when the fresh-vs-duplicate distinction
132
+ * matters. `isFirstScan` is the common "this is good, show the full
133
+ * experience" check (authentic AND not seen before, `status === 'valid'`).
134
+ * `isRescan` is authentic but a duplicate/replayed tap
135
+ * (`status === 'rescan'`) — e.g. a page refresh or the back button —
136
+ * for suppressing "first scan" celebration UX without treating the tag
137
+ * as fake.
112
138
  */
113
139
  export interface ItemStatusCondition extends BaseCondition {
114
140
  type: 'itemStatus';
115
- statusType: 'isClaimable' | 'notClaimable' | 'noProof' | 'hasProof' | 'isVirtual' | 'notVirtual';
141
+ statusType: 'isClaimable' | 'notClaimable' | 'noProof' | 'hasProof' | 'isVirtual' | 'notVirtual' | 'isAuthentic' | 'notAuthentic' | 'invalidProof' | 'isFirstScan' | 'isRescan';
116
142
  }
117
143
  /**
118
144
  * Facet-based condition — gates on the facet values assigned to the current product.
@@ -263,6 +289,8 @@ export interface ConditionParams {
263
289
  proof?: ProofInfo;
264
290
  /** Collection information */
265
291
  collection?: CollectionInfo;
292
+ /** Authenticity context for the item (proof) the URL points at, if any */
293
+ itemContext?: ItemContext;
266
294
  /** Statistics/tracking information */
267
295
  stats?: StatsInfo;
268
296
  /** Function to fetch conditions by ID (optional) */
@@ -300,7 +328,9 @@ export interface ConditionDebugOptions {
300
328
  * - **date** - Time-based conditions (before, after, between dates)
301
329
  * - **geofence** - Location-based restrictions
302
330
  * - **value** - Custom field comparisons
303
- * - **itemStatus** - Proof/item status checks (claimable, virtual, etc.)
331
+ * - **itemStatus** - Proof/item status checks: claimable, virtual, presence
332
+ * (`hasProof`/`noProof`), and authenticity (`isAuthentic`/`notAuthentic`/
333
+ * `invalidProof`/`isFirstScan`/`isRescan`)
304
334
  * - **condition** - Nested condition references
305
335
  *
306
336
  * Conditions can be combined with AND or OR logic.
@@ -173,7 +173,9 @@ async function evaluateConditionEntry(condition, params) {
173
173
  * - **date** - Time-based conditions (before, after, between dates)
174
174
  * - **geofence** - Location-based restrictions
175
175
  * - **value** - Custom field comparisons
176
- * - **itemStatus** - Proof/item status checks (claimable, virtual, etc.)
176
+ * - **itemStatus** - Proof/item status checks: claimable, virtual, presence
177
+ * (`hasProof`/`noProof`), and authenticity (`isAuthentic`/`notAuthentic`/
178
+ * `invalidProof`/`isFirstScan`/`isRescan`)
177
179
  * - **condition** - Nested condition references
178
180
  *
179
181
  * Conditions can be combined with AND or OR logic.
@@ -817,6 +819,7 @@ async function validateFacet(condition, params) {
817
819
  * Validate item status condition
818
820
  */
819
821
  async function validateItemStatus(condition, params) {
822
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
820
823
  switch (condition.statusType) {
821
824
  case 'isClaimable':
822
825
  return {
@@ -848,6 +851,36 @@ async function validateItemStatus(condition, params) {
848
851
  passed: !!(params.proof && !params.proof.virtual),
849
852
  detail: 'Checked proof.virtual for falsiness.',
850
853
  };
854
+ case 'isAuthentic':
855
+ return {
856
+ passed: !!((_a = params.itemContext) === null || _a === void 0 ? void 0 : _a.isAuthentic),
857
+ detail: 'Checked itemContext.isAuthentic for truthiness.',
858
+ context: { itemContextStatus: (_b = params.itemContext) === null || _b === void 0 ? void 0 : _b.status },
859
+ };
860
+ case 'notAuthentic':
861
+ return {
862
+ passed: !!(params.itemContext && !params.itemContext.isAuthentic),
863
+ detail: 'Checked that an itemContext was resolved and isAuthentic is false.',
864
+ context: { itemContextStatus: (_c = params.itemContext) === null || _c === void 0 ? void 0 : _c.status },
865
+ };
866
+ case 'invalidProof':
867
+ return {
868
+ passed: ((_d = params.itemContext) === null || _d === void 0 ? void 0 : _d.status) === 'invalid' || ((_e = params.itemContext) === null || _e === void 0 ? void 0 : _e.status) === 'not-found',
869
+ detail: 'Checked that resolution was attempted and came back invalid or not-found (as opposed to noProof, where nothing was attempted at all).',
870
+ context: { itemContextStatus: (_f = params.itemContext) === null || _f === void 0 ? void 0 : _f.status },
871
+ };
872
+ case 'isFirstScan':
873
+ return {
874
+ passed: ((_g = params.itemContext) === null || _g === void 0 ? void 0 : _g.status) === 'valid',
875
+ detail: 'Checked that itemContext is authentic and this is the first time it has been seen (status === valid, not rescan).',
876
+ context: { itemContextStatus: (_h = params.itemContext) === null || _h === void 0 ? void 0 : _h.status },
877
+ };
878
+ case 'isRescan':
879
+ return {
880
+ passed: ((_j = params.itemContext) === null || _j === void 0 ? void 0 : _j.status) === 'rescan' || !!((_k = params.itemContext) === null || _k === void 0 ? void 0 : _k.isRescan),
881
+ detail: 'Checked that itemContext is authentic but a duplicate/replayed tap (status === rescan).',
882
+ context: { itemContextStatus: (_l = params.itemContext) === null || _l === void 0 ? void 0 : _l.status },
883
+ };
851
884
  default:
852
885
  return {
853
886
  passed: false,
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.15.6 | Generated: 2026-07-19T08:21:33.985Z
3
+ Version: 1.15.10 | Generated: 2026-07-19T13:35:07.171Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -1447,7 +1447,6 @@ interface AppConfigSettings {
1447
1447
  addOns?: string[]
1448
1448
  requestedBasePlanId?: string
1449
1449
  itemRecordMode?: 'registered' | 'owned' | null
1450
- virtualItemsEnabled?: boolean
1451
1450
  system?: SystemBlock
1452
1451
  }
1453
1452
  ```
@@ -3698,8 +3697,6 @@ interface Collection {
3698
3697
  secondaryColor?: string
3699
3698
  portalUrl?: string // URL for the collection's portal (if applicable)
3700
3699
  allowAutoGenerateClaims?: boolean
3701
- variants: boolean // does this collection support variants?
3702
- batches: boolean // does this collection support batches?
3703
3700
  defaultAuthKitId: string // default auth kit for this collection, used for auth
3704
3701
  }
3705
3702
  ```
@@ -5881,6 +5878,23 @@ interface InteractionEventContext {
5881
5878
 
5882
5879
  **EffectConfig** = ``
5883
5880
 
5881
+ ### itemContext
5882
+
5883
+ **ItemContext** (interface)
5884
+ ```typescript
5885
+ interface ItemContext {
5886
+ isAuthentic: boolean
5887
+ status: ItemContextStatus
5888
+ source: 'nfc' | 'serial'
5889
+ errorMessage?: string
5890
+ isRescan?: boolean
5891
+ tag?: TagContext
5892
+ checkedAt: number
5893
+ }
5894
+ ```
5895
+
5896
+ **ItemContextStatus** = ``
5897
+
5884
5898
  ### jobs
5885
5899
 
5886
5900
  **Job** (interface)
@@ -6411,6 +6425,24 @@ interface NfcClaimTagRequest {
6411
6425
  }
6412
6426
  ```
6413
6427
 
6428
+ **TagContext** (interface)
6429
+ ```typescript
6430
+ interface TagContext {
6431
+ status: TagStatus
6432
+ tagId?: string
6433
+ claimSetId?: string
6434
+ codeId?: string
6435
+ count?: number
6436
+ previousCount?: number
6437
+ data?: Record<string, unknown>
6438
+ source: 'nfc-validate' | 'nfc-lookup' | 'tag-index' | 'none'
6439
+ validatedAt: number
6440
+ errorMessage?: string
6441
+ }
6442
+ ```
6443
+
6444
+ **TagStatus** = `'valid' | 'rescan' | 'invalid' | 'error'`
6445
+
6414
6446
  ### order
6415
6447
 
6416
6448
  **OrderItem** (interface)
@@ -7695,6 +7727,15 @@ interface SmartLinksWidgetProps {
7695
7727
  * accepted for backward compatibility.
7696
7728
  onNavigate?: (request: NavigationRequest | string) => void
7697
7729
  publicPortalUrl?: string
7730
+ * Authenticity context for the specific item (proof) the URL points at,
7731
+ * resolved via an NFC tap or a serial proof URL. `undefined` for
7732
+ * collection- and product-only URLs, where there's no item to verify.
7733
+ * See docs/item-context.md.
7734
+ itemContext?: ItemContext
7735
+ * @deprecated Use `itemContext.tag` instead. Kept for one release for
7736
+ * backward compatibility with scanner-aware apps that read raw NFC/SUN
7737
+ * data directly. See docs/item-context.md.
7738
+ tag?: TagContext
7698
7739
  size?: 'compact' | 'standard' | 'large'
7699
7740
  lang?: string
7700
7741
  translations?: Record<string, string>
@@ -7864,6 +7905,7 @@ interface ConditionParams {
7864
7905
  product?: ProductInfo
7865
7906
  proof?: ProofInfo
7866
7907
  collection?: CollectionInfo
7908
+ itemContext?: ItemContext
7867
7909
  stats?: StatsInfo
7868
7910
  fetchCondition?: (collectionId: string, conditionId: string) => Promise<ConditionSet | null>
7869
7911
  getLocation?: () => Promise<{ latitude: number; longitude: number }>
package/docs/appConfig.md CHANGED
@@ -36,7 +36,6 @@ export interface AppConfigSettings {
36
36
 
37
37
  /** How the account handles individual physical items. */
38
38
  itemRecordMode?: 'registered' | 'owned' | null;
39
- virtualItemsEnabled?: boolean;
40
39
 
41
40
  // ── SYSTEM-OWNED, PUBLIC (reconcile writes; client read-only) ─────
42
41
  /** Resolved entitlement truth. Read this to gate features. */
@@ -245,6 +244,25 @@ Always resolve flags through `appConfiguration.isFeatureEnabled(collectionId, fl
245
244
  if you already have a `system` block from elsewhere — never read
246
245
  `cfg.system.features[flag]` directly.
247
246
 
247
+ #### 4.4.1 Common feature flags
248
+
249
+ `features` is an open map — any microapp can define its own key — but
250
+ these are the major, cross-cutting flags most integrations will care
251
+ about:
252
+
253
+ | Flag | Gates |
254
+ | -------------- | ---------------------------------------------------------------------- |
255
+ | `analytics` | Analytics surfaces (dashboards, reporting, event exploration). |
256
+ | `crm` | Customer interaction / CRM — contact records, broadcasts, segments. Sub-features hang off the `crm.*` dotted prefix, e.g. `crm.broadcasts`, `crm.segments`. |
257
+ | `hub` | The Hub module. |
258
+ | `portal` | QR code scan portals. |
259
+ | `virtualItems` | Virtual items — algorithmic per-item IDs with no persistent row (battery serials, scan-to-collect points, bulk QR sheets). Replaces the old root-level `virtualItemsEnabled` boolean, which has been removed (§8) — resolve this like any other feature flag, not as a separate config key. Independent of `itemRecordMode`. |
260
+ | `batches` | Batch support. Used to be the `Collection.batches` boolean; that field has been removed — this flag is now the only source of truth. |
261
+ | `variants` | Variant support. Used to be the `Collection.variants` boolean; that field has been removed — this flag is now the only source of truth. |
262
+
263
+ Same resolution rule as any flag (§4.4) — don't read these off
264
+ `cfg.system.features` directly, always go through `isFeatureEnabled()`.
265
+
248
266
  ### 4.5 `meters`
249
267
 
250
268
  Map keyed by add-on key. Apps reporting usage should call the
@@ -407,18 +425,14 @@ with a stale local copy.
407
425
  Clients MUST NOT write directly to `system` or `systemPrivate`.
408
426
  `entitlements-reconcile` merges user-owned fields through untouched.
409
427
 
410
- ## 8. Item handling (`itemRecordMode`, `virtualItemsEnabled`)
428
+ ## 8. Item handling (`itemRecordMode`)
411
429
 
412
- Two **independent** root-level, user-owned keys describing how the
413
- account treats individual physical items. Reconcile never touches
414
- them.
430
+ A root-level, user-owned key describing how the account treats
431
+ individual physical items. Reconcile never touches it.
415
432
 
416
- Three axes (do NOT conflate):
433
+ Two axes (do NOT conflate):
417
434
 
418
- 1. **Virtual items** — `virtualItemsEnabled: boolean`. Algorithmic
419
- IDs, no per-item row. Battery serials, scan-to-collect points,
420
- bulk QR sheets. Can be on with `itemRecordMode` off.
421
- 2. **Item records** — `itemRecordMode`. Materialise a persistent row
435
+ 1. **Item records** — `itemRecordMode`. Materialise a persistent row
422
436
  per physical item. `null` / missing = **disabled** (no separate
423
437
  `"none"` enum — reads collapse to `mode === "off"` via the
424
438
  `useItemRecordMode()` hook).
@@ -426,7 +440,15 @@ Three axes (do NOT conflate):
426
440
  provenance without a customer relationship.
427
441
  - `"owned"` — tag row + owner + interaction history. Requires
428
442
  `basePlanId >= "engage"`. Enables claim / transfer / CRM.
429
- 3. **CRM** — downstream consequence of `owned`; not a peer flag.
443
+ 2. **CRM** — downstream consequence of `owned`; not a peer flag.
444
+
445
+ **Virtual items are not part of this key.** Algorithmic IDs with no
446
+ per-item row (battery serials, scan-to-collect points, bulk QR sheets)
447
+ used to be a separate root-level `virtualItemsEnabled: boolean` — that
448
+ field has been removed. Virtual items are now gated by the `virtualItems`
449
+ feature flag under `system.features` (§4.4.1), same as every other
450
+ feature — resolve it with `isFeatureEnabled(collectionId, 'virtualItems')`.
451
+ It's independent of `itemRecordMode` and can be on with item records off.
430
452
 
431
453
  **Billing:** the usage ledger stamps `tier` at event time from
432
454
  `itemRecordMode`. Writes with `mode === null` MUST be rejected. Mode
@@ -447,46 +469,3 @@ CRM visibility), not just a capability flag.
447
469
  features yet.
448
470
  - `systemOverrides` (old root-level key) → `systemPrivate.overrides`.
449
471
  One-off backfill moves the block and re-runs reconcile.
450
-
451
- ## 10. Change log
452
-
453
- - **2026-07-17**: Initial contract published; `appConfig.system` becomes
454
- canonical entitlements source of truth. `entitlements` group deprecated.
455
- - **2026-07-17**: Added `systemOverrides` for additive comps; reconcile
456
- records `system.appliedOverrides`.
457
- - **2026-07-18**: Renamed `systemOverrides` → `systemPrivate.overrides`
458
- and formalised the three-tier access model (user / system /
459
- systemPrivate). API strips `systemPrivate` for non-admin readers.
460
- Added `system.accountType` derived from
461
- `systemPrivate.overrides.accountType`. Added top-of-doc TypeScript
462
- definition.
463
- - **2026-07-18**: Added SDK helpers `appConfiguration.getAppConfig()`
464
- (typed + cached), `isFeatureEnabled()` (async, cache-backed feature
465
- gate), and `isFeatureEnabledSync()` (cache-only, for callers that can't
466
- await). Renamed the exported TypeScript type to `AppConfigSettings`
467
- to avoid a name collision with the existing per-module `AppConfig`
468
- type in `src/types/collection.ts`.
469
- - **2026-07-18**: `/public/collection/:id/app/config` (`collection.getAppsConfig()`)
470
- now returns the `appConfig` entitlements data (`system`, `addOns`,
471
- `requestedBasePlanId`, `itemRecordMode`, `virtualItemsEnabled`) merged
472
- alongside its existing app-catalog `apps[]`. `appConfiguration.getAppConfig()`
473
- was repointed at this endpoint (dropping its `admin` option — this endpoint
474
- is public-only); the settings-group endpoint (`appConfiguration.getConfig({ appId: 'appConfig' })`)
475
- is still the only way to reach `systemPrivate`. See §3.1 for why the two
476
- endpoints' `apps[]` fields are shaped differently and shouldn't be conflated.
477
- - **2026-07-18**: `system.features` changed from "only truthy flags appear"
478
- to explicit `Record<string, boolean>` overrides. Added the `accountType`
479
- default rule (§4.4): enterprise accounts default every flag to on unless
480
- explicitly `false`; standard accounts default off unless explicitly `true`.
481
- Added `appConfiguration.resolveFeature(system, flag)` — the pure resolver
482
- behind `isFeatureEnabled()`/`isFeatureEnabledSync()`, exposed only for the
483
- rare case of already holding a `system` block from somewhere other than
484
- `getAppConfig()`. There is no separate admin feature-check path — `system`
485
- is public data on every read, so admin surfaces should call
486
- `isFeatureEnabled()` like everything else. Reading `cfg.system.features[flag]`
487
- directly is no longer correct for enterprise accounts — always resolve
488
- through one of these three.
489
- - **2026-07-19**: Corrected flag naming convention (§4.4): flags are
490
- camelCase (e.g. `customDomain`), not snake_case, and may use a dotted
491
- grouping prefix for a topic/module (e.g. `crm.broadcasts`). Updated all
492
- examples in this doc accordingly.
@@ -231,6 +231,8 @@ All standard widget props apply:
231
231
  | `productId` | string | ❌ | Product context |
232
232
  | `proofId` | string | ❌ | Proof context |
233
233
  | `user` | object | ❌ | Current user info |
234
+ | `itemContext` | `ItemContext` | ❌ | Authenticity context for the specific item (proof) the URL points at; `undefined` for collection/product-only URLs — see [item-context.md](item-context.md) |
235
+ | `tag` | `TagContext` | ❌ | **Deprecated** — use `itemContext.tag` instead. Kept for one release. |
234
236
  | `onNavigate` | function | ❌ | Navigation callback (accepts `NavigationRequest` or legacy string) |
235
237
  | `size` | string | ❌ | `"compact"`, `"standard"`, or `"large"` |
236
238
  | `lang` | string | ❌ | Language code (e.g., `"en"`) |
@@ -0,0 +1,184 @@
1
+ # Item Context (container prop)
2
+
3
+ > **Copy this file into `node_modules/@proveanything/smartlinks/docs/item-context.md`** in the published SDK package.
4
+
5
+ When the URL points at a specific item — either a **serial proof URL** or an
6
+ **NFC tap** — the portal derives an `ItemContext` describing what it found
7
+ and hands it to the container as the **`itemContext`** prop.
8
+
9
+ For collection- and product-only URLs there is no item to describe, so
10
+ `itemContext` is `undefined`. Sub-apps that don't care about authenticity
11
+ can simply ignore the prop.
12
+
13
+ The prop is named `itemContext` (not `item`) to make clear it's *context
14
+ about the item*, not the item record itself. The underlying record is
15
+ passed separately via `proof`, `product` and `collection` props.
16
+
17
+ ```tsx
18
+ export default function MyContainer({ itemContext, collection, product, proof }) {
19
+ if (!itemContext) {
20
+ // Collection or product view — nothing to verify.
21
+ return <BrowseView collection={collection} product={product} />;
22
+ }
23
+ if (itemContext.isAuthentic) {
24
+ return <RealContent proof={proof} />;
25
+ }
26
+ if (itemContext.status === 'not-found' || itemContext.status === 'invalid') {
27
+ return <NotGenuine message={itemContext.errorMessage} />;
28
+ }
29
+ // status === 'error' — transport failure
30
+ return <TransientError message={itemContext.errorMessage} />;
31
+ }
32
+ ```
33
+
34
+ ---
35
+
36
+ ## Shape
37
+
38
+ ```ts
39
+ interface ItemContext {
40
+ /** True only for `valid` and `rescan`. Read this for the simple case. */
41
+ isAuthentic: boolean;
42
+
43
+ status:
44
+ | 'valid' // genuine
45
+ | 'rescan' // genuine, but this exact NFC tap has been seen before
46
+ | 'invalid' // SDK rejected the NFC payload (bad crypto)
47
+ | 'not-found' // identifier well-formed but 404
48
+ | 'error'; // transport / network failure — cannot tell
49
+
50
+ source: 'nfc' | 'serial';
51
+
52
+ /** Set for `invalid` / `not-found` / `error`. Safe to display. */
53
+ errorMessage?: string;
54
+
55
+ /** True when SDK reports this exact NFC tap was seen before. */
56
+ isRescan?: boolean;
57
+
58
+ /** Full NFC payload (SUN counter, tagId, claimSetId, codeId, mirror…). Present only when source === 'nfc'. */
59
+ tag?: TagContext;
60
+
61
+ /** Epoch ms when the check was derived. */
62
+ checkedAt: number;
63
+ }
64
+ ```
65
+
66
+ Also exported from `@proveanything/smartlinks` as `ItemContext` /
67
+ `ItemContextStatus` (`src/types/itemContext.ts`). The nested `TagContext`
68
+ shape is unchanged (`src/types/nfc.ts`).
69
+
70
+ ## When `itemContext` is present
71
+
72
+ | URL points at | itemContext |
73
+ | ---------------------------------------------- | -------------------- |
74
+ | Collection root | `undefined` |
75
+ | Product page (no proof) | `undefined` |
76
+ | Serial proof URL (any outcome) | present, source `serial` |
77
+ | NFC tap (any outcome) | present, source `nfc` |
78
+
79
+ ## Status matrix (when present)
80
+
81
+ | Situation | source | status | isAuthentic |
82
+ | ----------------------------------------------------- | -------- | ------------- | :---------: |
83
+ | Fresh NFC tap, SUN counter validated | `nfc` | `valid` | ✅ |
84
+ | Same NFC tap replayed / refresh | `nfc` | `rescan` | ✅ |
85
+ | NFC payload rejected (bad crypto / bad params) | `nfc` | `invalid` | ❌ |
86
+ | Serial proof URL, proof fetched OK | `serial` | `valid` | ✅ |
87
+ | Serial proof URL, proof returned 404 | `serial` | `not-found` | ❌ |
88
+ | Network / 5xx while resolving proof or NFC | (either) | `error` | ❌ |
89
+
90
+ `isAuthentic === (status === 'valid' || status === 'rescan')`.
91
+
92
+ ## How it's delivered
93
+
94
+ **Direct-mounted containers** (default public mode):
95
+
96
+ ```tsx
97
+ type Props = {
98
+ collection: Collection;
99
+ product?: Product;
100
+ proof?: Proof;
101
+ itemContext?: ItemContext; // ← only when URL targets a specific item
102
+ // …plus the normal navigation / theme / auth props
103
+ };
104
+ ```
105
+
106
+ NFC-aware apps that need the raw SUN / tag payload read `itemContext.tag`.
107
+
108
+ **Iframe containers**: the portal serialises `ItemContext` to JSON, base64-
109
+ encodes it (UTF-8 safe) and appends it as an `itemContext` query param on
110
+ the iframe URL — omitted entirely when there is no item context:
111
+
112
+ ```ts
113
+ function readItemContext(): ItemContext | undefined {
114
+ const raw = new URLSearchParams(location.search).get('itemContext');
115
+ if (!raw) return undefined;
116
+ try { return JSON.parse(decodeURIComponent(escape(atob(raw)))); }
117
+ catch { return undefined; }
118
+ }
119
+ ```
120
+
121
+ ## Guarantees
122
+
123
+ - `itemContext` is present on every container mount whose URL targeted an
124
+ item (proof or NFC tap). Absent otherwise.
125
+ - `SL.nfc.validate` is called at most once per `(claimSetId, codeId)` per
126
+ browser session — SUN counters are never double-consumed on refresh.
127
+ - SSR uses `SL.nfc.lookupTag` (non-consuming) only.
128
+ - `isAuthentic === true` implies the SDK either validated an NFC payload
129
+ or returned a full proof document. It is never inferred from URL shape.
130
+ - The portal will **not** render its full-page error surface for a 404 on
131
+ a proof — that surfaces as `status: 'not-found'` on `itemContext`, and
132
+ the container is still mounted so it can present the message its own way.
133
+
134
+ ## What containers should do
135
+
136
+ | Status | Recommended UI |
137
+ | ------------- | ------------------------------------------------------------------ |
138
+ | `valid` | Full experience. Show ownership, actions, etc. |
139
+ | `rescan` | Same as `valid`, optionally with a subtle "seen before" hint. |
140
+ | `invalid` | Clear "this doesn't look genuine" message. Do NOT reveal data. |
141
+ | `not-found` | "We don't recognise this item." Offer a link home. |
142
+ | `error` | Neutral "couldn't verify right now." Offer retry. |
143
+ | _absent_ | Standard browse UI. Don't mention authenticity at all. |
144
+
145
+ ## Gating UX with conditions
146
+
147
+ Sub-apps should standardize on passing `itemContext` into `validateCondition()`
148
+ and gating with `itemStatus` — the same condition type already used for
149
+ claim/virtual checks, extended with authenticity `statusType`s that read
150
+ `itemContext` instead of `proof`:
151
+
152
+ ```ts
153
+ import { validateCondition } from '@proveanything/smartlinks';
154
+
155
+ const showFakeWarning = await validateCondition({
156
+ condition: {
157
+ conditions: [{ type: 'itemStatus', statusType: 'invalidProof' }],
158
+ },
159
+ itemContext, // the prop delivered to the container
160
+ });
161
+ ```
162
+
163
+ - `isAuthentic` — passes when `itemContext.isAuthentic` is true (`valid`/`rescan`)
164
+ - `notAuthentic` — passes when an `itemContext` was resolved but isn't authentic (`invalid`/`not-found`/`error`)
165
+ - `invalidProof` — passes specifically when resolution was attempted and came back `invalid` or `not-found` — the "someone scanned a fake tag / typed a bad serial" case, as distinct from `noProof` ("nothing was on the URL to check at all")
166
+ - `isFirstScan` — the single "this is good, show the full experience" check: authentic AND this is the first time it's been seen (`status === 'valid'`)
167
+ - `isRescan` — authentic but a duplicate/replayed tap (`status === 'rescan'`) — use this to suppress "first scan" celebration UX without treating the tag as fake; `isAuthentic` alone is true for both `isFirstScan` and `isRescan` cases
168
+
169
+ ## Deprecated
170
+
171
+ The old top-level `tag` prop (`TagContext`) is still forwarded for one
172
+ release for backward compatibility with scanner-aware apps that read raw
173
+ SUN data. New code should read `itemContext.tag` and treat `itemContext`
174
+ as the source of truth.
175
+
176
+ ## Note on terminology
177
+
178
+ Today the SDK still uses `proof` for a specific item record (a serialised
179
+ instance of a product). "Item record" is under consideration as a clearer
180
+ name — no API changes are planned imminently, but a future SDK version may
181
+ support both `proof`- and `item record`-shaped names for the same
182
+ functions/types side by side. `itemContext` is deliberately future-proof:
183
+ it describes context about whatever the URL points at, regardless of what
184
+ we ultimately call the record itself.
package/docs/utils.md CHANGED
@@ -485,9 +485,12 @@ await utils.validateCondition({
485
485
  ```
486
486
 
487
487
  #### Item Status Conditions
488
- Check proof/item status:
488
+ Check proof/item status — claim/ownership state, and authenticity of the
489
+ item (proof) the URL points at (via NFC tap or serial proof URL — see
490
+ [item-context.md](item-context.md)):
489
491
 
490
492
  ```typescript
493
+ // Claim/ownership — unchanged, reads `proof`
491
494
  await utils.validateCondition({
492
495
  condition: {
493
496
  type: 'and',
@@ -498,9 +501,38 @@ await utils.validateCondition({
498
501
  },
499
502
  proof: { claimable: true }
500
503
  })
504
+
505
+ // Authenticity — reads `itemContext`
506
+ await utils.validateCondition({
507
+ condition: {
508
+ type: 'and',
509
+ conditions: [{
510
+ type: 'itemStatus',
511
+ statusType: 'invalidProof'
512
+ }]
513
+ },
514
+ itemContext: { isAuthentic: false, status: 'invalid', source: 'nfc', checkedAt: Date.now() }
515
+ })
516
+
517
+ // Fresh scan vs. duplicate/replayed scan — both authentic, different UX
518
+ await utils.validateCondition({
519
+ condition: {
520
+ type: 'and',
521
+ conditions: [{
522
+ type: 'itemStatus',
523
+ statusType: 'isRescan'
524
+ }]
525
+ },
526
+ itemContext: { isAuthentic: true, status: 'rescan', source: 'nfc', checkedAt: Date.now() }
527
+ })
501
528
  ```
502
529
 
503
- Status types: `'isClaimable'`, `'notClaimable'`, `'noProof'`, `'hasProof'`, `'isVirtual'`, `'notVirtual'`
530
+ Status types:
531
+ - Claim/ownership (reads `proof`): `'isClaimable'`, `'notClaimable'`, `'isVirtual'`, `'notVirtual'`
532
+ - Presence (reads `proof`): `'hasProof'`, `'noProof'` — `noProof` only means *nothing was attempted*, not that an attempt failed
533
+ - Authenticity (reads `itemContext`): `'isAuthentic'`, `'notAuthentic'`, `'invalidProof'`, `'isFirstScan'`, `'isRescan'`
534
+ - `invalidProof` is specifically "an identifier was passed and resolution came back `invalid`/`not-found`", distinct from `noProof`'s "nothing on the URL to check at all"
535
+ - `isAuthentic` is true for both a fresh tap and a rescan. Use `isFirstScan` (authentic AND `status === 'valid'`) as the single "this is good, show the full experience" check, and `isRescan` (authentic but `status === 'rescan'`) to suppress "first scan" celebration UX without treating the tag as fake
504
536
 
505
537
  #### Version Conditions
506
538
  For A/B testing or versioned content:
@@ -720,6 +752,7 @@ See [examples/utils-demo.ts](../examples/utils-demo.ts) for comprehensive exampl
720
752
  - **[API Summary](API_SUMMARY.md)** - Complete SDK reference with all namespaces and functions
721
753
  - **[QR Codes](API_SUMMARY.md#qr)** - QR code lookup functions that work with generated paths
722
754
  - **[NFC](API_SUMMARY.md#nfc)** - NFC tag claiming and validation
755
+ - **[Item Context](item-context.md)** - `itemContext` prop delivered to containers describing the item (proof) the URL points at and whether it's authentic
723
756
  - **[Collections](API_SUMMARY.md#collection)** - Collection management functions
724
757
  - **[Products](API_SUMMARY.md#product)** - Product CRUD operations
725
758
  - **[Batches](API_SUMMARY.md#batch)** - Batch management