@proveanything/smartlinks 1.15.17 → 1.15.19

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.
package/README.md CHANGED
@@ -22,6 +22,9 @@ If you're new to the SDK, this is the easiest path:
22
22
 
23
23
  - [docs/ai.md](docs/ai.md) — AI responses, chat, RAG, voice, streaming, and product assistants
24
24
  - [docs/analytics.md](docs/analytics.md) — fire-and-forget web analytics, tag scan telemetry, and dashboard queries
25
+ - [docs/proof-share-grants.md](docs/proof-share-grants.md) — delegated, scoped, revocable share links for a proof (album sharing, guest comments, proof-of-ownership)
26
+ - [docs/assets.md](docs/assets.md) — asset uploads, including resumable uploads for large files (video)
27
+ - [docs/app-objects.md](docs/app-objects.md) — cases, threads (comments), and records
25
28
  - [docs/translations.md](docs/translations.md) — runtime translation lookup, browser-side caching, and translation admin flows
26
29
  - [docs/widgets.md](docs/widgets.md) — embeddable React components
27
30
  - [docs/realtime.md](docs/realtime.md) — subscriptions and live updates
@@ -33,17 +36,22 @@ If you're new to the SDK, this is the easiest path:
33
36
  - **Build an AI assistant** → start with [docs/ai.md](docs/ai.md)
34
37
  - **Track page views, clicks, or tag scans** → start with [docs/analytics.md](docs/analytics.md)
35
38
  - **Translate dynamic content with local browser caching** → start with [docs/translations.md](docs/translations.md)
