@proveanything/smartlinks 1.6.6 → 1.7.0

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.
@@ -0,0 +1,498 @@
1
+ # Attestations
2
+
3
+ > Postgres-backed, append-only fact log for any object in the system.
4
+
5
+ The new `attestations` API replaces the legacy Firestore attestation endpoints and adds polymorphic subjects, three-tier data visibility, cryptographic hash-chain integrity, and time-series analytics.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [Overview](#overview)
12
+ - [Migration from Legacy Attestations](#migration-from-legacy-attestations)
13
+ - [TypeScript Interfaces](#typescript-interfaces)
14
+ - [Security & Audience Tiers](#security--audience-tiers)
15
+ - [SDK Usage](#sdk-usage)
16
+ - [Admin — Write](#admin--write)
17
+ - [Admin — Read](#admin--read)
18
+ - [Public — Read](#public--read)
19
+ - [Container-Scoped Shortcuts](#container-scoped-shortcuts)
20
+ - [REST API Reference](#rest-api-reference)
21
+ - [Design Notes](#design-notes)
22
+
23
+ ---
24
+
25
+ ## Overview
26
+
27
+ An **attestation** is an immutable fact record attached to a subject — a container, a proof, a product, a tag, or any other typed entity. Records are append-only: once written they are never updated or deleted.
28
+
29
+ Key properties:
30
+
31
+ | Property | Description |
32
+ |---|---|
33
+ | **Polymorphic** | One endpoint covers any `subjectType` |
34
+ | **Three data zones** | `value` (public), `ownerData` (owner-tier), `adminData` (admin-tier) |
35
+ | **Per-record visibility** | Each record carries its own `visibility` flag |
36
+ | **Hash-chain integrity** | SHA-256 `contentHash` chains to the previous record for the same `(subjectType, subjectId, attestationType)` tuple |
37
+ | **Tree analytics** | Rollup queries aggregate across an entire container subtree via BFS traversal |
38
+
39
+ ---
40
+
41
+ ## Migration from Legacy Attestations
42
+
43
+ | | Legacy (Firestore) | New (Postgres) |
44
+ |---|---|---|
45
+ | **Namespace** | `attestation` | `attestations` |
46
+ | **Subject scope** | Proof only (`/product/:pid/proof/:id/attestation`) | Any type: `container`, `proof`, `product`, `tag`, … |
47
+ | **Mutability** | Supports update & delete | Append-only (by design) |
48
+ | **Data zones** | `public`, `private`, `proof` | `value` (public), `ownerData` (owner), `adminData` (admin) |
49
+ | **Integrity** | None | SHA-256 hash chain |
50
+
51
+ The legacy `attestation` namespace (and its types `AttestationResponse`, `AttestationCreateRequest`, `AttestationUpdateRequest`) are marked `@deprecated`. The underlying Firestore endpoints remain active for backward-compatibility, but all new integrations should use the `attestations` namespace.
52
+
53
+ **Migrating a proof attestation write:**
54
+
55
+ ```typescript
56
+ // Before (legacy)
57
+ await attestation.create(collectionId, productId, proofId, {
58
+ public: { note: 'Temperature OK' },
59
+ private: {},
60
+ proof: {},
61
+ })
62
+
63
+ // After (new)
64
+ await attestations.create(collectionId, {
65
+ subjectType: 'proof',
66
+ subjectId: proofId,
67
+ attestationType: 'temperature_check',
68
+ value: { note: 'Temperature OK', celsius: 4.1 },
69
+ unit: '°C',
70
+ })
71
+ ```
72
+
73
+ ---
74
+
75
+ ## TypeScript Interfaces
76
+
77
+ ```typescript
78
+ type AttestationSubjectType = 'container' | 'proof' | 'product' | 'tag' | 'serial' | 'order_item' | string
79
+ type AttestationVisibility = 'public' | 'owner' | 'admin'
80
+ type AttestationAudience = 'public' | 'owner' | 'admin'
81
+ type AttestationGroupBy = 'hour' | 'day' | 'week' | 'month'
82
+
83
+ interface Attestation {
84
+ id: string
85
+ orgId: string
86
+ collectionId: string
87
+ subjectType: AttestationSubjectType
88
+ subjectId: string
89
+ attestationType: string // e.g. 'temperature', 'abv', 'angel_share'
90
+ recordedAt: string // ISO 8601 — when the fact was true
91
+ visibility: AttestationVisibility
92
+ value?: Record<string, any> // Public data zone
93
+ ownerData?: Record<string, any> // Stripped unless audience >= 'owner'
94
+ adminData?: Record<string, any> // Stripped unless audience === 'admin'
95
+ unit?: string
96
+ source?: string
97
+ authorId?: string
98
+ metadata?: Record<string, any>
99
+ contentHash: string // SHA-256 of this record (incl. prevHash)
100
+ prevHash?: string // contentHash of the preceding record
101
+ createdAt: string // ISO 8601 — database insertion time
102
+ }
103
+
104
+ interface LatestAttestation {
105
+ attestationType: string
106
+ latest: Attestation
107
+ }
108
+
109
+ interface AttestationSummaryBucket {
110
+ period: string // e.g. "2025-04-01" for groupBy=day
111
+ count: number
112
+ values?: Record<string, any>
113
+ }
114
+
115
+ interface ChainVerifyResult {
116
+ valid: boolean
117
+ checkedCount: number
118
+ failedAt?: string
119
+ message: string
120
+ }
121
+
122
+ interface CreateAttestationInput {
123
+ subjectType: AttestationSubjectType // required
124
+ subjectId: string // required
125
+ attestationType: string // required
126
+ recordedAt?: string
127
+ visibility?: AttestationVisibility // default 'public'
128
+ value?: Record<string, any>
129
+ ownerData?: Record<string, any>
130
+ adminData?: Record<string, any>
131
+ unit?: string
132
+ source?: string
133
+ authorId?: string
134
+ metadata?: Record<string, any>
135
+ }
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Security & Audience Tiers
141
+
142
+ ### Admin endpoints
143
+
144
+ All admin endpoints are mounted under `/api/v1/admin/collection/:collectionId/attestations` and require a valid admin session or bearer token. The caller always receives all three data zones.
145
+
146
+ ### Public endpoints
147
+
148
+ Public endpoints at `/api/v1/public/collection/:collectionId/attestations` are read-only and require no credentials for `audience='public'` data.
149
+
150
+ **Owner elevation** — supply a Firebase ID token via `Authorization: Bearer <idToken>`:
151
+
152
+ ```
153
+ Authorization: Bearer <Firebase ID Token>
154
+ ```
155
+
156
+ The middleware resolves the UID and checks ownership:
157
+ - `subjectType=proof` → checks `proof.userId` in Firestore
158
+ - `subjectType=container` → checks `container.metadata.proofId`
159
+
160
+ When ownership is confirmed the request is served at `audience='owner'`, which includes `ownerData`.
161
+
162
+ ### Visibility vs audience
163
+
164
+ `visibility` is a property of an **individual record** set at write time. `audience` describes the **caller's tier** resolved at read time. The server applies:
165
+
166
+ | Audience | Included records | Data zones |
167
+ |---|---|---|
168
+ | `'public'` | `visibility='public'` only | `value` only |
169
+ | `'owner'` | `'public'` and `'owner'` | `value`, `ownerData` |
170
+ | `'admin'` | All records | `value`, `ownerData`, `adminData` |
171
+
172
+ ---
173
+
174
+ ## SDK Usage
175
+
176
+ Import via the top-level SDK export:
177
+
178
+ ```typescript
179
+ import { attestations } from '@proveanything/smartlinks'
180
+ ```
181
+
182
+ ### Admin — Write
183
+
184
+ #### Create a single attestation
185
+
186
+ ```typescript
187
+ const record = await attestations.create('coll_123', {
188
+ subjectType: 'container',
189
+ subjectId: 'cask-uuid',
190
+ attestationType: 'temperature',
191
+ recordedAt: '2025-04-15T14:30:00Z',
192
+ value: { celsius: 12.4 },
193
+ ownerData: { sensorId: 'TEMP-7' },
194
+ unit: '°C',
195
+ visibility: 'public',
196
+ })
197
+ ```
198
+
199
+ #### Batch-create attestations
200
+
201
+ ```typescript
202
+ const records = await attestations.createBatch('coll_123', [
203
+ { subjectType: 'container', subjectId: 'cask-uuid', attestationType: 'temperature', value: { celsius: 12.4 } },
204
+ { subjectType: 'container', subjectId: 'cask-uuid', attestationType: 'humidity', value: { rh: 68 } },
205
+ ])
206
+ ```
207
+
208
+ ### Admin — Read
209
+
210
+ #### List attestations
211
+
212
+ ```typescript
213
+ const { attestations: records } = await attestations.list('coll_123', {
214
+ subjectType: 'container',
215
+ subjectId: 'cask-uuid',
216
+ attestationType: 'temperature',
217
+ recordedAfter: '2025-01-01T00:00:00Z',
218
+ limit: 50,
219
+ })
220
+ ```
221
+
222
+ #### Time-series summary
223
+
224
+ ```typescript
225
+ const { summary } = await attestations.summary('coll_123', {
226
+ subjectType: 'container',
227
+ subjectId: 'cask-uuid',
228
+ attestationType: 'temperature',
229
+ valueField: 'celsius',
230
+ groupBy: 'day',
231
+ })
232
+ // summary[0] = { period: '2025-04-01', count: 24, values: { celsius_avg: 12.1 } }
233
+ ```
234
+
235
+ #### Latest snapshot
236
+
237
+ Returns one entry per `attestationType` — the most recent record for each type:
238
+
239
+ ```typescript
240
+ const { latest } = await attestations.latest('coll_123', {
241
+ subjectType: 'container',
242
+ subjectId: 'fridge-uuid',
243
+ })
244
+ // latest[0].attestationType === 'temperature'
245
+ // latest[0].latest.value === { celsius: 4.1 }
246
+ ```
247
+
248
+ #### Verify hash chain
249
+
250
+ ```typescript
251
+ const result = await attestations.verify('coll_123', {
252
+ subjectType: 'container',
253
+ subjectId: 'cask-uuid',
254
+ attestationType: 'temperature',
255
+ })
256
+ if (!result.valid) {
257
+ console.warn('Chain integrity broken at record:', result.failedAt)
258
+ }
259
+ ```
260
+
261
+ #### Tree time-series summary
262
+
263
+ Aggregates attestations across an **entire container subtree** (BFS traversal from a root container):
264
+
265
+ ```typescript
266
+ const { summary, subjectCount } = await attestations.treeSummary('coll_123', {
267
+ subjectId: 'warehouse-uuid',
268
+ attestationType: 'temperature',
269
+ valueField: 'celsius',
270
+ groupBy: 'hour',
271
+ includeItems: true,
272
+ })
273
+ console.log(`Aggregated over ${subjectCount} subjects`)
274
+ ```
275
+
276
+ #### Tree latest snapshot
277
+
278
+ ```typescript
279
+ const { latest, subjectCount } = await attestations.treeLatest('coll_123', {
280
+ subjectId: 'warehouse-uuid',
281
+ includeItems: true,
282
+ })
283
+ ```
284
+
285
+ ### Public — Read
286
+
287
+ #### List (with optional owner elevation)
288
+
289
+ ```typescript
290
+ const { attestations: records, audience } = await attestations.publicList('coll_123', {
291
+ subjectType: 'proof',
292
+ subjectId: 'proof-uuid',
293
+ })
294
+ // audience === 'owner' when a valid Firebase token was provided
295
+ ```
296
+
297
+ #### Time-series summary (always public)
298
+
299
+ ```typescript
300
+ const { summary } = await attestations.publicSummary('coll_123', {
301
+ subjectType: 'container',
302
+ subjectId: 'cask-uuid',
303
+ attestationType: 'temperature',
304
+ groupBy: 'week',
305
+ })
306
+ ```
307
+
308
+ #### Latest snapshot
309
+
310
+ ```typescript
311
+ const { latest, audience } = await attestations.publicLatest('coll_123', {
312
+ subjectType: 'container',
313
+ subjectId: 'cask-uuid',
314
+ })
315
+ ```
316
+
317
+ #### Tree variants
318
+
319
+ ```typescript
320
+ const { summary, subjectCount } = await attestations.publicTreeSummary('coll_123', {
321
+ subjectId: 'warehouse-uuid',
322
+ attestationType: 'temperature',
323
+ groupBy: 'day',
324
+ })
325
+
326
+ const { latest } = await attestations.publicTreeLatest('coll_123', {
327
+ subjectId: 'warehouse-uuid',
328
+ })
329
+ ```
330
+
331
+ ### Container-Scoped Shortcuts
332
+
333
+ The public containers router re-exposes attestation endpoints pre-scoped to `subjectType=container`. Use these when you already have a `containerId`:
334
+
335
+ ```typescript
336
+ // List attestations for a container
337
+ const { attestations: records } = await attestations.publicContainerList(
338
+ 'coll_123',
339
+ 'cask-uuid',
340
+ { attestationType: 'temperature', limit: 10 }
341
+ )
342
+
343
+ // Latest snapshot
344
+ const { latest } = await attestations.publicContainerLatest('coll_123', 'cask-uuid')
345
+
346
+ // Time-series summary
347
+ const { summary } = await attestations.publicContainerSummary('coll_123', 'cask-uuid', {
348
+ attestationType: 'temperature',
349
+ valueField: 'celsius',
350
+ groupBy: 'day',
351
+ })
352
+
353
+ // Tree summary rooted at this container
354
+ const { summary: treeSummary } = await attestations.publicContainerTreeSummary('coll_123', 'warehouse-uuid', {
355
+ attestationType: 'temperature',
356
+ groupBy: 'hour',
357
+ })
358
+
359
+ // Tree latest
360
+ const { latest: treeLatest } = await attestations.publicContainerTreeLatest('coll_123', 'warehouse-uuid')
361
+ ```
362
+
363
+ ---
364
+
365
+ ## REST API Reference
366
+
367
+ ### Admin endpoints
368
+
369
+ ```
370
+ POST /api/v1/admin/collection/:collectionId/attestations
371
+ GET /api/v1/admin/collection/:collectionId/attestations
372
+ GET /api/v1/admin/collection/:collectionId/attestations/summary
373
+ GET /api/v1/admin/collection/:collectionId/attestations/latest
374
+ GET /api/v1/admin/collection/:collectionId/attestations/verify
375
+ GET /api/v1/admin/collection/:collectionId/attestations/tree-summary
376
+ GET /api/v1/admin/collection/:collectionId/attestations/tree-latest
377
+ ```
378
+
379
+ #### POST body — single record
380
+
381
+ ```json
382
+ {
383
+ "subjectType": "container",
384
+ "subjectId": "uuid-of-cask",
385
+ "attestationType": "temperature",
386
+ "recordedAt": "2025-04-15T14:30:00Z",
387
+ "value": { "celsius": 12.4 },
388
+ "ownerData": { "sensorId": "TEMP-7" },
389
+ "unit": "°C",
390
+ "visibility": "public"
391
+ }
392
+ ```
393
+
394
+ #### POST body — batch (array)
395
+
396
+ ```json
397
+ [
398
+ { "subjectType": "container", "subjectId": "uuid1", "attestationType": "temperature", "value": { "celsius": 12.4 } },
399
+ { "subjectType": "container", "subjectId": "uuid1", "attestationType": "humidity", "value": { "rh": 68 } }
400
+ ]
401
+ ```
402
+
403
+ #### GET query parameters (list)
404
+
405
+ | Parameter | Required | Description |
406
+ |---|---|---|
407
+ | `subjectType` | ✅ | Subject type |
408
+ | `subjectId` | ✅ | Subject UUID |
409
+ | `attestationType` | — | Filter by type |
410
+ | `recordedAfter` | — | ISO 8601 lower bound |
411
+ | `recordedBefore` | — | ISO 8601 upper bound |
412
+ | `limit` | — | Default `100` |
413
+ | `offset` | — | Default `0` |
414
+
415
+ #### GET query parameters (summary)
416
+
417
+ | Parameter | Required | Description |
418
+ |---|---|---|
419
+ | `subjectType` | ✅ | |
420
+ | `subjectId` | ✅ | |
421
+ | `attestationType` | ✅ | |
422
+ | `valueField` | — | Dot-path inside `value` to aggregate |
423
+ | `groupBy` | — | `hour` \| `day` \| `week` \| `month` (default `day`) |
424
+ | `recordedAfter` | — | ISO 8601 |
425
+ | `recordedBefore` | — | ISO 8601 |
426
+ | `limit` | — | Max buckets (default `200`) |
427
+
428
+ #### GET query parameters (tree-summary / tree-latest)
429
+
430
+ | Parameter | Required | Description |
431
+ |---|---|---|
432
+ | `subjectType` | ✅ | Must be `container` |
433
+ | `subjectId` | ✅ | Root container UUID |
434
+ | `attestationType` | ✅ (tree-summary) | |
435
+ | `valueField` | — | |
436
+ | `groupBy` | — | |
437
+ | `recordedAfter` | — | |
438
+ | `recordedBefore` | — | |
439
+ | `limit` | — | |
440
+ | `includeItems` | — | Include container items in BFS traversal (default `true`) |
441
+
442
+ ### Public endpoints
443
+
444
+ ```
445
+ GET /api/v1/public/collection/:collectionId/attestations
446
+ GET /api/v1/public/collection/:collectionId/attestations/summary
447
+ GET /api/v1/public/collection/:collectionId/attestations/latest
448
+ GET /api/v1/public/collection/:collectionId/attestations/tree-summary
449
+ GET /api/v1/public/collection/:collectionId/attestations/tree-latest
450
+ ```
451
+
452
+ Same query parameters as admin. Add `Authorization: Bearer <idToken>` for owner elevation.
453
+
454
+ ### Container-scoped public shortcuts
455
+
456
+ ```
457
+ GET /api/v1/public/collection/:collectionId/containers/:containerId/attestations
458
+ GET /api/v1/public/collection/:collectionId/containers/:containerId/attestations/summary
459
+ GET /api/v1/public/collection/:collectionId/containers/:containerId/attestations/latest
460
+ GET /api/v1/public/collection/:collectionId/containers/:containerId/attestations/tree-summary
461
+ GET /api/v1/public/collection/:collectionId/containers/:containerId/attestations/tree-latest
462
+ ```
463
+
464
+ Identical to the polymorphic routes with `subjectType=container&subjectId=:containerId` pre-filled.
465
+
466
+ ---
467
+
468
+ ## Design Notes
469
+
470
+ ### Append-only records
471
+
472
+ Attestations must **never** be updated or deleted. The hash chain exists to make tampering detectable. To correct a recorded fact, append a new attestation record with a note in `metadata`; run `/verify` to confirm chain integrity.
473
+
474
+ ### Hash chain structure
475
+
476
+ Each record's `contentHash` is computed as:
477
+
478
+ ```
479
+ SHA-256(subjectType + subjectId + attestationType + recordedAt + JSON(value) + prevHash)
480
+ ```
481
+
482
+ `prevHash` is the `contentHash` of the immediately preceding record for the same `(subjectType, subjectId, attestationType)` tuple. The first record in a chain has no `prevHash`.
483
+
484
+ ### Tree analytics use case
485
+
486
+ The tree endpoints enable queries like *"what was the average temperature inside the warehouse (and all sub-containers and their items) over the last 7 days?"*:
487
+
488
+ ```typescript
489
+ const { summary } = await attestations.publicTreeSummary('coll_123', {
490
+ subjectId: 'warehouse-uuid',
491
+ attestationType: 'temperature',
492
+ valueField: 'celsius',
493
+ groupBy: 'day',
494
+ recordedAfter: sevenDaysAgo,
495
+ })
496
+ ```
497
+
498
+ This also enables the cold-chain use-case: was a particular item inside a container that exceeded a temperature threshold during a given window? Cross-reference container attestations with container item membership timestamps using `ContainerItem.addedAt` and `ContainerItem.removedAt`.