@dpdpguard/react-native 0.0.0

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 (71) hide show
  1. package/.github/workflows/ci.yml +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/ReactNative.podspec +20 -0
  5. package/android/build.gradle +51 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/dpdpguard/reactnative/ReactNativeModule.kt +15 -0
  8. package/android/src/main/java/com/dpdpguard/reactnative/ReactNativePackage.kt +31 -0
  9. package/example/.bundle/config +2 -0
  10. package/example/.watchmanconfig +1 -0
  11. package/example/Gemfile +17 -0
  12. package/example/README.md +97 -0
  13. package/example/android/app/build.gradle +119 -0
  14. package/example/android/app/debug.keystore +0 -0
  15. package/example/android/app/proguard-rules.pro +10 -0
  16. package/example/android/app/src/main/AndroidManifest.xml +27 -0
  17. package/example/android/app/src/main/java/com/dpdpguard.reactnative.example/MainActivity.kt +22 -0
  18. package/example/android/app/src/main/java/com/dpdpguard.reactnative.example/MainApplication.kt +27 -0
  19. package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +37 -0
  20. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  21. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  22. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  23. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  24. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  25. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  26. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  27. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  28. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  29. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  30. package/example/android/app/src/main/res/values/strings.xml +3 -0
  31. package/example/android/app/src/main/res/values/styles.xml +9 -0
  32. package/example/android/build.gradle +21 -0
  33. package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  34. package/example/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  35. package/example/android/gradle.properties +44 -0
  36. package/example/android/gradlew +248 -0
  37. package/example/android/gradlew.bat +98 -0
  38. package/example/android/settings.gradle +6 -0
  39. package/example/app.json +4 -0
  40. package/example/babel.config.js +12 -0
  41. package/example/index.js +5 -0
  42. package/example/ios/.xcode.env +11 -0
  43. package/example/ios/Podfile +34 -0
  44. package/example/ios/ReactNativeExample/AppDelegate.swift +48 -0
  45. package/example/ios/ReactNativeExample/Images.xcassets/AppIcon.appiconset/Contents.json +53 -0
  46. package/example/ios/ReactNativeExample/Images.xcassets/Contents.json +6 -0
  47. package/example/ios/ReactNativeExample/Info.plist +59 -0
  48. package/example/ios/ReactNativeExample/LaunchScreen.storyboard +47 -0
  49. package/example/ios/ReactNativeExample/PrivacyInfo.xcprivacy +37 -0
  50. package/example/ios/ReactNativeExample.xcodeproj/project.pbxproj +475 -0
  51. package/example/ios/ReactNativeExample.xcodeproj/xcshareddata/xcschemes/ReactNativeExample.xcscheme +88 -0
  52. package/example/jest.config.js +3 -0
  53. package/example/metro.config.js +11 -0
  54. package/example/package.json +34 -0
  55. package/example/react-native.config.js +21 -0
  56. package/ios/ReactNative.h +5 -0
  57. package/ios/ReactNative.mm +21 -0
  58. package/jest.config.js +6 -0
  59. package/package.json +49 -0
  60. package/scripts/codegen.mjs +31 -0
  61. package/src/NativeReactNative.ts +7 -0
  62. package/src/client.test.ts +119 -0
  63. package/src/client.ts +203 -0
  64. package/src/consentGate.test.ts +17 -0
  65. package/src/consentGate.ts +14 -0
  66. package/src/errorCatalog.test.ts +19 -0
  67. package/src/errorCatalog.ts +39 -0
  68. package/src/index.tsx +8 -0
  69. package/src/multiply.native.tsx +5 -0
  70. package/src/multiply.tsx +3 -0
  71. package/tsconfig.json +18 -0
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@dpdpguard/react-native-example",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "scripts": {
6
+ "android": "react-native run-android",
7
+ "ios": "react-native run-ios",
8
+ "start": "react-native start",
9
+ "build:android": "react-native build-android --extra-params \"--no-daemon --console=plain -PreactNativeArchitectures=arm64-v8a\"",
10
+ "build:ios": "react-native build-ios --mode Debug"
11
+ },
12
+ "dependencies": {
13
+ "react": "19.2.3",
14
+ "react-native": "0.85.0"
15
+ },
16
+ "devDependencies": {
17
+ "@babel/core": "^7.25.2",
18
+ "@babel/preset-env": "^7.25.3",
19
+ "@babel/runtime": "^7.25.0",
20
+ "@react-native-community/cli": "20.1.0",
21
+ "@react-native-community/cli-platform-android": "20.1.0",
22
+ "@react-native-community/cli-platform-ios": "20.1.0",
23
+ "@react-native/babel-preset": "0.85.0",
24
+ "@react-native/jest-preset": "0.85.0",
25
+ "@react-native/metro-config": "0.85.0",
26
+ "@react-native/typescript-config": "0.85.0",
27
+ "@types/react": "^19.2.0",
28
+ "react-native-builder-bob": "^0.41.0",
29
+ "react-native-monorepo-config": "^0.4.0"
30
+ },
31
+ "engines": {
32
+ "node": ">= 22.11.0"
33
+ }
34
+ }
@@ -0,0 +1,21 @@
1
+ const path = require('path');
2
+ const pkg = require('../package.json');
3
+
4
+ module.exports = {
5
+ project: {
6
+ ios: {
7
+ automaticPodsInstallation: true,
8
+ },
9
+ },
10
+ dependencies: {
11
+ [pkg.name]: {
12
+ root: path.join(__dirname, '..'),
13
+ platforms: {
14
+ // Codegen script incorrectly fails without this
15
+ // So we explicitly specify the platforms with empty object
16
+ ios: {},
17
+ android: {},
18
+ },
19
+ },
20
+ },
21
+ };
@@ -0,0 +1,5 @@
1
+ #import <ReactNativeSpec/ReactNativeSpec.h>
2
+
3
+ @interface ReactNative : NSObject <NativeReactNativeSpec>
4
+
5
+ @end
@@ -0,0 +1,21 @@
1
+ #import "ReactNative.h"
2
+
3
+ @implementation ReactNative
4
+ - (NSNumber *)multiply:(double)a b:(double)b {
5
+ NSNumber *result = @(a * b);
6
+
7
+ return result;
8
+ }
9
+
10
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
11
+ (const facebook::react::ObjCTurboModule::InitParams &)params
12
+ {
13
+ return std::make_shared<facebook::react::NativeReactNativeSpecJSI>(params);
14
+ }
15
+
16
+ + (NSString *)moduleName
17
+ {
18
+ return @"ReactNative";
19
+ }
20
+
21
+ @end
package/jest.config.js ADDED
@@ -0,0 +1,6 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ testMatch: ['<rootDir>/src/**/*.test.ts'],
6
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@dpdpguard/react-native",
3
+ "version": "0.0.0",
4
+ "description": "DPDP Guard React Native SDK - bridge over the native Android/iOS consent engines",
5
+ "main": "src/index",
6
+ "scripts": {
7
+ "codegen": "node scripts/codegen.mjs",
8
+ "postinstall": "node scripts/codegen.mjs",
9
+ "test": "jest",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "codegenConfig": {
13
+ "name": "ReactNativeSpec",
14
+ "type": "modules",
15
+ "jsSrcsDir": "src",
16
+ "android": {
17
+ "javaPackageName": "com.dpdpguard.reactnative"
18
+ }
19
+ },
20
+ "author": " <> ()",
21
+ "license": "MIT",
22
+ "homepage": "#readme",
23
+ "devDependencies": {
24
+ "@react-native/babel-preset": "0.85.0",
25
+ "@types/jest": "^29.5.14",
26
+ "@types/node": "^22.20.1",
27
+ "@types/react": "^19.2.0",
28
+ "jest": "^29.7.0",
29
+ "react": "19.2.3",
30
+ "react-native": "0.85.0",
31
+ "ts-jest": "^29.4.11",
32
+ "typescript": "~5.8"
33
+ },
34
+ "create-react-native-library": {
35
+ "type": "turbo-module",
36
+ "languages": "kotlin-objc",
37
+ "tools": [
38
+ "eslint",
39
+ "jest",
40
+ "lefthook",
41
+ "release-it",
42
+ "vite"
43
+ ],
44
+ "version": "0.63.0"
45
+ },
46
+ "dependencies": {
47
+ "@dpdpguard/contract": "^0.2.0"
48
+ }
49
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * Regenerates src/generated/api-types.ts from the installed
9
+ * @dpdpguard/contract's openapi/v1.yaml. Run after every install/update of
10
+ * @dpdpguard/contract, or via `npm run codegen`.
11
+ *
12
+ * Uses `npx openapi-typescript` rather than a devDependency, matching
13
+ * dpdpguard-server-sdk's approach — keeps this repo's own TypeScript
14
+ * version free of a forced peer-dependency resolution.
15
+ */
16
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ const specPath = join(repoRoot, "node_modules", "@dpdpguard", "contract", "openapi", "v1.yaml");
18
+ const outPath = join(repoRoot, "src", "generated", "api-types.ts");
19
+
20
+ if (!existsSync(specPath)) {
21
+ console.error(`codegen: ${specPath} not found — is @dpdpguard/contract installed? Run npm install first.`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const result = spawnSync(
26
+ "npx",
27
+ ["--yes", "openapi-typescript@7", specPath, "-o", outPath],
28
+ { stdio: "inherit", shell: process.platform === "win32" },
29
+ );
30
+
31
+ process.exit(result.status ?? 1);
@@ -0,0 +1,7 @@
1
+ import { TurboModuleRegistry, type TurboModule } from 'react-native';
2
+
3
+ export interface Spec extends TurboModule {
4
+ multiply(a: number, b: number): number;
5
+ }
6
+
7
+ export default TurboModuleRegistry.getEnforcing<Spec>('ReactNative');
@@ -0,0 +1,119 @@
1
+ import { DpdpGuardClient } from "./client";
2
+ import { DpdpGuardApiError } from "./errorCatalog";
3
+
4
+ function jsonResponse(status: number, body: unknown): Response {
5
+ return new Response(JSON.stringify(body), {
6
+ status,
7
+ headers: { "Content-Type": "application/json" },
8
+ });
9
+ }
10
+
11
+ describe("DpdpGuardClient", () => {
12
+ test("getOrganization calls the public endpoint with no Authorization header", async () => {
13
+ const fetchImpl = jest
14
+ .fn()
15
+ .mockResolvedValue(
16
+ jsonResponse(200, { orgId: "org_1", name: "Acme", slug: "acme" }),
17
+ );
18
+ const client = new DpdpGuardClient({
19
+ baseUrl: "https://example.convex.site",
20
+ fetchImpl,
21
+ });
22
+
23
+ const org = await client.getOrganization("acme");
24
+
25
+ expect(org).toEqual({ orgId: "org_1", name: "Acme", slug: "acme" });
26
+ const [url, init] = fetchImpl.mock.calls[0];
27
+ expect(url).toBe("https://example.convex.site/api/v1/org/acme");
28
+ expect(init.headers.Authorization).toBeUndefined();
29
+ });
30
+
31
+ test("an authenticated call without setAccessToken() throws before hitting the network", async () => {
32
+ const fetchImpl = jest.fn();
33
+ const client = new DpdpGuardClient({
34
+ baseUrl: "https://example.convex.site",
35
+ fetchImpl,
36
+ });
37
+
38
+ await expect(client.listDsrRequests()).rejects.toThrow(/access token/i);
39
+ expect(fetchImpl).not.toHaveBeenCalled();
40
+ });
41
+
42
+ test("setAccessToken() makes subsequent authenticated calls use the new token", async () => {
43
+ const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(200, { requests: [] }));
44
+ const client = new DpdpGuardClient({
45
+ baseUrl: "https://example.convex.site",
46
+ fetchImpl,
47
+ });
48
+
49
+ client.setAccessToken("token-from-my-backend");
50
+ await client.listDsrRequests();
51
+
52
+ const [, init] = fetchImpl.mock.calls[0];
53
+ expect(init.headers.Authorization).toBe("Bearer token-from-my-backend");
54
+ });
55
+
56
+ test("a constructor-supplied accessToken is used immediately", async () => {
57
+ const fetchImpl = jest.fn().mockResolvedValue(jsonResponse(200, { requests: [] }));
58
+ const client = new DpdpGuardClient({
59
+ baseUrl: "https://example.convex.site",
60
+ accessToken: "initial-token",
61
+ fetchImpl,
62
+ });
63
+
64
+ await client.listDsrRequests();
65
+
66
+ const [, init] = fetchImpl.mock.calls[0];
67
+ expect(init.headers.Authorization).toBe("Bearer initial-token");
68
+ });
69
+
70
+ test("a non-2xx response is thrown as a typed DpdpGuardApiError", async () => {
71
+ const fetchImpl = jest
72
+ .fn()
73
+ .mockImplementation(() =>
74
+ Promise.resolve(
75
+ jsonResponse(404, { code: "NOT_FOUND", error: "No such organization" }),
76
+ ),
77
+ );
78
+ const client = new DpdpGuardClient({
79
+ baseUrl: "https://example.convex.site",
80
+ fetchImpl,
81
+ });
82
+
83
+ await expect(client.getOrganization("missing")).rejects.toMatchObject({
84
+ code: "NOT_FOUND",
85
+ status: 404,
86
+ });
87
+ await expect(client.getOrganization("missing")).rejects.toBeInstanceOf(
88
+ DpdpGuardApiError,
89
+ );
90
+ });
91
+
92
+ test("createDsrRequest forwards an Idempotency-Key header when provided", async () => {
93
+ const fetchImpl = jest.fn().mockResolvedValue(
94
+ jsonResponse(201, {
95
+ _id: "dsr_1",
96
+ organizationId: "org_1",
97
+ type: "erasure",
98
+ }),
99
+ );
100
+ const client = new DpdpGuardClient({
101
+ baseUrl: "https://example.convex.site",
102
+ accessToken: "token-abc",
103
+ fetchImpl,
104
+ });
105
+
106
+ await client.createDsrRequest(
107
+ { organizationId: "org_1", type: "erasure" },
108
+ "idem-key-1",
109
+ );
110
+
111
+ const [, init] = fetchImpl.mock.calls[0];
112
+ expect(init.headers["Idempotency-Key"]).toBe("idem-key-1");
113
+ });
114
+
115
+ test("has no brokerToken method — mobile apps must never hold the service API key", () => {
116
+ const client = new DpdpGuardClient({ baseUrl: "https://example.convex.site" });
117
+ expect((client as unknown as Record<string, unknown>).brokerToken).toBeUndefined();
118
+ });
119
+ });
package/src/client.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { DpdpGuardApiError } from "./errorCatalog";
2
+ import type { components } from "./generated/api-types";
3
+
4
+ type OrgSummary = components["schemas"]["OrgSummary"];
5
+ type Notice = components["schemas"]["Notice"];
6
+ type DsrRequest = components["schemas"]["DsrRequest"];
7
+ type Grievance = components["schemas"]["Grievance"];
8
+ type Nomination = components["schemas"]["Nomination"];
9
+
10
+ export interface DpdpGuardClientOptions {
11
+ /** e.g. "https://trustworthy-alligator-303.convex.site" */
12
+ baseUrl: string;
13
+ /**
14
+ * A brokered principal access token (ADR-004 D1/D2), obtained from your
15
+ * own app backend — this client deliberately has no `brokerToken()`
16
+ * method or API-key option. Minting a token requires a service API key
17
+ * (convex/apiKeys.ts), which must never ship inside a mobile app
18
+ * bundle; brokering stays server-side.
19
+ */
20
+ accessToken?: string;
21
+ /** Override for testing; defaults to the global `fetch` (RN provides one natively). */
22
+ fetchImpl?: typeof fetch;
23
+ }
24
+
25
+ type AuthMode = "none" | "bearer";
26
+
27
+ interface RequestOptions {
28
+ body?: unknown;
29
+ auth?: AuthMode;
30
+ headers?: Record<string, string>;
31
+ }
32
+
33
+ /**
34
+ * Thin typed client over DPDP Guard's public `/api/v1` (spec §4.2), for use
35
+ * from a React Native app. Wraps the openapi-typescript-generated
36
+ * request/response shapes (src/generated/api-types.ts) in ergonomic
37
+ * methods, and maps every non-2xx response to a {@link DpdpGuardApiError}
38
+ * keyed by the ADR-002 error catalog's `code`.
39
+ */
40
+ export class DpdpGuardClient {
41
+ private readonly baseUrl: string;
42
+ private accessToken: string | undefined;
43
+ private readonly fetchImpl: typeof fetch;
44
+
45
+ constructor(options: DpdpGuardClientOptions) {
46
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
47
+ this.accessToken = options.accessToken;
48
+ this.fetchImpl = options.fetchImpl ?? fetch;
49
+ }
50
+
51
+ /** Set/replace the brokered principal access token (e.g. after your backend re-brokers on expiry). */
52
+ setAccessToken(token: string): void {
53
+ this.accessToken = token;
54
+ }
55
+
56
+ private async request<T>(
57
+ method: string,
58
+ path: string,
59
+ options: RequestOptions = {},
60
+ ): Promise<T> {
61
+ const auth = options.auth ?? "bearer";
62
+ const headers: Record<string, string> = {
63
+ "Content-Type": "application/json",
64
+ ...options.headers,
65
+ };
66
+
67
+ if (auth === "bearer") {
68
+ if (!this.accessToken) {
69
+ throw new Error(
70
+ "DpdpGuardClient: this call requires an access token — call setAccessToken() with a token from your own backend first.",
71
+ );
72
+ }
73
+ headers.Authorization = `Bearer ${this.accessToken}`;
74
+ }
75
+
76
+ const init: RequestInit = { method, headers };
77
+ if (options.body !== undefined) {
78
+ init.body = JSON.stringify(options.body);
79
+ }
80
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, init);
81
+
82
+ const text = await response.text();
83
+ const json: unknown = text.length > 0 ? JSON.parse(text) : undefined;
84
+
85
+ if (!response.ok) {
86
+ const code =
87
+ json && typeof json === "object" && "code" in json
88
+ ? String((json as { code: unknown }).code)
89
+ : "VALIDATION_ERROR";
90
+ const message =
91
+ json && typeof json === "object" && "error" in json
92
+ ? String((json as { error: unknown }).error)
93
+ : `Request failed with status ${response.status}`;
94
+ throw new DpdpGuardApiError(code, message, response.status);
95
+ }
96
+
97
+ return json as T;
98
+ }
99
+
100
+ // --- Public reads (no auth) ---
101
+
102
+ getOrganization(slug: string): Promise<OrgSummary> {
103
+ return this.request("GET", `/api/v1/org/${encodeURIComponent(slug)}`, {
104
+ auth: "none",
105
+ });
106
+ }
107
+
108
+ getNotices(orgId: string): Promise<{ notices: Notice[] }> {
109
+ return this.request(
110
+ "GET",
111
+ `/api/v1/org/${encodeURIComponent(orgId)}/notices`,
112
+ { auth: "none" },
113
+ );
114
+ }
115
+
116
+ getNotice(noticeId: string): Promise<Notice> {
117
+ return this.request(
118
+ "GET",
119
+ `/api/v1/notices/${encodeURIComponent(noticeId)}`,
120
+ { auth: "none" },
121
+ );
122
+ }
123
+
124
+ getBannerConfig(
125
+ orgId: string,
126
+ scope?: { domain?: string; appId?: string },
127
+ ): Promise<{ bannerSettings?: Record<string, never>; configVersion: number }> {
128
+ const query = new URLSearchParams();
129
+ if (scope?.domain) query.set("domain", scope.domain);
130
+ if (scope?.appId) query.set("appId", scope.appId);
131
+ const qs = query.toString();
132
+ return this.request(
133
+ "GET",
134
+ `/api/v1/org/${encodeURIComponent(orgId)}/banner-config${qs ? `?${qs}` : ""}`,
135
+ { auth: "none" },
136
+ );
137
+ }
138
+
139
+ // --- Anonymous → known-user reconciliation (ADR-004 D3) ---
140
+
141
+ linkAnonymousConsent(
142
+ anonymousId: string,
143
+ ): Promise<{
144
+ linkedCount: number;
145
+ needsReconsent: { noticeId: string; purpose: string }[];
146
+ }> {
147
+ return this.request("POST", "/api/v1/link-anonymous-consent", {
148
+ body: { anonymousId },
149
+ });
150
+ }
151
+
152
+ // --- DSR (spec §4.2) ---
153
+
154
+ listDsrRequests(): Promise<{ requests: DsrRequest[] }> {
155
+ return this.request("GET", "/api/v1/dsr");
156
+ }
157
+
158
+ createDsrRequest(
159
+ input: {
160
+ organizationId: string;
161
+ type: "summary" | "processors" | "correction" | "erasure";
162
+ details?: string;
163
+ },
164
+ idempotencyKey?: string,
165
+ ): Promise<DsrRequest> {
166
+ const options: RequestOptions = { body: input };
167
+ if (idempotencyKey !== undefined) {
168
+ options.headers = { "Idempotency-Key": idempotencyKey };
169
+ }
170
+ return this.request("POST", "/api/v1/dsr", options);
171
+ }
172
+
173
+ // --- Grievances (spec §4.2) ---
174
+
175
+ listGrievances(): Promise<{ grievances: Grievance[] }> {
176
+ return this.request("GET", "/api/v1/grievances");
177
+ }
178
+
179
+ createGrievance(input: {
180
+ organizationId: string;
181
+ subject: string;
182
+ description: string;
183
+ }): Promise<Grievance> {
184
+ return this.request("POST", "/api/v1/grievances", { body: input });
185
+ }
186
+
187
+ // --- Nomination (spec §4.2) ---
188
+
189
+ getNomination(): Promise<Nomination | null> {
190
+ return this.request("GET", "/api/v1/nomination");
191
+ }
192
+
193
+ upsertNomination(input: {
194
+ nomineeName: string;
195
+ nomineeContact: string;
196
+ }): Promise<Nomination> {
197
+ return this.request("PUT", "/api/v1/nomination", { body: input });
198
+ }
199
+
200
+ revokeNomination(): Promise<{ revoked: boolean }> {
201
+ return this.request("DELETE", "/api/v1/nomination");
202
+ }
203
+ }
@@ -0,0 +1,17 @@
1
+ import { hasConsent } from "./consentGate";
2
+
3
+ describe("hasConsent", () => {
4
+ test("true when a matching purpose has no withdrawnAt", () => {
5
+ expect(hasConsent([{ purpose: "Analytics" }], "Analytics")).toBe(true);
6
+ });
7
+
8
+ test("false when the matching purpose was withdrawn", () => {
9
+ expect(
10
+ hasConsent([{ purpose: "Analytics", withdrawnAt: 123 }], "Analytics"),
11
+ ).toBe(false);
12
+ });
13
+
14
+ test("false when no record matches the purpose", () => {
15
+ expect(hasConsent([{ purpose: "Marketing" }], "Analytics")).toBe(false);
16
+ });
17
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pure helper for gating a feature/tracker on an existing consent decision —
3
+ * e.g. `if (!hasConsent(consents, "Analytics")) return;` before firing an
4
+ * analytics event.
5
+ */
6
+ export interface ConsentRecord {
7
+ purpose: string;
8
+ /** Present when the decision was withdrawn; absent/undefined means still active. */
9
+ withdrawnAt?: number | null;
10
+ }
11
+
12
+ export function hasConsent(consents: ConsentRecord[], purpose: string): boolean {
13
+ return consents.some((c) => c.purpose === purpose && c.withdrawnAt == null);
14
+ }
@@ -0,0 +1,19 @@
1
+ import { ERROR_CATALOG } from "./errorCatalog";
2
+
3
+ describe("ERROR_CATALOG", () => {
4
+ test("loads all 10 codes from the installed @dpdpguard/contract package", () => {
5
+ const codes = ERROR_CATALOG.map((entry) => entry.code);
6
+ expect(codes).toEqual([
7
+ "MINOR_TRACKING_BLOCKED",
8
+ "NOTICE_NOT_PUBLISHED",
9
+ "ALREADY_CONSENTED",
10
+ "NOT_ASSOCIATED_WITH_ORG",
11
+ "INVALID_STATUS_TRANSITION",
12
+ "SDK_VERSION_UNSUPPORTED",
13
+ "NOT_FOUND",
14
+ "UNAUTHORIZED",
15
+ "RATE_LIMITED",
16
+ "VALIDATION_ERROR",
17
+ ]);
18
+ });
19
+ });
@@ -0,0 +1,39 @@
1
+ import errorCatalog from "@dpdpguard/contract/conformance/error-catalog.json";
2
+
3
+ /**
4
+ * ADR-002 D2's typed error-code enum, loaded from the installed
5
+ * @dpdpguard/contract package via a static JSON import (Metro and
6
+ * TypeScript's resolveJsonModule both support this directly — unlike the
7
+ * Node server SDK, there's no CommonJS `require()` trick needed here).
8
+ */
9
+ export interface ErrorCatalogEntry {
10
+ code: string;
11
+ description: string;
12
+ }
13
+
14
+ export const ERROR_CATALOG: ErrorCatalogEntry[] = errorCatalog.codes;
15
+
16
+ export type ApiErrorCode =
17
+ | "MINOR_TRACKING_BLOCKED"
18
+ | "NOTICE_NOT_PUBLISHED"
19
+ | "ALREADY_CONSENTED"
20
+ | "NOT_ASSOCIATED_WITH_ORG"
21
+ | "INVALID_STATUS_TRANSITION"
22
+ | "SDK_VERSION_UNSUPPORTED"
23
+ | "NOT_FOUND"
24
+ | "UNAUTHORIZED"
25
+ | "RATE_LIMITED"
26
+ | "VALIDATION_ERROR";
27
+
28
+ /** Thrown by {@link DpdpGuardClient} for any non-2xx `/api/v1` response. */
29
+ export class DpdpGuardApiError extends Error {
30
+ readonly code: ApiErrorCode | string;
31
+ readonly status: number;
32
+
33
+ constructor(code: ApiErrorCode | string, message: string, status: number) {
34
+ super(message);
35
+ this.name = "DpdpGuardApiError";
36
+ this.code = code;
37
+ this.status = status;
38
+ }
39
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,8 @@
1
+ export { multiply } from './multiply';
2
+
3
+ export { DpdpGuardClient } from './client';
4
+ export type { DpdpGuardClientOptions } from './client';
5
+ export { DpdpGuardApiError, ERROR_CATALOG } from './errorCatalog';
6
+ export type { ApiErrorCode, ErrorCatalogEntry } from './errorCatalog';
7
+ export { hasConsent } from './consentGate';
8
+ export type { ConsentRecord } from './consentGate';
@@ -0,0 +1,5 @@
1
+ import ReactNative from './NativeReactNative';
2
+
3
+ export function multiply(a: number, b: number): number {
4
+ return ReactNative.multiply(a, b);
5
+ }
@@ -0,0 +1,3 @@
1
+ export function multiply(a: number, b: number): number {
2
+ return a * b;
3
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2020",
4
+ "module": "commonjs",
5
+ "moduleResolution": "node",
6
+ "lib": ["es2020"],
7
+ "jsx": "react-native",
8
+ "strict": true,
9
+ "resolveJsonModule": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "declaration": true,
13
+ "outDir": "lib",
14
+ "rootDir": "src"
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["src/**/*.test.ts", "src/generated", "lib"]
18
+ }