39
+ - **Share a proof / album by link, or add guest comments** → start with [docs/proof-share-grants.md](docs/proof-share-grants.md)
40
+ - **Upload large video with resume-on-reconnect** → see [Resumable uploads](docs/assets.md#resumable-uploads-large-files-eg-video)
36
41
  - **Fetch collections/products** → see [Quick start](README.md#quick-start)
37
42
  - **Authenticate admins or end users** → see [Authentication](README.md#authentication)
38
43
  - **Upload and manage files** → see [Assets](README.md#assets)
39
- - **Browse the full surface area** → use [API_SUMMARY.md](API_SUMMARY.md) as reference
44
+ - **Browse the full surface area** → use [API_SUMMARY.md](docs/API_SUMMARY.md) as reference
40
45
 
41
46
  For the full list of functions and types, see the API summary:
42
- → [API Summary](API_SUMMARY.md)
47
+ → [API Summary](docs/API_SUMMARY.md)
43
48
 
44
49
  **Documentation:**
45
50
  - [AI & Chat Completions](docs/ai.md) - Chat completions, RAG, voice integration
46
51
  - [Analytics](docs/analytics.md) - Fire-and-forget analytics tracking, tag scans, and dashboard queries
52
+ - [Proof Share Grants](docs/proof-share-grants.md) - Delegated, revocable share links; guest comments; proof-of-ownership
53
+ - [Assets](docs/assets.md) - Uploads, resumable large-file uploads, token uploads, replace/versioning
54
+ - [App Objects](docs/app-objects.md) - Cases, threads (comments), records
47
55
  - [Translations](docs/translations.md) - Runtime translation lookup, browser-side IndexedDB caching, and admin translation management
48
56
  - [Widgets](docs/widgets.md) - Embeddable React components
49
57
  - [Realtime](docs/realtime.md) - Realtime data updates
@@ -85,6 +85,12 @@ export declare namespace app {
85
85
  * Atomically appends to replies array, increments replyCount, updates lastReplyAt
86
86
  */
87
87
  function reply(collectionId: string, appId: string, threadId: string, input: ReplyInput, admin?: boolean): Promise<AppThread>;
88
+ /**
89
+ * Delete a single reply from a thread by its reply id (moderation).
90
+ * DELETE /threads/:threadId/reply/:replyId
91
+ * Authorised for the reply's author, the proof owner, or a collection admin.
92
+ */
93
+ function deleteReply(collectionId: string, appId: string, threadId: string, replyId: string, admin?: boolean): Promise<AppThread>;
88
94
  /**
89
95
  * Get aggregate statistics for threads
90
96
  * POST /threads/aggregate
@@ -164,6 +164,16 @@ export var app;
164
164
  return post(path, input);
165
165
  }
166
166
  threads.reply = reply;
167
+ /**
168
+ * Delete a single reply from a thread by its reply id (moderation).
169
+ * DELETE /threads/:threadId/reply/:replyId
170
+ * Authorised for the reply's author, the proof owner, or a collection admin.
171
+ */
172
+ async function deleteReply(collectionId, appId, threadId, replyId, admin = false) {
173
+ const path = `${basePath(collectionId, appId, admin)}/${encodeURIComponent(threadId)}/reply/${encodeURIComponent(replyId)}`;
174
+ return del(path);
175
+ }
176
+ threads.deleteReply = deleteReply;
167
177
  /**
168
178
  * Get aggregate statistics for threads
169
179
  * POST /threads/aggregate
@@ -18,6 +18,11 @@ export declare namespace authKit {
18
18
  * `trustDevice: true`), pass it here to skip the challenge entirely as long as it's
19
19
  * still valid. If it's revoked/expired, the server silently falls back to requiring a
20
20
  * fresh challenge — `login()` just returns `MFA_REQUIRED` again, no special handling.
21
+ *
22
+ * Security errors (thrown as `SmartlinksApiError`, see {@link LoginSecurityErrorCode}):
23
+ * - `ACCOUNT_TEMPORARILY_LOCKED` (429) — `err.details.retryAfterSeconds` says how long to wait.
24
+ * - `PASSWORD_EXPIRED` (403) — `err.details.resetToken` is short-lived; route into
25
+ * {@link completePasswordReset} to change the password in place.
21
26
  */
22
27
  function login(clientId: string, email: string, password: string, trustedDeviceToken?: string): Promise<AuthLoginResponse>;
23
28
  /**
@@ -25,6 +30,11 @@ export declare namespace authKit {
25
30
  *
26
31
  * Not gated by step-up MFA — a brand-new user has no enrolled factors yet, so there's
27
32
  * nothing to challenge against.
33
+ *
34
+ * The new password is validated against the collection's `passwordPolicy` — may throw
35
+ * a {@link PasswordPolicyErrorCode} (400). The same validation applies to
36
+ * {@link completePasswordReset} and {@link changePassword}. Read the policy for a live
37
+ * checklist from `authKit.load(clientId)` → `config.security.passwordPolicy`.
28
38
  */
29
39
  function register(clientId: string, data: {
30
40
  email: string;
@@ -22,6 +22,11 @@ export var authKit;
22
22
  * `trustDevice: true`), pass it here to skip the challenge entirely as long as it's
23
23
  * still valid. If it's revoked/expired, the server silently falls back to requiring a
24
24
  * fresh challenge — `login()` just returns `MFA_REQUIRED` again, no special handling.
25
+ *
26
+ * Security errors (thrown as `SmartlinksApiError`, see {@link LoginSecurityErrorCode}):
27
+ * - `ACCOUNT_TEMPORARILY_LOCKED` (429) — `err.details.retryAfterSeconds` says how long to wait.
28
+ * - `PASSWORD_EXPIRED` (403) — `err.details.resetToken` is short-lived; route into
29
+ * {@link completePasswordReset} to change the password in place.
25
30
  */
26
31
  async function login(clientId, email, password, trustedDeviceToken) {
27
32
  const body = { email, password };
@@ -40,6 +45,11 @@ export var authKit;
40
45
  *
41
46
  * Not gated by step-up MFA — a brand-new user has no enrolled factors yet, so there's
42
47
  * nothing to challenge against.
48
+ *
49
+ * The new password is validated against the collection's `passwordPolicy` — may throw
50
+ * a {@link PasswordPolicyErrorCode} (400). The same validation applies to
51
+ * {@link completePasswordReset} and {@link changePassword}. Read the policy for a live
52
+ * checklist from `authKit.load(clientId)` → `config.security.passwordPolicy`.
43
53
  */
44
54
  async function register(clientId, data) {
45
55
  return post(`/authkit/${encodeURIComponent(clientId)}/auth/register`, data);
@@ -12,11 +12,34 @@ export declare namespace proof {
12
12
  /**
13
13
  * Create a proof for a product (admin only).
14
14
  * POST /admin/collection/:collectionId/product/:productId/proof
15
+ *
16
+ * Pass the proof's content in a `proof` block, keyed by zone (see {@link ProofWrite}):
17
+ * ```ts
18
+ * proof.create(collectionId, productId, {
19
+ * proof: {
20
+ * values: { colour: 'red' }, // public + owner readable, owner + admin writable
21
+ * data: { serialNo: 1001 }, // public + owner readable, ADMIN-only writable
22
+ * admin: { costPrice: 4.20 }, // admin-only
23
+ * },
24
+ * claimable: true,
25
+ * })
26
+ * ```
27
+ * Note: a top-level `data`/`admin` on the request body is legacy — top-level
28
+ * `data` gets folded into the values bag, so use `proof.data` for `proof.data`.
15
29
  */
16
- function create(collectionId: string, productId: string, values: ProofCreateRequest): Promise<ProofResponse>;
30
+ function create(collectionId: string, productId: string, request: ProofCreateRequest): Promise<ProofResponse>;
17
31
  /**
18
32
  * Update a proof for a product (admin only).
19
33
  * PUT /admin/collection/:collectionId/product/:productId/proof/:proofId
34
+ *
35
+ * Pass the fields to change **at the root**, keyed by zone (see {@link ProofWrite}):
36
+ * ```ts
37
+ * proof.update(collectionId, productId, proofId, {
38
+ * data: { serialNo: 1002 }, // → proof.data (admin-only writable)
39
+ * values: { colour: 'blue' }, // → proof.values
40
+ * })
41
+ * ```
42
+ * Object zones deep-merge, so you can change one field without wiping the rest.
20
43
  */
21
44
  function update(collectionId: string, productId: string, proofId: string, values: ProofUpdateRequest): Promise<ProofResponse>;
22
45
  /**
package/dist/api/proof.js CHANGED
@@ -26,15 +26,38 @@ export var proof;
26
26
  /**
27
27
  * Create a proof for a product (admin only).
28
28
  * POST /admin/collection/:collectionId/product/:productId/proof
29
+ *
30
+ * Pass the proof's content in a `proof` block, keyed by zone (see {@link ProofWrite}):
31
+ * ```ts
32
+ * proof.create(collectionId, productId, {
33
+ * proof: {
34
+ * values: { colour: 'red' }, // public + owner readable, owner + admin writable
35
+ * data: { serialNo: 1001 }, // public + owner readable, ADMIN-only writable
36
+ * admin: { costPrice: 4.20 }, // admin-only
37
+ * },
38
+ * claimable: true,
39
+ * })
40
+ * ```
41
+ * Note: a top-level `data`/`admin` on the request body is legacy — top-level
42
+ * `data` gets folded into the values bag, so use `proof.data` for `proof.data`.
29
43
  */
30
- async function create(collectionId, productId, values) {
44
+ async function create(collectionId, productId, request) {
31
45
  const path = `/admin/collection/${encodeURIComponent(collectionId)}/product/${encodeURIComponent(productId)}/proof`;
32
- return post(path, values);
46
+ return post(path, request);
33
47
  }
34
48
  proof.create = create;
35
49
  /**
36
50
  * Update a proof for a product (admin only).
37
51
  * PUT /admin/collection/:collectionId/product/:productId/proof/:proofId
52
+ *
53
+ * Pass the fields to change **at the root**, keyed by zone (see {@link ProofWrite}):
54
+ * ```ts
55
+ * proof.update(collectionId, productId, proofId, {
56
+ * data: { serialNo: 1002 }, // → proof.data (admin-only writable)
57
+ * values: { colour: 'blue' }, // → proof.values
58
+ * })
59
+ * ```
60
+ * Object zones deep-merge, so you can change one field without wiping the rest.
38
61
  */
39
62
  async function update(collectionId, productId, proofId, values) {
40
63
  const path = `/admin/collection/${encodeURIComponent(collectionId)}/product/${encodeURIComponent(productId)}/proof/${encodeURIComponent(proofId)}`;
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.15.17 | Generated: 2026-08-18T08:10:50.608Z
3
+ Version: 1.15.19 | Generated: 2026-08-20T18:06:08.134Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -1993,6 +1993,10 @@ interface CreateThreadInput {
1993
1993
  data?: Record<string, unknown>
1994
1994
  owner?: Record<string, unknown>
1995
1995
  admin?: Record<string, unknown> // admin only
1996
+ * Optional atomic first reply. Posting a comment no longer needs a separate
1997
+ * create-thread-then-reply round trip (which could orphan an empty thread on
1998
+ * partial failure). The reply is stored with a generated `id` and timestamp.
1999
+ firstReply?: ReplyInput
1996
2000
  }
1997
2001
  ```
1998
2002
 
@@ -3460,6 +3464,55 @@ interface AuthKitConfig {
3460
3464
  supportEmail?: string
3461
3465
  redirectUrl?: string
3462
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
3463
3516
  }
3464
3517
  ```
3465
3518
 
@@ -3471,6 +3524,10 @@ interface AuthKitConfig {
3471
3524
 
3472
3525
  **VerifyStatus** = `'pending' | 'verified' | 'failed' | 'expired' | 'unknown'`
3473
3526
 
3527
+ **PasswordPolicyErrorCode** = ``
3528
+
3529
+ **LoginSecurityErrorCode** = ``
3530
+
3474
3531
  ### batch
3475
3532
 
3476
3533
  **FirebaseTimestamp** (interface)
@@ -7275,14 +7332,36 @@ interface Proof {
7275
7332
  }
7276
7333
  ```
7277
7334
 
7278
- **ProofCreateRequest** (interface)
7335
+ **ProofWrite** (interface)
7279
7336
  ```typescript
7280
- interface ProofCreateRequest {
7281
- values: ProofValues
7337
+ interface ProofWrite {
7338
+ * Choose the proof's ID (serial, NFC id, etc.). Honoured **on create only** —
7339
+ * the ledger doc becomes `{productId}-{id}`. Omit to auto-generate. Ignored on
7340
+ * update (a proof's ID is immutable).
7341
+ id?: string
7342
+ values?: ProofValues
7282
7343
  data?: Record<string, JsonValue>
7283
7344
  admin?: Record<string, JsonValue>
7345
+ owner?: Record<string, JsonValue>
7346
+ claimable?: boolean
7347
+ [key: string]: JsonValue | Record<string, JsonValue> | ProofValues | undefined
7348
+ }
7349
+ ```
7350
+
7351
+ **ProofCreateRequest** (interface)
7352
+ ```typescript
7353
+ interface ProofCreateRequest {
7354
+ * The proof to create, by zone (mirrors the proof document). This is the clear,
7355
+ * recommended shape — `create(collectionId, productId, { proof: {...} })`.
7356
+ proof?: ProofWrite
7357
+ values?: ProofValues
7284
7358
  claimable?: boolean
7285
7359
  virtual?: boolean
7360
+ core?: ProofWrite
7361
+ * @deprecated On the request body this is folded into the **values bag**
7362
+ * (public + owner-writable) — NOT `proof.data`. Use `proof.data`.
7363
+ data?: Record<string, JsonValue>
7364
+ admin?: Record<string, JsonValue>
7286
7365
  }
7287
7366
  ```
7288
7367
 
@@ -7339,7 +7418,7 @@ interface RedeemGrantOptions {
7339
7418
 
7340
7419
  **ProofResponse** = `Proof`
7341
7420
 
7342
- **ProofUpdateRequest** = `Partial<ProofCreateRequest>`
7421
+ **ProofUpdateRequest** = `Partial<ProofWrite> & { proof?: ProofWrite }`
7343
7422
 
7344
7423
  **ProofClaimRequest** = `Record<string, any>`
7345
7424
 
@@ -8435,6 +8514,13 @@ Soft delete a thread DELETE /threads/:threadId
8435
8514
  admin: boolean = false) → `Promise<AppThread>`
8436
8515
  Add a reply to a thread POST /threads/:threadId/reply Atomically appends to replies array, increments replyCount, updates lastReplyAt
8437
8516
 
8517
+ **deleteReply**(collectionId: string,
8518
+ appId: string,
8519
+ threadId: string,
8520
+ replyId: string,
8521
+ admin: boolean = false) → `Promise<AppThread>`
8522
+ Delete a single reply from a thread by its reply id (moderation). DELETE /threads/:threadId/reply/:replyId Authorised for the reply's author, the proof owner, or a collection admin.
8523
+
8438
8524
  **aggregate**(collectionId: string,
8439
8525
  appId: string,
8440
8526
  request: AggregateRequest,
@@ -8737,10 +8823,10 @@ Gets current account information for the logged in user. Returns user, owner, ac
8737
8823
  ### authKit
8738
8824
 
8739
8825
  **login**(clientId: string, email: string, password: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
8740
- 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.
8826
+ 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.
8741
8827
 
8742
8828
  **register**(clientId: string, data: { email: string; password: string; displayName?: string; accountData?: Record<string, any> }) → `Promise<AuthLoginResponse>`
8743
- 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.
8829
+ 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`.
8744
8830
 
8745
8831
  **googleLogin**(clientId: string, idToken: string, trustedDeviceToken?: string) → `Promise<AuthLoginResponse>`
8746
8832
  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}.
@@ -9941,14 +10027,14 @@ List all Proofs for a Collection.
9941
10027
 
9942
10028
  **create**(collectionId: string,
9943
10029
  productId: string,
9944
- values: ProofCreateRequest) → `Promise<ProofResponse>`
9945
- Create a proof for a product (admin only). POST /admin/collection/:collectionId/product/:productId/proof
10030
+ request: ProofCreateRequest) → `Promise<ProofResponse>`
10031
+ 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`.
9946
10032
 
9947
10033
  **update**(collectionId: string,
9948
10034
  productId: string,
9949
10035
  proofId: string,
9950
10036
  values: ProofUpdateRequest) → `Promise<ProofResponse>`
9951
- Update a proof for a product (admin only). PUT /admin/collection/:collectionId/product/:productId/proof/:proofId
10037
+ 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.
9952
10038
 
9953
10039
  **claim**(collectionId: string,
9954
10040
  productId: string,
@@ -429,6 +429,71 @@ const productComments = await app.threads.list(collectionId, appId, {
429
429
  });
430
430
  ```
431
431
 
432
+ ### Anchoring to app entities and proofs
433
+
434
+ Anchor a thread to your own entity with `parentType` + `parentId`. `parentId` is a
435
+ free-form string (SmartLinks short ids, not just UUIDs), so you can use your app's
436
+ native ids directly — no need to stash them in `body` or a tag.
437
+
438
+ ```typescript
439
+ await app.threads.create(collectionId, appId, {
440
+ parentType: 'memory',
441
+ parentId: memoryId, // e.g. "dVdthBQLAjQEnitU7aWy" — text, not a UUID
442
+ proofId, // anchor to a proof (enables grant-tier reads)
443
+ body: { text: 'Lovely photo' },
444
+ });
445
+ ```
446
+
447
+ List filters that pair with this (see `ThreadListQueryParams`):
448
+
449
+ | Param | Purpose |
450
+ |-------|---------|
451
+ | `parentType` / `parentId` | one app entity's threads |
452
+ | `parentIds` | **batch** — every thread for many entities in one call (e.g. a feed) |
453
+ | `proofId` / `productId` | threads anchored to a proof / product |
454
+
455
+ ```typescript
456
+ // One request for the 30 memories currently on screen:
457
+ const feed = await app.threads.list(collectionId, appId, {
458
+ parentType: 'memory',
459
+ parentIds: visibleMemoryIds,
460
+ sort: 'createdAt:desc',
461
+ });
462
+ ```
463
+
464
+ ### Atomic first comment (`firstReply`)
465
+
466
+ Posting the first comment no longer needs a create-thread-then-reply two-step (which
467
+ could leave an orphan empty thread if the reply failed). Pass `firstReply` to create
468
+ the thread and its first reply in one atomic call:
469
+
470
+ ```typescript
471
+ await app.threads.create(collectionId, appId, {
472
+ parentType: 'memory', parentId: memoryId, proofId,
473
+ firstReply: { text: 'Lovely photo', authorName: 'Sam' },
474
+ });
475
+ ```
476
+
477
+ ### Deleting a reply (moderation)
478
+
479
+ Replies carry a stable `id`. Remove a single one — authorised for the reply's author,
480
+ the proof owner, or a collection admin:
481
+
482
+ ```typescript
483
+ await app.threads.deleteReply(collectionId, appId, threadId, replyId);
484
+ ```
485
+
486
+ ### Grant-tier access (private, shareable comments)
487
+
488
+ A comment thread on a shared album should be `visibility: 'owner'` (private to the
489
+ proof), with access coming from a **share grant** rather than opening the data to the
490
+ world. A holder of an active `read`/`comment` grant on the proof is treated as
491
+ owner-tier **for that proof only**. Enable it with the `publicCreate.threads.grant`
492
+ policy branch and carry the token via `setGrantToken`.
493
+
494
+ See **[Proof Share Grants](proof-share-grants.md)** for the full flow (create/redeem
495
+ grants, guest commenting, revocation, and the app config).
496
+
432
497
  ---
433
498
 
434
499
  ## Records
@@ -243,6 +243,87 @@ await Api.asset.bulkDelete({
243
243
 
244
244
  ---
245
245
 
246
+ ## Resumable uploads (large files, e.g. video)
247
+
248
+ `asset.upload()` is a single request — if the connection drops, the whole file
249
+ restarts. For large files on flaky connections (e.g. phone video), use a
250
+ **resumable** upload: the file is chunked directly to storage and can be paused,
251
+ resumed, and — crucially — **continued after a page reload or app restart**.
252
+
253
+ ```typescript
254
+ import { asset } from '@proveanything/smartlinks'
255
+
256
+ // 1. Open a resumable upload.
257
+ const handle = await asset.createResumableUpload({
258
+ file, // a File (input[type=file] / drag-drop)
259
+ scope: { type: 'proof', collectionId, productId, proofId },
260
+ appId: 'photo-memory',
261
+ // token: uploadToken, // for public/token uploads (see below)
262
+ })
263
+
264
+ // 2. Persist handle.id so the upload survives a reload.
265
+ localStorage.setItem('pendingUpload', handle.id)
266
+
267
+ // 3. Upload. Resumes automatically from the offset storage already holds.
268
+ const uploaded = await handle.start({
269
+ onProgress: (pct) => setProgress(pct), // 0–100
270
+ signal: abortController.signal, // optional: cancel a stalled upload
271
+ })
272
+ localStorage.removeItem('pendingUpload')
273
+ ```
274
+
275
+ ### Pause / resume, and resume after a reload
276
+
277
+ ```typescript
278
+ handle.pause() // stop after the current chunk
279
+ await handle.resume({ onProgress }) // continue
280
+
281
+ // After a reload / app kill — rehydrate from the persisted id and the same file:
282
+ const saved = localStorage.getItem('pendingUpload')
283
+ if (saved) {
284
+ const handle = await asset.resumeUpload(saved, file)
285
+ await handle.start({ onProgress }) // continues, does not restart
286
+ }
287
+ ```
288
+
289
+ ### API
290
+
291
+ ```typescript
292
+ namespace asset {
293
+ createResumableUpload(options: CreateResumableUploadOptions): Promise<ResumableUploadHandle>
294
+ resumeUpload(handleId: string, file: File): Promise<ResumableUploadHandle>
295
+ }
296
+
297
+ interface CreateResumableUploadOptions {
298
+ file: File
299
+ scope: { type: 'collection'; collectionId: string }
300
+ | { type: 'product'; collectionId: string; productId: string }
301
+ | { type: 'proof'; collectionId: string; productId: string; proofId: string }
302
+ name?: string
303
+ metadata?: Record<string, any>
304
+ appId?: string
305
+ admin?: boolean // admin route (default is the public route)
306
+ token?: string // upload token for public/unauthenticated uploads
307
+ }
308
+
309
+ interface ResumableUploadHandle {
310
+ readonly id: string // durable, persistable — pass to resumeUpload() after a reload
311
+ readonly size: number // total bytes
312
+ start(opts?: { onProgress?: (pct: number) => void; signal?: AbortSignal }): Promise<Asset>
313
+ pause(): void
314
+ resume(opts?: { onProgress?: (pct: number) => void; signal?: AbortSignal }): Promise<Asset>
315
+ }
316
+ ```
317
+
318
+ **Notes**
319
+
320
+ - `handle.id` is an opaque string that carries everything needed to resume — persist it as-is.
321
+ - On completion, `start()`/`resume()` resolves to the finalized `Asset` record.
322
+ - `pause()` causes the in-flight `start()`/`resume()` promise to reject with an `UploadPausedError`; call `resume()` to continue.
323
+ - `asset.upload()` also now accepts an `AbortSignal` (`upload({ ..., signal })`) for cancelling a stalled single-shot upload.
324
+
325
+ ---
326
+
246
327
  ## Public (token-based) uploads
247
328
 
248
329
  For anonymous or contact-initiated uploads from the portal — no admin auth required.
@@ -502,6 +502,79 @@ 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 (via the admin console). **The API
508
+ enforces all of it**; your login UI reads the policy for UX only (a live password checklist,
509
+ idle sign-out). Read it from the config:
510
+
511
+ ```ts
512
+ const config = await authKit.load(clientId);
513
+ const policy = config.security?.passwordPolicy; // min length, char classes, block-common
514
+ const session = config.security?.session; // inactivity + absolute timeouts, rememberMe
515
+ ```
516
+
517
+ `lockout` values are admin-only and never returned here.
518
+
519
+ ### Password policy
520
+
521
+ `register`, `completePasswordReset`, and `changePassword` validate the new password
522
+ server-side and throw a `SmartlinksApiError` with a {@link PasswordPolicyErrorCode}:
523
+
524
+ | `errorCode` (400) | Meaning |
525
+ |---|---|
526
+ | `PASSWORD_TOO_SHORT` | below `minLength` |
527
+ | `PASSWORD_REQUIREMENTS_NOT_MET` | missing a required character class |
528
+ | `PASSWORD_TOO_COMMON` | on the common/breached list |
529
+ | `PASSWORD_RECENTLY_USED` | matched one of the last `historyCount` passwords |
530
+
531
+ Render a live checklist from `policy` so users see the rules before submitting.
532
+
533
+ ### Lockout
534
+
535
+ After too many failed logins the account is temporarily locked. `login` throws:
536
+
537
+ ```ts
538
+ try {
539
+ await authKit.login(clientId, email, password);
540
+ } catch (err) {
541
+ if (err.errorCode === 'ACCOUNT_TEMPORARILY_LOCKED') {
542
+ const mins = Math.ceil(err.details.retryAfterSeconds / 60);
543
+ show(`Too many attempts. Try again in ${mins} minute(s).`);
544
+ }
545
+ }
546
+ ```
547
+
548
+ Failed MFA challenges count toward the same lock. Locking responds identically for unknown
549
+ accounts (no enumeration).
550
+
551
+ ### Password expiry
552
+
553
+ If a password is older than `passwordPolicy.expiryDays`, a valid login is refused with
554
+ **403 `PASSWORD_EXPIRED`** carrying a short-lived `resetToken` — send the user straight into
555
+ the reset form to change it in place:
556
+
557
+ ```ts
558
+ catch (err) {
559
+ if (err.errorCode === 'PASSWORD_EXPIRED') {
560
+ await authKit.completePasswordReset(clientId, err.details.resetToken, newPassword);
561
+ }
562
+ }
563
+ ```
564
+
565
+ ### Session lifetime
566
+
567
+ - **Absolute timeout** (`session.absoluteTimeoutHours`) is enforced server-side on the native
568
+ refresh path: once the session is too old, `refreshToken` throws **401 `SESSION_EXPIRED`**
569
+ (see {@link RefreshErrorCode}) — clear storage and route to login. Web sessions use the
570
+ stateless bearer token and rely on inactivity sign-out below.
571
+ - **Inactivity timeout** (`session.inactivityTimeoutMinutes` / `inactivityWarningSeconds`) is
572
+ **client-enforced** — sign the user out after idle, warning first. Sync across tabs.
573
+ - **`session.rememberMe: false`** → don't persist tokens to durable storage; treat the session
574
+ as browser-scoped.
575
+
576
+ ---
577
+
505
578
  ## Relationship to other parts of the SDK
506
579
 
507
580
  | Concern | Where it lives |