@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.
@@ -105,4 +105,32 @@ export declare namespace integrations {
105
105
  function runConnectorStep<T = unknown>(collectionId: string, appId: string, step: ConnectorConsoleStep, input?: Record<string, unknown> & {
106
106
  flowId?: string;
107
107
  }): Promise<T>;
108
+ /**
109
+ * Invoke a connector ACTION from the console (any visibility): redeem, enrol, adjust points, issue a
110
+ * coupon… The connector's function runs with its own credentials and is told who asked.
111
+ * POST /integrations/connectors/:appId/actions/:action { input }
112
+ */
113
+ function runConnectorAction<T = unknown>(collectionId: string, appId: string, action: string, input?: Record<string, unknown>): Promise<T>;
114
+ /**
115
+ * PUBLIC (the Hub, a site): the connector actions the caller may invoke — those declared `public`,
116
+ * plus `member` ones when signed in.
117
+ * GET /public/collection/:collectionId/connectors/:appId/actions
118
+ */
119
+ function listConnectorActions(collectionId: string, appId: string): Promise<{
120
+ actions: Array<{
121
+ name: string;
122
+ title: string;
123
+ description?: string;
124
+ visibility: 'public' | 'member' | 'admin';
125
+ input?: Record<string, unknown>;
126
+ output?: Record<string, unknown>;
127
+ }>;
128
+ }>;
129
+ /**
130
+ * PUBLIC (the Hub, a site): invoke a connector action — e.g. redeem a reward for the signed-in member.
131
+ * 401 SIGN_IN_REQUIRED for a `member` action without a session; 403 for an admin-only one;
132
+ * 400 with `missing[]` when a required input is absent.
133
+ * POST /public/collection/:collectionId/connectors/:appId/actions/:action { input }
134
+ */
135
+ function callConnectorAction<T = unknown>(collectionId: string, appId: string, action: string, input?: Record<string, unknown>): Promise<T>;
108
136
  }
@@ -200,4 +200,32 @@ export var integrations;
200
200
  return post(`${connectorsBase(collectionId)}/${enc(appId)}/steps/${enc(step)}`, input);
201
201
  }
202
202
  integrations.runConnectorStep = runConnectorStep;
203
+ /**
204
+ * Invoke a connector ACTION from the console (any visibility): redeem, enrol, adjust points, issue a
205
+ * coupon… The connector's function runs with its own credentials and is told who asked.
206
+ * POST /integrations/connectors/:appId/actions/:action { input }
207
+ */
208
+ async function runConnectorAction(collectionId, appId, action, input = {}) {
209
+ return post(`${connectorsBase(collectionId)}/${enc(appId)}/actions/${enc(action)}`, { input });
210
+ }
211
+ integrations.runConnectorAction = runConnectorAction;
212
+ /**
213
+ * PUBLIC (the Hub, a site): the connector actions the caller may invoke — those declared `public`,
214
+ * plus `member` ones when signed in.
215
+ * GET /public/collection/:collectionId/connectors/:appId/actions
216
+ */
217
+ async function listConnectorActions(collectionId, appId) {
218
+ return request(`/public/collection/${enc(collectionId)}/connectors/${enc(appId)}/actions`);
219
+ }
220
+ integrations.listConnectorActions = listConnectorActions;
221
+ /**
222
+ * PUBLIC (the Hub, a site): invoke a connector action — e.g. redeem a reward for the signed-in member.
223
+ * 401 SIGN_IN_REQUIRED for a `member` action without a session; 403 for an admin-only one;
224
+ * 400 with `missing[]` when a required input is absent.
225
+ * POST /public/collection/:collectionId/connectors/:appId/actions/:action { input }
226
+ */
227
+ async function callConnectorAction(collectionId, appId, action, input = {}) {
228
+ return post(`/public/collection/${enc(collectionId)}/connectors/${enc(appId)}/actions/${enc(action)}`, { input });
229
+ }
230
+ integrations.callConnectorAction = callConnectorAction;
203
231
  })(integrations || (integrations = {}));
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.1.13 | Generated: 2026-11-09T11:51:55.948Z
3
+ Version: 2.1.15 | Generated: 2026-11-09T15:17:42.472Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -64,8 +64,9 @@ For detailed guides on specific features:
64
64
  - **[Analytics](analytics.md)** - Web analytics, link-click tracking, QR/tag scan telemetry, and event reporting
65
65
  - **[Analytics Metadata Conventions](analytics-metadata-conventions.md)** - Standard recommended keys and conventions for analytics metadata fields
