@proveanything/smartlinks 1.15.17 → 1.15.18
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 +8 -0
- package/dist/api/appObjects.d.ts +6 -0
- package/dist/api/appObjects.js +10 -0
- package/dist/docs/API_SUMMARY.md +12 -1
- package/dist/docs/app-objects.md +65 -0
- package/dist/docs/assets.md +81 -0
- package/dist/docs/proof-share-grants.md +232 -0
- package/dist/openapi.yaml +73 -0
- package/dist/types/appObjects.d.ts +14 -0
- package/docs/API_SUMMARY.md +12 -1
- package/docs/app-objects.md +65 -0
- package/docs/assets.md +81 -0
- package/docs/proof-share-grants.md +232 -0
- package/openapi.yaml +73 -0
- package/package.json +1 -1
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,6 +36,8 @@ 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)
|
|
@@ -44,6 +49,9 @@ For the full list of functions and types, see the API summary:
|
|
|
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
|
package/dist/api/appObjects.d.ts
CHANGED
|
@@ -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
|
package/dist/api/appObjects.js
CHANGED
|
@@ -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
|
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.15.
|
|
3
|
+
Version: 1.15.18 | Generated: 2026-08-19T06:40:47.505Z
|
|
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
|
|
|
@@ -8435,6 +8439,13 @@ Soft delete a thread DELETE /threads/:threadId
|
|
|
8435
8439
|
admin: boolean = false) → `Promise<AppThread>`
|
|
8436
8440
|
Add a reply to a thread POST /threads/:threadId/reply Atomically appends to replies array, increments replyCount, updates lastReplyAt
|
|
8437
8441
|
|
|
8442
|
+
**deleteReply**(collectionId: string,
|
|
8443
|
+
appId: string,
|
|
8444
|
+
threadId: string,
|
|
8445
|
+
replyId: string,
|
|
8446
|
+
admin: boolean = false) → `Promise<AppThread>`
|
|
8447
|
+
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.
|
|
8448
|
+
|
|
8438
8449
|
**aggregate**(collectionId: string,
|
|
8439
8450
|
appId: string,
|
|
8440
8451
|
request: AggregateRequest,
|
package/dist/docs/app-objects.md
CHANGED
|
@@ -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/dist/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.
|
|
@@ -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
|
+
```
|
package/dist/openapi.yaml
CHANGED
|
@@ -13941,6 +13941,23 @@ paths:
|
|
|
13941
13941
|
required: false
|
|
13942
13942
|
schema:
|
|
13943
13943
|
type: string
|
|
13944
|
+
- name: parentIds
|
|
13945
|
+
in: query
|
|
13946
|
+
required: false
|
|
13947
|
+
schema:
|
|
13948
|
+
type: array
|
|
13949
|
+
items:
|
|
13950
|
+
type: string
|
|
13951
|
+
- name: proofId
|
|
13952
|
+
in: query
|
|
13953
|
+
required: false
|
|
13954
|
+
schema:
|
|
13955
|
+
type: string
|
|
13956
|
+
- name: productId
|
|
13957
|
+
in: query
|
|
13958
|
+
required: false
|
|
13959
|
+
schema:
|
|
13960
|
+
type: string
|
|
13944
13961
|
- name: tag
|
|
13945
13962
|
in: query
|
|
13946
13963
|
required: false
|
|
@@ -14182,6 +14199,52 @@ paths:
|
|
|
14182
14199
|
application/json:
|
|
14183
14200
|
schema:
|
|
14184
14201
|
$ref: "#/components/schemas/ReplyInput"
|
|
14202
|
+
/{zone}/collection/{collectionId}/app/{appId}/threads/{threadId}/reply/{replyId}:
|
|
14203
|
+
delete:
|
|
14204
|
+
tags:
|
|
14205
|
+
- threads
|
|
14206
|
+
summary: Delete a single reply from a thread by its reply id (moderation).
|
|
14207
|
+
operationId: threads_deleteReply
|
|
14208
|
+
security: []
|
|
14209
|
+
parameters:
|
|
14210
|
+
- name: zone
|
|
14211
|
+
in: path
|
|
14212
|
+
required: true
|
|
14213
|
+
schema:
|
|
14214
|
+
type: string
|
|
14215
|
+
- name: collectionId
|
|
14216
|
+
in: path
|
|
14217
|
+
required: true
|
|
14218
|
+
schema:
|
|
14219
|
+
type: string
|
|
14220
|
+
- name: appId
|
|
14221
|
+
in: path
|
|
14222
|
+
required: true
|
|
14223
|
+
schema:
|
|
14224
|
+
type: string
|
|
14225
|
+
- name: threadId
|
|
14226
|
+
in: path
|
|
14227
|
+
required: true
|
|
14228
|
+
schema:
|
|
14229
|
+
type: string
|
|
14230
|
+
- name: replyId
|
|
14231
|
+
in: path
|
|
14232
|
+
required: true
|
|
14233
|
+
schema:
|
|
14234
|
+
type: string
|
|
14235
|
+
responses:
|
|
14236
|
+
200:
|
|
14237
|
+
description: Success
|
|
14238
|
+
content:
|
|
14239
|
+
application/json:
|
|
14240
|
+
schema:
|
|
14241
|
+
$ref: "#/components/schemas/AppThread"
|
|
14242
|
+
400:
|
|
14243
|
+
description: Bad request
|
|
14244
|
+
401:
|
|
14245
|
+
description: Unauthorized
|
|
14246
|
+
404:
|
|
14247
|
+
description: Not found
|
|
14185
14248
|
components:
|
|
14186
14249
|
securitySchemes:
|
|
14187
14250
|
bearerAuth:
|
|
@@ -16967,6 +17030,8 @@ components:
|
|
|
16967
17030
|
admin:
|
|
16968
17031
|
type: object
|
|
16969
17032
|
additionalProperties: true
|
|
17033
|
+
firstReply:
|
|
17034
|
+
$ref: "#/components/schemas/ReplyInput"
|
|
16970
17035
|
UpdateThreadInput:
|
|
16971
17036
|
type: object
|
|
16972
17037
|
properties:
|
|
@@ -17012,6 +17077,14 @@ components:
|
|
|
17012
17077
|
type: string
|
|
17013
17078
|
parentId:
|
|
17014
17079
|
type: string
|
|
17080
|
+
parentIds:
|
|
17081
|
+
type: array
|
|
17082
|
+
items:
|
|
17083
|
+
type: string
|
|
17084
|
+
proofId:
|
|
17085
|
+
type: string
|
|
17086
|
+
productId:
|
|
17087
|
+
type: string
|
|
17015
17088
|
tag:
|
|
17016
17089
|
type: string
|
|
17017
17090
|
contactId:
|
|
@@ -250,6 +250,12 @@ export interface CreateThreadInput {
|
|
|
250
250
|
data?: Record<string, unknown>;
|
|
251
251
|
owner?: Record<string, unknown>;
|
|
252
252
|
admin?: Record<string, unknown>;
|
|
253
|
+
/**
|
|
254
|
+
* Optional atomic first reply. Posting a comment no longer needs a separate
|
|
255
|
+
* create-thread-then-reply round trip (which could orphan an empty thread on
|
|
256
|
+
* partial failure). The reply is stored with a generated `id` and timestamp.
|
|
257
|
+
*/
|
|
258
|
+
firstReply?: ReplyInput;
|
|
253
259
|
}
|
|
254
260
|
/**
|
|
255
261
|
* Input for updating a thread
|
|
@@ -279,8 +285,16 @@ export interface ReplyInput {
|
|
|
279
285
|
export interface ThreadListQueryParams extends ListQueryParams {
|
|
280
286
|
slug?: string;
|
|
281
287
|
authorId?: string;
|
|
288
|
+
/** Disambiguates the parent entity kind (e.g. "memory", "case", "proof"). */
|
|
282
289
|
parentType?: string;
|
|
290
|
+
/** Anchor to a single app entity id (text — SmartLinks short ids, not UUIDs). */
|
|
283
291
|
parentId?: string;
|
|
292
|
+
/** Batch-fetch threads for many app entities in one call (e.g. a memory feed). */
|
|
293
|
+
parentIds?: string[];
|
|
294
|
+
/** Anchor threads to a proof. For grant-token callers this is the enforced filter. */
|
|
295
|
+
proofId?: string;
|
|
296
|
+
/** Anchor threads to a product (one tier up from proof). */
|
|
297
|
+
productId?: string;
|
|
284
298
|
tag?: string;
|
|
285
299
|
contactId?: string;
|
|
286
300
|
}
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 1.15.
|
|
3
|
+
Version: 1.15.18 | Generated: 2026-08-19T06:40:47.505Z
|
|
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
|
|
|
@@ -8435,6 +8439,13 @@ Soft delete a thread DELETE /threads/:threadId
|
|
|
8435
8439
|
admin: boolean = false) → `Promise<AppThread>`
|
|
8436
8440
|
Add a reply to a thread POST /threads/:threadId/reply Atomically appends to replies array, increments replyCount, updates lastReplyAt
|
|
8437
8441
|
|
|
8442
|
+
**deleteReply**(collectionId: string,
|
|
8443
|
+
appId: string,
|
|
8444
|
+
threadId: string,
|
|
8445
|
+
replyId: string,
|
|
8446
|
+
admin: boolean = false) → `Promise<AppThread>`
|
|
8447
|
+
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.
|
|
8448
|
+
|
|
8438
8449
|
**aggregate**(collectionId: string,
|
|
8439
8450
|
appId: string,
|
|
8440
8451
|
request: AggregateRequest,
|
package/docs/app-objects.md
CHANGED
|
@@ -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.
|
|
@@ -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
|
+
```
|
package/openapi.yaml
CHANGED
|
@@ -13941,6 +13941,23 @@ paths:
|
|
|
13941
13941
|
required: false
|
|
13942
13942
|
schema:
|
|
13943
13943
|
type: string
|
|
13944
|
+
- name: parentIds
|
|
13945
|
+
in: query
|
|
13946
|
+
required: false
|
|
13947
|
+
schema:
|
|
13948
|
+
type: array
|
|
13949
|
+
items:
|
|
13950
|
+
type: string
|
|
13951
|
+
- name: proofId
|
|
13952
|
+
in: query
|
|
13953
|
+
required: false
|
|
13954
|
+
schema:
|
|
13955
|
+
type: string
|
|
13956
|
+
- name: productId
|
|
13957
|
+
in: query
|
|
13958
|
+
required: false
|
|
13959
|
+
schema:
|
|
13960
|
+
type: string
|
|
13944
13961
|
- name: tag
|
|
13945
13962
|
in: query
|
|
13946
13963
|
required: false
|
|
@@ -14182,6 +14199,52 @@ paths:
|
|
|
14182
14199
|
application/json:
|
|
14183
14200
|
schema:
|
|
14184
14201
|
$ref: "#/components/schemas/ReplyInput"
|
|
14202
|
+
/{zone}/collection/{collectionId}/app/{appId}/threads/{threadId}/reply/{replyId}:
|
|
14203
|
+
delete:
|
|
14204
|
+
tags:
|
|
14205
|
+
- threads
|
|
14206
|
+
summary: Delete a single reply from a thread by its reply id (moderation).
|
|
14207
|
+
operationId: threads_deleteReply
|
|
14208
|
+
security: []
|
|
14209
|
+
parameters:
|
|
14210
|
+
- name: zone
|
|
14211
|
+
in: path
|
|
14212
|
+
required: true
|
|
14213
|
+
schema:
|
|
14214
|
+
type: string
|
|
14215
|
+
- name: collectionId
|
|
14216
|
+
in: path
|
|
14217
|
+
required: true
|
|
14218
|
+
schema:
|
|
14219
|
+
type: string
|
|
14220
|
+
- name: appId
|
|
14221
|
+
in: path
|
|
14222
|
+
required: true
|
|
14223
|
+
schema:
|
|
14224
|
+
type: string
|
|
14225
|
+
- name: threadId
|
|
14226
|
+
in: path
|
|
14227
|
+
required: true
|
|
14228
|
+
schema:
|
|
14229
|
+
type: string
|
|
14230
|
+
- name: replyId
|
|
14231
|
+
in: path
|
|
14232
|
+
required: true
|
|
14233
|
+
schema:
|
|
14234
|
+
type: string
|
|
14235
|
+
responses:
|
|
14236
|
+
200:
|
|
14237
|
+
description: Success
|
|
14238
|
+
content:
|
|
14239
|
+
application/json:
|
|
14240
|
+
schema:
|
|
14241
|
+
$ref: "#/components/schemas/AppThread"
|
|
14242
|
+
400:
|
|
14243
|
+
description: Bad request
|
|
14244
|
+
401:
|
|
14245
|
+
description: Unauthorized
|
|
14246
|
+
404:
|
|
14247
|
+
description: Not found
|
|
14185
14248
|
components:
|
|
14186
14249
|
securitySchemes:
|
|
14187
14250
|
bearerAuth:
|
|
@@ -16967,6 +17030,8 @@ components:
|
|
|
16967
17030
|
admin:
|
|
16968
17031
|
type: object
|
|
16969
17032
|
additionalProperties: true
|
|
17033
|
+
firstReply:
|
|
17034
|
+
$ref: "#/components/schemas/ReplyInput"
|
|
16970
17035
|
UpdateThreadInput:
|
|
16971
17036
|
type: object
|
|
16972
17037
|
properties:
|
|
@@ -17012,6 +17077,14 @@ components:
|
|
|
17012
17077
|
type: string
|
|
17013
17078
|
parentId:
|
|
17014
17079
|
type: string
|
|
17080
|
+
parentIds:
|
|
17081
|
+
type: array
|
|
17082
|
+
items:
|
|
17083
|
+
type: string
|
|
17084
|
+
proofId:
|
|
17085
|
+
type: string
|
|
17086
|
+
productId:
|
|
17087
|
+
type: string
|
|
17015
17088
|
tag:
|
|
17016
17089
|
type: string
|
|
17017
17090
|
contactId:
|