@proveanything/smartlinks 1.15.11 → 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.
@@ -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.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
 
@@ -3096,6 +3107,66 @@ interface AppleLoginOptions {
3096
3107
  }
3097
3108
  ```
3098
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
+
3099
3170
  **MagicLinkSendResponse** (interface)
3100
3171
  ```typescript
3101
3172
  interface MagicLinkSendResponse {
@@ -3345,6 +3416,8 @@ interface AuthKitConfig {
3345
3416
 
3346
3417
  **AuthKitErrorCode** = ``
3347
3418
 
3419
+ **MfaErrorCode** = ``
3420
+
3348
3421
  **VerifyStatus** = `'pending' | 'verified' | 'failed' | 'expired' | 'unknown'`
3349
3422
 
3350
3423
  ### batch
@@ -8543,8 +8616,8 @@ Gets current account information for the logged in user. Returns user, owner, ac
8543
8616
 
8544
8617
  ### authKit
8545
8618
 
8546
- **login**(clientId: string, email: string, password: string) → `Promise<AuthLoginResponse>`
8547
- 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.
8548
8621
 
8549
8622
  **register**(clientId: string, data: { email: string; password: string; displayName?: string; accountData?: Record<string, any> }) → `Promise<AuthLoginResponse>`
8550
8623
  Register a new user (public).
@@ -8636,23 +8709,59 @@ Update the authenticated user's profile and replace the bearer token when refres
8636
8709
  **deleteAccount**(clientId: string, password: string, confirmText: string) → `Promise<SuccessResponse>`
8637
8710
  Update the authenticated user's profile and replace the bearer token when refreshed claims are returned.
8638
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
+
8639
8748
  **load**(authKitId: string) → `Promise<AuthKitConfig>`
8640
- 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).
8641
8750
 
8642
8751
  **get**(collectionId: string, authKitId: string) → `Promise<AuthKitConfig>`
8643
- 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).
8644
8753
 
8645
8754
  **list**(collectionId: string, admin?: boolean) → `Promise<AuthKitConfig[]>`
8646
- 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).
8647
8756
 
8648
8757
  **create**(collectionId: string, data: any) → `Promise<AuthKitConfig>`
8649
- 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).
8650
8759
 
8651
8760
  **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.
8761
+ Revoke a single trusted device by id (authenticated).
8653
8762
 
8654
8763
  **remove**(collectionId: string, authKitId: string) → `Promise<void>`
8655
- 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).
8656
8765
 
8657
8766
  ### batch
8658
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/auth-kit.md CHANGED
@@ -304,6 +304,116 @@ clearPersistedTokens();
304
304
 
305
305
  ---
306
306
 
