@proveanything/smartlinks 1.15.11 → 1.15.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.15.11 | Generated: 2026-07-20T13:46:09.781Z
3
+ Version: 1.15.14 | Generated: 2026-07-29T07:45:17.540Z
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
 
@@ -3093,6 +3104,70 @@ interface AppleLoginOptions {
3093
3104
  * these again, and never inside the token. Forwarded so the server can persist the
3094
3105
  * display name on first account creation. Treated as untrusted (never used for identity).
3095
3106
  userInfo?: { email?: string; name?: string }
3107
+ * A previously-issued trusted-device token (from a prior MFA `challenge/verify`
3108
+ * or `challenge/recovery-code` response). If still valid, the server skips any
3109
+ * step-up challenge for this login. See `SDK_AUTHKIT_MFA_UPDATE.md` §3.
3110
+ trustedDeviceToken?: string
3111
+ }
3112
+ ```
3113
+
3114
+ **MfaRequiredDetails** (interface)
3115
+ ```typescript
3116
+ interface MfaRequiredDetails {
3117
+ mfaSessionToken: string
3118
+ availableFactors: Array<'email' | 'sms'>
3119
+ preferredFactor: 'email' | 'sms'
3120
+ maskedDestinations: { email?: string; sms?: string }
3121
+ }
3122
+ ```
3123
+
3124
+ **MfaChallengeSendResponse** (interface)
3125
+ ```typescript
3126
+ interface MfaChallengeSendResponse {
3127
+ success: true
3128
+ factor: 'email' | 'sms'
3129
+ destination: string
3130
+ expiresAt: string
3131
+ }
3132
+ ```
3133
+
3134
+ **MfaEnrollSendResponse** (interface)
3135
+ ```typescript
3136
+ interface MfaEnrollSendResponse {
3137
+ mfaSessionToken: string
3138
+ destination: string
3139
+ expiresAt: string
3140
+ }
3141
+ ```
3142
+
3143
+ **MfaEnrolledResponse** (interface)
3144
+ ```typescript
3145
+ interface MfaEnrolledResponse {
3146
+ enrolled: true
3147
+ factor: 'email' | 'sms'
3148
+ }
3149
+ ```
3150
+
3151
+ **MfaFactorsResponse** (interface)
3152
+ ```typescript
3153
+ interface MfaFactorsResponse {
3154
+ enrolledFactors: {
3155
+ email?: { enrolledAt: string; destination: string }
3156
+ sms?: { enrolledAt: string; destination: string }
3157
+ }
3158
+ recoveryCodesRemaining: number
3159
+ mfaEnabledForClient: boolean
3160
+ }
3161
+ ```
3162
+
3163
+ **TrustedDevice** (interface)
3164
+ ```typescript
3165
+ interface TrustedDevice {
3166
+ id: string
3167
+ label: string | null
3168
+ createdAtMs: number
3169
+ lastUsedAtMs: number
3170
+ expiresAtMs: number
3096
3171
  }
3097
3172
  ```
3098
3173
 
@@ -3345,6 +3420,8 @@ interface AuthKitConfig {
3345
3420
 
3346
3421
  **AuthKitErrorCode** = ``
3347
3422
 
3423
+ **MfaErrorCode** = ``
3424
+
3348
3425
  **VerifyStatus** = `'pending' | 'verified' | 'failed' | 'expired' | 'unknown'`
3349
3426
 
3350
3427
  ### batch
@@ -3705,6 +3782,21 @@ interface Collection {
3705
3782
  portalUrl?: string // URL for the collection's portal (if applicable)
3706
3783
  allowAutoGenerateClaims?: boolean
3707
3784
  defaultAuthKitId: string // default auth kit for this collection, used for auth
3785
+ admin?: {
3786
+ * Redirect behavior for plain collection-level scans (a short link with
3787
+ * no product/serial code in the path, e.g. `https://.../c/shortId`).
3788
+ * Unset means such links always go to the normal collection page.
3789
+ redirect?: CollectionRedirectConfig
3790
+ }
3791
+ }
3792
+ ```
3793
+
3794
+ **CollectionRedirectConfig** (interface)
3795
+ ```typescript
3796
+ interface CollectionRedirectConfig {
3797
+ mode: 'fixed' | 'dynamic' | 'deep'
3798
+ url?: string
3799
+ template?: string
3708
3800
  }
