@proveanything/smartlinks 2.1.12 → 2.1.14

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.
@@ -3,16 +3,18 @@
3
3
  An **integration flow** is one input/output pipeline between SmartLinks and an external
4
4
  system. There are two directions:
5
5
 
6
- - **outbound** — read a SmartLinks entity (v1: a product), transform it with field
7
- mappings, and send it to an external endpoint.
8
- - **inbound** — fetch from an external system and write a SmartLinks entity. *(Executor is
9
- outbound-first; inbound lands in a later increment.)*
6
+ - **outbound** — read a SmartLinks **product** that changed, or **one event** (an interaction, a
7
+ contact change, a tag scan — any spine event) as the [envelope](#event-sourced-flows-and-the-envelope),
8
+ transform it with field mappings, and send it to an external endpoint (signed, if you want).
9
+ - **inbound** — fetch from an external system and write a SmartLinks **product** or **contact**
10
+ (a provider's view of a person: member id, tier, points — see [Contact as a target](#contact-as-a-target)).
10
11
 
11
12
  Flows are triggered three ways, all converging on the same executor:
12
13
 
13
14
  - **manual** — `integrations.runFlow(...)`, inline (returns a run summary) or enqueued.
14
- - **event** — an outbound flow subscribed to an event type (e.g. `product.updated`) fires
15
- automatically when that entity changes.
15
+ - **event** — an outbound flow subscribed to an event type (e.g. `product.updated`,
16
+ `interaction.submitted`, `contact.*`, `tag.scanned`, `*`) fires automatically when it happens. See
17
+ [Event names and patterns](#event-names-and-patterns).
16
18
  - **schedule** — a flow carrying a cron/interval `schedule` is run by the scan job. *(next)*
17
19
 
18
20
  Provider-specific logic (a provider's payload format, its API, its connect form) lives in a
@@ -52,17 +54,21 @@ interface IntegrationFlow {
52
54
  direction: 'inbound' | 'outbound'
53
55
  name: string
54
56
  status: 'draft' | 'active' | 'paused' | 'error' // only 'active' flows fire on events/schedule
55
- eventTypes: string[] // e.g. ['product.updated']
57
+ eventTypes: string[] // e.g. ['product.updated'], ['interaction.*'], ['*']
56
58
  schedule: string | null // cron/interval for scheduled flows
57
- sourceEntity: string | null // outbound source, v1: 'product'
58
- targetEntity: string | null // inbound target
59
+ sourceEntity: 'product' | 'interaction' | 'contact' | 'event' | null // outbound: what it reads
60
+ targetEntity: 'product' | 'contact' | null // inbound: what it writes
59
61
  config: {
60
62
  connection?: {
61
63
  baseUrl?: string
62
64
  sendEndpoint?: string // outbound: appended to baseUrl
63
65
  defaultHeaders?: Record<string, string>
64
66
  auth?: { method: 'api_key' | 'bearer' | 'basic' | ..., headerName?: string, credentialRef?: string }
67
+ timeoutMs?: number
65
68
  }
69
+ signing?: { credentialRef: string, previousCredentialRef?: string } // X-SmartLinks-Signature (below)
70
+ identity?: { policy?: 'externalIds' | 'hashed' | 'raw', hashKeyCredentialRef?: string } // event-sourced flows
71
+ eventMapping?: EventMapping // event-sourced flows: many events → the provider's few
66
72
  fieldMappings?: FieldMapping[]
67
73
  }
68
74
  // ...run watermark/telemetry: lastRunAt, lastRunStatus, lastRunCount, totalSynced
@@ -85,6 +91,150 @@ aborts the whole record.
85
91
 
86
92
  ---
87
93
 
94
+ ## Event names and patterns
95
+
96
+ A flow's `eventTypes` (and a server function's `trigger.eventTypes`) name events exactly or by prefix:
97
+
98
+ | Subscription | Matches |
99
+ |---|---|
100
+ | `product.updated` | exactly that |
101
+ | `interaction.submitted:<typeId>` | one interaction type's submissions |
102
+ | `interaction.*` | every interaction event |
103
+ | `record.updated:*` | record updates of any type |
104
+ | `*` | everything in the collection — a raw event stream |
105
+
106
+ Events on the spine today: `product.created / updated / deleted`, `record.<verb>[:<recordType>]`,
107
+ `interaction.submitted[:<typeId>]`, `contact.created / updated / deleted / erased`,
108
+ `tag.scanned[:<eventType>]` (a scan resolves in the redirect, before any page could run a tag),
109
+ `proof.created / transferred`, `attestation.created[:type]`, `booking.*`, `slot.*`.
110
+
111
+ ---
112
+
113
+ ## Event-sourced flows and the envelope
114
+
115
+ A flow with `sourceEntity: 'interaction' | 'contact' | 'event'` sends **one event per run** — not a
116
+ product. Every such event is wrapped in the same **envelope**, whatever the destination, and only then
117
+ mapped with the flow's field mappings (or handed to a connector's `serialize`):
118
+
119
+ ```jsonc
120
+ {
121
+ "id": "0b2b…", // the event's id — stable across retries; use it as the destination's idempotency / transaction id
122
+ "type": "interaction.submitted:quiz-1",
123
+ "entityType": "interaction", "entityId": "0b2b…",
124
+ "occurredAt": "2026-11-08T10:00:00.000Z",
125
+ "collectionId": "…", "orgId": "…", "appId": "quiz",
126
+ "identity": {
127
+ "contactId": "…", "userId": "…",
128
+ "externalIds": { "acme-loyalty": "M-9", "crm": "C-1" }, // ids other systems know this person by
129
+ "emailHash": "…", "phoneHash": "…", "hashAlgorithm": "hmac-sha256" // policy 'hashed' (default)
130
+ // "email", "phone": only under policy 'raw'
131
+ },
132
+ "identityPolicy": "hashed",
133
+ "product": { "id": "P1", "name": "Widget", "sku": "W-1", "gtin": "0500…", "variantId": null, "batchId": null },
134
+ "proof": { "id": "…" },
135
+ "subject": { "interactionId": "quiz-1", "outcome": "completed", "metadata": { "score": 9 }, "source": "hub" },
136
+ "event": { "name": "engagement_completed", "attributes": { "kind": "quiz", "score": 9 }, "rule": "quiz" }, // from eventMapping
137
+ "attributes": {}
138
+ }
139
+ ```
140
+
141
+ Field mappings read envelope paths (`identity.externalIds.acme-loyalty`, `subject.outcome`,
142
+ `event.name`, `product.sku`). With no mappings the envelope itself is sent. A failed event delivery
143
+ keeps its envelope and is retried from it; a product is re-read instead.
144
+
145
+ ```ts
146
+ const flow = await integrations.createFlow(collectionId, {
147
+ appId: 'core-integrations', direction: 'outbound', name: 'Engagement → loyalty', status: 'active',
148
+ eventTypes: ['interaction.*', 'tag.scanned:*'],
149
+ sourceEntity: 'event',
150
+ config: {
151
+ connection: { baseUrl: 'https://api.acme.example', sendEndpoint: '/events', auth: { method: 'bearer', credentialRef: ref } },
152
+ signing: { credentialRef: signingRef },
153
+ identity: { policy: 'hashed' },
154
+ eventMapping: {
155
+ rules: [
156
+ { id: 'quiz', match: { interactionIds: ['quiz-1'], outcomes: ['completed'] }, event: 'engagement_completed',
157
+ attributes: { kind: 'quiz', score: '{{ subject.metadata.score }}', sku: { path: 'product.sku' } } },
158
+ { id: 'scan', match: { types: ['tag.scanned:*'] }, event: 'pack_scanned', attributes: { sku: { path: 'product.sku' } } },
159
+ ],
160
+ unmatched: 'skip',
161
+ },
162
+ fieldMappings: [
163
+ { targetPath: 'event_name', sourcePath: 'event.name', transformType: 'direct' },
164
+ { targetPath: 'external_id', sourcePath: 'identity.externalIds.acme-loyalty', transformType: 'direct' },
165
+ { targetPath: 'transaction_id', sourcePath: 'id', transformType: 'direct' },
166
+ ],
167
+ },
168
+ })
169
+ ```
170
+
171
+ ### Identity policy
172
+
173
+ `config.identity.policy` decides what about the person leaves the platform:
174
+
175
+ | Policy | The envelope's `identity` carries |
176
+ |---|---|
177
+ | `externalIds` | `contactId`, `userId`, `externalIds` — nothing else |
178
+ | `hashed` (default) | the above plus `emailHash` / `phoneHash`: HMAC-SHA256 with a **per-collection** key, or the brand's own key when `identity.hashKeyCredentialRef` names one (so hashes match a mirror the brand built) |
179
+ | `raw` | the above plus `email` and `phone` |
180
+
181
+ No hash key configured on the server → no hashes, never an unkeyed hash.
182
+
183
+ ### Event mapping
184
+
185
+ Loyalty and engagement platforms accept a small, pre-registered set of event names. `config.eventMapping`
186
+ folds our many interaction types into those names: rules in order, first match wins; `match` may name
187
+ `types` (exact or patterns), `interactionIds`, `outcomes`, `appIds`; attributes are literals, Liquid
188
+ templates over the envelope, or `{ path, default }`. Unmatched events are skipped (and shown as skipped
189
+ in the run) unless `unmatched: 'passthrough'`. When the flow has a connector with a `describeEvents`
190
+ step, the mapping is **validated when saved** against the provider's registered events and its
191
+ `limits` (`maxEvents`, `maxAttributes`): `createFlow` / `updateFlow` answer `400 EVENT_MAPPING_INVALID`
192
+ with `problems[]`.
193
+
194
+ ### Signed deliveries
195
+
196
+ With `config.signing.credentialRef`, every HTTP delivery carries:
197
+
198
+ ```
199
+ X-SmartLinks-Signature: t=1730900000,v1=<hex HMAC-SHA256(secret, `${t}.${rawBody}`)>
200
+ X-SmartLinks-Delivery: <uuid, new per attempt>
201
+ X-SmartLinks-Event: interaction.submitted:quiz-1
202
+ X-SmartLinks-Run: <run id>
203
+ Idempotency-Key: <flowId>:<eventId>:<hash of the body>
204
+ ```
205
+
206
+ The signature covers the exact bytes of the body. A receiver recomputes it, compares in constant time,
207
+ and rejects `t` older than about five minutes. During a rotation `previousCredentialRef` adds a second
208
+ `v1=` so a receiver that knows either secret verifies.
209
+
210
+ Destinations must be **public hosts**: loopback, private ranges and cloud metadata addresses are
211
+ refused as a permanent failure. (Local development: `INTEGRATION_ALLOW_PRIVATE_DESTINATIONS=1` on the
212
+ server.)
213
+
214
+ ### Contact as a target
215
+
216
+ An inbound flow with `targetEntity: 'contact'` writes a provider's view of a person. After the
217
+ connector's `shape` (or your field mappings) each record is a **member document**:
218
+
219
+ ```jsonc
220
+ {
221
+ "identity": { "externalId": "M-9", "email": "ada@example.com" }, // contactId | externalId | email | phone — at least one
222
+ "contact": { "firstName": "Ada", "lastName": "Lovelace" }, // fills EMPTY profile fields only
223
+ "externalIds": { "acme-crm": "C-1" }, // merged
224
+ "customFields": { "region": "UK" }, // merged (never `comms`)
225
+ "tags": { "add": ["gold"], "remove": ["silver"] },
226
+ "loyalty": { "memberId": "M-9", "tier": "gold", "points": 1500, "status": "active", "asOf": "…", "attributes": { "since": 2024 } }
227
+ }
228
+ ```
229
+
230
+ Matching: `contactId` → `externalIds[<provider>]` → email → phone; a new contact is created once however
231
+ many workers see the same member. Loyalty lands as `customFields.loyalty` plus flat
232
+ `customFields.loyaltyTier / loyaltyPoints / loyaltyMemberId / loyaltyProvider` for segments and comms
233
+ targeting. The same document is what a server function applies with `ctx.sl.members.apply(...)` (a
234
+ provider's webhook, see [connectors.md](connectors.md#loyalty-and-crm-connectors)).
235
+
236
+ ---
237
+
88
238
  ## Secrets (write-only)
89
239
 
90
240
  The secret store is **write-only from the client**: you can set, rotate, list (refs +