@redacto.io/consent-sdk-react 10.0.0-beta.1 → 10.0.0-beta.11

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 (84) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +78 -0
  4. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  5. package/dist/index.d.mts +32 -4
  6. package/dist/index.d.ts +32 -4
  7. package/dist/index.js +340 -230
  8. package/dist/index.mjs +339 -231
  9. package/dist/privacy-center.d.mts +55 -12
  10. package/dist/privacy-center.d.ts +55 -12
  11. package/dist/privacy-center.js +848 -950
  12. package/dist/privacy-center.mjs +698 -802
  13. package/package.json +1 -1
  14. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.test.tsx +87 -0
  15. package/src/RedactoNoticeAssisted/RedactoNoticeAssisted.tsx +27 -0
  16. package/src/RedactoNoticeAssisted/api/index.ts +7 -1
  17. package/src/RedactoNoticeAssisted/api/types.ts +2 -0
  18. package/src/RedactoNoticeAssisted/i18n.ts +0 -55
  19. package/src/RedactoNoticeAssisted/types.ts +2 -0
  20. package/src/RedactoNoticeConsent/RedactoNoticeConsent.test.tsx +42 -8
  21. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +74 -33
  22. package/src/RedactoNoticeConsent/api/index.ts +88 -175
  23. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  24. package/src/RedactoNoticeConsent/api/types.ts +58 -43
  25. package/src/RedactoNoticeConsent/language-codes.ts +0 -2
  26. package/src/RedactoNoticeConsent/types.ts +28 -2
  27. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +85 -5
  28. package/src/RedactoNoticeConsentInline/api/index.ts +10 -1
  29. package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
  30. package/src/RedactoNoticeConsentInline/api/types.ts +2 -0
  31. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +37 -2
  32. package/src/RedactoPrivacyCenter/api/actions.ts +3 -1
  33. package/src/RedactoPrivacyCenter/api/client.ts +122 -25
  34. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  35. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
  36. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +47 -45
  37. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  38. package/src/RedactoPrivacyCenter/components/Form.tsx +55 -30
  39. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
  40. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +25 -13
  41. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +8 -6
  42. package/src/RedactoPrivacyCenter/components/constant.ts +3 -3
  43. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  44. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  45. package/src/RedactoPrivacyCenter/context/types.ts +38 -0
  46. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  47. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
  48. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
  49. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  50. package/src/RedactoPrivacyCenter/lib/types.ts +25 -1
  51. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  52. package/src/RedactoPrivacyCenter/locales/as/translation.json +3 -3
  53. package/src/RedactoPrivacyCenter/locales/bn/translation.json +3 -3
  54. package/src/RedactoPrivacyCenter/locales/brx/translation.json +3 -3
  55. package/src/RedactoPrivacyCenter/locales/doi/translation.json +3 -3
  56. package/src/RedactoPrivacyCenter/locales/en/translation.json +4 -3
  57. package/src/RedactoPrivacyCenter/locales/gom/translation.json +3 -3
  58. package/src/RedactoPrivacyCenter/locales/gu/translation.json +3 -3
  59. package/src/RedactoPrivacyCenter/locales/hi/translation.json +3 -3
  60. package/src/RedactoPrivacyCenter/locales/kn/translation.json +3 -3
  61. package/src/RedactoPrivacyCenter/locales/ks/translation.json +3 -3
  62. package/src/RedactoPrivacyCenter/locales/mai/translation.json +3 -3
  63. package/src/RedactoPrivacyCenter/locales/ml/translation.json +3 -3
  64. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +3 -3
  65. package/src/RedactoPrivacyCenter/locales/mr/translation.json +3 -3
  66. package/src/RedactoPrivacyCenter/locales/ne/translation.json +3 -3
  67. package/src/RedactoPrivacyCenter/locales/or/translation.json +3 -3
  68. package/src/RedactoPrivacyCenter/locales/pa/translation.json +3 -3
  69. package/src/RedactoPrivacyCenter/locales/sa/translation.json +3 -3
  70. package/src/RedactoPrivacyCenter/locales/sat/translation.json +3 -3
  71. package/src/RedactoPrivacyCenter/locales/sd/translation.json +3 -3
  72. package/src/RedactoPrivacyCenter/locales/ta/translation.json +3 -3
  73. package/src/RedactoPrivacyCenter/locales/te/translation.json +3 -3
  74. package/src/RedactoPrivacyCenter/locales/ur/translation.json +3 -3
  75. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +7 -2
  76. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
  77. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
  78. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
  79. package/src/shared/notice-consent-status.ts +190 -0
  80. package/src/shared/sandbox.ts +6 -0
  81. package/tests/Form.test.tsx +142 -5
  82. package/tests/RevokeWarningMessage.test.tsx +151 -0
  83. package/tests/consentClient.test.ts +46 -0
  84. package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -340