307
+ ## Step-up MFA (Phase 1)
308
+
309
+ Backend-only for now — **no admin-console UI and no challenge UI ship with this pass.**
310
+ Factors: **email OTP, SMS OTP, recovery codes**. WhatsApp OTP, TOTP, and passkeys are
311
+ deferred to a later phase; don't build against them yet.
312
+
313
+ The step-up gate is wired into **`login()` only** — `register`, `verifyPhoneCode`, and
314
+ `verifyMagicLink` never return `MFA_REQUIRED`, regardless of client config.
315
+
316
+ ### Handling `MFA_REQUIRED` from `login()`
317
+
318
+ `login()`'s return type is unchanged. When the client's MFA policy requires a step-up,
319
+ the server returns 403 instead of a session, and `login()` throws:
320
+
321
+ ```ts
322
+ import { authKit, SmartlinksApiError } from '@proveanything/smartlinks';
323
+
324
+ try {
325
+ const session = await authKit.login(clientId, email, password);
326
+ // logged in, no MFA required
327
+ } catch (err) {
328
+ if (err instanceof SmartlinksApiError && err.errorCode === 'MFA_REQUIRED') {
329
+ const { mfaSessionToken, availableFactors, preferredFactor, maskedDestinations } = err.details!;
330
+ // → route to a challenge UI; call authKit.mfaChallengeSend(clientId, mfaSessionToken, preferredFactor)
331
+ } else {
332
+ throw err;
333
+ }
334
+ }
335
+ ```
336
+
337
+ ### Completing the challenge
338
+
339
+ ```ts
340
+ // Send (or resend, or switch factor) a code on the same mfaSessionToken
341
+ const sent = await authKit.mfaChallengeSend(clientId, mfaSessionToken, 'sms');
342
+ // sent.destination is masked, e.g. "+1******1234"
343
+
344
+ // Verify the code — behaves like login() on success: bearer token is adopted automatically
345
+ const session = await authKit.mfaChallengeVerify(clientId, mfaSessionToken, '123456', /* trustDevice */ true, 'My Laptop');
346
+
347
+ // Or finalize with a single-use recovery code instead
348
+ const session = await authKit.mfaRecoveryCode(clientId, mfaSessionToken, recoveryCode);
349
+ ```
350
+
351
+ `mfaSessionToken` is short-lived (10 min) and single-use — burned on 5 failed attempts
352
+ (`MFA_TOO_MANY_ATTEMPTS`) or on success. Once burned/expired, there's no "resend within the
353
+ same session" — the caller must restart from `login()`.
354
+
355
+ ### Trusted devices — "don't ask again on this device"
356
+
357
+ There's no fingerprinting involved: a "recognized device" is purely "the caller presented a
358
+ valid, unexpired, unrevoked `trustedDeviceToken`". Handle it like the native refresh token
359
+ above — same secure storage, same persist-before-next-call discipline:
360
+
361
+ ```ts
362
+ // 1) Persist trustedDeviceToken from a successful challenge (trustDevice: true)
363
+ persistTrustedDeviceToken(session.trustedDeviceToken, session.trustedDeviceExpiresAt);
364
+
365
+ // 2) Send it on every subsequent login() — if still valid, the challenge is skipped entirely
366
+ const session = await authKit.login(clientId, email, password, storedTrustedDeviceToken);
367
+ // If it's revoked/expired, the server silently falls back to requiring a fresh challenge —
368
+ // login() just throws MFA_REQUIRED again, no special-case handling needed.
369
+
370
+ // 3) Let users audit/revoke devices from a Settings screen
371
+ const { devices } = await authKit.listTrustedDevices(clientId);
372
+ await authKit.revokeTrustedDevice(clientId, deviceId);
373
+ ```
374
+
375
+ ### Factor management (Settings → Security)
376
+
377
+ Authenticated (bearer token) — same `mfaSessionToken` + code mechanism as login challenges,
378
+ just against a `purpose: 'enroll'` challenge instead of `'login'`.
379
+
380
+ ```ts
381
+ // Enroll email (uses the account's existing email)
382
+ const { mfaSessionToken } = await authKit.enrollEmailMfa(clientId);
383
+ await authKit.confirmEmailMfa(clientId, mfaSessionToken, code);
384
+
385
+ // Enroll SMS
386
+ const { mfaSessionToken: smsToken } = await authKit.enrollSmsMfa(clientId, '+61400000000');
387
+ await authKit.confirmSmsMfa(clientId, smsToken, code);
388
+
389
+ // Recovery codes — plaintext, shown exactly once; nothing else in the API returns them again
390
+ const { recoveryCodes } = await authKit.generateMfaRecoveryCodes(clientId, password);
391
+
392
+ // Inspect / remove
393
+ const factors = await authKit.getMfaFactors(clientId);
394
+ // factors: { enrolledFactors: { email?, sms? }, recoveryCodesRemaining, mfaEnabledForClient }
395
+ await authKit.removeMfaFactor(clientId, 'sms', password);
396
+ ```
397
+
398
+ > Enrollment endpoints don't currently check the client's `mfa.mode` — enrolling is possible
399
+ > even when MFA is configured `'off'` for a client. Those factors simply won't be challenged
400
+ > at login until an admin turns the mode on (via the existing admin AuthKit config update,
401
+ > no new admin route in this pass).
402
+
403
+ ### MFA error codes
404
+
405
+ | `errorCode` | HTTP | Meaning / client action |
406
+ |---|---|---|
407
+ | `MFA_REQUIRED` | 403 | From `login()` only — see above |
408
+ | `INVALID_MFA_CODE` | 401 | Wrong code — let the user retry (attempts are capped) |
409
+ | `MFA_TOO_MANY_ATTEMPTS` | 429 | 5 wrong attempts — the challenge is burned; restart from `login()` |
410
+ | `MFA_FACTOR_NOT_ENROLLED` | 400 | Requested factor isn't enrolled — programming error if the UI only offers `availableFactors` |
411
+ | `MFA_SESSION_EXPIRED` | 401 | `mfaSessionToken` past its 10-minute TTL — restart from `login()` |
412
+ | `MFA_SESSION_INVALID` | 401 | Unknown/wrong-client/already-consumed token, or (on enroll confirm) mismatched user |
413
+ | `RECOVERY_CODE_INVALID` | 401 | Wrong or already-used recovery code |
414
+
415
+ ---
416
+
307
417
  ## Profile management
308
418
 
309
419
  ```ts