@proveanything/smartlinks 2.1.13 → 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.
@@ -1,7 +1,7 @@
1
1
  # Integration connectors
2
2
 
3
3
  A **connector** is an app that knows one external provider (a PIM, a content network, a shop
4
- platform…). It is installed on a collection like any app, versioned and released like any app, and
4
+ platform, a loyalty or CRM system…). It is installed on a collection like any app, versioned and released like any app, and
5
5
  it adds a manifest `connector` block plus a handful of [server functions](server-functions.md) the
6
6
  integration engine and the integrations console call by convention.
7
7
 
@@ -83,7 +83,7 @@ Types: `AppConnectorDeclaration` and friends in `@proveanything/smartlinks` (`sr
83
83
  | `provider` | Stable provider key (`syndigo`, `oneworldsync`). |
84
84
  | `title` | Provider display name; defaults to the app name. |
85
85
  | `directions` | `inbound` (provider → SmartLinks), `outbound` (SmartLinks → provider), or both. |
86
- | `entities` | SmartLinks entities it maps, e.g. `["product"]`. |
86
+ | `entities` | SmartLinks entities it maps: `product`, and for people-shaped providers `interaction`, `contact`, `event` (see [Loyalty and CRM connectors](#loyalty-and-crm-connectors)). |
87
87
  | `connection.fields[]` | The console's **Connect** form. A field with `secret` is stored as this app's secret of that name on the collection (write-only; never returned to the browser). Other fields are saved in the installation's connection settings. |
88
88
  | `settings` | JSON Schema for installation settings; the console renders it as a form. |
89
89
  | `presets[]` | Ready-made flows ("Products → provider"). A preset's `flow.config.fieldMappings` is a **suggested** mapping: the connector's knowledge of the provider, which the customer accepts or edits in core. |
@@ -91,7 +91,7 @@ Types: `AppConnectorDeclaration` and friends in `@proveanything/smartlinks` (`sr
91
91
  | `panels[]` | Provider-specific screens the console mounts (an API explorer, a status page). |
92
92
  | `aiGuide` | A guide the mapping assistant reads: the provider's quirks, required fields, good defaults. |
93
93
  | `recordId` | Path of the provider's own id in a fetched record (e.g. `gtin`). Inbound records are landed and matched by it, so a re-import updates the same product instead of creating another. Default `id`; a flow can override it (`config.upsert.externalIdPath`). |
94
- | `limits` | `{ requestsPerSecond }` toward the provider, enforced across every SmartLinks worker. |
94
+ | `limits` | `{ requestsPerSecond }` toward the provider, enforced across every SmartLinks worker; `{ maxEvents, maxAttributes }` when the provider caps registered events — a flow's `eventMapping` is checked against them when saved. |
95
95
 
96
96
  ---
97
97
 
@@ -111,7 +111,9 @@ declare the provider's hosts (`network:<host>`) and the secrets it reads (`secre
111
111
  | `fetch` | Every inbound run | `ConnectorFetchInput` `{ installation, cursor, limit }` | `ConnectorRecords` `{ records, nextCursor }`: one page per call |
112
112
  | `shape` | Every inbound run (and sample dry runs) | `ConnectorShapeInput` `{ installation, records }` | `ConnectorShapeResult` `{ documents, skipped }`: one provider-neutral document per record, same order; `null` skips one |
113
113
  | `serialize` | Every outbound run | `ConnectorSerializeInput` `{ installation, documents, entities, targetOptions, eventType? }`: documents core mapped to `describeTarget` paths; `entities` = the SmartLinks record each came from (id, name); `targetOptions` = per-field options the mapping carries (e.g. locale, unit); a deletion arrives as `{ id, _deleted: true }` | `ConnectorSerializeResult` `{ payloads, skipped }`: one provider payload per document, same order; `null` skips one |
114
- | `send` | Every outbound run | `ConnectorSendInput` `{ installation, payloads }` | `ConnectorSendResult` `{ results: [{ index, ok, status, error, externalId }] }` |
114
+ | `send` | Every outbound run | `ConnectorSendInput` `{ installation, payloads, eventType? }` | `ConnectorSendResult` `{ results: [{ index, ok, status, error, externalId, retryable?, retryAfterSeconds? }] }` — `retryable: false` for a provider validation error (dead at once), `true` / a 5xx or 429 status for anything worth retrying |
115
+ | `describeEvents` | Mapper opens; a flow with an `eventMapping` is saved | `{ installation }` | `ConnectorEventsSchema` `{ events: [{ name, title, attributes: [{ name, type, required }] }], limits: { maxEvents, maxAttributes } }`: the event names the provider has registered for this customer |
116
+ | `lookup` | An entitlement check (`docs/entitlements.md`) | `ConnectorLookupInput` `{ installation, identity: { externalId, email, phone } \| { externalId, emailHash, phoneHash }, want }` | `ConnectorLookupResult` `{ found, memberId, tier, points, status, attributes, asOf }`. Throw for an outage — never `found: false` |
115
117
 
116
118
  **How a run flows:**
117
119
  - **Inbound:** `fetch` (paged; every record is landed as received) → `shape` → core maps the documents
@@ -182,6 +184,41 @@ export async function shape(_ctx: any, event: any): Promise<ConnectorShapeResult
182
184
 
183
185
  ---
184
186
 
187
+ ## Loyalty and CRM connectors
188
+
189
+ A provider that is about **people** rather than products uses the same contract with three more
190
+ pieces; nothing provider-specific lives in core. Full contract: `loyalty-connector-authoring.md` in the
191
+ platform's design docs; the shapes are in [integrations.md](integrations.md#event-sourced-flows-and-the-envelope).
192
+
193
+ | Flow | What core does | What the connector does |
194
+ |---|---|---|
195
+ | **Earn** (us → them) | an outbound flow with `sourceEntity: 'interaction' \| 'contact' \| 'event'` sends one **envelope** per event, through the flow's identity policy and `eventMapping`, signed, with retries and a run log | `describeEvents` (what the provider has registered), `serialize` (envelope → the provider's event payload), `send` if the generic HTTP destination can't make the call |
196
+ | **Profile** (them → us) | `ctx.sl.members.apply(document)` matches the contact and writes member id, tier, points, tags, external ids; or an inbound flow with `targetEntity: 'contact'` | an **HTTP-trigger function** receiving the provider's member webhook (verify their signature, then apply); or `fetch` + `shape` for a poll |
197
+ | **Gate / display** (on demand) | `entitlements.check` — claims → cached `lookup` → the contact's own loyalty fields → fallback; Valkey cache keyed on an identity hash, brand-set TTL | `lookup`: one identity → tier / points / status |
198
+
199
+ ```ts
200
+ // A member webhook: the provider tells us about a person; we land it on the contact.
201
+ export default async function memberWebhook(event: any, ctx: any) {
202
+ verifyProviderSignature(event, await ctx.secrets.get('webhookSecret'))
203
+ const m = event.body
204
+ return ctx.sl.members.apply({
205
+ identity: { externalId: m.memberId, email: m.email },
206
+ contact: { firstName: m.firstName, lastName: m.lastName }, // fills empty fields only
207
+ loyalty: { memberId: m.memberId, tier: m.tier, points: m.points, status: m.status, asOf: m.updatedAt },
208
+ tags: { add: [`tier:${m.tier}`] },
209
+ }, { provider: 'acme-loyalty' }) // → { contactId, op: 'created' | 'updated' | 'unchanged' }
210
+ }
211
+ ```
212
+
213
+ Declare it `{ "trigger": { "type": "http" }, "capabilities": ["sl:members:write", "secrets:webhookSecret"] }`;
214
+ `sl:members:write` is approved by the admin on install. The envelope's `identity.externalIds[<provider>]`
215
+ is the member id this wrote back, so **Earn** can address the member by the provider's own id.
216
+
217
+ A connector should ship an `eventMapping` **preset** with the provider's standard event names and an
218
+ `aiGuide` paragraph, so the console's assistant proposes a customer's mapping in the provider's terms.
219
+
220
+ ---
221
+
185
222
  ## Where configuration lives
