@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.
@@ -0,0 +1,1136 @@
1
+ /**
2
+ * The HTTP request core shared by every resource. It is runtime-self-contained —
3
+ * it uses only global Web standards (`fetch`, `Headers`, `AbortController`,
4
+ * `URLSearchParams`) so it runs unchanged on Node 20+, edge/Workers, Deno, and Bun.
5
+ * No `@loyalty/*` (or any runtime dependency) is imported.
6
+ *
7
+ * Responsibilities: bearer auth, JSON writes, idempotency-key passthrough, a
8
+ * per-attempt timeout, bounded retries with backoff (honoring `Retry-After`),
9
+ * `X-Request-Id` capture, tolerant success decoding, and envelope→`TesseraError`.
10
+ */
11
+ /** A `fetch`-compatible function. `globalThis.fetch` satisfies this; a caller may inject their own. */
12
+ type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
13
+ type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
14
+ type QueryValue = string | number | boolean | null | undefined;
15
+ interface RequestOptions {
16
+ /** Query params; `undefined`/`null` values are skipped. */
17
+ query?: Record<string, QueryValue>;
18
+ /** A JSON-serializable request body. Its presence sets `Content-Type: application/json`. */
19
+ body?: unknown;
20
+ /**
21
+ * A pre-serialized request body string, transmitted BYTE-FOR-BYTE (never re-serialized). This is
22
+ * the signed-bytes path for event ingestion (`POST /v1/events`): the caller signs the exact string
23
+ * it passes here, so re-serializing an object (`JSON.stringify` reordering keys / changing
24
+ * whitespace) can never invalidate that signature. Presence sets `Content-Type: application/json`
25
+ * (a rawBody IS a body, including an empty string). Takes precedence over `body` when both are set.
26
+ */
27
+ rawBody?: string;
28
+ /** Extra headers layered under the SDK's own (auth/content-type/idempotency win). */
29
+ headers?: Record<string, string>;
30
+ /** Sets the `Idempotency-Key` header; held constant across this call's retries. */
31
+ idempotencyKey?: string;
32
+ /**
33
+ * A hint for the publishable-vs-secret key GUARD added in a later ticket (TER-86).
34
+ * Accepted here for a stable signature; `request` itself is key-scope agnostic.
35
+ */
36
+ keyScope?: 'secret' | 'publishable' | 'any';
37
+ }
38
+ interface HttpCoreConfig {
39
+ /** The `/v1` base URL; trailing slashes are trimmed. */
40
+ baseUrl: string;
41
+ /** The resolved bearer token (secret or publishable key). */
42
+ token: string;
43
+ /** Per-attempt timeout in milliseconds. */
44
+ timeoutMs: number;
45
+ /** Maximum retry attempts on retriable failures (network / 5xx / 429). */
46
+ maxRetries: number;
47
+ /** The `fetch` implementation to use. */
48
+ fetchImpl: FetchLike;
49
+ }
50
+ declare class HttpCore {
51
+ private readonly baseUrl;
52
+ private readonly token;
53
+ private readonly timeoutMs;
54
+ private readonly maxRetries;
55
+ private readonly fetchImpl;
56
+ constructor(config: HttpCoreConfig);
57
+ /**
58
+ * Issue a request. On 2xx returns the tolerantly-parsed JSON body; on a non-2xx
59
+ * envelope throws a base `TesseraError`. Retries network errors / 5xx / 429 up to
60
+ * `maxRetries`; a per-attempt timeout is terminal (it means the call blew its budget).
61
+ */
62
+ request<T = unknown>(method: HttpMethod, path: string, opts?: RequestOptions): Promise<T>;
63
+ /** One fetch attempt, raced against a per-attempt timeout that aborts the request. */
64
+ private dispatch;
65
+ private buildUrl;
66
+ private buildHeaders;
67
+ }
68
+
69
+ /**
70
+ * The public rewards catalogue resource (`GET /v1/rewards`) — the ONE read a publishable key may
71
+ * make (non-personalized: no balance, no PII). The SDK owns its public wire↔camelCase types; the
72
+ * API body is snake_case (`apps/api` rewardsHandler + engine `toPublic`).
73
+ */
74
+ /** A reward option's redemption constraints (camelCased from the API `constraints` block). */
75
+ interface RewardOptionConstraints {
76
+ /** Minimum cart subtotal (minor units) required to redeem, or null for no minimum. */
77
+ minSpendCents: number | null;
78
+ /** Collection ids the option is scoped to (empty = unrestricted). */
79
+ eligibleCollectionIds: string[];
80
+ /** Product ids the option is scoped to (empty = unrestricted). */
81
+ eligibleProductIds: string[];
82
+ /** Whether this option may stack with another active redemption. */
83
+ stackable: boolean;
84
+ /** Per-customer redemption cap over a rolling window, or null for uncapped. */
85
+ perCustomerCap: {
86
+ count: number;
87
+ window: string;
88
+ } | null;
89
+ /** The tier key required to redeem, or null when ungated. */
90
+ tierGate: string | null;
91
+ /** Maximum discount value (minor units) the option may apply, or null for uncapped. */
92
+ valueCapCents: number | null;
93
+ }
94
+ /** One public (storefront-safe) catalogue option. */
95
+ interface RewardOption {
96
+ id: string;
97
+ /** Redemption type (e.g. `fixed_discount`, `percentage_discount`, `free_product`). */
98
+ type: string;
99
+ label: string;
100
+ /** How the reward is fulfilled (e.g. `code`, `automatic`). */
101
+ mechanism: string;
102
+ /** Surfaces the option may be triggered on (e.g. `cart`, `checkout`). */
103
+ triggerSurfaces: string[];
104
+ /**
105
+ * The cost model, an open variant record keyed by `kind` (`fixed_points` / `percentage` /
106
+ * `points_per_unit_value` / `free`). Its inner keys are camelCased; kept loosely typed because
107
+ * the shape varies by kind. `costPoints` is the flattened flat cost for the common case.
108
+ */
109
+ costModel: Record<string, unknown>;
110
+ /** Flat point cost when known; null for variable (value-dependent) burn. */
111
+ costPoints: number | null;
112
+ constraints: RewardOptionConstraints;
113
+ }
114
+ /** The rewards catalogue: the merchant's points label + the list of options. */
115
+ interface RewardsCatalogue {
116
+ /** The merchant's display label for points (e.g. `Stars`); defaults server-side to `points`. */
117
+ pointsLabel: string;
118
+ data: RewardOption[];
119
+ }
120
+ declare class RewardsResource {
121
+ private readonly http;
122
+ constructor(http: HttpCore);
123
+ /**
124
+ * Fetch the public rewards catalogue. `keyScope: 'publishable'` — allowed with a publishable OR
125
+ * a secret key (the one read a browser/publishable client may perform).
126
+ */
127
+ list(): Promise<RewardsCatalogue>;
128
+ }
129
+
130
+ /**
131
+ * The `client.redemptions` resource — the F16 burn-hold lifecycle over HTTP: `place` a hold →
132
+ * `finalize` (permanent burn) or `release` (abandon, restoring the reserved points), plus `get` to
133
+ * read a hold's status. Every endpoint is SECRET-scope (they write ledger state, never from a
134
+ * browser): a publishable-key client fails fast pre-network (better DX than a 403 round-trip),
135
+ * mirroring the customer write/read guards.
136
+ *
137
+ * Ledger correctness lives in the engine, not here — the SDK only CALLS the API. The engine's typed
138
+ * eligibility/balance errors surface as the mapped taxonomy classes (`insufficient_balance` →
139
+ * {@link InsufficientBalanceError} incl. `.shortfall`, `tier_gated`, `cap_reached`,
140
+ * `option_not_eligible`, unknown option → `not_found`) via the shared `errorFromEnvelope` factory.
141
+ *
142
+ * Channel discipline (design §5.2): the channel the customer lives on is validated through the
143
+ * client's `resolveChannel()` (the `channels` allowlist choke point) BEFORE any request, so a typo'd
144
+ * channel can never resolve the wrong (or a split) customer.
145
+ *
146
+ * Idempotency (doc 02 §3): `POST /v1/redemptions` REQUIRES an `Idempotency-Key`. When the caller
147
+ * does not supply one, `place` auto-generates a UUID and lets the HTTP core hold it constant across
148
+ * the automatic retries of THAT call — a retried place never double-charges the hold. A
149
+ * caller-supplied key is used verbatim (stable across the caller's own retries).
150
+ *
151
+ * The SDK owns its public camelCase types; the API bodies are snake_case (`apps/api` app.ts +
152
+ * `placeRedemptionSchema` / `finalizeRedemptionSchema`). Fields are mapped explicitly (precise
153
+ * types + drift detection).
154
+ */
155
+ /** The `place` input — the option + the member's external id, plus optional cost/eligibility inputs. */
156
+ interface PlaceRedemptionInput {
157
+ /** The reward option UUID to redeem. */
158
+ optionId: string;
159
+ /** The merchant-facing external id of the member redeeming (keyed on the resolved channel). */
160
+ externalId: string;
161
+ /**
162
+ * The channel this external id lives on. Resolved through the client's `resolveChannel()` — omit
163
+ * to use the client's bound channel, or name one (validated against the `channels` allowlist,
164
+ * throwing PRE-NETWORK on an out-of-vocabulary value).
165
+ */
166
+ channel?: string;
167
+ /** Cart subtotal (minor units) — feeds min-spend eligibility + percentage/value cost resolution. */
168
+ cartSubtotalCents?: number;
169
+ /** An explicit requested discount value (minor units); `null` clears any prior request. */
170
+ requestedValueCents?: number | null;
171
+ /** When the hold should expire — an ISO-8601 string or a `Date`. */
172
+ expiresAt?: string | Date;
173
+ }
174
+ /** Per-call options for `place`. */
175
+ interface PlaceRedemptionOptions {
176
+ /**
177
+ * The `Idempotency-Key`. When omitted, `place` auto-generates a UUID (held constant across the
178
+ * automatic retries of this call). Supply your own to make a place stable across YOUR retries.
179
+ */
180
+ idempotencyKey?: string;
181
+ }
182
+ /** The placed-hold result (`POST /v1/redemptions`, state `held`). */
183
+ interface RedemptionResult {
184
+ redemptionId: string;
185
+ customerUuid: string;
186
+ optionId: string;
187
+ /** The points reserved by the hold (minor points units). */
188
+ amount: number;
189
+ /** The resolved discount value (minor currency units) the redemption applies. */
190
+ discountValueCents: number;
191
+ /** How the reward is fulfilled (e.g. `code`, `automatic`). */
192
+ mechanism: string;
193
+ /** The hold state (`held` on placement). */
194
+ state: string;
195
+ /** When the hold expires (ISO-8601), or null when it does not. */
196
+ expiresAt: string | null;
197
+ /** ISO-8601 creation timestamp. */
198
+ createdAt: string;
199
+ }
200
+ /** A redemption hold's status (`GET /v1/redemptions/:id`). */
201
+ interface RedemptionStatus {
202
+ redemptionId: string;
203
+ /** The hold state (`held` | `finalized` | `released`). */
204
+ state: string;
205
+ amount: number;
206
+ }
207
+ /** The `finalize` input — the order that consumed the redemption (its burn provenance). */
208
+ interface FinalizeRedemptionInput {
209
+ /** Which external system consumed the redemption (e.g. `shopify`). */
210
+ sourceChannel: string;
211
+ /** The external object id (e.g. the order id) the burn is stamped with. */
212
+ sourceRef: string;
213
+ }
214
+ /** The `finalize` result (permanent burn). */
215
+ interface FinalizeRedemptionResult {
216
+ redemptionId: string;
217
+ state: 'finalized';
218
+ }
219
+ /** The `release` result (hold abandoned; reserved points restored). */
220
+ interface ReleaseRedemptionResult {
221
+ redemptionId: string;
222
+ state: 'released';
223
+ /** The member's `available` after the reserved points were restored. */
224
+ available: number;
225
+ }
226
+ /**
227
+ * A placed redemption augmented with `.finalize()` / `.release()` convenience methods bound to its
228
+ * id — what `member.redeem(...)` returns so the storefront flow chains (`place → finalize|release`)
229
+ * without threading the redemption id back through the resource.
230
+ */
231
+ interface RedemptionHandle extends RedemptionResult {
232
+ /** Finalize this hold (permanent burn), stamped with the consuming order's provenance. */
233
+ finalize(input: FinalizeRedemptionInput): Promise<FinalizeRedemptionResult>;
234
+ /** Release this hold (abandon it; restore the reserved points). */
235
+ release(): Promise<ReleaseRedemptionResult>;
236
+ }
237
+ /** Everything a {@link RedemptionsResource} needs from its parent client, passed explicitly (no cycle). */
238
+ interface RedemptionsDeps {
239
+ http: HttpCore;
240
+ keyType: KeyType;
241
+ /** The client's channel resolver — validates a channel against the `channels` allowlist. */
242
+ resolveChannel: (channel?: string) => string;
243
+ }
244
+ declare class RedemptionsResource {
245
+ private readonly http;
246
+ private readonly keyType;
247
+ private readonly resolveChannel;
248
+ constructor(deps: RedemptionsDeps);
249
+ /** Fail fast pre-network when a secret-scope operation is attempted with a publishable key. */
250
+ private requireSecret;
251
+ /**
252
+ * Place a redemption hold (`POST /v1/redemptions`). The channel is resolved through the client
253
+ * (bound default when omitted, else validated against the allowlist — a PRE-NETWORK throw on an
254
+ * out-of-vocabulary value). An `Idempotency-Key` is REQUIRED by the API; when `opts.idempotencyKey`
255
+ * is absent the SDK auto-generates a UUID and holds it constant across this call's retries.
256
+ *
257
+ * Not `async` so the channel-allowlist check throws SYNCHRONOUSLY before any transport work.
258
+ */
259
+ place(input: PlaceRedemptionInput, opts?: PlaceRedemptionOptions): Promise<RedemptionResult>;
260
+ private sendPlace;
261
+ /**
262
+ * Read a redemption hold's status (`GET /v1/redemptions/:id`). Tenant-scoped: 404 →
263
+ * {@link RedemptionNotFoundError} (no cross-tenant existence leak).
264
+ */
265
+ get(id: string): Promise<RedemptionStatus>;
266
+ /**
267
+ * Finalize a hold — the permanent burn (`POST /v1/redemptions/:id/finalize`). Idempotent on the
268
+ * hold: an already-finalized hold replays; finalizing a RELEASED (terminal) hold →
269
+ * {@link HoldAlreadyReleasedError} (409). `sourceChannel`/`sourceRef` stamp the burn's provenance.
270
+ */
271
+ finalize(id: string, input: FinalizeRedemptionInput): Promise<FinalizeRedemptionResult>;
272
+ /**
273
+ * Release a hold — abandon it and restore the reserved points (`POST /v1/redemptions/:id/release`).
274
+ * Idempotent. Returns the member's `available` after the restore.
275
+ */
276
+ release(id: string): Promise<ReleaseRedemptionResult>;
277
+ /**
278
+ * @internal Wrap a placed-hold result in a {@link RedemptionHandle} — the same fields plus
279
+ * `.finalize()` / `.release()` bound to this resource + the redemption id. Consumed by
280
+ * `member.redeem(...)`; not part of the documented public surface.
281
+ */
282
+ toHandle(result: RedemptionResult): RedemptionHandle;
283
+ }
284
+
285
+ /**
286
+ * The `(channel, externalId)`-keyed member handle — the ergonomic core of the read surface. Its
287
+ * channel is BOUND (from the client's `resolveChannel()`), never a per-call arg, so the
288
+ * "`shopify` here, `shoify` there" identity split is structurally impossible. All four reads are
289
+ * SECRET-scope; a publishable-key client fails fast pre-network (better DX than a 403 round-trip).
290
+ *
291
+ * The SDK owns its public camelCase types; the API bodies are snake_case (`apps/api`). Structured
292
+ * reads map fields explicitly (precise types + drift detection).
293
+ */
294
+ /** The pseudonymous member profile (`GET /v1/customers/:channel/:externalId`). */
295
+ interface MemberProfile {
296
+ customerUuid: string;
297
+ channel: string;
298
+ externalId: string;
299
+ /** ISO-8601 creation timestamp, or null when no customer row exists for the pair. */
300
+ createdAt: string | null;
301
+ }
302
+ /** A member's balances across the two currencies + the redemption `available` (`.../balance`). */
303
+ interface MemberBalance {
304
+ customerUuid: string;
305
+ /** The merchant's display label for points. */
306
+ pointsLabel: string;
307
+ /** Confirmed (spendable) points. */
308
+ spendableBalance: number;
309
+ /** Accruing points not yet matured (visible, not spendable). */
310
+ pendingSpendable: number;
311
+ /** Cumulative qualifying credits (the status-track currency). */
312
+ qualifyingBalance: number;
313
+ /** Points reserved by in-flight redemption holds. */
314
+ activeHolds: number;
315
+ /** Outstanding clawbacks (post-refund debt recovered on the next earn). */
316
+ clawbacksOutstanding: number;
317
+ /** `confirmed − activeHolds − clawbacksOutstanding` — what may be redeemed now (may be negative). */
318
+ available: number;
319
+ }
320
+ /** A member's current tier (camelCased from the `current_tier` block). */
321
+ interface MemberTier {
322
+ key: string;
323
+ name: string;
324
+ /** The tier's earn multiplier. */
325
+ earnMultiplier: number;
326
+ /** Reward-eligibility tags carried on the tier (config round-tripped; enforcement is deferred). */
327
+ rewardEligibility: string[];
328
+ }
329
+ /** Progress toward the next tier + the current hold threshold (camelCased `qualifying_progress`). */
330
+ interface QualifyingProgress {
331
+ /** The member's current qualifying total. */
332
+ current: number;
333
+ /** Qualifying needed to HOLD the current tier this period, or null when no tier applies. */
334
+ holdThreshold: number | null;
335
+ /** The next tier's key, or null at the top of the ladder / with no program. */
336
+ nextTierKey: string | null;
337
+ /** Qualifying needed to reach the next tier, or null when there is none. */
338
+ nextQualifyThreshold: number | null;
339
+ }
340
+ /** A member's tier status + progress (`.../status`). Every field is null when no program applies. */
341
+ interface MemberStatus {
342
+ customerUuid: string;
343
+ currentTier: MemberTier | null;
344
+ effectiveTierKey: string | null;
345
+ periodStart: string | null;
346
+ periodEnd: string | null;
347
+ requalificationState: string | null;
348
+ graceDeadline: string | null;
349
+ qualifyingProgress: QualifyingProgress;
350
+ }
351
+ /** One ledger history entry (`.../history` data item), camelCased. */
352
+ interface HistoryEntry {
353
+ id: string;
354
+ /** entry type: earn | maturation | burn | expiry | adjustment | reversal | clawback. */
355
+ type: string;
356
+ currency: string;
357
+ /** Signed minor units: positive = credit, negative = debit. */
358
+ amount: number;
359
+ state: string;
360
+ /** Which external system triggered the entry, or null. */
361
+ sourceChannel: string | null;
362
+ /** The external object id (e.g. order id), or null. */
363
+ sourceRef: string | null;
364
+ /** The originating event id, or null. */
365
+ sourceEventId: string | null;
366
+ createdAt: string;
367
+ }
368
+ /** Page cursor metadata for a single history page. */
369
+ interface HistoryPageInfo {
370
+ /** The opaque cursor for the NEXT page, or null when there are no more pages. */
371
+ nextCursor: string | null;
372
+ hasMore: boolean;
373
+ }
374
+ /** One page of history: the entries + cursor info (what a single `await member.history()` yields). */
375
+ interface HistoryPage {
376
+ data: HistoryEntry[];
377
+ page: HistoryPageInfo;
378
+ }
379
+ /** Options for a history read. `types` is joined into the API's comma-separated `type` param. */
380
+ interface HistoryOptions {
381
+ /** Page size (1–100). */
382
+ limit?: number;
383
+ /** An opaque cursor to start from (a prior page's `nextCursor`). */
384
+ cursor?: string;
385
+ /** Restrict to these entry types (joined into `?type=a,b`). */
386
+ types?: string[];
387
+ /** Only entries at/after this ISO-8601 timestamp (or a `Date`). */
388
+ since?: string | Date;
389
+ /** Only entries at/before this ISO-8601 timestamp (or a `Date`). */
390
+ until?: string | Date;
391
+ }
392
+ /** Per-member overrides (currently only a channel override, validated vs the client allowlist). */
393
+ interface MemberOptions {
394
+ /** Override the client's bound channel for THIS handle (validated against the allowlist). */
395
+ channel?: string;
396
+ }
397
+ /**
398
+ * The `member.redeem(...)` input — a `place` request with the member's `externalId` + channel already
399
+ * bound by the handle (so only the option + optional cost/eligibility inputs are passed here).
400
+ */
401
+ interface MemberRedeemInput {
402
+ /** The reward option UUID to redeem. */
403
+ optionId: string;
404
+ /** Cart subtotal (minor units) — feeds min-spend eligibility + percentage/value cost resolution. */
405
+ cartSubtotalCents?: number;
406
+ /** An explicit requested discount value (minor units); `null` clears any prior request. */
407
+ requestedValueCents?: number | null;
408
+ /** When the hold should expire — an ISO-8601 string or a `Date`. */
409
+ expiresAt?: string | Date;
410
+ }
411
+ /**
412
+ * The history pager. `member.history(opts)` returns one synchronously; it is BOTH:
413
+ * - an async-iterable that auto-follows the cursor across every page (`for await (…)`), and
414
+ * - awaitable (`PromiseLike`) to resolve the FIRST page as a {@link HistoryPage} with
415
+ * `.data` / `.page`, for simple single-page use.
416
+ * `for await` uses `Symbol.asyncIterator` (never `.then`), so the two forms never collide.
417
+ */
418
+ declare class HistoryPager implements AsyncIterable<HistoryEntry>, PromiseLike<HistoryPage> {
419
+ private readonly fetchPage;
420
+ constructor(fetchPage: (cursor?: string) => Promise<HistoryPage>);
421
+ [Symbol.asyncIterator](): AsyncIterator<HistoryEntry>;
422
+ then<TResult1 = HistoryPage, TResult2 = never>(onfulfilled?: ((value: HistoryPage) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null): PromiseLike<TResult1 | TResult2>;
423
+ }
424
+ /** Everything a {@link MemberHandle} needs from its parent client, passed explicitly (no cycle). */
425
+ interface MemberDeps {
426
+ http: HttpCore;
427
+ keyType: KeyType;
428
+ channel: string;
429
+ externalId: string;
430
+ /** The client's redemptions resource — powers `member.redeem(...)` (the write sugar). */
431
+ redemptions: RedemptionsResource;
432
+ }
433
+ declare class MemberHandle {
434
+ /** The channel bound to this handle (from the client's `resolveChannel()`). */
435
+ readonly channel: string;
436
+ /** The merchant-facing external id this handle reads. */
437
+ readonly externalId: string;
438
+ private readonly http;
439
+ private readonly keyType;
440
+ private readonly redemptions;
441
+ constructor(deps: MemberDeps);
442
+ /** The `/customers/:channel/:externalId` path prefix (both segments URL-encoded). */
443
+ private base;
444
+ /** Fail fast pre-network when a secret-scope read is attempted with a publishable key. */
445
+ private requireSecret;
446
+ /** The pseudonymous member profile (identity + channel binding; no PII). */
447
+ get(): Promise<MemberProfile>;
448
+ /** The member's balances (spendable / pending / qualifying / holds / available). */
449
+ balance(): Promise<MemberBalance>;
450
+ /** The member's tier status + progress toward the next tier. */
451
+ status(): Promise<MemberStatus>;
452
+ /**
453
+ * The member's points history. Returns a {@link HistoryPager}: iterate it with `for await` to
454
+ * auto-follow the cursor across all pages, or `await` it for just the first {@link HistoryPage}
455
+ * (`.data` / `.page`). `opts.cursor`, if given, is the starting page for the awaited-first-page
456
+ * form; the iterator always starts fresh and follows the cursor from there.
457
+ */
458
+ history(opts?: HistoryOptions): HistoryPager;
459
+ /**
460
+ * Place a redemption hold for THIS member (`POST /v1/redemptions`) — sugar over
461
+ * `client.redemptions.place(...)` with the handle's `externalId` + bound channel supplied for you.
462
+ * Returns a {@link RedemptionHandle}: the placed-hold result plus `.finalize({ sourceChannel,
463
+ * sourceRef })` / `.release()` bound to it, so the storefront flow chains cleanly. Secret-scope
464
+ * (delegated to the redemptions resource); a publishable-key client rejects pre-network.
465
+ */
466
+ redeem(input: MemberRedeemInput, opts?: PlaceRedemptionOptions): Promise<RedemptionHandle>;
467
+ }
468
+
469
+ /**
470
+ * The `client.customers` resource — the customer/identity WRITE + server-side PII surface
471
+ * (`upsert` / `update` / `getPii` / `linkChannel` / `unlinkChannel`). Every endpoint here is
472
+ * SECRET-scope: a publishable-key client fails fast pre-network (better DX than a 403 round-trip),
473
+ * mirroring the read handle's guard.
474
+ *
475
+ * Channel discipline (design §5.2): the channel a customer lives on is validated through the
476
+ * client's `resolveChannel()` (the `channels` allowlist choke point) — `upsert` resolves its
477
+ * (optional) input channel to the bound default, and `linkChannel` validates its TARGET channel —
478
+ * both BEFORE any request, so a typo'd channel can never split identity/ledger.
479
+ *
480
+ * The SDK owns its public camelCase types; the API bodies are snake_case (`apps/api` app.ts, the
481
+ * `toProfileFields` / `piiToWire` helpers). Fields are mapped explicitly (precise types + drift
482
+ * detection), and `null` is preserved as an intentional CLEAR (never dropped).
483
+ */
484
+ /** A per-channel marketing-consent state (`opted_in` / `opted_out` / `unset`). */
485
+ type ConsentState = 'opted_in' | 'opted_out' | 'unset';
486
+ /** Marketing-consent flags + when they last changed (camelCased from `marketing_consent`). */
487
+ interface MarketingConsent {
488
+ email?: ConsentState;
489
+ sms?: ConsentState;
490
+ /** ISO-8601 timestamp the consent last changed. */
491
+ updatedAt?: string;
492
+ }
493
+ /**
494
+ * The mutable PII field set shared by `upsert` and `update`. A `null` value CLEARS the field
495
+ * (server-side); an ABSENT field is left untouched (merge semantics). Semantic validation
496
+ * (email/birthday/locale) is enforced server-side and surfaces as a `ProfileValidationError` (422).
497
+ */
498
+ interface CustomerProfileFields {
499
+ email?: string | null;
500
+ name?: string | null;
501
+ birthday?: string | null;
502
+ locale?: string | null;
503
+ socialHandles?: Record<string, string> | null;
504
+ marketingConsent?: MarketingConsent | null;
505
+ }
506
+ /** The `upsert` input — the profile fields plus the REQUIRED `externalId` and an optional channel. */
507
+ interface CustomerUpsertInput extends CustomerProfileFields {
508
+ /** The merchant-facing external id keying this customer on its channel. Required. */
509
+ externalId: string;
510
+ /**
511
+ * The channel this external id lives on. Resolved through the client's `resolveChannel()` — omit
512
+ * to use the client's bound channel, or name one (validated against the `channels` allowlist,
513
+ * throwing PRE-NETWORK on an out-of-vocabulary value).
514
+ */
515
+ channel?: string;
516
+ }
517
+ /** Per-call options for `upsert`. */
518
+ interface CustomerUpsertOptions {
519
+ /** Sets the `Idempotency-Key` header for a safe create-or-update retry. */
520
+ idempotencyKey?: string;
521
+ }
522
+ /** The `upsert` result: the resolved customer + whether this call CREATED it (201) or updated it (200). */
523
+ interface CustomerUpsertResult {
524
+ customerUuid: string;
525
+ channel: string;
526
+ externalId: string;
527
+ /** `true` when this call created the customer (HTTP 201); `false` on an update (HTTP 200). */
528
+ created: boolean;
529
+ /** ISO-8601 timestamp of the resulting row. */
530
+ updatedAt: string;
531
+ }
532
+ /** Decrypted server-side PII (`getPii` / the `update` response). Absent keys were never set. */
533
+ interface CustomerPii {
534
+ customerUuid: string;
535
+ email?: string;
536
+ name?: string;
537
+ birthday?: string;
538
+ locale?: string;
539
+ socialHandles?: Record<string, string>;
540
+ marketingConsent?: MarketingConsent;
541
+ /** ISO-8601 timestamp the identity row last changed. */
542
+ updatedAt?: string;
543
+ }
544
+ /** The `linkChannel` input — the TARGET channel + external id to bind to an existing customer. */
545
+ interface LinkChannelInput {
546
+ /** The channel to link. Validated against the client's `channels` allowlist pre-network. */
547
+ channel: string;
548
+ externalId: string;
549
+ }
550
+ /** The `linkChannel` result. */
551
+ interface LinkChannelResult {
552
+ customerUuid: string;
553
+ /** How the link was created (e.g. `manual`). */
554
+ linkedVia: string;
555
+ /** The resulting link status (e.g. `active`). */
556
+ status: string;
557
+ }
558
+ /** The `unlinkChannel` result. */
559
+ interface UnlinkChannelResult {
560
+ /** The resulting link status (e.g. `unlinked`). */
561
+ status: string;
562
+ }
563
+ /** Everything a {@link CustomersResource} needs from its parent client, passed explicitly (no cycle). */
564
+ interface CustomersDeps {
565
+ http: HttpCore;
566
+ keyType: KeyType;
567
+ /** The client's channel resolver — validates a channel against the `channels` allowlist. */
568
+ resolveChannel: (channel?: string) => string;
569
+ }
570
+ declare class CustomersResource {
571
+ private readonly http;
572
+ private readonly keyType;
573
+ private readonly resolveChannel;
574
+ constructor(deps: CustomersDeps);
575
+ /** Fail fast pre-network when a secret-scope write is attempted with a publishable key. */
576
+ private requireSecret;
577
+ /**
578
+ * Create-or-update a customer profile (`POST /v1/customers`). Returns `created: true` on a create
579
+ * (201) or `false` on an update (200) — natural idempotency keys on the resolved `(channel,
580
+ * externalId)`. The channel is resolved through the client (bound default when omitted, else
581
+ * validated against the allowlist — a PRE-NETWORK throw on an out-of-vocabulary value). Pass
582
+ * `opts.idempotencyKey` to make a retry safe.
583
+ *
584
+ * Not `async` so the channel-allowlist check throws SYNCHRONOUSLY before any transport work.
585
+ */
586
+ upsert(input: CustomerUpsertInput, opts?: CustomerUpsertOptions): Promise<CustomerUpsertResult>;
587
+ private sendUpsert;
588
+ /**
589
+ * Partial-update a customer's PII (`PATCH /v1/customers/:ref`). `ref` is a customer UUID or an
590
+ * external id (on the `api` channel). A `null` field CLEARS it; an absent field is untouched
591
+ * (merge). Returns the decrypted PII (camelCase). 404 → `CustomerNotFoundError`.
592
+ */
593
+ update(ref: string, fields: CustomerProfileFields): Promise<CustomerPii>;
594
+ /**
595
+ * Read a customer's decrypted PII by UUID (`GET /v1/customers/:uuid`). Secret-only. 404 →
596
+ * `CustomerNotFoundError` (no cross-tenant existence leak).
597
+ */
598
+ getPii(uuid: string): Promise<CustomerPii>;
599
+ /**
600
+ * Manually link a `(channel, externalId)` pair to an existing customer (`POST
601
+ * /v1/customers/:uuid/links`). The TARGET channel is validated against the client's `channels`
602
+ * allowlist PRE-NETWORK (a typo cannot split identity). 404 → `CustomerNotFoundError`.
603
+ *
604
+ * Not `async` so the channel-allowlist check throws SYNCHRONOUSLY before any transport work.
605
+ */
606
+ linkChannel(uuid: string, input: LinkChannelInput): Promise<LinkChannelResult>;
607
+ private sendLink;
608
+ /**
609
+ * Manually unlink a `(channel, externalId)` pair (`DELETE
610
+ * /v1/customers/:uuid/links/:channel/:externalId`). Idempotent. The `:uuid` scopes/authorizes the
611
+ * call; the pair identifies the specific link to remove.
612
+ */
613
+ unlinkChannel(uuid: string, channel: string, externalId: string): Promise<UnlinkChannelResult>;
614
+ }
615
+
616
+ /**
617
+ * The `client.events` resource — the SIGNED, idempotent front door for inbound events
618
+ * (`POST /v1/events`). A merchant/connector constructs an event envelope (a purchase or refund) and
619
+ * the SDK HMAC-signs it and posts it; the engine authenticates the signature, dedupes, resolves the
620
+ * customer, and enqueues earn/refund. Ledger correctness lives in the engine — this resource only
621
+ * signs and transmits.
622
+ *
623
+ * ## The sign==sent invariant (the whole risk of this resource)
624
+ * The signature covers the LITERAL request body bytes (`${timestamp}.${rawBody}`, mirrored from
625
+ * `packages/engine/src/crypto.ts`). So the bytes signed MUST equal the bytes sent — a re-serialized
626
+ * body (key reordering / whitespace) invalidates the signature and the API 401s. This resource
627
+ * guarantees byte-identity structurally:
628
+ * 1. Serialize the wire payload to a raw JSON string EXACTLY ONCE (`JSON.stringify`).
629
+ * 2. Sign THAT string (`signEventBody(rawBody, signingSecret)`).
630
+ * 3. Transmit THAT SAME string verbatim via the HTTP core's `rawBody` path — which never
631
+ * re-serializes (contrast the object `body` path, which `JSON.stringify`s) — with the three
632
+ * returned signing headers.
633
+ * The one string produced in (1) is the one thing signed and the one thing sent; there is no second
634
+ * serialization anywhere in between.
635
+ *
636
+ * ## Guards (both PRE-NETWORK)
637
+ * - SECRET-scope: signed ingestion is a server-to-server write; a publishable-key client fails fast
638
+ * (mirrors the customer/redemption write guards).
639
+ * - `signingSecret` REQUIRED: signing needs the per-tenant connector secret (`whsec_...`), which is
640
+ * SEPARATE from the bearer API key. A client constructed without one throws a specific config
641
+ * error before any request — never a silent unsigned POST that the API would only reject as a 401.
642
+ *
643
+ * The SDK owns its public camelCase types; the API request body is snake_case (`ingestEventSchema` in
644
+ * `packages/shared`, parsed by `apps/api` `POST /v1/events`). Fields are mapped explicitly; the
645
+ * top-level wire is `.strict()`, so only known keys are emitted. `amount` is intentionally open (the
646
+ * engine reads the known bases and strips the rest), so extra breakdown fields are forwarded verbatim.
647
+ */
648
+ /**
649
+ * The event types the operations API accepts at its boundary. Phase 1 INGESTS `purchase` and
650
+ * `refund` (others resolve to an `unsupported_event_type` 422 → {@link UnsupportedEventTypeError});
651
+ * the wider vocabulary is typed here for forward-compatibility with the server schema.
652
+ */
653
+ type EventType = 'purchase' | 'refund' | 'referral' | 'review' | 'profile';
654
+ /**
655
+ * The monetary breakdown for an event. The named fields are what the engine reads (purchase earn
656
+ * bases; refund `refundedBase`/`orderBase`, minor units). It is deliberately OPEN — connectors
657
+ * legitimately attach extra breakdown fields (tax/shipping/discounts/fees) which the engine strips;
658
+ * any extra key is forwarded verbatim (use the wire snake_case name for a field you want preserved).
659
+ */
660
+ interface EventAmount {
661
+ /** Gross order total, minor units. */
662
+ gross?: number;
663
+ /** Order total net of tax + shipping, minor units. */
664
+ netOfTaxShipping?: number;
665
+ /** Order total after discounts, minor units. */
666
+ finalAfterDiscounts?: number;
667
+ /** ISO-4217 currency code (3 letters). */
668
+ currency?: string;
669
+ /** Refund events: this refund's amount, minor units (same denomination as the earn base). */
670
+ refundedBase?: number;
671
+ /** Refund events: the ORIGINAL order's earn base, minor units (pinned on the first refund). */
672
+ orderBase?: number;
673
+ /** Extra breakdown fields, forwarded verbatim; the engine reads only the known bases. */
674
+ [extra: string]: number | string | undefined;
675
+ }
676
+ /**
677
+ * The event envelope a merchant/connector constructs to ingest. `type` + the core provenance
678
+ * (`channel`, `externalId`, `sourceRef`, `idempotencyKey`, `occurredAt`) are always required; the
679
+ * rest are type-specific (purchase: `trigger`/`orderRef`/`amount` earn bases; refund:
680
+ * `orderRef`/`amount.refundedBase`/`amount.orderBase`). `(channel, externalId)` resolves the
681
+ * pseudonymous customer server-side (never a bare external id, invariant #1).
682
+ */
683
+ interface IngestEventInput {
684
+ /** The event type (`purchase` | `refund` in Phase 1). */
685
+ type: EventType;
686
+ /** The channel this event originated on (also the customer-resolution + provenance axis). */
687
+ channel: string;
688
+ /** The merchant-facing external id keying the customer on `channel`. */
689
+ externalId: string;
690
+ /** The external object id (e.g. the order id) — the business-dedup axis + ledger provenance. */
691
+ sourceRef: string;
692
+ /**
693
+ * The idempotency key (namespace the channel in, e.g. `shopify:ord_5512-purchase`). This value in
694
+ * the SIGNED BODY is the canonical dedup key; the `Idempotency-Key` header, when sent, must echo it.
695
+ */
696
+ idempotencyKey: string;
697
+ /** When the event occurred — an ISO-8601 string or a `Date` (serialized to ISO on the wire). */
698
+ occurredAt: string | Date;
699
+ /** Optional defense-in-depth tenant id; when present it MUST equal the authenticated key's tenant. */
700
+ tenant?: string;
701
+ /** The earn trigger (e.g. `orders/paid`), captured for the Earn Engine. */
702
+ trigger?: string;
703
+ /** The originating order id (required for a `refund`; maps a refund to the original accrual). */
704
+ orderRef?: string;
705
+ /** The monetary breakdown (earn bases for a purchase; refund/order bases for a refund). */
706
+ amount?: EventAmount;
707
+ /** Optional email carried for identity resolution (feature 27). */
708
+ email?: string;
709
+ /** Optional phone carried for identity resolution (feature 27). */
710
+ phone?: string;
711
+ }
712
+ /** Per-call options for `ingest`. */
713
+ interface IngestEventOptions {
714
+ /**
715
+ * Sets the `Idempotency-Key` HEADER. Optional: the canonical dedup key is the SIGNED BODY's
716
+ * `idempotencyKey`. When provided it MUST equal that body value (the API rejects a mismatch with
717
+ * `invalid_payload`); use it only to make transport-layer replay explicit.
718
+ */
719
+ idempotencyKey?: string;
720
+ }
721
+ /**
722
+ * The result of ingesting an event (`POST /v1/events`). `status` is `accepted` on the first sighting
723
+ * (HTTP 201) or `duplicate` on a deduped redelivery (HTTP 200). On a duplicate, `customerCreated` and
724
+ * `enqueued` are `false` (no new customer, no new earn job).
725
+ */
726
+ interface IngestEventResult {
727
+ eventId: string;
728
+ customerId: string;
729
+ channel: string;
730
+ sourceRef: string;
731
+ type: string;
732
+ /** `accepted` (201, first sighting) | `duplicate` (200, deduped redelivery). */
733
+ status: 'accepted' | 'duplicate';
734
+ /** `true` when Identity Resolution minted a new customer for this event. */
735
+ customerCreated: boolean;
736
+ /** How the customer was linked (`channel` today; email/phone match once feature 27 lands). */
737
+ linkedVia: string;
738
+ /** `true` when this call enqueued an earn job (only on `accepted`; `false` on `duplicate`). */
739
+ enqueued: boolean;
740
+ }
741
+ /** Everything an {@link EventsResource} needs from its parent client, passed explicitly (no cycle). */
742
+ interface EventsDeps {
743
+ http: HttpCore;
744
+ keyType: KeyType;
745
+ /** The per-tenant connector signing secret (`whsec_...`); `undefined` when not configured. */
746
+ signingSecret: string | undefined;
747
+ }
748
+ declare class EventsResource {
749
+ private readonly http;
750
+ private readonly keyType;
751
+ private readonly signingSecret;
752
+ constructor(deps: EventsDeps);
753
+ /** Fail fast pre-network when signed ingestion is attempted with a publishable key. */
754
+ private requireSecret;
755
+ /**
756
+ * Fail fast pre-network when no signing secret is configured — signing is impossible without the
757
+ * per-tenant connector secret (`whsec_...`), which is SEPARATE from the bearer API key. Throwing
758
+ * here (rather than sending an unsigned body the API would 401) gives a clear, specific diagnosis.
759
+ */
760
+ private requireSigningSecret;
761
+ /**
762
+ * Ingest a signed event (`POST /v1/events`). SECRET-scope + requires a `signingSecret` (both fail
763
+ * fast pre-network). Serializes the payload to a raw JSON string ONCE, HMAC-signs THAT string, and
764
+ * transmits THE SAME string verbatim with the `X-Loyalty-Signature`/`-Timestamp`/`-Nonce` headers —
765
+ * so the bytes signed are byte-identical to the bytes sent (see the resource doc). Returns the
766
+ * ingestion result mapped to camelCase (`accepted` 201 / `duplicate` 200).
767
+ */
768
+ ingest(input: IngestEventInput, opts?: IngestEventOptions): Promise<IngestEventResult>;
769
+ }
770
+
771
+ /**
772
+ * `TesseraClient` — the SDK entry point. It validates + resolves configuration
773
+ * (exactly one of secret/publishable key → the bearer token), binds the channel,
774
+ * holds the signing / transport settings later tickets consume, and wires the HTTP core.
775
+ *
776
+ * ## Channel binding (the typo-split defense — design §5.2)
777
+ * Customer-scoped operations take NO per-call `channel`: it is bound once, here, so
778
+ * the "`'shopify'` here, `'shoify'` there" identity/ledger split is structurally
779
+ * impossible. Three layers, all enforced by this class:
780
+ * 1. **Bound channel** — set once (default `'api'`); `withChannel(x)` derives a
781
+ * channel-scoped client for the rare legitimate multi-channel case.
782
+ * 2. **`channels` allowlist** — if provided, the bound channel, every `withChannel`
783
+ * target, and every `resolveChannel(target)` (used later by `linkChannel`) must
784
+ * be a member, else an immediate PRE-NETWORK throw listing the known channels.
785
+ * 3. **TS union** — the client is generic over the channel literal union `Ch`,
786
+ * inferred from a `channels: [...] as const`, so a typo fails to COMPILE.
787
+ *
788
+ * The publishable-vs-secret per-endpoint guard is still deferred (a later ticket);
789
+ * the resource groups and the member handle land later and read the bound channel
790
+ * via `resolveChannel()`.
791
+ */
792
+ type KeyType = 'secret' | 'publishable';
793
+ /**
794
+ * Configuration for {@link TesseraClient}. Generic over the channel literal union
795
+ * `Ch`, which is inferred from a `channels: [...] as const` allowlist so that a typo
796
+ * in `channel` fails to compile. When `channels` is omitted, `Ch` widens to `string`
797
+ * (any channel is accepted).
798
+ */
799
+ interface TesseraClientConfig<Ch extends string = string> {
800
+ /** The secret bearer key — unlocks every operation. Provide exactly one of secret/publishable. */
801
+ secretKey?: string;
802
+ /** The publishable bearer key — catalogue-only. Provide exactly one of secret/publishable. */
803
+ publishableKey?: string;
804
+ /** The per-tenant connector HMAC secret (events signing, TER-87). Never equal to the bearer key. */
805
+ signingSecret?: string;
806
+ /**
807
+ * The channel bound to customer-scoped operations. Defaults to `'api'`. Must be a
808
+ * member of `channels` when that allowlist is provided. Wrapped in `NoInfer` so the
809
+ * channel union is driven ONLY by `channels` — a lone `channel` never narrows `Ch`.
810
+ */
811
+ channel?: NoInfer<Ch>;
812
+ /**
813
+ * Optional channel vocabulary. When provided it is enforced two ways: at runtime
814
+ * (an out-of-list channel throws pre-network) and, via `as const`, at compile time
815
+ * (`Ch` narrows to the literal union so `withChannel('typo')` is a type error).
816
+ */
817
+ channels?: readonly Ch[];
818
+ /** The `/v1` base URL. Defaults to the production constant; overridable for local/dev. */
819
+ baseUrl?: string;
820
+ /** Per-request timeout in milliseconds. Default 30000. */
821
+ timeoutMs?: number;
822
+ /** Maximum retries on retriable failures (network / 5xx / 429). Default 2. */
823
+ maxRetries?: number;
824
+ /** A custom `fetch` implementation. Defaults to `globalThis.fetch`. */
825
+ fetch?: FetchLike;
826
+ }
827
+ declare class TesseraClient<Ch extends string = string> {
828
+ /** Whether this client authenticates with a secret or publishable key. */
829
+ readonly keyType: KeyType;
830
+ /** The channel bound to customer-scoped operations (default `'api'`). */
831
+ readonly channel: Ch;
832
+ /** The optional channel allowlist. When set, every channel used must be a member. */
833
+ readonly channels?: readonly Ch[];
834
+ /** The per-tenant events signing secret, if configured (used by TER-87). */
835
+ readonly signingSecret?: string;
836
+ /** The resolved `/v1` base URL. */
837
+ readonly baseUrl: string;
838
+ /** The per-request timeout in milliseconds. */
839
+ readonly timeoutMs: number;
840
+ /** The maximum retry count on retriable failures. */
841
+ readonly maxRetries: number;
842
+ /**
843
+ * @internal The shared HTTP transport. Resource groups (later tickets) issue their
844
+ * requests through this; it is not part of the documented public surface.
845
+ */
846
+ readonly http: HttpCore;
847
+ constructor(config: TesseraClientConfig<Ch>);
848
+ /**
849
+ * Derive a NEW client bound to `channel`, for the rare legitimate multi-channel
850
+ * case. It SHARES this client's auth, transport (the very same {@link HttpCore}
851
+ * instance — never rebuilt), signing secret, and `channels` allowlist; the original
852
+ * is never mutated. Throws pre-network (before any request) if `channel` is outside
853
+ * the allowlist. `channel` is typed against `Ch`, so a typo fails to compile.
854
+ */
855
+ withChannel(channel: Ch): TesseraClient<Ch>;
856
+ /**
857
+ * The public rewards catalogue resource (`GET /v1/rewards`). Allowed with a publishable OR a
858
+ * secret key — the one read a storefront/browser client may make. A fresh (stateless) resource
859
+ * is returned per access so channel-scoped clones (`withChannel`) inherit it for free.
860
+ */
861
+ get rewards(): RewardsResource;
862
+ /**
863
+ * The customer/identity WRITE + server-side PII resource (`upsert` / `update` / `getPii` /
864
+ * `linkChannel` / `unlinkChannel`). Every operation is secret-scope; a publishable-key client
865
+ * fails fast pre-network. A fresh (stateless) resource is returned per access, wired with the
866
+ * shared transport + this client's channel resolver so `withChannel` clones inherit it correctly.
867
+ */
868
+ get customers(): CustomersResource;
869
+ /**
870
+ * The redemption burn-hold lifecycle resource (`place` / `get` / `finalize` / `release`). Every
871
+ * operation is secret-scope; a publishable-key client fails fast pre-network. A fresh (stateless)
872
+ * resource is returned per access, wired with the shared transport + this client's channel
873
+ * resolver so `withChannel` clones inherit it correctly.
874
+ */
875
+ get redemptions(): RedemptionsResource;
876
+ /**
877
+ * The signed event-ingestion resource (`ingest` → `POST /v1/events`). Every ingestion is
878
+ * SECRET-scope AND requires a `signingSecret` (the per-tenant connector HMAC secret, separate from
879
+ * the bearer key) — both fail fast pre-network. A fresh (stateless) resource is returned per access,
880
+ * wired with the shared transport + this client's signing secret so `withChannel` clones inherit it.
881
+ */
882
+ get events(): EventsResource;
883
+ /**
884
+ * Open a {@link MemberHandle} for a member identified by `externalId` on this client's bound
885
+ * channel — the ergonomic `(channel, externalId)`-keyed read + redeem surface (`get` / `balance` /
886
+ * `status` / `history` / `redeem`). The channel comes from {@link resolveChannel}: omit
887
+ * `opts.channel` to use the bound channel, or pass `opts.channel` to override it (validated against
888
+ * the `channels` allowlist, throwing PRE-NETWORK on an out-of-vocabulary value). All member
889
+ * operations are secret-scope; a publishable-key client fails fast when one is attempted.
890
+ */
891
+ member(externalId: string, opts?: MemberOptions): MemberHandle;
892
+ /**
893
+ * @internal Resolve the channel for a customer-scoped operation. Called with no
894
+ * argument it returns the bound channel (the member reads / redeem path). Called
895
+ * with a `channel` (e.g. the target of `linkChannel`) it validates that value
896
+ * against the `channels` allowlist and returns it — throwing pre-network on an
897
+ * out-of-vocabulary channel. Kept internal: resource groups consume it; it is not
898
+ * part of the documented public surface.
899
+ */
900
+ resolveChannel(channel?: string): string;
901
+ }
902
+
903
+ /**
904
+ * The SDK error taxonomy. One structured base class (`TesseraError`) carrying the
905
+ * stable, code-first envelope shape (`apps/api/src/errors.ts`), plus a typed subclass
906
+ * per stable wire `code` so a consumer can `catch` by TYPE — not by string-matching a
907
+ * `code` — and read `err.requestId` for support without ever parsing an HTTP body.
908
+ *
909
+ * The class identity comes from the snake_case `code`; the HTTP `status` flows from the
910
+ * response (never hard-coded here). `CODE_TO_ERROR` maps each `code` to its subclass and
911
+ * `errorFromEnvelope` constructs the right one. An UNKNOWN code yields the base
912
+ * `TesseraError` (forward-compat: a new server code never crashes an old SDK), so the
913
+ * factory NEVER throws on an unrecognized code.
914
+ */
915
+ interface TesseraErrorInit {
916
+ /** The stable, snake_case error `code` (from the API envelope, or an SDK-local code). */
917
+ code: string;
918
+ /** Human-readable detail. Never branch on this — branch on `code` / the error type. */
919
+ message: string;
920
+ /** The `X-Request-Id` correlating this failure, for support. Absent pre-network. */
921
+ requestId?: string;
922
+ /** The HTTP status, when the error came from a response. Absent for pre-network errors. */
923
+ status?: number;
924
+ /** Structured, code-specific detail from the envelope (e.g. a redemption shortfall). */
925
+ details?: unknown;
926
+ }
927
+ declare class TesseraError extends Error {
928
+ /** The stable, snake_case error `code`; the contract consumers branch on. */
929
+ readonly code: string;
930
+ /** The `X-Request-Id` of the failing request, when available. */
931
+ readonly requestId?: string;
932
+ /** The HTTP status, when this error originated from a response. */
933
+ readonly status?: number;
934
+ /** Structured, code-specific detail carried from the envelope `details`. */
935
+ readonly details?: unknown;
936
+ constructor(init: TesseraErrorInit);
937
+ }
938
+ /** `invalid_request` (400) — a malformed request body / query the API rejected at its boundary. */
939
+ declare class ValidationError extends TesseraError {
940
+ }
941
+ /** `unauthorized` (401) — missing, unknown, or revoked API key. */
942
+ declare class UnauthorizedError extends TesseraError {
943
+ }
944
+ /** `signature_invalid` (401) — an event body's HMAC signature is absent or did not verify. */
945
+ declare class SignatureInvalidError extends TesseraError {
946
+ }
947
+ /** `replay_detected` (401) — an event timestamp is outside the skew window, or its nonce was reused. */
948
+ declare class ReplayDetectedError extends TesseraError {
949
+ }
950
+ /** `forbidden` (403) — the authenticated key lacks authority for this operation. */
951
+ declare class ForbiddenError extends TesseraError {
952
+ }
953
+ /** `tier_gated` (403) — the redemption option requires a higher tier than the customer holds. */
954
+ declare class TierGatedError extends TesseraError {
955
+ }
956
+ /** `publishable_key_forbidden` (403) — a publishable key was used on a secret-key-only endpoint. */
957
+ declare class PublishableKeyForbiddenError extends TesseraError {
958
+ }
959
+ /** `not_found` (404) — the requested resource does not exist within the caller's tenant. */
960
+ declare class NotFoundError extends TesseraError {
961
+ }
962
+ /** `customer_not_found` (404) — no such customer in this tenant (no cross-tenant existence leak). */
963
+ declare class CustomerNotFoundError extends TesseraError {
964
+ }
965
+ /** `redemption_not_found` (404) — no such redemption in this tenant. */
966
+ declare class RedemptionNotFoundError extends TesseraError {
967
+ }
968
+ /** `conflict` (409) — a generic state conflict. */
969
+ declare class ConflictError extends TesseraError {
970
+ }
971
+ /**
972
+ * `idempotency_key_conflict` / `idempotency_key_reuse` (409) — an idempotency key was reused with a
973
+ * DIFFERENT payload. Both wire codes (the events-endpoint-specific `..._conflict` and the
974
+ * platform-wide `..._reuse`) surface as this one type.
975
+ */
976
+ declare class IdempotencyConflictError extends TesseraError {
977
+ }
978
+ /** `version_conflict` (409) — an optimistic-concurrency stale write; re-read and retry. */
979
+ declare class VersionConflictError extends TesseraError {
980
+ }
981
+ /** `cap_reached` (409) — the redemption limit for this option has been reached. */
982
+ declare class CapReachedError extends TesseraError {
983
+ }
984
+ /** `not_stackable` (409) — a non-stackable option conflicts with an existing active hold. */
985
+ declare class NotStackableError extends TesseraError {
986
+ }
987
+ /** `hold_already_released` (409) — finalizing a redemption hold that is already released (terminal). */
988
+ declare class HoldAlreadyReleasedError extends TesseraError {
989
+ }
990
+ /** `customer_erased` (410) — the customer's PII has been erased (GDPR); the profile is gone. */
991
+ declare class CustomerErasedError extends TesseraError {
992
+ }
993
+ /** `payload_too_large` (413) — the request body exceeded the API's size limit. */
994
+ declare class PayloadTooLargeError extends TesseraError {
995
+ }
996
+ /**
997
+ * `insufficient_balance` (422) — the customer's `available` was below the redemption cost, so no hold
998
+ * was placed. `shortfall` is how far `available` fell short (read from the envelope `details`).
999
+ */
1000
+ declare class InsufficientBalanceError extends TesseraError {
1001
+ /** How far `available` fell short of the cost, from `details.shortfall`; `undefined` if absent. */
1002
+ get shortfall(): number | undefined;
1003
+ }
1004
+ /** `option_not_eligible` (422) — the option exists but a non-tier/non-cap eligibility rule failed. */
1005
+ declare class OptionNotEligibleError extends TesseraError {
1006
+ }
1007
+ /** `invalid_payload` (422) — a well-formed event body that failed the purchase-event contract. */
1008
+ declare class InvalidPayloadError extends TesseraError {
1009
+ }
1010
+ /** `unsupported_event_type` (422) — an event type not supported in this phase. */
1011
+ declare class UnsupportedEventTypeError extends TesseraError {
1012
+ }
1013
+ /**
1014
+ * `profile_validation_error` (422) — a semantic profile-field validation failure (email/birthday/
1015
+ * locale). `field` names the offending field (read from the envelope `details`).
1016
+ */
1017
+ declare class ProfileValidationError extends TesseraError {
1018
+ /** The offending profile field, from `details.field`; `undefined` if absent. */
1019
+ get field(): string | undefined;
1020
+ }
1021
+ /** `rate_limited` (429) — the tenant exceeded its rate limit; honor `Retry-After` and back off. */
1022
+ declare class RateLimitedError extends TesseraError {
1023
+ }
1024
+ /** `internal_error` (500) — an opaque server error. */
1025
+ declare class InternalError extends TesseraError {
1026
+ }
1027
+ /** `not_implemented` (501) — a valid-vocabulary option that is not implemented this phase. */
1028
+ declare class NotImplementedError extends TesseraError {
1029
+ }
1030
+ /** The shape of an error-envelope subclass constructor. */
1031
+ type TesseraErrorClass = new (init: TesseraErrorInit) => TesseraError;
1032
+ /**
1033
+ * The authoritative map from the operations API's stable snake_case wire `code` to the SDK error
1034
+ * subclass a consumer catches. Keyed by `code` (NOT status) — status flows from the response. The
1035
+ * contract-test suite (§8) asserts this covers every `code` the API can emit, so the server cannot
1036
+ * add a code the SDK silently downgrades to the base error.
1037
+ */
1038
+ declare const CODE_TO_ERROR: Record<string, TesseraErrorClass>;
1039
+ /**
1040
+ * Build the right `TesseraError` subclass from a non-2xx response. `body` is the tolerantly-parsed
1041
+ * JSON body (any shape); `requestId` is the captured `X-Request-Id` header (preferred over the
1042
+ * envelope's `request_id`). An unknown or missing `code` yields the base `TesseraError` — this
1043
+ * NEVER throws, so a new server code cannot crash an old SDK.
1044
+ */
1045
+ declare function errorFromEnvelope(status: number, body: unknown, requestId?: string): TesseraError;
1046
+
1047
+ /**
1048
+ * Client-side HMAC signer for inbound event ingestion (`POST /v1/events`).
1049
+ *
1050
+ * This is the exact mirror of the engine's verifier so a signed request is accepted byte-for-byte.
1051
+ * Source of truth: `packages/engine/src/crypto.ts` — `signEventPayload` / `signedPayload` /
1052
+ * `verifyEventSignature`. The scheme (do NOT change without changing the engine):
1053
+ *
1054
+ * - **Algorithm:** HMAC-SHA256, keyed on the per-tenant *connector* `signingSecret`
1055
+ * (`whsec_...`) — the credential separate from the bearer API key.
1056
+ * - **Canonical message:** the literal string `` `${timestamp}.${rawBody}` `` — the unix-seconds
1057
+ * timestamp, a single `.` delimiter, then the RAW request body byte-for-byte as sent. The nonce
1058
+ * is deliberately NOT part of the signed payload (it is an independent replay-defense header).
1059
+ * - **Encoding:** lowercase hex, presented with the scheme prefix `v1=`
1060
+ * (`X-Loyalty-Signature: v1=<hex>`).
1061
+ * - **Timestamp:** unix SECONDS as a string (the engine does `Number(timestamp)` and checks skew
1062
+ * against `Date.now()/1000`).
1063
+ * - **Nonce:** a fresh, opaque per-attempt value carried in `X-Loyalty-Nonce`; a verbatim resend
1064
+ * of the same nonce is a replay server-side.
1065
+ *
1066
+ * Runtime-self-contained: uses only Web Crypto (`globalThis.crypto.subtle` + `randomUUID`) and
1067
+ * `TextEncoder`, so it runs on Node 20+, edge/Workers, Deno, and Bun with zero runtime deps and no
1068
+ * `@loyalty/*` import. UTF-8 encoding via `TextEncoder` matches Node's default string encoding in
1069
+ * the engine's `createHmac(...).update(string)`, so multibyte bodies sign identically.
1070
+ */
1071
+ /** The signature scheme version prefix — matches the engine's `SIGNATURE_VERSION`. */
1072
+ declare const SIGNATURE_SCHEME = "v1";
1073
+ /** Header carrying the `v1=<hex>` HMAC signature. */
1074
+ declare const SIGNATURE_HEADER = "X-Loyalty-Signature";
1075
+ /** Header carrying the unix-seconds timestamp that is part of the signed payload. */
1076
+ declare const TIMESTAMP_HEADER = "X-Loyalty-Timestamp";
1077
+ /** Header carrying the per-attempt replay nonce (not signed). */
1078
+ declare const NONCE_HEADER = "X-Loyalty-Nonce";
1079
+ interface SignEventOptions {
1080
+ /**
1081
+ * The unix-SECONDS timestamp to sign with. A number is stringified; a string is used verbatim.
1082
+ * Defaults to `Math.floor(now() / 1000)`. Injectable so tests are deterministic.
1083
+ */
1084
+ timestamp?: string | number;
1085
+ /** The replay nonce. Defaults to `crypto.randomUUID()`. Injectable for deterministic tests. */
1086
+ nonce?: string;
1087
+ /** Millisecond clock used only to derive a default `timestamp`. Defaults to `Date.now`. */
1088
+ now?: () => number;
1089
+ }
1090
+ /** The three signing headers, ready to spread into a request's `headers`. */
1091
+ interface SignedEventHeaders {
1092
+ 'X-Loyalty-Signature': string;
1093
+ 'X-Loyalty-Timestamp': string;
1094
+ 'X-Loyalty-Nonce': string;
1095
+ }
1096
+ interface SignedEvent {
1097
+ /** `v1=<hex>` — the HMAC-SHA256 of `${timestamp}.${rawBody}` under the signing secret. */
1098
+ signature: string;
1099
+ /** The unix-seconds timestamp that was signed (echoed as `X-Loyalty-Timestamp`). */
1100
+ timestamp: string;
1101
+ /** The replay nonce (echoed as `X-Loyalty-Nonce`). */
1102
+ nonce: string;
1103
+ /** The complete set of signing headers to attach to the `POST /v1/events` request. */
1104
+ headers: SignedEventHeaders;
1105
+ }
1106
+ /**
1107
+ * Sign a raw event body for `POST /v1/events`.
1108
+ *
1109
+ * @param rawBody The exact request body string that will be sent — sign and send the SAME bytes;
1110
+ * re-serializing after signing invalidates the signature.
1111
+ * @param signingSecret The per-tenant connector signing secret (`whsec_...`).
1112
+ * @param opts Injectable `timestamp` / `nonce` / `now` (defaults: unix-seconds now, a random UUID).
1113
+ * @returns The `v1=<hex>` signature plus the timestamp, nonce, and the ready-to-attach headers.
1114
+ */
1115
+ declare function signEventBody(rawBody: string, signingSecret: string, opts?: SignEventOptions): Promise<SignedEvent>;
1116
+
1117
+ /**
1118
+ * `@tesseraloyalty/sdk` — the official TypeScript SDK for the Tessera loyalty platform's
1119
+ * public operations API (`/v1`).
1120
+ *
1121
+ * This entry point exports the client, the error taxonomy, the public config types, the READ
1122
+ * resources (the rewards catalogue + the `member(externalId)` handle), the `customers` identity/PII
1123
+ * resource, the `redemptions` burn-hold lifecycle (`place`/`get`/`finalize`/`release` +
1124
+ * `member.redeem`), the `events` signed-ingestion resource (`ingest` → `POST /v1/events`), and the
1125
+ * `signEventBody` HMAC signer.
1126
+ *
1127
+ * Runtime-self-contained: it uses only global Web standards (`fetch` + friends), so
1128
+ * it runs on Node 20+, edge/Workers, Deno, and Bun with zero runtime dependencies.
1129
+ */
1130
+
1131
+ /** The published version of this SDK. Kept in sync with `package.json` at release. */
1132
+ declare const VERSION = "0.1.0";
1133
+ /** The major version of the Tessera operations API this SDK targets. */
1134
+ declare const API_VERSION = "v1";
1135
+
1136
+ export { API_VERSION, CODE_TO_ERROR, CapReachedError, ConflictError, type ConsentState, CustomerErasedError, CustomerNotFoundError, type CustomerPii, type CustomerProfileFields, type CustomerUpsertInput, type CustomerUpsertOptions, type CustomerUpsertResult, CustomersResource, type EventAmount, type EventType, EventsResource, type FetchLike, type FinalizeRedemptionInput, type FinalizeRedemptionResult, ForbiddenError, type HistoryEntry, type HistoryOptions, type HistoryPage, type HistoryPageInfo, HistoryPager, HoldAlreadyReleasedError, IdempotencyConflictError, type IngestEventInput, type IngestEventOptions, type IngestEventResult, InsufficientBalanceError, InternalError, InvalidPayloadError, type KeyType, type LinkChannelInput, type LinkChannelResult, type MarketingConsent, type MemberBalance, MemberHandle, type MemberOptions, type MemberProfile, type MemberRedeemInput, type MemberStatus, type MemberTier, NONCE_HEADER, NotFoundError, NotImplementedError, NotStackableError, OptionNotEligibleError, PayloadTooLargeError, type PlaceRedemptionInput, type PlaceRedemptionOptions, ProfileValidationError, PublishableKeyForbiddenError, type QualifyingProgress, RateLimitedError, type RedemptionHandle, RedemptionNotFoundError, type RedemptionResult, type RedemptionStatus, RedemptionsResource, type ReleaseRedemptionResult, ReplayDetectedError, type RewardOption, type RewardOptionConstraints, type RewardsCatalogue, RewardsResource, SIGNATURE_HEADER, SIGNATURE_SCHEME, type SignEventOptions, SignatureInvalidError, type SignedEvent, type SignedEventHeaders, TIMESTAMP_HEADER, TesseraClient, type TesseraClientConfig, TesseraError, type TesseraErrorInit, TierGatedError, UnauthorizedError, type UnlinkChannelResult, UnsupportedEventTypeError, VERSION, ValidationError, VersionConflictError, errorFromEnvelope, signEventBody };