@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 +21 -0
- package/README.md +238 -0
- package/dist/index.cjs +1210 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1136 -0
- package/dist/index.d.ts +1136 -0
- package/dist/index.js +1187 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
API_VERSION: () => API_VERSION,
|
|
24
|
+
CODE_TO_ERROR: () => CODE_TO_ERROR,
|
|
25
|
+
CapReachedError: () => CapReachedError,
|
|
26
|
+
ConflictError: () => ConflictError,
|
|
27
|
+
CustomerErasedError: () => CustomerErasedError,
|
|
28
|
+
CustomerNotFoundError: () => CustomerNotFoundError,
|
|
29
|
+
CustomersResource: () => CustomersResource,
|
|
30
|
+
EventsResource: () => EventsResource,
|
|
31
|
+
ForbiddenError: () => ForbiddenError,
|
|
32
|
+
HistoryPager: () => HistoryPager,
|
|
33
|
+
HoldAlreadyReleasedError: () => HoldAlreadyReleasedError,
|
|
34
|
+
IdempotencyConflictError: () => IdempotencyConflictError,
|
|
35
|
+
InsufficientBalanceError: () => InsufficientBalanceError,
|
|
36
|
+
InternalError: () => InternalError,
|
|
37
|
+
InvalidPayloadError: () => InvalidPayloadError,
|
|
38
|
+
MemberHandle: () => MemberHandle,
|
|
39
|
+
NONCE_HEADER: () => NONCE_HEADER,
|
|
40
|
+
NotFoundError: () => NotFoundError,
|
|
41
|
+
NotImplementedError: () => NotImplementedError,
|
|
42
|
+
NotStackableError: () => NotStackableError,
|
|
43
|
+
OptionNotEligibleError: () => OptionNotEligibleError,
|
|
44
|
+
PayloadTooLargeError: () => PayloadTooLargeError,
|
|
45
|
+
ProfileValidationError: () => ProfileValidationError,
|
|
46
|
+
PublishableKeyForbiddenError: () => PublishableKeyForbiddenError,
|
|
47
|
+
RateLimitedError: () => RateLimitedError,
|
|
48
|
+
RedemptionNotFoundError: () => RedemptionNotFoundError,
|
|
49
|
+
RedemptionsResource: () => RedemptionsResource,
|
|
50
|
+
ReplayDetectedError: () => ReplayDetectedError,
|
|
51
|
+
RewardsResource: () => RewardsResource,
|
|
52
|
+
SIGNATURE_HEADER: () => SIGNATURE_HEADER,
|
|
53
|
+
SIGNATURE_SCHEME: () => SIGNATURE_SCHEME,
|
|
54
|
+
SignatureInvalidError: () => SignatureInvalidError,
|
|
55
|
+
TIMESTAMP_HEADER: () => TIMESTAMP_HEADER,
|
|
56
|
+
TesseraClient: () => TesseraClient,
|
|
57
|
+
TesseraError: () => TesseraError,
|
|
58
|
+
TierGatedError: () => TierGatedError,
|
|
59
|
+
UnauthorizedError: () => UnauthorizedError,
|
|
60
|
+
UnsupportedEventTypeError: () => UnsupportedEventTypeError,
|
|
61
|
+
VERSION: () => VERSION,
|
|
62
|
+
ValidationError: () => ValidationError,
|
|
63
|
+
VersionConflictError: () => VersionConflictError,
|
|
64
|
+
errorFromEnvelope: () => errorFromEnvelope,
|
|
65
|
+
signEventBody: () => signEventBody
|
|
66
|
+
});
|
|
67
|
+
module.exports = __toCommonJS(index_exports);
|
|
68
|
+
|
|
69
|
+
// src/errors.ts
|
|
70
|
+
var TesseraError = class extends Error {
|
|
71
|
+
/** The stable, snake_case error `code`; the contract consumers branch on. */
|
|
72
|
+
code;
|
|
73
|
+
/** The `X-Request-Id` of the failing request, when available. */
|
|
74
|
+
requestId;
|
|
75
|
+
/** The HTTP status, when this error originated from a response. */
|
|
76
|
+
status;
|
|
77
|
+
/** Structured, code-specific detail carried from the envelope `details`. */
|
|
78
|
+
details;
|
|
79
|
+
constructor(init) {
|
|
80
|
+
super(init.message);
|
|
81
|
+
this.name = new.target.name;
|
|
82
|
+
this.code = init.code;
|
|
83
|
+
this.requestId = init.requestId;
|
|
84
|
+
this.status = init.status;
|
|
85
|
+
this.details = init.details;
|
|
86
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
function detailValue(details, key) {
|
|
90
|
+
if (details && typeof details === "object" && key in details) {
|
|
91
|
+
return details[key];
|
|
92
|
+
}
|
|
93
|
+
return void 0;
|
|
94
|
+
}
|
|
95
|
+
var ValidationError = class extends TesseraError {
|
|
96
|
+
};
|
|
97
|
+
var UnauthorizedError = class extends TesseraError {
|
|
98
|
+
};
|
|
99
|
+
var SignatureInvalidError = class extends TesseraError {
|
|
100
|
+
};
|
|
101
|
+
var ReplayDetectedError = class extends TesseraError {
|
|
102
|
+
};
|
|
103
|
+
var ForbiddenError = class extends TesseraError {
|
|
104
|
+
};
|
|
105
|
+
var TierGatedError = class extends TesseraError {
|
|
106
|
+
};
|
|
107
|
+
var PublishableKeyForbiddenError = class extends TesseraError {
|
|
108
|
+
};
|
|
109
|
+
var NotFoundError = class extends TesseraError {
|
|
110
|
+
};
|
|
111
|
+
var CustomerNotFoundError = class extends TesseraError {
|
|
112
|
+
};
|
|
113
|
+
var RedemptionNotFoundError = class extends TesseraError {
|
|
114
|
+
};
|
|
115
|
+
var ConflictError = class extends TesseraError {
|
|
116
|
+
};
|
|
117
|
+
var IdempotencyConflictError = class extends TesseraError {
|
|
118
|
+
};
|
|
119
|
+
var VersionConflictError = class extends TesseraError {
|
|
120
|
+
};
|
|
121
|
+
var CapReachedError = class extends TesseraError {
|
|
122
|
+
};
|
|
123
|
+
var NotStackableError = class extends TesseraError {
|
|
124
|
+
};
|
|
125
|
+
var HoldAlreadyReleasedError = class extends TesseraError {
|
|
126
|
+
};
|
|
127
|
+
var CustomerErasedError = class extends TesseraError {
|
|
128
|
+
};
|
|
129
|
+
var PayloadTooLargeError = class extends TesseraError {
|
|
130
|
+
};
|
|
131
|
+
var InsufficientBalanceError = class extends TesseraError {
|
|
132
|
+
/** How far `available` fell short of the cost, from `details.shortfall`; `undefined` if absent. */
|
|
133
|
+
get shortfall() {
|
|
134
|
+
const value = detailValue(this.details, "shortfall");
|
|
135
|
+
return typeof value === "number" ? value : void 0;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
var OptionNotEligibleError = class extends TesseraError {
|
|
139
|
+
};
|
|
140
|
+
var InvalidPayloadError = class extends TesseraError {
|
|
141
|
+
};
|
|
142
|
+
var UnsupportedEventTypeError = class extends TesseraError {
|
|
143
|
+
};
|
|
144
|
+
var ProfileValidationError = class extends TesseraError {
|
|
145
|
+
/** The offending profile field, from `details.field`; `undefined` if absent. */
|
|
146
|
+
get field() {
|
|
147
|
+
const value = detailValue(this.details, "field");
|
|
148
|
+
return typeof value === "string" ? value : void 0;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
var RateLimitedError = class extends TesseraError {
|
|
152
|
+
};
|
|
153
|
+
var InternalError = class extends TesseraError {
|
|
154
|
+
};
|
|
155
|
+
var NotImplementedError = class extends TesseraError {
|
|
156
|
+
};
|
|
157
|
+
var CODE_TO_ERROR = {
|
|
158
|
+
// 400
|
|
159
|
+
invalid_request: ValidationError,
|
|
160
|
+
// 401
|
|
161
|
+
unauthorized: UnauthorizedError,
|
|
162
|
+
signature_invalid: SignatureInvalidError,
|
|
163
|
+
replay_detected: ReplayDetectedError,
|
|
164
|
+
// 403
|
|
165
|
+
forbidden: ForbiddenError,
|
|
166
|
+
tier_gated: TierGatedError,
|
|
167
|
+
publishable_key_forbidden: PublishableKeyForbiddenError,
|
|
168
|
+
// 404
|
|
169
|
+
not_found: NotFoundError,
|
|
170
|
+
customer_not_found: CustomerNotFoundError,
|
|
171
|
+
redemption_not_found: RedemptionNotFoundError,
|
|
172
|
+
// 409
|
|
173
|
+
conflict: ConflictError,
|
|
174
|
+
idempotency_key_conflict: IdempotencyConflictError,
|
|
175
|
+
idempotency_key_reuse: IdempotencyConflictError,
|
|
176
|
+
version_conflict: VersionConflictError,
|
|
177
|
+
cap_reached: CapReachedError,
|
|
178
|
+
not_stackable: NotStackableError,
|
|
179
|
+
hold_already_released: HoldAlreadyReleasedError,
|
|
180
|
+
// 410
|
|
181
|
+
customer_erased: CustomerErasedError,
|
|
182
|
+
// 413
|
|
183
|
+
payload_too_large: PayloadTooLargeError,
|
|
184
|
+
// 422
|
|
185
|
+
insufficient_balance: InsufficientBalanceError,
|
|
186
|
+
option_not_eligible: OptionNotEligibleError,
|
|
187
|
+
invalid_payload: InvalidPayloadError,
|
|
188
|
+
unsupported_event_type: UnsupportedEventTypeError,
|
|
189
|
+
profile_validation_error: ProfileValidationError,
|
|
190
|
+
// 429
|
|
191
|
+
rate_limited: RateLimitedError,
|
|
192
|
+
// 500 / 501
|
|
193
|
+
internal_error: InternalError,
|
|
194
|
+
not_implemented: NotImplementedError
|
|
195
|
+
};
|
|
196
|
+
function isRecord(value) {
|
|
197
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
198
|
+
}
|
|
199
|
+
function extractEnvelope(body) {
|
|
200
|
+
if (!isRecord(body)) return {};
|
|
201
|
+
const error = isRecord(body.error) ? body.error : void 0;
|
|
202
|
+
if (!error) return {};
|
|
203
|
+
return {
|
|
204
|
+
code: typeof error.code === "string" ? error.code : void 0,
|
|
205
|
+
message: typeof error.message === "string" ? error.message : void 0,
|
|
206
|
+
requestId: typeof error.request_id === "string" ? error.request_id : void 0,
|
|
207
|
+
details: error.details
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function errorFromEnvelope(status, body, requestId) {
|
|
211
|
+
const env = extractEnvelope(body);
|
|
212
|
+
const code = env.code ?? "unknown_error";
|
|
213
|
+
const init = {
|
|
214
|
+
code,
|
|
215
|
+
message: env.message ?? `Request failed with HTTP ${status}.`,
|
|
216
|
+
status,
|
|
217
|
+
requestId: requestId ?? env.requestId,
|
|
218
|
+
details: env.details
|
|
219
|
+
};
|
|
220
|
+
const ErrorClass = CODE_TO_ERROR[code];
|
|
221
|
+
return ErrorClass ? new ErrorClass(init) : new TesseraError(init);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/http.ts
|
|
225
|
+
var TimeoutSignal = class extends Error {
|
|
226
|
+
};
|
|
227
|
+
var HttpCore = class {
|
|
228
|
+
baseUrl;
|
|
229
|
+
token;
|
|
230
|
+
timeoutMs;
|
|
231
|
+
maxRetries;
|
|
232
|
+
fetchImpl;
|
|
233
|
+
constructor(config) {
|
|
234
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
235
|
+
this.token = config.token;
|
|
236
|
+
this.timeoutMs = config.timeoutMs;
|
|
237
|
+
this.maxRetries = config.maxRetries;
|
|
238
|
+
this.fetchImpl = config.fetchImpl;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Issue a request. On 2xx returns the tolerantly-parsed JSON body; on a non-2xx
|
|
242
|
+
* envelope throws a base `TesseraError`. Retries network errors / 5xx / 429 up to
|
|
243
|
+
* `maxRetries`; a per-attempt timeout is terminal (it means the call blew its budget).
|
|
244
|
+
*/
|
|
245
|
+
async request(method, path, opts = {}) {
|
|
246
|
+
const url = this.buildUrl(path, opts.query);
|
|
247
|
+
const hasRawBody = opts.rawBody !== void 0;
|
|
248
|
+
const hasBody = hasRawBody || opts.body !== void 0;
|
|
249
|
+
const headers = this.buildHeaders(opts, hasBody);
|
|
250
|
+
const body = hasRawBody ? opts.rawBody : opts.body !== void 0 ? JSON.stringify(opts.body) : void 0;
|
|
251
|
+
let attempt = 0;
|
|
252
|
+
for (; ; ) {
|
|
253
|
+
let response;
|
|
254
|
+
try {
|
|
255
|
+
response = await this.dispatch(method, url, headers, body);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (err instanceof TimeoutSignal) {
|
|
258
|
+
throw new TesseraError({
|
|
259
|
+
code: "timeout",
|
|
260
|
+
message: `Request to ${method} ${path} timed out after ${this.timeoutMs}ms.`
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (attempt < this.maxRetries) {
|
|
264
|
+
await delay(backoffMs(attempt));
|
|
265
|
+
attempt += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
throw new TesseraError({
|
|
269
|
+
code: "network_error",
|
|
270
|
+
message: err instanceof Error ? err.message : "Network request failed.",
|
|
271
|
+
details: err
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (isRetriableStatus(response.status) && attempt < this.maxRetries) {
|
|
275
|
+
const retryAfterMs = parseRetryAfter(response.headers.get("Retry-After"));
|
|
276
|
+
await delay(retryAfterMs ?? backoffMs(attempt));
|
|
277
|
+
attempt += 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const requestId = response.headers.get("X-Request-Id") ?? void 0;
|
|
281
|
+
if (response.status >= 200 && response.status < 300) {
|
|
282
|
+
return await readJson(response);
|
|
283
|
+
}
|
|
284
|
+
const errorBody = await readJson(response);
|
|
285
|
+
throw errorFromEnvelope(response.status, errorBody, requestId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** One fetch attempt, raced against a per-attempt timeout that aborts the request. */
|
|
289
|
+
async dispatch(method, url, headers, body) {
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
let timer;
|
|
292
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
293
|
+
timer = setTimeout(() => {
|
|
294
|
+
controller.abort();
|
|
295
|
+
reject(new TimeoutSignal());
|
|
296
|
+
}, this.timeoutMs);
|
|
297
|
+
});
|
|
298
|
+
try {
|
|
299
|
+
return await Promise.race([
|
|
300
|
+
this.fetchImpl(url, { method, headers, body, signal: controller.signal }),
|
|
301
|
+
timeout
|
|
302
|
+
]);
|
|
303
|
+
} finally {
|
|
304
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
buildUrl(path, query) {
|
|
308
|
+
const rel = path.startsWith("/") ? path : `/${path}`;
|
|
309
|
+
let url = `${this.baseUrl}${rel}`;
|
|
310
|
+
if (query) {
|
|
311
|
+
const params = new URLSearchParams();
|
|
312
|
+
for (const [key, value] of Object.entries(query)) {
|
|
313
|
+
if (value !== void 0 && value !== null) params.set(key, String(value));
|
|
314
|
+
}
|
|
315
|
+
const qs = params.toString();
|
|
316
|
+
if (qs) url += `?${qs}`;
|
|
317
|
+
}
|
|
318
|
+
return url;
|
|
319
|
+
}
|
|
320
|
+
buildHeaders(opts, hasBody) {
|
|
321
|
+
const headers = new Headers(opts.headers);
|
|
322
|
+
headers.set("Authorization", `Bearer ${this.token}`);
|
|
323
|
+
headers.set("Accept", "application/json");
|
|
324
|
+
if (hasBody) headers.set("Content-Type", "application/json");
|
|
325
|
+
if (opts.idempotencyKey) headers.set("Idempotency-Key", opts.idempotencyKey);
|
|
326
|
+
return headers;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
function isRetriableStatus(status) {
|
|
330
|
+
return status === 429 || status >= 500;
|
|
331
|
+
}
|
|
332
|
+
function backoffMs(attempt) {
|
|
333
|
+
const base = 200;
|
|
334
|
+
const cap = 2e4;
|
|
335
|
+
return Math.min(cap, base * 2 ** attempt);
|
|
336
|
+
}
|
|
337
|
+
function delay(ms) {
|
|
338
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
339
|
+
}
|
|
340
|
+
function parseRetryAfter(value) {
|
|
341
|
+
if (!value) return void 0;
|
|
342
|
+
const seconds = Number(value);
|
|
343
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
344
|
+
const at = Date.parse(value);
|
|
345
|
+
if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
|
|
346
|
+
return void 0;
|
|
347
|
+
}
|
|
348
|
+
async function readJson(response) {
|
|
349
|
+
let text;
|
|
350
|
+
try {
|
|
351
|
+
text = await response.text();
|
|
352
|
+
} catch {
|
|
353
|
+
return void 0;
|
|
354
|
+
}
|
|
355
|
+
if (!text) return void 0;
|
|
356
|
+
try {
|
|
357
|
+
return JSON.parse(text);
|
|
358
|
+
} catch {
|
|
359
|
+
return void 0;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/internal/camel.ts
|
|
364
|
+
function snakeToCamel(key) {
|
|
365
|
+
return key.replace(/[_-]+([a-z0-9])/g, (_match, ch) => ch.toUpperCase());
|
|
366
|
+
}
|
|
367
|
+
function deepCamelize(value) {
|
|
368
|
+
if (Array.isArray(value)) return value.map(deepCamelize);
|
|
369
|
+
if (value !== null && typeof value === "object") {
|
|
370
|
+
const out = {};
|
|
371
|
+
for (const [key, val] of Object.entries(value)) {
|
|
372
|
+
out[snakeToCamel(key)] = deepCamelize(val);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/resources/rewards.ts
|
|
380
|
+
function toRewardOption(w) {
|
|
381
|
+
const c = w.constraints;
|
|
382
|
+
return {
|
|
383
|
+
id: w.id,
|
|
384
|
+
type: w.type,
|
|
385
|
+
label: w.label,
|
|
386
|
+
mechanism: w.mechanism,
|
|
387
|
+
triggerSurfaces: w.trigger_surfaces,
|
|
388
|
+
// The cost model is an open variant record — deep-camelCase its keys generically.
|
|
389
|
+
costModel: deepCamelize(w.cost_model),
|
|
390
|
+
costPoints: w.cost_points,
|
|
391
|
+
constraints: {
|
|
392
|
+
minSpendCents: c.min_spend_cents,
|
|
393
|
+
eligibleCollectionIds: c.eligible_collection_ids,
|
|
394
|
+
eligibleProductIds: c.eligible_product_ids,
|
|
395
|
+
stackable: c.stackable,
|
|
396
|
+
perCustomerCap: c.per_customer_cap ? { count: c.per_customer_cap.count, window: c.per_customer_cap.window } : null,
|
|
397
|
+
tierGate: c.tier_gate,
|
|
398
|
+
valueCapCents: c.value_cap_cents
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
var RewardsResource = class {
|
|
403
|
+
constructor(http) {
|
|
404
|
+
this.http = http;
|
|
405
|
+
}
|
|
406
|
+
http;
|
|
407
|
+
/**
|
|
408
|
+
* Fetch the public rewards catalogue. `keyScope: 'publishable'` — allowed with a publishable OR
|
|
409
|
+
* a secret key (the one read a browser/publishable client may perform).
|
|
410
|
+
*/
|
|
411
|
+
async list() {
|
|
412
|
+
const wire = await this.http.request("GET", "/rewards", {
|
|
413
|
+
keyScope: "publishable"
|
|
414
|
+
});
|
|
415
|
+
return {
|
|
416
|
+
pointsLabel: wire.points_label,
|
|
417
|
+
data: (wire.data ?? []).map(toRewardOption)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// src/internal/scope.ts
|
|
423
|
+
function assertSecretKey(keyType, message) {
|
|
424
|
+
if (keyType === "secret") return;
|
|
425
|
+
throw new PublishableKeyForbiddenError({ code: "publishable_key_forbidden", message });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/internal/serialize.ts
|
|
429
|
+
function toIso(value) {
|
|
430
|
+
if (value === void 0) return void 0;
|
|
431
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/resources/members.ts
|
|
435
|
+
function toProfile(w) {
|
|
436
|
+
return {
|
|
437
|
+
customerUuid: w.customer_uuid,
|
|
438
|
+
channel: w.channel,
|
|
439
|
+
externalId: w.external_id,
|
|
440
|
+
createdAt: w.created_at
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function toBalance(w) {
|
|
444
|
+
return {
|
|
445
|
+
customerUuid: w.customer_uuid,
|
|
446
|
+
pointsLabel: w.points_label,
|
|
447
|
+
spendableBalance: w.spendable_balance,
|
|
448
|
+
pendingSpendable: w.pending_spendable,
|
|
449
|
+
qualifyingBalance: w.qualifying_balance,
|
|
450
|
+
activeHolds: w.active_holds,
|
|
451
|
+
clawbacksOutstanding: w.clawbacks_outstanding,
|
|
452
|
+
available: w.available
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function toStatus(w) {
|
|
456
|
+
const p = w.qualifying_progress;
|
|
457
|
+
return {
|
|
458
|
+
customerUuid: w.customer_uuid,
|
|
459
|
+
currentTier: w.current_tier ? {
|
|
460
|
+
key: w.current_tier.key,
|
|
461
|
+
name: w.current_tier.name,
|
|
462
|
+
earnMultiplier: w.current_tier.earn_multiplier,
|
|
463
|
+
rewardEligibility: w.current_tier.reward_eligibility
|
|
464
|
+
} : null,
|
|
465
|
+
effectiveTierKey: w.effective_tier_key,
|
|
466
|
+
periodStart: w.period_start,
|
|
467
|
+
periodEnd: w.period_end,
|
|
468
|
+
requalificationState: w.requalification_state,
|
|
469
|
+
graceDeadline: w.grace_deadline,
|
|
470
|
+
qualifyingProgress: {
|
|
471
|
+
current: p.current,
|
|
472
|
+
holdThreshold: p.hold_threshold,
|
|
473
|
+
nextTierKey: p.next_tier_key,
|
|
474
|
+
nextQualifyThreshold: p.next_qualify_threshold
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function toHistoryEntry(w) {
|
|
479
|
+
return {
|
|
480
|
+
id: w.id,
|
|
481
|
+
type: w.type,
|
|
482
|
+
currency: w.currency,
|
|
483
|
+
amount: w.amount,
|
|
484
|
+
state: w.state,
|
|
485
|
+
sourceChannel: w.source_channel,
|
|
486
|
+
sourceRef: w.source_ref,
|
|
487
|
+
sourceEventId: w.source_event_id,
|
|
488
|
+
createdAt: w.created_at
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
var HistoryPager = class {
|
|
492
|
+
constructor(fetchPage) {
|
|
493
|
+
this.fetchPage = fetchPage;
|
|
494
|
+
}
|
|
495
|
+
fetchPage;
|
|
496
|
+
async *[Symbol.asyncIterator]() {
|
|
497
|
+
let cursor;
|
|
498
|
+
for (; ; ) {
|
|
499
|
+
const page = await this.fetchPage(cursor);
|
|
500
|
+
for (const entry of page.data) yield entry;
|
|
501
|
+
const next = page.page.nextCursor;
|
|
502
|
+
if (!page.page.hasMore || next === null || next === void 0) return;
|
|
503
|
+
cursor = next;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
then(onfulfilled, onrejected) {
|
|
507
|
+
return this.fetchPage(void 0).then(onfulfilled, onrejected);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
var MemberHandle = class {
|
|
511
|
+
/** The channel bound to this handle (from the client's `resolveChannel()`). */
|
|
512
|
+
channel;
|
|
513
|
+
/** The merchant-facing external id this handle reads. */
|
|
514
|
+
externalId;
|
|
515
|
+
http;
|
|
516
|
+
keyType;
|
|
517
|
+
redemptions;
|
|
518
|
+
constructor(deps) {
|
|
519
|
+
this.http = deps.http;
|
|
520
|
+
this.keyType = deps.keyType;
|
|
521
|
+
this.channel = deps.channel;
|
|
522
|
+
this.externalId = deps.externalId;
|
|
523
|
+
this.redemptions = deps.redemptions;
|
|
524
|
+
}
|
|
525
|
+
/** The `/customers/:channel/:externalId` path prefix (both segments URL-encoded). */
|
|
526
|
+
base() {
|
|
527
|
+
return `/customers/${encodeURIComponent(this.channel)}/${encodeURIComponent(this.externalId)}`;
|
|
528
|
+
}
|
|
529
|
+
/** Fail fast pre-network when a secret-scope read is attempted with a publishable key. */
|
|
530
|
+
requireSecret() {
|
|
531
|
+
assertSecretKey(
|
|
532
|
+
this.keyType,
|
|
533
|
+
"This member read requires a secret key. The client is configured with a publishable key (catalogue-only). Construct the client with `secretKey` to read a member."
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
/** The pseudonymous member profile (identity + channel binding; no PII). */
|
|
537
|
+
async get() {
|
|
538
|
+
this.requireSecret();
|
|
539
|
+
const wire = await this.http.request("GET", this.base(), {
|
|
540
|
+
keyScope: "secret"
|
|
541
|
+
});
|
|
542
|
+
return toProfile(wire);
|
|
543
|
+
}
|
|
544
|
+
/** The member's balances (spendable / pending / qualifying / holds / available). */
|
|
545
|
+
async balance() {
|
|
546
|
+
this.requireSecret();
|
|
547
|
+
const wire = await this.http.request("GET", `${this.base()}/balance`, {
|
|
548
|
+
keyScope: "secret"
|
|
549
|
+
});
|
|
550
|
+
return toBalance(wire);
|
|
551
|
+
}
|
|
552
|
+
/** The member's tier status + progress toward the next tier. */
|
|
553
|
+
async status() {
|
|
554
|
+
this.requireSecret();
|
|
555
|
+
const wire = await this.http.request("GET", `${this.base()}/status`, {
|
|
556
|
+
keyScope: "secret"
|
|
557
|
+
});
|
|
558
|
+
return toStatus(wire);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The member's points history. Returns a {@link HistoryPager}: iterate it with `for await` to
|
|
562
|
+
* auto-follow the cursor across all pages, or `await` it for just the first {@link HistoryPage}
|
|
563
|
+
* (`.data` / `.page`). `opts.cursor`, if given, is the starting page for the awaited-first-page
|
|
564
|
+
* form; the iterator always starts fresh and follows the cursor from there.
|
|
565
|
+
*/
|
|
566
|
+
history(opts = {}) {
|
|
567
|
+
this.requireSecret();
|
|
568
|
+
const baseQuery = {
|
|
569
|
+
limit: opts.limit,
|
|
570
|
+
type: opts.types && opts.types.length > 0 ? opts.types.join(",") : void 0,
|
|
571
|
+
since: toIso(opts.since),
|
|
572
|
+
until: toIso(opts.until)
|
|
573
|
+
};
|
|
574
|
+
const fetchPage = async (cursor) => {
|
|
575
|
+
const wire = await this.http.request("GET", `${this.base()}/history`, {
|
|
576
|
+
keyScope: "secret",
|
|
577
|
+
query: { ...baseQuery, cursor: cursor ?? opts.cursor }
|
|
578
|
+
});
|
|
579
|
+
return {
|
|
580
|
+
data: (wire.data ?? []).map(toHistoryEntry),
|
|
581
|
+
page: {
|
|
582
|
+
nextCursor: wire.page?.next_cursor ?? null,
|
|
583
|
+
hasMore: wire.page?.has_more ?? false
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
};
|
|
587
|
+
return new HistoryPager(fetchPage);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Place a redemption hold for THIS member (`POST /v1/redemptions`) — sugar over
|
|
591
|
+
* `client.redemptions.place(...)` with the handle's `externalId` + bound channel supplied for you.
|
|
592
|
+
* Returns a {@link RedemptionHandle}: the placed-hold result plus `.finalize({ sourceChannel,
|
|
593
|
+
* sourceRef })` / `.release()` bound to it, so the storefront flow chains cleanly. Secret-scope
|
|
594
|
+
* (delegated to the redemptions resource); a publishable-key client rejects pre-network.
|
|
595
|
+
*/
|
|
596
|
+
async redeem(input, opts) {
|
|
597
|
+
const result = await this.redemptions.place(
|
|
598
|
+
{
|
|
599
|
+
optionId: input.optionId,
|
|
600
|
+
externalId: this.externalId,
|
|
601
|
+
channel: this.channel,
|
|
602
|
+
cartSubtotalCents: input.cartSubtotalCents,
|
|
603
|
+
requestedValueCents: input.requestedValueCents,
|
|
604
|
+
expiresAt: input.expiresAt
|
|
605
|
+
},
|
|
606
|
+
opts
|
|
607
|
+
);
|
|
608
|
+
return this.redemptions.toHandle(result);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// src/resources/customers.ts
|
|
613
|
+
function marketingConsentToWire(mc) {
|
|
614
|
+
const out = {};
|
|
615
|
+
if (mc.email !== void 0) out.email = mc.email;
|
|
616
|
+
if (mc.sms !== void 0) out.sms = mc.sms;
|
|
617
|
+
if (mc.updatedAt !== void 0) out.updated_at = mc.updatedAt;
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
function profileFieldsToWire(fields) {
|
|
621
|
+
const out = {};
|
|
622
|
+
if (fields.email !== void 0) out.email = fields.email;
|
|
623
|
+
if (fields.name !== void 0) out.name = fields.name;
|
|
624
|
+
if (fields.birthday !== void 0) out.birthday = fields.birthday;
|
|
625
|
+
if (fields.locale !== void 0) out.locale = fields.locale;
|
|
626
|
+
if (fields.socialHandles !== void 0) out.social_handles = fields.socialHandles;
|
|
627
|
+
if (fields.marketingConsent !== void 0) {
|
|
628
|
+
out.marketing_consent = fields.marketingConsent === null ? null : marketingConsentToWire(fields.marketingConsent);
|
|
629
|
+
}
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
function toPii(w) {
|
|
633
|
+
const out = { customerUuid: w.customer_uuid };
|
|
634
|
+
if (w.email !== void 0) out.email = w.email;
|
|
635
|
+
if (w.name !== void 0) out.name = w.name;
|
|
636
|
+
if (w.birthday !== void 0) out.birthday = w.birthday;
|
|
637
|
+
if (w.locale !== void 0) out.locale = w.locale;
|
|
638
|
+
if (w.social_handles !== void 0) out.socialHandles = w.social_handles;
|
|
639
|
+
if (w.marketing_consent !== void 0) {
|
|
640
|
+
const mc = w.marketing_consent;
|
|
641
|
+
const consent = {};
|
|
642
|
+
if (mc.email !== void 0) consent.email = mc.email;
|
|
643
|
+
if (mc.sms !== void 0) consent.sms = mc.sms;
|
|
644
|
+
if (mc.updated_at !== void 0) consent.updatedAt = mc.updated_at;
|
|
645
|
+
out.marketingConsent = consent;
|
|
646
|
+
}
|
|
647
|
+
if (w.updated_at !== void 0) out.updatedAt = w.updated_at;
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
var CustomersResource = class {
|
|
651
|
+
http;
|
|
652
|
+
keyType;
|
|
653
|
+
resolveChannel;
|
|
654
|
+
constructor(deps) {
|
|
655
|
+
this.http = deps.http;
|
|
656
|
+
this.keyType = deps.keyType;
|
|
657
|
+
this.resolveChannel = deps.resolveChannel;
|
|
658
|
+
}
|
|
659
|
+
/** Fail fast pre-network when a secret-scope write is attempted with a publishable key. */
|
|
660
|
+
requireSecret() {
|
|
661
|
+
assertSecretKey(
|
|
662
|
+
this.keyType,
|
|
663
|
+
"This customer operation requires a secret key. The client is configured with a publishable key (catalogue-only). Construct the client with `secretKey`."
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Create-or-update a customer profile (`POST /v1/customers`). Returns `created: true` on a create
|
|
668
|
+
* (201) or `false` on an update (200) — natural idempotency keys on the resolved `(channel,
|
|
669
|
+
* externalId)`. The channel is resolved through the client (bound default when omitted, else
|
|
670
|
+
* validated against the allowlist — a PRE-NETWORK throw on an out-of-vocabulary value). Pass
|
|
671
|
+
* `opts.idempotencyKey` to make a retry safe.
|
|
672
|
+
*
|
|
673
|
+
* Not `async` so the channel-allowlist check throws SYNCHRONOUSLY before any transport work.
|
|
674
|
+
*/
|
|
675
|
+
upsert(input, opts = {}) {
|
|
676
|
+
const channel = this.resolveChannel(input.channel);
|
|
677
|
+
return this.sendUpsert(channel, input, opts);
|
|
678
|
+
}
|
|
679
|
+
async sendUpsert(channel, input, opts) {
|
|
680
|
+
this.requireSecret();
|
|
681
|
+
const body = {
|
|
682
|
+
external_id: input.externalId,
|
|
683
|
+
channel,
|
|
684
|
+
...profileFieldsToWire(input)
|
|
685
|
+
};
|
|
686
|
+
const wire = await this.http.request("POST", "/customers", {
|
|
687
|
+
keyScope: "secret",
|
|
688
|
+
body,
|
|
689
|
+
idempotencyKey: opts.idempotencyKey
|
|
690
|
+
});
|
|
691
|
+
return {
|
|
692
|
+
customerUuid: wire.customer_uuid,
|
|
693
|
+
channel: wire.channel,
|
|
694
|
+
externalId: wire.external_id,
|
|
695
|
+
created: wire.created,
|
|
696
|
+
updatedAt: wire.updated_at
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Partial-update a customer's PII (`PATCH /v1/customers/:ref`). `ref` is a customer UUID or an
|
|
701
|
+
* external id (on the `api` channel). A `null` field CLEARS it; an absent field is untouched
|
|
702
|
+
* (merge). Returns the decrypted PII (camelCase). 404 → `CustomerNotFoundError`.
|
|
703
|
+
*/
|
|
704
|
+
async update(ref, fields) {
|
|
705
|
+
this.requireSecret();
|
|
706
|
+
const wire = await this.http.request(
|
|
707
|
+
"PATCH",
|
|
708
|
+
`/customers/${encodeURIComponent(ref)}`,
|
|
709
|
+
{ keyScope: "secret", body: profileFieldsToWire(fields) }
|
|
710
|
+
);
|
|
711
|
+
return toPii(wire);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Read a customer's decrypted PII by UUID (`GET /v1/customers/:uuid`). Secret-only. 404 →
|
|
715
|
+
* `CustomerNotFoundError` (no cross-tenant existence leak).
|
|
716
|
+
*/
|
|
717
|
+
async getPii(uuid) {
|
|
718
|
+
this.requireSecret();
|
|
719
|
+
const wire = await this.http.request(
|
|
720
|
+
"GET",
|
|
721
|
+
`/customers/${encodeURIComponent(uuid)}`,
|
|
722
|
+
{ keyScope: "secret" }
|
|
723
|
+
);
|
|
724
|
+
return toPii(wire);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Manually link a `(channel, externalId)` pair to an existing customer (`POST
|
|
728
|
+
* /v1/customers/:uuid/links`). The TARGET channel is validated against the client's `channels`
|
|
729
|
+
* allowlist PRE-NETWORK (a typo cannot split identity). 404 → `CustomerNotFoundError`.
|
|
730
|
+
*
|
|
731
|
+
* Not `async` so the channel-allowlist check throws SYNCHRONOUSLY before any transport work.
|
|
732
|
+
*/
|
|
733
|
+
linkChannel(uuid, input) {
|
|
734
|
+
const channel = this.resolveChannel(input.channel);
|
|
735
|
+
return this.sendLink(uuid, channel, input.externalId);
|
|
736
|
+
}
|
|
737
|
+
async sendLink(uuid, channel, externalId) {
|
|
738
|
+
this.requireSecret();
|
|
739
|
+
const wire = await this.http.request(
|
|
740
|
+
"POST",
|
|
741
|
+
`/customers/${encodeURIComponent(uuid)}/links`,
|
|
742
|
+
{ keyScope: "secret", body: { channel, external_id: externalId } }
|
|
743
|
+
);
|
|
744
|
+
return {
|
|
745
|
+
customerUuid: wire.customer_uuid,
|
|
746
|
+
linkedVia: wire.linked_via,
|
|
747
|
+
status: wire.status
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Manually unlink a `(channel, externalId)` pair (`DELETE
|
|
752
|
+
* /v1/customers/:uuid/links/:channel/:externalId`). Idempotent. The `:uuid` scopes/authorizes the
|
|
753
|
+
* call; the pair identifies the specific link to remove.
|
|
754
|
+
*/
|
|
755
|
+
async unlinkChannel(uuid, channel, externalId) {
|
|
756
|
+
this.requireSecret();
|
|
757
|
+
const wire = await this.http.request(
|
|
758
|
+
"DELETE",
|
|
759
|
+
`/customers/${encodeURIComponent(uuid)}/links/${encodeURIComponent(channel)}/${encodeURIComponent(externalId)}`,
|
|
760
|
+
{ keyScope: "secret" }
|
|
761
|
+
);
|
|
762
|
+
return { status: wire.status };
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
// src/resources/redemptions.ts
|
|
767
|
+
function toRedemptionResult(w) {
|
|
768
|
+
return {
|
|
769
|
+
redemptionId: w.redemption_id,
|
|
770
|
+
customerUuid: w.customer_uuid,
|
|
771
|
+
optionId: w.option_id,
|
|
772
|
+
amount: w.amount,
|
|
773
|
+
discountValueCents: w.discount_value_cents,
|
|
774
|
+
mechanism: w.mechanism,
|
|
775
|
+
state: w.state,
|
|
776
|
+
expiresAt: w.expires_at ?? null,
|
|
777
|
+
createdAt: w.created_at
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
var RedemptionsResource = class {
|
|
781
|
+
http;
|
|
782
|
+
keyType;
|
|
783
|
+
resolveChannel;
|
|
784
|
+
constructor(deps) {
|
|
785
|
+
this.http = deps.http;
|
|
786
|
+
this.keyType = deps.keyType;
|
|
787
|
+
this.resolveChannel = deps.resolveChannel;
|
|
788
|
+
}
|
|
789
|
+
/** Fail fast pre-network when a secret-scope operation is attempted with a publishable key. */
|
|
790
|
+
requireSecret() {
|
|
791
|
+
assertSecretKey(
|
|
792
|
+
this.keyType,
|
|
793
|
+
"This redemption operation requires a secret key. The client is configured with a publishable key (catalogue-only). Construct the client with `secretKey`."
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Place a redemption hold (`POST /v1/redemptions`). The channel is resolved through the client
|
|
798
|
+
* (bound default when omitted, else validated against the allowlist — a PRE-NETWORK throw on an
|
|
799
|
+
* out-of-vocabulary value). An `Idempotency-Key` is REQUIRED by the API; when `opts.idempotencyKey`
|
|
800
|
+
* is absent the SDK auto-generates a UUID and holds it constant across this call's retries.
|
|
801
|
+
*
|
|
802
|
+
* Not `async` so the channel-allowlist check throws SYNCHRONOUSLY before any transport work.
|
|
803
|
+
*/
|
|
804
|
+
place(input, opts = {}) {
|
|
805
|
+
const channel = this.resolveChannel(input.channel);
|
|
806
|
+
return this.sendPlace(channel, input, opts);
|
|
807
|
+
}
|
|
808
|
+
async sendPlace(channel, input, opts) {
|
|
809
|
+
this.requireSecret();
|
|
810
|
+
const body = {
|
|
811
|
+
option_id: input.optionId,
|
|
812
|
+
customer_channel: channel,
|
|
813
|
+
customer_external_id: input.externalId
|
|
814
|
+
};
|
|
815
|
+
if (input.cartSubtotalCents !== void 0) body.cart_subtotal_cents = input.cartSubtotalCents;
|
|
816
|
+
if (input.requestedValueCents !== void 0)
|
|
817
|
+
body.requested_value_cents = input.requestedValueCents;
|
|
818
|
+
if (input.expiresAt !== void 0) body.expires_at = toIso(input.expiresAt);
|
|
819
|
+
const idempotencyKey = opts.idempotencyKey ?? crypto.randomUUID();
|
|
820
|
+
const wire = await this.http.request("POST", "/redemptions", {
|
|
821
|
+
keyScope: "secret",
|
|
822
|
+
body,
|
|
823
|
+
idempotencyKey
|
|
824
|
+
});
|
|
825
|
+
return toRedemptionResult(wire);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Read a redemption hold's status (`GET /v1/redemptions/:id`). Tenant-scoped: 404 →
|
|
829
|
+
* {@link RedemptionNotFoundError} (no cross-tenant existence leak).
|
|
830
|
+
*/
|
|
831
|
+
async get(id) {
|
|
832
|
+
this.requireSecret();
|
|
833
|
+
const wire = await this.http.request(
|
|
834
|
+
"GET",
|
|
835
|
+
`/redemptions/${encodeURIComponent(id)}`,
|
|
836
|
+
{ keyScope: "secret" }
|
|
837
|
+
);
|
|
838
|
+
return { redemptionId: wire.redemption_id, state: wire.state, amount: wire.amount };
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Finalize a hold — the permanent burn (`POST /v1/redemptions/:id/finalize`). Idempotent on the
|
|
842
|
+
* hold: an already-finalized hold replays; finalizing a RELEASED (terminal) hold →
|
|
843
|
+
* {@link HoldAlreadyReleasedError} (409). `sourceChannel`/`sourceRef` stamp the burn's provenance.
|
|
844
|
+
*/
|
|
845
|
+
async finalize(id, input) {
|
|
846
|
+
this.requireSecret();
|
|
847
|
+
const wire = await this.http.request(
|
|
848
|
+
"POST",
|
|
849
|
+
`/redemptions/${encodeURIComponent(id)}/finalize`,
|
|
850
|
+
{
|
|
851
|
+
keyScope: "secret",
|
|
852
|
+
body: { source_channel: input.sourceChannel, source_ref: input.sourceRef }
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
return { redemptionId: wire.redemption_id, state: "finalized" };
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Release a hold — abandon it and restore the reserved points (`POST /v1/redemptions/:id/release`).
|
|
859
|
+
* Idempotent. Returns the member's `available` after the restore.
|
|
860
|
+
*/
|
|
861
|
+
async release(id) {
|
|
862
|
+
this.requireSecret();
|
|
863
|
+
const wire = await this.http.request(
|
|
864
|
+
"POST",
|
|
865
|
+
`/redemptions/${encodeURIComponent(id)}/release`,
|
|
866
|
+
{ keyScope: "secret" }
|
|
867
|
+
);
|
|
868
|
+
return { redemptionId: wire.redemption_id, state: "released", available: wire.available };
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* @internal Wrap a placed-hold result in a {@link RedemptionHandle} — the same fields plus
|
|
872
|
+
* `.finalize()` / `.release()` bound to this resource + the redemption id. Consumed by
|
|
873
|
+
* `member.redeem(...)`; not part of the documented public surface.
|
|
874
|
+
*/
|
|
875
|
+
toHandle(result) {
|
|
876
|
+
return {
|
|
877
|
+
...result,
|
|
878
|
+
finalize: (input) => this.finalize(result.redemptionId, input),
|
|
879
|
+
release: () => this.release(result.redemptionId)
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
// src/signing.ts
|
|
885
|
+
var SIGNATURE_SCHEME = "v1";
|
|
886
|
+
var SIGNATURE_HEADER = "X-Loyalty-Signature";
|
|
887
|
+
var TIMESTAMP_HEADER = "X-Loyalty-Timestamp";
|
|
888
|
+
var NONCE_HEADER = "X-Loyalty-Nonce";
|
|
889
|
+
function canonicalMessage(timestamp, rawBody) {
|
|
890
|
+
return `${timestamp}.${rawBody}`;
|
|
891
|
+
}
|
|
892
|
+
function webCrypto() {
|
|
893
|
+
const c = globalThis.crypto;
|
|
894
|
+
if (!c || !c.subtle) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
"Web Crypto (globalThis.crypto.subtle) is unavailable in this runtime; @tesseraloyalty/sdk event signing requires Node 20+, an edge/Workers runtime, Deno, or Bun."
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
return c;
|
|
900
|
+
}
|
|
901
|
+
function toHex(bytes) {
|
|
902
|
+
const view = new Uint8Array(bytes);
|
|
903
|
+
let out = "";
|
|
904
|
+
for (let i = 0; i < view.length; i += 1) {
|
|
905
|
+
out += view[i].toString(16).padStart(2, "0");
|
|
906
|
+
}
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
async function hmacSha256Hex(secret, message) {
|
|
910
|
+
const encoder = new TextEncoder();
|
|
911
|
+
const key = await webCrypto().subtle.importKey(
|
|
912
|
+
"raw",
|
|
913
|
+
encoder.encode(secret),
|
|
914
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
915
|
+
false,
|
|
916
|
+
["sign"]
|
|
917
|
+
);
|
|
918
|
+
const signature = await webCrypto().subtle.sign("HMAC", key, encoder.encode(message));
|
|
919
|
+
return toHex(signature);
|
|
920
|
+
}
|
|
921
|
+
async function signEventBody(rawBody, signingSecret, opts = {}) {
|
|
922
|
+
const nowMs = opts.now ? opts.now() : Date.now();
|
|
923
|
+
const timestamp = opts.timestamp !== void 0 ? String(opts.timestamp) : String(Math.floor(nowMs / 1e3));
|
|
924
|
+
const nonce = opts.nonce ?? webCrypto().randomUUID();
|
|
925
|
+
const hex = await hmacSha256Hex(signingSecret, canonicalMessage(timestamp, rawBody));
|
|
926
|
+
const signature = `${SIGNATURE_SCHEME}=${hex}`;
|
|
927
|
+
return {
|
|
928
|
+
signature,
|
|
929
|
+
timestamp,
|
|
930
|
+
nonce,
|
|
931
|
+
headers: {
|
|
932
|
+
"X-Loyalty-Signature": signature,
|
|
933
|
+
"X-Loyalty-Timestamp": timestamp,
|
|
934
|
+
"X-Loyalty-Nonce": nonce
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/resources/events.ts
|
|
940
|
+
var AMOUNT_KEY_MAP = {
|
|
941
|
+
gross: "gross",
|
|
942
|
+
netOfTaxShipping: "net_of_tax_shipping",
|
|
943
|
+
finalAfterDiscounts: "final_after_discounts",
|
|
944
|
+
currency: "currency",
|
|
945
|
+
refundedBase: "refunded_base",
|
|
946
|
+
orderBase: "order_base"
|
|
947
|
+
};
|
|
948
|
+
function amountToWire(amount) {
|
|
949
|
+
const out = {};
|
|
950
|
+
for (const [key, value] of Object.entries(amount)) {
|
|
951
|
+
if (value === void 0) continue;
|
|
952
|
+
out[AMOUNT_KEY_MAP[key] ?? key] = value;
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
function toWireBody(input) {
|
|
957
|
+
const wire = {
|
|
958
|
+
type: input.type,
|
|
959
|
+
channel: input.channel,
|
|
960
|
+
external_id: input.externalId,
|
|
961
|
+
source_ref: input.sourceRef,
|
|
962
|
+
idempotency_key: input.idempotencyKey,
|
|
963
|
+
occurred_at: toIso(input.occurredAt)
|
|
964
|
+
};
|
|
965
|
+
if (input.tenant !== void 0) wire.tenant = input.tenant;
|
|
966
|
+
if (input.trigger !== void 0) wire.trigger = input.trigger;
|
|
967
|
+
if (input.orderRef !== void 0) wire.order_ref = input.orderRef;
|
|
968
|
+
if (input.amount !== void 0) wire.amount = amountToWire(input.amount);
|
|
969
|
+
if (input.email !== void 0) wire.email = input.email;
|
|
970
|
+
if (input.phone !== void 0) wire.phone = input.phone;
|
|
971
|
+
return wire;
|
|
972
|
+
}
|
|
973
|
+
function toResult(w) {
|
|
974
|
+
return {
|
|
975
|
+
eventId: w.event_id,
|
|
976
|
+
customerId: w.customer_id,
|
|
977
|
+
channel: w.channel,
|
|
978
|
+
sourceRef: w.source_ref,
|
|
979
|
+
type: w.type,
|
|
980
|
+
status: w.status,
|
|
981
|
+
customerCreated: w.customer_created,
|
|
982
|
+
linkedVia: w.linked_via,
|
|
983
|
+
enqueued: w.enqueued
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
var EventsResource = class {
|
|
987
|
+
http;
|
|
988
|
+
keyType;
|
|
989
|
+
signingSecret;
|
|
990
|
+
constructor(deps) {
|
|
991
|
+
this.http = deps.http;
|
|
992
|
+
this.keyType = deps.keyType;
|
|
993
|
+
this.signingSecret = deps.signingSecret;
|
|
994
|
+
}
|
|
995
|
+
/** Fail fast pre-network when signed ingestion is attempted with a publishable key. */
|
|
996
|
+
requireSecret() {
|
|
997
|
+
assertSecretKey(
|
|
998
|
+
this.keyType,
|
|
999
|
+
"Event ingestion requires a secret key. The client is configured with a publishable key (catalogue-only). Construct the client with `secretKey`."
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Fail fast pre-network when no signing secret is configured — signing is impossible without the
|
|
1004
|
+
* per-tenant connector secret (`whsec_...`), which is SEPARATE from the bearer API key. Throwing
|
|
1005
|
+
* here (rather than sending an unsigned body the API would 401) gives a clear, specific diagnosis.
|
|
1006
|
+
*/
|
|
1007
|
+
requireSigningSecret() {
|
|
1008
|
+
if (this.signingSecret) return this.signingSecret;
|
|
1009
|
+
throw new TesseraError({
|
|
1010
|
+
code: "config_error",
|
|
1011
|
+
message: "Event ingestion requires a `signingSecret` (the per-tenant connector HMAC secret, `whsec_...`) \u2014 it is separate from the API key. Construct the client with `signingSecret`."
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Ingest a signed event (`POST /v1/events`). SECRET-scope + requires a `signingSecret` (both fail
|
|
1016
|
+
* fast pre-network). Serializes the payload to a raw JSON string ONCE, HMAC-signs THAT string, and
|
|
1017
|
+
* transmits THE SAME string verbatim with the `X-Loyalty-Signature`/`-Timestamp`/`-Nonce` headers —
|
|
1018
|
+
* so the bytes signed are byte-identical to the bytes sent (see the resource doc). Returns the
|
|
1019
|
+
* ingestion result mapped to camelCase (`accepted` 201 / `duplicate` 200).
|
|
1020
|
+
*/
|
|
1021
|
+
async ingest(input, opts = {}) {
|
|
1022
|
+
this.requireSecret();
|
|
1023
|
+
const signingSecret = this.requireSigningSecret();
|
|
1024
|
+
const rawBody = JSON.stringify(toWireBody(input));
|
|
1025
|
+
const signed = await signEventBody(rawBody, signingSecret);
|
|
1026
|
+
const wire = await this.http.request("POST", "/events", {
|
|
1027
|
+
keyScope: "secret",
|
|
1028
|
+
rawBody,
|
|
1029
|
+
// Spread into a fresh object literal so the fixed-key `SignedEventHeaders` interface widens to
|
|
1030
|
+
// the transport's `Record<string, string>` header bag.
|
|
1031
|
+
headers: { ...signed.headers },
|
|
1032
|
+
idempotencyKey: opts.idempotencyKey
|
|
1033
|
+
});
|
|
1034
|
+
return toResult(wire);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
// src/client.ts
|
|
1039
|
+
var DEFAULT_BASE_URL = "https://api.usetessera.io/v1";
|
|
1040
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1041
|
+
var DEFAULT_MAX_RETRIES = 2;
|
|
1042
|
+
var DEFAULT_CHANNEL = "api";
|
|
1043
|
+
var TesseraClient = class _TesseraClient {
|
|
1044
|
+
/** Whether this client authenticates with a secret or publishable key. */
|
|
1045
|
+
keyType;
|
|
1046
|
+
/** The channel bound to customer-scoped operations (default `'api'`). */
|
|
1047
|
+
channel;
|
|
1048
|
+
/** The optional channel allowlist. When set, every channel used must be a member. */
|
|
1049
|
+
channels;
|
|
1050
|
+
/** The per-tenant events signing secret, if configured (used by TER-87). */
|
|
1051
|
+
signingSecret;
|
|
1052
|
+
/** The resolved `/v1` base URL. */
|
|
1053
|
+
baseUrl;
|
|
1054
|
+
/** The per-request timeout in milliseconds. */
|
|
1055
|
+
timeoutMs;
|
|
1056
|
+
/** The maximum retry count on retriable failures. */
|
|
1057
|
+
maxRetries;
|
|
1058
|
+
/**
|
|
1059
|
+
* @internal The shared HTTP transport. Resource groups (later tickets) issue their
|
|
1060
|
+
* requests through this; it is not part of the documented public surface.
|
|
1061
|
+
*/
|
|
1062
|
+
http;
|
|
1063
|
+
constructor(config) {
|
|
1064
|
+
const hasSecret = nonEmpty(config.secretKey);
|
|
1065
|
+
const hasPublishable = nonEmpty(config.publishableKey);
|
|
1066
|
+
if (hasSecret === hasPublishable) {
|
|
1067
|
+
throw new TesseraError({
|
|
1068
|
+
code: "config_error",
|
|
1069
|
+
message: hasSecret ? "Provide exactly one of `secretKey` or `publishableKey` \u2014 not both." : "A `secretKey` or `publishableKey` is required to construct a TesseraClient."
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
this.keyType = hasSecret ? "secret" : "publishable";
|
|
1073
|
+
const token = hasSecret ? config.secretKey : config.publishableKey;
|
|
1074
|
+
this.signingSecret = config.signingSecret;
|
|
1075
|
+
this.channels = config.channels;
|
|
1076
|
+
const channel = config.channel ?? DEFAULT_CHANNEL;
|
|
1077
|
+
assertChannelAllowed(channel, this.channels);
|
|
1078
|
+
this.channel = channel;
|
|
1079
|
+
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
|
1080
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1081
|
+
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1082
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
1083
|
+
if (!fetchImpl) {
|
|
1084
|
+
throw new TesseraError({
|
|
1085
|
+
code: "config_error",
|
|
1086
|
+
message: "No global `fetch` is available in this runtime. Pass a `fetch` implementation in the TesseraClient config."
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
this.http = new HttpCore({
|
|
1090
|
+
baseUrl: this.baseUrl,
|
|
1091
|
+
token,
|
|
1092
|
+
timeoutMs: this.timeoutMs,
|
|
1093
|
+
maxRetries: this.maxRetries,
|
|
1094
|
+
fetchImpl
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Derive a NEW client bound to `channel`, for the rare legitimate multi-channel
|
|
1099
|
+
* case. It SHARES this client's auth, transport (the very same {@link HttpCore}
|
|
1100
|
+
* instance — never rebuilt), signing secret, and `channels` allowlist; the original
|
|
1101
|
+
* is never mutated. Throws pre-network (before any request) if `channel` is outside
|
|
1102
|
+
* the allowlist. `channel` is typed against `Ch`, so a typo fails to compile.
|
|
1103
|
+
*/
|
|
1104
|
+
withChannel(channel) {
|
|
1105
|
+
assertChannelAllowed(channel, this.channels);
|
|
1106
|
+
const scoped = Object.create(_TesseraClient.prototype);
|
|
1107
|
+
scoped.keyType = this.keyType;
|
|
1108
|
+
scoped.channel = channel;
|
|
1109
|
+
scoped.channels = this.channels;
|
|
1110
|
+
scoped.signingSecret = this.signingSecret;
|
|
1111
|
+
scoped.baseUrl = this.baseUrl;
|
|
1112
|
+
scoped.timeoutMs = this.timeoutMs;
|
|
1113
|
+
scoped.maxRetries = this.maxRetries;
|
|
1114
|
+
scoped.http = this.http;
|
|
1115
|
+
return scoped;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The public rewards catalogue resource (`GET /v1/rewards`). Allowed with a publishable OR a
|
|
1119
|
+
* secret key — the one read a storefront/browser client may make. A fresh (stateless) resource
|
|
1120
|
+
* is returned per access so channel-scoped clones (`withChannel`) inherit it for free.
|
|
1121
|
+
*/
|
|
1122
|
+
get rewards() {
|
|
1123
|
+
return new RewardsResource(this.http);
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* The customer/identity WRITE + server-side PII resource (`upsert` / `update` / `getPii` /
|
|
1127
|
+
* `linkChannel` / `unlinkChannel`). Every operation is secret-scope; a publishable-key client
|
|
1128
|
+
* fails fast pre-network. A fresh (stateless) resource is returned per access, wired with the
|
|
1129
|
+
* shared transport + this client's channel resolver so `withChannel` clones inherit it correctly.
|
|
1130
|
+
*/
|
|
1131
|
+
get customers() {
|
|
1132
|
+
return new CustomersResource({
|
|
1133
|
+
http: this.http,
|
|
1134
|
+
keyType: this.keyType,
|
|
1135
|
+
resolveChannel: (channel) => this.resolveChannel(channel)
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* The redemption burn-hold lifecycle resource (`place` / `get` / `finalize` / `release`). Every
|
|
1140
|
+
* operation is secret-scope; a publishable-key client fails fast pre-network. A fresh (stateless)
|
|
1141
|
+
* resource is returned per access, wired with the shared transport + this client's channel
|
|
1142
|
+
* resolver so `withChannel` clones inherit it correctly.
|
|
1143
|
+
*/
|
|
1144
|
+
get redemptions() {
|
|
1145
|
+
return new RedemptionsResource({
|
|
1146
|
+
http: this.http,
|
|
1147
|
+
keyType: this.keyType,
|
|
1148
|
+
resolveChannel: (channel) => this.resolveChannel(channel)
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* The signed event-ingestion resource (`ingest` → `POST /v1/events`). Every ingestion is
|
|
1153
|
+
* SECRET-scope AND requires a `signingSecret` (the per-tenant connector HMAC secret, separate from
|
|
1154
|
+
* the bearer key) — both fail fast pre-network. A fresh (stateless) resource is returned per access,
|
|
1155
|
+
* wired with the shared transport + this client's signing secret so `withChannel` clones inherit it.
|
|
1156
|
+
*/
|
|
1157
|
+
get events() {
|
|
1158
|
+
return new EventsResource({
|
|
1159
|
+
http: this.http,
|
|
1160
|
+
keyType: this.keyType,
|
|
1161
|
+
signingSecret: this.signingSecret
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Open a {@link MemberHandle} for a member identified by `externalId` on this client's bound
|
|
1166
|
+
* channel — the ergonomic `(channel, externalId)`-keyed read + redeem surface (`get` / `balance` /
|
|
1167
|
+
* `status` / `history` / `redeem`). The channel comes from {@link resolveChannel}: omit
|
|
1168
|
+
* `opts.channel` to use the bound channel, or pass `opts.channel` to override it (validated against
|
|
1169
|
+
* the `channels` allowlist, throwing PRE-NETWORK on an out-of-vocabulary value). All member
|
|
1170
|
+
* operations are secret-scope; a publishable-key client fails fast when one is attempted.
|
|
1171
|
+
*/
|
|
1172
|
+
member(externalId, opts) {
|
|
1173
|
+
const channel = this.resolveChannel(opts?.channel);
|
|
1174
|
+
return new MemberHandle({
|
|
1175
|
+
http: this.http,
|
|
1176
|
+
keyType: this.keyType,
|
|
1177
|
+
channel,
|
|
1178
|
+
externalId,
|
|
1179
|
+
redemptions: this.redemptions
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* @internal Resolve the channel for a customer-scoped operation. Called with no
|
|
1184
|
+
* argument it returns the bound channel (the member reads / redeem path). Called
|
|
1185
|
+
* with a `channel` (e.g. the target of `linkChannel`) it validates that value
|
|
1186
|
+
* against the `channels` allowlist and returns it — throwing pre-network on an
|
|
1187
|
+
* out-of-vocabulary channel. Kept internal: resource groups consume it; it is not
|
|
1188
|
+
* part of the documented public surface.
|
|
1189
|
+
*/
|
|
1190
|
+
resolveChannel(channel) {
|
|
1191
|
+
if (channel === void 0) return this.channel;
|
|
1192
|
+
assertChannelAllowed(channel, this.channels);
|
|
1193
|
+
return channel;
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
function nonEmpty(value) {
|
|
1197
|
+
return typeof value === "string" && value.length > 0;
|
|
1198
|
+
}
|
|
1199
|
+
function assertChannelAllowed(channel, allowlist) {
|
|
1200
|
+
if (!allowlist || allowlist.includes(channel)) return;
|
|
1201
|
+
throw new TesseraError({
|
|
1202
|
+
code: "config_error",
|
|
1203
|
+
message: `Channel "${channel}" is not in this client's configured \`channels\` allowlist. Known channels: ${allowlist.join(", ")}.`
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// src/index.ts
|
|
1208
|
+
var VERSION = "0.1.0";
|
|
1209
|
+
var API_VERSION = "v1";
|
|
1210
|
+
//# sourceMappingURL=index.cjs.map
|