@redacto.io/consent-sdk-react 10.0.0-beta.0 → 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 (89) hide show
  1. package/.turbo/turbo-build.log +22 -25
  2. package/CHANGELOG.md +123 -0
  3. package/README.md +78 -0
  4. package/dist/{chunk-6A665OPR.mjs → chunk-OGL6OC3A.mjs} +4 -0
  5. package/dist/index.d.mts +43 -8
  6. package/dist/index.d.ts +43 -8
  7. package/dist/index.js +345 -237
  8. package/dist/index.mjs +344 -238
  9. package/dist/privacy-center.d.mts +113 -37
  10. package/dist/privacy-center.d.ts +113 -37
  11. package/dist/privacy-center.js +2317 -1910
  12. package/dist/privacy-center.mjs +2245 -1835
  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 +75 -33
  22. package/src/RedactoNoticeConsent/api/index.ts +90 -177
  23. package/src/RedactoNoticeConsent/api/sandbox.ts +150 -0
  24. package/src/RedactoNoticeConsent/api/types.ts +60 -44
  25. package/src/RedactoNoticeConsent/language-codes.ts +0 -2
  26. package/src/RedactoNoticeConsent/types.ts +29 -3
  27. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +86 -5
  28. package/src/RedactoNoticeConsentInline/api/index.ts +14 -5
  29. package/src/RedactoNoticeConsentInline/api/notice-read-routing.test.ts +72 -0
  30. package/src/RedactoNoticeConsentInline/api/types.ts +4 -1
  31. package/src/RedactoNoticeConsentInline/types.ts +1 -1
  32. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +8 -5
  33. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +38 -3
  34. package/src/RedactoPrivacyCenter/api/actions.ts +13 -5
  35. package/src/RedactoPrivacyCenter/api/client.ts +216 -39
  36. package/src/RedactoPrivacyCenter/api/fetcher.ts +24 -9
  37. package/src/RedactoPrivacyCenter/components/AppShell/AppShell.tsx +28 -0
  38. package/src/RedactoPrivacyCenter/components/AppShell/UserMenu.tsx +107 -76
  39. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +2 -1
  40. package/src/RedactoPrivacyCenter/components/Form.tsx +53 -24
  41. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -1
  42. package/src/RedactoPrivacyCenter/components/SelectUser.tsx +95 -0
  43. package/src/RedactoPrivacyCenter/components/SelectUserGate.tsx +195 -0
  44. package/src/RedactoPrivacyCenter/components/constant.ts +11 -0
  45. package/src/RedactoPrivacyCenter/components/groupHelpers.ts +42 -6
  46. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +131 -39
  47. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +1 -1
  48. package/src/RedactoPrivacyCenter/context/ProfileSwitchContext.tsx +15 -0
  49. package/src/RedactoPrivacyCenter/context/types.ts +39 -1
  50. package/src/RedactoPrivacyCenter/lib/api.ts +3 -0
  51. package/src/RedactoPrivacyCenter/lib/constants.ts +12 -6
  52. package/src/RedactoPrivacyCenter/lib/locales.ts +0 -3
  53. package/src/RedactoPrivacyCenter/lib/sandbox.ts +104 -0
  54. package/src/RedactoPrivacyCenter/lib/types.ts +78 -2
  55. package/src/RedactoPrivacyCenter/lib/utils.ts +15 -0
  56. package/src/RedactoPrivacyCenter/locales/as/translation.json +7 -1
  57. package/src/RedactoPrivacyCenter/locales/bn/translation.json +7 -1
  58. package/src/RedactoPrivacyCenter/locales/brx/translation.json +7 -1
  59. package/src/RedactoPrivacyCenter/locales/doi/translation.json +7 -1
  60. package/src/RedactoPrivacyCenter/locales/en/translation.json +8 -1
  61. package/src/RedactoPrivacyCenter/locales/gom/translation.json +7 -1
  62. package/src/RedactoPrivacyCenter/locales/gu/translation.json +7 -1
  63. package/src/RedactoPrivacyCenter/locales/hi/translation.json +7 -1
  64. package/src/RedactoPrivacyCenter/locales/kn/translation.json +7 -1
  65. package/src/RedactoPrivacyCenter/locales/ks/translation.json +7 -1
  66. package/src/RedactoPrivacyCenter/locales/mai/translation.json +7 -1
  67. package/src/RedactoPrivacyCenter/locales/ml/translation.json +7 -1
  68. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +7 -1
  69. package/src/RedactoPrivacyCenter/locales/mr/translation.json +7 -1
  70. package/src/RedactoPrivacyCenter/locales/ne/translation.json +7 -1
  71. package/src/RedactoPrivacyCenter/locales/or/translation.json +7 -1
  72. package/src/RedactoPrivacyCenter/locales/pa/translation.json +7 -1
  73. package/src/RedactoPrivacyCenter/locales/sa/translation.json +7 -1
  74. package/src/RedactoPrivacyCenter/locales/sat/translation.json +7 -1
  75. package/src/RedactoPrivacyCenter/locales/sd/translation.json +7 -1
  76. package/src/RedactoPrivacyCenter/locales/ta/translation.json +7 -1
  77. package/src/RedactoPrivacyCenter/locales/te/translation.json +7 -1
  78. package/src/RedactoPrivacyCenter/locales/ur/translation.json +7 -1
  79. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +112 -2
  80. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +17 -0
  81. package/src/RedactoPrivacyCenter/ui/PCAvatar.test.tsx +83 -0
  82. package/src/RedactoPrivacyCenter/ui/PCAvatar.tsx +33 -9
  83. package/src/shared/notice-consent-status.ts +190 -0
  84. package/src/shared/sandbox.ts +6 -0
  85. package/tests/Form.test.tsx +190 -7
  86. package/tests/RevokeWarningMessage.test.tsx +151 -0
  87. package/tests/consentClient.test.ts +217 -0
  88. package/tests/manageConsentServer.test.ts +38 -0
  89. package/src/RedactoPrivacyCenter/locales/bho/translation.json +0 -334
