totemlayer 0.1.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/LICENSE.md ADDED
@@ -0,0 +1,17 @@
1
+ # TotemLayer CLI Use License 1.0
2
+
3
+ Copyright (c) 2026 TotemLayer. All rights reserved.
4
+
5
+ Permission is granted, without charge, to any person or organization to download, install, make
6
+ internal copies of, and use unmodified copies of the TotemLayer CLI, including for commercial use,
7
+ solely to connect to and interoperate with TotemLayer-hosted or authorized TotemLayer-compatible
8
+ services.
9
+
10
+ This permission does not include the right to modify, create derivative works from, publicly
11
+ redistribute, sublicense, or sell the CLI. It does not grant rights to TotemLayer server software,
12
+ services, documentation, or trademarks. Separate terms may govern a hosted TotemLayer service.
13
+
14
+ THE CLI IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
15
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
16
+ NONINFRINGEMENT. TO THE MAXIMUM EXTENT PERMITTED BY LAW, TOTEMLAYER WILL NOT BE LIABLE FOR ANY
17
+ CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM OR RELATED TO THE CLI OR ITS USE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # TotemLayer CLI
2
+
3
+ Install the cross-platform terminal client for TotemLayer agent identity and
4
+ action-level authorization. The package contains only the dependency-free CLI;
5
+ it does not include the TotemLayer web application or server source.
6
+
7
+ ## Install
8
+
9
+ Node.js 22.14 or newer is required.
10
+
11
+ ```bash
12
+ npm install --global totemlayer
13
+ totem --help
14
+ ```
15
+
16
+ For a one-off run without a global install:
17
+
18
+ ```bash
19
+ npx --yes totemlayer --help
20
+ ```
21
+
22
+ The installed client works on macOS, Linux, and Windows. Applications written
23
+ in any language can instead call the same HTTPS API described by the
24
+ [TotemLayer OpenAPI document](https://totemlayer.com/api/v1/openapi.json).
25
+
26
+ The package is distributed under the included TotemLayer CLI Use License, which
27
+ permits anyone to install and use unmodified copies with TotemLayer-compatible
28
+ services without exposing or licensing the private server application.
29
+
30
+ ## Before the first production request
31
+
32
+ Each participating company completes its one-time setup at
33
+ [totemlayer.com](https://totemlayer.com):
34
+
35
+ 1. Verify the organization and establish an accepted company relationship.
36
+ 2. Register the presenting and receiving agents.
37
+ 3. Issue a live, recipient-bound credential for the permitted action.
38
+ 4. In **Developer Settings**, create a `credential.verify` key bound to the
39
+ receiving agent and a separate `credential.present` key bound to the
40
+ presenting agent.
41
+ 5. Copy each raw key when it is shown and store it in that company's secret
42
+ manager. TotemLayer does not display the raw key again.
43
+
44
+ Never place keys, nonces, credentials, or presentation tokens in command-line
45
+ arguments. The CLI reads keys from environment variables and reads proof
46
+ artifacts from files or standard input.
47
+
48
+ ## Production handshake
49
+
50
+ Assume `request.json` contains the exact business request that the receiving
51
+ company may process.
52
+
53
+ Receiver — create a five-minute challenge:
54
+
55
+ ```bash
56
+ export TOTEM_URL="https://totemlayer.com"
57
+ export TOTEM_API_KEY="$VERIFIER_KEY"
58
+
59
+ totem challenge \
60
+ --presenter-agent "$PRESENTER_AGENT_ID" \
61
+ --action quote.request \
62
+ --request-file request.json \
63
+ --idempotency-key verify-request-001 \
64
+ --json > challenge.json
65
+ ```
66
+
67
+ Keep `challenge.json` as the receiver-owned original and send a copy to the
68
+ presenting workload over the companies' existing authenticated business
69
+ channel. Do not replace the original with a file returned by the presenter.
70
+
71
+ Presenter — create a one-minute proof:
72
+
73
+ ```bash
74
+ export TOTEM_AGENT_API_KEY="$PRESENTER_KEY"
75
+
76
+ totem present \
77
+ --credential-id "$CREDENTIAL_ID" \
78
+ --challenge-file challenge.json \
79
+ --json > presentation.json
80
+ ```
81
+
82
+ Return `presentation.json` to the receiver. Receiver — consume it once:
83
+
84
+ ```bash
85
+ export TOTEM_API_KEY="$VERIFIER_KEY"
86
+
87
+ totem verify \
88
+ --challenge-file challenge.json \
89
+ --presentation-file presentation.json \
90
+ --request-file request.json \
91
+ --json
92
+ ```
93
+
94
+ The CLI hashes the receiver-owned request bytes and accepts approval only when
95
+ the result matches `evidence.requestDigest`. Never copy the expected digest from
96
+ the server response—that would defeat the independent check. Act on the same
97
+ immutable bytes, or hash them again immediately before taking the business
98
+ action, because an approval never authorizes different bytes.
99
+ The CLI rejects a local challenge/request mismatch before consuming the proof.
100
+ If returned approval evidence later fails its complete binding check, treat the
101
+ proof as consumed and create a new challenge and presentation.
102
+
103
+ An application may use `--request-digest <sha256>` instead of
104
+ `--request-file` when it computes that digest from receiver-owned immutable
105
+ bytes. Never take the expected digest from a presenter or from TotemLayer's
106
+ approval response.
107
+
108
+ ### Windows PowerShell
109
+
110
+ PowerShell uses a backtick for line continuation. `Set-Content -Encoding UTF8`
111
+ also avoids the shell-dependent native redirection encoding used by Windows
112
+ PowerShell 5.1. The CLI accepts both UTF-8 BOM and UTF-16LE BOM artifacts for
113
+ compatibility.
114
+
115
+ ```powershell
116
+ $env:TOTEM_URL = "https://totemlayer.com"
117
+ $env:TOTEM_API_KEY = $env:VERIFIER_KEY
118
+
119
+ totem challenge `
120
+ --presenter-agent $env:PRESENTER_AGENT_ID `
121
+ --action quote.request `
122
+ --request-file request.json `
123
+ --idempotency-key verify-request-001 `
124
+ --json | Set-Content -Encoding UTF8 challenge.json
125
+
126
+ $env:TOTEM_AGENT_API_KEY = $env:PRESENTER_KEY
127
+
128
+ totem present `
129
+ --credential-id $env:CREDENTIAL_ID `
130
+ --challenge-file challenge.json `
131
+ --json | Set-Content -Encoding UTF8 presentation.json
132
+
133
+ $env:TOTEM_API_KEY = $env:VERIFIER_KEY
134
+
135
+ totem verify `
136
+ --challenge-file challenge.json `
137
+ --presentation-file presentation.json `
138
+ --request-file request.json `
139
+ --json
140
+ ```
141
+
142
+ ## Exit codes
143
+
144
+ - `0` — approved or the requested operation completed.
145
+ - `2` — server policy rejection, expiration, or replay.
146
+ - `1` — invalid input or local binding, network failure, or protocol/operational error.
147
+
148
+ ## Procurement sandbox
149
+
150
+ `totem rfq` is a sandbox and self-hosted demonstration command. It is not
151
+ enabled on the production `totemlayer.com` service. Point `TOTEM_URL` at a
152
+ TotemLayer deployment with the procurement demo enabled and use an
153
+ `operator.rfq.execute` key.
154
+
155
+ ## Compatibility
156
+
157
+ The package also installs the legacy `agenttrust` executable and accepts the
158
+ legacy `AGENTTRUST_*` environment-variable aliases. New integrations should use
159
+ `totem` and `TOTEM_*`.
package/bin/totem.mjs ADDED
@@ -0,0 +1,867 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { createReadStream } from "node:fs";
5
+ import { stripVTControlCharacters } from "node:util";
6
+
7
+ const RFQ_EXAMPLE =
8
+ "Request a quote for 50,000 Industrial Sensors (SKU-1001) from SupplyCo using Acme Procurement Agent.";
9
+ const CLI_VERSION = "0.1.0";
10
+ const DEFAULT_BASE_URL = "https://totemlayer.com";
11
+ const API_KEY_PATTERN = /^at_(?:live|test)_[A-Za-z0-9_-]{20,}$/;
12
+ const ACTION_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
13
+ const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9._:-]+$/;
14
+ const SHA256_PATTERN = /^[a-fA-F0-9]{64}$/;
15
+ const TERMINAL_UNSAFE_PATTERN =
16
+ /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
17
+ const MAX_JSON_INPUT_BYTES = 128 * 1_024;
18
+ const MAX_REQUEST_FILE_BYTES = 16 * 1_024 * 1_024;
19
+ const PRESENTATION_REJECTION_CODES = new Set([
20
+ "CHALLENGE_NOT_FOUND",
21
+ "CHALLENGE_NOT_PRESENTED",
22
+ "CHALLENGE_ALREADY_PRESENTED",
23
+ "CHALLENGE_ALREADY_CONSUMED",
24
+ "CHALLENGE_EXPIRED",
25
+ "PRESENTATION_EXPIRED",
26
+ "PRESENTATION_MISMATCH",
27
+ "PRESENTER_AGENT_INACTIVE",
28
+ "AGENT_AUTHORITY_REVOKED",
29
+ "INVALID_PRESENTATION_SIGNATURE",
30
+ "PRESENTATION_CLAIMS_MISMATCH",
31
+ ]);
32
+
33
+ function help() {
34
+ process.stdout.write(`TotemLayer CLI ${CLI_VERSION}
35
+
36
+ Install:
37
+ npm install --global totemlayer
38
+ npx --yes totemlayer --help
39
+
40
+ Usage:
41
+ totem challenge --presenter-agent <agent-id> --action <action> \\
42
+ (--request-digest <sha256> | --request-file <path>) [--amount <number> --currency <ISO-4217>] --json
43
+ totem present --credential-id <credential-id> [--challenge-file <path>] --json
44
+ totem verify --challenge-file <receiver-challenge.json> \\
45
+ (--request-digest <sha256> | --request-file <path>) [--presentation-file <path>] --json
46
+ totem rfq "${RFQ_EXAMPLE}" # sandbox/self-hosted demo only
47
+
48
+ Shared options:
49
+ --base-url <url> Totem URL (default: TOTEM_URL or ${DEFAULT_BASE_URL})
50
+ --json Print the complete machine-readable JSON response
51
+ -h, --help Show this help
52
+ -v, --version Print the CLI version
53
+
54
+ Challenge options:
55
+ --presenter-agent <id> Exact agent expected to present authority
56
+ --action <action> Namespaced action, for example inventory.read
57
+ --request-digest <sha256> SHA-256 of the request being authorized
58
+ --request-file <path> Hash this file locally; file contents are never uploaded
59
+ --amount <number> Optional transaction amount; requires --currency
60
+ --currency <code> Three-letter ISO currency; requires --amount
61
+ --idempotency-key <key> Reuse for an exact retry (a random key is used by default)
62
+
63
+ Verification binding:
64
+ --challenge-file <path> Receiver's original challenge; never trust a returned copy
65
+ --request-digest <sha256> Require approval evidence for this exact request digest
66
+ --request-file <path> Hash the exact local request before accepting an approval
67
+
68
+ Presentation input:
69
+ present reads challenge JSON from stdin unless --challenge-file is supplied.
70
+ verify reads presentation JSON from stdin unless --presentation-file is supplied and
71
+ compares it with the receiver-owned --challenge-file before consuming the proof.
72
+ Raw keys, nonces, credentials, and presentation tokens are never accepted as arguments.
73
+
74
+ Authentication:
75
+ challenge and verify use TOTEM_API_KEY.
76
+ present uses TOTEM_AGENT_API_KEY bound to the presenting agent.
77
+ Keys are sent only in the Authorization header and are never printed.
78
+
79
+ Production supports challenge, present, and verify. The RFQ command requires a
80
+ sandbox or self-hosted Totem deployment with the procurement demo enabled.
81
+ `);
82
+ }
83
+
84
+ function fail(message) {
85
+ process.stderr.write(`Totem: ${terminalText(message, "The Totem command failed.")}\n`);
86
+ process.exitCode = 1;
87
+ }
88
+
89
+ function failCommand(message, json) {
90
+ if (json) process.stdout.write(`${JSON.stringify({ error: { message } })}\n`);
91
+ else fail(message);
92
+ process.exitCode = 1;
93
+ }
94
+
95
+ function failUnknownOutcome(message, idempotencyKey, json, machineStdout = false) {
96
+ if (json) {
97
+ const serialized = `${JSON.stringify({
98
+ error: { message, outcome: "unknown", idempotencyKey },
99
+ })}\n`;
100
+ (machineStdout ? process.stdout : process.stderr).write(serialized);
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ fail(`${message} The outcome is unknown; retry with --idempotency-key "${idempotencyKey}".`);
105
+ }
106
+
107
+ function parseArguments(values) {
108
+ const options = { json: false, positionals: [] };
109
+ const valuedOptions = new Set([
110
+ "--base-url",
111
+ "--idempotency-key",
112
+ "--presenter-agent",
113
+ "--action",
114
+ "--request-digest",
115
+ "--request-file",
116
+ "--amount",
117
+ "--currency",
118
+ "--challenge-file",
119
+ "--credential-id",
120
+ "--presentation-file",
121
+ ]);
122
+ for (let index = 0; index < values.length; index += 1) {
123
+ const value = values[index];
124
+ if (value === "--") continue;
125
+ if (value === "--json") {
126
+ options.json = true;
127
+ continue;
128
+ }
129
+ if (value === "-h" || value === "--help") {
130
+ options.help = true;
131
+ continue;
132
+ }
133
+ if (value === "-v" || value === "--version") {
134
+ options.version = true;
135
+ continue;
136
+ }
137
+ if (valuedOptions.has(value)) {
138
+ const next = values[index + 1];
139
+ if (!next || next.startsWith("--")) throw new Error(`${value} requires a value.`);
140
+ options[value.slice(2).replaceAll("-", "_")] = next;
141
+ index += 1;
142
+ continue;
143
+ }
144
+ if (value.startsWith("--")) throw new Error(`Unknown option ${value}.`);
145
+ options.positionals.push(value);
146
+ }
147
+ return options;
148
+ }
149
+
150
+ function safeBaseUrl(value) {
151
+ const url = new URL(value);
152
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
153
+ throw new Error("The Totem URL must be an HTTP(S) origin without credentials.");
154
+ }
155
+ const local = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
156
+ if (url.protocol !== "https:" && !local) {
157
+ throw new Error("Use HTTPS for a non-local Totem URL.");
158
+ }
159
+ url.pathname = url.pathname.replace(/\/$/, "");
160
+ url.search = "";
161
+ url.hash = "";
162
+ return url;
163
+ }
164
+
165
+ function aliasedEnvironment(primaryName, legacyName) {
166
+ const primary = process.env[primaryName]?.trim();
167
+ const legacy = process.env[legacyName]?.trim();
168
+ if (primary && legacy && primary !== legacy) {
169
+ throw new Error(
170
+ `${primaryName} and its legacy alias ${legacyName} must match when both are set.`,
171
+ );
172
+ }
173
+ return primary || legacy;
174
+ }
175
+
176
+ function apiKey(environmentName, legacyEnvironmentName) {
177
+ const value = aliasedEnvironment(environmentName, legacyEnvironmentName);
178
+ if (!value || !API_KEY_PATTERN.test(value)) {
179
+ throw new Error(`Set ${environmentName} to a valid scoped API key.`);
180
+ }
181
+ return value;
182
+ }
183
+
184
+ function errorFrom(payload, fallback) {
185
+ if (!payload || typeof payload !== "object") return fallback;
186
+ const error = payload.error;
187
+ if (!error || typeof error !== "object") return fallback;
188
+ const code = typeof error.code === "string" ? error.code : "REQUEST_FAILED";
189
+ const message = typeof error.message === "string" ? error.message : fallback;
190
+ return `${code}: ${message}`;
191
+ }
192
+
193
+ function number(value) {
194
+ return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US") : "—";
195
+ }
196
+
197
+ function terminalText(value, fallback = "unknown") {
198
+ if (typeof value !== "string" && typeof value !== "number") return fallback;
199
+ const sanitized = stripVTControlCharacters(String(value))
200
+ .replace(TERMINAL_UNSAFE_PATTERN, " ")
201
+ .replace(/\s+/g, " ")
202
+ .trim();
203
+ return sanitized.slice(0, 2_000) || fallback;
204
+ }
205
+
206
+ function record(value) {
207
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
208
+ }
209
+
210
+ function requiredOption(options, key, flag, pattern = SAFE_IDENTIFIER_PATTERN) {
211
+ const value = options[key]?.trim();
212
+ if (!value || value.length > 200 || (pattern && !pattern.test(value))) {
213
+ throw new Error(`${flag} must contain a valid value.`);
214
+ }
215
+ return value;
216
+ }
217
+
218
+ function validateCommandOptions(command, options) {
219
+ const shared = new Set(["json", "help", "base_url", "positionals"]);
220
+ const allowed = {
221
+ rfq: new Set([...shared, "idempotency_key"]),
222
+ challenge: new Set([
223
+ ...shared,
224
+ "idempotency_key",
225
+ "presenter_agent",
226
+ "action",
227
+ "request_digest",
228
+ "request_file",
229
+ "amount",
230
+ "currency",
231
+ ]),
232
+ present: new Set([...shared, "challenge_file", "credential_id"]),
233
+ verify: new Set([
234
+ ...shared,
235
+ "challenge_file",
236
+ "presentation_file",
237
+ "request_digest",
238
+ "request_file",
239
+ ]),
240
+ }[command];
241
+ if (!allowed) throw new Error(`Unknown command ${command || "(missing)"}.`);
242
+ for (const [key, value] of Object.entries(options)) {
243
+ if (value !== undefined && !allowed.has(key)) {
244
+ throw new Error(`The --${key.replaceAll("_", "-")} option is not valid for ${command}.`);
245
+ }
246
+ }
247
+ }
248
+
249
+ async function readBounded(stream, maximumBytes, label) {
250
+ const chunks = [];
251
+ let size = 0;
252
+ try {
253
+ for await (const chunk of stream) {
254
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
255
+ size += bytes.length;
256
+ if (size > maximumBytes) {
257
+ stream.destroy?.();
258
+ throw new Error(`${label} exceeds the ${maximumBytes}-byte limit.`);
259
+ }
260
+ chunks.push(bytes);
261
+ }
262
+ } catch (error) {
263
+ if (error instanceof Error && error.message.includes("exceeds the")) throw error;
264
+ throw new Error(`Could not read ${label}.`);
265
+ }
266
+ if (size === 0) throw new Error(`${label} is empty.`);
267
+ return Buffer.concat(chunks);
268
+ }
269
+
270
+ async function readJson(path, label) {
271
+ if (!path && process.stdin.isTTY) {
272
+ throw new Error(`Pipe ${label} to stdin or provide its file option.`);
273
+ }
274
+ const source = path ? createReadStream(path) : process.stdin;
275
+ const bytes = await readBounded(source, MAX_JSON_INPUT_BYTES, label);
276
+ try {
277
+ let text;
278
+ if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
279
+ text = bytes.subarray(3).toString("utf8");
280
+ } else if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
281
+ text = bytes.subarray(2).toString("utf16le");
282
+ } else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
283
+ const bigEndian = bytes.subarray(2);
284
+ if (bigEndian.length % 2 !== 0) throw new Error();
285
+ text = Buffer.from(bigEndian).swap16().toString("utf16le");
286
+ } else {
287
+ text = bytes.toString("utf8");
288
+ }
289
+ const parsed = JSON.parse(text);
290
+ if (!record(parsed)) throw new Error();
291
+ return parsed;
292
+ } catch {
293
+ throw new Error(`${label} must be a JSON object.`);
294
+ }
295
+ }
296
+
297
+ async function digestFile(path) {
298
+ const hash = createHash("sha256");
299
+ let size = 0;
300
+ const stream = createReadStream(path);
301
+ try {
302
+ for await (const chunk of stream) {
303
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
304
+ size += bytes.length;
305
+ if (size > MAX_REQUEST_FILE_BYTES) {
306
+ stream.destroy();
307
+ throw new Error(`Request file exceeds the ${MAX_REQUEST_FILE_BYTES}-byte limit.`);
308
+ }
309
+ hash.update(bytes);
310
+ }
311
+ } catch (error) {
312
+ if (error instanceof Error && error.message.includes("exceeds the")) throw error;
313
+ throw new Error("Could not read the request file.");
314
+ }
315
+ return hash.digest("hex");
316
+ }
317
+
318
+ async function resolveRequestDigest(options) {
319
+ if (Boolean(options.request_digest) === Boolean(options.request_file)) {
320
+ throw new Error("Provide exactly one of --request-digest or --request-file.");
321
+ }
322
+ if (options.request_digest) {
323
+ if (!SHA256_PATTERN.test(options.request_digest)) {
324
+ throw new Error("--request-digest must be a 64-character SHA-256 hexadecimal digest.");
325
+ }
326
+ return options.request_digest.toLowerCase();
327
+ }
328
+ return digestFile(options.request_file);
329
+ }
330
+
331
+ function validIsoTimestamp(value) {
332
+ return typeof value === "string" && value.length <= 100 && Number.isFinite(Date.parse(value));
333
+ }
334
+
335
+ function receiverChallenge(input) {
336
+ const challenge = record(input.challenge) ?? input;
337
+ const requiredIdentifiers = [
338
+ challenge.id,
339
+ challenge.recipientOrganizationId,
340
+ challenge.issuerOrganizationId,
341
+ challenge.verifierAgentId,
342
+ challenge.presenterAgentId,
343
+ ];
344
+ if (
345
+ requiredIdentifiers.some(
346
+ (value) =>
347
+ typeof value !== "string" || value.length > 200 || !SAFE_IDENTIFIER_PATTERN.test(value),
348
+ ) ||
349
+ typeof challenge.nonce !== "string" ||
350
+ challenge.nonce.length < 32 ||
351
+ challenge.nonce.length > 256 ||
352
+ !/^[A-Za-z0-9_-]+$/.test(challenge.nonce) ||
353
+ typeof challenge.requestedAction !== "string" ||
354
+ !ACTION_PATTERN.test(challenge.requestedAction) ||
355
+ typeof challenge.requestDigest !== "string" ||
356
+ !SHA256_PATTERN.test(challenge.requestDigest) ||
357
+ !validIsoTimestamp(challenge.expiresAt)
358
+ ) {
359
+ throw new Error("Receiver challenge JSON is missing required trusted challenge context.");
360
+ }
361
+
362
+ const hasAmount = challenge.transactionAmount !== undefined;
363
+ const hasCurrency = challenge.currency !== undefined;
364
+ if (hasAmount !== hasCurrency) {
365
+ throw new Error("Receiver challenge JSON has incomplete transaction context.");
366
+ }
367
+ if (
368
+ hasAmount &&
369
+ (typeof challenge.transactionAmount !== "number" ||
370
+ !Number.isFinite(challenge.transactionAmount) ||
371
+ challenge.transactionAmount < 0 ||
372
+ challenge.transactionAmount > 1e12 ||
373
+ typeof challenge.currency !== "string" ||
374
+ !/^[A-Z]{3}$/.test(challenge.currency))
375
+ ) {
376
+ throw new Error("Receiver challenge JSON has invalid transaction context.");
377
+ }
378
+
379
+ return {
380
+ id: challenge.id,
381
+ nonce: challenge.nonce,
382
+ recipientOrganizationId: challenge.recipientOrganizationId,
383
+ issuerOrganizationId: challenge.issuerOrganizationId,
384
+ verifierAgentId: challenge.verifierAgentId,
385
+ presenterAgentId: challenge.presenterAgentId,
386
+ requestedAction: challenge.requestedAction,
387
+ requestDigest: challenge.requestDigest.toLowerCase(),
388
+ ...(hasAmount
389
+ ? { transactionAmount: challenge.transactionAmount, currency: challenge.currency }
390
+ : {}),
391
+ expiresAt: challenge.expiresAt,
392
+ };
393
+ }
394
+
395
+ function decodeJwtObject(segment) {
396
+ if (
397
+ typeof segment !== "string" ||
398
+ segment.length === 0 ||
399
+ segment.length > MAX_JSON_INPUT_BYTES ||
400
+ !/^[A-Za-z0-9_-]+$/.test(segment)
401
+ ) {
402
+ throw new Error("Presentation token is not a valid compact JWT.");
403
+ }
404
+ try {
405
+ const decoded = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
406
+ if (!record(decoded)) throw new Error();
407
+ return decoded;
408
+ } catch {
409
+ throw new Error("Presentation token is not a valid compact JWT.");
410
+ }
411
+ }
412
+
413
+ function boundPresentationClaims(token, challenge, credentialId, expiresAt) {
414
+ const parts = token.split(".");
415
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
416
+ throw new Error("Presentation token is not a valid compact JWT.");
417
+ }
418
+ const header = decodeJwtObject(parts[0]);
419
+ const claims = decodeJwtObject(parts[1]);
420
+ const expectedAmount = challenge.transactionAmount;
421
+ const expectedCurrency = challenge.currency;
422
+ const expectedExpiresAt = Math.floor(Date.parse(expiresAt) / 1_000);
423
+ if (
424
+ header.alg !== "ES256" ||
425
+ header.typ !== "at+agent-proof+jwt" ||
426
+ header.kid !== challenge.presenterAgentId ||
427
+ claims.iss !== "urn:agenttrust:organization:" + challenge.issuerOrganizationId ||
428
+ claims.sub !== "urn:agenttrust:agent:" + challenge.presenterAgentId ||
429
+ claims.aud !== "urn:agenttrust:organization:" + challenge.recipientOrganizationId ||
430
+ claims.challengeId !== challenge.id ||
431
+ claims.nonce !== challenge.nonce ||
432
+ claims.credentialId !== credentialId ||
433
+ claims.agentId !== challenge.presenterAgentId ||
434
+ claims.organizationId !== challenge.issuerOrganizationId ||
435
+ claims.requestedAction !== challenge.requestedAction ||
436
+ claims.requestDigest !== challenge.requestDigest ||
437
+ claims.transactionAmount !== expectedAmount ||
438
+ claims.currency !== expectedCurrency ||
439
+ typeof claims.iat !== "number" ||
440
+ !Number.isInteger(claims.iat) ||
441
+ typeof claims.exp !== "number" ||
442
+ !Number.isInteger(claims.exp) ||
443
+ claims.exp <= claims.iat ||
444
+ claims.exp !== expectedExpiresAt ||
445
+ typeof claims.jti !== "string" ||
446
+ claims.jti.length > 200 ||
447
+ !SAFE_IDENTIFIER_PATTERN.test(claims.jti)
448
+ ) {
449
+ throw new Error("Presentation token claims do not match the receiver-owned challenge context.");
450
+ }
451
+ return claims;
452
+ }
453
+
454
+ async function requestJson({ baseUrl, path, key, body, idempotencyKey, timeout = 30_000 }) {
455
+ const headers = {
456
+ authorization: `Bearer ${key}`,
457
+ "content-type": "application/json",
458
+ };
459
+ if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
460
+ let response;
461
+ try {
462
+ response = await fetch(new URL(path, baseUrl), {
463
+ method: "POST",
464
+ headers,
465
+ body: JSON.stringify(body),
466
+ signal: AbortSignal.timeout(timeout),
467
+ });
468
+ } catch (error) {
469
+ const timedOut =
470
+ error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
471
+ throw new Error(timedOut ? "The Totem request timed out." : "Could not reach Totem.");
472
+ }
473
+ const payload = await response.json().catch(() => null);
474
+ return { response, payload };
475
+ }
476
+
477
+ function writeJson(payload) {
478
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
479
+ }
480
+
481
+ async function runChallenge(options, baseUrl) {
482
+ const key = apiKey("TOTEM_API_KEY", "AGENTTRUST_API_KEY");
483
+ if (options.positionals.length !== 1)
484
+ throw new Error("challenge does not accept positional values.");
485
+ const presenterAgentId = requiredOption(options, "presenter_agent", "--presenter-agent");
486
+ const requestedAction = requiredOption(options, "action", "--action", ACTION_PATTERN);
487
+ const requestDigest = await resolveRequestDigest(options);
488
+
489
+ const hasAmount = options.amount !== undefined;
490
+ const hasCurrency = options.currency !== undefined;
491
+ if (hasAmount !== hasCurrency)
492
+ throw new Error("--amount and --currency must be supplied together.");
493
+ let transactionAmount;
494
+ let currency;
495
+ if (hasAmount) {
496
+ transactionAmount = Number(options.amount);
497
+ if (!Number.isFinite(transactionAmount) || transactionAmount < 0 || transactionAmount > 1e12) {
498
+ throw new Error("--amount must be a finite number from 0 through 1000000000000.");
499
+ }
500
+ currency = options.currency.trim().toUpperCase();
501
+ if (!/^[A-Z]{3}$/.test(currency)) {
502
+ throw new Error("--currency must be a three-letter ISO currency code.");
503
+ }
504
+ }
505
+
506
+ const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
507
+ if (
508
+ typeof idempotencyKey !== "string" ||
509
+ idempotencyKey.length < 8 ||
510
+ idempotencyKey.length > 128 ||
511
+ !SAFE_IDENTIFIER_PATTERN.test(idempotencyKey)
512
+ ) {
513
+ throw new Error("--idempotency-key must contain 8 to 128 safe identifier characters.");
514
+ }
515
+ if (!options.json) process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
516
+
517
+ let result;
518
+ try {
519
+ result = await requestJson({
520
+ baseUrl,
521
+ path: "/api/v1/challenges",
522
+ key,
523
+ idempotencyKey,
524
+ body: {
525
+ presenterAgentId,
526
+ requestedAction,
527
+ requestDigest,
528
+ ...(transactionAmount !== undefined ? { transactionAmount, currency } : {}),
529
+ },
530
+ });
531
+ } catch (error) {
532
+ const message = error instanceof Error ? error.message : "Could not create the challenge.";
533
+ failUnknownOutcome(message, idempotencyKey, options.json, true);
534
+ return;
535
+ }
536
+ const challenge = record(result.payload?.challenge);
537
+ if (!result.response.ok) {
538
+ failCommand(
539
+ errorFrom(result.payload, `Challenge creation returned HTTP ${result.response.status}.`),
540
+ options.json,
541
+ );
542
+ return;
543
+ }
544
+ if (!challenge || typeof challenge.id !== "string" || typeof challenge.nonce !== "string") {
545
+ failUnknownOutcome(
546
+ "Totem returned an invalid challenge response.",
547
+ idempotencyKey,
548
+ options.json,
549
+ true,
550
+ );
551
+ return;
552
+ }
553
+ if (options.json) writeJson(result.payload);
554
+ else {
555
+ process.stdout.write(`Totem challenge · ${terminalText(challenge.id)}\n`);
556
+ process.stdout.write(`Action: ${terminalText(challenge.requestedAction ?? requestedAction)}\n`);
557
+ process.stdout.write(`Expires: ${terminalText(challenge.expiresAt)}\n`);
558
+ process.stdout.write(
559
+ "Use --json to emit the nonce-bearing challenge for the presenting agent.\n",
560
+ );
561
+ }
562
+ }
563
+
564
+ async function runPresent(options, baseUrl) {
565
+ const key = apiKey("TOTEM_AGENT_API_KEY", "AGENTTRUST_AGENT_API_KEY");
566
+ if (options.positionals.length !== 1)
567
+ throw new Error("present does not accept positional values.");
568
+ const credentialId = requiredOption(options, "credential_id", "--credential-id");
569
+ const input = await readJson(options.challenge_file, "challenge JSON");
570
+ const challenge = record(input.challenge) ?? input;
571
+ const challengeId = challenge.id;
572
+ const nonce = challenge.nonce;
573
+ if (
574
+ typeof challengeId !== "string" ||
575
+ !SAFE_IDENTIFIER_PATTERN.test(challengeId) ||
576
+ typeof nonce !== "string" ||
577
+ nonce.length < 32 ||
578
+ nonce.length > 256 ||
579
+ !/^[A-Za-z0-9_-]+$/.test(nonce)
580
+ ) {
581
+ throw new Error("Challenge JSON is missing a valid challenge id or nonce.");
582
+ }
583
+
584
+ const result = await requestJson({
585
+ baseUrl,
586
+ path: "/api/v1/presentations",
587
+ key,
588
+ body: { credentialId, challengeId, nonce },
589
+ });
590
+ const presentation = record(result.payload?.presentation);
591
+ if (!result.response.ok) {
592
+ const apiError = record(result.payload?.error);
593
+ if (typeof apiError?.code === "string" && PRESENTATION_REJECTION_CODES.has(apiError.code)) {
594
+ if (options.json) writeJson(result.payload);
595
+ else {
596
+ process.stdout.write("Totem presentation · REJECTED\n");
597
+ process.stdout.write(`Reason: ${apiError.code}\n`);
598
+ }
599
+ process.exitCode = 2;
600
+ return;
601
+ }
602
+ failCommand(
603
+ errorFrom(result.payload, `Presentation creation returned HTTP ${result.response.status}.`),
604
+ options.json,
605
+ );
606
+ return;
607
+ }
608
+ if (
609
+ !presentation ||
610
+ typeof presentation.challengeId !== "string" ||
611
+ typeof presentation.credentialId !== "string" ||
612
+ typeof presentation.token !== "string" ||
613
+ typeof presentation.expiresAt !== "string"
614
+ ) {
615
+ failCommand("Totem returned an invalid presentation response.", options.json);
616
+ return;
617
+ }
618
+ if (options.json) writeJson(result.payload);
619
+ else {
620
+ process.stdout.write(`Totem presentation · ${terminalText(presentation.challengeId)}\n`);
621
+ process.stdout.write(`Credential: ${terminalText(presentation.credentialId)}\n`);
622
+ process.stdout.write(`Expires: ${terminalText(presentation.expiresAt)}\n`);
623
+ process.stdout.write("Use --json to emit the token-bearing presentation for the verifier.\n");
624
+ }
625
+ }
626
+
627
+ async function runVerify(options, baseUrl) {
628
+ const key = apiKey("TOTEM_API_KEY", "AGENTTRUST_API_KEY");
629
+ if (options.positionals.length !== 1)
630
+ throw new Error("verify does not accept positional values.");
631
+ const expectedRequestDigest = await resolveRequestDigest(options);
632
+ if (!options.challenge_file?.trim()) {
633
+ throw new Error("Provide the receiver's original --challenge-file.");
634
+ }
635
+ const challengeInput = await readJson(options.challenge_file, "receiver challenge JSON");
636
+ const challenge = receiverChallenge(challengeInput);
637
+ if (challenge.requestDigest !== expectedRequestDigest) {
638
+ throw new Error(
639
+ "The local request digest does not match the receiver-owned challenge. Create a new challenge for the exact request.",
640
+ );
641
+ }
642
+ const input = await readJson(options.presentation_file, "presentation JSON");
643
+ const presentation = record(input.presentation) ?? input;
644
+ const challengeId = presentation.challengeId;
645
+ const credentialId = presentation.credentialId;
646
+ const token = presentation.token;
647
+ if (
648
+ typeof challengeId !== "string" ||
649
+ !SAFE_IDENTIFIER_PATTERN.test(challengeId) ||
650
+ challengeId !== challenge.id ||
651
+ typeof credentialId !== "string" ||
652
+ credentialId.length > 200 ||
653
+ !SAFE_IDENTIFIER_PATTERN.test(credentialId) ||
654
+ typeof token !== "string" ||
655
+ token.length < 20 ||
656
+ token.length > 100_000 ||
657
+ !validIsoTimestamp(presentation.expiresAt)
658
+ ) {
659
+ throw new Error(
660
+ "Presentation JSON does not match the receiver-owned challenge or is missing required proof fields.",
661
+ );
662
+ }
663
+ const claims = boundPresentationClaims(token, challenge, credentialId, presentation.expiresAt);
664
+
665
+ const result = await requestJson({
666
+ baseUrl,
667
+ path: "/api/v1/presentations/verify",
668
+ key,
669
+ body: { challengeId: challenge.id, presentation: token },
670
+ });
671
+ const payload = record(result.payload);
672
+ if (!payload || typeof payload.verified !== "boolean" || typeof payload.result !== "string") {
673
+ const apiError = record(payload?.error);
674
+ if (typeof apiError?.code === "string" && PRESENTATION_REJECTION_CODES.has(apiError.code)) {
675
+ if (options.json) writeJson(payload);
676
+ else {
677
+ process.stdout.write("Totem verification · REJECTED\n");
678
+ process.stdout.write(`Reason: ${apiError.code}\n`);
679
+ }
680
+ process.exitCode = 2;
681
+ return;
682
+ }
683
+ failCommand(
684
+ errorFrom(
685
+ result.payload,
686
+ `Presentation verification returned HTTP ${result.response.status}.`,
687
+ ),
688
+ options.json,
689
+ );
690
+ return;
691
+ }
692
+
693
+ const approved = payload.verified && payload.result === "APPROVED";
694
+ if ((payload.verified || payload.result === "APPROVED") && !approved) {
695
+ failCommand("Totem returned an internally inconsistent approval response.", options.json);
696
+ return;
697
+ }
698
+ if (approved && result.response.status !== 200) {
699
+ failCommand("Totem returned an approval without the required HTTP 200 status.", options.json);
700
+ return;
701
+ }
702
+ if (approved) {
703
+ const evidence = record(payload.evidence);
704
+ const approvedRequestDigest = evidence?.requestDigest;
705
+ const agent = record(payload.agent);
706
+ const organization = record(payload.organization);
707
+ const authorization = record(payload.authorization);
708
+ const evidenceMatches =
709
+ evidence?.challengeId === challenge.id &&
710
+ evidence.presentationJti === claims.jti &&
711
+ evidence.credentialId === credentialId &&
712
+ typeof approvedRequestDigest === "string" &&
713
+ SHA256_PATTERN.test(approvedRequestDigest) &&
714
+ approvedRequestDigest.toLowerCase() === expectedRequestDigest &&
715
+ validIsoTimestamp(evidence.verifiedAt);
716
+ const identityAndAuthorityMatch =
717
+ agent?.id === challenge.presenterAgentId &&
718
+ agent.status === "ACTIVE" &&
719
+ typeof agent.name === "string" &&
720
+ typeof agent.purpose === "string" &&
721
+ organization?.id === challenge.issuerOrganizationId &&
722
+ typeof organization.name === "string" &&
723
+ typeof organization.domain === "string" &&
724
+ organization.verified === true &&
725
+ authorization?.requestedAction === challenge.requestedAction &&
726
+ authorization.allowed === true &&
727
+ validIsoTimestamp(authorization.expiresAt) &&
728
+ typeof authorization.humanApprovalRequired === "boolean" &&
729
+ typeof payload.requestId === "string" &&
730
+ payload.requestId.length > 0 &&
731
+ payload.requestId.length <= 200;
732
+ if (!evidenceMatches || !identityAndAuthorityMatch) {
733
+ failCommand(
734
+ "Totem approval does not match the receiver-owned challenge and complete approval evidence; do not process the request.",
735
+ options.json,
736
+ );
737
+ return;
738
+ }
739
+ }
740
+
741
+ if (options.json) writeJson(payload);
742
+ else {
743
+ process.stdout.write(`Totem verification · ${terminalText(payload.result)}\n`);
744
+ if (payload.agent?.name || payload.agent?.id) {
745
+ process.stdout.write(`Agent: ${terminalText(payload.agent.name ?? payload.agent.id)}\n`);
746
+ }
747
+ if (payload.organization?.name || payload.organization?.id) {
748
+ process.stdout.write(
749
+ `Organization: ${terminalText(payload.organization.name ?? payload.organization.id)}\n`,
750
+ );
751
+ }
752
+ if (payload.authorization?.requestedAction) {
753
+ process.stdout.write(`Action: ${terminalText(payload.authorization.requestedAction)}\n`);
754
+ }
755
+ if (payload.reasonCode) process.stdout.write(`Reason: ${terminalText(payload.reasonCode)}\n`);
756
+ if (payload.requestId) process.stdout.write(`Receipt: ${terminalText(payload.requestId)}\n`);
757
+ }
758
+ if (payload.verified && payload.result === "APPROVED" && result.response.ok) return;
759
+ process.exitCode = payload.result === "REJECTED" ? 2 : 1;
760
+ }
761
+
762
+ async function runRfq(options, baseUrl) {
763
+ const key = apiKey("TOTEM_API_KEY", "AGENTTRUST_API_KEY");
764
+ if (baseUrl.origin === DEFAULT_BASE_URL) {
765
+ throw new Error(
766
+ "The RFQ command is available only on a sandbox or self-hosted deployment. Set TOTEM_URL to that deployment; production totemlayer.com supports challenge, present, and verify.",
767
+ );
768
+ }
769
+ const requestParts = options.positionals.slice(1);
770
+ if (requestParts.length === 0) throw new Error('Provide an RFQ request after the "rfq" command.');
771
+ const idempotencyKey = options.idempotency_key ?? `cli:${randomUUID()}`;
772
+ const prompt = requestParts.join(" ").trim();
773
+ process.stderr.write(`Totem retry key: ${idempotencyKey}\n`);
774
+
775
+ let result;
776
+ try {
777
+ result = await requestJson({
778
+ baseUrl,
779
+ path: "/api/v1/operator/rfq",
780
+ key,
781
+ idempotencyKey,
782
+ body: { prompt },
783
+ timeout: 90_000,
784
+ });
785
+ } catch (error) {
786
+ failUnknownOutcome(
787
+ error instanceof Error
788
+ ? error.message.replace("The Totem request", "The RFQ request")
789
+ : "Could not reach Totem.",
790
+ idempotencyKey,
791
+ options.json,
792
+ );
793
+ return;
794
+ }
795
+ if (!result.response.ok) {
796
+ const message = errorFrom(
797
+ result.payload,
798
+ `The RFQ endpoint returned HTTP ${result.response.status}.`,
799
+ );
800
+ if (result.response.status >= 500) failUnknownOutcome(message, idempotencyKey, options.json);
801
+ else fail(message);
802
+ return;
803
+ }
804
+ const run = record(result.payload?.run);
805
+ if (!run) {
806
+ failUnknownOutcome("Totem returned an invalid success response.", idempotencyKey, options.json);
807
+ return;
808
+ }
809
+ if (options.json) {
810
+ writeJson(result.payload);
811
+ return;
812
+ }
813
+
814
+ const verification = record(run.verification);
815
+ const supplier = record(run.supplierRequest);
816
+ process.stdout.write(`Totem RFQ · ${terminalText(run.id, "unknown run")}\n`);
817
+ process.stdout.write(`Run: ${terminalText(run.status, "UNKNOWN")}\n`);
818
+ if (run.summary) process.stdout.write(`${terminalText(run.summary)}\n`);
819
+ if (verification)
820
+ process.stdout.write(`Agent authority: ${terminalText(verification.result, "UNKNOWN")}\n`);
821
+ if (supplier) {
822
+ process.stdout.write(`Delivery: ${terminalText(supplier.status, "UNKNOWN")}\n`);
823
+ process.stdout.write(
824
+ `Supplier response: ${terminalText(supplier.businessStatus, "UNKNOWN")}\n`,
825
+ );
826
+ process.stdout.write(
827
+ `Available now: ${number(supplier.availableQuantity)} ${terminalText(supplier.unit, "units")}\n`,
828
+ );
829
+ process.stdout.write(
830
+ `Backorder: ${number(supplier.backorderQuantity)} ${terminalText(supplier.unit, "units")}\n`,
831
+ );
832
+ if (supplier.supplierReference)
833
+ process.stdout.write(`Reference: ${terminalText(supplier.supplierReference)}\n`);
834
+ }
835
+ if (run.status !== "SUCCEEDED") process.exitCode = 1;
836
+ }
837
+
838
+ async function main() {
839
+ let options;
840
+ try {
841
+ options = parseArguments(process.argv.slice(2));
842
+ const command = options.positionals[0];
843
+ if (options.version) {
844
+ process.stdout.write(`${CLI_VERSION}\n`);
845
+ return;
846
+ }
847
+ if (options.help || command === "help" || !command) {
848
+ help();
849
+ return;
850
+ }
851
+ validateCommandOptions(command, options);
852
+ const baseUrl = safeBaseUrl(
853
+ options.base_url ?? aliasedEnvironment("TOTEM_URL", "AGENTTRUST_URL") ?? DEFAULT_BASE_URL,
854
+ );
855
+ if (command === "challenge") await runChallenge(options, baseUrl);
856
+ else if (command === "present") await runPresent(options, baseUrl);
857
+ else if (command === "verify") await runVerify(options, baseUrl);
858
+ else await runRfq(options, baseUrl);
859
+ } catch (error) {
860
+ const message = error instanceof Error ? error.message : "The Totem command failed.";
861
+ const command = options?.positionals?.[0];
862
+ if (command && command !== "rfq") failCommand(message, options?.json);
863
+ else fail(message);
864
+ }
865
+ }
866
+
867
+ await main();
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "totemlayer",
3
+ "version": "0.1.0",
4
+ "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
+ "type": "module",
6
+ "bin": {
7
+ "totem": "bin/totem.mjs",
8
+ "totemlayer": "bin/totem.mjs",
9
+ "agenttrust": "bin/totem.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "LICENSE.md",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.14.0"
18
+ },
19
+ "keywords": [
20
+ "ai-agents",
21
+ "authorization",
22
+ "cli",
23
+ "identity",
24
+ "totemlayer",
25
+ "verification"
26
+ ],
27
+ "homepage": "https://totemlayer.com",
28
+ "license": "SEE LICENSE IN LICENSE.md",
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org/"
32
+ }
33
+ }