@consentera/cli 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 ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ `@consentera/cli` follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [2.0.0] — 2026-09-22
6
+
7
+ Versioned in step with `@consentera/consent-sdk` 2.0.0: both speak the U58 wire
8
+ and the refusal of `data_principal_ref`, and a mismatched pair is the thing
9
+ these version numbers exist to prevent.
10
+
11
+ ### Breaking
12
+
13
+ - **`--secret <value>` is removed** from `trigger`. It is refused with an error
14
+ rather than ignored, because a caller who typed it believes the event was
15
+ signed. Use `CONSENTERA_WEBHOOK_SECRET`, `consentera login --webhook`, or
16
+ `--secret-file <path>`. 1.x documented the flag **in its own usage block**, so
17
+ the tool taught every user to put a webhook secret in their shell history.
18
+ - **Unknown flags are refused** (exit 2) instead of silently ignored.
19
+ `--guardian_email` used to be dropped, creating a child's session with no
20
+ guardian.
21
+ - **`CONSENTERA_API_URL` is required; there is no default target.** 1.x fell
22
+ back to `http://localhost:9090` — plain HTTP, carrying the secret key as a
23
+ Bearer header to whatever listened on a port many other tools also use. A
24
+ command that sends the key now exits 2 naming `CONSENTERA_API_URL` when it is
25
+ unset or not an absolute http(s) URL (after the key check, which stays exit 4).
26
+ - **Exit codes are differentiated**: 1 API refusal, 2 usage, 3 network, 4 auth.
27
+ Everything used to be 1, so a script could not tell a typo from an outage.
28
+ - `engines.node` is `>=20` (Node 18 reached end of life on 2025-04-30).
29
+ - The lifecycle roads take identifiers, not `data_principal_ref` (landed in
30
+ `459a3967`).
31
+
32
+ ### Added
33
+
34
+ - **`consentera login`** — reads a secret from **stdin** and stores it in the OS
35
+ keychain (`security` / `secret-tool`), falling back to
36
+ `~/.config/consentera/credentials.json` created at mode `0600`.
37
+ - **`--json`**: one JSON document on stdout and nothing else, so the output can
38
+ be piped. The envelope carries `ok`, `status`, `request_id` and `data`.
39
+ - **`--version`**. `consentera --version` used to print the whole help text and
40
+ exit 0.
41
+ - **`X-Consentera-SDK: cli/<version>`** on every request.
42
+ - **The platform's `X-Request-ID` is surfaced** on failures.
43
+ - A test suite (`node --test`), where there was none.
44
+
45
+ [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.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @consentera/cli
2
+
3
+ The fastest way to exercise the [Consentera](https://docs.consentera.in) consent
4
+ API and webhooks from a terminal. Zero dependencies, Node ≥ 20.
5
+
6
+ ```
7
+ npm install -g @consentera/cli
8
+ consentera --help
9
+ ```
10
+
11
+ ## Quickstart (5 minutes)
12
+
13
+ ```bash
14
+ # 1. point it at YOUR tenant's API (required — there is no default server)
15
+ # and store a key — from stdin, never from a flag
16
+ export CONSENTERA_API_URL=https://<your Consentera API host>
17
+ consentera login # paste a tiq_test_… key, stored in your OS keychain
18
+
19
+ # 2. check the key and what mode it is in
20
+ consentera keys check
21
+ # { "ok": true, "mode": "test", "api": "https://<your Consentera API host>" }
22
+
23
+ # 3. mint a consent session and open the hosted page it prints
24
+ consentera sessions create \
25
+ --data-principal email=always.allow@test.consentera.in \
26
+ --notice bnb_consent_v2 \
27
+ --dob 1998-04-12
28
+
29
+ # 4. ask the authoritative question
30
+ consentera validate --data-principal email=always.allow@test.consentera.in \
31
+ --purpose product_analytics
32
+
33
+ # 5. send your local webhook endpoint a correctly-signed synthetic event
34
+ consentera trigger consent.granted --forward-to http://localhost:4242/hooks
35
+ ```
36
+
37
+ ## Credentials
38
+
39
+ **A secret is never a command-line argument.** There is no `--api-key` and no
40
+ `--secret`: a value in `argv` is visible in `ps` for the life of the process,
41
+ and lives forever in shell history and in CI logs.
42
+
43
+ `consentera login` reads the secret from **stdin** and stores it in the OS
44
+ keychain — `security` on macOS, `secret-tool` on Linux — falling back to
45
+ `~/.config/consentera/credentials.json` at mode `0600` when no keyring tool is
46
+ present. `consentera login --webhook` stores the webhook signing secret.
47
+
48
+ Resolution order, first hit wins:
49
+
50
+ 1. `CONSENTERA_API_KEY` / `CONSENTERA_WEBHOOK_SECRET`
51
+ 2. the OS keychain
52
+ 3. `~/.config/consentera/credentials.json`
53
+
54
+ A public site key (`tiq_pub_…`) is refused: the CLI is a server-side tool and
55
+ the roads it calls need a secret key.
56
+
57
+ ## `--json`
58
+
59
+ With `--json`, **stdout carries exactly one JSON document and nothing else** —
60
+ every human note is suppressed rather than merely redirected — so it is safe to
61
+ pipe:
62
+
63
+ ```bash
64
+ consentera keys check --json | jq -r .mode
65
+ consentera validate --json --data-principal email=x@y.in --purpose analytics | jq -r .data.decision
66
+ ```
67
+
68
+ The envelope is `{ ok, status, request_id, data }`. `request_id` is the
69
+ platform's `X-Request-ID` — quote it in a support ticket.
70
+
71
+ ## Exit codes
72
+
73
+ | code | meaning |
74
+ |---|---|
75
+ | 0 | success |
76
+ | 1 | the platform answered, and said no (4xx/5xx other than auth) |
77
+ | 2 | the command line was wrong — unknown flag, missing argument |
78
+ | 3 | the platform could not be reached |
79
+ | 4 | no usable credential, or it was refused (401/403) |
80
+
81
+ Unknown flags are **refused**, not ignored: `--guardian_email` (underscore) used
82
+ to be dropped in silence, so a child's session was created with no guardian and
83
+ the failure surfaced as a `412 GUARDIAN_REQUIRED` naming a condition rather than
84
+ the typo. It now exits 2 and suggests `--guardian-email`.
85
+
86
+ ## Naming a Data Principal
87
+
88
+ **One vocabulary, both roads.** `--data-principal <field>=<value>` is
89
+ repeatable, and the field names are **your organisation's locked integration
90
+ key** — an open, per-tenant set, so the type comes from the field name and the
91
+ CLI does not allow-list. It sends what you give it and the SERVER answers
92
+ `UNKNOWN_IDENTIFIER_FIELD`, naming the field and listing your key's actual
93
+ fields, when you get one wrong. (It used to carry a hard-coded five-field set
94
+ that rejected a legal key before the server ever saw it.)
95
+
96
+ The mobile atom is **`mobile`** on `sessions create` *and* on the lifecycle
97
+ roads (`validate`, `withdraw`). It used to be `phone` on the lifecycle roads,
98
+ folded server-side; F015 removed the fold, so **`phone` is refused by name**.
99
+ There is no `pan` key: it is evidence-class and can never be a scheme field.
100
+
101
+ Only the wire key differs — create spells the object `data_principal` and may
102
+ mint a person; the lifecycle roads spell it `data_principal_identifiers` and
103
+ only resolve.
104
+
105
+ `data_principal_ref` is refused outright by the API
106
+ (`DATA_PRINCIPAL_REF_REFUSED`) with no transition period. The other refusals are
107
+ `UNKNOWN_IDENTIFIER_FIELD`, `IDENTIFIER_REQUIRED`, `INVALID_IDENTIFIER_FORMAT`
108
+ (a raw 12-digit Aadhaar lives here — send the linked token) and
109
+ `SCHEME_NOT_CONFIGURED`.
110
+
111
+ ## Sandbox
112
+
113
+ Use a `tiq_test_…` key and the magic Data Principals for deterministic runs:
114
+ `always.allow@test.consentera.in`, `always.withdrawn@…`, `always.none@…`,
115
+ `always.expired@…`.
116
+
117
+ ## Licence
118
+
119
+ MIT — see [LICENSE](./LICENSE).
package/consentera.js ADDED
@@ -0,0 +1,766 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * consentera — the Consentera developer CLI.
4
+ *
5
+ * The fastest way to exercise the consent API and webhooks from a terminal:
6
+ *
7
+ * consentera sessions create --data-principal email=riya@example.in --notice bnb_consent_v2
8
+ * consentera validate --data-principal riya@example.in --purpose product_analytics
9
+ * consentera withdraw --data-principal riya@example.in --purposes product_analytics
10
+ * consentera portal --data-principal riya@example.in
11
+ * consentera trigger consent.granted --forward-to http://localhost:4242/hooks
12
+ * consentera keys check
13
+ *
14
+ * Auth + target, and a secret is NEVER a command-line argument:
15
+ * CONSENTERA_API_URL your tenant's Consentera API origin — REQUIRED, no default
16
+ * CONSENTERA_API_KEY a tiq_test_* (sandbox) or tiq_live_* secret key — server-side only
17
+ * or `consentera login`, which reads it from stdin into the OS keychain
18
+ *
19
+ * Zero dependencies; Node 18+.
20
+ */
21
+ 'use strict';
22
+
23
+ const crypto = require('node:crypto');
24
+ const http = require('node:http');
25
+ const https = require('node:https');
26
+ const fs = require('node:fs');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+ const { execFileSync } = require('node:child_process');
30
+ const { URL } = require('node:url');
31
+
32
+ const SDK_VERSION = require('./package.json').version;
33
+
34
+ // ─── EXIT CODES ────────────────────────────────────────────────────────────
35
+ //
36
+ // One code for everything was the 1.x behaviour: `die()` used 1 and
37
+ // `printResult` used 1, so a typo in a flag, an expired key, a dropped
38
+ // connection and a 409 from the API were indistinguishable to a caller — which
39
+ // matters because the caller is usually a script.
40
+ const EXIT = {
41
+ OK: 0,
42
+ API: 1, // the platform answered, and said no
43
+ USAGE: 2, // the command line was wrong
44
+ NETWORK: 3, // the platform could not be reached
45
+ AUTH: 4, // no usable credential, or it was refused
46
+ };
47
+
48
+ // NO DEFAULT TARGET. 1.x fell back to http://localhost:9090 — plain HTTP, to
49
+ // whatever happens to listen on a port many other tools also use, carrying the
50
+ // secret key as a Bearer header. The SDKs in this repository shipped built-in
51
+ // hosts too, one of them on a domain the company does not own. A CLI holding a
52
+ // tiq_live_ key must be told where to send it; see requireApiUrl().
53
+ const API_URL = (process.env.CONSENTERA_API_URL || '').trim().replace(/\/+$/, '');
54
+
55
+ // ─── CREDENTIALS ───────────────────────────────────────────────────────────
56
+ //
57
+ // NEVER FROM argv. 1.x documented `--secret whsec_xxx` in its own usage block,
58
+ // which puts the value in `ps`, in shell history, and in any CI log that echoes
59
+ // the command. There is no `--api-key` flag and there is no `--secret` flag.
60
+ //
61
+ // Resolution order, first hit wins:
62
+ // 1. the environment CONSENTERA_API_KEY / CONSENTERA_WEBHOOK_SECRET
63
+ // 2. the OS keychain `security` (macOS) or `secret-tool` (Linux),
64
+ // used only if the binary is present
65
+ // 3. ~/.config/consentera/credentials.json, which `consentera login` writes
66
+ // with mode 0600
67
+ //
68
+ // Zero dependencies is a deliberate property of this CLI, so the keychain is
69
+ // reached by shelling out to the tool the OS already ships rather than by
70
+ // adding a native module.
71
+ const CONFIG_DIR = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'consentera');
72
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'credentials.json');
73
+
74
+ function keychainGet(account) {
75
+ try {
76
+ if (process.platform === 'darwin') {
77
+ return execFileSync('security', ['find-generic-password', '-s', 'consentera', '-a', account, '-w'], {
78
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
79
+ }).trim();
80
+ }
81
+ if (process.platform === 'linux') {
82
+ return execFileSync('secret-tool', ['lookup', 'service', 'consentera', 'account', account], {
83
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
84
+ }).trim();
85
+ }
86
+ } catch { /* no tool, no entry, or no keyring daemon — fall through */ }
87
+ return '';
88
+ }
89
+
90
+ function keychainSet(account, secret) {
91
+ try {
92
+ if (process.platform === 'darwin') {
93
+ execFileSync('security', ['add-generic-password', '-U', '-s', 'consentera', '-a', account, '-w', secret], {
94
+ stdio: 'ignore',
95
+ });
96
+ return true;
97
+ }
98
+ if (process.platform === 'linux') {
99
+ execFileSync('secret-tool', ['store', '--label=consentera', 'service', 'consentera', 'account', account], {
100
+ input: secret, stdio: ['pipe', 'ignore', 'ignore'],
101
+ });
102
+ return true;
103
+ }
104
+ } catch { /* fall back to the file */ }
105
+ return false;
106
+ }
107
+
108
+ function fileCreds() {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
111
+ } catch {
112
+ return {};
113
+ }
114
+ }
115
+
116
+ function resolveCredential(account, envName) {
117
+ const fromEnv = process.env[envName];
118
+ if (fromEnv) return fromEnv.trim();
119
+ const fromKeychain = keychainGet(account);
120
+ if (fromKeychain) return fromKeychain;
121
+ const v = fileCreds()[account];
122
+ return typeof v === 'string' ? v.trim() : '';
123
+ }
124
+
125
+ const API_KEY = resolveCredential('api_key', 'CONSENTERA_API_KEY');
126
+
127
+ // ---------- tiny arg parser ----------
128
+ //
129
+ // A REPEATED FLAG COLLECTS INTO AN ARRAY rather than overwriting. U58 needs it:
130
+ // `--data-principal customer_id=C1 --data-principal mobile=+91…` is ONE
131
+ // identifier object with two fields, and last-one-wins would have sent half of
132
+ // it — a request the API then refuses for not meeting the key's floor, naming a
133
+ // condition rather than the flag that was dropped. Single use is unchanged: a
134
+ // flag given once is still a plain string, so every other command reads exactly
135
+ // what it read before.
136
+ function parseArgs(argv) {
137
+ const args = { _: [] };
138
+ const put = (key, value) => {
139
+ if (!(key in args)) { args[key] = value; return; }
140
+ if (Array.isArray(args[key])) { args[key].push(value); return; }
141
+ args[key] = [args[key], value];
142
+ };
143
+ for (let i = 0; i < argv.length; i++) {
144
+ const a = argv[i];
145
+ if (a.startsWith('--')) {
146
+ const key = a.slice(2);
147
+ const next = argv[i + 1];
148
+ if (next === undefined || next.startsWith('--')) { put(key, true); }
149
+ else { put(key, next); i++; }
150
+ } else args._.push(a);
151
+ }
152
+ return args;
153
+ }
154
+
155
+ // Set from --json. In JSON mode stdout carries exactly one JSON document and
156
+ // nothing else, so `consentera … --json | jq` is safe; the human commentary
157
+ // that normally goes to stderr is suppressed entirely.
158
+ let JSON_MODE = false;
159
+
160
+ function note(msg) { if (!JSON_MODE) console.error(msg); }
161
+
162
+ function die(msg, code = EXIT.USAGE) {
163
+ if (JSON_MODE) console.log(JSON.stringify({ ok: false, error: String(msg), exit_code: code }, null, 2));
164
+ else console.error(`error: ${msg}`);
165
+ process.exit(code);
166
+ }
167
+
168
+ /** Every command that sends the key also needs a target; refused by name. */
169
+ function requireApiUrl() {
170
+ if (!API_URL) {
171
+ die(
172
+ 'CONSENTERA_API_URL is not set. There is no default server: set it to your Consentera API ' +
173
+ 'origin, e.g. `export CONSENTERA_API_URL=https://<your Consentera API host>`.',
174
+ EXIT.USAGE
175
+ );
176
+ }
177
+ if (!/^https?:\/\/[^\s/?#]+/i.test(API_URL)) {
178
+ die('CONSENTERA_API_URL must be an absolute http(s) URL (it is the origin the key is sent to).', EXIT.USAGE);
179
+ }
180
+ }
181
+
182
+ function requireKey() {
183
+ if (!API_KEY) {
184
+ die(
185
+ 'no API key. Set CONSENTERA_API_KEY, or run `consentera login` (which reads the key from ' +
186
+ 'stdin and stores it in your OS keychain, or in ~/.config/consentera/credentials.json at ' +
187
+ 'mode 0600). There is deliberately no --api-key flag: a secret on the command line is in ' +
188
+ 'ps, in shell history and in CI logs.',
189
+ EXIT.AUTH
190
+ );
191
+ }
192
+ if (API_KEY.startsWith('tiq_pub_')) {
193
+ die('that is a public site key; the CLI needs a secret key (tiq_test_* or tiq_live_*).', EXIT.AUTH);
194
+ }
195
+ // The key is checked first (exit 4), then where it would go (exit 2).
196
+ requireApiUrl();
197
+ }
198
+
199
+ function modeBanner() {
200
+ if (API_KEY.startsWith('tiq_test_')) return 'sandbox (tiq_test_)';
201
+ if (API_KEY.startsWith('tiq_live_')) return 'LIVE (tiq_live_)';
202
+ return 'unknown key type';
203
+ }
204
+
205
+ // ---------- HTTP ----------
206
+ function request(method, path, body, extraHeaders = {}, rawUrl = null) {
207
+ const url = new URL(rawUrl || API_URL + path);
208
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
209
+ const lib = url.protocol === 'https:' ? https : http;
210
+ // THE PAIR, agreed across all six surfaces (coordinator ruling 2026-09-22):
211
+ // User-Agent: ConsenteraSDK/<version> (<platform>; <runtime>)
212
+ // X-Consentera-SDK: <surface>/<version>
213
+ // `ConsenteraSDK/<version>` is the form the platform's audit pipeline already
214
+ // parses (core/audit/user_agent_coarsening_test.go:39-40). The CLI sends BOTH
215
+ // because User-Agent is ours to set here; the browser SDK sends only the
216
+ // second, because User-Agent is a forbidden fetch header in a browser.
217
+ const headers = {
218
+ Accept: 'application/json',
219
+ 'User-Agent': `ConsenteraSDK/${SDK_VERSION} (cli; node ${process.versions.node})`,
220
+ 'X-Consentera-SDK': `cli/${SDK_VERSION}`,
221
+ ...extraHeaders,
222
+ };
223
+ if (payload) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = payload.length; }
224
+ if (!rawUrl && API_KEY) headers.Authorization = `Bearer ${API_KEY}`;
225
+ return new Promise((resolve, reject) => {
226
+ const req = lib.request(url, { method, headers, timeout: 30000 }, (res) => {
227
+ const chunks = [];
228
+ res.on('data', (c) => chunks.push(c));
229
+ res.on('end', () => {
230
+ const text = Buffer.concat(chunks).toString('utf8');
231
+ let json = null;
232
+ try { json = JSON.parse(text); } catch { /* leave as text */ }
233
+ // The platform sets X-Request-ID and exposes it; it is the first thing
234
+ // support asks for, and 1.x read no response header at all.
235
+ resolve({ status: res.statusCode, json, text, requestId: res.headers['x-request-id'] });
236
+ });
237
+ });
238
+ req.on('timeout', () => { req.destroy(new Error('request timed out after 30s')); });
239
+ req.on('error', reject);
240
+ if (payload) req.write(payload);
241
+ req.end();
242
+ });
243
+ }
244
+
245
+ function printResult(res) {
246
+ const ok = res.status >= 200 && res.status < 300;
247
+ if (JSON_MODE) {
248
+ console.log(JSON.stringify({
249
+ ok,
250
+ status: res.status,
251
+ request_id: res.requestId || null,
252
+ data: res.json !== null ? res.json : res.text,
253
+ }, null, 2));
254
+ } else {
255
+ console.log(res.json !== null ? JSON.stringify(res.json, null, 2) : res.text);
256
+ if (!ok && res.requestId) note(`# request id: ${res.requestId}`);
257
+ }
258
+ if (!ok) process.exit(res.status === 401 || res.status === 403 ? EXIT.AUTH : EXIT.API);
259
+ }
260
+
261
+ /** A value that must never be echoed. */
262
+ function maskSecret(v) {
263
+ if (!v) return '';
264
+ return v.length <= 12 ? '***' : `${v.slice(0, 9)}…${v.slice(-2)}`;
265
+ }
266
+
267
+ // ---------- commands ----------
268
+ /**
269
+ * POST /api/v1/public/consent/sessions.
270
+ *
271
+ * EVERY KEY BUILT HERE IS ONE THE API DECODES. The handler decodes with a plain
272
+ * JSON decoder and no DisallowUnknownFields, so a key it does not know is
273
+ * dropped in silence and the call still returns 200 — an invented field never
274
+ * happens and never says so. Check a name against the server's request struct
275
+ * before adding it here.
276
+ *
277
+ * ─── THIS IS THE U58 WIRE, AND THERE IS NO OVERLAP WINDOW ──────────────────
278
+ *
279
+ * `--data-principal <field>=<value>` (repeatable) replaces
280
+ * `--data-principal <ref>` + `--data-principal-type <type>`: the identifiers
281
+ * are keyed BY THE FIELD NAMES OF THE ORGANISATION'S LOCKED INTEGRATION KEY,
282
+ * and THE TYPE COMES FROM THE FIELD NAME. A field outside the key is refused
283
+ * 400 UNKNOWN_IDENTIFIER_FIELD and the message LISTS the allowed fields — this
284
+ * CLI does not know the key and deliberately does not guess at it, because a
285
+ * guess could only turn that message into silence.
286
+ *
287
+ * The bare form `--data-principal riya@example.in` is still accepted and is
288
+ * REFUSED HERE, with the reason, rather than sent: it has no field name, so the
289
+ * API would drop the identifier in silence and then refuse the request for
290
+ * carrying none — a 400 naming a condition instead of the mistake.
291
+ */
292
+
293
+ /**
294
+ * Parse repeatable `--data-principal field=value` into the U58 `data_principal`
295
+ * object. A bare value (no `=`) is a wire error we can catch here, so we do.
296
+ */
297
+ function parseDataPrincipal(raw) {
298
+ if (raw === undefined) return undefined;
299
+ const items = Array.isArray(raw) ? raw : [raw];
300
+ const out = {};
301
+ for (const item of items) {
302
+ if (typeof item !== 'string' || !item.includes('=')) {
303
+ die(`--data-principal takes field=value (U58), not a bare identifier: got '${item}'.\n` +
304
+ ` The field name IS the type, and it must be one of YOUR organisation's locked\n` +
305
+ ` integration-key fields — for example --data-principal email=riya@example.in\n` +
306
+ ` or --data-principal customer_id=CUST-90210 --data-principal mobile=+919876500000.`);
307
+ }
308
+ const i = item.indexOf('=');
309
+ const field = item.slice(0, i).trim();
310
+ const value = item.slice(i + 1);
311
+ if (!field) die(`--data-principal: empty field name in '${item}'`);
312
+ out[field] = value;
313
+ }
314
+ return Object.keys(out).length ? out : undefined;
315
+ }
316
+
317
+ async function cmdSessionsCreate(args) {
318
+ requireKey();
319
+ const notice = args.notice || die('--notice <internal_name> is required');
320
+ const principalId = args['data-principal-id'];
321
+ const dataPrincipal = parseDataPrincipal(args['data-principal']);
322
+ if (!dataPrincipal && !principalId) {
323
+ die('send --data-principal <field>=<value> (repeatable), or --data-principal-id <uuid>; ' +
324
+ "the API refuses a session that names neither");
325
+ }
326
+ if (args['data-principal-type']) {
327
+ die('--data-principal-type is gone (U58): the type comes from the field name.\n' +
328
+ ` Send --data-principal ${args['data-principal-type']}=<value> instead.`);
329
+ }
330
+ const body = {
331
+ notice_internal_name: notice,
332
+ ui_mode: args['ui-mode'] || 'redirect',
333
+ callback_url: args.callback || 'https://example.invalid/consent/done',
334
+ session_ref: args['session-ref'] || `cli-${Date.now()}`,
335
+ };
336
+ if (dataPrincipal) body.data_principal = dataPrincipal;
337
+ if (principalId) body.data_principal_id = principalId;
338
+ // THE ONE AGE SIGNAL, AND IT IS A DATE. Age is server-authoritative: the date
339
+ // is re-read every time it is used, so a person graduates at eighteen without
340
+ // anyone updating a flag. Omit it and the person's age is UNKNOWN — the
341
+ // session is still created, and purposes restricted for children are then
342
+ // refused. If the date IS a child's, send --guardian-email or --guardian-phone
343
+ // as well, or the call is refused 412 GUARDIAN_REQUIRED.
344
+ if (args.dob) body.age = { date_of_birth: args.dob };
345
+ // ONE language field, replacing locale_pref / notice_language /
346
+ // template_language together. Goes AHEAD of the tenant's own default in the
347
+ // server's fallback chain, so send it only when one was actually asked for.
348
+ if (args.language || args.locale) body.language = args.language || args.locale;
349
+ if (args['notice-version']) body.notice_version_number = Number(args['notice-version']);
350
+ // The guardian channel — top level, never inside data_principal, which holds
351
+ // the identifiers of the person the consent is ABOUT. Either, not both.
352
+ if (args['guardian-email'] && args['guardian-phone']) {
353
+ die('send --guardian-email OR --guardian-phone, not both: one invitation goes to one ' +
354
+ 'address, and two addresses name two people with nothing saying which is the guardian');
355
+ }
356
+ if (args['guardian-email']) body.guardian_email = args['guardian-email'];
357
+ if (args['guardian-phone']) body.guardian_phone = args['guardian-phone'];
358
+ if (args['guardian-relationship']) body.guardian_relationship = args['guardian-relationship'];
359
+ note(`# mode: ${modeBanner()} → POST ${API_URL}/api/v1/public/consent/sessions`);
360
+ const res = await request('POST', '/api/v1/public/consent/sessions', body);
361
+ printResult(res);
362
+ if (res.json && res.json.consent_url) {
363
+ note(`\n# open this in a browser to complete consent:\n# ${res.json.consent_url}`);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * ONE FLAG, ONE VOCABULARY — the identifier fields are the tenant's own locked
369
+ * integration key, on every road (F015).
370
+ *
371
+ * session create data_principal: {<field>: <value>, …}
372
+ * an OPEN map keyed by the tenant's LOCKED integration key
373
+ * lifecycle roads data_principal_identifiers: {<field>: <value>, …}
374
+ * THE SAME open scheme-keyed map — only the wire KEY differs,
375
+ * because create MAY MINT a person and this one resolves only
376
+ * portal-sessions data_principal_ref + data_principal_ref_type — a DIFFERENT
377
+ * module (rights/principal), untouched by the consent
378
+ * ruling, and it still requires the pair
379
+ *
380
+ * `--data-principal field=value` is uniform across all three.
381
+ *
382
+ * THE CLI DOES NOT ALLOW-LIST. The admissible fields are a PER-TENANT fact this
383
+ * CLI cannot know (F015 deleted the closed five-field vocabulary — `phone` and
384
+ * `pan` among them; the mobile atom is `mobile` and `pan` can never be a key
385
+ * field). Whatever field=value you type is sent as-is, and the SERVER answers
386
+ * UNKNOWN_IDENTIFIER_FIELD — naming the field and listing the key's actual
387
+ * fields — when it is wrong. Guessing here would only hide that refusal.
388
+ */
389
+ function lifecycleIdentifiers(raw) {
390
+ return parseDataPrincipal(raw);
391
+ }
392
+
393
+ /** The person-naming half of a lifecycle body: the id, or the identifiers. */
394
+ function lifecyclePrincipalBody(args, road) {
395
+ const id = args['data-principal-id'];
396
+ const ids = lifecycleIdentifiers(args['data-principal']);
397
+ if (id && ids) {
398
+ die('send --data-principal-id OR --data-principal field=value, not both: the API refuses a ' +
399
+ 'request whose fields could name two different people');
400
+ }
401
+ if (id) return { data_principal_id: id };
402
+ if (ids) return { data_principal_identifiers: ids };
403
+ die(`${road} needs --data-principal-id <uuid>, or --data-principal field=value ` +
404
+ `where field is one of your organisation's locked integration-key fields.\n` +
405
+ ` data_principal_ref is REFUSED by the API outright (DATA_PRINCIPAL_REF_REFUSED) — ` +
406
+ `owner ruling 2026-09-21, no transition period.`);
407
+ }
408
+
409
+ async function cmdValidate(args) {
410
+ requireKey();
411
+ const purpose = args.purpose || die('--purpose <code> is required');
412
+ const who = lifecyclePrincipalBody(args, 'validate');
413
+ note(`# mode: ${modeBanner()}`);
414
+ const res = await request('POST', '/api/v1/public/consent/validate', {
415
+ ...who, purpose_code: purpose,
416
+ });
417
+ printResult(res);
418
+ }
419
+
420
+ async function cmdWithdraw(args) {
421
+ requireKey();
422
+ const purposes = (args.purposes || die('--purposes <code,code> is required')).split(',').map(s => s.trim()).filter(Boolean);
423
+ const who = lifecyclePrincipalBody(args, 'withdraw');
424
+ note(`# mode: ${modeBanner()}`);
425
+ const res = await request('POST', '/api/v1/public/consent/withdraw', {
426
+ ...who, purposes,
427
+ });
428
+ printResult(res);
429
+ }
430
+
431
+ async function cmdPortal(args) {
432
+ requireKey();
433
+ // THE PORTAL ROAD IS NOT A CONSENT LIFECYCLE ROAD. It lives in
434
+ // rights/principal (portal_session_handlers.go), the 2026-09-21 ruling does
435
+ // not reach it, and it still REQUIRES data_principal_ref plus its declared
436
+ // type — so this is the one command that must keep sending the old pair.
437
+ // A sweep that "fixed" it would have broken a working endpoint.
438
+ const dp = parseDataPrincipal(args['data-principal']);
439
+ if (!dp) die('portal needs --data-principal field=value, e.g. --data-principal email=riya@example.in');
440
+ const fields = Object.keys(dp);
441
+ if (fields.length !== 1) {
442
+ die('portal takes exactly ONE identifier: it mints an SSO link for one person, ' +
443
+ `and ${fields.length} were given (${fields.join(', ')})`);
444
+ }
445
+ const refType = fields[0];
446
+ note(`# mode: ${modeBanner()}`);
447
+ const res = await request('POST', '/api/v1/public/consent/portal-sessions', {
448
+ data_principal_ref: dp[refType], data_principal_ref_type: refType,
449
+ });
450
+ printResult(res);
451
+ if (res.json && res.json.portal_url) {
452
+ note(`\n# single-use portal SSO link (expires in ${res.json.expires_in_seconds}s) — open it now:\n# ${res.json.portal_url}`);
453
+ }
454
+ }
455
+
456
+ async function cmdKeysCheck() {
457
+ requireKey();
458
+ note(`# mode: ${modeBanner()} → GET ${API_URL}/api/v1/public/df/purposes`);
459
+ const res = await request('GET', '/api/v1/public/df/purposes?limit=1');
460
+ if (res.status === 200) {
461
+ console.log(JSON.stringify({ ok: true, mode: API_KEY.startsWith('tiq_test_') ? 'test' : 'live', api: API_URL }, null, 2));
462
+ } else printResult(res);
463
+ }
464
+
465
+ // Sample payloads mirror the real delivery worker's envelope for each event.
466
+ //
467
+ // TRANSCRIBED FROM THE DISPATCH SITES, because they had drifted from all three
468
+ // and this command exists so a receiver can be built against it:
469
+ //
470
+ // consent.granted consent/submit_evidence.go:1741-1745
471
+ // session_id, consent_artifact_id, data_principal_id
472
+ // consent.withdrawn consent/engine.go:963-971
473
+ // consent.expired (same site; the event name is the only difference)
474
+ // data_principal_id, artifact_id, purpose_ids[], source
475
+ //
476
+ // WHAT WAS WRONG, and why it mattered: every sample named the person with
477
+ // `data_principal_ref` and carried `purpose_code` / `decision` / `reason` /
478
+ // `expired_at`. The platform emits NONE of those. A receiver written against
479
+ // this command keyed on fields that never arrive, and — because a webhook
480
+ // receiver has no schema to refuse them — it would have read `undefined` in
481
+ // production and blamed the delivery.
482
+ //
483
+ // NOTE THE ARTIFACT KEY IS NOT THE SAME ON BOTH EVENTS: granted sends
484
+ // `consent_artifact_id`, withdrawn/expired send `artifact_id`. That asymmetry
485
+ // is the platform's, faithfully reproduced here rather than smoothed over —
486
+ // smoothing it would hide it from the one place an integrator would notice.
487
+ const TRIGGER_SAMPLES = {
488
+ 'consent.granted': {
489
+ session_id: '00000000-0000-4000-8000-000000000010',
490
+ consent_artifact_id: '00000000-0000-4000-8000-000000000001',
491
+ data_principal_id: '00000000-0000-4000-8000-000000000002',
492
+ },
493
+ 'consent.withdrawn': {
494
+ data_principal_id: '00000000-0000-4000-8000-000000000002',
495
+ artifact_id: '00000000-0000-4000-8000-000000000001',
496
+ purpose_ids: ['00000000-0000-4000-8000-000000000003'],
497
+ source: 'principal_withdrawal',
498
+ },
499
+ 'consent.expired': {
500
+ data_principal_id: '00000000-0000-4000-8000-000000000002',
501
+ artifact_id: '00000000-0000-4000-8000-000000000001',
502
+ purpose_ids: ['00000000-0000-4000-8000-000000000003'],
503
+ source: 'expiry_sweep',
504
+ },
505
+ };
506
+
507
+ async function cmdTrigger(args) {
508
+ const event = args._[1] || die(`trigger needs an event: ${Object.keys(TRIGGER_SAMPLES).join(' | ')}`);
509
+ if (!TRIGGER_SAMPLES[event]) die(`unknown event "${event}". Available: ${Object.keys(TRIGGER_SAMPLES).join(', ')}`);
510
+ const forwardTo = args['forward-to'] || die('--forward-to <url> is required (your local webhook endpoint)');
511
+ // `--secret` is refused in checkFlags(), which every command passes through —
512
+ // ONE implementation of that refusal, so the message cannot drift between
513
+ // here and there.
514
+ let secret = resolveCredential('webhook_secret', 'CONSENTERA_WEBHOOK_SECRET');
515
+ if (args['secret-file']) {
516
+ try {
517
+ secret = fs.readFileSync(String(args['secret-file']), 'utf8').trim();
518
+ } catch (e) {
519
+ die(`--secret-file: ${e.message}`, EXIT.USAGE);
520
+ }
521
+ }
522
+
523
+ const notificationId = crypto.randomUUID();
524
+ const body = Buffer.from(JSON.stringify({
525
+ event,
526
+ notification_id: notificationId,
527
+ occurred_at: new Date().toISOString(),
528
+ data: TRIGGER_SAMPLES[event],
529
+ livemode: false, // trigger always sends synthetic sandbox-shaped events
530
+ }));
531
+
532
+ // Sign exactly the way the server does: hex(HMAC_SHA256(secret, ts + "." + rawBody))
533
+ const ts = String(Math.floor(Date.now() / 1000));
534
+ const headers = {
535
+ 'Content-Type': 'application/json',
536
+ 'X-ConsentEra-Event': event,
537
+ 'X-ConsentEra-Timestamp': ts,
538
+ 'X-ConsentEra-Notification-ID': notificationId,
539
+ };
540
+ if (secret) {
541
+ headers['X-ConsentEra-Signature'] =
542
+ crypto.createHmac('sha256', secret).update(ts + '.').update(body).digest('hex');
543
+ } else {
544
+ note('# note: no webhook secret configured — sending UNSIGNED. Set CONSENTERA_WEBHOOK_SECRET ' +
545
+ 'or run \'consentera login --webhook\', so what you test is what production sends.');
546
+ }
547
+
548
+ note(`# trigger ${event} → ${forwardTo} (signed: ${Boolean(secret)})`);
549
+ const res = await new Promise((resolve, reject) => {
550
+ const url = new URL(forwardTo);
551
+ const lib = url.protocol === 'https:' ? https : http;
552
+ const req = lib.request(url, { method: 'POST', headers: { ...headers, 'Content-Length': body.length }, timeout: 15000 }, (r) => {
553
+ const chunks = [];
554
+ r.on('data', (c) => chunks.push(c));
555
+ r.on('end', () => resolve({ status: r.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
556
+ });
557
+ req.on('timeout', () => req.destroy(new Error('endpoint timed out after 15s')));
558
+ req.on('error', reject);
559
+ req.write(body); req.end();
560
+ });
561
+ console.log(JSON.stringify({ delivered: true, endpoint_status: res.status, notification_id: notificationId, signed: Boolean(secret) }, null, 2));
562
+ if (res.status < 200 || res.status >= 300) {
563
+ note(`# endpoint answered ${res.status} — a production delivery would be retried`);
564
+ process.exit(EXIT.API);
565
+ }
566
+ }
567
+
568
+ /**
569
+ * `consentera login [--webhook]` — read a secret from STDIN and store it.
570
+ *
571
+ * From stdin and nowhere else. A flag value is visible in `ps` for the life of
572
+ * the process and lives forever in shell history; an environment variable is
573
+ * inherited by every child. Stdin is read once and not retained.
574
+ */
575
+ async function cmdLogin(args) {
576
+ const isWebhook = Boolean(args.webhook);
577
+ const account = isWebhook ? 'webhook_secret' : 'api_key';
578
+ const label = isWebhook ? 'webhook signing secret (whsec_…)' : 'secret API key (tiq_test_… or tiq_live_…)';
579
+
580
+ note(`# paste your ${label} and press enter (input is not echoed to the terminal by this CLI)`);
581
+ const secret = await new Promise((resolve) => {
582
+ let buf = '';
583
+ process.stdin.setEncoding('utf8');
584
+ process.stdin.on('data', (c) => { buf += c; });
585
+ process.stdin.on('end', () => resolve(buf.trim()));
586
+ });
587
+ if (!secret) die('nothing was read from stdin', EXIT.USAGE);
588
+ if (!isWebhook && secret.startsWith('tiq_pub_')) {
589
+ die('that is a public site key; the CLI needs a secret key (tiq_test_* or tiq_live_*).', EXIT.AUTH);
590
+ }
591
+
592
+ let where;
593
+ if (keychainSet(account, secret)) {
594
+ where = process.platform === 'darwin' ? 'macOS keychain' : 'the OS keyring';
595
+ } else {
596
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
597
+ const creds = fileCreds();
598
+ creds[account] = secret;
599
+ // 0600 BEFORE the write, not after: a file created 0644 and chmod'ed
600
+ // afterwards is world-readable for the interval in between.
601
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
602
+ fs.chmodSync(CONFIG_FILE, 0o600);
603
+ where = CONFIG_FILE;
604
+ }
605
+
606
+ const payload = { ok: true, stored: account, location: where, value: maskSecret(secret) };
607
+ if (JSON_MODE) console.log(JSON.stringify(payload, null, 2));
608
+ else console.log(`stored ${account} in ${where} (${maskSecret(secret)})`);
609
+ }
610
+
611
+ const HELP = `consentera — Consentera developer CLI
612
+
613
+ USAGE
614
+ consentera <command> [flags]
615
+
616
+ GLOBAL FLAGS
617
+ --json one JSON document on stdout and nothing else; the
618
+ human commentary that normally goes to stderr is
619
+ suppressed. Safe to pipe into jq.
620
+ --version print the CLI version
621
+ --help this text
622
+
623
+ EXIT CODES
624
+ 0 success · 1 the platform answered and said no · 2 the command line was wrong
625
+ 3 the platform could not be reached · 4 no usable credential, or it was refused
626
+
627
+ COMMANDS
628
+ login [--webhook] read a secret from STDIN and store it in your OS
629
+ keychain (macOS 'security', Linux 'secret-tool') or,
630
+ failing that, ~/.config/consentera/credentials.json at
631
+ mode 0600. --webhook stores the webhook signing secret
632
+ instead of the API key. There is deliberately NO
633
+ --api-key and NO --secret flag: a secret on the command
634
+ line is in ps, in shell history and in CI logs.
635
+ keys check verify the resolved API key and report its mode
636
+ sessions create --data-principal <field>=<value> --notice <name>
637
+ [--data-principal <field>=<value> ...] [--data-principal-id <uuid>]
638
+ [--dob YYYY-MM-DD] [--language <code>]
639
+ [--guardian-email <addr> | --guardian-phone <num>]
640
+ [--guardian-relationship <what>]
641
+ [--notice-version <n>] [--callback <url>] [--ui-mode redirect]
642
+ mint a consent session; prints the hosted consent_url.
643
+ --data-principal is FIELD=VALUE and repeatable: the
644
+ field names are YOUR organisation's locked
645
+ integration-key fields, and THE TYPE COMES FROM THE
646
+ FIELD NAME (U58). A field outside the key is refused
647
+ 400 UNKNOWN_IDENTIFIER_FIELD and the message lists
648
+ the allowed fields; too few is 400
649
+ IDENTIFIER_REQUIRED. --data-principal-type is GONE.
650
+ --data-principal-id may be sent instead of, or
651
+ alongside, --data-principal; sent together they must
652
+ agree (409 IDENTITY_MISMATCH), and a session naming
653
+ neither is refused. --language goes AHEAD of the
654
+ tenant's default language, so send it only when one
655
+ was asked for; it replaces --locale, notice_language
656
+ and template_language together. --dob is YYYY-MM-DD
657
+ and is the one age signal; without it the person's
658
+ age is unknown and purposes restricted for children
659
+ are refused. If --dob is a CHILD's, send
660
+ --guardian-email or --guardian-phone (either, not
661
+ both) or the call is refused 412 GUARDIAN_REQUIRED.
662
+ validate --data-principal <field>=<value> --purpose <code>
663
+ | --data-principal-id <uuid> --purpose <code>
664
+ authoritative ALLOW/DENY decision check.
665
+ field is one of your organisation's locked
666
+ integration-key fields (the same vocabulary
667
+ 'sessions create' uses; the mobile atom is
668
+ 'mobile', and a field outside the key is
669
+ answered UNKNOWN_IDENTIFIER_FIELD by the
670
+ server). data_principal_ref is refused
671
+ outright (DATA_PRINCIPAL_REF_REFUSED).
672
+ withdraw --data-principal <field>=<value> --purposes <code,code>
673
+ | --data-principal-id <uuid> --purposes <code,code>
674
+ withdraw consent for that person
675
+ portal --data-principal <field>=<value> mint a single-use DP-portal SSO link.
676
+ Exactly ONE identifier; this road is
677
+ rights/principal's and still sends the
678
+ data_principal_ref pair, deliberately.
679
+ trigger <event> --forward-to <url> [--secret-file <path>]
680
+ POST a synthetic, correctly-signed webhook to your
681
+ local endpoint (events: consent.granted |
682
+ consent.withdrawn | consent.expired)
683
+
684
+ ENVIRONMENT
685
+ CONSENTERA_API_URL your Consentera API origin — REQUIRED, there is no default
686
+ CONSENTERA_API_KEY tiq_test_* (sandbox) or tiq_live_* secret key
687
+ CONSENTERA_WEBHOOK_SECRET the webhook signing secret for 'trigger'
688
+
689
+ Credentials resolve environment -> OS keychain -> ~/.config/consentera/credentials.json,
690
+ first hit wins.
691
+
692
+ SANDBOX
693
+ Use a tiq_test_* key and the magic data principals (always.allow@test.consentera.in,
694
+ always.withdrawn@…, always.none@…, always.expired@…) for deterministic runs, e.g.
695
+ --data-principal email=always.allow@test.consentera.in
696
+ `;
697
+
698
+ // Every flag the CLI understands. A flag outside this set is REFUSED, not
699
+ // ignored: `--guardian_email` (underscore) used to be dropped in silence, so a
700
+ // child's session was created with no guardian and the failure surfaced as a
701
+ // 412 GUARDIAN_REQUIRED naming a condition rather than the typo.
702
+ const KNOWN_FLAGS = new Set([
703
+ 'help', 'version', 'json',
704
+ 'notice', 'notice-version', 'data-principal', 'data-principal-id', 'data-principal-type',
705
+ 'dob', 'language', 'locale', 'callback', 'ui-mode', 'session-ref',
706
+ 'guardian-email', 'guardian-phone', 'guardian-relationship',
707
+ 'purpose', 'purposes', 'reason',
708
+ 'forward-to', 'secret-file', 'webhook',
709
+ ]);
710
+
711
+ function checkFlags(args) {
712
+ // `--secret` is REMOVED, not unknown, and the difference matters to whoever
713
+ // typed it: an "unknown flag" reads as a typo, and they would try again with
714
+ // a different spelling of the same mistake.
715
+ if ('secret' in args) {
716
+ die(
717
+ '--secret <value> is gone: a webhook secret on the command line lands in ps, in shell ' +
718
+ 'history and in CI logs. Use CONSENTERA_WEBHOOK_SECRET, `consentera login --webhook` ' +
719
+ '(reads it from stdin), or --secret-file <path>.',
720
+ EXIT.USAGE
721
+ );
722
+ }
723
+ const unknown = Object.keys(args).filter((k) => k !== '_' && !KNOWN_FLAGS.has(k));
724
+ if (unknown.length) {
725
+ const near = (bad) => {
726
+ const alt = bad.replace(/_/g, '-');
727
+ return KNOWN_FLAGS.has(alt) ? ` (did you mean --${alt}?)` : '';
728
+ };
729
+ die(`unknown flag${unknown.length > 1 ? 's' : ''}: ${unknown.map((u) => `--${u}${near(u)}`).join(', ')}`, EXIT.USAGE);
730
+ }
731
+ }
732
+
733
+ (async () => {
734
+ const args = parseArgs(process.argv.slice(2));
735
+ JSON_MODE = Boolean(args.json);
736
+ const cmd = args._[0];
737
+
738
+ if (args.version) {
739
+ if (JSON_MODE) console.log(JSON.stringify({ name: '@consentera/cli', version: SDK_VERSION }, null, 2));
740
+ else console.log(SDK_VERSION);
741
+ process.exit(EXIT.OK);
742
+ }
743
+
744
+ try {
745
+ if (!cmd || cmd === 'help' || args.help) { console.log(HELP); process.exit(EXIT.OK); }
746
+ checkFlags(args);
747
+ if (cmd === 'login') await cmdLogin(args);
748
+ else if (cmd === 'keys' && args._[1] === 'check') await cmdKeysCheck();
749
+ else if (cmd === 'sessions' && args._[1] === 'create') await cmdSessionsCreate(args);
750
+ else if (cmd === 'validate') await cmdValidate(args);
751
+ else if (cmd === 'withdraw') await cmdWithdraw(args);
752
+ else if (cmd === 'portal') await cmdPortal(args);
753
+ else if (cmd === 'trigger') await cmdTrigger(args);
754
+ else {
755
+ note(`unknown command "${cmd}"`);
756
+ if (!JSON_MODE) console.log(HELP);
757
+ die(`unknown command "${cmd}"`, EXIT.USAGE);
758
+ }
759
+ } catch (e) {
760
+ // A connection that never reached the platform is not the same failure as
761
+ // one the platform refused, and a script needs to tell them apart.
762
+ const transport = e && (e.code === 'ECONNREFUSED' || e.code === 'ENOTFOUND' || e.code === 'ETIMEDOUT' ||
763
+ e.code === 'ECONNRESET' || /timed out/.test(String(e.message)));
764
+ die(e.message || String(e), transport ? EXIT.NETWORK : EXIT.API);
765
+ }
766
+ })();
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@consentera/cli",
3
+ "version": "2.0.0",
4
+ "description": "Consentera developer CLI \u2014 exercise the consent API, mint sessions/portal links, and test webhooks with correctly-signed synthetic events.",
5
+ "bin": {
6
+ "consentera": "consentera.js"
7
+ },
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/consentera-platform/consentera-sdks.git",
15
+ "directory": "cli"
16
+ },
17
+ "keywords": [
18
+ "consentera",
19
+ "consent",
20
+ "dpdp",
21
+ "cli",
22
+ "webhooks"
23
+ ],
24
+ "files": [
25
+ "LICENSE",
26
+ "README.md",
27
+ "CHANGELOG.md",
28
+ "consentera.js"
29
+ ],
30
+ "homepage": "https://docs.consentera.in",
31
+ "bugs": {
32
+ "url": "https://github.com/consentera-platform/consentera-sdks/issues"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test test/*.test.js",
36
+ "lint": "node --check consentera.js",
37
+ "typecheck": "node --check consentera.js",
38
+ "verify:pack": "bash ../scripts/verify-pack.sh cli",
39
+ "prepublishOnly": "npm test && npm run verify:pack"
40
+ }
41
+ }