@phygitallabs/phygital-consent 1.0.1 → 1.0.3

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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Hash user_id or device_id with SHA-256 for the two POST consent hooks
3
+ * (createUserConsent, createDeviceCookieConsent).
4
+ * Input: string, encoded as UTF-8 before hashing.
5
+ * Output: lowercase hex string.
6
+ * Uses Web Crypto when available; falls back to pure-JS SHA-256 for old devices.
7
+ */
8
+
9
+ /** Encode hash digest bytes as lowercase hex string. */
10
+ function arrayBufferToHex(buffer: ArrayBuffer): string {
11
+ return Array.from(new Uint8Array(buffer))
12
+ .map((b) => b.toString(16).padStart(2, "0"))
13
+ .join("")
14
+ .toLowerCase();
15
+ }
16
+
17
+ /**
18
+ * SHA-256 via Web Crypto (modern browsers, Node 19+).
19
+ * Input encoded as UTF-8; output lowercase hex.
20
+ */
21
+ async function sha256WebCrypto(value: string): Promise<string> {
22
+ const utf8 = new TextEncoder().encode(value);
23
+ const hashBuffer = await crypto.subtle.digest("SHA-256", utf8);
24
+ return arrayBufferToHex(hashBuffer);
25
+ }
26
+
27
+ /**
28
+ * Pure-JS SHA-256 fallback for environments without crypto.subtle
29
+ * (e.g. old browsers, insecure context, Node < 19).
30
+ * Input encoded as UTF-8; output lowercase hex.
31
+ */
32
+ function sha256Fallback(value: string): string {
33
+ const bytes = utf8Encode(value);
34
+ const K = getK();
35
+ const H = getH();
36
+ const W = new Uint32Array(64);
37
+ const numBlocks = bytes.length >> 6;
38
+
39
+ for (let block = 0; block < numBlocks; block++) {
40
+ const start = block << 6;
41
+ for (let i = 0; i < 16; i++) {
42
+ const o = start + (i << 2);
43
+ W[i] =
44
+ (bytes[o]! << 24) |
45
+ (bytes[o + 1]! << 16) |
46
+ (bytes[o + 2]! << 8) |
47
+ bytes[o + 3]!;
48
+ }
49
+ for (let i = 16; i < 64; i++) {
50
+ const s0 = rotr(W[i - 15]!, 7) ^ rotr(W[i - 15]!, 18) ^ (W[i - 15]! >>> 3);
51
+ const s1 = rotr(W[i - 2]!, 17) ^ rotr(W[i - 2]!, 19) ^ (W[i - 2]! >>> 10);
52
+ W[i] = (W[i - 16]! + s0 + W[i - 7]! + s1) >>> 0;
53
+ }
54
+ let a = H[0]!,
55
+ b = H[1]!,
56
+ c = H[2]!,
57
+ d = H[3]!,
58
+ e = H[4]!,
59
+ f = H[5]!,
60
+ g = H[6]!,
61
+ h = H[7]!;
62
+ for (let i = 0; i < 64; i++) {
63
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
64
+ const ch = (e & f) ^ (~e & g);
65
+ const t1 = (h + S1 + ch + K[i]! + W[i]!) >>> 0;
66
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
67
+ const maj = (a & b) ^ (a & c) ^ (b & c);
68
+ const t2 = (S0 + maj) >>> 0;
69
+ h = g;
70
+ g = f;
71
+ f = e;
72
+ e = (d + t1) >>> 0;
73
+ d = c;
74
+ c = b;
75
+ b = a;
76
+ a = (t1 + t2) >>> 0;
77
+ }
78
+ H[0] = (H[0]! + a) >>> 0;
79
+ H[1] = (H[1]! + b) >>> 0;
80
+ H[2] = (H[2]! + c) >>> 0;
81
+ H[3] = (H[3]! + d) >>> 0;
82
+ H[4] = (H[4]! + e) >>> 0;
83
+ H[5] = (H[5]! + f) >>> 0;
84
+ H[6] = (H[6]! + g) >>> 0;
85
+ H[7] = (H[7]! + h) >>> 0;
86
+ }
87
+
88
+ let out = "";
89
+ for (let i = 0; i < 8; i++) {
90
+ const v = H[i]!;
91
+ out +=
92
+ (v >>> 28).toString(16) +
93
+ ((v >>> 24) & 0xf).toString(16) +
94
+ ((v >>> 20) & 0xf).toString(16) +
95
+ ((v >>> 16) & 0xf).toString(16) +
96
+ ((v >>> 12) & 0xf).toString(16) +
97
+ ((v >>> 8) & 0xf).toString(16) +
98
+ ((v >>> 4) & 0xf).toString(16) +
99
+ (v & 0xf).toString(16);
100
+ }
101
+ return out.toLowerCase();
102
+ }
103
+
104
+ function rotr(n: number, b: number): number {
105
+ return (n >>> b) | (n << (32 - b));
106
+ }
107
+
108
+ /** Encode string to UTF-8 bytes (with SHA-256 padding for block processing). */
109
+ function utf8Encode(s: string): Uint8Array {
110
+ const n = s.length;
111
+ const bytes: number[] = [];
112
+ for (let i = 0; i < n; i++) {
113
+ let c = s.charCodeAt(i);
114
+ if (c < 0x80) {
115
+ bytes.push(c);
116
+ } else if (c < 0x800) {
117
+ bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
118
+ } else if (c < 0xd800 || c >= 0xe000) {
119
+ bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
120
+ } else {
121
+ c = 0x10000 + (((c & 0x3ff) << 10) | (s.charCodeAt(++i) & 0x3ff));
122
+ bytes.push(
123
+ 0xf0 | (c >> 18),
124
+ 0x80 | ((c >> 12) & 0x3f),
125
+ 0x80 | ((c >> 6) & 0x3f),
126
+ 0x80 | (c & 0x3f)
127
+ );
128
+ }
129
+ }
130
+ const len = bytes.length;
131
+ const pad = (64 - ((len + 9) % 64)) % 64;
132
+ const total = len + 9 + pad;
133
+ const out = new Uint8Array(total);
134
+ out.set(bytes);
135
+ out[len] = 0x80;
136
+ const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
137
+ view.setUint32(total - 8, 0, false);
138
+ view.setUint32(total - 4, (len * 8) >>> 0, false);
139
+ return out;
140
+ }
141
+
142
+ function getK(): number[] {
143
+ const k: number[] = [];
144
+ const hex =
145
+ "428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2";
146
+ hex.split(" ").forEach((h) => k.push(parseInt(h, 16)));
147
+ return k;
148
+ }
149
+
150
+ function getH(): number[] {
151
+ return [
152
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
153
+ 0x1f83d9ab, 0x5be0cd19,
154
+ ];
155
+ }
156
+
157
+ function hasWebCrypto(): boolean {
158
+ if (typeof crypto === "undefined" || !crypto.subtle) return false;
159
+ return true;
160
+ }
161
+
162
+ /**
163
+ * Hash a string with SHA-256.
164
+ * Input: encoded as UTF-8. Output: lowercase hex string.
165
+ * Use for user_id and device_id before calling createUserConsent / createDeviceCookieConsent.
166
+ * Uses Web Crypto when available; falls back to pure-JS for old devices (no crypto.subtle).
167
+ */
168
+ export async function sha256(value: string): Promise<string> {
169
+ if (typeof value !== "string" || value.length === 0) {
170
+ return value;
171
+ }
172
+ if (hasWebCrypto()) {
173
+ return sha256WebCrypto(value);
174
+ }
175
+ return Promise.resolve(sha256Fallback(value));
176
+ }
@@ -5,8 +5,9 @@ import {
5
5
  UseQueryOptions,
6
6
  UseMutationOptions,
7
7
  } from "@tanstack/react-query";
