@tesseraloyalty/sdk 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Common Sense Startups, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # @tesseraloyalty/sdk
2
+
3
+ Official TypeScript / JavaScript SDK for the [Tessera](https://github.com/Common-Sense-Startups/teressa) loyalty platform. It wraps the public operations API (`/v1`) and owns the error-prone plumbing — bearer auth, HMAC request signing, idempotency keys, cursor pagination, retries with backoff, and a typed error taxonomy — so your backend can drive the whole member journey (browse → identify → earn → read → redeem) in a handful of lines.
4
+
5
+ Runs anywhere the Web `fetch` standard exists: **Node 20+, edge / Cloudflare Workers, Deno, and Bun**. Ships **ESM + CommonJS** with bundled types, and has **zero runtime dependencies**.
6
+
7
+ > **Status — publishing pending ([TER-95](https://github.com/Common-Sense-Startups/teressa)).** `@tesseraloyalty/sdk` is `0.x`; the release pipeline is prepared but the package is **not on npm yet**, so the install line below is forthcoming. SDK `v0.x` targets Tessera operations API **`v1`** (exported as `API_VERSION`).
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm i @tesseraloyalty/sdk
13
+ # or: pnpm add @tesseraloyalty/sdk / yarn add @tesseraloyalty/sdk / bun add @tesseraloyalty/sdk
14
+ ```
15
+
16
+ ```ts
17
+ import { TesseraClient } from '@tesseraloyalty/sdk';
18
+ ```
19
+
20
+ ## Constructing the client
21
+
22
+ `TesseraClient` takes exactly one API key plus optional transport and channel settings. **Secret-key operations must run server-side** (backend / BFF / edge function) — never ship a secret key to a browser.
23
+
24
+ ```ts
25
+ const tessera = new TesseraClient({
26
+ secretKey: process.env.TESSERA_SECRET_KEY, // unlocks every operation (server-side only)
27
+ signingSecret: process.env.TESSERA_SIGNING_SECRET, // SEPARATE connector HMAC secret, for events.ingest
28
+ channel: 'shopify', // the channel bound to every member operation
29
+ });
30
+ ```
31
+
32
+ | Option | Type | Default | Purpose |
33
+ | --------------- | -------------------------- | ------------------------------ | ------- |
34
+ | `secretKey` | `string` | — | The bearer key that unlocks every operation. Provide **exactly one** of `secretKey` / `publishableKey`. |
35
+ | `publishableKey`| `string` | — | A **catalogue-only** bearer key — the only call it may make is `rewards.list()`. Any secret-scope call fails fast, pre-network, with `PublishableKeyForbiddenError`. |
36
+ | `signingSecret` | `string` | — | The per-tenant **connector** HMAC secret (`whsec_…`) used to sign `events.ingest`. It is a **separate credential from the API key** and is only needed to ingest events. |
37
+ | `channel` | `Ch` | `'api'` | The channel bound to every customer-scoped operation (see [Channel binding](#channel-binding)). Must be a member of `channels` when that allowlist is set. |
38
+ | `channels` | `readonly Ch[]` | — | An optional channel allowlist. Enforced at runtime (out-of-list → pre-network throw) and, via `as const`, at compile time (typos fail to compile). |
39
+ | `baseUrl` | `string` | `https://api.usetessera.io/v1` | The `/v1` base URL; override for local / staging. |
40
+ | `timeoutMs` | `number` | `30000` | Per-request timeout. A timeout is **terminal** (not retried). |
41
+ | `maxRetries` | `number` | `2` | Retries on retriable failures (network / 5xx / 429). |
42
+ | `fetch` | `FetchLike` | `globalThis.fetch` | A custom `fetch` implementation (tests, proxies, older runtimes). |
43
+
44
+ The keys map to the two resource scopes: a `publishableKey` client may only call `rewards.list()`; everything else (`customers`, `events`, `member(...)` reads/redeem, `redemptions`) requires a `secretKey`.
45
+
46
+ ## Quickstart — the full journey (server-side)
47
+
48
+ A complete earn → read → redeem flow with a secret key. `rewards.list()` → `customers.upsert()` → `events.ingest()` (auto-signed) → `member.balance()` / `.status()` / `.history()` → `member.redeem()` → `finalize()`.
49
+
50
+ ```ts
51
+ import { TesseraClient } from '@tesseraloyalty/sdk';
52
+
53
+ const tessera = new TesseraClient({
54
+ secretKey: process.env.TESSERA_SECRET_KEY,
55
+ signingSecret: process.env.TESSERA_SIGNING_SECRET, // required for events.ingest
56
+ channel: 'shopify',
57
+ });
58
+
59
+ // 1. Browse the public reward catalogue (allowed with a publishable OR a secret key).
60
+ const { pointsLabel, data: rewards } = await tessera.rewards.list();
61
+
62
+ // 2. Identify / enroll the member — create-or-update, keyed on (channel, externalId).
63
+ const customer = await tessera.customers.upsert({
64
+ externalId: 'cust_5512',
65
+ email: 'ada@example.com',
66
+ name: 'Ada Lovelace',
67
+ });
68
+ console.log(customer.created ? 'enrolled' : 'updated', customer.customerUuid);
69
+
70
+ // 3. Ingest a purchase. The SDK HMAC-signs the EXACT bytes it sends (needs signingSecret);
71
+ // earn is processed asynchronously server-side, so `enqueued` means the earn job was queued.
72
+ const event = await tessera.events.ingest({
73
+ type: 'purchase',
74
+ channel: 'shopify',
75
+ externalId: 'cust_5512',
76
+ sourceRef: 'ord_9001',
77
+ idempotencyKey: 'shopify:ord_9001-purchase', // namespace the channel into the key
78
+ occurredAt: new Date(),
79
+ trigger: 'orders/paid',
80
+ amount: { netOfTaxShipping: 4200, currency: 'USD' }, // minor units
81
+ });
82
+ console.log(event.status); // 'accepted' (first sighting) | 'duplicate' (deduped redelivery)
83
+
84
+ // 4. Open a member handle on the bound channel and read their standing.
85
+ const member = tessera.member('cust_5512');
86
+
87
+ const balance = await member.balance();
88
+ console.log(`${balance.available} ${balance.pointsLabel} available`);
89
+
90
+ const status = await member.status();
91
+ console.log('tier:', status.currentTier?.name ?? 'none');
92
+
93
+ // 5. Redeem: place a hold (auto Idempotency-Key), then finalize it against the order that
94
+ // consumed it — or release it if the cart is abandoned.
95
+ const redemption = await member.redeem({
96
+ optionId: rewards[0].id,
97
+ cartSubtotalCents: 4200,
98
+ });
99
+ // …order is placed…
100
+ await redemption.finalize({ sourceChannel: 'shopify', sourceRef: 'ord_9001' });
101
+ // …or, on an abandoned cart: await redemption.release();
102
+ ```
103
+
104
+ ### Reading history — the async iterator
105
+
106
+ `member.history()` returns a `HistoryPager` that is both **awaitable** (resolves the first `HistoryPage` — `.data` + `.page`) and **async-iterable** (auto-follows the cursor across every page):
107
+
108
+ ```ts
109
+ // Iterate every entry across all pages — the pager follows the cursor for you.
110
+ for await (const entry of member.history()) {
111
+ console.log(entry.type, entry.currency, entry.amount, entry.sourceRef);
112
+ }
113
+
114
+ // …or just the first page, with filters:
115
+ const page = await member.history({ limit: 50, types: ['earn', 'burn'], since: '2026-01-01' });
116
+ console.log(page.data.length, 'entries; more?', page.page.hasMore);
117
+ ```
118
+
119
+ The other member reads — `member.get()` (pseudonymous profile) and `member.balance()` / `member.status()` — resolve to a single object.
120
+
121
+ ## Channel binding
122
+
123
+ A member operation never takes a per-call `channel` — it is **bound once** on the client, so a `'shopify'`-here / `'shoify'`-there typo can't silently split a customer's identity and ledger. Three layers enforce this:
124
+
125
+ - **Bound channel** — set once via `channel` (default `'api'`); `withChannel(x)` derives a channel-scoped clone that **shares the same transport, auth, and signing secret** for the rare legitimate multi-channel case.
126
+ - **Runtime allowlist** — when `channels` is provided, the bound channel, every `withChannel` target, and every resolved channel must be a member, or the SDK throws **pre-network** naming the known channels.
127
+ - **Compile-time union** — pass `channels: [...] as const` and the client is generic over that literal union, so a typo is a **type error**, not a runtime surprise.
128
+
129
+ ```ts
130
+ const tessera = new TesseraClient({
131
+ secretKey: process.env.TESSERA_SECRET_KEY,
132
+ channels: ['shopify', 'pos', 'api'] as const, // Ch = 'shopify' | 'pos' | 'api'
133
+ channel: 'shopify',
134
+ });
135
+
136
+ const pos = tessera.withChannel('pos'); // ok — clone bound to 'pos'
137
+ tessera.withChannel('shoify'); // ✗ compile error AND a pre-network throw
138
+
139
+ // Per-handle override, still validated against the allowlist:
140
+ const m = tessera.member('cust_5512', { channel: 'pos' });
141
+ ```
142
+
143
+ ## Errors
144
+
145
+ Every failure is a `TesseraError` (or a subclass) carrying the stable, snake_case `code` from the API envelope plus `status`, `requestId` (the `X-Request-Id`, for support), and structured `details`. **Catch by type** — never string-match `code`:
146
+
147
+ ```ts
148
+ import {
149
+ InsufficientBalanceError,
150
+ TierGatedError,
151
+ HoldAlreadyReleasedError,
152
+ PublishableKeyForbiddenError,
153
+ TesseraError,
154
+ } from '@tesseraloyalty/sdk';
155
+
156
+ try {
157
+ const r = await member.redeem({ optionId, cartSubtotalCents: 4200 });
158
+ await r.finalize({ sourceChannel: 'shopify', sourceRef: 'ord_9001' });
159
+ } catch (err) {
160
+ if (err instanceof InsufficientBalanceError) {
161
+ console.log('short by', err.shortfall); // number | undefined, from details.shortfall
162
+ } else if (err instanceof TierGatedError) {
163
+ // option requires a higher tier than the member holds
164
+ } else if (err instanceof HoldAlreadyReleasedError) {
165
+ // finalizing a hold that was already released (terminal)
166
+ } else if (err instanceof TesseraError) {
167
+ // any other code — including one this SDK version doesn't yet know (see below)
168
+ console.log(err.code, err.status, err.requestId);
169
+ }
170
+ }
171
+ ```
172
+
173
+ A **new server `code` never crashes an old SDK**: an unrecognized code resolves to the base `TesseraError` (with its `code` preserved), never a throw. The full `code → subclass` map is exported as `CODE_TO_ERROR`, and `errorFromEnvelope` is the factory that builds them.
174
+
175
+ | Error class | Wire `code` | Extra field |
176
+ | ------------------------------ | -------------------------------------------------- | ----------- |
177
+ | `ValidationError` | `invalid_request` | |
178
+ | `UnauthorizedError` | `unauthorized` | |
179
+ | `SignatureInvalidError` | `signature_invalid` | |
180
+ | `ReplayDetectedError` | `replay_detected` | |
181
+ | `ForbiddenError` | `forbidden` | |
182
+ | `TierGatedError` | `tier_gated` | |
183
+ | `PublishableKeyForbiddenError` | `publishable_key_forbidden` | |
184
+ | `NotFoundError` | `not_found` | |
185
+ | `CustomerNotFoundError` | `customer_not_found` | |
186
+ | `RedemptionNotFoundError` | `redemption_not_found` | |
187
+ | `ConflictError` | `conflict` | |
188
+ | `IdempotencyConflictError` | `idempotency_key_conflict` / `idempotency_key_reuse` | |
189
+ | `VersionConflictError` | `version_conflict` | |
190
+ | `CapReachedError` | `cap_reached` | |
191
+ | `NotStackableError` | `not_stackable` | |
192
+ | `HoldAlreadyReleasedError` | `hold_already_released` | |
193
+ | `CustomerErasedError` | `customer_erased` | |
194
+ | `PayloadTooLargeError` | `payload_too_large` | |
195
+ | `InsufficientBalanceError` | `insufficient_balance` | `.shortfall: number \| undefined` |
196
+ | `OptionNotEligibleError` | `option_not_eligible` | |
197
+ | `InvalidPayloadError` | `invalid_payload` | |
198
+ | `UnsupportedEventTypeError` | `unsupported_event_type` | |
199
+ | `ProfileValidationError` | `profile_validation_error` | `.field: string \| undefined` |
200
+ | `RateLimitedError` | `rate_limited` | |
201
+ | `InternalError` | `internal_error` | |
202
+ | `NotImplementedError` | `not_implemented` | |
203
+
204
+ Pre-network failures (bad config, an out-of-allowlist channel, a missing signing secret) throw a base `TesseraError` with an SDK-local `code` (`config_error`) and no `status`.
205
+
206
+ ## Idempotency & retries
207
+
208
+ - **Redemptions auto-key.** `POST /v1/redemptions` requires an `Idempotency-Key`; when you don't pass one, `member.redeem(...)` / `redemptions.place(...)` **auto-generate a UUID** and hold it constant across that call's own retries, so a retried place never double-charges the hold. Pass `{ idempotencyKey }` to make a place stable across *your* retries too.
209
+ - **`customers.upsert`** accepts an optional `idempotencyKey` for a safe create-or-update retry.
210
+ - **`events.ingest`** dedups on the **`idempotencyKey` inside the signed body** (namespace the channel in, e.g. `shopify:ord_9001-purchase`). The `Idempotency-Key` header is optional and, if sent, must echo that body value.
211
+ - **Retries.** Network errors, `5xx`, and `429` are retried up to `maxRetries` (default 2) with exponential backoff (200 ms, doubling, capped at 20 s), honoring a `Retry-After` header when present. A per-request **timeout** (`timeoutMs`, default 30 s) is **terminal** — it means the call blew its budget and is not retried.
212
+
213
+ ## Event signing (advanced)
214
+
215
+ `events.ingest` signs for you. If you need to sign a request body yourself (e.g. a custom transport), the low-level signer and its header constants are exported:
216
+
217
+ ```ts
218
+ import { signEventBody, SIGNATURE_HEADER, TIMESTAMP_HEADER, NONCE_HEADER } from '@tesseraloyalty/sdk';
219
+
220
+ const rawBody = JSON.stringify(payload); // sign and send the SAME bytes
221
+ const signed = await signEventBody(rawBody, signingSecret); // HMAC-SHA256, `v1=<hex>`
222
+ // signed.headers → { 'X-Loyalty-Signature', 'X-Loyalty-Timestamp', 'X-Loyalty-Nonce' }
223
+ ```
224
+
225
+ Re-serializing the body after signing invalidates the signature — sign and transmit the exact same string.
226
+
227
+ ## What this is *not*
228
+
229
+ `@tesseraloyalty/sdk` is a **loyalty-operations** client only. It serves customers, events, balances/reads, and redemptions for an **already-provisioned tenant** under a per-tenant API key. It deliberately does **not** cover:
230
+
231
+ - merchant signup or tenant provisioning;
232
+ - **program configuration** — earn rules, tiers, reward options, points label, branding. Those are managed in the **merchant console**, not the SDK.
233
+
234
+ If you're looking for a method to change how points are earned or what rewards exist, it isn't here by design — configure the program in the console; use this SDK to run it.
235
+
236
+ ## License
237
+
238
+ MIT — see [LICENSE](./LICENSE).