@did-btcr2/cli 0.21.0 → 0.23.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/README.md +29 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +494 -64
- package/dist/esm/src/cli.js +4 -2
- package/dist/esm/src/cli.js.map +1 -1
- package/dist/esm/src/commands/completion.js +1 -1
- package/dist/esm/src/commands/completion.js.map +1 -1
- package/dist/esm/src/commands/create.js +33 -41
- package/dist/esm/src/commands/create.js.map +1 -1
- package/dist/esm/src/commands/genesis.js +119 -0
- package/dist/esm/src/commands/genesis.js.map +1 -0
- package/dist/esm/src/commands/identifier.js +96 -0
- package/dist/esm/src/commands/identifier.js.map +1 -0
- package/dist/esm/src/commands/index.js +2 -0
- package/dist/esm/src/commands/index.js.map +1 -1
- package/dist/esm/src/commands/resolve.js +15 -1
- package/dist/esm/src/commands/resolve.js.map +1 -1
- package/dist/esm/src/commands/write.js +10 -7
- package/dist/esm/src/commands/write.js.map +1 -1
- package/dist/esm/src/genesis-document-file.js +26 -0
- package/dist/esm/src/genesis-document-file.js.map +1 -0
- package/dist/esm/src/genesis-spec.js +93 -0
- package/dist/esm/src/genesis-spec.js.map +1 -0
- package/dist/esm/src/genesis-wizard.js +132 -0
- package/dist/esm/src/genesis-wizard.js.map +1 -0
- package/dist/esm/src/hints.js +14 -3
- package/dist/esm/src/hints.js.map +1 -1
- package/dist/esm/src/network-option.js +39 -0
- package/dist/esm/src/network-option.js.map +1 -0
- package/dist/esm/src/resolution-options.js +19 -2
- package/dist/esm/src/resolution-options.js.map +1 -1
- package/dist/types/src/cli.d.ts +1 -1
- package/dist/types/src/cli.d.ts.map +1 -1
- package/dist/types/src/commands/create.d.ts +5 -4
- package/dist/types/src/commands/create.d.ts.map +1 -1
- package/dist/types/src/commands/genesis.d.ts +20 -0
- package/dist/types/src/commands/genesis.d.ts.map +1 -0
- package/dist/types/src/commands/identifier.d.ts +12 -0
- package/dist/types/src/commands/identifier.d.ts.map +1 -0
- package/dist/types/src/commands/index.d.ts +2 -0
- package/dist/types/src/commands/index.d.ts.map +1 -1
- package/dist/types/src/commands/resolve.d.ts +7 -0
- package/dist/types/src/commands/resolve.d.ts.map +1 -1
- package/dist/types/src/commands/write.d.ts +2 -1
- package/dist/types/src/commands/write.d.ts.map +1 -1
- package/dist/types/src/genesis-document-file.d.ts +11 -0
- package/dist/types/src/genesis-document-file.d.ts.map +1 -0
- package/dist/types/src/genesis-spec.d.ts +70 -0
- package/dist/types/src/genesis-spec.d.ts.map +1 -0
- package/dist/types/src/genesis-wizard.d.ts +30 -0
- package/dist/types/src/genesis-wizard.d.ts.map +1 -0
- package/dist/types/src/hints.d.ts +7 -0
- package/dist/types/src/hints.d.ts.map +1 -1
- package/dist/types/src/network-option.d.ts +15 -0
- package/dist/types/src/network-option.d.ts.map +1 -0
- package/dist/types/src/resolution-options.d.ts +6 -2
- package/dist/types/src/resolution-options.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +38 -1
- package/dist/types/src/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/cli.ts +5 -1
- package/src/commands/completion.ts +1 -1
- package/src/commands/create.ts +39 -55
- package/src/commands/genesis.ts +149 -0
- package/src/commands/identifier.ts +134 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/resolve.ts +20 -1
- package/src/commands/write.ts +10 -7
- package/src/genesis-document-file.ts +35 -0
- package/src/genesis-spec.ts +149 -0
- package/src/genesis-wizard.ts +163 -0
- package/src/hints.ts +13 -2
- package/src/network-option.ts +50 -0
- package/src/resolution-options.ts +21 -2
- package/src/types.ts +34 -2
|
@@ -0,0 +1,134 @@
|
|
|
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 type { ApiFactory } from '../config.js';
|
|
6
|
+
import { CLIError } from '../error.js';
|
|
7
|
+
import { readGenesisDocumentFile } from '../genesis-document-file.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 readGenesisDocumentFile(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 readGenesisDocumentFile(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
|
+
}
|
package/src/commands/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ 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';
|
|
8
|
+
export { registerGenesisCommand } from './genesis.js';
|
|
7
9
|
export { registerKeyCommand } from './key.js';
|
|
8
10
|
export { registerKeystoreCommand } from './keystore.js';
|
|
9
11
|
export { registerConfigCommand } from './config.js';
|
package/src/commands/resolve.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Identifier } from '@did-btcr2/api';
|
|
2
2
|
import type { Command } from 'commander';
|
|
3
3
|
import { deriveNetwork, type ApiFactory } from '../config.js';
|
|
4
|
+
import { CLIError } from '../error.js';
|
|
5
|
+
import { GENESIS_DOCUMENT_HELP } from '../genesis-document-file.js';
|
|
4
6
|
import { formatResult } from '../output.js';
|
|
5
7
|
import { MIN_CONF_HELP, parseMinConf, readResolutionOptions, type ResolutionOptionFlags } from '../resolution-options.js';
|
|
6
8
|
import type { GlobalOptions, ResolveCommandOptions } from '../types.js';
|
|
@@ -18,6 +20,7 @@ export function registerResolveCommand(
|
|
|
18
20
|
.option('-r, --resolution-options <json>', 'JSON string containing resolution options')
|
|
19
21
|
.option('-p, --resolution-options-path <path>', 'Path to a JSON file containing resolution options')
|
|
20
22
|
.option('--min-conf <n>', MIN_CONF_HELP, parseMinConf)
|
|
23
|
+
.option('--genesis-document <path>', GENESIS_DOCUMENT_HELP)
|
|
21
24
|
.action(async (options: { identifier: string } & ResolutionOptionFlags) => {
|
|
22
25
|
const parsed = await validateResolveOptions(options);
|
|
23
26
|
const network = deriveNetwork(parsed.identifier);
|
|
@@ -32,7 +35,23 @@ async function validateResolveOptions(
|
|
|
32
35
|
options: { identifier: string } & ResolutionOptionFlags,
|
|
33
36
|
): Promise<ResolveCommandOptions> {
|
|
34
37
|
// Validate identifier format early
|
|
35
|
-
Identifier.decode(options.identifier);
|
|
38
|
+
const components = Identifier.decode(options.identifier);
|
|
39
|
+
assertGenesisDocumentApplies(options, components.hrp);
|
|
36
40
|
const resolutionOptions = await readResolutionOptions(options);
|
|
37
41
|
return { identifier: options.identifier, options: resolutionOptions };
|
|
38
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Refuses `--genesis-document` for a KEY identifier before the file is read.
|
|
46
|
+
* A KEY identifier has no genesis document; the resolver would ignore the
|
|
47
|
+
* file in silence. `resolve`, `update`, and `deactivate` share the check.
|
|
48
|
+
*/
|
|
49
|
+
export function assertGenesisDocumentApplies(flags: ResolutionOptionFlags, hrp: string): void {
|
|
50
|
+
if (flags.genesisDocument !== undefined && hrp === 'k') {
|
|
51
|
+
throw new CLIError(
|
|
52
|
+
'--genesis-document applies only to external identifiers (x).',
|
|
53
|
+
'INVALID_ARGUMENT_ERROR',
|
|
54
|
+
{ genesisDocument: flags.genesisDocument },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/commands/write.ts
CHANGED
|
@@ -8,10 +8,13 @@ import {
|
|
|
8
8
|
resolveSigningKeyRef,
|
|
9
9
|
type ApiFactory,
|
|
10
10
|
} from '../config.js';
|
|
11
|
+
import { Identifier } from '@did-btcr2/api';
|
|
11
12
|
import { CLIError } from '../error.js';
|
|
13
|
+
import { GENESIS_DOCUMENT_HELP } from '../genesis-document-file.js';
|
|
12
14
|
import { resolveKeyRef } from '../keystore/resolve-key-ref.js';
|
|
13
|
-
import { MIN_CONF_HELP, parseMinConf, readResolutionOptions, type ResolutionOptionFlags } from '../resolution-options.js';
|
|
15
|
+
import { hasResolutionFlags, MIN_CONF_HELP, parseMinConf, readResolutionOptions, type ResolutionOptionFlags } from '../resolution-options.js';
|
|
14
16
|
import type { GlobalOptions, NetworkOption, UpdateCommandOptions } from '../types.js';
|
|
17
|
+
import { assertGenesisDocumentApplies } from './resolve.js';
|
|
15
18
|
|
|
16
19
|
/** The parsed flags that `update` and `deactivate` share. */
|
|
17
20
|
export type WriteFlags = ResolutionOptionFlags & {
|
|
@@ -62,6 +65,7 @@ export function registerWriteOptions(command: Command): Command {
|
|
|
62
65
|
'Path to a JSON file with resolution options, for the resolution of the source document',
|
|
63
66
|
)
|
|
64
67
|
.option('--min-conf <n>', MIN_CONF_HELP, parseMinConf)
|
|
68
|
+
.option('--genesis-document <path>', `${GENESIS_DOCUMENT_HELP}, for the resolution of the source document`)
|
|
65
69
|
.option(
|
|
66
70
|
'--publish-to-cas <mode>',
|
|
67
71
|
'Publish update artifacts to a writable CAS before broadcast: auto|always|never. '
|
|
@@ -93,7 +97,8 @@ export function registerWriteOptions(command: Command): Command {
|
|
|
93
97
|
* 3. The resolution flags come only without the source pair. The api ignores
|
|
94
98
|
* `resolutionOptions` when the pair is supplied (ADR 098). A silent ignore
|
|
95
99
|
* of `--min-conf` would mislead.
|
|
96
|
-
* 4.
|
|
100
|
+
* 4. `--genesis-document` comes only with an external (x) identifier.
|
|
101
|
+
* 5. A mainnet write is refused with an unencrypted dev keystore (ADR 080).
|
|
97
102
|
*/
|
|
98
103
|
export async function prepareWrite(
|
|
99
104
|
options : WriteFlags,
|
|
@@ -112,17 +117,15 @@ export async function prepareWrite(
|
|
|
112
117
|
{ did },
|
|
113
118
|
);
|
|
114
119
|
}
|
|
115
|
-
|
|
116
|
-
|| options.resolutionOptionsPath !== undefined
|
|
117
|
-
|| options.minConf !== undefined;
|
|
118
|
-
if (hasDocument && hasResolutionFlags) {
|
|
120
|
+
if (hasDocument && hasResolutionFlags(options)) {
|
|
119
121
|
throw new CLIError(
|
|
120
|
-
'--resolution-options, --resolution-options-path,
|
|
122
|
+
'--resolution-options, --resolution-options-path, --min-conf, and --genesis-document apply only when '
|
|
121
123
|
+ '--source-document and --source-version-id are omitted. A supplied source pair skips resolution.',
|
|
122
124
|
'INVALID_ARGUMENT_ERROR',
|
|
123
125
|
{ did },
|
|
124
126
|
);
|
|
125
127
|
}
|
|
128
|
+
assertGenesisDocumentApplies(options, Identifier.decode(did).hrp);
|
|
126
129
|
assertKeystoreAllowedForNetwork(network, g);
|
|
127
130
|
const resolutionOptions = await readResolutionOptions(options);
|
|
128
131
|
const api = factory(network, g);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { CLIError } from './error.js';
|
|
3
|
+
|
|
4
|
+
/** The help text of `--genesis-document`. `resolve`, `update`, and `deactivate` share it. */
|
|
5
|
+
export const GENESIS_DOCUMENT_HELP =
|
|
6
|
+
'Path to the JSON genesis document of an external identifier (x). '
|
|
7
|
+
+ 'Fills sidecar.genesisDocument of the resolution options';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reads and parses a genesis document file. The content must be a JSON
|
|
11
|
+
* object. The shape of the document is checked by the api, not here.
|
|
12
|
+
* @param path The path of the JSON file.
|
|
13
|
+
* @returns The parsed object.
|
|
14
|
+
* @throws {CLIError} If the file is unreadable, not JSON, or not an object.
|
|
15
|
+
*/
|
|
16
|
+
export async function readGenesisDocumentFile(path: string): Promise<object> {
|
|
17
|
+
let parsed: unknown;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(await readFile(path, 'utf-8'));
|
|
20
|
+
} catch {
|
|
21
|
+
throw new CLIError(
|
|
22
|
+
'Invalid genesis document path. Must be a valid path to a JSON file.',
|
|
23
|
+
'INVALID_ARGUMENT_ERROR',
|
|
24
|
+
{ genesisDocument: path },
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
28
|
+
throw new CLIError(
|
|
29
|
+
'Invalid genesis document. The file must contain a JSON object.',
|
|
30
|
+
'INVALID_ARGUMENT_ERROR',
|
|
31
|
+
{ genesisDocument: path },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BeaconAddressType,
|
|
3
|
+
BeaconType,
|
|
4
|
+
GenesisBeaconSpec,
|
|
5
|
+
GenesisDocumentSpec,
|
|
6
|
+
GenesisVerificationMethodSpec,
|
|
7
|
+
VerificationRelationship
|
|
8
|
+
} from '@did-btcr2/api';
|
|
9
|
+
import { hexToBytes } from '@noble/hashes/utils.js';
|
|
10
|
+
import type { DidService } from '@web5/dids';
|
|
11
|
+
import { CLIError } from './error.js';
|
|
12
|
+
import type { NetworkOption } from './types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One verification method of a genesis spec file. `key` is a keystore
|
|
16
|
+
* reference (URN, name, or fingerprint prefix); `publicKey` is a 33-byte
|
|
17
|
+
* compressed public key as hex. Exactly one of the two.
|
|
18
|
+
*/
|
|
19
|
+
export interface GenesisSpecVerificationMethod {
|
|
20
|
+
key? : string;
|
|
21
|
+
publicKey? : string;
|
|
22
|
+
relationships? : string[];
|
|
23
|
+
fragment? : string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One beacon of a genesis spec file. `key` or `publicKey` derives the beacon
|
|
28
|
+
* address from a key; `address` uses a Bitcoin address as given. At most one
|
|
29
|
+
* of the three; the api requires one.
|
|
30
|
+
*/
|
|
31
|
+
export interface GenesisSpecBeacon {
|
|
32
|
+
type : string;
|
|
33
|
+
key? : string;
|
|
34
|
+
publicKey? : string;
|
|
35
|
+
addressType? : string;
|
|
36
|
+
address? : string;
|
|
37
|
+
fragment? : string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One other service of a genesis spec file. */
|
|
41
|
+
export interface GenesisSpecService {
|
|
42
|
+
id? : string;
|
|
43
|
+
type : string;
|
|
44
|
+
serviceEndpoint : unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The genesis spec file that `btcr2 genesis build --spec <path>` reads, and
|
|
49
|
+
* that the wizard collects. The network comes from `-n` or the configuration,
|
|
50
|
+
* not from the file.
|
|
51
|
+
*/
|
|
52
|
+
export interface GenesisSpecFile {
|
|
53
|
+
verificationMethods : GenesisSpecVerificationMethod[];
|
|
54
|
+
beacons? : GenesisSpecBeacon[];
|
|
55
|
+
services? : GenesisSpecService[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolves a keystore key reference to the 33-byte public key. */
|
|
59
|
+
export type ResolvePublicKey = (ref: string) => Uint8Array;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Checks the shape of a parsed genesis spec file. The values (relationship
|
|
63
|
+
* names, beacon types, addresses) are checked by the api when it builds the
|
|
64
|
+
* document; this function checks only what the api cannot see: that each
|
|
65
|
+
* verification method names exactly one key source, and that each beacon
|
|
66
|
+
* names at most one.
|
|
67
|
+
* @param value The parsed JSON value.
|
|
68
|
+
* @param source The file path, for the error data.
|
|
69
|
+
* @returns The spec.
|
|
70
|
+
* @throws {CLIError} If the shape is not valid.
|
|
71
|
+
*/
|
|
72
|
+
export function parseGenesisSpec(value: unknown, source: string): GenesisSpecFile {
|
|
73
|
+
const fail = (message: string): never => {
|
|
74
|
+
throw new CLIError(`Invalid genesis spec: ${message}`, 'INVALID_ARGUMENT_ERROR', { spec: source });
|
|
75
|
+
};
|
|
76
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
77
|
+
return fail('the file must contain a JSON object.');
|
|
78
|
+
}
|
|
79
|
+
const spec = value as Record<string, unknown>;
|
|
80
|
+
if (!Array.isArray(spec.verificationMethods) || spec.verificationMethods.length === 0) {
|
|
81
|
+
return fail('"verificationMethods" must be a non-empty array.');
|
|
82
|
+
}
|
|
83
|
+
spec.verificationMethods.forEach((vm, index) => {
|
|
84
|
+
if (vm === null || typeof vm !== 'object') return fail(`verificationMethods[${index}] must be an object.`);
|
|
85
|
+
const hasKey = typeof vm.key === 'string' && vm.key.length > 0;
|
|
86
|
+
const hasPublicKey = typeof vm.publicKey === 'string' && vm.publicKey.length > 0;
|
|
87
|
+
if (hasKey === hasPublicKey) {
|
|
88
|
+
return fail(`verificationMethods[${index}] needs exactly one of "key" (a keystore reference) or "publicKey" (hex).`);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
if (spec.beacons !== undefined) {
|
|
92
|
+
if (!Array.isArray(spec.beacons)) return fail('"beacons" must be an array.');
|
|
93
|
+
spec.beacons.forEach((beacon, index) => {
|
|
94
|
+
if (beacon === null || typeof beacon !== 'object') return fail(`beacons[${index}] must be an object.`);
|
|
95
|
+
const sources = ['key', 'publicKey', 'address'].filter(name => beacon[name] !== undefined);
|
|
96
|
+
if (sources.length > 1) {
|
|
97
|
+
return fail(`beacons[${index}] names ${sources.join(' and ')}. Give only one of "key", "publicKey", or "address".`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (spec.services !== undefined && !Array.isArray(spec.services)) {
|
|
102
|
+
return fail('"services" must be an array.');
|
|
103
|
+
}
|
|
104
|
+
return spec as unknown as GenesisSpecFile;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Converts a genesis spec file to the api spec: resolves each keystore
|
|
109
|
+
* reference to its public key, parses each hex public key, and passes the
|
|
110
|
+
* other values through for the api to check.
|
|
111
|
+
* @param spec The spec file.
|
|
112
|
+
* @param network The network of the beacon addresses.
|
|
113
|
+
* @param resolvePublicKey Resolves a keystore reference to the public key.
|
|
114
|
+
* @returns The api spec.
|
|
115
|
+
* @throws {CLIError} If a hex public key is not hex.
|
|
116
|
+
*/
|
|
117
|
+
export function toApiSpec(spec: GenesisSpecFile, network: NetworkOption, resolvePublicKey: ResolvePublicKey): GenesisDocumentSpec {
|
|
118
|
+
const keyBytes = (entry: { key?: string; publicKey?: string }, label: string): Uint8Array =>
|
|
119
|
+
entry.key !== undefined ? resolvePublicKey(entry.key) : parseHexPublicKey(entry.publicKey as string, label);
|
|
120
|
+
const verificationMethods: GenesisVerificationMethodSpec[] = spec.verificationMethods.map((vm, index) => ({
|
|
121
|
+
publicKey : keyBytes(vm, `verificationMethods[${index}].publicKey`),
|
|
122
|
+
relationships : vm.relationships as VerificationRelationship[] | undefined,
|
|
123
|
+
fragment : vm.fragment,
|
|
124
|
+
}));
|
|
125
|
+
const beacons: GenesisBeaconSpec[] | undefined = spec.beacons?.map((beacon, index) => ({
|
|
126
|
+
type : beacon.type as BeaconType,
|
|
127
|
+
publicKey : beacon.key !== undefined || beacon.publicKey !== undefined
|
|
128
|
+
? keyBytes(beacon, `beacons[${index}].publicKey`)
|
|
129
|
+
: undefined,
|
|
130
|
+
addressType : beacon.addressType as BeaconAddressType | undefined,
|
|
131
|
+
address : beacon.address,
|
|
132
|
+
fragment : beacon.fragment,
|
|
133
|
+
}));
|
|
134
|
+
return {
|
|
135
|
+
network,
|
|
136
|
+
verificationMethods,
|
|
137
|
+
beacons,
|
|
138
|
+
services : spec.services as DidService[] | undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Parses a hex public key. The length and the curve check belong to the api. */
|
|
143
|
+
export function parseHexPublicKey(hex: string, label: string): Uint8Array {
|
|
144
|
+
try {
|
|
145
|
+
return hexToBytes(hex.trim());
|
|
146
|
+
} catch {
|
|
147
|
+
throw new CLIError(`Invalid ${label}: not valid hex.`, 'INVALID_ARGUMENT_ERROR', { publicKey: hex });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { BEACON_ADDRESS_TYPES, BEACON_TYPES, DEFAULT_BEACON_ADDRESS_TYPE, VERIFICATION_RELATIONSHIPS } from '@did-btcr2/api';
|
|
2
|
+
import type { GenesisSpecBeacon, GenesisSpecFile, GenesisSpecService, GenesisSpecVerificationMethod } from './genesis-spec.js';
|
|
3
|
+
|
|
4
|
+
/** Asks one question and returns the answer line. */
|
|
5
|
+
export type Ask = (question: string) => Promise<string>;
|
|
6
|
+
|
|
7
|
+
/** One keystore key as the wizard lists it. */
|
|
8
|
+
export interface WizardKey {
|
|
9
|
+
keyId : string;
|
|
10
|
+
fingerprint : string;
|
|
11
|
+
name? : string;
|
|
12
|
+
active : boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What the wizard needs from the command: output, the key list, and key resolution. */
|
|
16
|
+
export interface WizardContext {
|
|
17
|
+
/** Prints one line to the operator. */
|
|
18
|
+
say: (line: string) => void;
|
|
19
|
+
/** The keys of the keystore, for the list and the default. */
|
|
20
|
+
keys: WizardKey[];
|
|
21
|
+
/** Resolves a key reference to its id, or throws with a message. */
|
|
22
|
+
resolveKey: (ref: string) => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A 33-byte compressed public key as hex. */
|
|
26
|
+
const PUBLIC_KEY_HEX = /^0[23][0-9a-fA-F]{64}$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collects a genesis spec through questions: the verification methods with
|
|
30
|
+
* their relationships, the beacons, and the other services. The answers are
|
|
31
|
+
* not checked against the network here; the api checks them when it builds
|
|
32
|
+
* the document, and the command reports the failure.
|
|
33
|
+
* @param ask Asks one question.
|
|
34
|
+
* @param ctx The output, the key list, and the key resolver.
|
|
35
|
+
* @returns The spec.
|
|
36
|
+
*/
|
|
37
|
+
export async function collectGenesisSpec(ask: Ask, ctx: WizardContext): Promise<GenesisSpecFile> {
|
|
38
|
+
const verificationMethods = await collectVerificationMethods(ask, ctx);
|
|
39
|
+
const beacons = await collectBeacons(ask, ctx, verificationMethods[0]);
|
|
40
|
+
const services = await collectServices(ask, ctx);
|
|
41
|
+
return { verificationMethods, beacons, ...(services.length > 0 && { services }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Asks for the verification methods. The first one defaults to the active key. */
|
|
45
|
+
async function collectVerificationMethods(ask: Ask, ctx: WizardContext): Promise<GenesisSpecVerificationMethod[]> {
|
|
46
|
+
if (ctx.keys.length === 0) {
|
|
47
|
+
ctx.say('The keystore has no keys. Enter each key as a 33-byte compressed public key in hex.');
|
|
48
|
+
} else {
|
|
49
|
+
ctx.say('Keys in the keystore:');
|
|
50
|
+
for (const key of ctx.keys) {
|
|
51
|
+
ctx.say(` ${key.fingerprint}${key.name ? ` ${key.name}` : ''}${key.active ? ' (active)' : ''}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const active = ctx.keys.find(key => key.active);
|
|
55
|
+
const methods: GenesisSpecVerificationMethod[] = [];
|
|
56
|
+
for (let n = 1; ; n++) {
|
|
57
|
+
const defaultRef = n === 1 && active ? (active.name ?? active.fingerprint) : undefined;
|
|
58
|
+
const source = await askKeySource(ask, ctx, `Verification method ${n}: key reference or public key hex`, defaultRef);
|
|
59
|
+
const relationships = await askRelationships(ask, ctx, n);
|
|
60
|
+
methods.push({ ...source, ...(relationships && { relationships }) });
|
|
61
|
+
if (!(await askYesNo(ask, 'Add another verification method?'))) break;
|
|
62
|
+
}
|
|
63
|
+
return methods;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Asks for the beacons. The first one defaults to a Singleton beacon on the first key. */
|
|
67
|
+
async function collectBeacons(ask: Ask, ctx: WizardContext, firstMethod: GenesisSpecVerificationMethod): Promise<GenesisSpecBeacon[]> {
|
|
68
|
+
const beacons: GenesisSpecBeacon[] = [];
|
|
69
|
+
for (let n = 1; ; n++) {
|
|
70
|
+
const type = await askChoice(ask, ctx, `Beacon ${n} type`, BEACON_TYPES, 'SingletonBeacon');
|
|
71
|
+
const address = (await ask(`Beacon ${n} address (blank: derive the address from the key of method 1): `)).trim();
|
|
72
|
+
if (address.length === 0) {
|
|
73
|
+
const addressType = await askChoice(ask, ctx, `Beacon ${n} address type`, BEACON_ADDRESS_TYPES, DEFAULT_BEACON_ADDRESS_TYPE);
|
|
74
|
+
const keySource = firstMethod.key !== undefined ? { key: firstMethod.key } : { publicKey: firstMethod.publicKey };
|
|
75
|
+
beacons.push({ type, ...keySource, addressType });
|
|
76
|
+
} else {
|
|
77
|
+
beacons.push({ type, address });
|
|
78
|
+
}
|
|
79
|
+
if (!(await askYesNo(ask, 'Add another beacon?'))) break;
|
|
80
|
+
}
|
|
81
|
+
return beacons;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Asks for the other services, if any. */
|
|
85
|
+
async function collectServices(ask: Ask, ctx: WizardContext): Promise<GenesisSpecService[]> {
|
|
86
|
+
const services: GenesisSpecService[] = [];
|
|
87
|
+
while (await askYesNo(ask, 'Add a service?')) {
|
|
88
|
+
const n = services.length + 1;
|
|
89
|
+
const fragment = (await ask(`Service ${n} id fragment (blank: service-<position>): `)).trim();
|
|
90
|
+
const type = await askRequired(ask, ctx, `Service ${n} type`);
|
|
91
|
+
const serviceEndpoint = await askRequired(ask, ctx, `Service ${n} endpoint`);
|
|
92
|
+
services.push({ ...(fragment.length > 0 && { id: `#${fragment.replace(/^#/, '')}` }), type, serviceEndpoint });
|
|
93
|
+
}
|
|
94
|
+
return services;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Asks for a key reference or a public key hex, until the answer resolves. */
|
|
98
|
+
async function askKeySource(
|
|
99
|
+
ask : Ask,
|
|
100
|
+
ctx : WizardContext,
|
|
101
|
+
question : string,
|
|
102
|
+
defaultRef : string | undefined,
|
|
103
|
+
): Promise<{ key: string } | { publicKey: string }> {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const raw = (await ask(`${question}${defaultRef ? ` [${defaultRef}]` : ''}: `)).trim();
|
|
106
|
+
const answer = raw.length > 0 ? raw : defaultRef;
|
|
107
|
+
if (answer === undefined) {
|
|
108
|
+
ctx.say('A key is required.');
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (PUBLIC_KEY_HEX.test(answer)) return { publicKey: answer.toLowerCase() };
|
|
112
|
+
try {
|
|
113
|
+
ctx.resolveKey(answer);
|
|
114
|
+
return { key: answer };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
ctx.say((error as Error).message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Asks for the relationships of a method. Blank means all four (the api default). */
|
|
122
|
+
async function askRelationships(ask: Ask, ctx: WizardContext, n: number): Promise<string[] | undefined> {
|
|
123
|
+
const names = VERIFICATION_RELATIONSHIPS.join(', ');
|
|
124
|
+
for (;;) {
|
|
125
|
+
const raw = (await ask(`Relationships of method ${n} (comma-separated: ${names}) [all]: `)).trim();
|
|
126
|
+
if (raw.length === 0) return undefined;
|
|
127
|
+
const chosen = raw.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
128
|
+
const unknown = chosen.filter(name => !(VERIFICATION_RELATIONSHIPS as readonly string[]).includes(name));
|
|
129
|
+
if (unknown.length === 0) return chosen;
|
|
130
|
+
ctx.say(`Unknown relationship: ${unknown.join(', ')}. Expected one of ${names}.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Asks for one value of a list, with a default. */
|
|
135
|
+
async function askChoice<T extends string>(
|
|
136
|
+
ask : Ask,
|
|
137
|
+
ctx : WizardContext,
|
|
138
|
+
question : string,
|
|
139
|
+
choices : readonly T[],
|
|
140
|
+
defaultValue : T,
|
|
141
|
+
): Promise<T> {
|
|
142
|
+
for (;;) {
|
|
143
|
+
const raw = (await ask(`${question} (${choices.join(', ')}) [${defaultValue}]: `)).trim();
|
|
144
|
+
if (raw.length === 0) return defaultValue;
|
|
145
|
+
if ((choices as readonly string[]).includes(raw)) return raw as T;
|
|
146
|
+
ctx.say(`Unknown value "${raw}". Expected one of ${choices.join(', ')}.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Asks for a non-empty value. */
|
|
151
|
+
async function askRequired(ask: Ask, ctx: WizardContext, question: string): Promise<string> {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const raw = (await ask(`${question}: `)).trim();
|
|
154
|
+
if (raw.length > 0) return raw;
|
|
155
|
+
ctx.say('A value is required.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Asks a yes/no question. Blank means no. */
|
|
160
|
+
async function askYesNo(ask: Ask, question: string): Promise<boolean> {
|
|
161
|
+
const raw = (await ask(`${question} [y/N]: `)).trim();
|
|
162
|
+
return /^y(es)?$/i.test(raw);
|
|
163
|
+
}
|
package/src/hints.ts
CHANGED
|
@@ -20,8 +20,6 @@ import type { GlobalOptions, NetworkOption } from './types.js';
|
|
|
20
20
|
*/
|
|
21
21
|
export function printCreateFundingHint(g: GlobalOptions, network: NetworkOption, did: string): void {
|
|
22
22
|
if (g.quiet || g.output === 'json') return;
|
|
23
|
-
const faucet = faucetUrl(network);
|
|
24
|
-
if (!faucet) return;
|
|
25
23
|
let beaconAddress: string;
|
|
26
24
|
try {
|
|
27
25
|
const { serviceEndpoint } = BeaconUtils.createBeaconService(did, 'p2wpkh', 'SingletonBeacon');
|
|
@@ -29,6 +27,19 @@ export function printCreateFundingHint(g: GlobalOptions, network: NetworkOption,
|
|
|
29
27
|
} catch {
|
|
30
28
|
return;
|
|
31
29
|
}
|
|
30
|
+
printBeaconFundingHint(g, network, beaconAddress);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Prints a funding hint for a beacon address on a network with a public
|
|
35
|
+
* faucet: the address next to the faucet and explorer links. A no-op on a
|
|
36
|
+
* network without a faucet (regtest/mainnet). `create -t x --document` and
|
|
37
|
+
* `genesis build` use it for the first beacon of the genesis document.
|
|
38
|
+
*/
|
|
39
|
+
export function printBeaconFundingHint(g: GlobalOptions, network: NetworkOption, beaconAddress: string): void {
|
|
40
|
+
if (g.quiet || g.output === 'json') return;
|
|
41
|
+
const faucet = faucetUrl(network);
|
|
42
|
+
if (!faucet) return;
|
|
32
43
|
const explorer = explorerAddressUrl(network, beaconAddress);
|
|
33
44
|
const lines = [
|
|
34
45
|
'Fund the initial beacon to anchor updates:',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ConnectionOverrides } from './config.js';
|
|
2
|
+
import { profileNetworkMismatch, resolveDefaultNetwork } from './config.js';
|
|
3
|
+
import { CLIError } from './error.js';
|
|
4
|
+
import type { GlobalOptions, NetworkOption } from './types.js';
|
|
5
|
+
import { SUPPORTED_NETWORKS } from './types.js';
|
|
6
|
+
|
|
7
|
+
/** The help text of `-n, --network` on the offline creation commands. */
|
|
8
|
+
export const NETWORK_OPTION_HELP =
|
|
9
|
+
'Identifier bitcoin network <bitcoin|testnet3|testnet4|signet|mutinynet|regtest> '
|
|
10
|
+
+ '(default: config defaults.network, else regtest)';
|
|
11
|
+
|
|
12
|
+
/** Builds the keystore- and config-resolution overrides from the global flags. */
|
|
13
|
+
export function overridesFromGlobals(g: GlobalOptions): ConnectionOverrides {
|
|
14
|
+
return {
|
|
15
|
+
home : g.home,
|
|
16
|
+
config : g.config,
|
|
17
|
+
profile : g.profile,
|
|
18
|
+
keystore : g.keystore,
|
|
19
|
+
passphraseFile : g.passphraseFile,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Validates an explicit `--network`, or resolves the default from configuration. */
|
|
24
|
+
export function resolveNetworkOption(explicit: string | undefined, overrides: ConnectionOverrides): NetworkOption {
|
|
25
|
+
if (!explicit) return resolveDefaultNetwork(overrides);
|
|
26
|
+
if (!SUPPORTED_NETWORKS.includes(explicit as NetworkOption)) {
|
|
27
|
+
throw new CLIError(
|
|
28
|
+
'Invalid network. Must be one of "bitcoin", "testnet3", "testnet4", "signet", "mutinynet", or "regtest".',
|
|
29
|
+
'INVALID_ARGUMENT_ERROR',
|
|
30
|
+
{ network: explicit },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return explicit as NetworkOption;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Warns on stderr, and never blocks, when the network of a new identifier
|
|
38
|
+
* disagrees with the network that the active profile declares. A `production`
|
|
39
|
+
* profile that holds mainnet endpoints must not mint a regtest DID in silence.
|
|
40
|
+
*/
|
|
41
|
+
export function warnProfileNetworkMismatch(g: GlobalOptions, network: NetworkOption, overrides: ConnectionOverrides): void {
|
|
42
|
+
const mismatch = profileNetworkMismatch(network, overrides);
|
|
43
|
+
if (mismatch && !g.quiet) {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`Warning: creating a "${network}" identifier while the active profile `
|
|
46
|
+
+ `"${mismatch.profile}" declares network "${mismatch.declared}". The `
|
|
47
|
+
+ 'identifier\'s network and the profile\'s endpoints may not match.\n'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|