3709
3801
  ```
3710
3802
 
@@ -8543,20 +8635,20 @@ Gets current account information for the logged in user. Returns user, owner, ac
8543
8635
 
8544
8636
  ### authKit
8545
8637
 
8546
- **login**(clientId: string, email: string, password: string) → `Promise<AuthLoginResponse>`
8547
- Login with email + password (public).
8638
+ **login**(clientId: string, email: string, password: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
8639
+ 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.
8548
8640
 
8549
8641
  **register**(clientId: string, data: { email: string; password: string; displayName?: string; accountData?: Record<string, any> }) → `Promise<AuthLoginResponse>`
8550
- Register a new user (public).
8642
+ Register a new user (public). Not gated by step-up MFA — a brand-new user has no enrolled factors yet, so there's nothing to challenge against.
8551
8643
 
8552
- **googleLogin**(clientId: string, idToken: string) → `Promise<AuthLoginResponse>`
8553
- Google OAuth login via ID token (public).
8644
+ **googleLogin**(clientId: string, idToken: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
8645
+ Google OAuth login via ID token (public). Gated by step-up MFA — see {@link login} for the `MFA_REQUIRED` error shape. {@link mfaChallengeVerify}/{@link mfaRecoveryCode} (with `trustDevice: true`) to skip the challenge on this device, same as {@link login}.
8554
8646
 
8555
8647
  **googleCodeLogin**(clientId: string, code: string, redirectUri: string) → `Promise<AuthLoginResponse>`
8556
8648
  Google OAuth login via server-side authorization code (public).
8557
8649
 
8558
8650
  **appleLogin**(clientId: string, identityToken: string, opts?: AppleLoginOptions) → `Promise<AuthLoginResponse>`
8559
- Sign in with Apple via an Apple identity token (public). Mirrors {@link googleLogin}. On success the returned bearer token is stored automatically and the cache is invalidated. Notable error codes (thrown as `SmartlinksApiError`, read via `err.errorCode`): - `MISSING_APPLE_TOKEN` (400), `APPLE_AUTH_NOT_CONFIGURED` (400), `INVALID_APPLE_TOKEN` (401), `APPLE_AUTH_FAILED` (500) - `ACCOUNT_EXISTS_UNVERIFIED` (409) — an unverified account already owns this email; the server refuses to silently link. `err.details.requiresEmailVerification` is `true`. Recoverable: the user should sign in with their password (or reset it), then link Apple from settings. **The same 409 can now come back from {@link googleLogin}** under the shared verified-to-verified linking policy.
8651
+ Sign in with Apple via an Apple identity token (public). Mirrors {@link googleLogin}. On success the returned bearer token is stored automatically and the cache is invalidated. Notable error codes (thrown as `SmartlinksApiError`, read via `err.errorCode`): - `MISSING_APPLE_TOKEN` (400), `APPLE_AUTH_NOT_CONFIGURED` (400), `INVALID_APPLE_TOKEN` (401), `APPLE_AUTH_FAILED` (500) - `ACCOUNT_EXISTS_UNVERIFIED` (409) — an unverified account already owns this email; the server refuses to silently link. `err.details.requiresEmailVerification` is `true`. Recoverable: the user should sign in with their password (or reset it), then link Apple from settings. **The same 409 can now come back from {@link googleLogin}** under the shared verified-to-verified linking policy. Gated by step-up MFA — see {@link login} for the `MFA_REQUIRED` error shape. Pass `opts.trustedDeviceToken` to skip the challenge on a recognized device.
8560
8652
 
8561
8653
  **refreshToken**(clientId: string, refreshToken: string) → `Promise<RefreshResponse>`
8562
8654
  Exchange a refresh token for a fresh access token (public — the refresh token IS the credential). **Native sessions only**; refresh tokens are issued only when the host opted in via `initializeApi({ platform: 'native' })`. On success the new access token is stored automatically (`setBearerToken`). The returned `refreshToken` is **rotated** — the caller must persist it and discard the old one before refreshing again. ⚠️ **Single-use, no retry, serialize calls.** This method issues exactly one request and never retries: replaying a consumed refresh token triggers `REFRESH_TOKEN_REUSE_DETECTED` (the whole session family is revoked). The caller is responsible for ensuring only one refresh is in flight at a time (e.g. across tabs or resume events). Errors (thrown as `SmartlinksApiError`, read via `err.errorCode`): `MISSING_REFRESH_TOKEN` (400), `INVALID_REFRESH_TOKEN` (401), `REFRESH_TOKEN_REUSE_DETECTED` (401) — the last two mean a hard logout.
@@ -8567,26 +8659,26 @@ Revoke a refresh token's entire family server-side (that device's whole rotation
8567
8659
  **sendMagicLink**(clientId: string, data: { email: string; redirectUrl: string; accountData?: Record<string, any> }) → `Promise<MagicLinkSendResponse>`
8568
8660
  Send a magic link email to the user (public).
8569
8661
 
8570
- **verifyMagicLink**(clientId: string, token: string) → `Promise<MagicLinkVerifyResponse>`
8571
- Verify a magic link token and authenticate/create the user (public).
8662
+ **verifyMagicLink**(clientId: string, token: string, trustedDeviceToken?: string) → `Promise<MagicLinkVerifyResponse>`
8663
+ Verify a magic link token and authenticate/create the user (public). Gated by step-up MFA — see {@link login} for the `MFA_REQUIRED` error shape.
8572
8664
 
8573
8665
  **sendPhoneCode**(clientId: string, phoneNumber: string) → `Promise<PhoneSendCodeResponse>`
8574
8666
  Send phone verification code (public).
8575
8667
 
8576
- **verifyPhoneCode**(clientId: string, phoneNumber: string, code: string) → `Promise<PhoneVerifyResponse>`
8577
- Verify phone verification code (public).
8668
+ **verifyPhoneCode**(clientId: string, phoneNumber: string, code: string, trustedDeviceToken?: string) → `Promise<PhoneVerifyResponse>`
8669
+ Verify phone verification code (public). Gated by step-up MFA — see {@link login} for the `MFA_REQUIRED` error shape.
8578
8670
 
8579
8671
  **sendWhatsApp**(clientId: string, body: SendWhatsAppRequest = {}) → `Promise<SendWhatsAppResponse>`
8580
8672
  Send a WhatsApp verification deep-link (public).
8581
8673
 
8582
8674
  **verifyWhatsApp**(clientId: string, token: string, phoneNumber: string) → `Promise<VerifyWhatsAppResponse>`
8583
- Manually verify WhatsApp token if inbound webhook path is unavailable (legacy/public fallback).
8675
+ Manually verify WhatsApp token if inbound webhook path is unavailable (legacy/public fallback). Not gated by step-up MFA — this endpoint only confirms the code, it never issues a session/bearer token, so there is nothing to challenge. {@link exchangeWhatsAppSession} is the WhatsApp method that's gated.
8584
8676
 
8585
8677
  **getWhatsAppStatus**(clientId: string, token: string) → `Promise<WhatsAppStatusResponse>`
8586
8678
  Poll WhatsApp verification status for a token (public).
8587
8679
 
8588
- **exchangeWhatsAppSession**(clientId: string, token: string, sessionKey: string) → `Promise<ExchangeWhatsAppSessionResponse>`
8589
- Exchange a verified WhatsApp token for an Auth Kit session (public).
8680
+ **exchangeWhatsAppSession**(clientId: string, token: string, sessionKey: string, trustedDeviceToken?: string) → `Promise<ExchangeWhatsAppSessionResponse>`
8681
+ Exchange a verified WhatsApp token for an Auth Kit session (public). Gated by step-up MFA — see {@link login} for the `MFA_REQUIRED` error shape. This is the WhatsApp method that needs `trustedDeviceToken`, not {@link verifyWhatsApp} (which never issues a session).
8590
8682
 
8591
8683
  **sendSmsVerify**(clientId: string, body: SendSmsVerifyRequest) → `Promise<SendSmsVerifyResponse>`
8592
8684
  Send an SMS click-to-verify link (public).
@@ -8636,23 +8728,59 @@ Update the authenticated user's profile and replace the bearer token when refres
8636
8728
  **deleteAccount**(clientId: string, password: string, confirmText: string) → `Promise<SuccessResponse>`
8637
8729
  Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8638
8730
 
8731
+ **mfaChallengeSend**(clientId: string, mfaSessionToken: string, factor: 'email' | 'sms') → `Promise<MfaChallengeSendResponse>`
8732
+ Send (or resend) an MFA challenge code to the given factor (public).
8733
+
8734
+ **mfaChallengeVerify**(clientId: string, mfaSessionToken: string, code: string, trustDevice?: boolean, deviceLabel?: string) → `Promise<MfaFinalizeResponse>`
8735
+ 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.
8736
+
8737
+ **mfaRecoveryCode**(clientId: string, mfaSessionToken: string, code: string, trustDevice?: boolean, deviceLabel?: string) → `Promise<MfaFinalizeResponse>`
8738
+ Finalize a challenged login using a single-use recovery code instead of a sent code (public). Same finalize-session behaviour as {@link mfaChallengeVerify}.
8739
+
8740
+ **getMfaFactors**(clientId: string) → `Promise<MfaFactorsResponse>`
8741
+ List enrolled factors and recovery-code count for the current user (authenticated).
8742
+
8743
+ **enrollEmailMfa**(clientId: string) → `Promise<MfaEnrollSendResponse>`
8744
+ Begin email-factor enrollment; sends a code to the account's existing email (authenticated).
8745
+
8746
+ **confirmEmailMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
8747
+ Confirm email-factor enrollment with the code sent by {@link enrollEmailMfa} (authenticated).
8748
+
8749
+ **enrollSmsMfa**(clientId: string, phoneNumber: string) → `Promise<MfaEnrollSendResponse>`
8750
+ Begin SMS-factor enrollment; sends a code to the given phone number (authenticated).
8751
+
8752
+ **confirmSmsMfa**(clientId: string, mfaSessionToken: string, code: string) → `Promise<MfaEnrolledResponse>`
8753
+ Confirm SMS-factor enrollment with the code sent by {@link enrollSmsMfa} (authenticated).
8754
+
8755
+ **generateMfaRecoveryCodes**(clientId: string, password: string) → `Promise<`
8756
+ 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.
8757
+
8758
+ **removeMfaFactor**(clientId: string, factor: 'email' | 'sms', password: string) → `Promise<SuccessResponse>`
8759
+ Remove an enrolled MFA factor (authenticated). Server route is DELETE with a body.
8760
+
8761
+ **listTrustedDevices**(clientId: string) → `Promise<`
8762
+ List devices trusted to skip MFA challenges for the current user (authenticated).
8763
+
8764
+ **revokeTrustedDevice**(clientId: string, id: string) → `Promise<SuccessResponse>`
8765
+ Revoke a single trusted device by id (authenticated).
8766
+
8639
8767
  **load**(authKitId: string) → `Promise<AuthKitConfig>`
8640
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8768
+ Revoke a single trusted device by id (authenticated).
8641
8769
 
8642
8770
  **get**(collectionId: string, authKitId: string) → `Promise<AuthKitConfig>`
8643
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8771
+ Revoke a single trusted device by id (authenticated).
8644
8772
 
8645
8773
  **list**(collectionId: string, admin?: boolean) → `Promise<AuthKitConfig[]>`
8646
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8774
+ Revoke a single trusted device by id (authenticated).
8647
8775
 
8648
8776
  **create**(collectionId: string, data: any) → `Promise<AuthKitConfig>`
8649
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8777
+ Revoke a single trusted device by id (authenticated).
8650
8778
 
8651
8779
  **update**(collectionId: string, authKitId: string, data: any) → `Promise<AuthKitConfig>`
8652
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8780
+ Revoke a single trusted device by id (authenticated).
8653
8781
 
8654
8782
  **remove**(collectionId: string, authKitId: string) → `Promise<void>`
8655
- Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8783
+ Revoke a single trusted device by id (authenticated).
8656
8784
 
8657
8785
  ### batch
8658
8786
 
@@ -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
  },
@@ -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
 
@@ -226,6 +226,7 @@ const session = await authKit.appleLogin(clientId, appleIdentityToken, {
226
226
  // All optional:
227
227
  nonce, // raw nonce, if you used nonce binding
228
228
  userInfo: { name, email }, // first authorization callback ONLY — Apple never resends it
229
+ trustedDeviceToken, // skip an MFA step-up challenge on a recognized device — see "Step-up MFA" below
229
230
  });
230
231
  // session.isNewUser and session.expiresAt (ms epoch) are populated by this endpoint.
231
232
  ```