8
- import { useConsentService } from "../provider/ConsentServiceProvider";
9
8
  import { consentService } from "../api/consent";
9
+ import { sha256 } from "../helpers/sha256";
10
+ import { useConsentService } from "../provider/ConsentServiceProvider";
10
11
  import type {
11
12
  GetManyConsentParams,
12
13
  GetManyConsentResponse,
@@ -83,7 +84,7 @@ export const useConsentById = (
83
84
  });
84
85
  };
85
86
 
86
- /** Get latest consent for a user – GET /consent/v1/user-id */
87
+ /** Get latest consent for a user – GET /consent/v1/user-id (user_id hashed before send) */
87
88
  export const useLatestConsentByUserId = (
88
89
  params: GetLatestConsentByUserIdParams,
89
90
  options?: UseQueryOptions<GetLatestConsentByUserIdResponse>
@@ -93,13 +94,16 @@ export const useLatestConsentByUserId = (
93
94
 
94
95
  return useQuery<GetLatestConsentByUserIdResponse>({
95
96
  queryKey: consentQueryKeys.latestByUserId(params.user_id),
96
- queryFn: () => service.getLatestConsentByUserId(params),
97
+ queryFn: async () => {
98
+ const user_id = await sha256(params.user_id);
99
+ return service.getLatestConsentByUserId({ user_id });
100
+ },
97
101
  enabled: !!params.user_id,
98
102
  ...options,
99
103
  });
100
104
  };
101
105
 
102
- /** Get latest cookie consent for a device – GET /consent/v1/cookies/device-id */
106
+ /** Get latest cookie consent for a device – GET /consent/v1/cookies/device-id (device_id hashed before send) */
103
107
  export const useLatestCookieConsentByDeviceId = (
104
108
  params: GetLatestCookieConsentByDeviceIdParams,
105
109
  options?: UseQueryOptions<GetLatestCookieConsentByDeviceIdResponse>
@@ -109,7 +113,10 @@ export const useLatestCookieConsentByDeviceId = (
109
113
 
110
114
  return useQuery<GetLatestCookieConsentByDeviceIdResponse>({
111
115
  queryKey: consentQueryKeys.latestCookieByDeviceId(params.device_id),
112
- queryFn: () => service.getLatestCookieConsentByDeviceId(params),
116
+ queryFn: async () => {
117
+ const device_id = await sha256(params.device_id);
118
+ return service.getLatestCookieConsentByDeviceId({ device_id });
119
+ },
113
120
  enabled: !!params.device_id,
114
121
  ...options,
115
122
  });
@@ -130,7 +137,16 @@ export const useCreateUserConsent = (
130
137
  const service = consentService(consentApi);
131
138
 
132
139
  return useMutation<CreateUserConsentResponse, Error, CreateUserConsentUpsertDTO>({
133
- mutationFn: (body: CreateUserConsentUpsertDTO) => service.createUserConsent(body),
140
+ mutationFn: async (body: CreateUserConsentUpsertDTO) => {
141
+ const payload = { ...body };
142
+ if (body.user_id != null && body.user_id !== "") {
143
+ payload.user_id = await sha256(body.user_id);
144
+ }
145
+ if (body.device_id != null && body.device_id !== "") {
146
+ payload.device_id = await sha256(body.device_id);
147
+ }
148
+ return service.createUserConsent(payload);
149
+ },
134
150
  onSuccess: (
135
151
  _data: CreateUserConsentResponse,
136
152
  variables: CreateUserConsentUpsertDTO
@@ -163,8 +179,13 @@ export const useCreateDeviceCookieConsent = (
163
179
  Error,
164
180
  CreateDeviceCookieConsentUpsertDTO
165
181
  >({
166
- mutationFn: (body: CreateDeviceCookieConsentUpsertDTO) =>
167
- service.createDeviceCookieConsent(body),
182
+ mutationFn: async (body: CreateDeviceCookieConsentUpsertDTO) => {
183
+ const payload = {
184
+ ...body,
185
+ device_id: await sha256(body.device_id),
186
+ };
187
+ return service.createDeviceCookieConsent(payload);
188
+ },
168
189
  onSuccess: (
169
190
  _data: CreateDeviceCookieConsentResponse,
170
191
  variables: CreateDeviceCookieConsentUpsertDTO
@@ -5,6 +5,7 @@ import { getConsentPreferenceCookie } from "../helpers/cookie";
5
5
  import { ConsentServiceProvider, ConsentServiceProviderProps } from "./ConsentServiceProvider";
6
6
 
7
7
  export interface PhygitalConsentContextValue {
8
+ deviceId: string;
8
9
  /** True when cookie phygital_cookie_consent exists and has valid deviceId + selected_preferences. Use to show/hide cookie banner. */
9
10
  hasConsentPreference: boolean;
10
11
  /** Re-read cookie and update hasConsentPreference. Call after storing preference (e.g. from CookieConsentBanner onSuccess). */
@@ -14,6 +15,7 @@ export interface PhygitalConsentContextValue {
14
15
  const PhygitalConsentContext = createContext<PhygitalConsentContextValue | undefined>(undefined);
15
16
 
16
17
  export interface PhygitalConsentProviderProps extends ConsentServiceProviderProps {
18
+ deviceId: string;
17
19
  children: React.ReactNode;
18
20
  }
19
21
 
@@ -23,7 +25,7 @@ export interface PhygitalConsentProviderProps extends ConsentServiceProviderProp
23
25
  * the cookie banner based on whether the user has already chosen a preference.
24
26
  */
25
27
  export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
26
- const { children, ...consentServiceProps } = props;
28
+ const { children, deviceId, ...consentServiceProps } = props;
27
29
  const [hasConsentPreference, setHasConsentPreference] = useState(false);
28
30
 
29
31
  const refreshConsentPreference = useCallback(() => {
@@ -34,8 +36,9 @@ export function PhygitalConsentProvider(props: PhygitalConsentProviderProps) {
34
36
  useEffect(() => {
35
37
  refreshConsentPreference();
36
38
  }, [refreshConsentPreference]);
37
-
39
+
38
40
  const value: PhygitalConsentContextValue = {
41
+ deviceId,
39
42
  hasConsentPreference,
40
43
  refreshConsentPreference,
41
44
  };