@c15t/node-sdk 3.0.0-alpha.2 → 3.0.0-alpha.4

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/dist/index.js CHANGED
@@ -1,397 +1,1064 @@
1
- class C15TError extends Error {
2
- status;
3
- code;
4
- details;
5
- cause;
6
- constructor(options){
7
- super(options.message);
8
- this.name = 'C15TError';
9
- this.status = options.status;
10
- this.code = options.code;
11
- this.details = options.details;
12
- this.cause = options.cause;
13
- if (Error.captureStackTrace) Error.captureStackTrace(this, C15TError);
14
- }
15
- isStatus(status) {
16
- return this.status === status;
17
- }
18
- isNotFound() {
19
- return 404 === this.status || 'NOT_FOUND' === this.code;
1
+ import { checkConsentQuerySchema, experimentSummaryQuerySchema, legalDocumentCurrentInputSchema, legalDocumentCurrentParamsSchema, listSubjectsQuerySchema, patchSubjectInputSchema, postSubjectInputSchema } from "@c15t/schema";
2
+ import { POLICY_CONTRACT_HEADER, POLICY_CONTRACT_VERSION, brandingValues } from "@c15t/schema/types";
3
+ import { C15tError, isStalePolicyReason } from "./160.js";
4
+ const defineEndpoint = (endpoint)=>endpoint;
5
+ const checkSchema = async (schema, value, prefix = [])=>{
6
+ const result = await schema['~standard'].validate(value);
7
+ return (result.issues ?? []).map((issue)=>({
8
+ message: issue.message,
9
+ path: [
10
+ ...prefix,
11
+ ...(issue.path ?? []).map((segment)=>'object' == typeof segment ? segment.key : segment)
12
+ ]
13
+ }));
14
+ };
15
+ const requireText = (value, path)=>'string' == typeof value && '' !== value.trim() ? [] : [
16
+ {
17
+ message: 'Expected a non-empty string.',
18
+ path
19
+ }
20
+ ];
21
+ const requirePathSegment = (value, path)=>{
22
+ const issues = requireText(value, path);
23
+ if (issues.length > 0) return issues;
24
+ return '.' === value || '..' === value ? [
25
+ {
26
+ message: 'Expected an id, not a dot path segment.',
27
+ path
28
+ }
29
+ ] : [];
30
+ };
31
+ const optionalText = (value, path)=>void 0 === value ? [] : requireText(value, path);
32
+ const checkTypeList = (value, path, { required })=>{
33
+ if (void 0 === value && !required) return [];
34
+ if (!Array.isArray(value) || required && 0 === value.length) return [
35
+ {
36
+ message: required ? 'Expected a non-empty array of policy types.' : 'Expected an array of policy types.',
37
+ path
38
+ }
39
+ ];
40
+ return value.flatMap((entry, index)=>'string' != typeof entry || '' === entry || entry.includes(',') ? [
41
+ {
42
+ message: 'Expected a policy type without commas.',
43
+ path: [
44
+ ...path,
45
+ index
46
+ ]
47
+ }
48
+ ] : []);
49
+ };
50
+ const ISO_DATE_OR_TIMESTAMP = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2}))?$/u;
51
+ const toIsoString = (value)=>value instanceof Date ? value.toISOString() : value;
52
+ const checkDateLike = (value, path, { required })=>{
53
+ if (void 0 === value && !required) return [];
54
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? [
55
+ {
56
+ message: 'Expected a valid Date.',
57
+ path
58
+ }
59
+ ] : [];
60
+ return 'string' == typeof value && ISO_DATE_OR_TIMESTAMP.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value.slice(0, 10)).toISOString().slice(0, 10) === value.slice(0, 10) ? [] : [
61
+ {
62
+ message: 'Expected a Date or an ISO 8601 string.',
63
+ path
64
+ }
65
+ ];
66
+ };
67
+ const isObject = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
68
+ const requireObject = (value, path)=>isObject(value) ? [] : [
69
+ {
70
+ message: 'Expected an object.',
71
+ path
72
+ }
73
+ ];
74
+ class DecodeError extends Error {
75
+ name = 'DecodeError';
76
+ }
77
+ const asObject = (value, field)=>{
78
+ if (!isObject(value)) throw new DecodeError(`Expected ${field} to be an object.`);
79
+ return value;
80
+ };
81
+ const asArray = (value, field)=>{
82
+ if (!Array.isArray(value)) throw new DecodeError(`Expected ${field} to be an array.`);
83
+ return value;
84
+ };
85
+ const toDate = (value, field)=>{
86
+ if ('string' == typeof value || 'number' == typeof value) {
87
+ const date = new Date(value);
88
+ if (!Number.isNaN(date.getTime())) return date;
20
89
  }
21
- isValidationError() {
22
- return 400 === this.status || 'VALIDATION_ERROR' === this.code;
90
+ throw new DecodeError(`Expected ${field} to be a date.`);
91
+ };
92
+ const decodeConsentItem = (value, field)=>{
93
+ const item = asObject(value, field);
94
+ const decoded = {
95
+ ...item,
96
+ givenAt: toDate(item.givenAt, `${field}.givenAt`)
97
+ };
98
+ if (null === item.policyEffectiveDate) delete decoded.policyEffectiveDate;
99
+ else if (void 0 !== item.policyEffectiveDate) decoded.policyEffectiveDate = toDate(item.policyEffectiveDate, `${field}.policyEffectiveDate`);
100
+ return decoded;
101
+ };
102
+ const decodeConsents = (value, field)=>asArray(value, field).map((item, index)=>decodeConsentItem(item, `${field}[${index}]`));
103
+ const NO_ISSUES = [];
104
+ const noValidation = ()=>Promise.resolve(NO_ISSUES);
105
+ const contract_status = defineEndpoint({
106
+ auth: 'none',
107
+ decode: (body)=>{
108
+ const value = asObject(body, 'body');
109
+ return {
110
+ ...value,
111
+ timestamp: toDate(value.timestamp, 'timestamp')
112
+ };
113
+ },
114
+ errorCodes: [
115
+ 'SERVICE_UNAVAILABLE'
116
+ ],
117
+ idempotent: true,
118
+ method: 'GET',
119
+ name: 'status',
120
+ path: '/status',
121
+ prepare: (_input)=>({
122
+ params: {}
123
+ }),
124
+ validate: noValidation
125
+ });
126
+ const contract_init = defineEndpoint({
127
+ auth: 'none',
128
+ decode: (body)=>asObject(body, 'body'),
129
+ errorCodes: [],
130
+ idempotent: true,
131
+ method: 'GET',
132
+ name: 'init',
133
+ path: '/init',
134
+ prepare: (input)=>{
135
+ const headers = {};
136
+ if (input?.language !== void 0) headers['accept-language'] = input.language;
137
+ if (input?.country !== void 0) headers['x-c15t-country'] = input.country;
138
+ if (input?.region !== void 0) headers['x-c15t-region'] = input.region;
139
+ if (input?.gpc !== void 0) headers['x-c15t-gpc'] = input.gpc ? '1' : '0';
140
+ return {
141
+ headers,
142
+ params: {}
143
+ };
144
+ },
145
+ validate: (input)=>{
146
+ if (void 0 === input) return Promise.resolve(NO_ISSUES);
147
+ if (!isObject(input)) return Promise.resolve(requireObject(input, []));
148
+ const issues = [
149
+ ...optionalText(input.language, [
150
+ 'language'
151
+ ]),
152
+ ...optionalText(input.country, [
153
+ 'country'
154
+ ]),
155
+ ...optionalText(input.region, [
156
+ 'region'
157
+ ]),
158
+ ...void 0 === input.gpc || 'boolean' == typeof input.gpc ? [] : [
159
+ {
160
+ message: 'Expected a boolean.',
161
+ path: [
162
+ 'gpc'
163
+ ]
164
+ }
165
+ ]
166
+ ];
167
+ return Promise.resolve(issues);
23
168
  }
24
- isUnauthorized() {
25
- return 401 === this.status || 'UNAUTHORIZED' === this.code;
169
+ });
170
+ const manifest = defineEndpoint({
171
+ accepts: (code)=>code >= 200 && code < 300 || 304 === code,
172
+ auth: 'none',
173
+ decode: (body, response)=>{
174
+ const etag = response.headers.get('etag') ?? void 0;
175
+ if (304 === response.status) return {
176
+ etag,
177
+ status: 'not-modified'
178
+ };
179
+ const value = asObject(body, 'body');
180
+ if (2 !== value.schemaVersion || 'string' != typeof value.revision || !brandingValues.some((branding)=>branding === value.branding)) throw new DecodeError('Expected manifest schemaVersion 2, a string revision and a supported branding value.');
181
+ return {
182
+ etag,
183
+ manifest: value,
184
+ status: 'modified'
185
+ };
186
+ },
187
+ errorCodes: [],
188
+ idempotent: true,
189
+ method: 'GET',
190
+ name: 'manifest',
191
+ path: '/manifest',
192
+ prepare: (input)=>{
193
+ const headers = {};
194
+ if (input?.ifNoneMatch !== void 0) headers['if-none-match'] = input.ifNoneMatch;
195
+ return {
196
+ headers,
197
+ params: {},
198
+ query: {
199
+ language: input?.language
200
+ }
201
+ };
202
+ },
203
+ validate: (input)=>{
204
+ if (void 0 === input) return Promise.resolve(NO_ISSUES);
205
+ if (!isObject(input)) return Promise.resolve(requireObject(input, []));
206
+ return Promise.resolve([
207
+ ...optionalText(input.language, [
208
+ 'language'
209
+ ]),
210
+ ...optionalText(input.ifNoneMatch, [
211
+ 'ifNoneMatch'
212
+ ])
213
+ ]);
26
214
  }
27
- isForbidden() {
28
- return 403 === this.status || 'FORBIDDEN' === this.code;
215
+ });
216
+ const getSubject = defineEndpoint({
217
+ auth: 'none',
218
+ decode: (body)=>{
219
+ const value = asObject(body, 'body');
220
+ const subject = {
221
+ ...asObject(value.subject, 'subject')
222
+ };
223
+ if (null === subject.createdAt) delete subject.createdAt;
224
+ else if (void 0 !== subject.createdAt) subject.createdAt = toDate(subject.createdAt, 'subject.createdAt');
225
+ return {
226
+ ...value,
227
+ consents: decodeConsents(value.consents, 'consents'),
228
+ subject
229
+ };
230
+ },
231
+ errorCodes: [
232
+ 'DATABASE_ERROR',
233
+ 'NOT_FOUND'
234
+ ],
235
+ idempotent: true,
236
+ method: 'GET',
237
+ name: 'subjects.get',
238
+ path: '/subjects/:id',
239
+ prepare: (input)=>({
240
+ params: {
241
+ id: input.id
242
+ },
243
+ query: {
244
+ type: input.request?.types?.join(',')
245
+ }
246
+ }),
247
+ validate: (input)=>{
248
+ if (void 0 !== input.request && !isObject(input.request)) return Promise.resolve(requireObject(input.request, [
249
+ 'options'
250
+ ]));
251
+ return Promise.resolve([
252
+ ...requirePathSegment(input.id, [
253
+ 'id'
254
+ ]),
255
+ ...checkTypeList(input.request?.types, [
256
+ 'types'
257
+ ], {
258
+ required: false
259
+ })
260
+ ]);
29
261
  }
30
- isServerError() {
31
- return this.status >= 500 && this.status < 600;
262
+ });
263
+ const listSubjects = defineEndpoint({
264
+ auth: 'api-key',
265
+ decode: (body)=>{
266
+ const value = asObject(body, 'body');
267
+ return {
268
+ ...value,
269
+ subjects: asArray(value.subjects, 'subjects').map((entry, index)=>{
270
+ const subject = asObject(entry, `subjects[${index}]`);
271
+ return {
272
+ ...subject,
273
+ consents: decodeConsents(subject.consents, `subjects[${index}].consents`),
274
+ createdAt: toDate(subject.createdAt, `subjects[${index}].createdAt`)
275
+ };
276
+ })
277
+ };
278
+ },
279
+ errorCodes: [
280
+ 'DATABASE_ERROR',
281
+ 'EXTERNAL_ID_REQUIRED',
282
+ 'UNAUTHORIZED'
283
+ ],
284
+ idempotent: true,
285
+ method: 'GET',
286
+ name: 'subjects.list',
287
+ path: '/subjects',
288
+ prepare: (input)=>({
289
+ params: {},
290
+ query: {
291
+ externalId: input.externalId
292
+ }
293
+ }),
294
+ validate: async (input)=>isObject(input) ? [
295
+ ...requireText(input.externalId, [
296
+ 'externalId'
297
+ ]),
298
+ ...await checkSchema(listSubjectsQuerySchema, input)
299
+ ] : requireObject(input, [])
300
+ });
301
+ const toWireSubject = (input)=>({
302
+ ...input,
303
+ givenAt: input.givenAt instanceof Date ? input.givenAt.getTime() : input.givenAt
304
+ });
305
+ const createSubject = defineEndpoint({
306
+ auth: 'none',
307
+ decode: (body)=>{
308
+ const { ok: _ok, ...value } = asObject(body, 'body');
309
+ return {
310
+ ...value,
311
+ givenAt: toDate(value.givenAt, 'givenAt')
312
+ };
313
+ },
314
+ errorCodes: [
315
+ 'CHOICE_OUT_OF_SCOPE',
316
+ 'CHOICE_PREFERENCE_MISMATCH',
317
+ 'CONFLICT',
318
+ 'DATABASE_ERROR',
319
+ 'INPUT_VALIDATION_FAILED',
320
+ 'POLICY_SNAPSHOT_EXPIRED',
321
+ 'POLICY_SNAPSHOT_INVALID',
322
+ 'POLICY_SNAPSHOT_REQUIRED',
323
+ 'PURPOSE_NOT_ALLOWED',
324
+ 'STALE_POLICY',
325
+ 'SUBJECT_CONFLICT'
326
+ ],
327
+ idempotent: true,
328
+ method: 'POST',
329
+ name: 'subjects.create',
330
+ path: '/subjects',
331
+ prepare: (input)=>({
332
+ body: toWireSubject(input),
333
+ params: {}
334
+ }),
335
+ validate: (input)=>isObject(input) ? checkSchema(postSubjectInputSchema, toWireSubject(input)) : Promise.resolve(requireObject(input, []))
336
+ });
337
+ const identifySubject = defineEndpoint({
338
+ auth: 'none',
339
+ decode: (body)=>{
340
+ const value = asObject(body, 'body');
341
+ asObject(value.subject, 'subject');
342
+ return value;
343
+ },
344
+ errorCodes: [
345
+ 'DATABASE_ERROR',
346
+ 'EXTERNAL_ID_REQUIRED',
347
+ 'NOT_FOUND'
348
+ ],
349
+ idempotent: true,
350
+ method: 'PATCH',
351
+ name: 'subjects.identify',
352
+ path: '/subjects/:id',
353
+ prepare: (input)=>({
354
+ body: input.input,
355
+ params: {
356
+ id: input.id
357
+ }
358
+ }),
359
+ validate: async (input)=>[
360
+ ...requirePathSegment(input.id, [
361
+ 'id'
362
+ ]),
363
+ ...isObject(input.input) ? [
364
+ ...requireText(input.input.externalId, [
365
+ 'externalId'
366
+ ]),
367
+ ...await checkSchema(patchSubjectInputSchema, input.input)
368
+ ] : requireObject(input.input, [])
369
+ ]
370
+ });
371
+ const checkConsent = defineEndpoint({
372
+ auth: 'none',
373
+ decode: (body, _response, input)=>{
374
+ const value = asObject(body, 'body');
375
+ const results = asObject(value.results, 'results');
376
+ for (const type of new Set([
377
+ ...Object.keys(results),
378
+ ...input.types
379
+ ])){
380
+ const result = asObject(results[type], `results.${type}`);
381
+ if ('boolean' != typeof result.hasConsent || 'boolean' != typeof result.isLatestPolicy) throw new DecodeError(`Expected results.${type}.hasConsent and isLatestPolicy to be booleans.`);
382
+ }
383
+ return value;
384
+ },
385
+ errorCodes: [
386
+ 'DATABASE_ERROR',
387
+ 'EXTERNAL_ID_REQUIRED',
388
+ 'TYPE_REQUIRED'
389
+ ],
390
+ idempotent: true,
391
+ method: 'GET',
392
+ name: 'consents.check',
393
+ path: '/consents/check',
394
+ prepare: (input)=>({
395
+ params: {},
396
+ query: {
397
+ externalId: input.externalId,
398
+ type: input.types.join(',')
399
+ }
400
+ }),
401
+ validate: (input)=>{
402
+ if (!isObject(input)) return Promise.resolve(requireObject(input, []));
403
+ const issues = [
404
+ ...requireText(input.externalId, [
405
+ 'externalId'
406
+ ]),
407
+ ...checkTypeList(input.types, [
408
+ 'types'
409
+ ], {
410
+ required: true
411
+ })
412
+ ];
413
+ if (issues.length > 0) return Promise.resolve(issues);
414
+ return checkSchema(checkConsentQuerySchema, {
415
+ externalId: input.externalId,
416
+ type: input.types.join(',')
417
+ });
32
418
  }
33
- isNetworkError() {
34
- return 0 === this.status || 'NETWORK_ERROR' === this.code;
419
+ });
420
+ const toSummaryQuery = (request)=>({
421
+ domain: request?.domain,
422
+ from: request?.from === void 0 ? void 0 : toIsoString(request.from),
423
+ to: request?.to === void 0 ? void 0 : toIsoString(request.to)
424
+ });
425
+ const experimentSummary = defineEndpoint({
426
+ auth: 'api-key',
427
+ decode: (body)=>{
428
+ const value = asObject(body, 'body');
429
+ asArray(value.arms, 'arms');
430
+ return value;
431
+ },
432
+ errorCodes: [
433
+ 'DATABASE_ERROR',
434
+ 'INPUT_VALIDATION_FAILED',
435
+ 'UNAUTHORIZED'
436
+ ],
437
+ idempotent: true,
438
+ method: 'GET',
439
+ name: 'experiments.summary',
440
+ path: '/experiments/:id/summary',
441
+ prepare: (input)=>({
442
+ params: {
443
+ id: input.id
444
+ },
445
+ query: toSummaryQuery(input.request)
446
+ }),
447
+ validate: (input)=>{
448
+ if (void 0 !== input.request && !isObject(input.request)) return Promise.resolve(requireObject(input.request, [
449
+ 'options'
450
+ ]));
451
+ const issues = [
452
+ ...requirePathSegment(input.id, [
453
+ 'id'
454
+ ]),
455
+ ...checkDateLike(input.request?.from, [
456
+ 'from'
457
+ ], {
458
+ required: false
459
+ }),
460
+ ...checkDateLike(input.request?.to, [
461
+ 'to'
462
+ ], {
463
+ required: false
464
+ })
465
+ ];
466
+ if (issues.length > 0) return Promise.resolve(issues);
467
+ const query = Object.fromEntries(Object.entries(toSummaryQuery(input.request)).filter(([, value])=>void 0 !== value));
468
+ return checkSchema(experimentSummaryQuerySchema, query);
35
469
  }
36
- toJSON() {
470
+ });
471
+ const publishLegalDocument = defineEndpoint({
472
+ auth: 'api-key',
473
+ decode: (body)=>{
474
+ const value = asObject(body, 'body');
475
+ const policy = asObject(value.policy, 'policy');
37
476
  return {
38
- code: this.code,
39
- details: this.details,
40
- message: this.message,
41
- name: this.name,
42
- status: this.status
477
+ ...value,
478
+ policy: {
479
+ ...policy,
480
+ effectiveDate: toDate(policy.effectiveDate, 'policy.effectiveDate')
481
+ }
43
482
  };
483
+ },
484
+ errorCodes: [
485
+ 'CONFLICT',
486
+ 'DATABASE_ERROR',
487
+ 'INPUT_VALIDATION_FAILED',
488
+ 'UNAUTHORIZED'
489
+ ],
490
+ idempotent: true,
491
+ method: 'PUT',
492
+ name: 'legalDocuments.publish',
493
+ path: '/legal-documents/:type/current',
494
+ prepare: (input)=>({
495
+ body: {
496
+ effectiveDate: toIsoString(input.input.effectiveDate),
497
+ hash: input.input.hash,
498
+ version: input.input.version
499
+ },
500
+ params: {
501
+ type: input.type
502
+ }
503
+ }),
504
+ validate: async (input)=>{
505
+ if (!isObject(input.input)) return requireObject(input.input, []);
506
+ const dateIssues = checkDateLike(input.input.effectiveDate, [
507
+ 'effectiveDate'
508
+ ], {
509
+ required: true
510
+ });
511
+ return [
512
+ ...await checkSchema(legalDocumentCurrentParamsSchema, {
513
+ type: input.type
514
+ }, []),
515
+ ...requireText(input.input.version, [
516
+ 'version'
517
+ ]),
518
+ ...requireText(input.input.hash, [
519
+ 'hash'
520
+ ]),
521
+ ...dateIssues,
522
+ ...dateIssues.length > 0 ? [] : await checkSchema(legalDocumentCurrentInputSchema, {
523
+ effectiveDate: toIsoString(input.input.effectiveDate),
524
+ hash: input.input.hash,
525
+ version: input.input.version
526
+ })
527
+ ];
528
+ }
529
+ });
530
+ const endpoints = {
531
+ checkConsent: checkConsent,
532
+ createSubject: createSubject,
533
+ experimentSummary: experimentSummary,
534
+ getSubject: getSubject,
535
+ identifySubject: identifySubject,
536
+ init: contract_init,
537
+ listSubjects: listSubjects,
538
+ manifest: manifest,
539
+ publishLegalDocument: publishLegalDocument,
540
+ status: contract_status
541
+ };
542
+ class C15tConfigurationError extends Error {
543
+ name = 'C15tConfigurationError';
544
+ issues;
545
+ constructor(issues){
546
+ const count = issues.length;
547
+ super([
548
+ `Invalid c15t client options: ${count} ${1 === count ? 'problem' : 'problems'}.`,
549
+ ...issues.map((issue)=>`- ${issue.option}: ${issue.message}`)
550
+ ].join('\n'));
551
+ this.issues = issues;
44
552
  }
45
553
  }
46
- const error_isC15TError = function(error) {
47
- return error instanceof C15TError;
554
+ const DEFAULT_TIMEOUT_MS = 10000;
555
+ const MAX_RETRIES_LIMIT = 10;
556
+ const MAX_TIMER_DELAY_MS = 2147483647;
557
+ const NO_RETRY = {
558
+ initialDelayMs: 0,
559
+ maxDelayMs: 0,
560
+ maxRetries: 0
48
561
  };
49
- const version = '3.0.0-alpha.2';
50
- const C15T_VERSION_HEADERS = {
51
- 'x-c15t-version': version
562
+ const DEFAULT_RETRY = {
563
+ initialDelayMs: 200,
564
+ maxDelayMs: 5000,
565
+ maxRetries: 2
52
566
  };
53
- const DEFAULT_RETRY_CONFIG = {
54
- backoffFactor: 2,
55
- initialDelayMs: 100,
56
- maxRetries: 3,
57
- nonRetryableStatusCodes: [
58
- 400,
59
- 401,
60
- 403,
61
- 404
62
- ],
63
- retryOnNetworkError: true,
64
- retryableStatusCodes: [
65
- 500,
66
- 502,
67
- 503,
68
- 504
69
- ]
567
+ const LOOPBACK_HOSTS = new Set([
568
+ 'localhost',
569
+ '127.0.0.1',
570
+ '[::1]'
571
+ ]);
572
+ const isRecord = (value)=>'object' == typeof value && null !== value && !Array.isArray(value);
573
+ const isNonNegativeInteger = (value)=>'number' == typeof value && Number.isInteger(value) && value >= 0;
574
+ const isTimerDelay = (value)=>isNonNegativeInteger(value) && value <= MAX_TIMER_DELAY_MS;
575
+ const isTimeout = (value)=>isTimerDelay(value) && value > 0;
576
+ const decodeBaseUrl = (value, hasApiKey, issues)=>{
577
+ if ('string' != typeof value && !(value instanceof URL)) return void issues.push({
578
+ message: 'Pass the backend URL as a string or URL.',
579
+ option: 'baseUrl'
580
+ });
581
+ let url;
582
+ try {
583
+ url = new URL(value);
584
+ } catch {
585
+ issues.push({
586
+ message: `"${String(value)}" is not an absolute URL. Include the scheme, such as https://example.com/api/c15t.`,
587
+ option: 'baseUrl'
588
+ });
589
+ return;
590
+ }
591
+ if ('https:' !== url.protocol && 'http:' !== url.protocol) return void issues.push({
592
+ message: `Use an http: or https: URL, not ${url.protocol}.`,
593
+ option: 'baseUrl'
594
+ });
595
+ if ('' !== url.search || '' !== url.hash) return void issues.push({
596
+ message: 'Remove the query string and fragment from the base URL.',
597
+ option: 'baseUrl'
598
+ });
599
+ if (hasApiKey && 'http:' === url.protocol && !LOOPBACK_HOSTS.has(url.hostname)) return void issues.push({
600
+ message: 'Use https: when sending an API key. Plain http: is accepted only for localhost, 127.0.0.1 and [::1], because anywhere else the key travels in the clear.',
601
+ option: 'baseUrl'
602
+ });
603
+ if (!url.pathname.endsWith('/')) url.pathname = `${url.pathname}/`;
604
+ return url;
70
605
  };
71
- const fetcher_debugLog = function(debug, method, path, durationMs, status) {
72
- if (!debug) return;
73
- const timestamp = new Date().toISOString();
74
- console.log(`[c15t] ${timestamp} ${method} ${path} (${durationMs}ms) -> ${status}`);
606
+ const decodeHeaders = (value, option, issues)=>{
607
+ if (void 0 === value) return {};
608
+ if (!isRecord(value) || !Object.values(value).every((entry)=>'string' == typeof entry)) {
609
+ issues.push({
610
+ message: 'Pass headers as an object of string values.',
611
+ option
612
+ });
613
+ return {};
614
+ }
615
+ return value;
616
+ };
617
+ const options_decodeRetry = function(value, option, issues, fallback = DEFAULT_RETRY) {
618
+ if (void 0 === value) return fallback;
619
+ if (false === value) return NO_RETRY;
620
+ if (!isRecord(value)) {
621
+ issues.push({
622
+ message: 'Pass retry options as an object, or false to disable retries.',
623
+ option
624
+ });
625
+ return fallback;
626
+ }
627
+ const base = fallback === NO_RETRY ? DEFAULT_RETRY : fallback;
628
+ const maxRetries = value.maxRetries ?? base.maxRetries;
629
+ const initialDelayMs = value.initialDelayMs ?? base.initialDelayMs;
630
+ const maxDelayMs = value.maxDelayMs ?? base.maxDelayMs;
631
+ let valid = true;
632
+ if (!isNonNegativeInteger(maxRetries) || maxRetries > MAX_RETRIES_LIMIT) {
633
+ issues.push({
634
+ message: `Use an integer from 0 to ${MAX_RETRIES_LIMIT}.`,
635
+ option: `${option}.maxRetries`
636
+ });
637
+ valid = false;
638
+ }
639
+ if (!isTimerDelay(initialDelayMs)) {
640
+ issues.push({
641
+ message: `Use an integer from 0 to ${MAX_TIMER_DELAY_MS} milliseconds.`,
642
+ option: `${option}.initialDelayMs`
643
+ });
644
+ valid = false;
645
+ }
646
+ if (!isTimerDelay(maxDelayMs)) {
647
+ issues.push({
648
+ message: `Use an integer from 0 to ${MAX_TIMER_DELAY_MS} milliseconds.`,
649
+ option: `${option}.maxDelayMs`
650
+ });
651
+ valid = false;
652
+ }
653
+ if (!valid) return fallback;
654
+ return {
655
+ initialDelayMs: initialDelayMs,
656
+ maxDelayMs: maxDelayMs,
657
+ maxRetries: maxRetries
658
+ };
75
659
  };
76
- const delay = (ms)=>new Promise((resolve)=>{
77
- setTimeout(resolve, ms);
660
+ const options_decodeOptions = function(options) {
661
+ const issues = [];
662
+ if (!isRecord(options)) throw new C15tConfigurationError([
663
+ {
664
+ message: 'Pass an options object with at least baseUrl.',
665
+ option: 'options'
666
+ }
667
+ ]);
668
+ let apiKey;
669
+ if (void 0 !== options.apiKey) if ('string' == typeof options.apiKey && '' !== options.apiKey.trim()) ({ apiKey } = options);
670
+ else issues.push({
671
+ message: 'Pass the API key as a non-empty string, or leave it out for a client without key-only methods.',
672
+ option: 'apiKey'
673
+ });
674
+ const baseUrl = decodeBaseUrl(options.baseUrl, void 0 !== options.apiKey, issues);
675
+ const fetchImpl = options.fetch ?? globalThis.fetch;
676
+ if ('function' != typeof fetchImpl) issues.push({
677
+ message: 'Pass a fetch function. This runtime has no global fetch, so it must be provided.',
678
+ option: 'fetch'
78
679
  });
79
- const fetcher_generateUUID = function() {
80
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/gu, (c)=>{
81
- const r = Math.floor(16 * Math.random());
82
- const v = 'x' === c ? r : r % 4 + 8;
83
- return v.toString(16);
680
+ const headers = decodeHeaders(options.headers, 'headers', issues);
681
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
682
+ if (!isTimeout(timeoutMs)) issues.push({
683
+ message: `Use an integer from 1 to ${MAX_TIMER_DELAY_MS} milliseconds.`,
684
+ option: 'timeoutMs'
84
685
  });
686
+ const retry = options_decodeRetry(options.retry, 'retry', issues);
687
+ if (void 0 !== options.onEvent && 'function' != typeof options.onEvent) issues.push({
688
+ message: 'Pass a function.',
689
+ option: 'onEvent'
690
+ });
691
+ if (issues.length > 0 || void 0 === baseUrl) throw new C15tConfigurationError(issues);
692
+ return {
693
+ apiKey,
694
+ baseUrl,
695
+ fetch: (input, init)=>fetchImpl(input, init),
696
+ headers,
697
+ onEvent: options.onEvent,
698
+ retry,
699
+ timeoutMs: timeoutMs
700
+ };
85
701
  };
86
- const fetcher_createResponseContext = function createResponseContext(isSuccess, data = null, error = null, response = null) {
702
+ const options_decodeCallOptions = function(value, client, issues) {
703
+ const options = isRecord(value) ? value : {};
704
+ if (void 0 !== value && !isRecord(value)) issues.push({
705
+ message: 'Pass call options as an object.',
706
+ option: 'options'
707
+ });
708
+ const { signal } = options;
709
+ if (void 0 !== signal && !(signal instanceof AbortSignal)) issues.push({
710
+ message: 'Pass an AbortSignal.',
711
+ option: 'options.signal'
712
+ });
713
+ const timeoutMs = options.timeoutMs ?? client.timeoutMs;
714
+ if (!isTimeout(timeoutMs)) issues.push({
715
+ message: `Use an integer from 1 to ${MAX_TIMER_DELAY_MS} milliseconds.`,
716
+ option: 'options.timeoutMs'
717
+ });
718
+ const requestId = options.requestId ?? crypto.randomUUID();
719
+ if ('string' != typeof requestId || '' === requestId) issues.push({
720
+ message: 'Use a non-empty string.',
721
+ option: 'options.requestId'
722
+ });
87
723
  return {
88
- data,
89
- error,
90
- expect (message) {
91
- if (!isSuccess || null === data) throw new C15TError({
92
- cause: error?.cause,
93
- code: error?.code,
94
- details: error?.details,
95
- message,
96
- status: error?.status || 0
97
- });
98
- return data;
99
- },
100
- map (fn) {
101
- if (!isSuccess || null === data) return createResponseContext(false, null, error, response);
102
- return createResponseContext(true, fn(data), null, response);
103
- },
104
- ok: isSuccess,
105
- response,
106
- unwrap () {
107
- if (!isSuccess || null === data) throw new C15TError({
108
- cause: error?.cause,
109
- code: error?.code,
110
- details: error?.details,
111
- message: error?.message || 'Request failed',
112
- status: error?.status || 0
113
- });
114
- return data;
724
+ headers: {
725
+ ...client.headers,
726
+ ...decodeHeaders(options.headers, 'options.headers', issues)
115
727
  },
116
- unwrapOr (defaultValue) {
117
- if (!isSuccess || null === data) return defaultValue;
118
- return data;
119
- }
728
+ requestId: 'string' == typeof requestId ? requestId : '',
729
+ retry: options_decodeRetry(options.retry, 'options.retry', issues, client.retry),
730
+ signal: signal instanceof AbortSignal ? signal : void 0,
731
+ timeoutMs: isTimeout(timeoutMs) ? timeoutMs : client.timeoutMs
120
732
  };
121
733
  };
122
- const fetcher_resolveUrl = function(baseUrl, path) {
123
- const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
124
- const cleanPath = path.startsWith('/') ? path.slice(1) : path;
125
- return `${cleanBase}/${cleanPath}`;
734
+ const path_buildPath = function(template, params) {
735
+ const values = params;
736
+ return template.replace(/:(?<name>[A-Za-z]+)/gu, (_match, name)=>encodeURIComponent(values[name] ?? ''));
737
+ };
738
+ const version = '3.0.0-alpha.4';
739
+ const RETRYABLE_STATUSES = new Set([
740
+ 408,
741
+ 429,
742
+ 500,
743
+ 502,
744
+ 503,
745
+ 504
746
+ ]);
747
+ const ownedHeaders = (options, requestId, hasBody)=>{
748
+ const headers = {
749
+ accept: 'application/json',
750
+ [POLICY_CONTRACT_HEADER]: String(POLICY_CONTRACT_VERSION),
751
+ 'x-c15t-version': version,
752
+ 'x-request-id': requestId
753
+ };
754
+ if (hasBody) headers['content-type'] = 'application/json';
755
+ if (void 0 !== options.apiKey) headers.authorization = `Bearer ${options.apiKey}`;
756
+ return headers;
126
757
  };
127
- const parseResponseData = async (response)=>{
758
+ const normalizeHeaders = (headers)=>Object.fromEntries(Object.entries(headers).map(([name, value])=>[
759
+ name.toLowerCase(),
760
+ value
761
+ ]));
762
+ const buildUrl = (baseUrl, path, query)=>{
763
+ const url = new URL(path.replace(/^\//u, ''), baseUrl);
764
+ for (const [name, value] of Object.entries(query ?? {}))if (void 0 !== value) url.searchParams.set(name, value);
765
+ return url;
766
+ };
767
+ const emit = (options, event)=>{
128
768
  try {
129
- const contentType = response.headers.get('content-type');
130
- if (contentType?.includes('application/json') && 204 !== response.status && '0' !== response.headers.get('content-length')) return {
131
- data: await response.json(),
132
- parseError: null
133
- };
134
- return {
135
- data: null,
136
- parseError: null
137
- };
138
- } catch (parseError) {
139
- return {
140
- data: null,
141
- parseError
142
- };
769
+ options.onEvent?.(event);
770
+ } catch {}
771
+ };
772
+ const parseRetryAfter = (value)=>{
773
+ if (null === value) return;
774
+ const trimmed = value.trim();
775
+ if (/^\d+$/u.test(trimmed)) return 1000 * Number(trimmed);
776
+ const date = Date.parse(trimmed);
777
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
778
+ };
779
+ const backoff = (retry, attempt)=>Math.floor(Math.random() * Math.min(retry.maxDelayMs, retry.initialDelayMs * 2 ** attempt));
780
+ const wait = (ms, signal)=>new Promise((resolve)=>{
781
+ if (signal?.aborted) return void resolve(false);
782
+ const cleanup = new AbortController();
783
+ const timer = setTimeout(()=>{
784
+ cleanup.abort();
785
+ resolve(true);
786
+ }, ms);
787
+ signal?.addEventListener('abort', ()=>{
788
+ clearTimeout(timer);
789
+ resolve(false);
790
+ }, {
791
+ once: true,
792
+ signal: cleanup.signal
793
+ });
794
+ });
795
+ const readErrorBody = (text)=>{
796
+ let body;
797
+ try {
798
+ body = JSON.parse(text);
799
+ } catch {
800
+ return {};
143
801
  }
802
+ if ('object' != typeof body || null === body) return {};
803
+ const { message, cause } = body;
804
+ const parsed = {};
805
+ if ('string' == typeof message) parsed.message = message;
806
+ if ('string' == typeof cause?.code) parsed.code = cause.code;
807
+ if (isStalePolicyReason(cause?.reason)) parsed.reason = cause.reason;
808
+ return parsed;
144
809
  };
145
- const processApiError = (response, data, requestMethod, path, durationMs, context, options, retryableStatusCodes, nonRetryableStatusCodes, attemptsMade, maxRetries)=>{
146
- const errorData = data;
147
- const errorResponse = fetcher_createResponseContext(false, null, {
148
- code: errorData?.code || 'API_ERROR',
149
- details: errorData?.details || null,
150
- message: errorData?.message || `Request failed with status ${response.status}`,
151
- status: response.status
152
- }, response);
153
- const retry = !nonRetryableStatusCodes.includes(response.status) && retryableStatusCodes.includes(response.status) && attemptsMade < maxRetries;
154
- if (retry) return {
155
- retry: true
156
- };
157
- fetcher_debugLog(context.debug, requestMethod, path, durationMs, response.status);
158
- options?.onError?.(errorResponse, path);
159
- if (options?.throw) throw new Error(errorResponse.error?.message || 'Request failed');
160
- return {
161
- response: errorResponse,
162
- retry: false
163
- };
810
+ const failure = (error)=>({
811
+ error,
812
+ ok: false
813
+ });
814
+ const invalidInput = (endpoint, cause)=>{
815
+ const message = cause instanceof Error ? cause.message : String(cause);
816
+ return failure(new C15tError({
817
+ cause,
818
+ code: 'INVALID_INPUT',
819
+ issues: [
820
+ {
821
+ message
822
+ }
823
+ ],
824
+ message: `Invalid input for ${endpoint.name}: ${message}`
825
+ }));
164
826
  };
165
- const processResponse = async (response, requestMethod, path, durationMs, context, options, retryableStatusCodes, nonRetryableStatusCodes, attemptsMade, maxRetries)=>{
166
- const { data, parseError } = await parseResponseData(response);
167
- if (parseError) {
168
- const errorResponse = fetcher_createResponseContext(false, null, {
169
- cause: parseError,
170
- code: 'PARSE_ERROR',
171
- message: 'Failed to parse response',
827
+ const aborted = (endpoint, requestId, cause)=>failure(new C15tError({
828
+ cause,
829
+ code: 'ABORTED',
830
+ message: `${endpoint.name} was aborted.`,
831
+ requestId
832
+ }));
833
+ const describeIssues = (issues)=>issues.slice(0, 5).map((issue)=>issue.path && issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ${issue.message}` : issue.message).join('; ');
834
+ const decodeSuccess = (endpoint, response, text, requestId, input)=>{
835
+ try {
836
+ const body = '' === text ? null : JSON.parse(text);
837
+ const success = {
838
+ data: endpoint.decode(body, response, input),
839
+ headers: response.headers,
840
+ ok: true,
841
+ requestId,
172
842
  status: response.status
173
- }, response);
174
- options?.onError?.(errorResponse, path);
175
- if (options?.throw) throw new Error('Failed to parse response');
176
- return {
177
- response: errorResponse,
178
- retry: false
179
- };
180
- }
181
- if (response.ok) {
182
- fetcher_debugLog(context.debug, requestMethod, path, durationMs, response.status);
183
- const successResponse = fetcher_createResponseContext(true, data, null, response);
184
- options?.onSuccess?.(successResponse);
185
- return {
186
- response: successResponse,
187
- retry: false
188
843
  };
844
+ return success;
845
+ } catch (error) {
846
+ return failure(new C15tError({
847
+ cause: error,
848
+ code: 'UNEXPECTED_RESPONSE',
849
+ message: error instanceof DecodeError ? `${endpoint.name} returned a body without the documented shape: ${error.message}` : `${endpoint.name} returned a body that is not JSON.`,
850
+ requestId,
851
+ status: response.status
852
+ }));
189
853
  }
190
- return processApiError(response, data, requestMethod, path, durationMs, context, options, retryableStatusCodes, nonRetryableStatusCodes, attemptsMade, maxRetries);
191
854
  };
192
- const processFetchError = (fetchError, requestMethod, path, startTime, timeoutMs, context, options, retryOnNetworkError, attemptsMade, maxRetries)=>{
193
- if (fetchError instanceof Error && 'Failed to parse response' === fetchError.message) throw fetchError;
194
- const isAbortError = fetchError instanceof Error && 'AbortError' === fetchError.name;
195
- let message = String(fetchError);
196
- if (fetchError instanceof Error) ({ message } = fetchError);
197
- if (isAbortError) message = `Request timed out after ${timeoutMs}ms`;
198
- const errorResponse = fetcher_createResponseContext(false, null, {
199
- cause: fetchError,
200
- code: isAbortError ? 'TIMEOUT' : 'NETWORK_ERROR',
201
- message,
202
- status: 0
203
- }, null);
204
- const retry = !(fetchError instanceof Response) && retryOnNetworkError && attemptsMade < maxRetries;
205
- if (retry) return {
206
- retry: true
855
+ const classifyResponse = async (endpoint, response, requestId, input)=>{
856
+ const text = await response.text();
857
+ const accepts = endpoint.accepts ?? ((code)=>code >= 200 && code < 300);
858
+ if (accepts(response.status)) return {
859
+ kind: 'done',
860
+ result: decodeSuccess(endpoint, response, text, requestId, input)
861
+ };
862
+ const body = readErrorBody(text);
863
+ const retryable = RETRYABLE_STATUSES.has(response.status);
864
+ const declared = void 0 !== body.code && endpoint.errorCodes.includes(body.code);
865
+ const error = declared ? new C15tError({
866
+ code: body.code,
867
+ message: body.message ?? `Request failed with status ${response.status}.`,
868
+ reason: body.reason,
869
+ requestId,
870
+ retryable,
871
+ status: response.status
872
+ }) : new C15tError({
873
+ code: 'UNEXPECTED_RESPONSE',
874
+ message: body.message ?? `${endpoint.name} failed with status ${response.status}${void 0 === body.code ? '' : ` and undocumented code ${body.code}`}.`,
875
+ requestId,
876
+ retryable,
877
+ serverCode: body.code,
878
+ status: response.status
879
+ });
880
+ if (retryable && endpoint.idempotent) return {
881
+ error,
882
+ kind: 'retry',
883
+ retryAfterMs: parseRetryAfter(response.headers.get('retry-after'))
207
884
  };
208
- fetcher_debugLog(context.debug, requestMethod, path, Date.now() - startTime, 'ERROR');
209
- options?.onError?.(errorResponse, path);
210
- if (options?.throw) throw fetchError;
211
885
  return {
212
- response: errorResponse,
213
- retry: false
886
+ kind: 'done',
887
+ result: failure(error)
214
888
  };
215
889
  };
216
- const fetcher_fetcher = function(context, path, options) {
217
- const finalRetryConfig = {
218
- ...DEFAULT_RETRY_CONFIG,
219
- ...context.retryConfig,
220
- ...options?.retryConfig || {}
890
+ const attemptOnce = async (plan, attempt)=>{
891
+ const { call, endpoint, options } = plan;
892
+ let timeout;
893
+ const eventBase = {
894
+ attempt,
895
+ method: endpoint.method,
896
+ path: endpoint.path,
897
+ requestId: call.requestId
221
898
  };
222
- const { maxRetries = 3, initialDelayMs = 100, backoffFactor = 2, retryableStatusCodes = [
223
- 500,
224
- 502,
225
- 503,
226
- 504
227
- ], nonRetryableStatusCodes = [
228
- 400,
229
- 401,
230
- 403,
231
- 404
232
- ], retryOnNetworkError = true } = finalRetryConfig;
233
- let attemptsMade = 0;
234
- let currentDelay = initialDelayMs;
235
- const executeAttempt = async ()=>{
236
- const requestId = fetcher_generateUUID();
237
- const resolvedUrl = fetcher_resolveUrl(context.baseUrl, path);
238
- const url = new URL(resolvedUrl);
239
- if (options?.query) Object.entries(options.query).forEach(([key, value])=>{
240
- if (null != value) url.searchParams.append(key, String(value));
899
+ const startedAt = Date.now();
900
+ emit(options, {
901
+ ...eventBase,
902
+ type: 'request'
903
+ });
904
+ try {
905
+ timeout = AbortSignal.timeout(call.timeoutMs);
906
+ const signal = void 0 === call.signal ? timeout : AbortSignal.any([
907
+ call.signal,
908
+ timeout
909
+ ]);
910
+ const response = await options.fetch(plan.url, {
911
+ ...plan.init,
912
+ signal
241
913
  });
242
- const timeoutMs = options?.timeout ?? context.timeout;
243
- const controller = new AbortController();
244
- let timeoutId;
245
- if (timeoutMs > 0) timeoutId = setTimeout(()=>controller.abort(), timeoutMs);
246
- const requestOptions = {
247
- headers: {
248
- 'Content-Type': 'application/json',
249
- ...C15T_VERSION_HEADERS,
250
- ...context.headers,
251
- 'X-Request-ID': requestId,
252
- ...options?.headers
253
- },
254
- method: options?.method || 'GET',
255
- signal: controller.signal
914
+ const outcome = await classifyResponse(endpoint, response, call.requestId, plan.input);
915
+ emit(options, {
916
+ ...eventBase,
917
+ durationMs: Date.now() - startedAt,
918
+ status: response.status,
919
+ type: 'response'
920
+ });
921
+ return outcome;
922
+ } catch (error) {
923
+ if (call.signal?.aborted) return {
924
+ kind: 'done',
925
+ result: aborted(endpoint, call.requestId, error)
256
926
  };
257
- if (options?.body && 'GET' !== requestOptions.method) requestOptions.body = JSON.stringify(options.body);
258
- const startTime = Date.now();
259
- const requestMethod = requestOptions.method || 'GET';
260
- try {
261
- const response = await fetch(url.toString(), requestOptions);
262
- if (timeoutId) clearTimeout(timeoutId);
263
- const outcome = await processResponse(response, requestMethod, path, Date.now() - startTime, context, options, retryableStatusCodes, nonRetryableStatusCodes, attemptsMade, maxRetries);
264
- if (!outcome.retry) return outcome.response;
265
- } catch (fetchError) {
266
- if (timeoutId) clearTimeout(timeoutId);
267
- const outcome = processFetchError(fetchError, requestMethod, path, startTime, timeoutMs, context, options, retryOnNetworkError, attemptsMade, maxRetries);
268
- if (!outcome.retry) return outcome.response;
269
- }
270
- attemptsMade += 1;
271
- await delay(currentDelay);
272
- currentDelay *= backoffFactor;
273
- return executeAttempt();
274
- };
275
- return executeAttempt();
276
- };
277
- const CONSENT_CHECK_PATH = '/consents/check';
278
- const consent_checkConsent = function(context, query, options) {
279
- return fetcher_fetcher(context, CONSENT_CHECK_PATH, {
280
- method: 'GET',
281
- query,
282
- ...options
283
- });
284
- };
285
- const INIT_PATH = '/init';
286
- const init_init = function(context, options) {
287
- return fetcher_fetcher(context, INIT_PATH, {
288
- method: 'GET',
289
- ...options
290
- });
291
- };
292
- const STATUS_PATH = '/status';
293
- const status_status = function(context, options) {
294
- return fetcher_fetcher(context, STATUS_PATH, {
295
- method: 'GET',
296
- ...options
297
- });
298
- };
299
- const SUBJECTS_PATH = '/subjects';
300
- const subjects_createSubject = function(context, input, options) {
301
- return fetcher_fetcher(context, SUBJECTS_PATH, {
302
- body: input,
303
- method: 'POST',
304
- ...options
305
- });
306
- };
307
- const subjects_getSubject = function(context, id, query, options) {
308
- return fetcher_fetcher(context, `${SUBJECTS_PATH}/${id}`, {
309
- method: 'GET',
310
- query,
311
- ...options
312
- });
927
+ if (void 0 === timeout) return {
928
+ kind: 'done',
929
+ result: invalidInput(endpoint, error)
930
+ };
931
+ const timedOut = timeout.aborted;
932
+ const transportError = new C15tError({
933
+ cause: error,
934
+ code: timedOut ? 'TIMEOUT' : 'NETWORK_ERROR',
935
+ message: timedOut ? `${endpoint.name} timed out after ${call.timeoutMs}ms.` : `${endpoint.name} failed before a response arrived: ${error instanceof Error ? error.message : String(error)}`,
936
+ requestId: call.requestId,
937
+ retryable: true
938
+ });
939
+ return endpoint.idempotent ? {
940
+ error: transportError,
941
+ kind: 'retry',
942
+ retryAfterMs: void 0
943
+ } : {
944
+ kind: 'done',
945
+ result: failure(transportError)
946
+ };
947
+ }
313
948
  };
314
- const subjects_patchSubject = function(context, id, input, options) {
315
- return fetcher_fetcher(context, `${SUBJECTS_PATH}/${id}`, {
316
- body: input,
317
- method: 'PATCH',
318
- ...options
949
+ const runAttempts = async (plan, attempt)=>{
950
+ const { call, endpoint, options } = plan;
951
+ if (call.signal?.aborted) return aborted(endpoint, call.requestId, call.signal.reason);
952
+ const outcome = await attemptOnce(plan, attempt);
953
+ if ('done' === outcome.kind) return outcome.result;
954
+ const delayMs = outcome.retryAfterMs ?? backoff(call.retry, attempt);
955
+ if (attempt >= call.retry.maxRetries || delayMs > call.retry.maxDelayMs) return failure(outcome.error);
956
+ emit(options, {
957
+ attempt,
958
+ delayMs,
959
+ error: outcome.error,
960
+ method: endpoint.method,
961
+ path: endpoint.path,
962
+ requestId: call.requestId,
963
+ type: 'retry'
319
964
  });
965
+ if (!await wait(delayMs, call.signal)) return aborted(endpoint, call.requestId, call.signal?.reason);
966
+ return runAttempts(plan, attempt + 1);
320
967
  };
321
- const subjects_listSubjects = function(context, query, options) {
322
- return fetcher_fetcher(context, SUBJECTS_PATH, {
323
- method: 'GET',
324
- query,
325
- ...options
326
- });
968
+ const collectIssues = async (endpoint, input, optionIssues)=>{
969
+ const fromOptions = optionIssues.map((issue)=>({
970
+ message: issue.message,
971
+ path: issue.option.split('.')
972
+ }));
973
+ try {
974
+ return [
975
+ ...fromOptions,
976
+ ...await endpoint.validate(input)
977
+ ];
978
+ } catch (error) {
979
+ return [
980
+ ...fromOptions,
981
+ {
982
+ message: error instanceof Error ? error.message : String(error)
983
+ }
984
+ ];
985
+ }
327
986
  };
328
- class C15TClient {
329
- context;
330
- constructor(options = {}){
331
- const baseUrlString = options.baseUrl || ("u" < typeof process ? void 0 : process.env?.C15T_API_URL);
332
- if (!baseUrlString) throw new TypeError('baseUrl is required. Provide it in options or set C15T_API_URL environment variable.');
333
- const baseUrl = new URL(baseUrlString);
334
- if (options.prefix) baseUrl.pathname = options.prefix;
335
- const token = options.token || ("u" < typeof process ? void 0 : process.env?.C15T_API_TOKEN);
336
- const authHeaders = token ? {
337
- Authorization: `Bearer ${token}`
338
- } : {};
339
- const retryConfig = {
340
- ...DEFAULT_RETRY_CONFIG,
341
- ...options.retryConfig
342
- };
343
- const debug = options.debug ?? ("u" < typeof process ? false : process.env?.C15T_DEBUG === 'true');
344
- const timeout = options.timeout ?? 30000;
345
- this.context = {
346
- baseUrl: baseUrl.toString(),
347
- debug,
987
+ const transport_send = async function(options, endpoint, input, rawCallOptions) {
988
+ try {
989
+ const optionIssues = [];
990
+ const call = options_decodeCallOptions(rawCallOptions, options, optionIssues);
991
+ const issues = await collectIssues(endpoint, input, optionIssues);
992
+ if (issues.length > 0) return failure(new C15tError({
993
+ code: 'INVALID_INPUT',
994
+ issues,
995
+ message: `Invalid input for ${endpoint.name}: ${describeIssues(issues)}`
996
+ }));
997
+ if ('api-key' === endpoint.auth && void 0 === options.apiKey) return failure(new C15tError({
998
+ code: 'MISSING_API_KEY',
999
+ message: `${endpoint.name} needs an API key. Pass apiKey to createC15tClient.`
1000
+ }));
1001
+ const prepared = endpoint.prepare(input);
1002
+ const hasBody = void 0 !== prepared.body;
1003
+ const init = {
348
1004
  headers: {
349
- ...authHeaders,
350
- ...options.headers
1005
+ ...normalizeHeaders(call.headers),
1006
+ ...normalizeHeaders(prepared.headers ?? {}),
1007
+ ...ownedHeaders(options, call.requestId, hasBody)
351
1008
  },
352
- retryConfig,
353
- timeout
1009
+ method: endpoint.method
354
1010
  };
1011
+ if (hasBody) init.body = JSON.stringify(prepared.body);
1012
+ return runAttempts({
1013
+ call,
1014
+ endpoint: endpoint,
1015
+ init,
1016
+ input,
1017
+ options,
1018
+ url: buildUrl(options.baseUrl, path_buildPath(endpoint.path, prepared.params), prepared.query)
1019
+ }, 0);
1020
+ } catch (error) {
1021
+ return invalidInput(endpoint, error);
355
1022
  }
356
- status(options) {
357
- return status_status(this.context, options);
358
- }
359
- init(options) {
360
- return init_init(this.context, options);
361
- }
362
- createSubject(input, options) {
363
- return subjects_createSubject(this.context, input, options);
364
- }
365
- getSubject(id, query, options) {
366
- return subjects_getSubject(this.context, id, query, options);
367
- }
368
- patchSubject(id, input, options) {
369
- return subjects_patchSubject(this.context, id, input, options);
370
- }
371
- listSubjects(query, options) {
372
- return subjects_listSubjects(this.context, query, options);
373
- }
374
- checkConsent(query, options) {
375
- return consent_checkConsent(this.context, query, options);
376
- }
377
- $fetch(path, options) {
378
- return fetcher_fetcher(this.context, path, options);
379
- }
380
- consent = {
381
- check: (query, options)=>this.checkConsent(query, options)
382
- };
383
- subjects = {
384
- create: (input, options)=>this.createSubject(input, options),
385
- get: (id, query, options)=>this.getSubject(id, query, options),
386
- list: (query, options)=>this.listSubjects(query, options),
387
- patch: (id, input, options)=>this.patchSubject(id, input, options)
388
- };
389
- meta = {
390
- init: (options)=>this.init(options),
391
- status: (options)=>this.status(options)
1023
+ };
1024
+ function createC15tClient(options) {
1025
+ const resolved = options_decodeOptions(options);
1026
+ return {
1027
+ consents: {
1028
+ check: (input, callOptions)=>transport_send(resolved, endpoints.checkConsent, input, callOptions)
1029
+ },
1030
+ experiments: {
1031
+ summary: (id, request, callOptions)=>transport_send(resolved, endpoints.experimentSummary, {
1032
+ id,
1033
+ request
1034
+ }, callOptions)
1035
+ },
1036
+ init: (request, callOptions)=>transport_send(resolved, endpoints.init, request, callOptions),
1037
+ legalDocuments: {
1038
+ publish: (type, input, callOptions)=>transport_send(resolved, endpoints.publishLegalDocument, {
1039
+ input,
1040
+ type
1041
+ }, callOptions)
1042
+ },
1043
+ manifest: (request, callOptions)=>transport_send(resolved, endpoints.manifest, request, callOptions),
1044
+ status: (callOptions)=>transport_send(resolved, endpoints.status, void 0, callOptions),
1045
+ subjects: {
1046
+ create: (input, callOptions)=>transport_send(resolved, endpoints.createSubject, input, callOptions),
1047
+ get: (id, request, callOptions)=>transport_send(resolved, endpoints.getSubject, {
1048
+ id,
1049
+ request
1050
+ }, callOptions),
1051
+ identify: (id, input, callOptions)=>transport_send(resolved, endpoints.identifySubject, {
1052
+ id,
1053
+ input
1054
+ }, callOptions),
1055
+ list: (query, callOptions)=>transport_send(resolved, endpoints.listSubjects, query, callOptions)
1056
+ }
392
1057
  };
393
1058
  }
394
- const src_c15tClient = function(options) {
395
- return new C15TClient(options);
1059
+ const result_unwrap = function(result) {
1060
+ if (result.ok) return result.data;
1061
+ throw result.error;
396
1062
  };
397
- export { C15TClient, C15TError, error_isC15TError as isC15TError, fetcher_createResponseContext as createResponseContext, fetcher_fetcher as fetcher, fetcher_resolveUrl as resolveUrl, src_c15tClient as c15tClient };
1063
+ export { C15T_API_ERROR_CODES, C15T_CLIENT_ERROR_CODES, C15tError, isC15tError } from "./160.js";
1064
+ export { C15tConfigurationError, createC15tClient, result_unwrap as unwrap };