@redacto.io/consent-sdk-react 10.0.0-beta.7 → 10.0.0-beta.9

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  3. package/dist/index.d.mts +29 -3
  4. package/dist/index.d.ts +29 -3
  5. package/dist/index.js +162 -56
  6. package/dist/index.mjs +161 -57
  7. package/dist/privacy-center.d.mts +54 -12
  8. package/dist/privacy-center.d.ts +54 -12
  9. package/dist/privacy-center.js +265 -77
  10. package/dist/privacy-center.mjs +264 -78
  11. package/package.json +1 -1
  12. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +55 -5
  13. package/src/RedactoNoticeConsent/api/index.ts +78 -48
  14. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  15. package/src/RedactoNoticeConsent/api/types.ts +57 -11
  16. package/src/RedactoNoticeConsent/types.ts +28 -2
  17. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
  18. package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
  19. package/src/RedactoPrivacyCenter/api/client.ts +122 -25
  20. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  21. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  22. package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
  23. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -0
  24. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  25. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  26. package/src/RedactoPrivacyCenter/context/types.ts +38 -0
  27. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  28. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -5
  29. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  30. package/src/RedactoPrivacyCenter/lib/types.ts +20 -0
  31. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  32. package/src/shared/sandbox.ts +6 -0
  33. package/tests/Form.test.tsx +142 -5
  34. package/tests/consentClient.test.ts +46 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redacto.io/consent-sdk-react",
