@did-btcr2/cli 0.21.0 → 0.22.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.
@@ -0,0 +1,156 @@
1
+ import type { IdentifierReport } from '@did-btcr2/api';
2
+ import { Identifier } from '@did-btcr2/api';
3
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
4
+ import type { Command } from 'commander';
5
+ import { readFile } from 'node:fs/promises';
6
+ import type { ApiFactory } from '../config.js';
7
+ import { CLIError } from '../error.js';
8
+ import { formatResult } from '../output.js';
9
+ import type { CommandResult, GlobalOptions, IdentifierDecodeData } from '../types.js';
10
+
11
+ /**
12
+ * Registers the `identifier` command group. `decode` prints the components of
13
+ * a did:btcr2 identifier. `validate` checks that an identifier conforms to the
14
+ * specification and prints a report. Both commands are offline and
15
+ * keystore-free: they use the api with no Bitcoin connection, no CAS, and no
16
+ * key material.
17
+ */
18
+ export function registerIdentifierCommand(
19
+ program : Command,
20
+ factory : ApiFactory,
21
+ globals : () => GlobalOptions,
22
+ ): void {
23
+ const identifier = program
24
+ .command('identifier')
25
+ .description('Decode and validate did:btcr2 identifiers (offline).');
26
+ const print = (result: CommandResult): void => console.log(formatResult(result, globals()));
27
+
28
+ identifier
29
+ .command('decode <did>')
30
+ .description('Print the components of a did:btcr2 identifier.')
31
+ .option(
32
+ '--initial-document',
33
+ 'Add the initial DID document. An external identifier (x) needs --genesis-document.',
34
+ false,
35
+ )
36
+ .option(
37
+ '--genesis-document <path>',
38
+ 'Path to the JSON genesis document of an external identifier (x). Requires --initial-document.',
39
+ )
40
+ .action(async (did: string, options: { initialDocument?: boolean; genesisDocument?: string }) => {
41
+ if (options.genesisDocument !== undefined && !options.initialDocument) {
42
+ throw new CLIError('--genesis-document requires --initial-document.', 'INVALID_ARGUMENT_ERROR');
43
+ }
44
+ assertValidIdentifier(did);
45
+ const api = factory();
46
+ const components = api.did.decode(did);
47
+ if (options.genesisDocument !== undefined && components.hrp === 'k') {
48
+ throw new CLIError(
49
+ '--genesis-document applies only to external identifiers (x).',
50
+ 'INVALID_ARGUMENT_ERROR',
51
+ { did },
52
+ );
53
+ }
54
+ const data: IdentifierDecodeData = {
55
+ did,
56
+ idType : components.idType,
57
+ hrp : components.hrp,
58
+ version : components.version,
59
+ network : components.network,
60
+ genesisBytes : bytesToHex(components.genesisBytes),
61
+ };
62
+ if (options.initialDocument) {
63
+ if (components.hrp === 'x' && options.genesisDocument === undefined) {
64
+ throw new CLIError(
65
+ 'An external identifier (x) needs --genesis-document <path> for --initial-document.',
66
+ 'INVALID_ARGUMENT_ERROR',
67
+ { did },
68
+ );
69
+ }
70
+ const genesisDocument = options.genesisDocument === undefined
71
+ ? undefined
72
+ : await readGenesisDocument(options.genesisDocument);
73
+ data.initialDocument = api.btcr2.getInitialDocument(did, genesisDocument);
74
+ }
75
+ print({ action: 'identifier-decode', data });
76
+ });
77
+
78
+ identifier
79
+ .command('validate <did>')
80
+ .description('Check that a did:btcr2 identifier conforms to the specification. Exit code 1 if it does not.')
81
+ .option(
82
+ '-b, --bytes <hex>',
83
+ 'Genesis bytes as a hex string that the identifier must encode: the 33-byte public key (k) '
84
+ + 'or the 32-byte genesis document hash (x). Adds the genesisBytesMatch check.',
85
+ )
86
+ .option(
87
+ '--genesis-document <path>',
88
+ 'Path to the JSON genesis document of an external identifier (x). Adds the genesisDocument check.',
89
+ )
90
+ .action(async (did: string, options: { bytes?: string; genesisDocument?: string }) => {
91
+ const genesisBytes = options.bytes === undefined ? undefined : parseHexBytes(options.bytes);
92
+ let genesisDocument: object | undefined;
93
+ if (options.genesisDocument !== undefined) {
94
+ // Refuse the flag for a KEY identifier before the file read. An invalid
95
+ // identifier passes through: the report names its failed check.
96
+ if (Identifier.isValid(did) && Identifier.decode(did).hrp === 'k') {
97
+ throw new CLIError(
98
+ '--genesis-document applies only to external identifiers (x).',
99
+ 'INVALID_ARGUMENT_ERROR',
100
+ { did },
101
+ );
102
+ }
103
+ genesisDocument = await readGenesisDocument(options.genesisDocument);
104
+ }
105
+ const report: IdentifierReport = factory().did.validate(did, { genesisBytes, genesisDocument });
106
+ print({ action: 'identifier-validate', data: report });
107
+ if (!report.valid) process.exitCode = 1;
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Throws a `CLIError` that names the failed check if the identifier is not
113
+ * valid. The Bech32m decoder alone throws a raw error with a stack; this
114
+ * guard gives `decode` one message shape for every failure.
115
+ */
116
+ function assertValidIdentifier(did: string): void {
117
+ const report = Identifier.validate(did);
118
+ if (report.valid) return;
119
+ const failed = report.checks[report.checks.length - 1];
120
+ throw new CLIError(
121
+ `Invalid identifier (${failed.name} check): ${failed.detail ?? 'failed'}`,
122
+ 'INVALID_ARGUMENT_ERROR',
123
+ { did, check: failed.name },
124
+ );
125
+ }
126
+
127
+ /** Parses the `--bytes` hex string. The length is a validation result, not an argument error. */
128
+ function parseHexBytes(value: string): Uint8Array {
129
+ try {
130
+ return hexToBytes(value.trim());
131
+ } catch {
132
+ throw new CLIError('Invalid bytes: not valid hex.', 'INVALID_ARGUMENT_ERROR', { bytes: value });
133
+ }
134
+ }
135
+
136
+ /** Reads and parses the genesis document file. The content must be a JSON object. */
137
+ async function readGenesisDocument(path: string): Promise<object> {
138
+ let parsed: unknown;
139
+ try {
140
+ parsed = JSON.parse(await readFile(path, 'utf-8'));
141
+ } catch {
142
+ throw new CLIError(
143
+ 'Invalid genesis document path. Must be a valid path to a JSON file.',
144
+ 'INVALID_ARGUMENT_ERROR',
145
+ { genesisDocument: path },
146
+ );
147
+ }
148
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
149
+ throw new CLIError(
150
+ 'Invalid genesis document. The file must contain a JSON object.',
151
+ 'INVALID_ARGUMENT_ERROR',
152
+ { genesisDocument: path },
153
+ );
154
+ }
155
+ return parsed;
156
+ }
@@ -4,6 +4,7 @@ export { registerCreateCommand } from './create.js';
4
4
  export { registerResolveCommand } from './resolve.js';
5
5
  export { registerUpdateCommand } from './update.js';
6
6
  export { registerDeactivateCommand } from './deactivate.js';
7
+ export { registerIdentifierCommand } from './identifier.js';
7
8
  export { registerKeyCommand } from './key.js';
8
9
  export { registerKeystoreCommand } from './keystore.js';
9
10
  export { registerConfigCommand } from './config.js';
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BroadcastOptions, DidUpdateResult, PublishToCasMode, Signer } from '@did-btcr2/api';
1
+ import type { BroadcastOptions, DidUpdateResult, IdentifierReport, PublishToCasMode, Signer } from '@did-btcr2/api';
2
2
  import type { Btcr2DidDocument, ResolutionOptions } from '@did-btcr2/method';
3
3
  import type { DidResolutionResult } from '@web5/dids';
4
4
  import type { DoctorReport, EffectiveConfig } from './config.js';
@@ -54,11 +54,28 @@ export interface UpdateCommandOptions {
54
54
  broadcastOptions? : BroadcastOptions;
55
55
  }
56
56
 
57
+ /**
58
+ * The data that `identifier decode` prints: the decoded components of the
59
+ * identifier with the genesis bytes as hex, plus the initial DID document
60
+ * if the caller asked for it.
61
+ */
62
+ export interface IdentifierDecodeData {
63
+ did : string;
64
+ idType : string;
65
+ hrp : string;
66
+ version : number;
67
+ network : string;
68
+ genesisBytes : string;
69
+ initialDocument? : Btcr2DidDocument;
70
+ }
71
+
57
72
  export type CommandResult =
58
73
  | { action: 'create'; data: string; keyId?: string; publicKey?: string }
59
74
  | { action: 'resolve'; data: DidResolutionResult }
60
75
  | { action: 'update'; data: DidUpdateResult }
61
76
  | { action: 'deactivate'; data: DidUpdateResult }
77
+ | { action: 'identifier-decode'; data: IdentifierDecodeData }
78
+ | { action: 'identifier-validate'; data: IdentifierReport }
62
79
  | { action: 'key-generate'; data: { keyId: string; publicKey: string; active: boolean } }
63
80
  | { action: 'key-list'; data: Array<{ keyId: string; fingerprint: string; name?: string; active: boolean }> }
64
81
  | { action: 'key-show'; data: { keyId: string; publicKey: string; tags?: Record<string, string> } }