@proveanything/smartlinks 1.15.18 → 1.15.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,16 +40,71 @@ export interface Proof {
40
40
  values: ProofValues;
41
41
  }
42
42
  export type ProofResponse = Proof;
43
- export interface ProofCreateRequest {
44
- values: ProofValues;
45
- /** Business-writable public spec data. */
43
+ /**
44
+ * The proof's writable content, addressed by zone. Its keys mirror the proof
45
+ * document, so what you pass is what the proof looks like. Each zone has its own
46
+ * read/write visibility:
47
+ *
48
+ * | Zone | Stored at | Readable by | Writable by |
49
+ * |----------|----------------|------------------------|---------------|
50
+ * | `values` | `proof.values` | public + owner + admin | owner + admin |
51
+ * | `data` | `proof.data` | public + owner + admin | admin only |
52
+ * | `admin` | `proof.admin` | admin only | admin only |
53
+ * | `owner` | `proof.owner` | admin only | admin only |
54
+ *
55
+ * Use `data` for business fields everyone should *see* but only the business
56
+ * should *set* (e.g. a serial number). Use `admin` for fields only the business
57
+ * should see at all. On update, object zones deep-merge.
58
+ */
59
+ export interface ProofWrite {
60
+ /**
61
+ * Choose the proof's ID (serial, NFC id, etc.). Honoured **on create only** —
62
+ * the ledger doc becomes `{productId}-{id}`. Omit to auto-generate. Ignored on
63
+ * update (a proof's ID is immutable).
64
+ */
65
+ id?: string;
66
+ /** Owner + business-writable consumer data. Public + owner readable. → `proof.values` */
67
+ values?: ProofValues;
68
+ /** Business spec data (e.g. serialNo). Public + owner readable, admin-only writable. → `proof.data` */
46
69
  data?: Record<string, JsonValue>;
47
- /** Business-only spec data. */
70
+ /** Business-only spec data — admin-only read & write (stripped from public + owner). → `proof.admin` */
48
71
  admin?: Record<string, JsonValue>;
72
+ /** Business-only root data — admin-only. → `proof.owner` */
73
+ owner?: Record<string, JsonValue>;
74
+ /** Is this proof available to be claimed. */
75
+ claimable?: boolean;
76
+ /** Any other named root field. */
77
+ [key: string]: JsonValue | Record<string, JsonValue> | ProofValues | undefined;
78
+ }
79
+ export interface ProofCreateRequest {
80
+ /**
81
+ * The proof to create, by zone (mirrors the proof document). This is the clear,
82
+ * recommended shape — `create(collectionId, productId, { proof: {...} })`.
83
+ */
84
+ proof?: ProofWrite;
85
+ /** Owner + business-writable consumer data (→ `proof.values`). Same as `proof.values`. */
86
+ values?: ProofValues;
87
+ /** Canonical root `claimable` flag (also accepted as `proof.claimable`). */
49
88
  claimable?: boolean;
50
89
  virtual?: boolean;
90
+ /** @deprecated Legacy alias for `proof`. Use `proof`. */
91
+ core?: ProofWrite;
92
+ /**
93
+ * @deprecated On the request body this is folded into the **values bag**
94
+ * (public + owner-writable) — NOT `proof.data`. Use `proof.data`.
95
+ */
96
+ data?: Record<string, JsonValue>;
97
+ /** @deprecated Not routed to `proof.admin` on create — use `proof.admin`. */
98
+ admin?: Record<string, JsonValue>;
51
99
  }
52
- export type ProofUpdateRequest = Partial<ProofCreateRequest>;
100
+ /**
101
+ * Update passes the proof's fields **at the root** — `update(c, p, id, { data: {...} })`
102
+ * sets `proof.data`, `{ values: {...} }` sets `proof.values`, etc. (A `proof` block is
103
+ * also accepted and routed the same way.) Object zones deep-merge.
104
+ */
105
+ export type ProofUpdateRequest = Partial<ProofWrite> & {
106
+ proof?: ProofWrite;
107
+ };
53
108
  export type ProofClaimRequest = Record<string, any>;
54
109
  /**
55
110
  * `'public'` (default, omitted) reads/writes `proof.values[key]`.
@@ -120,3 +175,60 @@ export type RedeemGrantResult = {
120
175
  issuedAt?: string;
121
176
  expiresAt?: string;
122
177
  };
178
+ /**
179
+ * How a transfer was initiated.
180
+ * - `directed` — the owner pushed it to a named recipient, who must accept.
181
+ * - `open_release` — the owner released it; the proof is `claimable` by anyone.
182
+ * - `contested` — a holder claimed ownership the owner did not release (Phase 2).
183
+ */
184
+ export type ProofTransferType = 'directed' | 'open_release' | 'contested';
185
+ /**
186
+ * Where a transfer is in its lifecycle. `pending` is in-flight; `completed`
187
+ * means ownership moved. `disputed`/`escalated` are Phase 2 (contested claims).
188
+ */
189
+ export type ProofTransferState = 'pending' | 'completed' | 'cancelled' | 'expired' | 'disputed' | 'escalated' | 'rejected';
190
+ /** An ownership-transfer record on a proof. */
191
+ export interface ProofTransfer {
192
+ id: string;
193
+ proofId: string;
194
+ productId?: string | null;
195
+ type: ProofTransferType;
196
+ state: ProofTransferState;
197
+ /** Owner at the time the transfer was initiated. */
198
+ fromUserId?: string | null;
199
+ /** Intended recipient (directed) / claimant (contested). */
200
+ toUserId?: string | null;
201
+ toEmail?: string | null;
202
+ initiatedByUserId?: string | null;
203
+ initiatedByRole?: 'owner' | 'claimant' | 'admin' | null;
204
+ disputeReason?: string | null;
205
+ disputeDeadline?: string | null;
206
+ completedAt?: string | null;
207
+ createdAt: string;
208
+ updatedAt: string;
209
+ }
210
+ /**
211
+ * Start a push transfer. Provide **one** of:
212
+ * - `toEmail` / `toUserId` — a directed transfer to a named recipient (they accept).
213
+ * - `release: true` — an open release (the proof becomes claimable by anyone).
214
+ */
215
+ export interface TransferProofOptions {
216
+ /** Directed: recipient email (created/looked up if needed). */
217
+ toEmail?: string;
218
+ /** Directed: recipient user id, if already known. */
219
+ toUserId?: string;
220
+ /** Directed: display name for a newly-created recipient. */
221
+ toName?: string;
222
+ /** Open release: mark the proof claimable instead of directing it. */
223
+ release?: boolean;
224
+ /** Optional note included in the recipient email. */
225
+ message?: string;
226
+ /** Set `false` to skip the recipient notification email (directed only). */
227
+ notify?: boolean;
228
+ }
229
+ /** Result of initiating a push transfer. */
230
+ export interface TransferProofResult {
231
+ ok: boolean;
232
+ mode: 'directed' | 'open_release';
233
+ transfer: ProofTransfer;
234
+ }
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.15.18 | Generated: 2026-08-19T06:40:47.505Z
3
+ Version: 1.15.20 | Generated: 2026-08-25T07:03:45.544Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -3464,6 +3464,55 @@ interface AuthKitConfig {
3464
3464
  supportEmail?: string
3465
3465
  redirectUrl?: string
3466
3466
  updatedAt?: string
3467
+ * Per-collection security policy. On the public config endpoint only
3468
+ * `passwordPolicy` + `session` are returned (the client renders password
3469
+ * checklists / idle sign-out from them); `lockout` is admin-only and enforced
3470
+ * server-side. See {@link AuthKitSecurityConfig}.
3471
+ security?: AuthKitSecurityConfig
3472
+ }
3473
+ ```
3474
+
3475
+ **AuthKitSecurityConfig** (interface)
3476
+ ```typescript
3477
+ interface AuthKitSecurityConfig {
3478
+ passwordPolicy?: AuthKitPasswordPolicy
3479
+ session?: AuthKitSessionPolicy
3480
+ lockout?: AuthKitLockoutPolicy
3481
+ }
3482
+ ```
3483
+
3484
+ **AuthKitPasswordPolicy** (interface)
3485
+ ```typescript
3486
+ interface AuthKitPasswordPolicy {
3487
+ minLength?: number
3488
+ requireUppercase?: boolean
3489
+ requireLowercase?: boolean
3490
+ requireNumber?: boolean
3491
+ requireSymbol?: boolean
3492
+ blockCommonPasswords?: boolean
3493
+ expiryDays?: number
3494
+ historyCount?: number
3495
+ }
3496
+ ```
3497
+
3498
+ **AuthKitSessionPolicy** (interface)
3499
+ ```typescript
3500
+ interface AuthKitSessionPolicy {
3501
+ inactivityTimeoutMinutes?: number
3502
+ inactivityWarningSeconds?: number
3503
+ absoluteTimeoutHours?: number
3504
+ rememberMe?: boolean
3505
+ }
3506
+ ```
3507
+
3508
+ **AuthKitLockoutPolicy** (interface)
3509
+ ```typescript
3510
+ interface AuthKitLockoutPolicy {
3511
+ enabled?: boolean
3512
+ maxFailedAttempts?: number
3513
+ attemptWindowMinutes?: number
3514
+ lockoutMinutes?: number
3515
+ notifyUserOnLockout?: boolean
3467
3516
  }
3468
3517
  ```
3469
3518
 
@@ -3475,6 +3524,12 @@ interface AuthKitConfig {
3475
3524
 
3476
3525
  **VerifyStatus** = `'pending' | 'verified' | 'failed' | 'expired' | 'unknown'`
3477
3526
 
3527
+ **AuthKitConfigInput** = ``
3528
+
3529
+ **PasswordPolicyErrorCode** = ``
3530
+
3531
+ **LoginSecurityErrorCode** = ``
3532
+
3478
3533
  ### batch
3479
3534
 
3480
3535
  **FirebaseTimestamp** (interface)
@@ -7279,14 +7334,36 @@ interface Proof {
7279
7334
  }
7280
7335
  ```
7281
7336
 
7282
- **ProofCreateRequest** (interface)
7337
+ **ProofWrite** (interface)
7283
7338
  ```typescript
7284
- interface ProofCreateRequest {
7285
- values: ProofValues
7339
+ interface ProofWrite {
7340
+ * Choose the proof's ID (serial, NFC id, etc.). Honoured **on create only** —
7341
+ * the ledger doc becomes `{productId}-{id}`. Omit to auto-generate. Ignored on
7342
+ * update (a proof's ID is immutable).
7343
+ id?: string
7344
+ values?: ProofValues
7286
7345
  data?: Record<string, JsonValue>
7287
7346
  admin?: Record<string, JsonValue>
7347
+ owner?: Record<string, JsonValue>
7348
+ claimable?: boolean
7349
+ [key: string]: JsonValue | Record<string, JsonValue> | ProofValues | undefined
7350
+ }
7351
+ ```
7352
+
7353
+ **ProofCreateRequest** (interface)
7354
+ ```typescript
7355
+ interface ProofCreateRequest {
7356
+ * The proof to create, by zone (mirrors the proof document). This is the clear,
7357
+ * recommended shape — `create(collectionId, productId, { proof: {...} })`.
7358
+ proof?: ProofWrite
7359
+ values?: ProofValues
7288
7360
  claimable?: boolean
7289
7361
  virtual?: boolean
7362
+ core?: ProofWrite
7363
+ * @deprecated On the request body this is folded into the **values bag**
7364
+ * (public + owner-writable) — NOT `proof.data`. Use `proof.data`.
7365
+ data?: Record<string, JsonValue>
7366
+ admin?: Record<string, JsonValue>
7290
7367
  }
7291
7368
  ```
7292
7369
 
@@ -7341,9 +7418,51 @@ interface RedeemGrantOptions {
7341
7418
  }
7342
7419
  ```
7343
7420
 
7421
+ **ProofTransfer** (interface)
7422
+ ```typescript
7423
+ interface ProofTransfer {
7424
+ id: string
7425
+ proofId: string
7426
+ productId?: string | null
7427
+ type: ProofTransferType
7428
+ state: ProofTransferState
7429
+ fromUserId?: string | null
7430
+ toUserId?: string | null
7431
+ toEmail?: string | null
7432
+ initiatedByUserId?: string | null
7433
+ initiatedByRole?: 'owner' | 'claimant' | 'admin' | null
7434
+ disputeReason?: string | null
7435
+ disputeDeadline?: string | null
7436
+ completedAt?: string | null
7437
+ createdAt: string
7438
+ updatedAt: string
7439
+ }
7440
+ ```
7441
+
7442
+ **TransferProofOptions** (interface)
7443
+ ```typescript
7444
+ interface TransferProofOptions {
7445
+ toEmail?: string
7446
+ toUserId?: string
7447
+ toName?: string
7448
+ release?: boolean
7449
+ message?: string
7450
+ notify?: boolean
7451
+ }
7452
+ ```
7453
+
7454
+ **TransferProofResult** (interface)
7455
+ ```typescript
7456
+ interface TransferProofResult {
7457
+ ok: boolean
7458
+ mode: 'directed' | 'open_release'
7459
+ transfer: ProofTransfer
7460
+ }
7461
+ ```
7462
+
7344
7463
  **ProofResponse** = `Proof`
7345
7464
 
7346
- **ProofUpdateRequest** = `Partial<ProofCreateRequest>`
7465
+ **ProofUpdateRequest** = `Partial<ProofWrite> & { proof?: ProofWrite }`
7347
7466
 
7348
7467
  **ProofClaimRequest** = `Record<string, any>`
7349
7468
 
@@ -7355,6 +7474,10 @@ interface RedeemGrantOptions {
7355
7474
 
7356
7475
  **RedeemGrantResult** = ``
7357
7476
 
7477
+ **ProofTransferType** = `'directed' | 'open_release' | 'contested'`
7478
+
7479
+ **ProofTransferState** = ``
7480
+
7358
7481
  ### qr
7359
7482
 
7360
7483
  **QrShortCodeLookupResponse** (interface)
@@ -8748,10 +8871,10 @@ Gets current account information for the logged in user. Returns user, owner, ac
8748
8871
  ### authKit
8749
8872
 
8750
8873
  **login**(clientId: string, email: string, password: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
8751
- 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.
8874
+ 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. Security errors (thrown as `SmartlinksApiError`, see {@link LoginSecurityErrorCode}): - `ACCOUNT_TEMPORARILY_LOCKED` (429) — `err.details.retryAfterSeconds` says how long to wait. - `PASSWORD_EXPIRED` (403) — `err.details.resetToken` is short-lived; route into {@link completePasswordReset} to change the password in place.
8752
8875
 
8753
8876
  **register**(clientId: string, data: { email: string; password: string; displayName?: string; accountData?: Record<string, any> }) → `Promise<AuthLoginResponse>`
8754
- 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.
8877
+ 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. The new password is validated against the collection's `passwordPolicy` — may throw a {@link PasswordPolicyErrorCode} (400). The same validation applies to {@link completePasswordReset} and {@link changePassword}. Read the policy for a live checklist from `authKit.load(clientId)` → `config.security.passwordPolicy`.
8755
8878
 
8756
8879
  **googleLogin**(clientId: string, idToken: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
8757
8880
  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}.
@@ -8877,22 +9000,22 @@ List devices trusted to skip MFA challenges for the current user (authenticated)
8877
9000
  Revoke a single trusted device by id (authenticated).
8878
9001
 
8879
9002
  **load**(authKitId: string) → `Promise<AuthKitConfig>`
8880
- Revoke a single trusted device by id (authenticated).
9003
+ Load the **public** AuthKit config for a client (no auth). Returns branding + the public security subset (`security.passwordPolicy` + `security.session`); `security.lockout` is admin-only and never included here. Use this in the login UI to render password checklists and drive idle sign-out.
8881
9004
 
8882
9005
  **get**(collectionId: string, authKitId: string) → `Promise<AuthKitConfig>`
8883
- Revoke a single trusted device by id (authenticated).
9006
+ Get the full AuthKit config, including admin-only fields like `security.lockout` (admin auth).
8884
9007
 
8885
9008
  **list**(collectionId: string, admin?: boolean) → `Promise<AuthKitConfig[]>`
8886
- Revoke a single trusted device by id (authenticated).
9009
+ Get the full AuthKit config, including admin-only fields like `security.lockout` (admin auth).
8887
9010
 
8888
- **create**(collectionId: string, data: any) → `Promise<AuthKitConfig>`
8889
- Revoke a single trusted device by id (authenticated).
9011
+ **create**(collectionId: string, data: AuthKitConfigInput) → `Promise<AuthKitConfig>`
9012
+ Create an AuthKit client config (admin). Accepts the account `security` policy — see {@link AuthKitConfigInput}.
8890
9013
 
8891
- **update**(collectionId: string, authKitId: string, data: any) → `Promise<AuthKitConfig>`
8892
- Revoke a single trusted device by id (authenticated).
9014
+ **update**(collectionId: string, authKitId: string, data: AuthKitConfigInput) → `Promise<AuthKitConfig>`
9015
+ Update an AuthKit client config (admin). This is how the account **security policy** is written — pass a `security` block ({@link AuthKitSecurityConfig}). The server validates it and enforces it; the login UI reads the public subset back via {@link load}.
8893
9016
 
8894
9017
  **remove**(collectionId: string, authKitId: string) → `Promise<void>`
8895
- Revoke a single trusted device by id (authenticated).
9018
+ Update an AuthKit client config (admin). This is how the account **security policy** is written — pass a `security` block ({@link AuthKitSecurityConfig}). The server validates it and enforces it; the login UI reads the public subset back via {@link load}.
8896
9019
 
8897
9020
  ### batch
8898
9021
 
@@ -9952,14 +10075,14 @@ List all Proofs for a Collection.
9952
10075
 
9953
10076
  **create**(collectionId: string,
9954
10077
  productId: string,
9955
- values: ProofCreateRequest) → `Promise<ProofResponse>`
9956
- Create a proof for a product (admin only). POST /admin/collection/:collectionId/product/:productId/proof
10078
+ request: ProofCreateRequest) → `Promise<ProofResponse>`
10079
+ Create a proof for a product (admin only). POST /admin/collection/:collectionId/product/:productId/proof Pass the proof's content in a `proof` block, keyed by zone (see {@link ProofWrite}): ```ts proof.create(collectionId, productId, { proof: { values: { colour: 'red' }, // public + owner readable, owner + admin writable data: { serialNo: 1001 }, // public + owner readable, ADMIN-only writable admin: { costPrice: 4.20 }, // admin-only }, claimable: true, }) ``` Note: a top-level `data`/`admin` on the request body is legacy — top-level `data` gets folded into the values bag, so use `proof.data` for `proof.data`.
9957
10080
 
9958
10081
  **update**(collectionId: string,
9959
10082
  productId: string,
9960
10083
  proofId: string,
9961
10084
  values: ProofUpdateRequest) → `Promise<ProofResponse>`
9962
- Update a proof for a product (admin only). PUT /admin/collection/:collectionId/product/:productId/proof/:proofId
10085
+ Update a proof for a product (admin only). PUT /admin/collection/:collectionId/product/:productId/proof/:proofId Pass the fields to change **at the root**, keyed by zone (see {@link ProofWrite}): ```ts proof.update(collectionId, productId, proofId, { data: { serialNo: 1002 }, // → proof.data (admin-only writable) values: { colour: 'blue' }, // → proof.values }) ``` Object zones deep-merge, so you can change one field without wiping the rest.
9963
10086
 
9964
10087
  **claim**(collectionId: string,
9965
10088
  productId: string,
@@ -10026,6 +10149,27 @@ Revoke a grant by id (owner / collection admin only). Takes effect immediately.
10026
10149
  options?: RedeemGrantOptions) → `Promise<RedeemGrantResult>`
10027
10150
  Redeem a grant token (anonymous or signed-in). Records the redemption and returns the granted scope, or — for a `verify_owner` grant — an ownership assertion (never the account). After redeeming, call {@link setGrantToken} so subsequent data requests carry the token.
10028
10151
 
10152
+ **transfer**(collectionId: string,
10153
+ productId: string,
10154
+ proofId: string,
10155
+ options: TransferProofOptions) → `Promise<TransferProofResult>`
10156
+ Start a push transfer of a proof (current owner / collection admin only). Directed — hand it to a named recipient who then calls {@link acceptTransfer}: ```ts await proof.transfer(collectionId, productId, proofId, { toEmail: 'buyer@example.com' }) ``` Open release — make the proof claimable by anyone: ```ts await proof.transfer(collectionId, productId, proofId, { release: true }) ```
10157
+
10158
+ **acceptTransfer**(collectionId: string,
10159
+ productId: string,
10160
+ proofId: string) → `Promise<`
10161
+ Accept a directed transfer (the named recipient only). Completes the ownership move — the proof's `userId` becomes the caller and the previous owner's private data and share grants are cleared/voided.
10162
+
10163
+ **cancelTransfer**(collectionId: string,
10164
+ productId: string,
10165
+ proofId: string) → `Promise<`
10166
+ Cancel a pending push transfer (current owner / collection admin only).
10167
+
10168
+ **getTransfer**(collectionId: string,
10169
+ productId: string,
10170
+ proofId: string) → `Promise<`
10171
+ Get the active transfer/status for a proof (owner, collection admin, or the named recipient). Returns `{ transfer: null }` when nothing is in flight.
10172
+
10029
10173
  ### publicClient
10030
10174
 
10031
10175
  **chat**(collectionId: string,
package/docs/auth-kit.md CHANGED
@@ -502,6 +502,113 @@ await authKit.completePasswordReset(clientId, tokenFromUrl, 'newSecurePassword')
502
502
 
503
503
  ---
504
504
 
505
+ ## Account security policy
506
+
507
+ Each collection can configure account-security rules. **The API enforces all of it**; your
508
+ login UI reads the policy for UX only (a live password checklist, idle sign-out). There are
509
+ two sides: an **admin** writes the policy, and the **login UI** reads the public subset.
510
+
511
+ **Read (login UI, public — no auth):**
512
+
513
+ ```ts
514
+ const config = await authKit.load(clientId);
515
+ const policy = config.security?.passwordPolicy; // min length, char classes, block-common
516
+ const session = config.security?.session; // inactivity + absolute timeouts, rememberMe
517
+ ```
518
+
519
+ `lockout` values are admin-only and never returned by `load`.
520
+
521
+ **Write (admin):** set the whole `security` block with `authKit.update` (or `authKit.create`).
522
+ The shape is {@link AuthKitSecurityConfig}; see {@link AuthKitConfigInput}.
523
+
524
+ ```ts
525
+ await authKit.update(collectionId, clientId, {
526
+ security: {
527
+ passwordPolicy: {
528
+ minLength: 8, // hard floor enforced server-side
529
+ blockCommonPasswords: true, // reject the common/breached list
530
+ requireUppercase: false, requireLowercase: false,
531
+ requireNumber: false, requireSymbol: false,
532
+ expiryDays: 0, // 0 = never expires
533
+ historyCount: 0, // 0 = reuse allowed
534
+ },
535
+ lockout: { // admin-only; enforced server-side
536
+ enabled: true, maxFailedAttempts: 5,
537
+ attemptWindowMinutes: 15, lockoutMinutes: 15,
538
+ notifyUserOnLockout: true,
539
+ },
540
+ session: {
541
+ inactivityTimeoutMinutes: 0, // 0 = disabled (client-enforced when set)
542
+ inactivityWarningSeconds: 60,
543
+ absoluteTimeoutHours: 0, // 0 = use token lifetime (server-enforced)
544
+ rememberMe: true,
545
+ },
546
+ },
547
+ });
548
+ ```
549
+
550
+ Defaults if a collection has never set a policy: 8-char minimum + block-common for everyone,
551
+ lockout disabled, no expiry/history, no idle/absolute timeout. Omit any field to take its default.
552
+
553
+ ### Password policy
554
+
555
+ `register`, `completePasswordReset`, and `changePassword` validate the new password
556
+ server-side and throw a `SmartlinksApiError` with a {@link PasswordPolicyErrorCode}:
557
+
558
+ | `errorCode` (400) | Meaning |
559
+ |---|---|
560
+ | `PASSWORD_TOO_SHORT` | below `minLength` |
561
+ | `PASSWORD_REQUIREMENTS_NOT_MET` | missing a required character class |
562
+ | `PASSWORD_TOO_COMMON` | on the common/breached list |
563
+ | `PASSWORD_RECENTLY_USED` | matched one of the last `historyCount` passwords |
564
+
565
+ Render a live checklist from `policy` so users see the rules before submitting.
566
+
567
+ ### Lockout
568
+
569
+ After too many failed logins the account is temporarily locked. `login` throws:
570
+
571
+ ```ts
572
+ try {
573
+ await authKit.login(clientId, email, password);
574
+ } catch (err) {
575
+ if (err.errorCode === 'ACCOUNT_TEMPORARILY_LOCKED') {
576
+ const mins = Math.ceil(err.details.retryAfterSeconds / 60);
577
+ show(`Too many attempts. Try again in ${mins} minute(s).`);
578
+ }
579
+ }
580
+ ```
581
+
582
+ Failed MFA challenges count toward the same lock. Locking responds identically for unknown
583
+ accounts (no enumeration).
584
+
585
+ ### Password expiry
586
+
587
+ If a password is older than `passwordPolicy.expiryDays`, a valid login is refused with
588
+ **403 `PASSWORD_EXPIRED`** carrying a short-lived `resetToken` — send the user straight into
589
+ the reset form to change it in place:
590
+
591
+ ```ts
592
+ catch (err) {
593
+ if (err.errorCode === 'PASSWORD_EXPIRED') {
594
+ await authKit.completePasswordReset(clientId, err.details.resetToken, newPassword);
595
+ }
596
+ }
597
+ ```
598
+
599
+ ### Session lifetime
600
+
601
+ - **Absolute timeout** (`session.absoluteTimeoutHours`) is enforced server-side on the native
602
+ refresh path: once the session is too old, `refreshToken` throws **401 `SESSION_EXPIRED`**
603
+ (see {@link RefreshErrorCode}) — clear storage and route to login. Web sessions use the
604
+ stateless bearer token and rely on inactivity sign-out below.
605
+ - **Inactivity timeout** (`session.inactivityTimeoutMinutes` / `inactivityWarningSeconds`) is
606
+ **client-enforced** — sign the user out after idle, warning first. Sync across tabs.
607
+ - **`session.rememberMe: false`** → don't persist tokens to durable storage; treat the session
608
+ as browser-scoped.
609
+
610
+ ---
611
+
505
612
  ## Relationship to other parts of the SDK
506
613
 
507
614
  | Concern | Where it lives |
package/docs/overview.md CHANGED
@@ -81,6 +81,9 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
81
81
  | **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
82
82
  | **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
83
83
  | **Product/Proof Data Scoping** | `docs/proof-product-data-scoping.md` | Canonical spec for `product.data`/`.admin` and `proof.data`/`.admin`/`.values` (owner/personal) — who can read and write each bucket |
84
+ | **Proof Claiming** | `docs/proof-claiming-methods.md` | The ways a user claims a proof (serial, NFC, claim-set, auto-generate) |
85
+ | **Proof Share Grants** | `docs/proof-share-grants.md` | Delegated, scoped, revocable bearer access to a single proof (read/comment/verify-owner) |
86
+ | **Proof Ownership Transfer** | `docs/proof-ownership-transfer.md` | Moving a proof's single owner (directed transfer / open release), accept/cancel, and the state machine |
84
87
  | **appConfig / Feature Flags** | `docs/appConfig.md` | `appConfig` settings contract — installed apps, `system.features`/`entitledAppGroups`/`meters`, `isFeatureEnabled()` helper |
85
88
 
86
89
  ---
@@ -0,0 +1,114 @@
1
+ # Proof Ownership Transfer
2
+
3
+ Every proof has exactly **one owner** (`proof.userId`). A *transfer* moves that
4
+ owner from A to B — for a resale, a gift, or handing an item on. Ownership carries
5
+ the owner-scoped data with it and voids the previous owner's private access.
6
+
7
+ This covers **push (owner-initiated) transfers** — the current owner consents to the
8
+ move. Contested pull-claims and dispute resolution (where a holder claims ownership
9
+ the owner never released) are a later addition; the state machine below already
10
+ reserves states for them.
11
+
12
+ ---
13
+
14
+ ## Two ways to push
15
+
16
+ | Mode | How | Who completes it |
17
+ |------|-----|------------------|
18
+ | **Directed** | Owner names a recipient (`toEmail` / `toUserId`). The proof is earmarked for them (`claimUserId`) and they're emailed a link. | The named recipient calls `acceptTransfer`. |
19
+ | **Open release** | Owner marks the proof `claimable`. | Anyone claims it via the normal claim flow. |
20
+
21
+ Only the current owner (or a collection admin) can start a transfer. A proof can
22
+ have **one active transfer at a time**.
23
+
24
+ ### What completion does
25
+
26
+ When a directed transfer is accepted (or an admin resolves one), the move is
27
+ authoritative and atomic:
28
+
29
+ - `proof.userId` becomes the new owner; `claimable` is cleared.
30
+ - The **previous owner's private zones are removed** — `values.owner`,
31
+ `values.personal[oldOwner]`, and their `roles` entry.
32
+ - Every **share grant auto-voids** (grants are bound to the owner at issue), so
33
+ stale "I own this" links stop resolving.
34
+ - An append-only **`ownership_transfer` attestation** records `from → to`, so the
35
+ chain of custody is verifiable.
36
+
37
+ ---
38
+
39
+ ## Owner flow
40
+
41
+ ```ts
42
+ import { proof } from '@proveanything/smartlinks'
43
+
44
+ // Directed — hand it to a named buyer (they must accept)
45
+ const { transfer } = await proof.transfer(collectionId, productId, proofId, {
46
+ toEmail: 'buyer@example.com',
47
+ message: 'Enjoy the watch!',
48
+ })
49
+
50
+ // …or an open release — anyone can now claim it
51
+ await proof.transfer(collectionId, productId, proofId, { release: true })
52
+
53
+ // Check status at any time
54
+ const { transfer: active } = await proof.getTransfer(collectionId, productId, proofId)
55
+
56
+ // Change your mind before it's accepted
57
+ await proof.cancelTransfer(collectionId, productId, proofId)
58
+ ```
59
+
60
+ ## Recipient flow (directed)
61
+
62
+ ```ts
63
+ // Only the named recipient can accept — a third party is rejected.
64
+ const { proof: mine } = await proof.acceptTransfer(collectionId, productId, proofId)
65
+ ```
66
+
67
+ The recipient sees the pending transfer on their account (a pending claim) and via
68
+ the email link. Until they accept, the proof still belongs to the seller.
69
+
70
+ ---
71
+
72
+ ## State machine
73
+
74
+ ```
75
+ transfer({toEmail}) accept
76
+ (owned) ────────────────────────▶ pending ─────────────▶ completed
77
+ │ │
78
+ │ transfer({release}) │ cancel
79
+ └────────────────────────────────▶├─────────────────▶ cancelled
80
+
81
+ │ (later: dispute / escalation)
82
+ └─────────▶ disputed / escalated / rejected
83
+ ```
84
+
85
+ - `pending` — a transfer is in flight (directed earmark or open release).
86
+ - `completed` — ownership moved.
87
+ - `cancelled` — the owner withdrew it before acceptance.
88
+ - `expired` / `disputed` / `escalated` / `rejected` — reserved for contested
89
+ claims + dispute resolution (later).
90
+
91
+ ---
92
+
93
+ ## Security notes
94
+
95
+ - **Directed accept is access-controlled.** Only the earmarked recipient
96
+ (`transfer.toUserId`) can accept; the claim endpoint also rejects anyone whose
97
+ id ≠ `claimUserId` while a directed transfer is pending. A directed transfer
98
+ can't be grabbed by a bystander.
99
+ - **Ownership only moves one way** — through completion. There is no path that
100
+ silently strips an active owner without their consent (contested claims, when
101
+ added, are dispute-protected and never auto-transfer by default).
102
+ - **Every state change is audited** as an append-only attestation on the proof.
103
+
104
+ ## API
105
+
106
+ | Method | Endpoint |
107
+ |--------|----------|
108
+ | `proof.transfer(c, p, id, opts)` | `POST …/proof/:id/transfer` |
109
+ | `proof.acceptTransfer(c, p, id)` | `POST …/proof/:id/transfer/accept` |
110
+ | `proof.cancelTransfer(c, p, id)` | `POST …/proof/:id/transfer/cancel` |
111
+ | `proof.getTransfer(c, p, id)` | `GET …/proof/:id/transfer` |
112
+
113
+ See also [Proof Share Grants](./proof-share-grants.md) and
114
+ [Proof Claiming Methods](./proof-claiming-methods.md).