@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.
- package/dist/api/authKit.d.ts +17 -1
- package/dist/api/authKit.js +24 -0
- package/dist/docs/API_SUMMARY.md +120 -7
- package/dist/docs/auth-kit.md +50 -0
- package/dist/docs/connectors.md +42 -4
- package/dist/docs/entitlements.md +90 -0
- package/dist/docs/integrations.md +159 -9
- package/dist/docs/interactions.md +545 -540
- package/dist/docs/loyalty.md +17 -0
- package/dist/docs/server-functions.md +2 -0
- package/dist/openapi.yaml +258 -2
- package/dist/types/appManifest.d.ts +19 -1
- package/dist/types/authKit.d.ts +45 -1
- package/dist/types/integrations.d.ts +148 -3
- package/docs/API_SUMMARY.md +120 -7
- package/docs/auth-kit.md +50 -0
- package/docs/connectors.md +42 -4
- package/docs/entitlements.md +90 -0
- package/docs/integrations.md +159 -9
- package/docs/interactions.md +545 -540
- package/docs/loyalty.md +17 -0
- package/docs/server-functions.md +2 -0
- package/openapi.yaml +258 -2
- package/package.json +87 -87
|
@@ -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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
15
|
-
automatically when
|
|
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:
|
|
58
|
-
targetEntity:
|
|
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 +
|