@dpdpguard/react-native 1.1.1 → 1.3.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.
- package/README.md +77 -4
- package/package.json +2 -2
- package/src/CONTRACT_VERSION +1 -0
- package/src/auditHash.ts +63 -0
- package/src/client.ts +35 -0
- package/src/generated/api-types.ts +83 -0
- package/src/index.ts +29 -0
package/README.md
CHANGED
|
@@ -37,10 +37,18 @@ across both hybrid SDKs.
|
|
|
37
37
|
DPDP Guard's `/api/v1/auth/broker-token`, and hands the resulting
|
|
38
38
|
short-lived access token to the app. `DpdpGuardClient` takes that token
|
|
39
39
|
via `setAccessToken()` / the constructor, nothing more.
|
|
40
|
-
- **No audit
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
- **No keyed audit hash, and no webhook-signature code.** Both require a
|
|
41
|
+
secret the app must not hold (`DPDP_AUDIT_HASH_HMAC_SECRET` / a webhook
|
|
42
|
+
endpoint's own secret) — server-only concerns, unsafe inside a mobile
|
|
43
|
+
client. The *unkeyed* half of the audit hash is available as
|
|
44
|
+
`canonicalAuditString()` (see [Audit-hash canonicalization](#audit-hash-canonicalization));
|
|
45
|
+
it needs no secret.
|
|
46
|
+
- **No offline consent capture.** `dpdpguard-ios-sdk` implements the
|
|
47
|
+
device-side Cryptographic Consent Artifact (`docs/specs/offline-consent-capture.md`
|
|
48
|
+
§4) on top of Secure Enclave key storage and an encrypted local store.
|
|
49
|
+
That needs native code, which this package deliberately doesn't have — and
|
|
50
|
+
a software-only port would produce artifacts that don't prove what the
|
|
51
|
+
spec's artifacts prove.
|
|
44
52
|
|
|
45
53
|
## Usage
|
|
46
54
|
|
|
@@ -78,6 +86,71 @@ Every non-2xx response throws a `DpdpGuardApiError` with a `code` from the
|
|
|
78
86
|
ADR-002 error catalog (`err.code`, e.g. `"NOT_FOUND"`) and the HTTP
|
|
79
87
|
`status`.
|
|
80
88
|
|
|
89
|
+
### Types
|
|
90
|
+
|
|
91
|
+
The wire types are generated from `@dpdpguard/contract`'s `openapi/v1.yaml`
|
|
92
|
+
and re-exported, so you can name what the client returns instead of
|
|
93
|
+
re-declaring it:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import type { DsrRequest, Notice, components } from '@dpdpguard/react-native';
|
|
97
|
+
|
|
98
|
+
const notice: Notice = await client.getNotice(noticeId);
|
|
99
|
+
|
|
100
|
+
// Endpoints this SDK deliberately doesn't wrap are still typed:
|
|
101
|
+
type GateDecision = components['schemas']['ConsentGateDecision'];
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The contract version these were generated from is recorded in
|
|
105
|
+
`src/CONTRACT_VERSION`; `src/contractVersion.test.ts` fails if an install
|
|
106
|
+
drifts off it.
|
|
107
|
+
|
|
108
|
+
### Endpoints without a facade method
|
|
109
|
+
|
|
110
|
+
The DPDP Guard API is wider than the Data-Principal surface this SDK
|
|
111
|
+
curates. `call()` reaches the rest, with the same auth, URL joining, and
|
|
112
|
+
`DpdpGuardApiError` mapping as every other method:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import type { components } from '@dpdpguard/react-native';
|
|
116
|
+
|
|
117
|
+
type Decision = components['schemas']['ConsentGateDecision'];
|
|
118
|
+
const { decisions } = await client.call<{ decisions: Decision[] }>(
|
|
119
|
+
'GET',
|
|
120
|
+
'/api/v1/consent/gate/decisions?limit=50',
|
|
121
|
+
);
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This widens reach, not privilege: the client still sends only the brokered
|
|
125
|
+
principal token, so org-scoped endpoints will correctly 401 from an app.
|
|
126
|
+
|
|
127
|
+
### Audit-hash canonicalization
|
|
128
|
+
|
|
129
|
+
`canonicalAuditString()` builds the `|`-joined canonical string that the
|
|
130
|
+
`consentAuditTrail.auditHash` is computed over
|
|
131
|
+
(`@dpdpguard/contract`'s `conformance/audit-hash-spec.md`), checked against
|
|
132
|
+
that package's golden vectors:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { canonicalAuditString } from '@dpdpguard/react-native';
|
|
136
|
+
|
|
137
|
+
canonicalAuditString({
|
|
138
|
+
organizationId: 'org_abc123',
|
|
139
|
+
noticeId: 'notice_v1',
|
|
140
|
+
noticeVersion: 1,
|
|
141
|
+
purpose: 'Marketing',
|
|
142
|
+
dataTypes: ['phone', 'email'], // sorted for you
|
|
143
|
+
givenAt: 1700000001000,
|
|
144
|
+
source: 'brokered',
|
|
145
|
+
});
|
|
146
|
+
// 'org_abc123|notice_v1|1|Marketing|email,phone|1700000001000|brokered'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The hash itself is `HMAC-SHA256` of this string under a secret that stays
|
|
150
|
+
server-side, so this package stops at the canonical form — hand it to your
|
|
151
|
+
backend to sign, or use it to check which event a hash your backend
|
|
152
|
+
returned refers to.
|
|
153
|
+
|
|
81
154
|
## Contract
|
|
82
155
|
|
|
83
156
|
Depends on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dpdpguard/react-native",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "DPDP Guard React Native SDK - typed HTTP client over the DPDP Guard /api/v1 surface, usable via plain fetch (no native module)",
|
|
5
5
|
"main": "src/index",
|
|
6
6
|
"files": [
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"typescript": "~5.8"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@dpdpguard/contract": "^1.
|
|
34
|
+
"@dpdpguard/contract": "^1.6.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.6.0
|
package/src/auditHash.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-002 D5 audit-hash canonicalization, ported from
|
|
3
|
+
* `@dpdpguard/contract`'s `conformance/audit-hash-spec.md` and pinned by the
|
|
4
|
+
* golden vectors in `conformance/audit-hash-vectors.json`.
|
|
5
|
+
*
|
|
6
|
+
* **This module deliberately stops at the canonical string.** The full
|
|
7
|
+
* `auditHash` is `HMAC-SHA256(canonical, DPDP_AUDIT_HASH_HMAC_SECRET)`, and
|
|
8
|
+
* that secret is server-side-only — the spec's "Key management" section is
|
|
9
|
+
* explicit that it never leaves DPDP Guard's infrastructure, and anything
|
|
10
|
+
* shipped in a React Native bundle is extractable from the app binary. So
|
|
11
|
+
* unlike `dpdpguard-ios-sdk` and `dpdpguard-android-sdk`, which both expose
|
|
12
|
+
* `computeAuditHash(input, secret)` because a server-side caller may
|
|
13
|
+
* legitimately hold the key, this package exposes only the unkeyed half.
|
|
14
|
+
*
|
|
15
|
+
* What that half is good for: producing the exact bytes your own backend
|
|
16
|
+
* will sign, and re-deriving the canonical form of a record so you can
|
|
17
|
+
* check a hash your backend computed refers to the event you think it does.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** ADR-004 D5 auth provenance for a consent event. */
|
|
21
|
+
export type ConsentSource = "brokered" | "direct" | "anonymous" | "linked";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The exact fields the canonicalization is computed over. Every field is
|
|
25
|
+
* always present in the canonical string — an absent {@link source}
|
|
26
|
+
* serializes as the literal `"null"`, never an omitted position — so this is
|
|
27
|
+
* a *versioned*, safety-critical algorithm. Changing field order or the
|
|
28
|
+
* `"null"` sentinel is a major bump requiring fresh DPO sign-off.
|
|
29
|
+
*/
|
|
30
|
+
export interface AuditHashInput {
|
|
31
|
+
organizationId: string;
|
|
32
|
+
noticeId: string;
|
|
33
|
+
noticeVersion: number;
|
|
34
|
+
purpose: string;
|
|
35
|
+
dataTypes: string[];
|
|
36
|
+
/** Unix epoch milliseconds. Client-authoritative per ADR-003 D3. */
|
|
37
|
+
givenAt: number;
|
|
38
|
+
source?: ConsentSource | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The `|`-joined canonical string for `input`, per the spec's "Canonical
|
|
43
|
+
* form":
|
|
44
|
+
*
|
|
45
|
+
* ```
|
|
46
|
+
* organizationId|noticeId|noticeVersion|purpose|dataTypes(sorted,joined by ",")|givenAt|source("null" if absent)
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* `dataTypes` is sorted lexicographically, so call order never changes the
|
|
50
|
+
* result. The input array is not mutated.
|
|
51
|
+
*/
|
|
52
|
+
export function canonicalAuditString(input: AuditHashInput): string {
|
|
53
|
+
const sortedDataTypes = [...input.dataTypes].sort().join(",");
|
|
54
|
+
return [
|
|
55
|
+
input.organizationId,
|
|
56
|
+
input.noticeId,
|
|
57
|
+
String(input.noticeVersion),
|
|
58
|
+
input.purpose,
|
|
59
|
+
sortedDataTypes,
|
|
60
|
+
String(input.givenAt),
|
|
61
|
+
input.source ?? "null",
|
|
62
|
+
].join("|");
|
|
63
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -97,6 +97,41 @@ export class DpdpGuardClient {
|
|
|
97
97
|
return json as T;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Escape hatch for an `/api/v1` endpoint this client has no facade
|
|
102
|
+
* method for — the DPDP Guard API is wider than the Data-Principal
|
|
103
|
+
* surface this SDK curates (see "What's here vs. what's not" in
|
|
104
|
+
* README.md), and `src/generated/api-types.ts` types all of it.
|
|
105
|
+
*
|
|
106
|
+
* `dpdpguard-ios-sdk` ships its whole generated `DPDPGuardConsentAPI`
|
|
107
|
+
* target next to the hand-written facade so nothing is unreachable;
|
|
108
|
+
* this is the equivalent, without a second HTTP stack.
|
|
109
|
+
*
|
|
110
|
+
* Auth, base-URL joining, and {@link DpdpGuardApiError} mapping are the
|
|
111
|
+
* same as every facade method. Note this changes nothing about *which*
|
|
112
|
+
* credential the client holds: it still sends only the brokered
|
|
113
|
+
* principal token from {@link setAccessToken}, never a service API key,
|
|
114
|
+
* so org-scoped endpoints will correctly 401.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* import type { components } from '@dpdpguard/react-native';
|
|
119
|
+
*
|
|
120
|
+
* type Decision = components['schemas']['ConsentGateDecision'];
|
|
121
|
+
* const { decisions } = await client.call<{ decisions: Decision[] }>(
|
|
122
|
+
* 'GET',
|
|
123
|
+
* '/api/v1/consent/gate/decisions?limit=50',
|
|
124
|
+
* );
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
call<T>(
|
|
128
|
+
method: string,
|
|
129
|
+
path: string,
|
|
130
|
+
options: { body?: unknown; auth?: "none" | "bearer" } = {},
|
|
131
|
+
): Promise<T> {
|
|
132
|
+
return this.request<T>(method, path, options);
|
|
133
|
+
}
|
|
134
|
+
|
|
100
135
|
// --- Public reads (no auth) ---
|
|
101
136
|
|
|
102
137
|
getOrganization(slug: string): Promise<OrgSummary> {
|
|
@@ -330,6 +330,26 @@ export interface paths {
|
|
|
330
330
|
patch?: never;
|
|
331
331
|
trace?: never;
|
|
332
332
|
};
|
|
333
|
+
"/api/v1/consent-status": {
|
|
334
|
+
parameters: {
|
|
335
|
+
query?: never;
|
|
336
|
+
header?: never;
|
|
337
|
+
path?: never;
|
|
338
|
+
cookie?: never;
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* Look up an org's per-purpose consent state by email (docs/specs/ecommerce-cms-plugins.md, §4).
|
|
342
|
+
* @description Read-only: a WooCommerce/Shopify plugin's only fact about a customer is an email address from its own storefront — every other consent-reading route requires a dpdpbot-internal consent id or a brokered principal token. This resolves an email to the calling organization's own current per-purpose consent grants, so a plugin can gate a marketing sync in real time. `found: false` (not a 404) means the identifier is not yet a customer of record for this organization — distinct from "known, with no active consent", which is `found: true` and an empty or all-opted-out `purposes` array. Requires the `dpdp_consent_status_lookup_enabled` flag; while it is off, this route behaves as if it doesn't exist (404). Write access to consent stays `POST`/`DELETE /cm/v1/consent` — there is no corresponding write route here (ADR-007 D3).
|
|
343
|
+
*/
|
|
344
|
+
get: operations["getConsentStatus"];
|
|
345
|
+
put?: never;
|
|
346
|
+
post?: never;
|
|
347
|
+
delete?: never;
|
|
348
|
+
options?: never;
|
|
349
|
+
head?: never;
|
|
350
|
+
patch?: never;
|
|
351
|
+
trace?: never;
|
|
352
|
+
};
|
|
333
353
|
"/api/v1/breaches": {
|
|
334
354
|
parameters: {
|
|
335
355
|
query?: never;
|
|
@@ -1939,6 +1959,69 @@ export interface operations {
|
|
|
1939
1959
|
};
|
|
1940
1960
|
};
|
|
1941
1961
|
};
|
|
1962
|
+
getConsentStatus: {
|
|
1963
|
+
parameters: {
|
|
1964
|
+
query: {
|
|
1965
|
+
email: string;
|
|
1966
|
+
};
|
|
1967
|
+
header?: never;
|
|
1968
|
+
path?: never;
|
|
1969
|
+
cookie?: never;
|
|
1970
|
+
};
|
|
1971
|
+
requestBody?: never;
|
|
1972
|
+
responses: {
|
|
1973
|
+
/** @description The calling key's organization's consent status for this email. */
|
|
1974
|
+
200: {
|
|
1975
|
+
headers: {
|
|
1976
|
+
[name: string]: unknown;
|
|
1977
|
+
};
|
|
1978
|
+
content: {
|
|
1979
|
+
"application/json": {
|
|
1980
|
+
identifier: string;
|
|
1981
|
+
/** @enum {string} */
|
|
1982
|
+
identifierType: "email";
|
|
1983
|
+
found: boolean;
|
|
1984
|
+
purposes: {
|
|
1985
|
+
purposeKey: string;
|
|
1986
|
+
/** @enum {string} */
|
|
1987
|
+
category: "essential" | "non_essential";
|
|
1988
|
+
/** @enum {string} */
|
|
1989
|
+
status: "opt_in" | "opt_out";
|
|
1990
|
+
/** @description Epoch ms this purpose was withdrawn, if any. */
|
|
1991
|
+
withdrawnAt?: number;
|
|
1992
|
+
}[];
|
|
1993
|
+
};
|
|
1994
|
+
};
|
|
1995
|
+
};
|
|
1996
|
+
/** @description Missing `email` query parameter. */
|
|
1997
|
+
400: {
|
|
1998
|
+
headers: {
|
|
1999
|
+
[name: string]: unknown;
|
|
2000
|
+
};
|
|
2001
|
+
content: {
|
|
2002
|
+
"application/json": components["schemas"]["ApiError"];
|
|
2003
|
+
};
|
|
2004
|
+
};
|
|
2005
|
+
/** @description Missing or invalid API key. */
|
|
2006
|
+
401: {
|
|
2007
|
+
headers: {
|
|
2008
|
+
[name: string]: unknown;
|
|
2009
|
+
};
|
|
2010
|
+
content: {
|
|
2011
|
+
"application/json": components["schemas"]["ApiError"];
|
|
2012
|
+
};
|
|
2013
|
+
};
|
|
2014
|
+
/** @description The `dpdp_consent_status_lookup_enabled` flag is off. */
|
|
2015
|
+
404: {
|
|
2016
|
+
headers: {
|
|
2017
|
+
[name: string]: unknown;
|
|
2018
|
+
};
|
|
2019
|
+
content: {
|
|
2020
|
+
"application/json": components["schemas"]["ApiError"];
|
|
2021
|
+
};
|
|
2022
|
+
};
|
|
2023
|
+
};
|
|
2024
|
+
};
|
|
1942
2025
|
listBreaches: {
|
|
1943
2026
|
parameters: {
|
|
1944
2027
|
query?: never;
|
package/src/index.ts
CHANGED
|
@@ -4,3 +4,32 @@ export { DpdpGuardApiError, ERROR_CATALOG } from './errorCatalog';
|
|
|
4
4
|
export type { ApiErrorCode, ErrorCatalogEntry } from './errorCatalog';
|
|
5
5
|
export { hasConsent } from './consentGate';
|
|
6
6
|
export type { ConsentRecord } from './consentGate';
|
|
7
|
+
export { canonicalAuditString } from './auditHash';
|
|
8
|
+
export type { AuditHashInput, ConsentSource } from './auditHash';
|
|
9
|
+
|
|
10
|
+
// The openapi-typescript output generated from the installed
|
|
11
|
+
// @dpdpguard/contract's openapi/v1.yaml, re-exported so consumers can name
|
|
12
|
+
// the wire types this client returns (and reach endpoints `DpdpGuardClient`
|
|
13
|
+
// deliberately doesn't wrap) instead of re-declaring them by hand. Mirrors
|
|
14
|
+
// dpdpguard-ios-sdk, which exposes its whole generated `DPDPGuardConsentAPI`
|
|
15
|
+
// target alongside the hand-written facade.
|
|
16
|
+
export type {
|
|
17
|
+
components,
|
|
18
|
+
operations,
|
|
19
|
+
paths,
|
|
20
|
+
} from './generated/api-types';
|
|
21
|
+
|
|
22
|
+
import type { components } from './generated/api-types';
|
|
23
|
+
|
|
24
|
+
export type OrgSummary = components['schemas']['OrgSummary'];
|
|
25
|
+
export type Notice = components['schemas']['Notice'];
|
|
26
|
+
export type DsrRequest = components['schemas']['DsrRequest'];
|
|
27
|
+
export type Grievance = components['schemas']['Grievance'];
|
|
28
|
+
export type Nomination = components['schemas']['Nomination'];
|
|
29
|
+
export type ApiError = components['schemas']['ApiError'];
|
|
30
|
+
/**
|
|
31
|
+
* The wire-level consent record from `/cm/v1/consent`. Distinct from this
|
|
32
|
+
* package's own {@link ConsentRecord}, which is the minimal shape
|
|
33
|
+
* {@link hasConsent} gates on.
|
|
34
|
+
*/
|
|
35
|
+
export type ApiConsentRecord = components['schemas']['ConsentRecord'];
|