3
- "version": "10.0.0-beta.7",
3
+ "version": "10.0.0-beta.9",
4
4
  "main": "dist/index.cjs",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -1297,6 +1297,12 @@ export const RedactoNoticeConsent = ({
1297
1297
  noticeId,
1298
1298
  accessToken,
1299
1299
  refreshToken,
1300
+ token,
1301
+ email,
1302
+ mobile,
1303
+ ucic,
1304
+ organisationUuid,
1305
+ workspaceUuid,
1300
1306
  baseUrl,
1301
1307
  ledgerBaseUrl,
1302
1308
  language = "en",
@@ -1312,6 +1318,25 @@ export const RedactoNoticeConsent = ({
1312
1318
  includeFullyConsentedData = false,
1313
1319
  reviewModeButtonText = "Continue",
1314
1320
  }: Props) => {
1321
+ // =============================================================================
1322
+ // SANDBOX MODE
1323
+ // =============================================================================
1324
+
1325
+ // Sandbox PoC: when `token` is set, every API call bypasses the JWT and sends
1326
+ // the single X-Consent-Token header, carrying the acting identity (subject) in
1327
+ // each request's own payload. Org/workspace/subject are taken straight from
1328
+ // props. Passed through to the API layer alongside `accessToken`.
1329
+ const sandboxParams = useMemo(
1330
+ () => ({
1331
+ token,
1332
+ sandboxUcic: ucic,
1333
+ sandboxSubject: email || mobile,
1334
+ organisationUuid,
1335
+ workspaceUuid,
1336
+ }),
1337
+ [token, ucic, email, mobile, organisationUuid, workspaceUuid]
1338
+ );
1339
+
1315
1340
  // =============================================================================
1316
1341
  // PROP VALIDATION
1317
1342
  // =============================================================================
@@ -1321,11 +1346,31 @@ export const RedactoNoticeConsent = ({
1321
1346
  if (!noticeId?.trim()) {
1322
1347
  return "RedactoNoticeConsent: 'noticeId' prop is required and cannot be empty";
1323
1348
  }
1349
+ // Sandbox mode: require a static token, org/workspace, and a subject instead
1350
+ // of a JWT access token.
1351
+ if (token?.trim()) {
1352
+ if (!organisationUuid?.trim() || !workspaceUuid?.trim()) {
1353
+ return "RedactoNoticeConsent: sandbox mode requires 'organisationUuid' and 'workspaceUuid'";
1354
+ }
1355
+ if (!email?.trim() && !mobile?.trim() && !ucic?.trim()) {
1356
+ return "RedactoNoticeConsent: sandbox mode requires 'email', 'mobile', or 'ucic'";
1357
+ }
1358
+ return null;
1359
+ }
1324
1360
  if (!accessToken?.trim()) {
1325
1361
  return "RedactoNoticeConsent: 'accessToken' prop is required and cannot be empty";
1326
1362
  }
1327
1363
  return null;
1328
- }, [noticeId, accessToken]);
1364
+ }, [
1365
+ noticeId,
1366
+ accessToken,
1367
+ token,
1368
+ organisationUuid,
1369
+ workspaceUuid,
1370
+ email,
1371
+ mobile,
1372
+ ucic,
1373
+ ]);
1329
1374
 
1330
1375
  // Show error UI if props are invalid
1331
1376
  if (propValidationError) {
@@ -1822,6 +1867,7 @@ export const RedactoNoticeConsent = ({
1822
1867
  validate_against: validateAgainst,
1823
1868
  include_fully_consented_data: includeFullyConsentedData,
1824
1869
  signal: abortControllerRef.current?.signal,
1870
+ ...sandboxParams,
1825
1871
  });
1826
1872
  const activeConfig = consentContentData.detail.active_config;
1827
1873
  setContent(activeConfig);
@@ -1856,6 +1902,7 @@ export const RedactoNoticeConsent = ({
1856
1902
  ledgerBaseUrl,
1857
1903
  noticeUuid: noticeId,
1858
1904
  signal: abortControllerRef.current?.signal,
1905
+ ...sandboxParams,
1859
1906
  });
1860
1907
  if (ledgerStatus) {
1861
1908
  const overlay = buildLedgerConsentOverlay(
@@ -2033,6 +2080,7 @@ export const RedactoNoticeConsent = ({
2033
2080
  noticeId,
2034
2081
  accessToken,
2035
2082
  refreshToken,
2083
+ sandboxParams,
2036
2084
  language,
2037
2085
  applicationId,
2038
2086
  validateAgainst,
@@ -2194,6 +2242,7 @@ export const RedactoNoticeConsent = ({
2194
2242
  guardianVerificationReference: verificationReference || undefined,
2195
2243
  selfDeclaredAdult: selfDeclaredAdult || undefined,
2196
2244
  signal: submitController.signal,
2245
+ ...sandboxParams,
2197
2246
  });
2198
2247
  }
2199
2248
  onAccept?.();
@@ -2437,7 +2486,7 @@ export const RedactoNoticeConsent = ({
2437
2486
  } finally {
2438
2487
  setIsSubmittingGuardian(false);
2439
2488
  }
2440
- }, [accessToken, baseUrl, guardianFormData, digilockerMode, digilockerCallbackUrl, selectedPurposes, selectedDataElements, noticeId, onError]);
2489
+ }, [accessToken, sandboxParams, baseUrl, guardianFormData, digilockerMode, digilockerCallbackUrl, selectedPurposes, selectedDataElements, noticeId, onError]);
2441
2490
 
2442
2491
  // Age verification handlers
2443
2492
  const handleAgeVerificationYes = useCallback(() => {
@@ -2516,7 +2565,7 @@ export const RedactoNoticeConsent = ({
2516
2565
  setIsPollingStatus(false);
2517
2566
  isPollingStatusRef.current = false;
2518
2567
  setIsInitiatingVerification(false);
2519
- }, [accessToken, baseUrl]);
2568
+ }, [accessToken, sandboxParams, baseUrl]);
2520
2569
 
2521
2570
  const startPopupMonitoring = useCallback((popup: Window) => {
2522
2571
  // Check every 500ms if popup is closed
@@ -2697,7 +2746,7 @@ export const RedactoNoticeConsent = ({
2697
2746
  // First poll after 3s
2698
2747
  statusPollIntervalRef.current = window.setTimeout(poll, POLL_INTERVAL);
2699
2748
  },
2700
- [accessToken, baseUrl, getVerificationErrorMessage]
2749
+ [accessToken, sandboxParams, baseUrl, getVerificationErrorMessage]
2701
2750
  );
2702
2751
 
2703
2752
 
@@ -3232,6 +3281,7 @@ export const RedactoNoticeConsent = ({
3232
3281
  noticeUuid: content.notice_uuid,
3233
3282
  language: languageCode,
3234
3283
  signal: ttsAbortController.signal,
3284
+ ...sandboxParams,
3235
3285
  })
3236
3286
  .then((response) => {
3237
3287
  // Store the response and mark TTS as available
@@ -3250,7 +3300,7 @@ export const RedactoNoticeConsent = ({
3250
3300
  return () => {
3251
3301
  ttsAbortController.abort();
3252
3302
  };
3253
- }, [content, selectedLanguage, accessToken, baseUrl, getLanguageCode]);
3303
+ }, [content, selectedLanguage, accessToken, sandboxParams, baseUrl, getLanguageCode]);
3254
3304
 
3255
3305
  const modalStyle = {
3256
3306
  borderRadius: settings?.borderRadius || "8px",
@@ -1,5 +1,11 @@
1
1
  import { jwtDecode } from "jwt-decode";
2
2
  import { parseApiError } from "../../shared/api-errors";
3
+ import {
4
+ buildAuthHeaders,
5
+ resolveRequestAuth,
6
+ sandboxBodyIdentity,
7
+ sandboxQueryIdentity,
8
+ } from "./sandbox";
3
9
  import type {
4
10
  ConsentContent,
5
11
  ConsentEventPayload,
@@ -69,25 +75,28 @@ export const fetchConsentContent = async ({
69
75
  validate_against = "all",
70
76
  include_fully_consented_data = false,
71
77
  signal,
78
+ ...sandboxParams
72
79
  }: FetchConsentContentParams & { signal?: AbortSignal }): Promise<ConsentContent> => {
73
- if (!noticeId || !accessToken || !validate_against) {
74
- throw new Error("noticeId and accessToken are required");
80
+ if (!noticeId || !validate_against) {
81
+ throw new Error("noticeId is required");
75
82
  }
76
-
77
- const decodedToken = decodeTokenSafely(accessToken);
78
- if (!decodedToken) {
79
- throw new Error("Invalid access token");
83
+ if (!accessToken && !sandboxParams.token) {
84
+ throw new Error("accessToken or token is required");
80
85
  }
81
86
 
82
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
83
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
84
- throw new Error("Invalid token: missing organization or workspace UUID");
85
- }
87
+ const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
88
+ resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
86
89
 
87
90
  const apiBaseUrl = baseUrl || BASE_URL;
88
91
 
89
- // Create cache key for this request - include accessToken to ensure different users get different cache
90
- const cacheKey = `${accessToken}-${noticeId}-${validate_against}-${language}-${specific_uuid || ''}-${include_fully_consented_data}`;
92
+ // Create cache key for this request - include the auth identity (token or
93
+ // sandbox org/workspace/subject) so different users get different cache
94
+ // entries. Org + workspace are part of the sandbox identity so two sessions
95
+ // sharing a subject but scoped to different orgs never collide.
96
+ const cacheIdentity = sandbox
97
+ ? `sandbox:${sandbox.organisationUuid}:${sandbox.workspaceUuid}:${sandbox.ucic || sandbox.subject}`
98
+ : accessToken;
99
+ const cacheKey = `${cacheIdentity}-${noticeId}-${validate_against}-${language}-${specific_uuid || ''}-${include_fully_consented_data}`;
91
100
 
92
101
  if (validate_against === "all" && !specific_uuid) {
93
102
  const cachedData = getCachedData(cacheKey);
@@ -107,11 +116,18 @@ export const fetchConsentContent = async ({
107
116
  if (include_fully_consented_data) {
108
117
  url.searchParams.append("include_fully_consented_data", "true");
109
118
  }
119
+ // Sandbox mode: the acting identity travels as query params
120
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
121
+ if (sandbox) {
122
+ for (const [key, value] of Object.entries(sandboxQueryIdentity(sandbox))) {
123
+ url.searchParams.append(key, value);
124
+ }
125
+ }
110
126
 
111
127
  const response = await fetch(url.toString(), {
112
128
  method: "GET",
113
129
  headers: {
114
- Authorization: `Bearer ${accessToken}`,
130
+ ...buildAuthHeaders(accessToken, sandbox),
115
131
  "Accept-Language": language,
116
132
  },
117
133
  signal,
@@ -145,24 +161,23 @@ export const submitConsentEvent = async ({
145
161
  guardianVerificationReference,
146
162
  selfDeclaredAdult,
147
163
  signal,
164
+ ...sandboxParams
148
165
  }: SubmitConsentEventParams & { signal?: AbortSignal }): Promise<void> => {
149
- if (!noticeUuid || !accessToken || !Array.isArray(purposes)) {
150
- throw new Error("noticeUuid, accessToken, and purposes array are required");
166
+ if (!noticeUuid || !Array.isArray(purposes)) {
167
+ throw new Error("noticeUuid and purposes array are required");
151
168
  }
152
-
153
- const decodedToken = decodeTokenSafely(accessToken);
154
- if (!decodedToken) {
155
- throw new Error("Invalid access token");
169
+ if (!accessToken && !sandboxParams.token) {
170
+ throw new Error("accessToken or token is required");
156
171
  }
157
172
 
158
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
159
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
160
- throw new Error("Invalid token: missing organization or workspace UUID");
161
- }
173
+ const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
174
+ resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
162
175
 
163
176
  // Fall back to the consent server when no ledger is configured, so existing
164
- // integrations that only pass baseUrl remain backwards compatible.
165
- const apiBaseUrl = ledgerBaseUrl || baseUrl || BASE_URL;
177
+ // integrations that only pass baseUrl remain backwards compatible. Sandbox
178
+ // never routes to the Go ledger (no X-Consent-Token support), so a sandbox
179
+ // session forces the consent-server path even if a ledgerBaseUrl is set.
180
+ const apiBaseUrl = (sandbox ? null : ledgerBaseUrl) || baseUrl || BASE_URL;
166
181
 
167
182
  const validatedPurposes = purposes.map((purpose) => {
168
183
  if (!purpose.uuid) {
@@ -196,15 +211,19 @@ export const submitConsentEvent = async ({
196
211
  self_declared_adult: selfDeclaredAdult,
197
212
  };
198
213
 
214
+ // Sandbox mode: merge the acting test identity into the body (the server has
215
+ // no subject header; it namespaces this identity `test::`).
216
+ const requestBody = sandbox ? { ...payload, ...sandboxBodyIdentity(sandbox) } : payload;
217
+
199
218
  const response = await fetch(
200
219
  `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/submit-consent`,
201
220
  {
202
221
  method: "POST",
203
222
  headers: {
204
- Authorization: `Bearer ${accessToken}`,
223
+ ...buildAuthHeaders(accessToken, sandbox),
205
224
  "Content-Type": "application/json",
206
225
  },
207
- body: JSON.stringify(payload),
226
+ body: JSON.stringify(requestBody),
208
227
  signal,
209
228
  }
210
229
  );
@@ -283,29 +302,32 @@ export const fetchTTSAudioUrls = async ({
283
302
  noticeUuid,
284
303
  language,
285
304
  signal,
305
+ ...sandboxParams
286
306
  }: FetchTTSAudioUrlsParams & { signal?: AbortSignal }): Promise<TTSAudioUrlsResponse> => {
287
- if (!accessToken || !noticeUuid || !language) {
288
- throw new Error("accessToken, noticeUuid, and language are required");
289
- }
290
-
291
- const decodedToken = decodeTokenSafely(accessToken);
292
- if (!decodedToken) {
293
- throw new Error("Invalid access token");
307
+ if ((!accessToken && !sandboxParams.token) || !noticeUuid || !language) {
308
+ throw new Error("accessToken (or token), noticeUuid, and language are required");
294
309
  }
295
310
 
296
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
297
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
298
- throw new Error("Invalid token: missing organization or workspace UUID");
299
- }
311
+ const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
312
+ resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
300
313
 
301
314
  const apiBaseUrl = baseUrl || BASE_URL;
302
315
 
303
- const url = `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/audio/${language}`;
316
+ const url = new URL(
317
+ `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/audio/${language}`
318
+ );
319
+ // Sandbox mode: the acting identity travels as query params
320
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
321
+ if (sandbox) {
322
+ for (const [key, value] of Object.entries(sandboxQueryIdentity(sandbox))) {
323
+ url.searchParams.append(key, value);
324
+ }
325
+ }
304
326
 
305
- const response = await fetch(url, {
327
+ const response = await fetch(url.toString(), {
306
328
  method: "GET",
307
329
  headers: {
308
- Authorization: `Bearer ${accessToken}`,
330
+ ...buildAuthHeaders(accessToken, sandbox),
309
331
  Accept: "application/json",
310
332
  },
311
333
  signal,
@@ -343,6 +365,9 @@ export const initiateGuardianVerification = async ({
343
365
  signal,
344
366
  }: InitiateGuardianVerificationParams): Promise<InitiateGuardianVerificationResponse> => {
345
367
  try {
368
+ // Guardian verification is JWT-only: sandbox sessions have no minor-notice /
369
+ // guardian support (the server 403s them, and C1 blocks minor notices at
370
+ // render), so no sandbox identity is wired through here.
346
371
  if (!accessToken) {
347
372
  throw new Error("accessToken is required");
348
373
  }
@@ -363,8 +388,10 @@ export const initiateGuardianVerification = async ({
363
388
  if (!decodedToken) {
364
389
  throw new Error("Invalid access token");
365
390
  }
366
-
367
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
391
+ const {
392
+ organisation_uuid: ORGANISATION_UUID,
393
+ workspace_uuid: WORKSPACE_UUID,
394
+ } = decodedToken;
368
395
  if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
369
396
  throw new Error("Invalid token: missing organization or workspace UUID");
370
397
  }
@@ -458,6 +485,9 @@ export const verifyGuardianStatus = async ({
458
485
  sessionToken,
459
486
  signal,
460
487
  }: VerifyGuardianStatusParams): Promise<VerifyGuardianStatusResponse> => {
488
+ // Guardian verification is JWT-only: sandbox sessions have no minor-notice /
489
+ // guardian support (the server 403s them, and C1 blocks minor notices at
490
+ // render), so no sandbox identity is wired through here.
461
491
  if (!accessToken || !sessionToken) {
462
492
  throw new Error("accessToken and sessionToken are required");
463
493
  }
@@ -466,8 +496,10 @@ export const verifyGuardianStatus = async ({
466
496
  if (!decodedToken) {
467
497
  throw new Error("Invalid access token");
468
498
  }
469
-
470
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
499
+ const {
500
+ organisation_uuid: ORGANISATION_UUID,
501
+ workspace_uuid: WORKSPACE_UUID,
502
+ } = decodedToken;
471
503
  if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
472
504
  throw new Error("Invalid token: missing organization or workspace UUID");
473
505
  }
@@ -482,9 +514,7 @@ export const verifyGuardianStatus = async ({
482
514
  Authorization: `Bearer ${accessToken}`,
483
515
  "Content-Type": "application/json",
484
516
  },
485
- body: JSON.stringify({
486
- session_token: sessionToken,
487
- }),
517
+ body: JSON.stringify({ session_token: sessionToken }),
488
518
  signal,
489
519
  }
490
520
  );
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Sandbox-mode auth primitives for the consent PoC.
3
+ *
4
+ * In sandbox mode the SDK talks to the consent-server with a pasted static
5
+ * token and an explicit test subject, bypassing the JWT entirely. Org/workspace
6
+ * UUIDs come from props (they still build the `/public/organisations/{org}/
7
+ * workspaces/{ws}/...` URL paths) instead of being decoded from a token.
8
+ *
9
+ * Auth is the single `X-Consent-Token` header carrying the static token; its
10
+ * presence alone marks the request as `environment=test`. There is no subject
11
+ * header. The acting identity (the test subject) rides the request's own
12
+ * payload: query params (`primary_email` / `primary_mobile`) on GET/query reads,
13
+ * and the body on POSTs. The server normalizes and namespaces it `test::`
14
+ * server-side. Call sites resolve the credential via {@link buildAuthHeaders}
15
+ * and the identity via {@link sandboxQueryIdentity} / {@link sandboxBodyIdentity}.
16
+ *
17
+ * This file exists so every API call site resolves its org/workspace UUIDs and
18
+ * builds its auth header through one place, keeping sandbox vs. JWT branching
19
+ * from drifting across the ~6 fetch sites.
20
+ */
21
+ import { SANDBOX_TOKEN_HEADER } from "../../shared/sandbox";
22
+ import type {
23
+ RedactoJwtPayload,
24
+ ResolvedRequestAuth,
25
+ SandboxAuth,
26
+ SandboxAuthParams,
27
+ SandboxIdentityPayload,
28
+ } from "./types";
29
+
30
+ export { SANDBOX_TOKEN_HEADER };
31
+
32
+ /**
33
+ * Resolve a {@link SandboxAuth} from the raw sandbox props, or return null when
34
+ * sandbox mode is not active (no token).
35
+ *
36
+ * @throws Error when a sandbox token is provided but org/workspace or the acting
37
+ * identity are incomplete — sandbox mode cannot build URLs or identify the
38
+ * acting principal without them, so failing loudly beats a silent malformed
39
+ * request.
40
+ */
41
+ export const resolveSandboxAuth = (
42
+ params: SandboxAuthParams
43
+ ): SandboxAuth | null => {
44
+ const token = params.token?.trim();
45
+ if (!token) {
46
+ return null;
47
+ }
48
+
49
+ const organisationUuid = params.organisationUuid?.trim();
50
+ const workspaceUuid = params.workspaceUuid?.trim();
51
+ const ucic = params.sandboxUcic?.trim();
52
+ const subject = params.sandboxSubject?.trim();
53
+
54
+ if (!organisationUuid || !workspaceUuid) {
55
+ throw new Error(
56
+ "Sandbox mode requires organisationUuid and workspaceUuid props"
57
+ );
58
+ }
59
+ if (!ucic && !subject) {
60
+ throw new Error("Sandbox mode requires a UCIC, email, or mobile");
61
+ }
62
+
63
+ return { token, ucic, subject: subject ?? "", organisationUuid, workspaceUuid };
64
+ };
65
+
66
+ /**
67
+ * Resolve the org/workspace UUIDs and sandbox auth for a request. In sandbox
68
+ * mode these come straight from props; otherwise they are decoded from the JWT
69
+ * (preserving the pre-sandbox behavior and error messages exactly). The JWT
70
+ * decoder is injected so this module stays free of a direct jwt-decode import.
71
+ *
72
+ * @throws Error when neither a valid sandbox config nor a decodable token with
73
+ * org/workspace UUIDs is available.
74
+ */
75
+ export const resolveRequestAuth = (
76
+ accessToken: string | undefined,
77
+ sandboxParams: SandboxAuthParams,
78
+ decodeTokenSafely: (token: string) => RedactoJwtPayload | null
79
+ ): ResolvedRequestAuth => {
80
+ const sandbox = resolveSandboxAuth(sandboxParams);
81
+ if (sandbox) {
82
+ return {
83
+ organisationUuid: sandbox.organisationUuid,
84
+ workspaceUuid: sandbox.workspaceUuid,
85
+ sandbox,
86
+ };
87
+ }
88
+
89
+ const decodedToken = accessToken ? decodeTokenSafely(accessToken) : null;
90
+ if (!decodedToken) {
91
+ throw new Error("Invalid access token");
92
+ }
93
+ const { organisation_uuid, workspace_uuid } = decodedToken;
94
+ if (!organisation_uuid || !workspace_uuid) {
95
+ throw new Error("Invalid token: missing organization or workspace UUID");
96
+ }
97
+ return {
98
+ organisationUuid: organisation_uuid,
99
+ workspaceUuid: workspace_uuid,
100
+ sandbox: null,
101
+ };
102
+ };
103
+
104
+ /**
105
+ * Build the request headers for an authenticated call. In sandbox mode this is
106
+ * the single `X-Consent-Token` credential (its presence marks the request as
107
+ * `environment=test`); otherwise the standard `Authorization: Bearer`. There is
108
+ * no subject header — the acting identity travels in the request's own payload
109
+ * (see {@link sandboxQueryIdentity} for reads, {@link sandboxBodyIdentity} for
110
+ * POSTs).
111
+ */
112
+ export const buildAuthHeaders = (
113
+ accessToken: string | undefined,
114
+ sandbox: SandboxAuth | null
115
+ ): Record<string, string> => {
116
+ if (sandbox) {
117
+ return { [SANDBOX_TOKEN_HEADER]: sandbox.token };
118
+ }
119
+ return { Authorization: `Bearer ${accessToken}` };
120
+ };
121
+
122
+ /**
123
+ * The identity payload fragment for the sandbox acting principal:
124
+ * `{ org_user_id }` when a UCIC is set (it takes precedence), else
125
+ * `{ primary_email }` when the subject looks like an email (contains "@"),
126
+ * otherwise `{ primary_mobile }`. Merge this into a JSON request body (POST) or
127
+ * into query params (GET) so the server can resolve the acting test identity.
128
+ */
129
+ export const sandboxBodyIdentity = (
130
+ sandbox: SandboxAuth
131
+ ): SandboxIdentityPayload => {
132
+ if (sandbox.ucic) {
133
+ return { org_user_id: sandbox.ucic };
134
+ }
135
+ return sandbox.subject.includes("@")
136
+ ? { primary_email: sandbox.subject }
137
+ : { primary_mobile: sandbox.subject };
138
+ };
139
+
140
+ /**
141
+ * The identity query fragment for GET/query reads: `{ org_user_id }` when a UCIC
142
+ * is set (it takes precedence), else `{ primary_email }` when the subject looks
143
+ * like an email (contains "@"), otherwise `{ primary_mobile }`. Append these to
144
+ * the request's query string so the server can resolve the acting test identity
145
+ * (the server reads `org_user_id` / `primary_email` / `primary_mobile` query
146
+ * params on sandbox reads).
147
+ */
148
+ export const sandboxQueryIdentity = (
149
+ sandbox: SandboxAuth
150
+ ): SandboxIdentityPayload => sandboxBodyIdentity(sandbox);
@@ -6,6 +6,52 @@ export type RedactoJwtPayload = {
6
6
  iat?: number;
7
7
  };
8
8
 
9
+ /**
10
+ * Optional sandbox-mode auth fields shared by every API param type. When
11
+ * `token` is set the call sends the single `X-Consent-Token` header instead of
12
+ * `Authorization: Bearer`, injects the subject into each request's own payload
13
+ * (body for POSTs, query param for GETs), and takes org/workspace from these
14
+ * props instead of decoding the JWT. All optional so existing JWT callers are
15
+ * unaffected.
16
+ */
17
+ export type SandboxAuthParams = {
18
+ token?: string;
19
+ /** UCIC — the client's own user id (`org_user_id`); takes precedence over the subject. */
20
+ sandboxUcic?: string;
21
+ sandboxSubject?: string;
22
+ organisationUuid?: string;
23
+ workspaceUuid?: string;
24
+ };
25
+
26
+ /**
27
+ * Sandbox authentication context. Present only when the host opts into sandbox
28
+ * mode by passing a sandbox token; the token/org/workspace are always required
29
+ * at that point because there is no JWT to fall back on. The acting identity is
30
+ * a UCIC (the client's own `org_user_id`) or a `subject` (email/mobile) — at
31
+ * least one is present (UCIC takes precedence when both are set).
32
+ */
33
+ export type SandboxAuth = {
34
+ token: string;
35
+ /** UCIC — the client's own user id (`org_user_id`). Takes precedence over `subject`. */
36
+ ucic?: string;
37
+ subject: string;
38
+ organisationUuid: string;
39
+ workspaceUuid: string;
40
+ };
41
+
42
+ /** The identity payload fragment for the sandbox acting principal. */
43
+ export type SandboxIdentityPayload =
44
+ | { org_user_id: string }
45
+ | { primary_email: string }
46
+ | { primary_mobile: string };
47
+
48
+ /** Org/workspace UUIDs plus the resolved sandbox context for a request. */
49
+ export type ResolvedRequestAuth = {
50
+ organisationUuid: string;
51
+ workspaceUuid: string;
52
+ sandbox: SandboxAuth | null;
53
+ };
54
+
9
55
  export type PurposeSelection = {
10
56
  selected: boolean;
11
57
  status: string; // ACTIVE | EXPIRED | WITHDRAW | DECLINED
@@ -157,10 +203,10 @@ export type Settings = {
157
203
  font?: string;
158
204
  };
159
205
 
160
- export type FetchConsentContentParams = {
206
+ export type FetchConsentContentParams = SandboxAuthParams & {
161
207
  noticeId: string;
162
- accessToken: string;
163
- refreshToken: string;
208
+ accessToken?: string;
209
+ refreshToken?: string;
164
210
  baseUrl?: string;
165
211
  language?: string;
166
212
  specific_uuid?: string;
@@ -207,8 +253,8 @@ export type Purpose = {
207
253
  data_elements: Array<DataElement>;
208
254
  };
209
255
 
210
- export type SubmitConsentEventParams = {
211
- accessToken: string;
256
+ export type SubmitConsentEventParams = SandboxAuthParams & {
257
+ accessToken?: string;
212
258
  baseUrl?: string;
213
259
  ledgerBaseUrl?: string;
214
260
  noticeUuid: string;
@@ -331,8 +377,8 @@ export type DpoInfoAudio = {
331
377
  grievance_email_audio_url?: string;
332
378
  };
333
379
 
334
- export type FetchTTSAudioUrlsParams = {
335
- accessToken: string;
380
+ export type FetchTTSAudioUrlsParams = SandboxAuthParams & {
381
+ accessToken?: string;
336
382
  baseUrl?: string;
337
383
  noticeUuid: string;
338
384
  language: string;
@@ -346,8 +392,8 @@ export type FetchTTSAudioUrlsParams = {
346
392
  * Parameters for initiating guardian verification
347
393
  * POST /guardian/initiate-verification
348
394
  */
349
- export type InitiateGuardianVerificationParams = {
350
- accessToken: string;
395
+ export type InitiateGuardianVerificationParams = SandboxAuthParams & {
396
+ accessToken?: string;
351
397
  baseUrl?: string;
352
398
  guardianName: string;
353
399
  guardianContact: string;
@@ -382,8 +428,8 @@ export type InitiateGuardianVerificationResponse = {
382
428
  * Parameters for checking verification status
383
429
  * POST /guardian/verify-status
384
430
  */
385
- export type VerifyGuardianStatusParams = {
386
- accessToken: string;
431
+ export type VerifyGuardianStatusParams = SandboxAuthParams & {
432
+ accessToken?: string;
387
433
  baseUrl?: string;
388
434
  sessionToken: string;
389
435
  signal?: AbortSignal;
@@ -2,8 +2,34 @@ import type { Settings, DpoInfoTranslation } from "./api/types";
2
2
 
3
3
  export type Props = Readonly<{
4
4
  noticeId: string;
5
- accessToken: string;
6
- refreshToken: string;
5
+ /**
6
+ * JWT access token. Optional so sandbox integrations (see `token`) can
7
+ * omit it. Required in the normal JWT flow.
8
+ */
9
+ accessToken?: string;
10
+ /** JWT refresh token. Optional; accepted for source-compat, currently unused. */
11
+ refreshToken?: string;
12
+ /**
13
+ * Sandbox PoC: a pasted static token. When set, the SDK runs against the
14
+ * sandbox with no JWT — it sends the single `X-Consent-Token` header, carries
15
+ * the acting identity (`email` || `mobile`) in each request's own payload,
16
+ * takes org/workspace from `organisationUuid`/`workspaceUuid`, and does no
17
+ * token refresh.
18
+ */
19
+ token?: string;
20
+ /** Sandbox test subject email (carried in the request payload when set). */
21
+ email?: string;
22
+ /** Sandbox test subject mobile (fallback subject when `email` is absent). */
23
+ mobile?: string;
24
+ /**
25
+ * Sandbox UCIC: the client's own user id / org_user_id, e.g. a UUID or
26
+ * customer code; optional third identifier alongside email/mobile.
27
+ */
28
+ ucic?: string;
29
+ /** Sandbox org UUID (replaces the JWT-decoded org in sandbox mode). */
30
+ organisationUuid?: string;
31
+ /** Sandbox workspace UUID (replaces the JWT-decoded workspace in sandbox mode). */
32
+ workspaceUuid?: string;
7
33
  baseUrl?: string;
8
34
  ledgerBaseUrl?: string;
9
35
  settings?: Partial<Settings>;