@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 +45 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/consentera.js +766 -0
- package/package.json +41 -0
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
|
+
}
|