@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.
Files changed (75) hide show
  1. package/README.md +29 -2
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cjs/index.js +494 -64
  4. package/dist/esm/src/cli.js +4 -2
  5. package/dist/esm/src/cli.js.map +1 -1
  6. package/dist/esm/src/commands/completion.js +1 -1
  7. package/dist/esm/src/commands/completion.js.map +1 -1
  8. package/dist/esm/src/commands/create.js +33 -41
  9. package/dist/esm/src/commands/create.js.map +1 -1
  10. package/dist/esm/src/commands/genesis.js +119 -0
  11. package/dist/esm/src/commands/genesis.js.map +1 -0
  12. package/dist/esm/src/commands/identifier.js +96 -0
  13. package/dist/esm/src/commands/identifier.js.map +1 -0
  14. package/dist/esm/src/commands/index.js +2 -0
  15. package/dist/esm/src/commands/index.js.map +1 -1
  16. package/dist/esm/src/commands/resolve.js +15 -1
  17. package/dist/esm/src/commands/resolve.js.map +1 -1
  18. package/dist/esm/src/commands/write.js +10 -7
  19. package/dist/esm/src/commands/write.js.map +1 -1
  20. package/dist/esm/src/genesis-document-file.js +26 -0
  21. package/dist/esm/src/genesis-document-file.js.map +1 -0
  22. package/dist/esm/src/genesis-spec.js +93 -0
  23. package/dist/esm/src/genesis-spec.js.map +1 -0
  24. package/dist/esm/src/genesis-wizard.js +132 -0
  25. package/dist/esm/src/genesis-wizard.js.map +1 -0
  26. package/dist/esm/src/hints.js +14 -3
  27. package/dist/esm/src/hints.js.map +1 -1
  28. package/dist/esm/src/network-option.js +39 -0
  29. package/dist/esm/src/network-option.js.map +1 -0
  30. package/dist/esm/src/resolution-options.js +19 -2
  31. package/dist/esm/src/resolution-options.js.map +1 -1
  32. package/dist/types/src/cli.d.ts +1 -1
  33. package/dist/types/src/cli.d.ts.map +1 -1
  34. package/dist/types/src/commands/create.d.ts +5 -4
  35. package/dist/types/src/commands/create.d.ts.map +1 -1
  36. package/dist/types/src/commands/genesis.d.ts +20 -0
  37. package/dist/types/src/commands/genesis.d.ts.map +1 -0
  38. package/dist/types/src/commands/identifier.d.ts +12 -0
  39. package/dist/types/src/commands/identifier.d.ts.map +1 -0
  40. package/dist/types/src/commands/index.d.ts +2 -0
  41. package/dist/types/src/commands/index.d.ts.map +1 -1
  42. package/dist/types/src/commands/resolve.d.ts +7 -0
  43. package/dist/types/src/commands/resolve.d.ts.map +1 -1
  44. package/dist/types/src/commands/write.d.ts +2 -1
  45. package/dist/types/src/commands/write.d.ts.map +1 -1
  46. package/dist/types/src/genesis-document-file.d.ts +11 -0
  47. package/dist/types/src/genesis-document-file.d.ts.map +1 -0
  48. package/dist/types/src/genesis-spec.d.ts +70 -0
  49. package/dist/types/src/genesis-spec.d.ts.map +1 -0
  50. package/dist/types/src/genesis-wizard.d.ts +30 -0
  51. package/dist/types/src/genesis-wizard.d.ts.map +1 -0
  52. package/dist/types/src/hints.d.ts +7 -0
  53. package/dist/types/src/hints.d.ts.map +1 -1
  54. package/dist/types/src/network-option.d.ts +15 -0
  55. package/dist/types/src/network-option.d.ts.map +1 -0
  56. package/dist/types/src/resolution-options.d.ts +6 -2
  57. package/dist/types/src/resolution-options.d.ts.map +1 -1
  58. package/dist/types/src/types.d.ts +38 -1
  59. package/dist/types/src/types.d.ts.map +1 -1
  60. package/package.json +3 -3
  61. package/src/cli.ts +5 -1
  62. package/src/commands/completion.ts +1 -1
  63. package/src/commands/create.ts +39 -55
  64. package/src/commands/genesis.ts +149 -0
  65. package/src/commands/identifier.ts +134 -0
  66. package/src/commands/index.ts +2 -0
  67. package/src/commands/resolve.ts +20 -1
  68. package/src/commands/write.ts +10 -7
  69. package/src/genesis-document-file.ts +35 -0
  70. package/src/genesis-spec.ts +149 -0
  71. package/src/genesis-wizard.ts +163 -0
  72. package/src/hints.ts +13 -2
  73. package/src/network-option.ts +50 -0
  74. package/src/resolution-options.ts +21 -2
  75. 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
+ }
@@ -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';
@@ -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
+ }
@@ -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. A mainnet write is refused with an unencrypted dev keystore (ADR 080).
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
- const hasResolutionFlags = options.resolutionOptions !== undefined
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, and --min-conf apply only when '
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
+ }