@consentera/consent-sdk 2.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.
- package/CHANGELOG.md +245 -0
- package/LICENSE +21 -0
- package/README.md +489 -0
- package/dist/consentera-consent.cjs +4919 -0
- package/dist/consentera-consent.cjs.map +1 -0
- package/dist/consentera-consent.min.js +2 -0
- package/dist/consentera-consent.min.js.map +1 -0
- package/dist/consentera-consent.mjs +4864 -0
- package/dist/consentera-consent.mjs.map +1 -0
- package/dist/react/index.cjs +2731 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.mjs +2724 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/types/consent/CallbackHandler.d.ts +246 -0
- package/dist/types/consent/ConsentManager.d.ts +128 -0
- package/dist/types/consent/ConsentSession.d.ts +127 -0
- package/dist/types/consent/ConsentValidator.d.ts +63 -0
- package/dist/types/consent/artifactRead.d.ts +48 -0
- package/dist/types/consent/consentPopup.d.ts +115 -0
- package/dist/types/core/ConsentEraClient.d.ts +106 -0
- package/dist/types/core/ConsenteraConsent.d.ts +163 -0
- package/dist/types/core/errors.d.ts +108 -0
- package/dist/types/core/http.d.ts +176 -0
- package/dist/types/core/version.d.ts +36 -0
- package/dist/types/df/DFConfigClient.d.ts +59 -0
- package/dist/types/gcm/ConsentModeBridge.d.ts +54 -0
- package/dist/types/gpp/GPPManager.d.ts +62 -0
- package/dist/types/index.d.mts +5 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/principal/PrincipalClient.d.ts +34 -0
- package/dist/types/react/ConsentEraProvider.d.ts +58 -0
- package/dist/types/react/ConsentGate.d.ts +40 -0
- package/dist/types/react/index.d.mts +4 -0
- package/dist/types/react/index.d.ts +10 -0
- package/dist/types/react/useConsentEra.d.ts +65 -0
- package/dist/types/react/useConsentValidation.d.ts +23 -0
- package/dist/types/storage/ConsentStorage.d.ts +39 -0
- package/dist/types/tcf/TCFManager.d.ts +46 -0
- package/dist/types/types/consent-lifecycle.d.ts +804 -0
- package/dist/types/types/index.d.ts +311 -0
- package/dist/types/ui/ConsentBanner.d.ts +22 -0
- package/dist/types/ui/PreferenceCenter.d.ts +24 -0
- package/dist/types/utils/EventEmitter.d.ts +32 -0
- package/dist/types/utils/Logger.d.ts +16 -0
- package/dist/types/utils/browserStorage.d.ts +35 -0
- package/dist/types/utils/context.d.ts +81 -0
- package/dist/types/utils/helpers.d.ts +48 -0
- package/package.json +132 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
## Unreleased — renewal request wire correction
|
|
2
|
+
|
|
3
|
+
Existing `renewBulk(principalId, purposeIds, noticeHash, uiEventId, options)`
|
|
4
|
+
keeps its call signature and maps purpose IDs to the platform's `renewals`
|
|
5
|
+
items. Both renewal methods send top-level `captured_at`; the server does not
|
|
6
|
+
consume a nested `affirmative_action` on these roads. The legacy `uiEventId`
|
|
7
|
+
argument remains accepted but is not a server-stored renewal field. Exported
|
|
8
|
+
request types now describe these actual wire bodies. Response metadata and
|
|
9
|
+
idempotency/cancellation options remain available.
|
|
10
|
+
|
|
11
|
+
# Changelog
|
|
12
|
+
|
|
13
|
+
All notable changes to `@consentera/consent-sdk`.
|
|
14
|
+
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
15
|
+
|
|
16
|
+
## Unreleased — callback status contract (not published)
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **`handleCallback()` reads the platform's own callback vocabulary.** The
|
|
21
|
+
platform sets `status` to exactly `granted`, `partial` or `denied`, and
|
|
22
|
+
`pending=1` on every capture (consent/collection.go:4094-4098, :4243, :4274
|
|
23
|
+
on platform pre-main). The handler knew `completed|denied|expired|error`
|
|
24
|
+
instead: it had branches for `expired`, `rejected` and `timeout`, which the
|
|
25
|
+
platform never sends, did not know `partial`, never read `pending`, and sent
|
|
26
|
+
any unrecognised or missing status down the success road. Now
|
|
27
|
+
`readCallbackParams()` returns `claimed_status`
|
|
28
|
+
(`'granted' | 'partial' | 'denied' | 'unknown'`) and `pending`, the result
|
|
29
|
+
carries `claimed_status` and `claimed_pending`, and an `unknown` status is
|
|
30
|
+
`unverified` without an artefact read. `claimedCallbackStatus()` and
|
|
31
|
+
`CallbackClaimedStatus` are exported.
|
|
32
|
+
- **The redirect return can now verify against the real platform.**
|
|
33
|
+
`handleCallback()` compared the server's `challengeNonce`, stored at create,
|
|
34
|
+
with a `nonce`/`state` on the return. The platform's return never carries it:
|
|
35
|
+
it adds only `session_id`, `artifact_id`, `status`, `pending` and, when signed,
|
|
36
|
+
`sig` (collection.go:4225-4288). `challengeNonce` is the hosted page's
|
|
37
|
+
credential and rides `consent_url`. So every genuine redirect came back
|
|
38
|
+
`unverified`. `createSession` now adds its own random `state` to
|
|
39
|
+
`callback_url`, the way the mobile SDKs do. The platform builds the return on
|
|
40
|
+
top of that URL's query, so the state comes back, and `handleCallback()`
|
|
41
|
+
requires it. The stored record holds `state` and no longer holds the nonce.
|
|
42
|
+
`CallbackParams.nonce` is now `CallbackParams.state`. A `callback_url` that
|
|
43
|
+
already carries `state` is refused with `CALLBACK_STATE_RESERVED`, and a
|
|
44
|
+
relative one with `INVALID_CALLBACK_URL`. A session created before this
|
|
45
|
+
change, or without a callback, is `unverified` on return. Read it back
|
|
46
|
+
through your backend.
|
|
47
|
+
- **`CallbackStatus` no longer includes `'expired'`.** Nothing on the platform
|
|
48
|
+
produced it; a return URL saying `status=expired` is now `unverified`.
|
|
49
|
+
|
|
50
|
+
## Unreleased — M4 integration
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Existing `ConsentManager.renew` and `renewBulk` now return
|
|
55
|
+
`TransportResponse<RenewResponse>` and `TransportResponse<BulkRenewResponse>`:
|
|
56
|
+
the HTTP status, response body, request ID and retry delay are preserved.
|
|
57
|
+
A `202` records durable acceptance without claiming a completed artifact.
|
|
58
|
+
Retain its `artifact_id` and `session_id` for the existing bound artifact read;
|
|
59
|
+
do not repeat the renewal POST as a polling operation. A stored
|
|
60
|
+
`integrity_hash` is returned unchanged when available.
|
|
61
|
+
- Renewal change events invalidate caches for the purposes the response actually
|
|
62
|
+
renewed, including accepted `202` responses. The event is not artifact-readiness
|
|
63
|
+
evidence. Bulk requests with no successful renewal emit no change event.
|
|
64
|
+
- **Type migration:** callers that ignore the result can continue to do so, but
|
|
65
|
+
explicit function assignments returning `Promise<void>` must be updated to
|
|
66
|
+
the new response type. This is not universal source compatibility.
|
|
67
|
+
|
|
68
|
+
## [2.0.0] — 2026-09-22
|
|
69
|
+
|
|
70
|
+
A security and correctness release. **Every breaking change below exists because
|
|
71
|
+
the 1.x behaviour could report success for a consent that was never recorded, or
|
|
72
|
+
put a secret in a public bundle.** Read *Breaking* before upgrading.
|
|
73
|
+
|
|
74
|
+
### Breaking
|
|
75
|
+
|
|
76
|
+
- **`apiEndpoint` is required by the cookie SDK (`ConsenteraConsent`, the
|
|
77
|
+
default export), and there is no default server.** A missing or blank value
|
|
78
|
+
is refused at construction with `ENDPOINT_REQUIRED` — the code
|
|
79
|
+
`ConsentEraClient` already used. 1.x silently fell back to a built-in host on
|
|
80
|
+
the `.io` domain, which the company does not own: it did not resolve, so every
|
|
81
|
+
such embed failed, and it was one DNS record away from receiving the visitor's
|
|
82
|
+
consent payload and the site key. The script-tag auto-init now reads
|
|
83
|
+
`data-api-endpoint` and, without it, logs why and initialises nothing instead
|
|
84
|
+
of taking the bundle down.
|
|
85
|
+
- **`handleCallback()` / `CallbackHandler.parseCallback()` is now `async` and
|
|
86
|
+
fails closed.** 1.x read `session_id`, `artifact_id` and `status` from the
|
|
87
|
+
query string, logged *"Callback verified against stored session"*, and verified
|
|
88
|
+
**nothing** — it never parsed the record it had stored, never compared the
|
|
89
|
+
nonce, and returned `completed` for any URL carrying an `artifact_id`. A link
|
|
90
|
+
to `/consent/done?session_id=x&artifact_id=y` made the SDK answer "completed"
|
|
91
|
+
with no consent given. It now requires the stored session and its nonce to
|
|
92
|
+
match, and reaches `completed` **only** by fetching the artefact from the
|
|
93
|
+
platform and confirming it belongs to that session. Everything else is
|
|
94
|
+
`unverified`, never `completed`. The synchronous
|
|
95
|
+
`readCallbackParams()` returns the URL's claims for callers who want them.
|
|
96
|
+
- **The artefact read carries `?session_id=`, and `getArtifact` returns a
|
|
97
|
+
union** (SDK register WEB-033, WEB-034). `getArtifact(id, { sessionId })`
|
|
98
|
+
resolves `{ state: 'recorded', artifact }` on a 200 and
|
|
99
|
+
`{ state: 'pending', pending, retryAfterMs }` on the platform's 202; a 404
|
|
100
|
+
throws `ConsenteraNotFoundError`. `handleCallback()` reads with the session,
|
|
101
|
+
so a 404 is `unverified` at once instead of being polled as "maybe pending",
|
|
102
|
+
and it binds the artefact to the person the session was created for
|
|
103
|
+
(`data_principal_id`, now kept in the stored session record) instead of to a
|
|
104
|
+
`consent_session_id` field the platform's artefact does not have.
|
|
105
|
+
- **A failed consent sync throws.** 1.x caught every failure in `syncConsent`,
|
|
106
|
+
logged it at `warn`, wrote the local cookie anyway, hid the banner and fired
|
|
107
|
+
`consent_given`. The visitor's decision was lost and the banner never came back
|
|
108
|
+
to ask again. Now the error propagates, nothing is stored, the banner stays up,
|
|
109
|
+
and the host is told on the `sync_failed` event and `callbacks.onSyncFailed`.
|
|
110
|
+
- **No fabricated notice.** `getDefaultConfig()` is deleted. When the tenant's
|
|
111
|
+
configuration cannot be fetched, 1.x rendered a hardcoded "We value your
|
|
112
|
+
privacy" banner with invented purpose ids and an empty `policy_version`, and
|
|
113
|
+
collected consent against it. `init()` now rejects and no banner is shown.
|
|
114
|
+
- **`apiKey` is refused in a browser** (`SECRET_KEY_IN_BROWSER`) — at BOTH entry
|
|
115
|
+
points. The lifecycle client and the cookie SDK (`ConsentEraConsent`, the
|
|
116
|
+
package default export) now share one guard; before, the cookie SDK had no
|
|
117
|
+
browser check and shipped a `tiq_live_` secret to `/api/v1/cookie-consent/*`
|
|
118
|
+
silently — that plane ignores the key, so nothing refused it and nothing
|
|
119
|
+
failed. Consent
|
|
120
|
+
lifecycle roads need a Data Fiduciary secret; in a browser they must go through
|
|
121
|
+
`proxyEndpoint`. `unsafeAllowSecretKeyInBrowser: true` overrides, loudly.
|
|
122
|
+
1.x's advice — *"prefer site key (public, safe for frontend)"* — was wrong in
|
|
123
|
+
both directions: a site key authorises no lifecycle road at all (its
|
|
124
|
+
permissions are replaced with four that no route requires), so the only
|
|
125
|
+
credential that worked was the secret one.
|
|
126
|
+
- **One error type.** `ConsenteraError` with `kind`, `code`, `status`,
|
|
127
|
+
`requestId`, `retryAfterMs`, plus subclasses for `instanceof`.
|
|
128
|
+
`ConsentEraApiError` is an alias of it and `.statusCode` still reads, so most
|
|
129
|
+
1.x error handling keeps working.
|
|
130
|
+
- **Telemetry is off by default.** `collectContext` defaults to `'minimal'`. 1.x
|
|
131
|
+
sent `page_url: location.href`, the referrer, the raw User-Agent, screen size
|
|
132
|
+
and timezone on every mutation with no way to turn it off. `'full'` restores
|
|
133
|
+
them, with the page URL's query and fragment stripped.
|
|
134
|
+
- **`ConsentStorage.save()` returns `boolean`** (false when nothing durable was
|
|
135
|
+
written).
|
|
136
|
+
- `peerDependencies.react` is `^18 || ^19`; `engines.node` is `>=20`.
|
|
137
|
+
- The UMD global is `Consentera`. `window.ConsentEraConsent` is still set.
|
|
138
|
+
|
|
139
|
+
### Added
|
|
140
|
+
|
|
141
|
+
- **Deadlines, retries and idempotency.** Every request has an
|
|
142
|
+
`AbortController` timeout (default 10s), retries network failures / 5xx / 429
|
|
143
|
+
with exponential backoff and full jitter honouring `Retry-After`, and carries
|
|
144
|
+
**one idempotency key per logical operation** reused across retries.
|
|
145
|
+
`idempotencyKey`, `signal` and `timeoutMs` are caller-supplyable.
|
|
146
|
+
- **`X-Request-ID` surfaced** on every error.
|
|
147
|
+
- **SDK identification**, as the pair agreed across all six surfaces:
|
|
148
|
+
`User-Agent: ConsenteraSDK/<version> (<platform>; <runtime>)` plus
|
|
149
|
+
`X-Consentera-SDK: js/<version>`. In a browser only the second is sent —
|
|
150
|
+
`User-Agent` is a forbidden fetch header there.
|
|
151
|
+
- **The platform's callback signature is verified.** When a
|
|
152
|
+
`callback_signing_secret` is registered the platform appends
|
|
153
|
+
`sig = hex(HMAC_SHA256(secret, session_id|artifact_id|status))`; the SDK
|
|
154
|
+
checks it through `verifyCallbackSignature`, and a callback that carries a
|
|
155
|
+
`sig` with no verifier configured is `unverified`. The signing key is the
|
|
156
|
+
DF's, so in a browser the verifier calls your own server.
|
|
157
|
+
- **`pending`**, a new callback status. The consent artefact is written
|
|
158
|
+
asynchronously after submit (5.2–10.8 s measured), so the callback road polls
|
|
159
|
+
with backoff inside a bounded window (`artifactWaitMs`, default 15 s) and
|
|
160
|
+
reports `pending` when it expires — never `completed` on a 404, and never a
|
|
161
|
+
failure for a consent that was recorded.
|
|
162
|
+
- **React: no hook throws during render** (SDK register WEB-038). A refused
|
|
163
|
+
provider configuration, or a missing provider, used to throw out of
|
|
164
|
+
`useConsentEraClient()` and blank the page. Now `client` is `null` with the
|
|
165
|
+
reason in `error`, `useConsentEra()` returns `ready: false` and `null`
|
|
166
|
+
namespaces, and `<ConsentGate>` renders its fallback. Code that called a
|
|
167
|
+
namespace unconditionally must check it first.
|
|
168
|
+
- **`'use client'`** on the built React bundle — the package now works in the
|
|
169
|
+
Next.js App Router, where 1.x failed the build.
|
|
170
|
+
- **`beforeSend`** hook: the last look at every request body.
|
|
171
|
+
- **Guarded storage.** `localStorage`/`sessionStorage` throw in private mode,
|
|
172
|
+
with cookies blocked, and in sandboxed iframes; every access is wrapped and
|
|
173
|
+
degrades to memory. A cookie over the 4096-byte browser limit is refused
|
|
174
|
+
loudly instead of being dropped silently.
|
|
175
|
+
- **Popup flow: `openConsentPopup` opens a dialog on your page and hears the
|
|
176
|
+
message `/collect` actually posts** (SDK register WEB-035). It frames
|
|
177
|
+
`consent_url` and resolves on `consentera:submitted` / `consentera:declined`
|
|
178
|
+
from the `/collect` origin and the frame it opened. The result is
|
|
179
|
+
`{ outcome: 'decided', session_id, artifact_id, status, pending, … }` or
|
|
180
|
+
`{ outcome: 'dismissed' }`, with a timeout. The first 2.0.0 cut used
|
|
181
|
+
`window.open` and listened for `consentera:consent-result`, which the
|
|
182
|
+
platform never sends. A top-level `/collect` posts nothing, and that
|
|
183
|
+
promise could only end at the timeout or as `unverified`. Your page must be
|
|
184
|
+
on the session callback's origin (`CALLBACK_ORIGIN_MISMATCH` otherwise) and
|
|
185
|
+
in the client's Allowed Domains. `readDecisionMessage` is exported for hosts
|
|
186
|
+
that frame the page themselves.
|
|
187
|
+
- **`tenantId` is optional behind `proxyEndpoint`** (SDK register WEB-036).
|
|
188
|
+
The Data Fiduciary's server supplies the tenant there and the SDK sends no
|
|
189
|
+
tenant header, so requiring the value made integrators invent one. It is
|
|
190
|
+
still required with `apiEndpoint` (`TENANT_ID_REQUIRED`).
|
|
191
|
+
- **`ValidateResponse` is the platform's answer** (SDK register WEB-037):
|
|
192
|
+
`data_principal_id`, `purpose_id`, `validated_at`, `effective_at`,
|
|
193
|
+
`legal_basis`, `is_mandatory`, `dpdp_exemption_basis` and `message` are
|
|
194
|
+
typed, and the `artifact_id` the platform never sends is gone.
|
|
195
|
+
`purpose_code` is optional, as on the wire. `BulkValidateResponse` gains
|
|
196
|
+
`total_count`, `allow_count` and `deny_count`.
|
|
197
|
+
- **Type declarations actually ship.** `npm run build` now runs `build:types`;
|
|
198
|
+
1.x's `build` did not, so the published tarball declared `types` at a path that
|
|
199
|
+
did not exist. `scripts/verify-pack.sh` asserts it from the tarball.
|
|
200
|
+
- **The ESM and CJS bundles are `.mjs` and `.cjs`.** They were
|
|
201
|
+
`consentera-consent.esm.js` and `.cjs.js`, and Node decides a file's module
|
|
202
|
+
system from its EXTENSION plus the package `type`, not from the exports
|
|
203
|
+
condition that reached it — so with `"type": "commonjs"` **every named import
|
|
204
|
+
from an ESM project failed**: `SyntaxError: Named export 'CallbackHandler'
|
|
205
|
+
not found`. Found by installing the tarball, which is now a gate
|
|
206
|
+
(`scripts/install-smoke.sh`).
|
|
207
|
+
- `exports` carries per-condition `types` (with a `.d.mts` entry for ESM, so the
|
|
208
|
+
types are no longer "masquerading as CJS"), plus `sideEffects`, `engines`,
|
|
209
|
+
`unpkg`, `jsdelivr`, `homepage`, `bugs`, `typesVersions`, and README/LICENSE
|
|
210
|
+
in `files`. `publint` and `@arethetypeswrong/cli` are both clean.
|
|
211
|
+
- ESLint 9 flat config that runs (1.x's `lint` script had no eslint installed and
|
|
212
|
+
no config file — it printed `eslint: not found`).
|
|
213
|
+
- React tests, a coverage floor, `publint` and `@arethetypeswrong/cli`.
|
|
214
|
+
|
|
215
|
+
### Fixed
|
|
216
|
+
|
|
217
|
+
- `useConsentValidation` cancels in-flight checks and drops out-of-order
|
|
218
|
+
responses. A stale `ALLOW` could overwrite a fresh `DENY` — a consent gate
|
|
219
|
+
failing **open**.
|
|
220
|
+
- `useConsentValidation` subscribes to `consent.changed`, so `<ConsentGate>`
|
|
221
|
+
closes on withdrawal without a remount.
|
|
222
|
+
- `useConsentValidation` depends on `who` **by value**. With the object-shaped
|
|
223
|
+
prop, an inline literal made a new reference every render and the effect
|
|
224
|
+
re-fired every render — an unbounded request loop.
|
|
225
|
+
- `ConsentEraProvider` no longer calls `setState` during render, and keeps
|
|
226
|
+
providing context when construction fails, so hooks report the real cause
|
|
227
|
+
instead of "must be used within `<ConsentEraProvider>`" inside a provider.
|
|
228
|
+
- A 200 response whose body is not JSON is a server error, not a network error.
|
|
229
|
+
- Identifiers are never logged; request/response bodies are never logged.
|
|
230
|
+
- `tsc --strict` is clean, and `build:types` no longer disables the two strict
|
|
231
|
+
flags that were failing.
|
|
232
|
+
- Idempotency keys and generated ids use `crypto.randomUUID()`, not
|
|
233
|
+
`Math.random()`.
|
|
234
|
+
- The transport's `public` and `session` road classes are now WIRED — the DF
|
|
235
|
+
reads ride `public` (site-key-openable) and the session widget template rides
|
|
236
|
+
`session` (nonce, no credential). Nothing set a road before, so both classes
|
|
237
|
+
were dead and every DF read demanded a secret it did not need.
|
|
238
|
+
- `getWidgetTemplate()` (`GET /df/widget-template`, a 404 — no such route) is
|
|
239
|
+
replaced by `getSessionWidgetTemplate(sessionId)` (`GET
|
|
240
|
+
/consent/sessions/{id}/widget-template`). Use `getNoticeTemplate()` for the
|
|
241
|
+
notice-authoring template.
|
|
242
|
+
- `getArtifact` encodes the artifact id in the path (it did not; the
|
|
243
|
+
CallbackHandler copy did).
|
|
244
|
+
|
|
245
|
+
[2.0.0]: https://github.com/consentera-platform/consentera-sdks/releases/tag/v2.0.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Consentera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|