@c15t/node-sdk 3.0.0-alpha.6 → 3.0.0-alpha.7

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/README.md CHANGED
@@ -24,7 +24,7 @@ Typed client for hosted and self-hosted c15t backends. Check consent before send
24
24
 
25
25
  - One `createC15tClient()` call with an explicit backend URL. No environment variables are read
26
26
  - Methods resolve to `{ ok: true, data }` or `{ ok: false, error }` and never reject. Error codes are typed per method
27
- - Key-only methods (`subjects.list`, `experiments.summary`, `legalDocuments.publish`) exist only on a client created with an API key
27
+ - Key-only methods (`consents.check`, `subjects.list`, `experiments.summary`, `legalDocuments.publish`) exist only on a client created with an API key
28
28
  - Input is validated before anything is sent, and response dates are `Date` objects
29
29
  - Per-attempt timeouts and retries for network errors, timeouts and 408, 429, 500, 502, 503 and 504, honouring `Retry-After`
30
30
  - A typed mock client in `@c15t/node-sdk/testing`
@@ -77,8 +77,8 @@ For further information, guides, and examples visit the [reference documentation
77
77
 
78
78
  | Method | API key |
79
79
  | --- | --- |
80
- | `consents.check({ externalId, types })` | No |
81
- | `subjects.identify(id, { externalId, identityProvider })` | No |
80
+ | `consents.check({ externalId, types })` | Required |
81
+ | `subjects.identify(id, { externalId, identityProvider, identityToken })` | No; verified only with a key or token |
82
82
  | `subjects.get(id, { types })` | No |
83
83
  | `subjects.create(input)` | No |
84
84
  | `subjects.list({ externalId })` | Required |
@@ -88,6 +88,8 @@ For further information, guides, and examples visit the [reference documentation
88
88
 
89
89
  Every method takes `{ signal, timeoutMs, headers, retry, requestId }` as its last argument.
90
90
 
91
+ `createIdentityToken(user, { signingKey })` is a separate export, not a client method. It signs a token locally for the browser's `identify()`, takes no call options, and throws a `TypeError` on invalid input.
92
+
91
93
  ## Upgrading from v2
92
94
 
93
95
  `c15tClient()` and the `C15TClient` class are replaced by `createC15tClient()`. Results replace `ResponseContext`, `C15TError` is now `C15tError`, and `subjects.patch` is now `subjects.identify`. See the [migration guide](https://c15t.com/docs/upgrade-v3#update-the-nodejs-sdk).
package/dist/160.js CHANGED
@@ -5,6 +5,8 @@ const C15T_API_ERROR_CODES = [
5
5
  'CONFLICT',
6
6
  'DATABASE_ERROR',
7
7
  'EXTERNAL_ID_REQUIRED',
8
+ 'IDENTITY_CONFLICT',
9
+ 'IDENTITY_TOKEN_INVALID',
8
10
  'INPUT_VALIDATION_FAILED',
9
11
  'NOT_FOUND',
10
12
  'POLICY_SNAPSHOT_EXPIRED',
package/dist/index.js CHANGED
@@ -344,6 +344,8 @@ const identifySubject = defineEndpoint({
344
344
  errorCodes: [
345
345
  'DATABASE_ERROR',
346
346
  'EXTERNAL_ID_REQUIRED',
347
+ 'IDENTITY_CONFLICT',
348
+ 'IDENTITY_TOKEN_INVALID',
347
349
  'NOT_FOUND'
348
350
  ],
349
351
  idempotent: true,
@@ -369,7 +371,7 @@ const identifySubject = defineEndpoint({
369
371
  ]
370
372
  });
371
373
  const checkConsent = defineEndpoint({
372
- auth: 'none',
374
+ auth: 'api-key',
373
375
  decode: (body, _response, input)=>{
374
376
  const value = asObject(body, 'body');
375
377
  const results = asObject(value.results, 'results');
@@ -385,7 +387,8 @@ const checkConsent = defineEndpoint({
385
387
  errorCodes: [
386
388
  'DATABASE_ERROR',
387
389
  'EXTERNAL_ID_REQUIRED',
388
- 'TYPE_REQUIRED'
390
+ 'TYPE_REQUIRED',
391
+ 'UNAUTHORIZED'
389
392
  ],
390
393
  idempotent: true,
391
394
  method: 'GET',
@@ -735,7 +738,7 @@ const path_buildPath = function(template, params) {
735
738
  const values = params;
736
739
  return template.replace(/:(?<name>[A-Za-z]+)/gu, (_match, name)=>encodeURIComponent(values[name] ?? ''));
737
740
  };
738
- const version = '3.0.0-alpha.6';
741
+ const version = '3.0.0-alpha.7';
739
742
  const RETRYABLE_STATUSES = new Set([
740
743
  408,
741
744
  429,
@@ -1056,9 +1059,45 @@ function createC15tClient(options) {
1056
1059
  }
1057
1060
  };
1058
1061
  }
1062
+ const DEFAULT_TTL_SECONDS = 3600;
1063
+ const MIN_KEY_BYTES = 32;
1064
+ const base64url = (bytes)=>{
1065
+ let binary = '';
1066
+ for (const byte of bytes)binary += String.fromCodePoint(byte);
1067
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
1068
+ };
1069
+ const encodeJson = (value)=>base64url(new TextEncoder().encode(JSON.stringify(value)));
1070
+ const identity_token_createIdentityToken = async function(user, options) {
1071
+ if ('string' != typeof user?.externalId || '' === user.externalId) throw new TypeError('createIdentityToken: externalId must be a string.');
1072
+ if ('string' != typeof options?.signingKey || new TextEncoder().encode(options.signingKey).length < MIN_KEY_BYTES) throw new TypeError(`createIdentityToken: signingKey must be a string of at least ${MIN_KEY_BYTES} bytes.`);
1073
+ const ttl = options.ttlSeconds ?? DEFAULT_TTL_SECONDS;
1074
+ if (!Number.isSafeInteger(ttl) || ttl <= 0) throw new TypeError('createIdentityToken: ttlSeconds must be a positive integer.');
1075
+ const iat = Math.floor((options.now ?? Date.now)() / 1000);
1076
+ const header = encodeJson({
1077
+ alg: 'HS256',
1078
+ typ: 'JWT'
1079
+ });
1080
+ const claims = {
1081
+ aud: options.audience || 'c15t-identity',
1082
+ exp: iat + ttl,
1083
+ iat,
1084
+ iss: options.issuer || 'c15t',
1085
+ sub: user.externalId
1086
+ };
1087
+ if (void 0 !== user.identityProvider) claims.idp = user.identityProvider;
1088
+ const payload = encodeJson(claims);
1089
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(options.signingKey), {
1090
+ hash: 'SHA-256',
1091
+ name: 'HMAC'
1092
+ }, false, [
1093
+ 'sign'
1094
+ ]);
1095
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${header}.${payload}`));
1096
+ return `${header}.${payload}.${base64url(new Uint8Array(signature))}`;
1097
+ };
1059
1098
  const result_unwrap = function(result) {
1060
1099
  if (result.ok) return result.data;
1061
1100
  throw result.error;
1062
1101
  };
1063
1102
  export { C15T_API_ERROR_CODES, C15T_CLIENT_ERROR_CODES, C15tError, isC15tError } from "./160.js";
1064
- export { C15tConfigurationError, createC15tClient, result_unwrap as unwrap };
1103
+ export { C15tConfigurationError, createC15tClient, identity_token_createIdentityToken as createIdentityToken, result_unwrap as unwrap };
@@ -19,29 +19,39 @@ export interface C15tPublicSubjects {
19
19
  */
20
20
  create: (input: C15tCreateSubjectInput, options?: C15tCallOptions) => Promise<C15tResult<PostSubjectOutput, 'CHOICE_OUT_OF_SCOPE' | 'CHOICE_PREFERENCE_MISMATCH' | 'CONFLICT' | 'DATABASE_ERROR' | 'INPUT_VALIDATION_FAILED' | 'POLICY_SNAPSHOT_EXPIRED' | 'POLICY_SNAPSHOT_INVALID' | 'POLICY_SNAPSHOT_REQUIRED' | 'PURPOSE_NOT_ALLOWED' | 'STALE_POLICY' | 'SUBJECT_CONFLICT'>>;
21
21
  /**
22
- * Links a subject to your own user id, so `consents.check` and
23
- * `subjects.list` can find it. Call it after the visitor signs in.
22
+ * Links a subject to your own user id. Call it after the visitor signs in.
23
+ *
24
+ * `consents.check` and `subjects.list` only find a subject whose link was
25
+ * verified. A client with an API key verifies every link it makes.
26
+ * Without a key, pass an `identityToken` from `createIdentityToken`,
27
+ * or the link is stored unverified.
28
+ *
29
+ * An unverified link cannot replace a verified one: that returns
30
+ * `IDENTITY_CONFLICT`. A token that fails to verify returns
31
+ * `IDENTITY_TOKEN_INVALID`.
24
32
  *
25
33
  * @param id - Subject id from the visitor's browser.
26
- * @param input - Your user id and, optionally, who issued it.
34
+ * @param input - Your user id, optionally who issued it, and optionally an
35
+ * identity token.
27
36
  */
28
- identify: (id: string, input: C15tIdentifySubjectInput, options?: C15tCallOptions) => Promise<C15tResult<PatchSubjectOutput, 'DATABASE_ERROR' | 'EXTERNAL_ID_REQUIRED' | 'NOT_FOUND'>>;
37
+ identify: (id: string, input: C15tIdentifySubjectInput, options?: C15tCallOptions) => Promise<C15tResult<PatchSubjectOutput, 'DATABASE_ERROR' | 'EXTERNAL_ID_REQUIRED' | 'IDENTITY_CONFLICT' | 'IDENTITY_TOKEN_INVALID' | 'NOT_FOUND'>>;
29
38
  }
30
39
  /** `subjects` methods on a client with an API key. */
31
40
  export interface C15tSubjects extends C15tPublicSubjects {
32
41
  /**
33
- * Lists every subject linked to one of your user ids, with their consents.
34
- * Needs an API key.
42
+ * Lists every subject verifiably linked to one of your user ids, with
43
+ * their consents. Needs an API key.
35
44
  */
36
45
  list: (query: {
37
46
  readonly externalId: string;
38
47
  }, options?: C15tCallOptions) => Promise<C15tResult<ListSubjectsOutput, 'DATABASE_ERROR' | 'EXTERNAL_ID_REQUIRED' | 'UNAUTHORIZED'>>;
39
48
  }
40
- /** `consents` methods. */
49
+ /** `consents` methods. Need an API key. */
41
50
  export interface C15tConsents {
42
51
  /**
43
52
  * Reports whether a user has consented to each policy type. Every
44
- * requested type appears in `results`, consented or not.
53
+ * requested type appears in `results`, consented or not. Only subjects
54
+ * verifiably linked to the user count; see `subjects.identify`.
45
55
  *
46
56
  * @example
47
57
  * ```ts
@@ -54,7 +64,7 @@ export interface C15tConsents {
54
64
  * }
55
65
  * ```
56
66
  */
57
- check: <const Types extends readonly [ConsentPolicyType, ...ConsentPolicyType[]]>(input: C15tCheckConsentInput<Types>, options?: C15tCallOptions) => Promise<C15tResult<C15tCheckConsentOutput<Types[number]>, 'DATABASE_ERROR' | 'EXTERNAL_ID_REQUIRED' | 'TYPE_REQUIRED'>>;
67
+ check: <const Types extends readonly [ConsentPolicyType, ...ConsentPolicyType[]]>(input: C15tCheckConsentInput<Types>, options?: C15tCallOptions) => Promise<C15tResult<C15tCheckConsentOutput<Types[number]>, 'DATABASE_ERROR' | 'EXTERNAL_ID_REQUIRED' | 'TYPE_REQUIRED' | 'UNAUTHORIZED'>>;
58
68
  }
59
69
  /** `experiments` methods. Need an API key. */
60
70
  export interface C15tExperiments {
@@ -96,11 +106,11 @@ export interface C15tPublicClient {
96
106
  */
97
107
  manifest: (request?: C15tManifestRequest, options?: C15tCallOptions) => Promise<C15tResult<C15tManifestResult>>;
98
108
  readonly subjects: C15tPublicSubjects;
99
- readonly consents: C15tConsents;
100
109
  }
101
110
  /** A client created with an API key. */
102
111
  export interface C15tClient extends C15tPublicClient {
103
112
  readonly subjects: C15tSubjects;
113
+ readonly consents: C15tConsents;
104
114
  readonly experiments: C15tExperiments;
105
115
  readonly legalDocuments: C15tLegalDocuments;
106
116
  }
@@ -96,7 +96,7 @@ export type C15tCreateSubjectInput = PostSubjectInput extends infer Variant ? Va
96
96
  export type C15tIdentifySubjectInput = Omit<PatchSubjectFullInput, 'id'>;
97
97
  /** Input for `consents.check`. */
98
98
  export interface C15tCheckConsentInput<Types extends readonly ConsentPolicyType[]> {
99
- /** Your user id, as linked with `subjects.identify`. */
99
+ /** Your user id, as linked with `subjects.identify` or a verified `identify()`. */
100
100
  readonly externalId: string;
101
101
  /** Policy types to report. Every one appears in `results`. */
102
102
  readonly types: Types;
@@ -150,7 +150,7 @@ export declare const endpoints: {
150
150
  isLatestPolicy: boolean;
151
151
  };
152
152
  };
153
- }, "DATABASE_ERROR" | "EXTERNAL_ID_REQUIRED" | "TYPE_REQUIRED">;
153
+ }, "DATABASE_ERROR" | "EXTERNAL_ID_REQUIRED" | "TYPE_REQUIRED" | "UNAUTHORIZED">;
154
154
  readonly createSubject: Endpoint<"/subjects", (Omit<{
155
155
  consentAction?: string | undefined;
156
156
  documentSnapshotToken?: string | undefined;
@@ -158,6 +158,7 @@ export declare const endpoints: {
158
158
  externalSubjectId?: string | undefined;
159
159
  givenAt: number;
160
160
  identityProvider?: string | undefined;
161
+ identityToken?: string | undefined;
161
162
  jurisdiction?: string | undefined;
162
163
  jurisdictionModel?: string | undefined;
163
164
  metadata?: {
@@ -244,6 +245,7 @@ export declare const endpoints: {
244
245
  externalSubjectId?: string | undefined;
245
246
  givenAt: number;
246
247
  identityProvider?: string | undefined;
248
+ identityToken?: string | undefined;
247
249
  jurisdiction?: string | undefined;
248
250
  jurisdictionModel?: string | undefined;
249
251
  metadata?: {
@@ -270,6 +272,7 @@ export declare const endpoints: {
270
272
  externalSubjectId?: string | undefined;
271
273
  givenAt: number;
272
274
  identityProvider?: string | undefined;
275
+ identityToken?: string | undefined;
273
276
  jurisdiction?: string | undefined;
274
277
  jurisdictionModel?: string | undefined;
275
278
  metadata?: {
@@ -462,7 +465,7 @@ export declare const endpoints: {
462
465
  id: string;
463
466
  identityProvider: string;
464
467
  };
465
- }, "DATABASE_ERROR" | "EXTERNAL_ID_REQUIRED" | "NOT_FOUND">;
468
+ }, "DATABASE_ERROR" | "EXTERNAL_ID_REQUIRED" | "IDENTITY_CONFLICT" | "IDENTITY_TOKEN_INVALID" | "NOT_FOUND">;
466
469
  readonly init: Endpoint<"/init", C15tInitRequest | undefined, {
467
470
  branding: "c15t" | "consent" | "inth" | "none";
468
471
  cmpId?: number | undefined;
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Each method narrows this to the codes its endpoint can actually return.
12
12
  */
13
- export declare const C15T_API_ERROR_CODES: readonly ['BAD_REQUEST', 'CHOICE_OUT_OF_SCOPE', 'CHOICE_PREFERENCE_MISMATCH', 'CONFLICT', 'DATABASE_ERROR', 'EXTERNAL_ID_REQUIRED', 'INPUT_VALIDATION_FAILED', 'NOT_FOUND', 'POLICY_SNAPSHOT_EXPIRED', 'POLICY_SNAPSHOT_INVALID', 'POLICY_SNAPSHOT_REQUIRED', 'PURPOSE_NOT_ALLOWED', 'SERVICE_UNAVAILABLE', 'STALE_POLICY', 'SUBJECT_CONFLICT', 'TYPE_REQUIRED', 'UNAUTHORIZED'];
13
+ export declare const C15T_API_ERROR_CODES: readonly ['BAD_REQUEST', 'CHOICE_OUT_OF_SCOPE', 'CHOICE_PREFERENCE_MISMATCH', 'CONFLICT', 'DATABASE_ERROR', 'EXTERNAL_ID_REQUIRED', 'IDENTITY_CONFLICT', 'IDENTITY_TOKEN_INVALID', 'INPUT_VALIDATION_FAILED', 'NOT_FOUND', 'POLICY_SNAPSHOT_EXPIRED', 'POLICY_SNAPSHOT_INVALID', 'POLICY_SNAPSHOT_REQUIRED', 'PURPOSE_NOT_ALLOWED', 'SERVICE_UNAVAILABLE', 'STALE_POLICY', 'SUBJECT_CONFLICT', 'TYPE_REQUIRED', 'UNAUTHORIZED'];
14
14
  /** A code the c15t backend can return. */
15
15
  export type C15tApiErrorCode = (typeof C15T_API_ERROR_CODES)[number];
16
16
  /**
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Mints identity tokens: signed proof, from your server, that a browser
3
+ * belongs to one of your users.
4
+ *
5
+ * The browser passes the token to `identify()`. The backend checks it with
6
+ * the same key (its `identityToken.signingKey` option) and only then counts
7
+ * the subject in `subjects.list` and `consents.check` for that user.
8
+ *
9
+ * HS256 over WebCrypto, so it runs in Node, Bun, Deno and edge runtimes
10
+ * without a JWT library.
11
+ */
12
+ /** Options for {@link createIdentityToken}. */
13
+ export interface C15tIdentityTokenOptions {
14
+ /**
15
+ * The secret shared with the backend's `identityToken.signingKey`, at
16
+ * least 32 bytes. Keep it on your server: anyone holding it can sign in as
17
+ * any user.
18
+ */
19
+ readonly signingKey: string;
20
+ /**
21
+ * How long the token is valid, in seconds. Mint a fresh one on each page
22
+ * load or sign-in rather than storing it.
23
+ *
24
+ * @default 3600
25
+ */
26
+ readonly ttlSeconds?: number;
27
+ /** `iss` claim. Must match the backend's `identityToken.issuer`. @default 'c15t' */
28
+ readonly issuer?: string;
29
+ /** `aud` claim. Must match the backend's `identityToken.audience`. @default 'c15t-identity' */
30
+ readonly audience?: string;
31
+ /** Clock, for tests. @default Date.now */
32
+ readonly now?: () => number;
33
+ }
34
+ /** The identity a token vouches for. */
35
+ export interface C15tIdentityTokenUser {
36
+ /** Your user id. The same value the browser passes as `externalId`. */
37
+ readonly externalId: string;
38
+ /**
39
+ * Who issued the id, such as `clerk`. When set, the browser must send the
40
+ * same `identityProvider`.
41
+ */
42
+ readonly identityProvider?: string;
43
+ }
44
+ /**
45
+ * Signs an identity token for one of your users.
46
+ *
47
+ * Call it on your server after the visitor signs in, send the token to the
48
+ * page, and pass it to the browser client's `identify()` as
49
+ * `identityToken`.
50
+ *
51
+ * @param user - Your user id and, optionally, its identity provider.
52
+ * @param options - The shared signing key and token lifetime.
53
+ * @returns A compact JWT.
54
+ * @throws {TypeError} When `externalId` is empty, `signingKey` is shorter
55
+ * than 32 bytes, or `ttlSeconds` is not a positive integer.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { createIdentityToken } from '@c15t/node-sdk';
60
+ *
61
+ * const identityToken = await createIdentityToken(
62
+ * { externalId: session.user.id },
63
+ * { signingKey: process.env.C15T_IDENTITY_SIGNING_KEY! }
64
+ * );
65
+ * // In the browser:
66
+ * // await c15t.identify({ externalId: user.id, identityToken });
67
+ * ```
68
+ */
69
+ export declare const createIdentityToken: (user: C15tIdentityTokenUser, options: C15tIdentityTokenOptions) => Promise<string>;
@@ -6,6 +6,8 @@
6
6
  export { createC15tClient } from './client.js';
7
7
  export type { C15tClient, C15tConsents, C15tExperiments, C15tLegalDocuments, C15tPublicClient, C15tPublicSubjects, C15tSubjects, } from './client.js';
8
8
  export type { C15tCheckConsentInput, C15tCheckConsentOutput, C15tCreateSubjectInput, C15tExperimentSummaryRequest, C15tGetSubjectRequest, C15tIdentifySubjectInput, C15tInitRequest, C15tManifestRequest, C15tManifestResult, C15tPublishLegalDocumentInput, } from './contract.js';
9
+ export { createIdentityToken } from './identity-token.js';
10
+ export type { C15tIdentityTokenOptions, C15tIdentityTokenUser, } from './identity-token.js';
9
11
  export { C15tConfigurationError } from './configuration-error.js';
10
12
  export type { C15tConfigurationIssue } from './configuration-error.js';
11
13
  export { C15T_API_ERROR_CODES, C15T_CLIENT_ERROR_CODES, C15tError, isC15tError, } from './errors.js';
@@ -42,7 +42,7 @@ export type C15tMockCheckConsentHandler = (input: C15tCheckConsentInput<readonly
42
42
  readonly hasConsent: boolean;
43
43
  readonly isLatestPolicy: boolean;
44
44
  }>>;
45
- }, 'DATABASE_ERROR' | 'EXTERNAL_ID_REQUIRED' | 'TYPE_REQUIRED'>>;
45
+ }, 'DATABASE_ERROR' | 'EXTERNAL_ID_REQUIRED' | 'TYPE_REQUIRED' | 'UNAUTHORIZED'>>;
46
46
  /** Handlers for {@link createMockC15tClient}. Any method can be left out. */
47
47
  export interface C15tMockHandlers {
48
48
  readonly status?: C15tMockHandler<C15tClient['status']>;
@@ -1 +1 @@
1
- export declare const version = "3.0.0-alpha.6";
1
+ export declare const version = "3.0.0-alpha.7";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c15t/node-sdk",
3
- "version": "3.0.0-alpha.6",
3
+ "version": "3.0.0-alpha.7",
4
4
  "description": "Typed client for the c15t consent API: check consent, link users, record consents and publish legal documents from server code.",
5
5
  "keywords": [
6
6
  "c15t",
@@ -62,10 +62,10 @@
62
62
  "test:watch": "bun prebuild && vitest"
63
63
  },
64
64
  "dependencies": {
65
- "@c15t/schema": "3.0.0-alpha.5"
65
+ "@c15t/schema": "3.0.0-alpha.6"
66
66
  },
67
67
  "devDependencies": {
68
- "@c15t/backend": "3.0.0-alpha.6",
68
+ "@c15t/backend": "3.0.0-alpha.7",
69
69
  "@c15t/typescript-config": "0.0.1",
70
70
  "@c15t/vitest-config": "1.0.0",
71
71
  "@types/node": "25.5.0",