@proveanything/smartlinks 2.1.13 → 2.1.15

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,158 @@ 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
+ "specVersion": 1, // bumped only when a consumer could break
122
+ "id": "0b2b…", // the event's id — stable across retries; use it as the destination's idempotency / transaction id
123
+ "type": "interaction.submitted:quiz-1",
124
+ "entityType": "interaction", "entityId": "0b2b…",
125
+ "occurredAt": "2026-11-08T10:00:00.000Z",
126
+ "collectionId": "…", "orgId": "…", "appId": "quiz",
127
+ "identity": {
128
+ "contactId": "…", "userId": "…",
129
+ "externalIds": { "acme-loyalty": "M-9", "crm": "C-1" }, // ids other systems know this person by
130
+ "anonymousId": "v-77", "sessionId": "s-1", // the visitor / device and session, before anyone signed in (every policy)
131
+ "emailHash": "…", "phoneHash": "…", "hashAlgorithm": "hmac-sha256" // policy 'hashed' (default)
132
+ // "email", "phone": only under policy 'raw'
133
+ },
134
+ "identityPolicy": "hashed",
135
+ "product": { "id": "P1", "name": "Widget", "sku": "W-1", "gtin": "0500…", "variantId": null, "batchId": null },
136
+ "proof": { "id": "…" },
137
+ "consent": { "channels": { "email": "opted_in", "sms": "suppressed", "push": "unknown", "whatsapp": "unknown" }, "topics": { "offers": true }, "updatedAt": "…" }, // null when about nobody we hold
138
+ "subject": { "interactionId": "quiz-1", "outcome": "completed", "metadata": { "score": 9 }, "source": "hub" },
139
+ "event": { "name": "engagement_completed", "attributes": { "kind": "quiz", "score": 9 }, "rule": "quiz" }, // from eventMapping
140
+ "attributes": {}
141
+ }
142
+ ```
143
+
144
+ Field mappings read envelope paths (`identity.externalIds.acme-loyalty`, `subject.outcome`,
145
+ `event.name`, `product.sku`). With no mappings the envelope itself is sent. A failed event delivery
146
+ keeps its envelope and is retried from it; a product is re-read instead.
147
+
148
+ **CloudEvents.** `config.envelopeFormat: 'cloudevents'` wraps each delivered body (mapped or not) as a
149
+ CloudEvents 1.0 structured event — `{ specversion: '1.0', id, type, source: 'urn:smartlinks:collection:<id>',
150
+ subject, time, datacontenttype, dataschema: 'urn:smartlinks:envelope:v1', data }` — the shape every event
151
+ bus, lake and serverless tool already consumes.
152
+
153
+ ```ts
154
+ const flow = await integrations.createFlow(collectionId, {
155
+ appId: 'core-integrations', direction: 'outbound', name: 'Engagement → loyalty', status: 'active',
156
+ eventTypes: ['interaction.*', 'tag.scanned:*'],
157
+ sourceEntity: 'event',
158
+ config: {
159
+ connection: { baseUrl: 'https://api.acme.example', sendEndpoint: '/events', auth: { method: 'bearer', credentialRef: ref } },
160
+ signing: { credentialRef: signingRef },
161
+ identity: { policy: 'hashed' },
162
+ eventMapping: {
163
+ rules: [
164
+ { id: 'quiz', match: { interactionIds: ['quiz-1'], outcomes: ['completed'] }, event: 'engagement_completed',
165
+ attributes: { kind: 'quiz', score: '{{ subject.metadata.score }}', sku: { path: 'product.sku' } } },
166
+ { id: 'scan', match: { types: ['tag.scanned:*'] }, event: 'pack_scanned', attributes: { sku: { path: 'product.sku' } } },
167
+ ],
168
+ unmatched: 'skip',
169
+ },
170
+ fieldMappings: [
171
+ { targetPath: 'event_name', sourcePath: 'event.name', transformType: 'direct' },
172
+ { targetPath: 'external_id', sourcePath: 'identity.externalIds.acme-loyalty', transformType: 'direct' },
173
+ { targetPath: 'transaction_id', sourcePath: 'id', transformType: 'direct' },
174
+ ],
175
+ },
176
+ })
177
+ ```
178
+
179
+ ### Identity policy
180
+
181
+ `config.identity.policy` decides what about the person leaves the platform:
182
+
183
+ | Policy | The envelope's `identity` carries |
184
+ |---|---|
185
+ | `externalIds` | `contactId`, `userId`, `externalIds` — nothing else |
186
+ | `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) |
187
+ | `raw` | the above plus `email` and `phone` |
188
+
189
+ No hash key configured on the server → no hashes, never an unkeyed hash.
190
+
191
+ ### Event mapping
192
+
193
+ Loyalty and engagement platforms accept a small, pre-registered set of event names. `config.eventMapping`
194
+ folds our many interaction types into those names: rules in order, first match wins; `match` may name
195
+ `types` (exact or patterns), `interactionIds`, `outcomes`, `appIds`; attributes are literals, Liquid
196
+ templates over the envelope, or `{ path, default }`. Unmatched events are skipped (and shown as skipped
197
+ in the run) unless `unmatched: 'passthrough'`. When the flow has a connector with a `describeEvents`
198
+ step, the mapping is **validated when saved** against the provider's registered events and its
199
+ `limits` (`maxEvents`, `maxAttributes`): `createFlow` / `updateFlow` answer `400 EVENT_MAPPING_INVALID`
200
+ with `problems[]`.
201
+
202
+ ### Signed deliveries
203
+
204
+ With `config.signing.credentialRef`, every HTTP delivery carries:
205
+
206
+ ```
207
+ X-SmartLinks-Signature: t=1730900000,v1=<hex HMAC-SHA256(secret, `${t}.${rawBody}`)>
208
+ X-SmartLinks-Delivery: <uuid, new per attempt>
209
+ X-SmartLinks-Event: interaction.submitted:quiz-1
210
+ X-SmartLinks-Run: <run id>
211
+ Idempotency-Key: <flowId>:<eventId>:<hash of the body>
212
+ ```
213
+
214
+ The signature covers the exact bytes of the body. A receiver recomputes it, compares in constant time,
215
+ and rejects `t` older than about five minutes. During a rotation `previousCredentialRef` adds a second
216
+ `v1=` so a receiver that knows either secret verifies.
217
+
218
+ Destinations must be **public hosts**: loopback, private ranges and cloud metadata addresses are
219
+ refused as a permanent failure. (Local development: `INTEGRATION_ALLOW_PRIVATE_DESTINATIONS=1` on the
220
+ server.)
221
+
222
+ ### Contact as a target
223
+
224
+ An inbound flow with `targetEntity: 'contact'` writes a provider's view of a person. After the
225
+ connector's `shape` (or your field mappings) each record is a **member document**:
226
+
227
+ ```jsonc
228
+ {
229
+ "identity": { "externalId": "M-9", "email": "ada@example.com" }, // contactId | externalId | email | phone — at least one
230
+ "contact": { "firstName": "Ada", "lastName": "Lovelace" }, // fills EMPTY profile fields only
231
+ "externalIds": { "acme-crm": "C-1" }, // merged
232
+ "customFields": { "region": "UK" }, // merged (never `comms`)
233
+ "tags": { "add": ["gold"], "remove": ["silver"] },
234
+ "loyalty": { "memberId": "M-9", "tier": "gold", "points": 1500, "status": "active", "asOf": "…", "attributes": { "since": 2024 } }
235
+ }
236
+ ```
237
+
238
+ Matching: `contactId` → `externalIds[<provider>]` → email → phone; a new contact is created once however
239
+ many workers see the same member. Loyalty lands as `customFields.loyalty` plus flat
240
+ `customFields.loyaltyTier / loyaltyPoints / loyaltyMemberId / loyaltyProvider` for segments and comms
241
+ targeting. The same document is what a server function applies with `ctx.sl.members.apply(...)` (a
242
+ provider's webhook, see [connectors.md](connectors.md#loyalty-and-crm-connectors)).
243
+
244
+ ---
245
+
88
246
  ## Secrets (write-only)
89
247
 
90
248
  The secret store is **write-only from the client**: you can set, rotate, list (refs +