@proveanything/smartlinks 1.15.10 → 1.15.13

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.
@@ -69,9 +69,25 @@ export interface AppliedOverridesSummary {
69
69
  accountType?: boolean;
70
70
  note?: string;
71
71
  }
72
+ /**
73
+ * Capability ladder for `system.productMode`, low → high. See
74
+ * docs/appConfig.md §4.1 for what each tier unlocks. Not exhaustive going
75
+ * forward — `SystemBlock.productMode` accepts this union for autocomplete
76
+ * but also any other string, since future tiers will show up as new values
77
+ * before this type is updated to know about them.
78
+ */
79
+ export type ProductMode = 'simple_redirect' | 'rich_redirect' | 'inform' | 'engage';
72
80
  /** Resolved entitlements — written only by `entitlements-reconcile`, safe for every client to read. */
73
81
  export interface SystemBlock {
82
+ /** Internal billing/plan identifier — see docs/appConfig.md §4.1. Not a capability signal; use `productMode` for that. */
74
83
  basePlanId?: string;
84
+ /**
85
+ * Stable capability tier microapps should branch on instead of
86
+ * `basePlanId` — see docs/appConfig.md §4.1. Known tiers are `ProductMode`;
87
+ * an unrecognised value is a future tier your code doesn't know about yet
88
+ * — fail closed to the nearest tier you do understand rather than erroring.
89
+ */
90
+ productMode?: ProductMode | (string & {});
75
91
  addOnKeys?: string[];
76
92
  /** Apps unlocked by add-ons. */
77
93
  apps?: string[];
@@ -120,6 +120,96 @@ export interface AppleLoginOptions {
120
120
  * in `SmartlinksApiError.details`.
121
121
  */
122
122
  export type AuthKitErrorCode = 'MISSING_APPLE_TOKEN' | 'APPLE_AUTH_NOT_CONFIGURED' | 'INVALID_APPLE_TOKEN' | 'ACCOUNT_EXISTS_UNVERIFIED' | 'APPLE_AUTH_FAILED';
123
+ /**
124
+ * Details carried on the `MFA_REQUIRED` error thrown by {@link authKit.login} when the
125
+ * client's MFA policy requires a step-up. Surfaced as `err.details` (or
126
+ * `err.errorResponse.details`) on the `SmartlinksApiError` — `login()` never resolves in
127
+ * this case, it throws a 403 instead.
128
+ */
129
+ export interface MfaRequiredDetails {
130
+ /** Short-lived (10 min) and single-use. Pass it to every challenge/finalize call below. */
131
+ mfaSessionToken: string;
132
+ /** Only factors the user actually enrolled. */
133
+ availableFactors: Array<'email' | 'sms'>;
134
+ /** `sms` is preferred over `email` when both are enrolled. */
135
+ preferredFactor: 'email' | 'sms';
136
+ maskedDestinations: {
137
+ email?: string;
138
+ sms?: string;
139
+ };
140
+ }
141
+ /** Response from {@link authKit.mfaChallengeSend}. */
142
+ export interface MfaChallengeSendResponse {
143
+ success: true;
144
+ factor: 'email' | 'sms';
145
+ /** Masked, e.g. `"j***@x.com"` or `"+1******1234"`. */
146
+ destination: string;
147
+ /** ISO timestamp, 10 minutes from send. */
148
+ expiresAt: string;
149
+ }
150
+ /**
151
+ * Response from {@link authKit.mfaChallengeVerify} / {@link authKit.mfaRecoveryCode}.
152
+ * Same shape as a normal login response, plus trusted-device fields when the caller sent
153
+ * `trustDevice: true`.
154
+ */
155
+ export interface MfaFinalizeResponse extends AuthLoginResponse {
156
+ /**
157
+ * Present only when the challenge was completed with `trustDevice: true`. Persist it
158
+ * alongside the refresh token (same secure storage) and send it as `trustedDeviceToken`
159
+ * on future {@link authKit.login} calls to skip the challenge on this device.
160
+ */
161
+ trustedDeviceToken?: string;
162
+ trustedDeviceExpiresAt?: string;
163
+ }
164
+ /** Response from {@link authKit.enrollEmailMfa} / {@link authKit.enrollSmsMfa}. */
165
+ export interface MfaEnrollSendResponse {
166
+ mfaSessionToken: string;
167
+ destination: string;
168
+ expiresAt: string;
169
+ }
170
+ /** Response from {@link authKit.confirmEmailMfa} / {@link authKit.confirmSmsMfa}. */
171
+ export interface MfaEnrolledResponse {
172
+ enrolled: true;
173
+ factor: 'email' | 'sms';
174
+ }
175
+ /** Response from {@link authKit.getMfaFactors}. */
176
+ export interface MfaFactorsResponse {
177
+ enrolledFactors: {
178
+ email?: {
179
+ enrolledAt: string;
180
+ destination: string;
181
+ };
182
+ sms?: {
183
+ enrolledAt: string;
184
+ destination: string;
185
+ };
186
+ };
187
+ recoveryCodesRemaining: number;
188
+ mfaEnabledForClient: boolean;
189
+ }
190
+ /** One entry from {@link authKit.listTrustedDevices}. */
191
+ export interface TrustedDevice {
192
+ id: string;
193
+ label: string | null;
194
+ createdAtMs: number;
195
+ lastUsedAtMs: number;
196
+ expiresAtMs: number;
197
+ }
198
+ /**
199
+ * Server-defined error codes for the step-up MFA flow, surfaced via
200
+ * `SmartlinksApiError.errorCode`.
201
+ *
202
+ * - `MFA_REQUIRED` (403) — from {@link authKit.login} only; see {@link MfaRequiredDetails}.
203
+ * - `INVALID_MFA_CODE` (401) — wrong code; let the user retry (attempts are capped, see next).
204
+ * - `MFA_TOO_MANY_ATTEMPTS` (429) — 5 wrong attempts; the challenge is burned, restart from `login()`.
205
+ * - `MFA_FACTOR_NOT_ENROLLED` (400) — requested factor isn't enrolled (or disabled for the
206
+ * client) — a programming error if the UI only offers `availableFactors`.
207
+ * - `MFA_SESSION_EXPIRED` (401) — `mfaSessionToken` past its 10-minute TTL; restart from `login()`.
208
+ * - `MFA_SESSION_INVALID` (401) — unknown/wrong-client/already-consumed token, or (on enroll
209
+ * confirm) mismatched user; restart the enroll/challenge flow.
210
+ * - `RECOVERY_CODE_INVALID` (401) — wrong or already-used recovery code.
211
+ */
212
+ export type MfaErrorCode = 'MFA_REQUIRED' | 'INVALID_MFA_CODE' | 'MFA_TOO_MANY_ATTEMPTS' | 'MFA_FACTOR_NOT_ENROLLED' | 'MFA_SESSION_EXPIRED' | 'MFA_SESSION_INVALID' | 'RECOVERY_CODE_INVALID';
123
213
  export interface MagicLinkSendResponse {
124
214
  success: boolean;
125
215
  message: string;
@@ -122,23 +122,19 @@ export interface ValueCondition extends BaseCondition {
122
122
  * passed but didn't resolve" — see `invalidProof` below for that case.
123
123
  * - **Authenticity** (reads `params.itemContext` — see `ItemContext`,
124
124
  * docs/item-context.md): `isAuthentic`, `notAuthentic`, `invalidProof`,
125
- * `isFirstScan`, `isRescan`.
125
+ * `isRescan`.
126
126
  * - `invalidProof` is specifically "an identifier was passed and resolution
127
127
  * was attempted, but it came back invalid or not-found" — the "someone
128
128
  * scanned a fake tag / typed a bad serial" case, as opposed to `noProof`
129
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.
130
+ * - `isAuthentic` is true for both a fresh tap and a rescan. `isRescan` is
131
+ * authentic but a duplicate/replayed tap (`status === 'rescan'`) e.g.
132
+ * a page refresh or the back button for suppressing "first scan"
133
+ * celebration UX without treating the tag as fake.
138
134
  */
139
135
  export interface ItemStatusCondition extends BaseCondition {
140
136
  type: 'itemStatus';
141
- statusType: 'isClaimable' | 'notClaimable' | 'noProof' | 'hasProof' | 'isVirtual' | 'notVirtual' | 'isAuthentic' | 'notAuthentic' | 'invalidProof' | 'isFirstScan' | 'isRescan';
137
+ statusType: 'isClaimable' | 'notClaimable' | 'noProof' | 'hasProof' | 'isVirtual' | 'notVirtual' | 'isAuthentic' | 'notAuthentic' | 'invalidProof' | 'isRescan';
142
138
  }
143
139
  /**
144
140
  * Facet-based condition — gates on the facet values assigned to the current product.
@@ -330,7 +326,7 @@ export interface ConditionDebugOptions {
330
326
  * - **value** - Custom field comparisons
331
327
  * - **itemStatus** - Proof/item status checks: claimable, virtual, presence
332
328
  * (`hasProof`/`noProof`), and authenticity (`isAuthentic`/`notAuthentic`/
333
- * `invalidProof`/`isFirstScan`/`isRescan`)
329
+ * `invalidProof`/`isRescan`)
334
330
  * - **condition** - Nested condition references
335
331
  *
336
332
  * Conditions can be combined with AND or OR logic.
@@ -175,7 +175,7 @@ async function evaluateConditionEntry(condition, params) {
175
175
  * - **value** - Custom field comparisons
176
176
  * - **itemStatus** - Proof/item status checks: claimable, virtual, presence
177
177
  * (`hasProof`/`noProof`), and authenticity (`isAuthentic`/`notAuthentic`/
178
- * `invalidProof`/`isFirstScan`/`isRescan`)
178
+ * `invalidProof`/`isRescan`)
179
179
  * - **condition** - Nested condition references
180
180
  *
181
181
  * Conditions can be combined with AND or OR logic.
@@ -819,7 +819,7 @@ async function validateFacet(condition, params) {
819
819
  * Validate item status condition
820
820
  */
821
821
  async function validateItemStatus(condition, params) {
822
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
822
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
823
823
  switch (condition.statusType) {
824
824
  case 'isClaimable':
825
825
  return {
@@ -869,17 +869,11 @@ async function validateItemStatus(condition, params) {
869
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
870
  context: { itemContextStatus: (_f = params.itemContext) === null || _f === void 0 ? void 0 : _f.status },
871
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
872
  case 'isRescan':
879
873
  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),
874
+ passed: ((_g = params.itemContext) === null || _g === void 0 ? void 0 : _g.status) === 'rescan' || !!((_h = params.itemContext) === null || _h === void 0 ? void 0 : _h.isRescan),
881
875
  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 },
876
+ context: { itemContextStatus: (_j = params.itemContext) === null || _j === void 0 ? void 0 : _j.status },
883
877
  };
884
878
  default:
885
879
  return {
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.15.10 | Generated: 2026-07-19T13:35:07.171Z
3
+ Version: 1.15.13 | Generated: 2026-07-27T08:21:16.504Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -1139,6 +1139,17 @@ interface AnalyticsFilterRequest {
1139
1139
  claimIds?: string[]
1140
1140
  isAdmin?: boolean
1141
1141
  hasLocation?: boolean
1142
+ * Filter web-events rows by the `source` column (list-match). Web-events
1143
+ * only - has no effect on `source: 'tag'` queries.
1144
+ *
1145
+ * There is deliberately no singular `source` filter: the request's own
1146
+ * top-level `source` field (`'events'` vs `'tag'`) already owns that name
1147
+ * as the table selector and predates this column - same word, two
1148
+ * different things. Use a single-element array (`sources: ['portal']`)
1149
+ * for an exact-match filter.
1150
+ sources?: string[]
1151
+ redirectMode?: string
1152
+ redirectModes?: string[]
1142
1153
  }
1143
1154
  ```
1144
1155
 
@@ -1419,6 +1430,11 @@ interface AppliedOverridesSummary {
1419
1430
  ```typescript
1420
1431
  interface SystemBlock {
1421
1432
  basePlanId?: string
1433
+ * Stable capability tier microapps should branch on instead of
1434
+ * `basePlanId` — see docs/appConfig.md §4.1. Known tiers are `ProductMode`;
1435
+ * an unrecognised value is a future tier your code doesn't know about yet
1436
+ * — fail closed to the nearest tier you do understand rather than erroring.
1437
+ productMode?: ProductMode | (string & {})
1422
1438
  addOnKeys?: string[]
1423
1439
  apps?: string[]
1424
1440
  * Explicit overrides only — an absent key is NOT "off". Resolve with
@@ -1451,6 +1467,8 @@ interface AppConfigSettings {
1451
1467
  }
1452
1468
  ```
1453
1469
 
1470
+ **ProductMode** = `'simple_redirect' | 'rich_redirect' | 'inform' | 'engage'`
1471
+
1454
1472
  ### appManifest
1455
1473
 
1456
1474
  **AppBundle** (interface)
@@ -3089,6 +3107,66 @@ interface AppleLoginOptions {
3089
3107
  }
3090
3108
  ```
3091
3109
 
3110
+ **MfaRequiredDetails** (interface)
3111
+ ```typescript
3112
+ interface MfaRequiredDetails {
3113
+ mfaSessionToken: string
3114
+ availableFactors: Array<'email' | 'sms'>
3115
+ preferredFactor: 'email' | 'sms'
3116
+ maskedDestinations: { email?: string; sms?: string }
3117
+ }
3118
+ ```
3119
+
3120
+ **MfaChallengeSendResponse** (interface)
3121
+ ```typescript
3122
+ interface MfaChallengeSendResponse {
3123
+ success: true
3124
+ factor: 'email' | 'sms'
3125
+ destination: string
3126
+ expiresAt: string
3127
+ }
3128
+ ```
3129
+
3130
+ **MfaEnrollSendResponse** (interface)
3131
+ ```typescript
3132
+ interface MfaEnrollSendResponse {
3133
+ mfaSessionToken: string
3134
+ destination: string
3135
+ expiresAt: string
3136
+ }
3137
+ ```
3138
+
3139
+ **MfaEnrolledResponse** (interface)
3140
+ ```typescript
3141
+ interface MfaEnrolledResponse {
3142
+ enrolled: true
3143
+ factor: 'email' | 'sms'
3144
+ }
3145
+ ```
3146
+
3147
+ **MfaFactorsResponse** (interface)
3148
+ ```typescript
3149
+ interface MfaFactorsResponse {
3150
+ enrolledFactors: {
3151
+ email?: { enrolledAt: string; destination: string }
3152
+ sms?: { enrolledAt: string; destination: string }
3153
+ }
3154
+ recoveryCodesRemaining: number
3155
+ mfaEnabledForClient: boolean
3156
+ }
3157
+ ```
3158
+
3159
+ **TrustedDevice** (interface)
3160
+ ```typescript
3161
+ interface TrustedDevice {
3162
+ id: string
3163
+ label: string | null
3164
+ createdAtMs: number
3165
+ lastUsedAtMs: number
3166
+ expiresAtMs: number
3167
+ }
3168
+ ```
3169
+
3092
3170
  **MagicLinkSendResponse** (interface)
3093
3171
  ```typescript
3094
3172
  interface MagicLinkSendResponse {
@@ -3338,6 +3416,8 @@ interface AuthKitConfig {
3338
3416
 
3339
3417
  **AuthKitErrorCode** = ``
3340
3418
 
3419
+ **MfaErrorCode** = ``
3420
+
3341
3421
  **VerifyStatus** = `'pending' | 'verified' | 'failed' | 'expired' | 'unknown'`
3342
3422
 
3343
3423
  ### batch
@@ -8536,8 +8616,8 @@ Gets current account information for the logged in user. Returns user, owner, ac
8536
8616
 
8537
8617
  ### authKit
8538
8618
 
8539
- **login**(clientId: string, email: string, password: string) → `Promise<AuthLoginResponse>`
8540
- Login with email + password (public).
8619
+ **login**(clientId: string, email: string, password: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
8620
+ Login with email + password (public). When the client's MFA policy requires a step-up, the server returns **403 `MFA_REQUIRED`** instead of a session — `login()` throws a `SmartlinksApiError` with `err.errorResponse?.errorCode === 'MFA_REQUIRED'` and the challenge details in `err.details` (see {@link MfaRequiredDetails}). Route the caller to {@link mfaChallengeSend} on that error; this method's return type is unchanged. returned one (via {@link mfaChallengeVerify}/{@link mfaRecoveryCode} with `trustDevice: true`), pass it here to skip the challenge entirely as long as it's still valid. If it's revoked/expired, the server silently falls back to requiring a fresh challenge — `login()` just returns `MFA_REQUIRED` again, no special handling.
8541
8621
 
8542
8622
  **register**(clientId: string, data: { email: string; password: string; displayName?: string; accountData?: Record<string, any> }) → `Promise<AuthLoginResponse>`
8543
8623
  Register a new user (public).
@@ -8629,23 +8709,59 @@ Update the authenticated user's profile and replace the bearer token when refres
8629
8709
  **deleteAccount**(clientId: string, password: string, confirmText: string) → `Promise<SuccessResponse>`
8630
8710
  Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8631
8711
 
8712
+ **mfaChallengeSend**(clientId: string, mfaSessionToken: string, factor: 'email' | 'sms') → `Promise<MfaChallengeSendResponse>`
8713
+ Send (or resend) an MFA challenge code to the given factor (public).
8714
+
8715
+ **mfaChallengeVerify**(clientId: string, mfaSessionToken: string, code: string, trustDevice?: boolean, deviceLabel?: string) → `Promise<MfaFinalizeResponse>`
8716
+ Verify an MFA challenge code and finalize the login (public). On success this behaves like {@link login} — the bearer token is adopted and the cache invalidated. it and pass it to future {@link login} calls to skip the challenge on this device.
8717
+
8718
+ **mfaRecoveryCode**(clientId: string, mfaSessionToken: string, code: string, trustDevice?: boolean, deviceLabel?: string) → `Promise<MfaFinalizeResponse>`
8719
+ Finalize a challenged login using a single-use recovery code instead of a sent code (public). Same finalize-session behaviour as {@link mfaChallengeVerify}.
8720
+
8721
+ **getMfaFactors**(clientId: string) → `Promise<MfaFactorsResponse>`
8722
+ List enrolled factors and recovery-code count for the current user (authenticated).
8723
+
8724
+ **enrollEmailMfa**(clientId: string) → `Promise<MfaEnrollSendResponse>`
8725
+ Begin email-factor enrollment; sends a code to the account's existing email (authenticated).
8726
+
8727
+ **confirmEmailMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
8728
+ Confirm email-factor enrollment with the code sent by {@link enrollEmailMfa} (authenticated).
8729
+
8730
+ **enrollSmsMfa**(clientId: string, phoneNumber: string) → `Promise<MfaEnrollSendResponse>`
8731
+ Begin SMS-factor enrollment; sends a code to the given phone number (authenticated).
8732
+
8733
+ **confirmSmsMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
8734
+ Confirm SMS-factor enrollment with the code sent by {@link enrollSmsMfa} (authenticated).
8735
+
8736
+ **generateMfaRecoveryCodes**(clientId: string, password: string) → `Promise<`
8737
+ Generate a fresh set of recovery codes, invalidating any previous set (authenticated). Returned **in plaintext, exactly once** — nothing else in the API ever returns them again, so the caller must display/export them immediately.
8738
+
8739
+ **removeMfaFactor**(clientId: string, factor: 'email' | 'sms', password: string) → `Promise<SuccessResponse>`
8740
+ Remove an enrolled MFA factor (authenticated). Server route is DELETE with a body.
8741
+
8742
+ **listTrustedDevices**(clientId: string) → `Promise<`
8743
+ List devices trusted to skip MFA challenges for the current user (authenticated).
8744
+
8745
+ **revokeTrustedDevice**(clientId: string, id: string) → `Promise<SuccessResponse>`
8746
+ Revoke a single trusted device by id (authenticated).
8747
+
8632
8748
  **load**(authKitId: string) → `Promise<AuthKitConfig>`
8633
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8749
+ Revoke a single trusted device by id (authenticated).
8634
8750
 
8635
8751
  **get**(collectionId: string, authKitId: string) → `Promise<AuthKitConfig>`
8636
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8752
+ Revoke a single trusted device by id (authenticated).
8637
8753
 
8638
8754
  **list**(collectionId: string, admin?: boolean) → `Promise<AuthKitConfig[]>`
8639
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8755
+ Revoke a single trusted device by id (authenticated).
8640
8756
 
8641
8757
  **create**(collectionId: string, data: any) → `Promise<AuthKitConfig>`
8642
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8758
+ Revoke a single trusted device by id (authenticated).
8643
8759
 
8644
8760
  **update**(collectionId: string, authKitId: string, data: any) → `Promise<AuthKitConfig>`
8645
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8761
+ Revoke a single trusted device by id (authenticated).
8646
8762
 
8647
8763
  **remove**(collectionId: string, authKitId: string) → `Promise<void>`
8648
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8764
+ Revoke a single trusted device by id (authenticated).
8649
8765
 
8650
8766
  ### batch
8651
8767
 
@@ -15,6 +15,8 @@ Some of these are now promoted top-level analytics fields. Others remain good me
15
15
  - `entryType`
16
16
  - `pageId`
17
17
  - `scanMethod`
18
+ - `source` - collection/web-events only. Free-form client app identifier, e.g. `'portal'`, `'hub'`. No enum/whitelist. Not available on tag events - see the `analytics.tag.track(event)` section in [docs/analytics.md](analytics.md) for why.
19
+ - `redirectMode` - tag-events only. Usually server-written; only set this yourself if you're logging a redirect-style event.
18
20
 
19
21
  These should be sent as top-level analytics fields, not inside `metadata`.
20
22
 
@@ -59,6 +61,7 @@ These keys give teams a shared vocabulary for:
59
61
  - Send promoted fields at top level.
60
62
  - Keep values flat and scalar where possible so they are easier to filter and break down later.
61
63
  - Promote a field to a first-class backend column only when it becomes a hot platform-wide dimension.
64
+ - Note: `source` (the event column) and the query-time `source` parameter (`'events' | 'tag'`, which table to query) are unrelated fields that happen to share a name. When filtering by the `source` column, use the plural `sources` array - there is no singular `source` filter, precisely to avoid colliding with the table selector.
62
65
 
63
66
  ---
64
67
 
@@ -77,6 +80,7 @@ analytics.collection.track({
77
80
  campaign: 'summer-launch',
78
81
  utmSource: 'email',
79
82
  pageId: 'QR123',
83
+ source: 'portal',
80
84
  metadata: {
81
85
  pagePath: '/c/demo-collection',
82
86
  },
package/docs/analytics.md CHANGED
@@ -48,6 +48,12 @@ The backend stores custom analytics dimensions in `metadata`, but promoted analy
48
48
 
49
49
  See [docs/analytics-metadata-conventions.md](analytics-metadata-conventions.md) for the recommended key set.
50
50
 
51
+ ### A note on server-written events
52
+
53
+ Not every row in these tables is written by your app. As of 2026-07-23, the server itself logs a `scan_redirect` event on the tag-events table at the moment a GS1 digital-link scan, claim short-link scan, or NFC tap decides on a redirect destination - before the client ever loads anything. This is separate from `scan_tag`, which your app still writes on landing exactly as before. `scan_blank_tag` remains excluded from the analytics pipeline.
54
+
55
+ Server-issued redirects also append `?sid=<value>` to the destination URL. If your landing page reads `sid` off the URL and reuses it as its own `sessionId` on the next `analytics.collection.track(...)` or `analytics.tag.track(...)` call, "did the redirect actually land" becomes a free `sessionId` join - nothing new to send, just reuse the value if it's present.
56
+
51
57
  ---
52
58
 
53
59
  ## Quick Start
@@ -67,6 +73,7 @@ analytics.collection.track({
67
73
  appId: 'homepage',
68
74
  path: '/c/demo-collection',
69
75
  deviceType: 'mobile',
76
+ source: 'portal',
70
77
  })
71
78
  ```
72
79
 
@@ -298,7 +305,9 @@ Tracks generic collection analytics events such as:
298
305
  - internal navigation
299
306
  - outbound link activity
300
307
 
301
- Supported top-level fields include the core event fields plus promoted analytics columns such as `visitorId`, `referrerHost`, `pageId`, and `entryType`, along with custom metadata dimensions like `group`, `placement`, `pagePath`, and `qrCodeId`.
308
+ Supported top-level fields include the core event fields plus promoted analytics columns such as `visitorId`, `referrerHost`, `pageId`, `entryType`, and `source`, along with custom metadata dimensions like `group`, `placement`, `pagePath`, and `qrCodeId`.
309
+
310
+ `source` is a free-form string identifying which client app logged the event, e.g. `'portal'`, `'hub'` - there's no enum or whitelist, send whatever identifies your app. It's web-events only; there is no equivalent field on tag events (see the `analytics.tag.track(event)` section below for why).
302
311
 
303
312
  Example:
304
313
 
@@ -321,6 +330,7 @@ analytics.collection.track({
321
330
  group: 'summer-launch',
322
331
  placement: 'hero',
323
332
  pageId: 'QR123',
333
+ source: 'portal',
324
334
  metadata: { pagePath: '/c/demo-collection?pageId=QR123' },
325
335
  })
326
336
  ```
@@ -334,7 +344,11 @@ Tracks physical scan analytics such as:
334
344
  - claim/code activity
335
345
  - admin vs customer scan behavior
336
346
 
337
- Supported top-level fields include the core scan fields plus promoted analytics columns such as `visitorId`, `entryType`, and `scanMethod`, along with custom metadata dimensions like `group`, `tag`, and campaign extras.
347
+ Supported top-level fields include the core scan fields plus promoted analytics columns such as `visitorId`, `entryType`, `scanMethod`, and `redirectMode`, along with custom metadata dimensions like `group`, `tag`, and campaign extras.
348
+
349
+ `redirectMode` is only relevant if you're logging a redirect-style event yourself - in practice it's mostly written by the server automatically (see [A note on server-written events](#a-note-on-server-written-events) above).
350
+
351
+ There is deliberately no `source` field on tag events, even though one exists on collection events. `eventType` already tells you who wrote a tag-events row - `scan_redirect` is always server-written, `scan_tag`/`scan_blank_tag` are always client-written - so a `source` column here would just duplicate that. To segment tag scans by NFC vs. QR, use `entryType`/`scanMethod` instead.
338
352
 
339
353
  Example:
340
354
 
@@ -541,6 +555,24 @@ const countries = await analytics.admin.breakdown('demo-collection', {
541
555
  })
542
556
  ```
543
557
 
558
+ Breaking down or filtering by the new `source` *column* (web-events only) works the same way, just watch the naming: the request's own top-level `source: 'events'` is the pre-existing table selector, and it sits alongside a `sources` array that filters by the column's value. They are unrelated fields that happen to share a name.
559
+
560
+ ```typescript
561
+ const appSources = await analytics.admin.breakdown('demo-collection', {
562
+ source: 'events', // table selector: query web-events
563
+ dimension: 'source', // break down by the `source` column
564
+ metric: 'count',
565
+ })
566
+
567
+ const portalOnly = await analytics.admin.events('demo-collection', {
568
+ source: 'events', // table selector
569
+ sources: ['portal'], // column filter - single-element array for exact match
570
+ limit: 100,
571
+ })
572
+ ```
573
+
574
+ There is no singular `source` filter field for this reason - use `sources: ['portal']` for an exact match.
575
+
544
576
  ### Raw events
545
577
 
546
578
  ```typescript
@@ -588,7 +620,7 @@ For metrics, generic queries support:
588
620
  - `uniqueSessions`
589
621
  - `uniqueVisitors`
590
622
 
591
- Extra collection-event filters include:
623
+ Extra collection-event (web-events) filters include:
592
624
 
593
625
  - `appId`, `appIds[]`
594
626
  - `destinationAppId`, `destinationAppIds[]`
@@ -596,6 +628,7 @@ Extra collection-event filters include:
596
628
  - `href`, `path`
597
629
  - `hrefContains`, `pathContains`
598
630
  - `isExternal`
631
+ - `sources[]` - filter by the `source` column (list-match only, no singular form - see [Breakdown](#breakdown))
599
632
 
600
633
  Extra tag-event filters include:
601
634
 
@@ -603,6 +636,7 @@ Extra tag-event filters include:
603
636
  - `claimId`, `claimIds[]`
604
637
  - `isAdmin`
605
638
  - `hasLocation`
639
+ - `redirectMode`, `redirectModes[]`
606
640
 
607
641
  ---
608
642
 
package/docs/appConfig.md CHANGED
@@ -60,9 +60,15 @@ export interface AppEntry {
60
60
  [k: string]: unknown; // per-module inline settings preserved
61
61
  }
62
62
 
63
+ /** Known `productMode` tiers, low → high — see §4.1. Not exhaustive; unrecognised values are future tiers. */
64
+ export type ProductMode = 'simple_redirect' | 'rich_redirect' | 'inform' | 'engage';
65
+
63
66
  /** Resolved entitlements — safe for every client to read. */
64
67
  export interface SystemBlock {
68
+ /** Internal billing identifier — not a capability signal, see §4. */
65
69
  basePlanId?: string;
70
+ /** Stable capability tier — see §4.1. Prefer this over basePlanId. */
71
+ productMode?: ProductMode | (string & {});
66
72
  addOnKeys?: string[];
67
73
  apps?: string[]; // apps unlocked by add-ons
68
74
  features?: Record<string, boolean>; // explicit overrides only — see §4.4 for absent-key resolution
@@ -184,12 +190,46 @@ Written only by `entitlements-reconcile`. Represents the union of:
184
190
  - every add-on line item on their Stripe subscription
185
191
  - `systemPrivate.overrides` (applied last, always wins)
186
192
 
187
- ### 4.1 `basePlanId`
193
+ `basePlanId` is an internal Stripe billing/plan identifier (string,
194
+ sourced from subscription metadata) — a billing concern, not a capability
195
+ signal. Don't branch app behavior on its literal value or its internal
196
+ plan names; that's what `productMode` is for.
197
+
198
+ ### 4.1 `productMode`
199
+
200
+ Stable capability tier the app should branch on instead of `basePlanId`
201
+ (a billing identity that can change independently of capability). Written
202
+ by `entitlements-reconcile` on every sync. Typed as `ProductMode` in the
203
+ SDK (`src/types/appConfiguration.ts`) — a union of the known tiers below,
204
+ plus any other string, since future tiers will show up as new values
205
+ before the SDK type is updated to know their names.
206
+
207
+ Ladder, low → high:
208
+
209
+ | `productMode` | Unlocks (roughly) |
210
+ | ------------------ | --------------------------------------------------------------------------------- |
211
+ | `simple_redirect` | Redirect-only: scanning a tag/QR sends the user straight to a URL — no product/proof page, no catalog UI. |
212
+ | `rich_redirect` | Adds a rendered product/proof page (image, description, spec data) — still no deeper apps. |
213
+ | `inform` | Adds informational surfaces beyond the basic page — documentation, provenance, facts-style apps. |
214
+ | `engage` | Adds engagement/ownership capabilities — claim, CRM, loyalty, interaction tracking. Top of the ladder today. |
215
+
216
+ > These four one-liners are inferred from the tier names and ladder
217
+ > ordering, not copied from an authoritative source — worth a sanity check
218
+ > against the actual capability gating before treating them as precise.
219
+
220
+ ```ts
221
+ const mode = cfg.system?.productMode;
222
+ if (mode === 'simple_redirect' || mode === 'rich_redirect') {
223
+ // redirect-only surface — hide catalog UI
224
+ }
225
+ ```
226
+
227
+ Unknown values (future tiers your code predates) should fail closed to
228
+ the nearest tier you understand, not throw or assume the top tier.
188
229
 
189
- String id of the tier. Sourced from Stripe subscription metadata
190
- (`basePlanId` or legacy `planId`). Examples: `simple_redirect`,
191
- `rich_redirect`, `inform`, `enrich`, `engage`, `hub_inform`,
192
- `hub_enrich`, `hub_engage`.
230
+ Not a feature flag and not a billing key — per-capability gating still
231
+ goes through `features` (§4.4); Stripe price selection still goes
232
+ through `basePlanId` + add-ons.
193
233
 
194
234
  ### 4.2 `addOnKeys[]`
195
235
 
@@ -253,6 +293,7 @@ about:
253
293
  | Flag | Gates |
254
294
  | -------------- | ---------------------------------------------------------------------- |
255
295
  | `analytics` | Analytics surfaces (dashboards, reporting, event exploration). |
296
+ | `authenticity` | Live authenticity tracking — flags genuine vs. counterfeit items and enables auditing/tracing on them (e.g. impossible-travel detection). |
256
297
  | `crm` | Customer interaction / CRM — contact records, broadcasts, segments. Sub-features hang off the `crm.*` dotted prefix, e.g. `crm.broadcasts`, `crm.segments`. |
257
298
  | `hub` | The Hub module. |
258
299
  | `portal` | QR code scan portals. |