@proveanything/smartlinks 1.11.6 → 1.11.8

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,4 +1,4 @@
1
- import type { AdminInteractionsCountsByOutcomeRequest, AdminInteractionsQueryRequest, AdminInteractionsAggregateRequest, AdminInteractionsAggregateResponse, AppendInteractionBody, UpdateInteractionBody, OutcomeCount, InteractionEventRow, PublicInteractionsCountsByOutcomeRequest, PublicInteractionsByUserRequest, CreateInteractionTypeBody, UpdateInteractionTypeBody, ListInteractionTypesQuery, InteractionTypeRecord, InteractionTypeList } from "../types/interaction";
1
+ import type { AdminInteractionsCountsByOutcomeRequest, AdminInteractionsQueryRequest, AdminInteractionsAggregateRequest, AdminInteractionsAggregateResponse, AppendInteractionBody, UpdateInteractionBody, OutcomeCount, InteractionEventRow, PublicInteractionsCountsByOutcomeRequest, PublicInteractionsByUserRequest, SubmitInteractionResponse, SubmitInteractionError, CreateInteractionTypeBody, UpdateInteractionTypeBody, ListInteractionTypesQuery, InteractionTypeRecord, InteractionTypeList } from "../types/interaction";
2
2
  export declare namespace interactions {
3
3
  /**
4
4
  * POST /admin/collection/:collectionId/interactions/query
@@ -30,11 +30,14 @@ export declare namespace interactions {
30
30
  success: true;
31
31
  }>;
32
32
  /**
33
- * Appends one interaction event from a public source.
33
+ * POST /api/v1/public/collection/:collectionId/interactions/submit
34
+ *
35
+ * Submits an interaction event from a public/client-side context.
36
+ * When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor
37
+ * `contactId` is required. Pass `anonId` inside `metadata` to enable
38
+ * device-level deduplication via `uniquePerAnonId`.
34
39
  */
35
- function submitPublicEvent(collectionId: string, body: AppendInteractionBody): Promise<{
36
- success: true;
37
- }>;
40
+ function submitPublicEvent(collectionId: string, body: AppendInteractionBody): Promise<SubmitInteractionResponse | SubmitInteractionError>;
38
41
  function create(collectionId: string, body: CreateInteractionTypeBody): Promise<InteractionTypeRecord>;
39
42
  function list(collectionId: string, query?: ListInteractionTypesQuery): Promise<InteractionTypeList>;
40
43
  function get(collectionId: string, id: string): Promise<InteractionTypeRecord>;
@@ -73,12 +73,14 @@ export var interactions;
73
73
  }
74
74
  interactions.updateEvent = updateEvent;
75
75
  /**
76
- * Appends one interaction event from a public source.
76
+ * POST /api/v1/public/collection/:collectionId/interactions/submit
77
+ *
78
+ * Submits an interaction event from a public/client-side context.
79
+ * When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor
80
+ * `contactId` is required. Pass `anonId` inside `metadata` to enable
81
+ * device-level deduplication via `uniquePerAnonId`.
77
82
  */
78
83
  async function submitPublicEvent(collectionId, body) {
79
- if (!body.userId && !body.contactId) {
80
- throw new Error("AppendInteractionBody must include one of userId or contactId");
81
- }
82
84
  const path = `/public/collection/${encodeURIComponent(collectionId)}/interactions/submit`;
83
85
  return post(path, body);
84
86
  }
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.11.6 | Generated: 2026-05-01T13:42:06.488Z
3
+ Version: 1.11.8 | Generated: 2026-05-03T08:12:27.170Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -5153,6 +5153,10 @@ interface InteractionPermissions {
5153
5153
  * Authenticated summary visibility (counts, aggregates) when user is signed in.
5154
5154
  allowAuthenticatedSummary?: boolean
5155
5155
  allowOwnRead?: boolean
5156
+ uniquePerAnonId?: boolean
5157
+ * Time window in seconds for `uniquePerAnonId` enforcement.
5158
+ * `0` or omitted means all-time deduplication.
5159
+ uniquePerAnonIdWindowSeconds?: number
5156
5160
  }
5157
5161
  ```
5158
5162
 
@@ -5220,6 +5224,30 @@ interface ListInteractionTypesQuery {
5220
5224
  }
5221
5225
  ```
5222
5226
 
5227
+ **SubmitInteractionResponse** (interface)
5228
+ ```typescript
5229
+ interface SubmitInteractionResponse {
5230
+ success: true
5231
+ eventId: string
5232
+ }
5233
+ ```
5234
+
5235
+ **SubmitInteractionError** (interface)
5236
+ ```typescript
5237
+ interface SubmitInteractionError {
5238
+ error: 'FORBIDDEN'
5239
+ reason:
5240
+ | 'not_public'
5241
+ | 'auth_required'
5242
+ | 'duplicate'
5243
+ | 'duplicate_anon'
5244
+ | 'disabled'
5245
+ | 'before_start'
5246
+ | 'after_end'
5247
+ | 'origin_forbidden'
5248
+ }
5249
+ ```
5250
+
5223
5251
  ### jobs
5224
5252
 
5225
5253
  **Job** (interface)
@@ -8333,47 +8361,47 @@ POST /admin/collection/:collectionId/interactions/append Appends one interaction
8333
8361
  POST /admin/collection/:collectionId/interactions/append Appends one interaction event.
8334
8362
 
8335
8363
  **submitPublicEvent**(collectionId: string,
8336
- body: AppendInteractionBody) → `Promise<`
8337
- Appends one interaction event from a public source.
8364
+ body: AppendInteractionBody) → `Promise<SubmitInteractionResponse | SubmitInteractionError>`
8365
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8338
8366
 
