@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.
@@ -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
package/docs/assets.md CHANGED
@@ -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.
package/docs/auth-kit.md CHANGED
@@ -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 |
@@ -0,0 +1,232 @@
1
+ # Proof Share Grants
2
+
3
+ Delegated, scoped, **revocable** bearer access to a single proof — the middle tier
4
+ between "public" (everyone) and "owner" (only the signed-in owner).
5
+
6
+ A grant lets an owner hand out a link that lets specific recipients **see or do
7
+ specific things on one proof for a limited time**, without those recipients needing
8
+ a SmartLinks account or a proof claim. Typical uses: sharing a private photo album,
9
+ letting guests comment on it, or publishing a verifiable "I own this" assertion.
10
+
11
+ ---
12
+
13
+ ## Concepts
14
+
15
+ A **grant** is a row issued by the proof owner (or a collection admin) and redeemed
16
+ by a bearer holding an opaque token. Every data request that touches the proof
17
+ re-checks the grant **server-side** against the database, so revocation is immediate.
18
+
19
+ ### Scopes — what a grant authorises
20
+
21
+ | Scope | Grants the bearer… |
22
+ |-------|--------------------|
23
+ | `read` | read owner-tier data on the proof (attestations, threads, records, cases) |
24
+ | `comment` | create threads/replies on the proof (guest comments) |
25
+ | `admin` | read owner-tier data (reserved for elevated share cases; never exposes the platform admin zone) |
26
+ | `verify_owner` | redeem a shareable ownership **assertion** (not the account) |
27
+
28
+ A grant can carry several scopes, e.g. `['read', 'comment']` for a shareable,
29
+ commentable album.
30
+
31
+ ### Security & lifecycle
32
+
33
+ - The token is opaque, unguessable, and returned to the issuer **exactly once** (on `createGrant`). It is never returned by `listGrants`.
34
+ - **Revocation is immediate** — the grant is re-checked on every request, so `revokeGrant` invalidates a token across all clients at once.
35
+ - **Auto-invalidation on transfer** — every grant is voided the moment the proof's `ownerId` changes (e.g. a resale/re-claim), so a stale "I own this" link cannot keep resolving.
36
+ - A grant is scoped to **one proof**; it can never widen access to other proofs or collection-level data.
37
+
38
+ ---
39
+
40
+ ## Owner flow — create, list, revoke
41
+
42
+ ```typescript
43
+ import { proof } from '@proveanything/smartlinks'
44
+
45
+ // Create a read+comment grant that expires in 7 days.
46
+ const grant = await proof.createGrant(collectionId, productId, proofId, {
47
+ scope: ['read', 'comment'],
48
+ audience: { kind: 'public_link' },
49
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
50
+ })
51
+
52
+ // `grant.token` is available ONLY here — embed it in your share link now.
53
+ const shareUrl = `https://app.example.com/album?proofId=${proofId}&shareToken=${grant.token}`
54
+
55
+ // List active + past grants (tokens are never included).
56
+ const grants = await proof.listGrants(collectionId, productId, proofId)
57
+
58
+ // Stop sharing — takes effect on the very next request from any client.
59
+ await proof.revokeGrant(collectionId, productId, proofId, grant.grantId)
60
+ ```
61
+
62
+ `createGrant` / `listGrants` / `revokeGrant` require the caller to be the **proof
63
+ owner** (or a collection admin) — i.e. a signed-in user whose `bearerToken` is set.
64
+
65
+ ---
66
+
67
+ ## Recipient flow — redeem, then carry the token
68
+
69
+ A recipient opens the share link, redeems the token once, then sets it as the
70
+ active grant token. From then on **every** SDK request carries the token
71
+ (`X-Grant-Token`), so all proof reads/writes are evaluated against the grant.
72
+
73
+ ```typescript
74
+ import { proof, setGrantToken } from '@proveanything/smartlinks'
75
+
76
+ const shareToken = new URLSearchParams(location.search).get('shareToken')!
77
+
78
+ // Redeem once (anonymous or signed-in). Records the redemption; optionally names the guest.
79
+ await proof.redeemGrant(collectionId, productId, proofId, shareToken, {
80
+ guestName: 'Sam', // stamped on guest activity when not signed in
81
+ })
82
+
83
+ // Attach the token to every subsequent request.
84
+ setGrantToken(shareToken)
85
+
86
+ // Now grant-tier reads succeed — e.g. owner-visibility memories on the proof:
87
+ const { attestations } = await attestation.publicList(collectionId, {
88
+ subjectType: 'proof', subjectId: proofId,
89
+ })
90
+
91
+ // Clear it when leaving the shared view:
92
+ setGrantToken(undefined)
93
+ ```
94
+
95
+ > **Persisting across reloads.** `setGrantToken` holds the token in memory. To keep a
96
+ > shared session across reloads, persist `shareToken` yourself (e.g. in `localStorage`)
97
+ > and call `setGrantToken` again on load.
98
+
99
+ ---
100
+
101
+ ## Guest commenting
102
+
103
+ With a `comment` scope grant, a bearer can post comments even when they are not
104
+ signed in. The app must enable the **grant branch** of the thread create policy
105
+ (see [App config](#app-config)); comments are then created at `visibility: 'owner'`
106
+ (private to the proof) and stamped `authorType: 'guest'`.
107
+
108
+ ```typescript
109
+ import { app } from '@proveanything/smartlinks'
110
+ // setGrantToken(shareToken) has already been called.
111
+
112
+ // One atomic call — no separate create-then-reply round trip.
113
+ await app.threads.create(collectionId, 'photo-memory', {
114
+ parentType: 'memory',
115
+ parentId: memoryId, // text — SmartLinks short ids are fine (not just UUIDs)
116
+ proofId, // anchor to the proof so grant readers see it
117
+ firstReply: { text: 'Lovely photo', authorName: 'Sam' },
118
+ })
119
+ ```
120
+
121
+ Other grant holders (and the owner) see these comments because a `read` grant reveals
122
+ `owner`-visibility threads for the proof. See
123
+ [App Objects → Threads](app-objects.md#threads) for the full threads API.
124
+
125
+ ---
126
+
127
+ ## Proof of ownership — `verify_owner`
128
+
129
+ Ownership itself is **not** a grant — it is `proof.ownerId`, established via the
130
+ existing [claim flow](proof-claiming-methods.md). A `verify_owner` grant only
131
+ publishes a shareable, verifiable **assertion** derived from that ownership, without
132
+ handing over the account:
133
+
134
+ ```typescript
135
+ const grant = await proof.createGrant(collectionId, productId, proofId, {
136
+ scope: ['verify_owner'],
137
+ })
138
+
139
+ // The recipient redeems it and gets the assertion — never the account:
140
+ const result = await proof.redeemGrant(collectionId, productId, proofId, grant.token)
141
+ // { proofId, assertsOwnership: true, ownerDisplayName?, issuedAt, expiresAt }
142
+ ```
143
+
144
+ Because grants auto-invalidate on transfer, a resale cannot leave a stale
145
+ "I own this" link in circulation.
146
+
147
+ ---
148
+
149
+ ## What a grant gates
150
+
151
+ When a valid grant token is present, these public reads elevate to owner-tier for the
152
+ granted proof (and only that proof):
153
+
154
+ - **Attestations** — `attestation.publicList({ subjectType: 'proof', subjectId })`
155
+ - **Threads / Records / Cases** — `app.threads.list`, `app.records.*`, `app.cases.list`, and the single-item GETs, filtered to the granted proof
156
+ - **Thread creation / replies** — with a `comment` scope grant (see below)
157
+
158
+ The token never exposes the platform `admin` zone, and only reveals `owner`-visibility
159
+ rows for the granted `proofId`.
160
+
161
+ ---
162
+
163
+ ## App config
164
+
165
+ Grant-based commenting is opt-in per app, configured on the app's Firestore config
166
+ at `sites/{collectionId}/apps/{appId}` — a `grant` branch alongside
167
+ `anonymous` / `authenticated`:
168
+
169
+ ```jsonc
170
+ {
171
+ "publicCreate": {
172
+ "threads": {
173
+ "grant": {
174
+ "allow": true,
175
+ "requireScope": "comment",
176
+ "enforce": { "visibility": "owner", "status": "open" }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ This enables grant-scoped commenting **without** opening up anonymous creation. The
184
+ `enforce.visibility: "owner"` keeps comments private to the proof (visible to the
185
+ owner and other grant holders, not the wider public).
186
+
187
+ ---
188
+
189
+ ## API reference
190
+
191
+ ```typescript
192
+ namespace proof {
193
+ createGrant(collectionId, productId, proofId, options: CreateGrantOptions): Promise<ProofGrant>
194
+ listGrants(collectionId, productId, proofId): Promise<ProofGrant[]>
195
+ revokeGrant(collectionId, productId, proofId, grantId): Promise<void>
196
+ redeemGrant(collectionId, productId, proofId, token, options?: RedeemGrantOptions): Promise<RedeemGrantResult>
197
+ }
198
+
199
+ // Attach / clear the active grant token (sent as X-Grant-Token on every request).
200
+ function setGrantToken(token: string | undefined): void
201
+ function getGrantToken(): string | undefined
202
+
203
+ type GrantScope = 'read' | 'comment' | 'admin' | 'verify_owner'
204
+
205
+ interface CreateGrantOptions {
206
+ scope: GrantScope[] // at least one
207
+ audience?: { kind: 'public_link' } | { kind: 'named'; email?: string; userId?: string }
208
+ expiresAt?: Date | string
209
+ }
210
+
211
+ interface RedeemGrantOptions { guestName?: string }
212
+
213
+ type RedeemGrantResult =
214
+ | { scope: GrantScope[]; redeemedAt: string }
215
+ | { proofId: string; assertsOwnership: true; ownerDisplayName?: string; issuedAt?: string; expiresAt?: string }
216
+
217
+ interface ProofGrant {
218
+ grantId: string
219
+ proofId: string
220
+ productId?: string | null
221
+ scope: GrantScope[]
222
+ audience: { kind: 'public_link' | 'named'; email?: string; userId?: string }
223
+ createdBy: string
224
+ expiresAt?: string | null
225
+ revokedAt?: string | null
226
+ redeemedBy?: { userId?: string; guestName?: string; redeemedAt: string }
227
+ redeemCount: number
228
+ createdAt: string
229
+ updatedAt: string
230
+ token?: string // present ONLY on the createGrant response
231
+ }
232
+ ```