@@ -304,6 +305,137 @@ clearPersistedTokens();
304
305
 
305
306
  ---
306
307
 
308
+ ## Step-up MFA (Phase 1)
309
+
310
+ Backend-only for now — **no admin-console UI and no challenge UI ship with this pass.**
311
+ Factors: **email OTP, SMS OTP, recovery codes**. WhatsApp OTP, TOTP, and passkeys as
312
+ *factors* are deferred to a later phase; don't build against them yet.
313
+
314
+ The step-up gate applies to **every login method that issues a session**:
315
+
316
+ | Method | Gated? |
317
+ |---|---|
318
+ | `login()` | ✅ |
319
+ | `googleLogin()` | ✅ |
320
+ | `appleLogin()` | ✅ — pass `opts.trustedDeviceToken` |
321
+ | `verifyPhoneCode()` | ✅ |
322
+ | `verifyMagicLink()` | ✅ |
323
+ | `exchangeWhatsAppSession()` | ✅ — **this is the WhatsApp method that needs a trusted-device token, not `verifyWhatsApp()`** |
324
+ | `verifyWhatsApp()` | ❌ — only confirms the code, never issues a session, so there's nothing to gate |
325
+ | `register()` | ❌ — a brand-new user has no enrolled factors to challenge against |
326
+ | `googleCodeLogin()` | ❌ — no `/auth/google-code` route exists server-side |
327
+
328
+ All six gated methods accept an optional `trustedDeviceToken` param (the last positional
329
+ argument, or `opts.trustedDeviceToken` for `appleLogin()`) — same purpose everywhere: skip
330
+ the challenge on a device the user already verified.
331
+
332
+ ### Handling `MFA_REQUIRED`
333
+
334
+ Every gated method's return type is unchanged. When the client's MFA policy requires a
335
+ step-up, the server returns 403 instead of a session, and the method throws — identically
336
+ for all six:
337
+
338
+ ```ts
339
+ import { authKit, SmartlinksApiError } from '@proveanything/smartlinks';
340
+
341
+ try {
342
+ const session = await authKit.login(clientId, email, password);
343
+ // logged in, no MFA required
344
+ } catch (err) {
345
+ if (err instanceof SmartlinksApiError && err.errorCode === 'MFA_REQUIRED') {
346
+ const { mfaSessionToken, availableFactors, preferredFactor, maskedDestinations } = err.details!;
347
+ // → route to a challenge UI; call authKit.mfaChallengeSend(clientId, mfaSessionToken, preferredFactor)
348
+ } else {
349
+ throw err;
350
+ }
351
+ }
352
+ ```
353
+
354
+ ### Completing the challenge
355
+
356
+ ```ts
357
+ // Send (or resend, or switch factor) a code on the same mfaSessionToken
358
+ const sent = await authKit.mfaChallengeSend(clientId, mfaSessionToken, 'sms');
359
+ // sent.destination is masked, e.g. "+1******1234"
360
+
361
+ // Verify the code — behaves like login() on success: bearer token is adopted automatically
362
+ const session = await authKit.mfaChallengeVerify(clientId, mfaSessionToken, '123456', /* trustDevice */ true, 'My Laptop');
363
+
364
+ // Or finalize with a single-use recovery code instead
365
+ const session = await authKit.mfaRecoveryCode(clientId, mfaSessionToken, recoveryCode);
366
+ ```
367
+
368
+ `mfaSessionToken` is short-lived (10 min) and single-use — burned on 5 failed attempts
369
+ (`MFA_TOO_MANY_ATTEMPTS`) or on success. Once burned/expired, there's no "resend within the
370
+ same session" — the caller must restart from `login()`.
371
+
372
+ ### Trusted devices — "don't ask again on this device"
373
+
374
+ There's no fingerprinting involved: a "recognized device" is purely "the caller presented a
375
+ valid, unexpired, unrevoked `trustedDeviceToken`". Handle it like the native refresh token
376
+ above — same secure storage, same persist-before-next-call discipline:
377
+
378
+ ```ts
379
+ // 1) Persist trustedDeviceToken from a successful challenge (trustDevice: true)
380
+ persistTrustedDeviceToken(session.trustedDeviceToken, session.trustedDeviceExpiresAt);
381
+
382
+ // 2) Send it on every subsequent call to ANY of the six gated methods — if still valid,
383
+ // the challenge is skipped entirely. It isn't tied to which method the user challenged
384
+ // through, so "remember this device" works no matter which login method they pick next.
385
+ const session = await authKit.login(clientId, email, password, storedTrustedDeviceToken);
386
+ const session = await authKit.googleLogin(clientId, idToken, storedTrustedDeviceToken);
387
+ const session = await authKit.exchangeWhatsAppSession(clientId, waToken, sessionKey, storedTrustedDeviceToken);
388
+ // ...and so on for appleLogin (via opts), verifyPhoneCode, verifyMagicLink.
389
+ // If it's revoked/expired, the server silently falls back to requiring a fresh challenge —
390
+ // the method just throws MFA_REQUIRED again, no special-case handling needed.
391
+
392
+ // 3) Let users audit/revoke devices from a Settings screen
393
+ const { devices } = await authKit.listTrustedDevices(clientId);
394
+ await authKit.revokeTrustedDevice(clientId, deviceId);
395
+ ```
396
+
397
+ ### Factor management (Settings → Security)
398
+
399
+ Authenticated (bearer token) — same `mfaSessionToken` + code mechanism as login challenges,
400
+ just against a `purpose: 'enroll'` challenge instead of `'login'`.
401
+
402
+ ```ts
403
+ // Enroll email (uses the account's existing email)
404
+ const { mfaSessionToken } = await authKit.enrollEmailMfa(clientId);
405
+ await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
406
+
407
+ // Enroll SMS
408
+ const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
409
+ await authKit.confirmSmsMfa(clientId, smsToken, code);
410
+
411
+ // Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
412
+ const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
413
+
414
+ // Inspect / remove
415
+ const factors = await authKit.getMfaFactors(clientId);
416
+ // factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
417
+ await authKit.removeMfaFactor(clientId, 'sms', password);
418
+ ```
419
+
420
+ > Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
421
+ > even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
422
+ > at login until an admin turns the mode on (via the existing admin AuthKit config update,
423
+ > no new admin route in this pass).
424
+
425
+ ### MFA error codes
426
+
427
+ | `errorCode` | HTTP | Meaning / client action |
428
+ |---|---|---|
429
+ | `MFA_REQUIRED` | 403 | From any of the six gated login methods — see above |
430
+ | `INVALID_MFA_CODE` | 401 | Wrong code — let the user retry (attempts are capped) |
431
+ | `MFA_TOO_MANY_ATTEMPTS` | 429 | 5 wrong attempts — the challenge is burned; restart from `login()` |
432
+ | `MFA_FACTOR_NOT_ENROLLED` | 400 | Requested factor isn't enrolled — programming error if the UI only offers `availableFactors` |
433
+ | `MFA_SESSION_EXPIRED` | 401 | `mfaSessionToken` past its 10-minute TTL — restart from `login()` |
434
+ | `MFA_SESSION_INVALID` | 401 | Unknown/wrong-client/already-consumed token, or (on enroll confirm) mismatched user |
435
+ | `RECOVERY_CODE_INVALID` | 401 | Wrong or already-used recovery code |
436
+
437
+ ---
438
+
307
439
  ## Profile management
308
440
 
309
441
  ```ts