@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 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-hash or webhook-signature code.** Both require the
41
- platform's HMAC secret (`DPDP_AUDIT_HASH_HMAC_SECRET` /
42
- a webhook endpoint's own secret) — server-only concerns, irrelevant to
43
- and unsafe inside a mobile client.
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.1.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.5.0"
34
+ "@dpdpguard/contract": "^1.6.0"
35
35
  }
36
36
  }
@@ -0,0 +1 @@
1
+ 1.6.0
@@ -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'];