@@ -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,
@@ -131,6 +148,7 @@ export const fetchConsentContent = async ({
131
148
 
132
149
  export const submitConsentEvent = async ({
133
150
  accessToken,
151
+ baseUrl,
134
152
  ledgerBaseUrl,
135
153
  noticeUuid,
136
154
  purposes,
@@ -140,25 +158,23 @@ export const submitConsentEvent = async ({
140
158
  guardianVerificationReference,
141
159
  selfDeclaredAdult,
142
160
  signal,
161
+ ...sandboxParams
143
162
  }: SubmitConsentEventParams & { signal?: AbortSignal }): Promise<void> => {
144
- if (!noticeUuid || !accessToken || !Array.isArray(purposes)) {
145
- 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");
146
165
  }
147
-
148
- const decodedToken = decodeTokenSafely(accessToken);
149
- if (!decodedToken) {
150
- throw new Error("Invalid access token");
166
+ if (!accessToken && !sandboxParams.token) {
167
+ throw new Error("accessToken or token is required");
151
168
  }
152
169
 
153
- const { organisation_uuid: ORGANISATION_UUID, workspace_uuid: WORKSPACE_UUID } = decodedToken;
154
- if (!ORGANISATION_UUID || !WORKSPACE_UUID) {
155
- throw new Error("Invalid token: missing organization or workspace UUID");
156
- }
170
+ const { organisationUuid: ORGANISATION_UUID, workspaceUuid: WORKSPACE_UUID, sandbox } =
171
+ resolveRequestAuth(accessToken, sandboxParams, decodeTokenSafely);
157
172
 
158
- if (!ledgerBaseUrl) {
159
- throw new Error("ledgerBaseUrl is required to submit consent");
160
- }
161
- const apiBaseUrl = ledgerBaseUrl;
173
+ // Fall back to the consent server when no ledger is configured, so existing
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);