66
66
  - **[Loyalty: Points, Members & Earning Rules](loyalty.md)** - Loyalty schemes, automatic point earning via interaction rules, member balances, transaction history, and manual adjustments
67
- - **[Integrations](integrations.md)** - Inbound/outbound integration flows + the sealed-secret store; triggers (manual/event/schedule), field mappings, and the Syndigo/Event Hub outbound path
68
- - **[Connectors](connectors.md)** - Connector apps (adapters for one external provider): a manifest `connector` block + server functions (testConnection/describeTarget/sample/fetch/shape/serialize/send); core does the mapping
67
+ - **[Integrations](integrations.md)** - Inbound/outbound integration flows + the sealed-secret store; triggers (manual/event/schedule), event patterns, the event envelope (interaction/contact/event sources), identity policy, event mapping, signed deliveries, contact as a target, field mappings
68
+ - **[Connectors](connectors.md)** - Connector apps (adapters for one external provider): a manifest `connector` block + server functions (testConnection/describeTarget/sample/fetch/shape/serialize/send/describeEvents/lookup); loyalty and CRM connectors; actions (provider operations on demand); core does the mapping
69
+ - **[Entitlements](entitlements.md)** - "Is this person gold or above?" answered on demand from signed claims, a connector lookup (cached), or the contact — never a mirrored member base; gates on interaction types and in server functions
69
70
  - **[AI Tools & Skills](ai-tools-and-skills.md)** - The AI capability catalog: skills apps invoke by name (e.g. research.brand), the tools the AI reaches for (web fetch/screenshot/brand assets/image gen), the agent loop, and how apps discover them
70
71
  - **[Deep Link Discovery](deep-link-discovery.md)** - Registering and discovering navigable app states for portal menus and AI orchestration
71
72
  - **[AI Guide Template](ai-guide-template.md)** - A sample for an app on how to build an AI setup guide
@@ -3111,6 +3112,19 @@ interface ConnectorPanel {
3111
3112
  }
3112
3113
  ```
3113
3114
 
3115
+ **ConnectorAction** (interface)
3116
+ ```typescript
3117
+ interface ConnectorAction {
3118
+ name: string
3119
+ title?: string
3120
+ description?: string
3121
+ function: string
3122
+ visibility?: 'admin' | 'member' | 'public'
3123
+ input?: Record<string, unknown>
3124
+ output?: Record<string, unknown>
3125
+ }
3126
+ ```
3127
+
3114
3128
  **AppConnectorDeclaration** (interface)
3115
3129
  ```typescript
3116
3130
  interface AppConnectorDeclaration {
@@ -3129,11 +3143,14 @@ interface AppConnectorDeclaration {
3129
3143
  serialize?: string
3130
3144
  send?: string
3131
3145
  fetch?: string
3146
+ describeEvents?: string
3147
+ lookup?: string
3132
3148
  }
3133
3149
  panels?: ConnectorPanel[]
3150
+ actions?: ConnectorAction[]
3134
3151
  aiGuide?: string
3135
3152
  recordId?: string
3136
- limits?: { requestsPerSecond?: number }
3153
+ limits?: { requestsPerSecond?: number; maxEvents?: number; maxAttributes?: number }
3137
3154
  }
3138
3155
  ```
3139
3156
 
@@ -7953,9 +7970,31 @@ interface FlowConnectorRef {
7953
7970
  }
7954
7971
  ```
7955
7972
 
7973
+ **EventMappingRule** (interface)
7974
+ ```typescript
7975
+ interface EventMappingRule {
7976
+ id?: string
7977
+ match?: { types?: string[]; interactionIds?: string[]; outcomes?: string[]; appIds?: string[] }
7978
+ event: string
7979
+ attributes?: Record<string, string | number | boolean | { path: string; default?: unknown } | { value: unknown }>
7980
+ }
7981
+ ```
7982
+
7983
+ **EventMapping** (interface)
7984
+ ```typescript
7985
+ interface EventMapping {
7986
+ rules: EventMappingRule[]
7987
+ unmatched?: 'skip' | 'passthrough'
7988
+ }
7989
+ ```
7990
+
7956
7991
  **IntegrationFlowConfig** (interface)