8339
8367
  **create**(collectionId: string,
8340
8368
  body: CreateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
8341
- Appends one interaction event from a public source.
8369
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8342
8370
 
8343
8371
  **list**(collectionId: string,
8344
8372
  query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
8345
- Appends one interaction event from a public source.
8373
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8346
8374
 
8347
8375
  **get**(collectionId: string,
8348
8376
  id: string) → `Promise<InteractionTypeRecord>`
8349
- Appends one interaction event from a public source.
8377
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8350
8378
 
8351
8379
  **update**(collectionId: string,
8352
8380
  id: string,
8353
8381
  patchBody: UpdateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
8354
- Appends one interaction event from a public source.
8382
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8355
8383
 
8356
8384
  **remove**(collectionId: string,
8357
8385
  id: string) → `Promise<void>`
8358
- Appends one interaction event from a public source.
8386
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8359
8387
 
8360
8388
  **publicCountsByOutcome**(collectionId: string,
8361
8389
  body: PublicInteractionsCountsByOutcomeRequest,
8362
8390
  authToken?: string) → `Promise<OutcomeCount[]>`
8363
- Appends one interaction event from a public source.
8391
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8364
8392
 
8365
8393
  **publicMyInteractions**(collectionId: string,
8366
8394
  body: PublicInteractionsByUserRequest,
8367
8395
  authToken?: string) → `Promise<InteractionEventRow[]>`
8368
- Appends one interaction event from a public source.
8396
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8369
8397
 
8370
8398
  **publicList**(collectionId: string,
8371
8399
  query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
8372
- Appends one interaction event from a public source.
8400
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8373
8401
 
8374
8402
  **publicGet**(collectionId: string,
8375
8403
  id: string) → `Promise<InteractionTypeRecord>`
8376
- Appends one interaction event from a public source.
8404
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8377
8405
 
8378
8406
  ### jobs
8379
8407
 
@@ -270,7 +270,7 @@ See the [Deep Link Discovery guide](deep-link-discovery.md) for the full dual-so
270
270
 
271
271
  #### `records`
272
272
 
273
- Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [Records-Based Admin Pattern](records-admin-pattern.md). Omit if the app does not use scoped records.
273
+ Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [App Records Pattern](app-records-pattern.md). Omit if the app does not use scoped records.
274
274
 
275
275
  The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` read this block to render the right scope tabs, rule editor, and cardinality-appropriate right pane.
276
276
 
@@ -294,7 +294,7 @@ The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils
294
294
  | `scopes` | string[] | — | Allowed scope kinds in resolution order. Valid values: `"collection"`, `"product"`, `"variant"`, `"batch"`, `"facet"`, `"proof"`, `"rule"`. `'rule'` is a synthetic scope holding `facetRule`-targeted records. `'collection'` replaces the legacy empty-ref catch-all — **there is no `'global'` scope**. |
295
295
  | `defaultScope` | string | — | The scope the "Create new" button targets in the admin shell. Must be one of the declared `scopes`. |
296
296
 
297
- An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [records-admin-pattern.md](records-admin-pattern.md) for the full admin + public pattern.
297
+ An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [app-records-pattern.md](app-records-pattern.md) for the full admin + public pattern.
298
298
 
299
299
  #### `executor`
300
300
 
@@ -445,7 +445,7 @@ const productComments = await app.threads.list(collectionId, appId, {
445
445
  - **Usage logs** — record product usage metrics
446
446
  - **Audit trails** — immutable logs of actions
447
447
  - **Loyalty points** — track points earned/redeemed
448
- - **Per-product / per-facet configuration** — scoped data that varies by product axis (see [records-admin-pattern.md](records-admin-pattern.md))
448
+ - **Per-product / per-facet configuration** — scoped data that varies by product axis (see [app-records-pattern.md](app-records-pattern.md))
449
449
 
450
450
  ### Key Features
451
451
 
@@ -526,7 +526,7 @@ proof → batch → variant → product → rule(*) → facet(*) →
526
526
  - `facet(*)` — legacy single-facet anchors, walked alphabetically. Prefer `facetRule` for new work.
527
527
  - `collection` — the top of the chain and the catch-all for any record with no anchor fields. **There is no `'global'` tier above collection.**
528
528
 
529
- For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [records-admin-pattern.md §2](records-admin-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
529
+ For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [app-records-pattern.md §2](app-records-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
530
530
 
531
531
  ### Singleton Cardinality
532
532
 
@@ -1,4 +1,4 @@
1
- # SmartLinks Records — Admin & Public Pattern
1
+ # SmartLinks App Records Pattern
2
2
 
3
3
  > Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data.
4
4
  >
@@ -172,6 +172,26 @@ import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-e
172
172
 
173
173
  ## 5. Public side — pick the right hook (this is where apps go wrong)
174
174
 
175
+ > **Admin vs public — the rule is simple:**
176
+ >
177
+ > | Function | Public widget | Admin dashboard |
178
+ > |---|---|---|
179
+ > | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
180
+ > | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
181
+ > | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
182
+ > | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
183
+ > | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
184
+ > | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
185
+ > | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
186
+ > | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
187
+ > | `app.records.upsert()` | ❌ admin only — no public path | ✅ |
188
+ > | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ |
189
+ > | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ |
190
+ > | `app.records.restore()` | ❌ admin only — no public path | ✅ |
191
+ > | `app.records.previewRule()` | ❌ admin only — no public path | ✅ |
192
+ >
193
+ > The `admin` parameter on `match()` and `resolveAll()` defaults to `false`, so public widgets that omit it are fine. **Never hardcode `true` in a public widget.** The hooks below always use the public path and are the recommended approach — prefer them over raw SDK calls in widget code.
194
+
175
195
  There is exactly **one decision** to make on the public side, and it follows from the manifest's `cardinality`:
176
196
 
177
197
  ### 5a. Singleton → `useResolvedRecord` (best match wins)
@@ -236,6 +256,10 @@ const { entries, isLoading } = useResolveAllRecords({
236
256
  | ❌ Anti-pattern | ✅ Do this instead |
237
257
  | ---------------------------------------------------------- | ----------------------------------------------------------------- |
238
258
  | Calling `SL.app.records.list()` and filtering client-side | `useResolvedRecord` (singleton) or `useCollectedRecords` (collection). The server already knows the chain. |
259
+ | Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`). `list()` has a public path — just don't pass `true`. |
260
+ | Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolvedRecord` / `useCollectedRecords`. |
261
+ | Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolveAllRecords`. |
262
+ | Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data — it never writes records directly. |
239
263
  | Walking the chain by hand with multiple `getConfig` calls | One hook call. The resolver is tested, cached, and includes rules. |
240
264
  | Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
241
265
  | Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
@@ -302,4 +326,4 @@ interface EditorContext<TData> {
302
326
 
303
327
  ---
304
328
 
305
- _End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._
329
+ _End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._
@@ -119,18 +119,37 @@ await SL.interactions.appendEvent(collectionId, {
119
119
 
120
120
  ### Public Event Submit
121
121
 
122
- Use in client-side app code. Same body shape as `appendEvent` but hits the public endpoint (respects interaction permissions like `allowPublicSubmit`, `requireAuth`).
122
+ Use in client-side app code. Hits the public endpoint and respects interaction permissions (`allowPublicSubmit`, `allowAnonymousSubmit`, `requireAuth`, etc.).
123
123
 
124
124
  ```typescript
125
+ // Authenticated submission
125
126
  await SL.interactions.submitPublicEvent(collectionId, {
126
127
  appId: 'my-app',
127
128
  interactionId: 'competition-entry',
128
129
  outcome: 'entered',
129
- userId: currentUser.id,
130
+ contactId: currentUser.contactId,
130
131
  metadata: { answer: 'Paris' },
131
132
  });
133
+
134
+ // Anonymous submission (interaction must have allowAnonymousSubmit: true)
135
+ const response = await SL.interactions.submitPublicEvent(collectionId, {
136
+ appId: 'my-app',
137
+ interactionId: 'nps-score',
138
+ outcome: '9',
139
+ metadata: {
140
+ anonId: SL.utils.getAnonId(), // device-level dedup signal
141
+ },
142
+ });
143
+
144
+ if (!response.success) {
145
+ if (response.reason === 'duplicate_anon') {
146
+ // this device has already submitted
147
+ }
148
+ }
132
149
  ```
133
150
 
151
+ > **Anonymous submissions** — when `allowAnonymousSubmit: true` is set on the interaction, neither `userId` nor `contactId` is required. Use `utils.getAnonId()` to generate a stable browser-local UUID and pass it as `metadata.anonId`; the server will enforce `uniquePerAnonId` if configured.
152
+
134
153
  ### Update an Existing Event
135
154
 
136
155
  ```typescript
@@ -237,6 +256,8 @@ Set on the interaction type definition via `permissions`:
237
256
  | `uniquePerUser` | boolean | Prevent duplicate submissions per user |
238
257
  | `uniquePerUserWindowSeconds` | number | Time window for uniqueness (e.g., `86400` = 1 day) |
239
258
  | `uniqueOutcome` | string | Outcome tag to check for duplicates (e.g., `"submitted"`) |
259
+ | `uniquePerAnonId` | boolean | Reject a second submission that carries the same `anonId` in metadata |
260
+ | `uniquePerAnonIdWindowSeconds` | number | Time window for `uniquePerAnonId` enforcement; `0` or omitted = all-time |
240
261
  | `allowPublicSummary` | boolean | Show counts/aggregates to unauthenticated users |
241
262
  | `allowAuthenticatedSummary` | boolean | Show counts/aggregates to authenticated users |
242
263
  | `allowOwnRead` | boolean | Let users read their own event history via public API |
@@ -263,8 +284,10 @@ When defining a journey trigger, reference the `interactionId` that should fire
263
284
 
264
285
  ```typescript
265
286
  import type {
266
- AppendInteractionBody, // Event body for appendEvent / submitPublicEvent
287
+ AppendInteractionBody, // Event body for appendEvent and submitPublicEvent
267
288
  UpdateInteractionBody, // Event body for updateEvent
289
+ SubmitInteractionResponse, // { success: true; eventId: string }
290
+ SubmitInteractionError, // { error: 'FORBIDDEN'; reason: string }
268
291
  InteractionEventRow, // Raw event record returned by query()
269
292
  OutcomeCount, // { outcome: string | null; count: number }
270
293
  InteractionPermissions, // Full permissions config shape
@@ -70,7 +70,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
70
70
  | **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
71
71
  | **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
72
72
  | **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
73
- | **Records Admin Pattern** | `docs/records-admin-pattern.md` | Standard pattern for per-product/facet/variant/batch admin UIs |
73
+ | **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
74
74
  | **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
75
75
 
76
76
  ---
@@ -19,7 +19,7 @@ translate SDK data into consistent admin interfaces.
19
19
 
20
20
  **When do you need it?**
21
21
 
22
- - You are building an admin UI for a records-based microapp (see [records-admin-pattern.md](records-admin-pattern.md))
22
+ - You are building an admin UI for a records-based microapp (see [app-records-pattern.md](app-records-pattern.md))
23
23
  - You need a media asset picker, icon picker, or font picker in an admin panel
24
24
  - You need a recursive rule/conditions editor for targeting or audience logic
25
25
  - You want the standard inheritance/override editor for scoped records
@@ -326,7 +326,7 @@ Props:
326
326
 
327
327
  Free-text facet entry is **not** supported — admins must pick from defined facets.
328
328
 
329
- See [records-admin-pattern.md §4](records-admin-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
329
+ See [app-records-pattern.md §4](app-records-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
330
330
 
331
331
  ---
332
332
 
@@ -505,6 +505,6 @@ your microapp ← domain logic and custom forms
505
505
 
506
506
  ## Further reading
507
507
 
508
- - [records-admin-pattern.md](records-admin-pattern.md) — the data contract that `RecordsAdminShell` implements
508
+ - [app-records-pattern.md](app-records-pattern.md) — the data contract that `RecordsAdminShell` implements
509
509
  - [building-react-components.md](building-react-components.md) — dual-mode rendering rules that apply to all components
510
510
  - [app-manifest.md](app-manifest.md) — the `records` manifest block that drives tab generation
package/dist/openapi.yaml CHANGED
@@ -10355,6 +10355,40 @@ paths:
10355
10355
  application/json:
10356
10356
  schema:
10357
10357
  $ref: "#/components/schemas/PublicInteractionsCountsByOutcomeRequest"
10358
+ /public/collection/{collectionId}/interactions/submit:
10359
+ post:
10360
+ tags:
10361
+ - interactions
10362
+ summary: "POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context."
10363
+ operationId: interactions_submitPublicEvent
10364
+ security: []
10365
+ parameters:
10366
+ - name: collectionId
10367
+ in: path
10368
+ required: true
10369
+ schema:
10370
+ type: string
10371
+ responses:
10372
+ 200:
10373
+ description: Success
10374
+ content:
10375
+ application/json:
10376
+ schema:
10377
+ oneOf:
10378
+ - $ref: "#/components/schemas/SubmitInteractionResponse"
10379
+ - $ref: "#/components/schemas/SubmitInteractionError"
10380
+ 400:
10381
+ description: Bad request
10382
+ 401:
10383
+ description: Unauthorized
10384
+ 404:
10385
+ description: Not found
10386
+ requestBody:
10387
+ required: true
10388
+ content:
10389
+ application/json:
10390
+ schema:
10391
+ $ref: "#/components/schemas/AppendInteractionBody"
10358
10392
  /public/collection/{collectionId}/interactions/{id}:
10359
10393
  get:
10360
10394
  tags:
@@ -20187,6 +20221,10 @@ components:
20187
20221
  type: boolean
20188
20222
  allowOwnRead:
20189
20223
  type: boolean
20224
+ uniquePerAnonId:
20225
+ type: boolean
20226
+ uniquePerAnonIdWindowSeconds:
20227
+ type: number
20190
20228
  InteractionDisplay:
20191
20229
  type: object
20192
20230
  properties:
@@ -20275,6 +20313,26 @@ components:
20275
20313
  type: number
20276
20314
  offset:
20277
20315
  type: number
20316
+ SubmitInteractionResponse:
20317
+ type: object
20318
+ properties:
20319
+ success:
20320
+ type: object
20321
+ additionalProperties: true
20322
+ eventId:
20323
+ type: string
20324
+ required:
20325
+ - success
20326
+ - eventId
20327
+ SubmitInteractionError:
20328
+ type: object
20329
+ properties:
20330
+ error:
20331
+ type: string
20332
+ enum:
20333
+ - FORBIDDEN
20334
+ required:
20335
+ - error
20278
20336
  Job:
20279
20337
  type: object
20280
20338
  properties:
@@ -162,6 +162,13 @@ export interface InteractionPermissions {
162
162
  allowAuthenticatedSummary?: boolean;
163
163
  /** Allow an authenticated user to read their own interaction history via the public API. */
164
164
  allowOwnRead?: boolean;
165
+ /** Reject a second submission that carries the same `anonId` in metadata. */
166
+ uniquePerAnonId?: boolean;
167
+ /**
168
+ * Time window in seconds for `uniquePerAnonId` enforcement.
169
+ * `0` or omitted means all-time deduplication.
170
+ */
171
+ uniquePerAnonIdWindowSeconds?: number;
165
172
  }
166
173
  export interface InteractionDisplay {
167
174
  title?: string;
@@ -203,3 +210,11 @@ export interface ListInteractionTypesQuery {
203
210
  limit?: number;
204
211
  offset?: number;
205
212
  }
213
+ export interface SubmitInteractionResponse {
214
+ success: true;
215
+ eventId: string;
216
+ }
217
+ export interface SubmitInteractionError {
218
+ error: 'FORBIDDEN';
219
+ reason: 'not_public' | 'auth_required' | 'duplicate' | 'duplicate_anon' | 'disabled' | 'before_start' | 'after_end' | 'origin_forbidden';
220
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Returns a stable anonymous device ID stored in `localStorage`.
3
+ *
4
+ * On first call a random UUID is generated and persisted. All subsequent calls
5
+ * from the same browser return the same value. Returns `null` during SSR or in
6
+ * any environment without `window` (safe to call universally).
7
+ *
8
+ * The value is scoped to the browser's `localStorage` and is cleared if the
9
+ * user clears site data. It does **not** create any contact or user record on
10
+ * the server.
11
+ *
12
+ * Pass the result in `metadata.anonId` when submitting interactions to enable
13
+ * device-level deduplication via `uniquePerAnonId`.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { utils } from '@proveanything/smartlinks'
18
+ *
19
+ * const response = await interactions.submitPublicEvent(collectionId, {
20
+ * appId: 'my-app',
21
+ * interactionId: 'nps-score',
22
+ * outcome: '9',
23
+ * metadata: { anonId: utils.getAnonId() },
24
+ * })
25
+ * ```
26
+ */
27
+ export declare function getAnonId(): string | null;
@@ -0,0 +1,38 @@
1
+ // src/utils/anonId.ts
2
+ const ANON_ID_KEY = '_pa_anon_id';
3
+ /**
4
+ * Returns a stable anonymous device ID stored in `localStorage`.
5
+ *
6
+ * On first call a random UUID is generated and persisted. All subsequent calls
7
+ * from the same browser return the same value. Returns `null` during SSR or in
8
+ * any environment without `window` (safe to call universally).
9
+ *
10
+ * The value is scoped to the browser's `localStorage` and is cleared if the
11
+ * user clears site data. It does **not** create any contact or user record on
12
+ * the server.
13
+ *
14
+ * Pass the result in `metadata.anonId` when submitting interactions to enable
15
+ * device-level deduplication via `uniquePerAnonId`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { utils } from '@proveanything/smartlinks'
20
+ *
21
+ * const response = await interactions.submitPublicEvent(collectionId, {
22
+ * appId: 'my-app',
23
+ * interactionId: 'nps-score',
24
+ * outcome: '9',
25
+ * metadata: { anonId: utils.getAnonId() },
26
+ * })
27
+ * ```
28
+ */
29
+ export function getAnonId() {
30
+ if (typeof window === 'undefined')
31
+ return null;
32
+ let id = window.localStorage.getItem(ANON_ID_KEY);
33
+ if (!id) {
34
+ id = crypto.randomUUID();
35
+ window.localStorage.setItem(ANON_ID_KEY, id);
36
+ }
37
+ return id;
38
+ }
@@ -4,3 +4,4 @@
4
4
  */
5
5
  export * from './paths';
6
6
  export * from './conditions';
7
+ export * from './anonId';
@@ -5,3 +5,4 @@
5
5
  */
6
6
  export * from './paths';
7
7
  export * from './conditions';
8
+ export * from './anonId';
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 1.11.6 | Generated: 2026-05-01T13:42:06.488Z
3
+ Version: 1.11.8 | Generated: 2026-05-03T08:12:27.170Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -5153,6 +5153,10 @@ interface InteractionPermissions {
5153
5153
  * Authenticated summary visibility (counts, aggregates) when user is signed in.
5154
5154
  allowAuthenticatedSummary?: boolean
5155
5155
  allowOwnRead?: boolean
5156
+ uniquePerAnonId?: boolean
5157
+ * Time window in seconds for `uniquePerAnonId` enforcement.
5158
+ * `0` or omitted means all-time deduplication.
5159
+ uniquePerAnonIdWindowSeconds?: number
5156
5160
  }
5157
5161
  ```
5158
5162
 
@@ -5220,6 +5224,30 @@ interface ListInteractionTypesQuery {
5220
5224
  }
5221
5225
  ```
5222
5226
 
5227
+ **SubmitInteractionResponse** (interface)
5228
+ ```typescript
5229
+ interface SubmitInteractionResponse {
5230
+ success: true
5231
+ eventId: string
5232
+ }
5233
+ ```
5234
+
5235
+ **SubmitInteractionError** (interface)
5236
+ ```typescript
5237
+ interface SubmitInteractionError {
5238
+ error: 'FORBIDDEN'
5239
+ reason:
5240
+ | 'not_public'
5241
+ | 'auth_required'
5242
+ | 'duplicate'
5243
+ | 'duplicate_anon'
5244
+ | 'disabled'
5245
+ | 'before_start'
5246
+ | 'after_end'
5247
+ | 'origin_forbidden'
5248
+ }
5249
+ ```
5250
+
5223
5251
  ### jobs
5224
5252
 
5225
5253
  **Job** (interface)
@@ -8333,47 +8361,47 @@ POST /admin/collection/:collectionId/interactions/append Appends one interaction
8333
8361
  POST /admin/collection/:collectionId/interactions/append Appends one interaction event.
8334
8362
 
8335
8363
  **submitPublicEvent**(collectionId: string,
8336
- body: AppendInteractionBody) → `Promise<`
8337
- Appends one interaction event from a public source.
8364
+ body: AppendInteractionBody) → `Promise<SubmitInteractionResponse | SubmitInteractionError>`
8365
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8338
8366
 
8339
8367
  **create**(collectionId: string,
8340
8368
  body: CreateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
8341
- Appends one interaction event from a public source.
8369
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8342
8370
 
8343
8371
  **list**(collectionId: string,
8344
8372
  query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
8345
- Appends one interaction event from a public source.
8373
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8346
8374
 
8347
8375
  **get**(collectionId: string,
8348
8376
  id: string) → `Promise<InteractionTypeRecord>`
8349
- Appends one interaction event from a public source.
8377
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8350
8378
 
8351
8379
  **update**(collectionId: string,
8352
8380
  id: string,
8353
8381
  patchBody: UpdateInteractionTypeBody) → `Promise<InteractionTypeRecord>`
8354
- Appends one interaction event from a public source.
8382
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8355
8383
 
8356
8384
  **remove**(collectionId: string,
8357
8385
  id: string) → `Promise<void>`
8358
- Appends one interaction event from a public source.
8386
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8359
8387
 
8360
8388
  **publicCountsByOutcome**(collectionId: string,
8361
8389
  body: PublicInteractionsCountsByOutcomeRequest,
8362
8390
  authToken?: string) → `Promise<OutcomeCount[]>`
8363
- Appends one interaction event from a public source.
8391
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8364
8392
 
8365
8393
  **publicMyInteractions**(collectionId: string,
8366
8394
  body: PublicInteractionsByUserRequest,
8367
8395
  authToken?: string) → `Promise<InteractionEventRow[]>`
8368
- Appends one interaction event from a public source.
8396
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8369
8397
 
8370
8398
  **publicList**(collectionId: string,
8371
8399
  query: ListInteractionTypesQuery = {}) → `Promise<InteractionTypeList>`
8372
- Appends one interaction event from a public source.
8400
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8373
8401
 
8374
8402
  **publicGet**(collectionId: string,
8375
8403
  id: string) → `Promise<InteractionTypeRecord>`
8376
- Appends one interaction event from a public source.
8404
+ POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context. When the interaction has `allowAnonymousSubmit: true`, neither `userId` nor `contactId` is required. Pass `anonId` inside `metadata` to enable device-level deduplication via `uniquePerAnonId`.
8377
8405
 
8378
8406
  ### jobs
8379
8407
 
@@ -270,7 +270,7 @@ See the [Deep Link Discovery guide](deep-link-discovery.md) for the full dual-so
270
270
 
271
271
  #### `records`
272
272
 
273
- Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [Records-Based Admin Pattern](records-admin-pattern.md). Omit if the app does not use scoped records.
273
+ Declares which `app.records` record types the app stores, and which scopes each type supports. Required for any app that follows the [App Records Pattern](app-records-pattern.md). Omit if the app does not use scoped records.
274
274
 
275
275
  The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` read this block to render the right scope tabs, rule editor, and cardinality-appropriate right pane.
276
276
 
@@ -294,7 +294,7 @@ The platform and the `<RecordsAdminShell>` from `@proveanything/smartlinks-utils
294
294
  | `scopes` | string[] | — | Allowed scope kinds in resolution order. Valid values: `"collection"`, `"product"`, `"variant"`, `"batch"`, `"facet"`, `"proof"`, `"rule"`. `'rule'` is a synthetic scope holding `facetRule`-targeted records. `'collection'` replaces the legacy empty-ref catch-all — **there is no `'global'` scope**. |
295
295
  | `defaultScope` | string | — | The scope the "Create new" button targets in the admin shell. Must be one of the declared `scopes`. |
296
296
 
297
- An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [records-admin-pattern.md](records-admin-pattern.md) for the full admin + public pattern.
297
+ An app may declare multiple record types under different keys (e.g. `"nutrition"` and `"cooking_steps"`). See [app-records-pattern.md](app-records-pattern.md) for the full admin + public pattern.
298
298
 
299
299
  #### `executor`
300
300
 
@@ -445,7 +445,7 @@ const productComments = await app.threads.list(collectionId, appId, {
445
445
  - **Usage logs** — record product usage metrics
446
446
  - **Audit trails** — immutable logs of actions
447
447
  - **Loyalty points** — track points earned/redeemed
448
- - **Per-product / per-facet configuration** — scoped data that varies by product axis (see [records-admin-pattern.md](records-admin-pattern.md))
448
+ - **Per-product / per-facet configuration** — scoped data that varies by product axis (see [app-records-pattern.md](app-records-pattern.md))
449
449
 
450
450
  ### Key Features
451
451
 
@@ -526,7 +526,7 @@ proof → batch → variant → product → rule(*) → facet(*) →
526
526
  - `facet(*)` — legacy single-facet anchors, walked alphabetically. Prefer `facetRule` for new work.
527
527
  - `collection` — the top of the chain and the catch-all for any record with no anchor fields. **There is no `'global'` tier above collection.**
528
528
 
529
- For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [records-admin-pattern.md §2](records-admin-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
529
+ For a **singleton** record type (one answer wins), use `useResolvedRecord` — it performs this walk server-side and returns the first match plus a `matchedAt` tag. For a **collection** record type (every match is shown), use `useCollectedRecords`. See [app-records-pattern.md §2](app-records-pattern.md#2-resolution-order-one-canonical-chain) for the full guide.
530
530
 
531
531
  ### Singleton Cardinality
532
532
 
@@ -0,0 +1,329 @@
1
+ # SmartLinks App Records Pattern
2
+
3
+ > Canonical guide for microapps that store **per-product**, **per-facet**, **per-variant**, **per-batch**, or **rule-targeted** data.
4
+ >
5
+ > Audience: microapp developers (ingredients, nutrition, allergy, FAQs, recipes, warranty, provenance, …).
6
+ >
7
+ > Status: **standard**. New apps MUST follow this contract; existing apps SHOULD migrate.
8
+ >
9
+ > SDK: `@proveanything/smartlinks` ≥ **1.11**, `@proveanything/smartlinks-utils-ui` ≥ **0.7.6**.
10
+
11
+ ---
12
+
13
+ ## 0. TL;DR — pick your shape, then copy the snippet
14
+
15
+ Every records-based app fits into a 2×2:
16
+
17
+ | | **Singleton** (one record per scope) | **Collection** (many records per scope) |
18
+ | ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------- |
19
+ | **Best-match (one wins)**| Ingredients, nutrition, washing instructions | _(rare — usually you want all)_ |
20
+ | **All matches (aggregate)** | _(rare — usually you want best)_ | FAQs, recipes, SOPs, care tips, story cards |
21
+
22
+ That choice drives **three** things and nothing else:
23
+
24
+ 1. **Manifest:** `cardinality: 'singleton' | 'collection'` and `allowFacetRules: boolean`.
25
+ 2. **Admin (`<RecordsAdminShell>`):** pass `cardinality` + include `'rule'` in `scopes` if `allowFacetRules`.
26
+ 3. **Public hook:** `useResolvedRecord` (best match) **or** `useCollectedRecords` / `useResolveAllRecords` (all matches).
27
+
28
+ If you only remember one rule: **never write your own resolver**. The shell, the hooks, and the SDK already agree on resolution order. Reimplementing it is how apps drift.
29
+
30
+ ---
31
+
32
+ ## 1. The data model in one paragraph
33
+
34
+ A microapp owns a typed **records table** keyed by `(appId, recordType, id)`. Each `AppRecord` carries a `data` payload plus **either** a structured `scope` (anchored to a node in the chain) **or** a `facetRule` (matches products dynamically by their facets). The server resolves which record(s) apply to a given product context. There is no "global"; the top of the chain is **collection** — anything not explicitly scoped further applies to the whole collection.
35
+
36
+ ```ts
37
+ import * as SL from '@proveanything/smartlinks';
38
+
39
+ await SL.app.records.upsert(collectionId, appId, {
40
+ recordType: 'ingredients',
41
+ scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server derives the ref
42
+ data: { /* domain payload */ },
43
+ }, /* admin */ true);
44
+ ```
45
+
46
+ Or, for a rule-targeted record:
47
+
48
+ ```ts
49
+ await SL.app.records.upsert(collectionId, appId, {
50
+ recordType: 'ingredients',
51
+ facetRule: {
52
+ all: [
53
+ { facetKey: 'brand', anyOf: ['acme'] },
54
+ { facetKey: 'category', anyOf: ['bread', 'pastry'] },
55
+ ],
56
+ },
57
+ data: { /* domain payload */ },
58
+ }, true);
59
+ ```
60
+
61
+ `scope` and `facetRule` are **mutually exclusive on save**.
62
+
63
+ ---
64
+
65
+ ## 2. Resolution order (one canonical chain)
66
+
67
+ The server walks **most-specific → least-specific** and stops at the first match (for best-match) or collects every match (for aggregate):
68
+
69
+ ```
70
+ proof → batch → variant → product → rule(*) → facet(*) → collection
71
+ ```
72
+
73
+ - `rule(*)` — facet-rule records are scored by **specificity** (number of clauses + number of constrained values). The most specific rule wins.
74
+ - `facet(*)` — legacy single-facet anchors, walked deterministically (alphabetical).
75
+ - `collection` — the top of the chain. **There is no "global" tier above collection.** A collection-level record is the catch-all for that collection.
76
+
77
+ The resolved value comes back tagged with `matchedAt: 'product' | 'rule' | 'facet' | …` so the UI can say things like _"Matched by rule: brand=Acme AND category=bread"_.
78
+
79
+ > ⚠️ Legacy `scope.facets[]` (colon-delimited single-facet refs) is deprecated and removed in SDK 1.12. Use `facetRule` for everything that isn't a one-off facet pin.
80
+
81
+ ---
82
+
83
+ ## 3. Manifest declaration
84
+
85
+ Declare each record type once in `app.admin.json`. The shell and the platform read this to render the right scope tabs and disable the wrong affordances.
86
+
87
+ ```json
88
+ {
89
+ "records": {
90
+ "ingredients": {
91
+ "label": "Ingredients",
92
+ "cardinality": "singleton",
93
+ "allowFacetRules": true,
94
+ "scopes": ["collection", "facet", "rule", "product", "variant", "batch"],
95
+ "defaultScope": "product"
96
+ },
97
+ "faq": {
98
+ "label": "FAQs",
99
+ "cardinality": "collection",
100
+ "allowFacetRules": true,
101
+ "scopes": ["collection", "rule", "product"],
102
+ "defaultScope": "collection"
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ | Field | Meaning |
109
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
110
+ | `cardinality` | `'singleton'` (one per scope, e.g. ingredients) or `'collection'` (many per scope, e.g. FAQs). Default `'singleton'`. |
111
+ | `allowFacetRules` | `true` to enable the `rule` scope tab + `<FacetRuleEditor>` in the shell. Default `false`. |
112
+ | `scopes` | Allowed scope kinds in **resolution order**. `'rule'` is a synthetic scope that holds rule-targeted records. |
113
+ | `defaultScope` | Where the "Create new" button lands. |
114
+ | `label` | Human-readable label used in headings and toasts. |
115
+
116
+ ---
117
+
118
+ ## 4. Admin side — `<RecordsAdminShell>` (the only thing you should be writing)
119
+
120
+ The shell owns: scope tabs, browser pane, rule editor, save/discard, dirty navigation, inheritance markers, deletion, CSV, bulk apply, deep linking. **You only supply the editor for one record's `data`.**
121
+
122
+ ```tsx
123
+ import * as SL from '@proveanything/smartlinks';
124
+ import { RecordsAdminShell } from '@proveanything/smartlinks-utils-ui/records-admin';
125
+
126
+ <RecordsAdminShell<IngredientsConfig>
127
+ SL={SL}
128
+ collectionId={collectionId}
129
+ appId={appId}
130
+ recordType="ingredients"
131
+ label="Ingredients"
132
+ cardinality="singleton" // ← from manifest
133
+ scopes={['collection', 'facet', 'rule', 'product', 'variant', 'batch']}
134
+ defaultScope="product"
135
+ defaultData={() => emptyConfig()}
136
+ renderEditor={(ctx) => (
137
+ <IngredientsEditor
138
+ value={ctx.value}
139
+ onChange={ctx.onChange}
140
+ // For rule-targeted records, the shell hands you the live rule + setter:
141
+ facetRule={ctx.facetRule}
142
+ onFacetRuleChange={ctx.onFacetRuleChange}
143
+ />
144
+ )}
145
+ />
146
+ ```
147
+
148
+ ### What the shell gives you for free
149
+
150
+ - **Scope tabs** including a **`Rule`** tab when `'rule'` is in `scopes`. Selecting it opens `<FacetRuleEditor>` above your editor — no extra wiring.
151
+ - **`EditorContext.facetRule` / `onFacetRuleChange`** for rule-scoped records, plus `canSave: false` until at least one clause has values (avoids server 500s).
152
+ - **Inheritance markers** — when editing a variant, the product baseline is shown; per-field "↩ Inherited" / "● Override" is rendered by the inheritance helpers.
153
+ - **Collection cardinality flow** — set `cardinality="collection"` and the shell turns the right pane into a list of items (table / cards / gallery) with `+ New` and per-item nav.
154
+ - **Telemetry** — `record.save`, `record.delete`, `scope.change`, `csv.import`, `bulk.apply`, `item.create`, etc. via `onTelemetry`.
155
+
156
+ ### Standalone rule editor
157
+
158
+ If you need a rule editor outside the shell (e.g. on a settings page):
159
+
160
+ ```tsx
161
+ import { FacetRuleEditor } from '@proveanything/smartlinks-utils-ui/facet-rule-editor';
162
+
163
+ <FacetRuleEditor
164
+ value={rule}
165
+ onChange={setRule}
166
+ collectionId={collectionId} // lazy-fetches facets via SL.facets.publicList
167
+ preview={rulePreview} // optional — wire from useRulePreview
168
+ />
169
+ ```
170
+
171
+ ---
172
+
173
+ ## 5. Public side — pick the right hook (this is where apps go wrong)
174
+
175
+ > **Admin vs public — the rule is simple:**
176
+ >
177
+ > | Function | Public widget | Admin dashboard |
178
+ > |---|---|---|
179
+ > | `app.records.create(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
180
+ > | `app.records.list(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
181
+ > | `app.records.get(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
182
+ > | `app.records.update(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
183
+ > | `app.records.remove(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
184
+ > | `app.records.aggregate(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
185
+ > | `app.records.match(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
186
+ > | `app.records.resolveAll(…, false)` | ✅ default — omit the flag | ✅ pass `true` |
187
+ > | `app.records.upsert()` | ❌ admin only — no public path | ✅ |
188
+ > | `app.records.bulkUpsert()` | ❌ admin only — no public path | ✅ |
189
+ > | `app.records.bulkDelete()` | ❌ admin only — no public path | ✅ |
190
+ > | `app.records.restore()` | ❌ admin only — no public path | ✅ |
191
+ > | `app.records.previewRule()` | ❌ admin only — no public path | ✅ |
192
+ >
193
+ > The `admin` parameter on `match()` and `resolveAll()` defaults to `false`, so public widgets that omit it are fine. **Never hardcode `true` in a public widget.** The hooks below always use the public path and are the recommended approach — prefer them over raw SDK calls in widget code.
194
+
195
+ There is exactly **one decision** to make on the public side, and it follows from the manifest's `cardinality`:
196
+
197
+ ### 5a. Singleton → `useResolvedRecord` (best match wins)
198
+
199
+ Use this when the app shows **one** answer for the current product (ingredients, nutrition, washing instructions, warranty terms).
200
+
201
+ ```tsx
202
+ import * as SL from '@proveanything/smartlinks';
203
+ import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
204
+
205
+ const { data, source, sourceRef, matchedAt, matchedRule, isLoading } =
206
+ useResolvedRecord<IngredientsConfig>({
207
+ SL,
208
+ appId,
209
+ collectionId,
210
+ recordType: 'ingredients',
211
+ productId,
212
+ variantId, // optional
213
+ batchId, // optional
214
+ proofId, // optional
215
+ });
216
+ ```
217
+
218
+ The resolver walks `proof → batch → variant → product → rule → facet → collection` and returns the **first match**, plus `matchedAt` so you can render _"From the product record"_ vs _"Matched by rule"_ badges if useful.
219
+
220
+ > Wrap this once in your app (e.g. `useResolvedIngredientSet`) so the rest of the codebase reads `{ data, isLoading }` and never sees the resolver.
221
+
222
+ ### 5b. Collection → `useCollectedRecords` (every match, ordered)
223
+
224
+ Use this when the app shows **many** answers aggregated across the chain (FAQs, recipes, SOPs, care tips). Most-specific first by default; pass `sort` to override.
225
+
226
+ ```tsx
227
+ import { useCollectedRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
228
+
229
+ const { items, isLoading } = useCollectedRecords<FaqEntry>({
230
+ SL,
231
+ appId,
232
+ collectionId,
233
+ recordType: 'faq',
234
+ productId,
235
+ // sort: { kind: 'field', field: 'order', direction: 'asc' },
236
+ });
237
+
238
+ // items: CollectedRecord<FaqEntry>[] — each has { data, scope, ref, depth }
239
+ ```
240
+
241
+ ### 5c. Multi-type aggregate → `useResolveAllRecords`
242
+
243
+ When you need every record of every type that applies to a context (rare; mostly executors and SEO surfaces).
244
+
245
+ ```tsx
246
+ import { useResolveAllRecords } from '@proveanything/smartlinks-utils-ui/records-admin';
247
+
248
+ const { entries, isLoading } = useResolveAllRecords({
249
+ SL, collectionId, appId,
250
+ context: { productId, facets: { brand: 'acme', category: ['bread'] } },
251
+ });
252
+ ```
253
+
254
+ ### Common mistakes (do not do these)
255
+
256
+ | ❌ Anti-pattern | ✅ Do this instead |
257
+ | ---------------------------------------------------------- | ----------------------------------------------------------------- |
258
+ | Calling `SL.app.records.list()` and filtering client-side | `useResolvedRecord` (singleton) or `useCollectedRecords` (collection). The server already knows the chain. |
259
+ | Calling `SL.app.records.list(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`). `list()` has a public path — just don't pass `true`. |
260
+ | Calling `SL.app.records.match(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolvedRecord` / `useCollectedRecords`. |
261
+ | Calling `SL.app.records.resolveAll(…, true)` from a public widget | Omit the `admin` flag (defaults to `false`) or use `useResolveAllRecords`. |
262
+ | Calling `upsert`, `bulkUpsert`, `bulkDelete`, or `previewRule` from widget code | Those are admin-only. Widget code reads data — it never writes records directly. |
263
+ | Walking the chain by hand with multiple `getConfig` calls | One hook call. The resolver is tested, cached, and includes rules. |
264
+ | Treating `facet:key:value` refs as the rule mechanism | Use `facetRule` (`{ all: [{ facetKey, anyOf: [...] }] }`). Multi-condition, scored by specificity. |
265
+ | Reading `matchedAt === 'global'` | There is no `'global'`. The top of the chain is `'collection'`. |
266
+ | Building your own `<FacetRuleEditor>` | Use the one from `@proveanything/smartlinks-utils-ui/facet-rule-editor`. |
267
+
268
+ ---
269
+
270
+ ## 6. Reference: the `EditorContext` your `renderEditor` receives
271
+
272
+ ```ts
273
+ interface EditorContext<TData> {
274
+ value: TData;
275
+ onChange: (next: TData) => void;
276
+ source: 'self' | 'inherited' | 'empty';
277
+ recordId?: string;
278
+ parentValue?: TData | null;
279
+ scope: ParsedRef; // { kind: 'product' | 'rule' | …, productId?, … }
280
+
281
+ // Save lifecycle
282
+ isDirty: boolean;
283
+ isSaving?: boolean;
284
+ saveError?: unknown | null;
285
+ canSave?: boolean; // shell flips to false on empty rules
286
+ cannotSaveReason?: string;
287
+ save: () => Promise<void>;
288
+ reset: () => void;
289
+
290
+ // Deletion
291
+ remove: () => Promise<void>;
292
+ canRemove: boolean;
293
+
294
+ // Rule scope only
295
+ facetRule?: FacetRule | null;
296
+ onFacetRuleChange?: (next: FacetRule | null) => void;
297
+ }
298
+ ```
299
+
300
+ ---
301
+
302
+ ## 7. Migration checklist (existing apps)
303
+
304
+ 1. **Update SDKs:** `@proveanything/smartlinks@^1.11`, `@proveanything/smartlinks-utils-ui@^0.7.6`.
305
+ 2. **Add `cardinality` and `allowFacetRules`** to every entry under `records` in `app.admin.json`.
306
+ 3. **Add `'rule'` (and `'collection'` if missing) to `scopes`** wherever `allowFacetRules: true`.
307
+ 4. **Pass `cardinality`** to `<RecordsAdminShell>`.
308
+ 5. **Replace any handwritten chain walking** with `useResolvedRecord` (singleton) or `useCollectedRecords` (collection).
309
+ 6. **Delete any code that constructs `facet:key:value` refs** for matching. Use `facetRule` via the shell or `<FacetRuleEditor>`.
310
+ 7. **Search for the word "global"** in your code/docs and rename to "collection" — this is the most common source of confusion.
311
+
312
+ ---
313
+
314
+ ## 8. Where the canonical exports live
315
+
316
+ | Need | Import from |
317
+ | ----------------------------- | ---------------------------------------------------------------------- |
318
+ | Admin shell | `@proveanything/smartlinks-utils-ui/records-admin` → `RecordsAdminShell` |
319
+ | Standalone rule editor | `@proveanything/smartlinks-utils-ui/facet-rule-editor` → `FacetRuleEditor` |
320
+ | Conditions editor (non-facet) | `@proveanything/smartlinks-utils-ui/conditions-editor` → `ConditionsEditor` |
321
+ | Best-match resolver hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolvedRecord` |
322
+ | All-matches hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useCollectedRecords` |
323
+ | Multi-type aggregate hook | `@proveanything/smartlinks-utils-ui/records-admin` → `useResolveAllRecords` |
324
+ | Rule preview ("matches N") | `@proveanything/smartlinks-utils-ui/records-admin` → `useRulePreview` |
325
+ | Server-side record CRUD | `@proveanything/smartlinks` → `SL.app.records.{upsert, list, remove, match, resolveAll}` |
326
+
327
+ ---
328
+
329
+ _End of doc. If anything below the SDK contradicts this file, this file wins — open a PR against the SDK to bring the two back into sync._
@@ -119,18 +119,37 @@ await SL.interactions.appendEvent(collectionId, {
119
119
 
120
120
  ### Public Event Submit
121
121
 
122
- Use in client-side app code. Same body shape as `appendEvent` but hits the public endpoint (respects interaction permissions like `allowPublicSubmit`, `requireAuth`).
122
+ Use in client-side app code. Hits the public endpoint and respects interaction permissions (`allowPublicSubmit`, `allowAnonymousSubmit`, `requireAuth`, etc.).
123
123
 
124
124
  ```typescript
125
+ // Authenticated submission
125
126
  await SL.interactions.submitPublicEvent(collectionId, {
126
127
  appId: 'my-app',
127
128
  interactionId: 'competition-entry',
128
129
  outcome: 'entered',
129
- userId: currentUser.id,
130
+ contactId: currentUser.contactId,
130
131
  metadata: { answer: 'Paris' },
131
132
  });
133
+
134
+ // Anonymous submission (interaction must have allowAnonymousSubmit: true)
135
+ const response = await SL.interactions.submitPublicEvent(collectionId, {
136
+ appId: 'my-app',
137
+ interactionId: 'nps-score',
138
+ outcome: '9',
139
+ metadata: {
140
+ anonId: SL.utils.getAnonId(), // device-level dedup signal
141
+ },
142
+ });
143
+
144
+ if (!response.success) {
145
+ if (response.reason === 'duplicate_anon') {
146
+ // this device has already submitted
147
+ }
148
+ }
132
149
  ```
133
150
 
151
+ > **Anonymous submissions** — when `allowAnonymousSubmit: true` is set on the interaction, neither `userId` nor `contactId` is required. Use `utils.getAnonId()` to generate a stable browser-local UUID and pass it as `metadata.anonId`; the server will enforce `uniquePerAnonId` if configured.
152
+
134
153
  ### Update an Existing Event
135
154
 
136
155
  ```typescript
@@ -237,6 +256,8 @@ Set on the interaction type definition via `permissions`:
237
256
  | `uniquePerUser` | boolean | Prevent duplicate submissions per user |
238
257
  | `uniquePerUserWindowSeconds` | number | Time window for uniqueness (e.g., `86400` = 1 day) |
239
258
  | `uniqueOutcome` | string | Outcome tag to check for duplicates (e.g., `"submitted"`) |
259
+ | `uniquePerAnonId` | boolean | Reject a second submission that carries the same `anonId` in metadata |
260
+ | `uniquePerAnonIdWindowSeconds` | number | Time window for `uniquePerAnonId` enforcement; `0` or omitted = all-time |
240
261
  | `allowPublicSummary` | boolean | Show counts/aggregates to unauthenticated users |
241
262
  | `allowAuthenticatedSummary` | boolean | Show counts/aggregates to authenticated users |
242
263
  | `allowOwnRead` | boolean | Let users read their own event history via public API |
@@ -263,8 +284,10 @@ When defining a journey trigger, reference the `interactionId` that should fire
263
284
 
264
285
  ```typescript
265
286
  import type {
266
- AppendInteractionBody, // Event body for appendEvent / submitPublicEvent
287
+ AppendInteractionBody, // Event body for appendEvent and submitPublicEvent
267
288
  UpdateInteractionBody, // Event body for updateEvent
289
+ SubmitInteractionResponse, // { success: true; eventId: string }
290
+ SubmitInteractionError, // { error: 'FORBIDDEN'; reason: string }
268
291
  InteractionEventRow, // Raw event record returned by query()
269
292
  OutcomeCount, // { outcome: string | null; count: number }
270
293
  InteractionPermissions, // Full permissions config shape
package/docs/overview.md CHANGED
@@ -70,7 +70,7 @@ The SmartLinks SDK (`@proveanything/smartlinks`) includes comprehensive document
70
70
  | **AI Guide Template** | `docs/ai-guide-template.md` | Template for creating `public/ai-guide.md` — customise per app |
71
71
  | **Forms** | `docs/forms.md` | Form definitions, schema-driven rendering, submission patterns |
72
72
  | **Auth Kit** | `docs/auth-kit.md` | End-user sign-in: email/password, magic links, phone OTP, Google OAuth |
73
- | **Records Admin Pattern** | `docs/records-admin-pattern.md` | Standard pattern for per-product/facet/variant/batch admin UIs |
73
+ | **App Records Pattern** | `docs/app-records-pattern.md` | Standard pattern for per-product/facet/variant/batch admin + public widget UIs |
74
74
  | **UI Utils** | `docs/ui-utils.md` | `@proveanything/smartlinks-utils-ui` — React shells, hooks, and primitives for records-based apps |
75
75
 
76
76
  ---
package/docs/ui-utils.md CHANGED
@@ -19,7 +19,7 @@ translate SDK data into consistent admin interfaces.
19
19
 
20
20
  **When do you need it?**
21
21
 
22
- - You are building an admin UI for a records-based microapp (see [records-admin-pattern.md](records-admin-pattern.md))
22
+ - You are building an admin UI for a records-based microapp (see [app-records-pattern.md](app-records-pattern.md))
23
23
  - You need a media asset picker, icon picker, or font picker in an admin panel
24
24
  - You need a recursive rule/conditions editor for targeting or audience logic
25
25
  - You want the standard inheritance/override editor for scoped records
@@ -326,7 +326,7 @@ Props:
326
326
 
327
327
  Free-text facet entry is **not** supported — admins must pick from defined facets.
328
328
 
329
- See [records-admin-pattern.md §4](records-admin-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
329
+ See [app-records-pattern.md §4](app-records-pattern.md#4-admin-side----recordsadminshell-the-only-thing-you-should-be-writing) for the standalone usage example.
330
330
 
331
331
  ---
332
332
 
@@ -505,6 +505,6 @@ your microapp ← domain logic and custom forms
505
505
 
506
506
  ## Further reading
507
507
 
508
- - [records-admin-pattern.md](records-admin-pattern.md) — the data contract that `RecordsAdminShell` implements
508
+ - [app-records-pattern.md](app-records-pattern.md) — the data contract that `RecordsAdminShell` implements
509
509
  - [building-react-components.md](building-react-components.md) — dual-mode rendering rules that apply to all components
510
510
  - [app-manifest.md](app-manifest.md) — the `records` manifest block that drives tab generation
package/openapi.yaml CHANGED
@@ -10355,6 +10355,40 @@ paths:
10355
10355
  application/json:
10356
10356
  schema:
10357
10357
  $ref: "#/components/schemas/PublicInteractionsCountsByOutcomeRequest"
10358
+ /public/collection/{collectionId}/interactions/submit:
10359
+ post:
10360
+ tags:
10361
+ - interactions
10362
+ summary: "POST /api/v1/public/collection/:collectionId/interactions/submit Submits an interaction event from a public/client-side context."
10363
+ operationId: interactions_submitPublicEvent
10364
+ security: []
10365
+ parameters:
10366
+ - name: collectionId
10367
+ in: path
10368
+ required: true
10369
+ schema:
10370
+ type: string
10371
+ responses:
10372
+ 200:
10373
+ description: Success
10374
+ content:
10375
+ application/json:
10376
+ schema:
10377
+ oneOf:
10378
+ - $ref: "#/components/schemas/SubmitInteractionResponse"
10379
+ - $ref: "#/components/schemas/SubmitInteractionError"
10380
+ 400:
10381
+ description: Bad request
10382
+ 401:
10383
+ description: Unauthorized
10384
+ 404:
10385
+ description: Not found
10386
+ requestBody:
10387
+ required: true
10388
+ content:
10389
+ application/json:
10390
+ schema:
10391
+ $ref: "#/components/schemas/AppendInteractionBody"
10358
10392
  /public/collection/{collectionId}/interactions/{id}:
10359
10393
  get:
10360
10394
  tags:
@@ -20187,6 +20221,10 @@ components:
20187
20221
  type: boolean
20188
20222
  allowOwnRead:
20189
20223
  type: boolean
20224
+ uniquePerAnonId:
20225
+ type: boolean
20226
+ uniquePerAnonIdWindowSeconds:
20227
+ type: number
20190
20228
  InteractionDisplay:
20191
20229
  type: object
20192
20230
  properties:
@@ -20275,6 +20313,26 @@ components:
20275
20313
  type: number
20276
20314
  offset:
20277
20315
  type: number
20316
+ SubmitInteractionResponse:
20317
+ type: object
20318
+ properties:
20319
+ success:
20320
+ type: object
20321
+ additionalProperties: true
20322
+ eventId:
20323
+ type: string
20324
+ required:
20325
+ - success
20326
+ - eventId
20327
+ SubmitInteractionError:
20328
+ type: object
20329
+ properties:
20330
+ error:
20331
+ type: string
20332
+ enum:
20333
+ - FORBIDDEN
20334
+ required:
20335
+ - error
20278
20336
  Job:
20279
20337
  type: object
20280
20338
  properties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "1.11.6",
3
+ "version": "1.11.8",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",