@@ -1,16 +1,18 @@
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,
6
- DataElementSelection,
7
12
  FetchConsentContentParams,
8
- FetchNoticeConsentStatusParams,
9
13
  FetchTTSAudioUrlsParams,
10
14
  InitiateGuardianVerificationParams,
11
15
  InitiateGuardianVerificationResponse,
12
- NoticeConsentStatusResponse,
13
- PurposeSelection,
14
16
  RedactoJwtPayload,
15
17
  SubmitConsentEventParams,
16
18
  TTSAudioUrlsResponse,
@@ -18,6 +20,11 @@ import type {
18
20
  VerifyGuardianStatusResponse,
19
21
  } from "./types";
20
22
 
23
+ // The ledger `check-consent` read is shared with RedactoNoticeConsentInline.
24
+ // Re-exported through this barrel so `./api` importers (and the test mock)
25
+ // resolve it here unchanged.
26
+ export { fetchNoticeConsentStatus } from "../../shared/notice-consent-status";
27
+
21
28
  const BASE_URL = "https://api.redacto.io/consent";
22
29
 
23
30
  // Simple in-memory cache for API responses to reduce calls
@@ -65,25 +72,28 @@ export const fetchConsentContent = async ({
65
72
  validate_against = "all",
66
73
  include_fully_consented_data = false,
67
74
  signal,
75
+ ...sandboxParams
68
76
  }: FetchConsentContentParams & { signal?: AbortSignal }): Promise<ConsentContent> => {
69
- if (!noticeId || !accessToken || !validate_against) {
70
- throw new Error("noticeId and accessToken are required");
77
+ if (!noticeId || !validate_against) {
78
+ throw new Error("noticeId is required");
71
79
  }
72
-
73
- const decodedToken = decodeTokenSafely(accessToken);
74
- if (!decodedToken) {
75
- throw new Error("Invalid access token");
80
+ if (!accessToken && !sandboxParams.token) {
81
+ throw new Error("accessToken or token is required");
76
82
  }
77
83
 
78
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
79
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
80
- throw new Error("Invalid token: missing organization or workspace UUID");
81
- }
84
+ const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
85
+ resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
82
86
 
83
87
  const apiBaseUrl = baseUrl || BASE_URL;
84
88
 
85
- // Create cache key for this request - include accessToken to ensure different users get different cache
86
- const cacheKey = `${accessToken}-${noticeId}-${validate_against}-${language}-${specific_uuid || ''}-${include_fully_consented_data}`;
89
+ // Create cache key for this request - include the auth identity (token or
90
+ // sandbox org/workspace/subject) so different users get different cache
91
+ // entries. Org + workspace are part of the sandbox identity so two sessions
92
+ // sharing a subject but scoped to different orgs never collide.
93
+ const cacheIdentity = sandbox
94
+ ? `sandbox:${sandbox.organisationUuid}:${sandbox.workspaceUuid}:${sandbox.ucic || sandbox.subject}`
95
+ : accessToken;
96
+ const cacheKey = `${cacheIdentity}-${noticeId}-${validate_against}-${language}-${specific_uuid || ''}-${include_fully_consented_data}`;
87
97
 
88
98
  if (validate_against === "all" && !specific_uuid) {
89
99
  const cachedData = getCachedData(cacheKey);
@@ -103,11 +113,18 @@ export const fetchConsentContent = async ({
103
113
  if (include_fully_consented_data) {
104
114
  url.searchParams.append("include_fully_consented_data", "true");
105
115
  }
116
+ // Sandbox mode: the acting identity travels as query params
117
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
118
+ if (sandbox) {
119
+ for (const [key, value] of Object.entries(sandboxQueryIdentity(sandbox))) {
120
+ url.searchParams.append(key, value);
121
+ }
122
+ }
106
123
 
107
124
  const response = await fetch(url.toString(), {
108
125
  method: "GET",
109
126
  headers: {
110
- Authorization: `Bearer ${accessToken}`,
127
+ ...buildAuthHeaders(accessToken, sandbox),
111
128
  "Accept-Language": language,
112
129
  },
113
130
  signal,
@@ -141,24 +158,23 @@ export const submitConsentEvent = async ({
141
158
  guardianVerificationReference,
142
159
  selfDeclaredAdult,
143
160
  signal,
161
+ ...sandboxParams
144
162
  }: SubmitConsentEventParams & { signal?: AbortSignal }): Promise<void> => {
145
- if (!noticeUuid || !accessToken || !Array.isArray(purposes)) {
146
- throw new Error("noticeUuid, accessToken, and purposes array are required");
163
+ if (!noticeUuid || !Array.isArray(purposes)) {
164
+ throw new Error("noticeUuid and purposes array are required");
147
165
  }
148
-
149
- const decodedToken = decodeTokenSafely(accessToken);
150
- if (!decodedToken) {
151
- throw new Error("Invalid access token");
166
+ if (!accessToken && !sandboxParams.token) {
167
+ throw new Error("accessToken or token is required");
152
168
  }
153
169
 
154
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
155
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
156
- throw new Error("Invalid token: missing organization or workspace UUID");
157
- }
170
+ const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
171
+ resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
158
172
 
159
173
  // Fall back to the consent server when no ledger is configured, so existing
160
- // integrations that only pass baseUrl remain backwards compatible.
161
- const apiBaseUrl = ledgerBaseUrl || baseUrl || BASE_URL;
174
+ // integrations that only pass baseUrl remain backwards compatible. Sandbox
175
+ // never routes to the Go ledger (no X-Consent-Token support), so a sandbox
176
+ // session forces the consent-server path even if a ledgerBaseUrl is set.
177
+ const apiBaseUrl = (sandbox ? null : ledgerBaseUrl) || baseUrl || BASE_URL;
162
178
 
163
179
  const validatedPurposes = purposes.map((purpose) => {
164
180
  if (!purpose.uuid) {
@@ -192,15 +208,19 @@ export const submitConsentEvent = async ({
192
208
  self_declared_adult: selfDeclaredAdult,
193
209
  };
194
210
 
211
+ // Sandbox mode: merge the acting test identity into the body (the server has
212
+ // no subject header; it namespaces this identity `test::`).
213
+ const requestBody = sandbox ? { ...payload, ...sandboxBodyIdentity(sandbox) } : payload;
214
+
195
215
  const response = await fetch(
196
216
  `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/submit-consent`,
197
217
  {
198
218
  method: "POST",
199
219
  headers: {
200
- Authorization: `Bearer ${accessToken}`,
220
+ ...buildAuthHeaders(accessToken, sandbox),
201
221
  "Content-Type": "application/json",
202
222
  },
203
- body: JSON.stringify(payload),
223
+ body: JSON.stringify(requestBody),
204
224
  signal,
205
225
  }
206
226
  );
@@ -216,157 +236,42 @@ export const submitConsentEvent = async ({
216
236
  apiCache.clear();
217
237
  };
218
238
 
219
- /**
220
- * Fetch the principal's current consent status for a notice from the Go
221
- * ledger (`check-consent`). The principal resolves from the JWT, so the body
222
- * is empty. Returns null on any failure so the caller can fall back to the
223
- * Python-provided overlay (graceful degradation). This is how the banner
224
- * learns prior-consent / reconsent state post-cutover, since Python's notice
225
- * validation reads its own (now-empty) consent DB.
226
- */
227
- export const fetchNoticeConsentStatus = async ({
228
- accessToken,
229
- ledgerBaseUrl,
230
- noticeUuid,
231
- signal,
232
- }: FetchNoticeConsentStatusParams): Promise<NoticeConsentStatusResponse | null> => {
233
- if (!accessToken || !ledgerBaseUrl || !noticeUuid) {
234
- return null;
235
- }
236
-
237
- const decodedToken = decodeTokenSafely(accessToken);
238
- if (!decodedToken) {
239
- return null;
240
- }
241
-
242
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
243
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
244
- return null;
245
- }
246
-
247
- try {
248
- const response = await fetch(
249
- `${ledgerBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/check-consent`,
250
- {
251
- method: "POST",
252
- headers: {
253
- Authorization: `Bearer ${accessToken}`,
254
- "Content-Type": "application/json",
255
- },
256
- body: "{}",
257
- signal,
258
- }
259
- );
260
-
261
- if (!response.ok) {
262
- return null;
263
- }
264
-
265
- const data = (await response.json()) as
266
- | NoticeConsentStatusResponse
267
- | { detail?: NoticeConsentStatusResponse };
268
- // The ledger returns the response directly; tolerate a {detail} envelope too.
269
- const body =
270
- data && typeof data === "object" && "detail" in data && data.detail
271
- ? data.detail
272
- : (data as NoticeConsentStatusResponse);
273
-
274
- if (!body || !Array.isArray(body.purposes)) {
275
- return null;
276
- }
277
- return body;
278
- } catch {
279
- // Network / abort / parse failure — degrade to the Python overlay.
280
- return null;
281
- }
282
- };
283
-
284
- /**
285
- * Translate the ledger's notice consent status into the
286
- * `{ purpose_selections, reconsent_required }` shape the banner already
287
- * consumes, so prior-consent pre-fill and reconsent categorization work off
288
- * the ledger. Returns null for a first-time principal (no consent record —
289
- * every purpose INACTIVE), preserving the normal first-time consent flow.
290
- *
291
- * `needs_reconsent` is derived from `status` (anything not ACTIVE needs
292
- * reconsent: expired / withdrawn / declined / inactive). NOTE: notice-version
293
- * reconsent is NOT covered — the ledger's check-consent has no version
294
- * awareness yet, so a republished-notice version bump won't re-prompt until
295
- * that lands on the ledger (tracked fast-follow).
296
- */
297
- export const buildLedgerConsentOverlay = (
298
- status: NoticeConsentStatusResponse,
299
- configPurposes: ConsentContent["detail"]["active_config"]["purposes"]
300
- ): {
301
- reconsent_required: boolean;
302
- purpose_selections: Record<string, PurposeSelection>;
303
- } | null => {
304
- const byUuid = new Map(status.purposes.map((p) => [p.uuid, p]));
305
- const hasHistory = status.purposes.some(
306
- (p) => (p.status || "INACTIVE") !== "INACTIVE"
307
- );
308
- if (!hasHistory) {
309
- return null;
310
- }
311
-
312
- const purpose_selections: Record<string, PurposeSelection> = {};
313
- for (const cp of configPurposes) {
314
- const lp = byUuid.get(cp.uuid);
315
- const purposeStatus = lp?.status || "INACTIVE";
316
- const selectedByUuid = new Map(
317
- (lp?.data_elements || []).map((de) => [de.uuid, de.selected])
318
- );
319
- const data_elements: Record<string, DataElementSelection> = {};
320
- for (const de of cp.data_elements) {
321
- data_elements[de.uuid] = {
322
- selected: selectedByUuid.get(de.uuid) ?? false,
323
- enabled: de.enabled,
324
- required: de.required,
325
- };
326
- }
327
- purpose_selections[cp.uuid] = {
328
- selected: lp?.selected ?? false,
329
- status: purposeStatus,
330
- needs_reconsent: purposeStatus !== "ACTIVE",
331
- data_elements,
332
- };
333
- }
334
-
335
- const reconsent_required = configPurposes.some(
336
- (cp) => (purpose_selections[cp.uuid]?.status || "INACTIVE") !== "ACTIVE"
337
- );
338
- return { reconsent_required, purpose_selections };
339
- };
340
-
341
239
  export const fetchTTSAudioUrls = async ({
342
240
  accessToken,
343
241
  baseUrl,
242
+ ledgerBaseUrl,
344
243
  noticeUuid,
345
244
  language,
346
245
  signal,
246
+ ...sandboxParams
347
247
  }: FetchTTSAudioUrlsParams & { signal?: AbortSignal }): Promise<TTSAudioUrlsResponse> => {
348
- if (!accessToken || !noticeUuid || !language) {
349
- throw new Error("accessToken, noticeUuid, and language are required");
350
- }
351
-
352
- const decodedToken = decodeTokenSafely(accessToken);
353
- if (!decodedToken) {
354
- throw new Error("Invalid access token");
248
+ if ((!accessToken && !sandboxParams.token) || !noticeUuid || !language) {
249
+ throw new Error("accessToken (or token), noticeUuid, and language are required");
355
250
  }
356
251
 
357
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
358
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
359
- throw new Error("Invalid token: missing organization or workspace UUID");
360
- }
252
+ const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
253
+ resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
361
254
 
362
- const apiBaseUrl = baseUrl || BASE_URL;
255
+ // Prefer the Go ledger for audio, but a sandbox session never routes there:
256
+ // the ledger has no X-Consent-Token support, so it stays on the consent
257
+ // server. Mirrors submitConsentEvent's base-url selection.
258
+ const apiBaseUrl = (sandbox ? null : ledgerBaseUrl) || baseUrl || BASE_URL;
363
259
 
364
- const url = `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/audio/${language}`;
260
+ const url = new URL(
261
+ `${apiBaseUrl}/public/organisations/${ORGANISATION_UUID}/workspaces/${WORKSPACE_UUID}/notices/${noticeUuid}/audio/${language}`
262
+ );
263
+ // Sandbox mode: the acting identity travels as query params
264
+ // (primary_email / primary_mobile); the credential is the X-Consent-Token header.
265
+ if (sandbox) {
266
+ for (const [key, value] of Object.entries(sandboxQueryIdentity(sandbox))) {
267
+ url.searchParams.append(key, value);
268
+ }
269
+ }
365
270
 
366
- const response = await fetch(url, {
271
+ const response = await fetch(url.toString(), {
367
272
  method: "GET",
368
273
  headers: {
369
- Authorization: `Bearer ${accessToken}`,
274
+ ...buildAuthHeaders(accessToken, sandbox),
370
275
  Accept: "application/json",
371
276
  },
372
277
  signal,
@@ -404,6 +309,9 @@ export const initiateGuardianVerification = async ({
404
309
  signal,
405
310
  }: InitiateGuardianVerificationParams): Promise<InitiateGuardianVerificationResponse> => {
406
311
  try {
312
+ // Guardian verification is JWT-only: sandbox sessions have no minor-notice /
313
+ // guardian support (the server 403s them, and C1 blocks minor notices at
314
+ // render), so no sandbox identity is wired through here.
407
315
  if (!accessToken) {
408
316
  throw new Error("accessToken is required");
409
317
  }
@@ -424,8 +332,10 @@ export const initiateGuardianVerification = async ({
424
332
  if (!decodedToken) {
425
333
  throw new Error("Invalid access token");
426
334
  }
427
-
428
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
335
+ const {
336
+ organisation_uuid: ORGANISATION_UUID,
337
+ workspace_uuid: WORKSPACE_UUID,
338
+ } = decodedToken;
429
339
  if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
430
340
  throw new Error("Invalid token: missing organization or workspace UUID");
431
341
  }
@@ -519,6 +429,9 @@ export const verifyGuardianStatus = async ({
519
429
  sessionToken,
520
430
  signal,
521
431
  }: VerifyGuardianStatusParams): Promise<VerifyGuardianStatusResponse> => {
432
+ // Guardian verification is JWT-only: sandbox sessions have no minor-notice /
433
+ // guardian support (the server 403s them, and C1 blocks minor notices at
434
+ // render), so no sandbox identity is wired through here.
522
435
  if (!accessToken || !sessionToken) {
523
436
  throw new Error("accessToken and sessionToken are required");
524
437
  }
@@ -527,8 +440,10 @@ export const verifyGuardianStatus = async ({
527
440
  if (!decodedToken) {
528
441
  throw new Error("Invalid access token");
529
442
  }
530
-
531
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
443
+ const {
444
+ organisation_uuid: ORGANISATION_UUID,
445
+ workspace_uuid: WORKSPACE_UUID,
446
+ } = decodedToken;
532
447
  if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
533
448
  throw new Error("Invalid token: missing organization or workspace UUID");
534
449
  }
@@ -543,9 +458,7 @@ export const verifyGuardianStatus = async ({
543
458
  Authorization: `Bearer ${accessToken}`,
544
459
  "Content-Type": "application/json",
545
460
  },
546
- body: JSON.stringify({
547
- session_token: sessionToken,
548
- }),
461
+ body: JSON.stringify({ session_token: sessionToken }),
549
462
  signal,
550
463
  }
551
464
  );
@@ -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
@@ -19,38 +65,6 @@ export type DataElementSelection = {
19
65
  required: boolean;
20
66
  };
21
67
 
22
- // ─── Ledger notice check-consent ────────────────────────────────────────────
23
- // Mirrors the Go ledger's `NoticeConsentStatusResponse`. Used to overlay the
24
- // banner's prior-consent / reconsent state from the ledger, since Python's
25
- // notice-validation overlay is empty after the consent cutover.
26
- export type NoticeConsentStatusDataElement = {
27
- uuid: string;
28
- name: string;
29
- selected: boolean;
30
- };
31
-
32
- export type NoticeConsentStatusPurpose = {
33
- uuid: string;
34
- name: string;
35
- selected: boolean;
36
- status: string; // ACTIVE | EXPIRED | WITHDRAW | DECLINED | INACTIVE
37
- expiry_datetime?: string | null;
38
- data_elements: NoticeConsentStatusDataElement[];
39
- };
40
-
41
- export type NoticeConsentStatusResponse = {
42
- consented: boolean;
43
- all_mandatory_active: boolean;
44
- purposes: NoticeConsentStatusPurpose[];
45
- };
46
-
47
- export type FetchNoticeConsentStatusParams = {
48
- accessToken: string;
49
- ledgerBaseUrl?: string;
50
- noticeUuid: string;
51
- signal?: AbortSignal;
52
- };
53
-
54
68
  export type ConsentContent = {
55
69
  code: number;
56
70
  status: string;
@@ -178,10 +192,10 @@ export type Settings = {
178
192
  font?: string;
179
193
  };
180
194
 
181
- export type FetchConsentContentParams = {
195
+ export type FetchConsentContentParams = SandboxAuthParams & {
182
196
  noticeId: string;
183
- accessToken: string;
184
- refreshToken: string;
197
+ accessToken?: string;
198
+ refreshToken?: string;
185
199
  baseUrl?: string;
186
200
  language?: string;
187
201
  specific_uuid?: string;
@@ -228,8 +242,8 @@ export type Purpose = {
228
242
  data_elements: Array<DataElement>;
229
243
  };
230
244
 
231
- export type SubmitConsentEventParams = {
232
- accessToken: string;
245
+ export type SubmitConsentEventParams = SandboxAuthParams & {
246
+ accessToken?: string;
233
247
  baseUrl?: string;
234
248
  ledgerBaseUrl?: string;
235
249
  noticeUuid: string;
@@ -352,9 +366,10 @@ export type DpoInfoAudio = {
352
366
  grievance_email_audio_url?: string;
353
367
  };
354
368
 
355
- export type FetchTTSAudioUrlsParams = {
356
- accessToken: string;
369
+ export type FetchTTSAudioUrlsParams = SandboxAuthParams & {
370
+ accessToken?: string;
357
371
  baseUrl?: string;
372
+ ledgerBaseUrl?: string;
358
373
  noticeUuid: string;
359
374
  language: string;
360
375
  };
@@ -367,8 +382,8 @@ export type FetchTTSAudioUrlsParams = {
367
382
  * Parameters for initiating guardian verification
368
383
  * POST /guardian/initiate-verification
369
384
  */
370
- export type InitiateGuardianVerificationParams = {
371
- accessToken: string;
385
+ export type InitiateGuardianVerificationParams = SandboxAuthParams & {
386
+ accessToken?: string;
372
387
  baseUrl?: string;
373
388
  guardianName: string;
374
389
  guardianContact: string;
@@ -403,8 +418,8 @@ export type InitiateGuardianVerificationResponse = {
403
418
  * Parameters for checking verification status
404
419
  * POST /guardian/verify-status
405
420
  */
406
- export type VerifyGuardianStatusParams = {
407
- accessToken: string;
421
+ export type VerifyGuardianStatusParams = SandboxAuthParams & {
422
+ accessToken?: string;
408
423
  baseUrl?: string;
409
424
  sessionToken: string;
410
425
  signal?: AbortSignal;