7957
7992
  ```typescript
7958
7993
  interface IntegrationFlowConfig {
7994
+ signing?: { credentialRef: string; previousCredentialRef?: string }
7995
+ identity?: { policy?: IdentityPolicy; hashKeyCredentialRef?: string }
7996
+ eventMapping?: EventMapping
7997
+ envelopeFormat?: 'cloudevents'
7959
7998
  connection?: FlowConnection
7960
7999
  fieldMappings?: FieldMapping[]
7961
8000
  connector?: FlowConnectorRef
@@ -7975,8 +8014,8 @@ interface IntegrationFlow {
7975
8014
  status: FlowStatus
7976
8015
  eventTypes: string[]
7977
8016
  schedule: string | null
7978
- sourceEntity: string | null
7979
- targetEntity: string | null
8017
+ sourceEntity: FlowSourceEntity | string | null
8018
+ targetEntity: FlowTargetEntity | string | null
7980
8019
  config: IntegrationFlowConfig
7981
8020
  createdBy: string | null
7982
8021
  createdAt: string
@@ -8165,6 +8204,70 @@ interface SaveConnectorInstallationInput {
8165
8204
  }
8166
8205
  ```
8167
8206
 
8207
+ **ConnectorEventsSchema** (interface)
8208
+ ```typescript
8209
+ interface ConnectorEventsSchema {
8210
+ events: Array<{ name: string; title?: string; attributes?: Array<{ name: string; type?: string; required?: boolean }> }>
8211
+ limits?: { maxEvents?: number; maxAttributes?: number }
8212
+ }
8213
+ ```
8214
+
8215
+ **ConnectorLookupInput** (interface)
8216
+ ```typescript
8217
+ interface ConnectorLookupInput {
8218
+ installation: ConnectorInstallation
8219
+ identity: { externalId?: string | null; email?: string | null; phone?: string | null; emailHash?: string | null; phoneHash?: string | null; contactId?: string | null }
8220
+ want?: string[]
8221
+ }
8222
+ ```
8223
+
8224
+ **ConnectorLookupResult** (interface)
8225
+ ```typescript
8226
+ interface ConnectorLookupResult {
8227
+ found: boolean
8228
+ memberId?: string | null
8229
+ tier?: string | null
8230
+ points?: number | null
8231
+ status?: string | null
8232
+ attributes?: Record<string, unknown>
8233
+ asOf?: string | null
8234
+ }
8235
+ ```
8236
+
8237
+ **EventEnvelope** (interface)
8238
+ ```typescript
8239
+ interface EventEnvelope {
8240
+ specVersion: number
8241
+ id: string
8242
+ type: string
8243
+ entityType: string | null
8244
+ entityId: string | null
8245
+ occurredAt: string
8246
+ collectionId: string
8247
+ orgId: string
8248
+ appId: string | null
8249
+ identity: {
8250
+ contactId: string | null
8251
+ userId: string | null
8252
+ externalIds: Record<string, string>
8253
+ anonymousId: string | null
8254
+ sessionId: string | null
8255
+ emailHash?: string | null
8256
+ phoneHash?: string | null
8257
+ hashAlgorithm?: 'hmac-sha256' | null
8258
+ email?: string | null
8259
+ phone?: string | null
8260
+ }
8261
+ identityPolicy: IdentityPolicy
8262
+ product: { id: string; name?: string | null; sku?: string | null; gtin?: string | null; variantId?: string | null; batchId?: string | null } | null
8263
+ proof: { id: string } | null
8264
+ consent: { channels: Record<'email' | 'sms' | 'push' | 'whatsapp', 'opted_in' | 'opted_out' | 'suppressed' | 'unknown'>; topics: Record<string, boolean>; updatedAt: string | null } | null
8265
+ subject: Record<string, unknown>
8266
+ event?: { name: string; attributes: Record<string, unknown>; rule: string | null }
8267
+ attributes: Record<string, unknown>
8268
+ }
8269
+ ```
8270
+
8168
8271
  **CapturedRequest** (interface)