186
223
 
187
224
  | What | Where |
@@ -220,6 +257,7 @@ await secrets.put(collectionId, 'provider-key', value, { appId }) /
220
257
  await integrations.saveConnectorInstallation(collectionId, appId, { connection: { environment: 'production' } })
221
258
  const test = await integrations.runConnectorStep(collectionId, appId, 'testConnection')
222
259
  const target = await integrations.runConnectorStep(collectionId, appId, 'describeTarget')
260
+ const events = await integrations.runConnectorStep(collectionId, appId, 'describeEvents') // loyalty / engagement providers
223
261
  const { records } = await integrations.runConnectorStep(collectionId, appId, 'sample', { limit: 5 })
224
262
  const shaped = await integrations.runConnectorStep(collectionId, appId, 'shape', { records }) // dry run
225
263
  const wire = await integrations.runConnectorStep(collectionId, appId, 'serialize', { documents }) // dry run
@@ -0,0 +1,90 @@
1
+ # Entitlements: "can this person do this?" without a mirror of their member base
2
+
3
+ A brand with twenty million members wants a game only Gold members can play. SmartLinks never copies
4
+ the member base to find out. A **gate** says what it needs — a predicate such as `tier>=gold` — and the
5
+ platform asks the collection's configured **sources**, in order, stopping at the first that answers.
6
+ The brand's system stays the record of truth; every answer says where it came from (`basis`) and how
7
+ old it is (`asOf`).
8
+
9
+ | Source | Data kept here | When it fits |
10
+ |---|---|---|
11
+ | **claims** | none | the brand's SSO or app issues a short-lived signed token carrying the tier; the caller presents it |
12
+ | **lookup** | a hashed key → a small answer, for minutes | the loyalty connector's `lookup` step answers for one person; cached in Valkey under an HMAC of the identity for the TTL the brand sets (default 15 min; "not a member" 5 min); one in-flight lookup per key; the connector's own rate limit applies |
13
+ | **contact** | what the contact already holds | `customFields.loyalty` / `loyaltyTier` written by the connector's member webhook (`ctx.sl.members.apply`) |
14
+ | **fallback** | — | when nothing answered: `deny` (default), `allow`, or `degrade` (allow, flagged `degraded: true`) |
15
+
16
+ Every decision is one structured log line — gate, predicate, allowed, basis, age, an identity **hash**
17
+ — and never an identifier. A caller sees `attributes` (tier, points, status, member) and never a member
18
+ id.
19
+
20
+ ## Predicates
21
+
22
+ ```
23
+ 'tier>=gold' 'tier=silver' 'tier in gold,platinum' 'points>=500' 'member'
24
+ { tier: { gte: 'gold' } } { tier: { in: ['gold', 'platinum'] } } { member: true }
25
+ { attribute: { path: 'status', eq: 'active' } }
26
+ { all: [p1, p2] } { any: [p1, p2] } { not: p }
27
+ ```
28
+
29
+ Tier comparisons use the collection's ordered `tiers` list (lowest first); a tier the list doesn't know
30
+ never satisfies an ordered comparison. A malformed predicate is a configuration error (`BAD_PREDICATE`),
31
+ never a silent denial.
32
+
33
+ ## Where gates live
34
+
35
+ **An interaction type** — the public submit route checks it; the caller may present a claim:
36
+
37
+ ```ts
38
+ await SL.interactions.updateInteraction(collectionId, appId, typeId, {
39
+ permissions: { allowPublicSubmit: true, requires: { entitlement: 'tier>=gold', fallback: 'deny' } },
40
+ })
41
+ // A refused submit: 403 { error: 'FORBIDDEN', reason: 'entitlement_denied' }
42
+ // With a claim: body.entitlementToken = '<jwt>' or header X-SmartLinks-Entitlement: <jwt>
43
+ ```
44
+
45
+ **A server function** — for the signed-in caller by default:
46
+
47
+ ```ts
48
+ const d = await ctx.sl.entitlements.check('tier>=gold') // needs sl:entitlements:read
49
+ // → { allowed: true, basis: 'lookup-cache', asOf: '2026-11-08T09:00:00.000Z', attributes: { tier: 'gold', points: 1500, member: true } }
50
+ if (!d.allowed) return new Response(JSON.stringify({ error: 'gold_only' }), { status: 403 })
51
+ // Someone else, or a presented claim:
52
+ await ctx.sl.entitlements.check({ points: { gte: 500 } }, { identity: { email }, token, fallback: 'degrade' })
53
+ ```
54
+
55
+ ## Configuring a collection
56
+
57
+ The `entitlements` setting group (`settings.set(collectionId, 'entitlements', {...})`):
58
+
59
+ ```jsonc
60
+ {
61
+ "sources": ["claims", "lookup", "contact"], // tried in this order
62
+ "fallback": "deny",
63
+ "tiers": ["bronze", "silver", "gold", "platinum"], // lowest first
64
+ "claims": {
65
+ "secretRef": "sec_brand_sso", // HS256 shared secret in the secret store …
66
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----…", // … or an RS256 / ES256 public key
67
+ "issuer": "https://login.brand.example", "audience": "smartlinks",
68
+ "tierClaim": "tier", "memberIdClaim": "sub", "attributesClaim": "entitlements", "maxAgeSec": 900
69
+ },
70
+ "lookup": {
71
+ "connectorAppId": "acme-loyalty", "provider": "acme-loyalty",
72
+ "ttlSec": 900, "negativeTtlSec": 300,
73
+ "identity": "raw", // what the connector's lookup receives: 'raw' (email/phone) or 'hashed'
74
+ "want": ["tier", "points", "status"]
75
+ }
76
+ }
77
+ ```
78
+
79
+ The connector side (`lookup`) is one step: one identity in, `{ found, memberId, tier, points, status,
80
+ asOf }` out; throw on an outage so the next source is tried — see
81
+ [connectors.md](connectors.md#loyalty-and-crm-connectors).
82
+
83
+ ## What a brand can be told
84
+
85
+ - We never need a copy of your member base: claims and lookups hold nothing, or a minutes-old answer per
86
+ active person.
87
+ - Where you want a local copy, it holds no identifiers (the HMAC mirror — on request).
88
+ - Your system remains the record of truth: every answer carries `asOf` and `basis`; TTLs are yours to set.
89
+ - You see every call: the connector's calls are rate-limited, logged, and leave from a fixed egress IP.
90
+ - Nothing leaves without a policy: the envelope's identity policy per flow.
@@ -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 +