8169
8272
  ```typescript
8170
8273
  interface CapturedRequest {
@@ -8367,9 +8470,15 @@ interface RecordTypesResponse {
8367
8470
 
8368
8471
  **FlowAuthMethod** = `'api_key' | 'bearer' | 'basic' | 'webhook' | 'oauth2' | 'none'`
8369
8472
 
8473
+ **IdentityPolicy** = `'externalIds' | 'hashed' | 'raw'`
8474
+
8475
+ **FlowSourceEntity** = `'product' | 'interaction' | 'contact' | 'event'`
8476
+
8477
+ **FlowTargetEntity** = `'product' | 'contact'`
8478
+
8370
8479
  **UpdateFlowInput** = `Partial<Omit<CreateFlowInput, 'direction'>> & {`
8371
8480
 
8372
- **ConnectorConsoleStep** = `'testConnection' | 'describeTarget' | 'sample' | 'shape' | 'serialize'`
8481
+ **ConnectorConsoleStep** = `'testConnection' | 'describeTarget' | 'sample' | 'shape' | 'serialize' | 'describeEvents' | 'lookup'`
8373
8482
 
8374
8483
  **RunTrigger** = `'manual' | 'event' | 'schedule' | 'test'`
8375
8484
 
@@ -12894,6 +13003,22 @@ Save a connector's non-secret Connect-form values and/or settings. PUT /integrat
12894
13003
  input: Record<string, unknown> & { flowId?: string } = {}) → `Promise<T>`
12895
13004
  Run a connector step from the console: `testConnection` → ConnectorTestResult, `describeTarget` → ConnectorTargetSchema, `sample` → ConnectorRecords, and dry runs of `shape` ({ records }) → ConnectorShapeResult and `serialize` ({ documents }) → ConnectorSerializeResult. The server adds the installation; pass `flowId` to layer that flow's settings. POST /integrations/connectors/:appId/steps/:step
12896
13005
 
13006
+ **runConnectorAction**(collectionId: string,
13007
+ appId: string,
13008
+ action: string,
13009
+ input: Record<string, unknown> = {}) → `Promise<T>`
13010
+ Invoke a connector ACTION from the console (any visibility): redeem, enrol, adjust points, issue a coupon… The connector's function runs with its own credentials and is told who asked. POST /integrations/connectors/:appId/actions/:action { input }
13011
+
13012
+ **listConnectorActions**(collectionId: string,
13013
+ appId: string) → `Promise<`
13014
+ PUBLIC (the Hub, a site): the connector actions the caller may invoke — those declared `public`, plus `member` ones when signed in. GET /public/collection/:collectionId/connectors/:appId/actions
13015
+
13016
+ **callConnectorAction**(collectionId: string,
13017
+ appId: string,
13018
+ action: string,
13019
+ input: Record<string, unknown> = {}) → `Promise<T>`
13020
+ PUBLIC (the Hub, a site): invoke a connector action — e.g. redeem a reward for the signed-in member. 401 SIGN_IN_REQUIRED for a `member` action without a session; 403 for an admin-only one; 400 with `missing[]` when a required input is absent. POST /public/collection/:collectionId/connectors/:appId/actions/:action { input }
13021
+
12897
13022
  ### interactions
12898
13023
 
12899
13024
  **query**(collectionId: string,
@@ -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,8 @@ 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
+ | `actions[]` | Provider operations invoked on demand (redeem, enrol, issue…): name, title, function, `visibility`, input schema. See [Actions](#actions-provider-operations-on-demand). |
95
96
 
96
97
  ---
97
98
 
@@ -111,7 +112,9 @@ declare the provider's hosts (`network:<host>`) and the secrets it reads (`secre
111
112
  | `fetch` | Every inbound run | `ConnectorFetchInput` `{ installation, cursor, limit }` | `ConnectorRecords` `{ records, nextCursor }`: one page per call |
112
113
  | `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
114
  | `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 }] }` |
115
+ | `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 |
116
+ | `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 |
117
+ | `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
118
 
116
119
  **How a run flows:**
117
120
  - **Inbound:** `fetch` (paged; every record is landed as received) → `shape` → core maps the documents
@@ -182,6 +185,96 @@ export async function shape(_ctx: any, event: any): Promise<ConnectorShapeResult
182
185
 
183
186
  ---
184
187
 
188
+ ## Loyalty and CRM connectors
189
+
190
+ A provider that is about **people** rather than products uses the same contract with three more
191
+ pieces; nothing provider-specific lives in core. Full contract: `loyalty-connector-authoring.md` in the
192
+ platform's design docs; the shapes are in [integrations.md](integrations.md#event-sourced-flows-and-the-envelope).
193
+
194
+ | Flow | What core does | What the connector does |
195
+ |---|---|---|
196
+ | **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 |
197
+ | **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 |
198
+ | **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 |
199
+
200
+ ```ts
201
+ // A member webhook: the provider tells us about a person; we land it on the contact.
202
+ export default async function memberWebhook(event: any, ctx: any) {
203
+ verifyProviderSignature(event, await ctx.secrets.get('webhookSecret'))
204
+ const m = event.body
205
+ return ctx.sl.members.apply({
206
+ identity: { externalId: m.memberId, email: m.email },
207
+ contact: { firstName: m.firstName, lastName: m.lastName }, // fills empty fields only
208
+ loyalty: { memberId: m.memberId, tier: m.tier, points: m.points, status: m.status, asOf: m.updatedAt },
209
+ tags: { add: [`tier:${m.tier}`] },
210
+ }, { provider: 'acme-loyalty' }) // → { contactId, op: 'created' | 'updated' | 'unchanged' }
211
+ }
212
+ ```
213
+
214
+ Declare it `{ "trigger": { "type": "http" }, "capabilities": ["sl:members:write", "secrets:webhookSecret"] }`;
215
+ `sl:members:write` is approved by the admin on install. The envelope's `identity.externalIds[<provider>]`
216
+ is the member id this wrote back, so **Earn** can address the member by the provider's own id.
217
+
218
+ A connector should ship an `eventMapping` **preset** with the provider's standard event names and an
219
+ `aiGuide` paragraph, so the console's assistant proposes a customer's mapping in the provider's terms.
220
+
221
+ ---
222
+
223
+ ## Actions: provider operations on demand
224
+
225
+ Steps are called by the engine. **Actions** are called when someone asks: redeem a reward, enrol a
226
+ member, issue a coupon, raise a ticket, send a receipt, check a balance. A connector declares them in
227
+ the manifest and implements each as a server function; the platform decides who may call them and
228
+ runs them with the connector's credentials, telling the function who asked.
229
+
230
+ ```jsonc
231
+ "connector": {
232
+ "actions": [
233
+ { "name": "redeem", "title": "Redeem a reward", "function": "redeemReward", "visibility": "member",
234
+ "input": { "type": "object", "required": ["rewardId"], "properties": { "rewardId": { "type": "string" } } } },
235
+ { "name": "enrol", "title": "Join the programme", "function": "enrolMember", "visibility": "member" },
236
+ { "name": "coupon-status", "title": "Coupon status", "function": "couponStatus", "visibility": "public" },
237
+ { "name": "adjust-points", "title": "Adjust points", "function": "adjustPoints" } // admin only (the default)
238
+ ]
239
+ }
240
+ ```
241
+
242
+ | `visibility` | Who may invoke it |
243
+ |---|---|
244
+ | `admin` (default) | the console and admin API; any app function |
245
+ | `member` | also a signed-in person through the Hub / a site |
246
+ | `public` | also anyone |
247
+
248
+ The function receives `event.body = { input, installation, caller: { userId, contactId, role, surface } }`
249
+ and returns JSON (or a `Response`). `input.required` fields from the declared schema are checked before
250
+ the function runs (400 with `missing[]`); fuller validation is the connector's job.
251
+
252
+ ```ts
253
+ export async function redeemReward(ctx: any, event: any) {
254
+ const { input, caller } = event.body
255
+ if (!caller.contactId) return new Response(JSON.stringify({ error: 'no_contact' }), { status: 400 })
256
+ const memberId = (await ctx.sl.contacts.forCaller())?.customFields?.loyaltyMemberId // written by Profile
257
+ const res = await ctx.fetch(`${api}/members/${memberId}/redemptions`, { method: 'POST', headers: auth(await ctx.secrets.get('apiKey')), body: JSON.stringify({ reward: input.rewardId }) })
258
+ return res.json()
259
+ }
260
+ ```
261
+
262
+ Calling an action:
263
+
264
+ ```ts
265
+ // From the Hub or a site (visibility public / member):
266
+ const { actions } = await integrations.listConnectorActions(collectionId, 'acme-loyalty')
267
+ const out = await integrations.callConnectorAction(collectionId, 'acme-loyalty', 'redeem', { rewardId: 'R-5' })
268
+
269
+ // From another app's server function (any visibility; needs sl:connectors:write):
270
+ const out = await ctx.sl.connectors.call('acme-loyalty', 'redeem', { rewardId: 'R-5' })
271
+
272
+ // From the console (any visibility):
273
+ await integrations.runConnectorAction(collectionId, 'acme-loyalty', 'adjust-points', { memberId, delta: 100 })
274
+ ```
275
+
276
+ ---
277
+
185
278
  ## Where configuration lives
186
279
 
187
280
  | What | Where |
@@ -220,6 +313,7 @@ await secrets.put(collectionId, 'provider-key', value, { appId }) /
220
313
  await integrations.saveConnectorInstallation(collectionId, appId, { connection: { environment: 'production' } })
221
314
  const test = await integrations.runConnectorStep(collectionId, appId, 'testConnection')
222
315
  const target = await integrations.runConnectorStep(collectionId, appId, 'describeTarget')
316
+ const events = await integrations.runConnectorStep(collectionId, appId, 'describeEvents') // loyalty / engagement providers
223
317
  const { records } = await integrations.runConnectorStep(collectionId, appId, 'sample', { limit: 5 })
224
318
  const shaped = await integrations.runConnectorStep(collectionId, appId, 'shape', { records }) // dry run
225
319
  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.