@1sat/cli 0.0.120 → 0.0.121

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,107 @@
1
+ /**
2
+ * The CLI acting as the wallet itself.
3
+ *
4
+ * `LocalWalletPermissionsManager` lets its admin originator bypass every
5
+ * permission check, so `CLI_ADMIN_ORIGINATOR` must only ever be used by
6
+ * in-process code. The wallet-api router rejects any HTTP request whose
7
+ * origin normalizes to it, so an app cannot claim it. It is a
8
+ * hostname-shaped string that is stable under the manager's originator
9
+ * normalization (lowercase, no scheme, no port).
10
+ *
11
+ * The CLI's own commands go through a manager too, rather than the raw
12
+ * toolbox wallet. That is what keeps their output readable now that
13
+ * transaction metadata is encrypted at rest: the manager decrypts
14
+ * descriptions and custom instructions on the way back
15
+ * (`decryptListActionsMetadata` / `maybeDecryptMetadata`, which return
16
+ * values that were never encrypted unchanged), while the admin originator
17
+ * skips every grant check.
18
+ */
19
+
20
+ import { PERMISSION_SCHEME_IDS } from '@1sat/types'
21
+ import {
22
+ type IPermissionStore,
23
+ LocalWalletPermissionsManager,
24
+ } from '@1sat/wallet'
25
+ import type { WalletInterface } from '@bsv/sdk'
26
+
27
+ export const CLI_ADMIN_ORIGINATOR = '1sat-cli.internal'
28
+
29
+ /**
30
+ * Pass-through permission modules for every scheme the actions package can
31
+ * label with.
32
+ *
33
+ * `buildInputAssetLabel` puts `p <scheme> input id <id>` on any action that
34
+ * spends a basket asset, unconditionally — there is no opt-out on the action.
35
+ * The manager routes a `p ` label to `config.permissionModules[scheme]` and
36
+ * throws `Unsupported P-module scheme` when none is registered, so the CLI's
37
+ * own commands would fail on a label they have always emitted.
38
+ *
39
+ * A real module exists to describe an intent and apply it once a person
40
+ * approves. The admin originator bypasses every check and has no prompt
41
+ * surface, so there is nothing for it to do: passing the request and response
42
+ * through unchanged is what the raw toolbox wallet did before the CLI gained
43
+ * a manager.
44
+ */
45
+ const adminPermissionModules = Object.fromEntries(
46
+ PERMISSION_SCHEME_IDS.map((scheme) => [
47
+ scheme,
48
+ {
49
+ onRequest: async (req: { args: object }) => ({ args: req.args }),
50
+ onResponse: async (res: unknown) => res,
51
+ },
52
+ ]),
53
+ )
54
+
55
+ /**
56
+ * Wrap the toolbox wallet in a permissions manager the CLI calls as the
57
+ * admin originator.
58
+ *
59
+ * Every method is invoked with `CLI_ADMIN_ORIGINATOR` as the originator —
60
+ * the manager requires one for its checks and would otherwise throw
61
+ * "Originator is required for permission checks" on calls the CLI's own
62
+ * actions make without one.
63
+ *
64
+ * Metadata encryption is left at the toolbox default (on), the same as
65
+ * the served endpoint: the CLI writes encrypted descriptions and custom
66
+ * instructions and reads them back decrypted. Nothing downstream loses by
67
+ * that. `1sat serve wallet` is the wallet-toolbox *storage* interface, not
68
+ * a BRC-100 API — it moves opaque records and never interprets a
69
+ * description — and the wallet at the far end (wallet-desktop, the
70
+ * browser wallet, a remote CLI) runs its own permissions manager, which
71
+ * decrypts. Records written before this change stay readable: decryption
72
+ * is attempted unconditionally and `maybeDecryptMetadata` returns the
73
+ * original string when it fails.
74
+ */
75
+ export function adminWallet(
76
+ wallet: WalletInterface,
77
+ store: IPermissionStore,
78
+ ): WalletInterface {
79
+ const manager = new LocalWalletPermissionsManager(
80
+ wallet,
81
+ CLI_ADMIN_ORIGINATOR,
82
+ { permissionModules: adminPermissionModules },
83
+ { store },
84
+ )
85
+ return withAdminOriginator(manager)
86
+ }
87
+
88
+ /**
89
+ * A `WalletInterface` view of `manager` that passes the admin originator on
90
+ * every call. BRC-100 methods all take `(args, originator?)`, so one proxy
91
+ * covers the surface without a per-method wrapper that would go stale as
92
+ * the interface grows.
93
+ */
94
+ export function withAdminOriginator(manager: WalletInterface): WalletInterface {
95
+ return new Proxy(manager, {
96
+ get(target, property, receiver) {
97
+ const value = Reflect.get(target, property, receiver)
98
+ if (typeof value !== 'function') return value
99
+ return (args: unknown) =>
100
+ (value as (a: unknown, o: string) => unknown).call(
101
+ target,
102
+ args,
103
+ CLI_ADMIN_ORIGINATOR,
104
+ )
105
+ },
106
+ })
107
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The wallet-api's answer to a permission it has not been granted.
3
+ *
4
+ * `1sat serve wallet-api` exists for agents and automation; a person who
5
+ * wants to be asked runs a graphical wallet. So there is no prompt and no
6
+ * auto-approve: anything the permission store does not already hold is
7
+ * denied on the spot. The agent driving the app never sees this process's
8
+ * terminal — it sees the app's error output — so the denial itself has to
9
+ * carry the fix, which is the exact `1sat permissions grant …` command for
10
+ * the permission that was refused.
11
+ *
12
+ * `WalletPermissionsManager.denyPermission` rejects the app's call with a
13
+ * fixed `Permission denied.`, so the command is recorded as the request is
14
+ * denied and swapped in at the HTTP boundary by `withDenialMessage`. The
15
+ * recording is scoped to the in-flight call with `AsyncLocalStorage`: the
16
+ * manager fires the permission event inside the same awaited chain as the
17
+ * call that needs it, so the handler and the boundary share a context even
18
+ * when several apps are being served at once.
19
+ */
20
+
21
+ import { AsyncLocalStorage } from 'node:async_hooks'
22
+ import {
23
+ type PermissionKey,
24
+ normalizeOriginator,
25
+ permissionKeyFromRequest,
26
+ permissionKeysFromGroup,
27
+ } from '@1sat/wallet'
28
+ import type {
29
+ CounterpartyPermissionEventHandler,
30
+ GroupedPermissionEventHandler,
31
+ GroupedPermissionRequest,
32
+ PermissionEventHandler,
33
+ PermissionRequest,
34
+ WalletPermissionsManagerCallbacks,
35
+ } from '@bsv/wallet-toolbox'
36
+ import { GRANT_COMMAND, type GrantSpec, grantCommand } from './grants.js'
37
+
38
+ /** The subset of `WalletPermissionsManager` the denial handlers drive. */
39
+ export interface PermissionDenyTarget {
40
+ bindCallback(
41
+ eventName: keyof WalletPermissionsManagerCallbacks,
42
+ handler:
43
+ | PermissionEventHandler
44
+ | GroupedPermissionEventHandler
45
+ | CounterpartyPermissionEventHandler,
46
+ ): number
47
+ denyPermission(requestID: string): Promise<void>
48
+ denyGroupedPermission(requestID: string): Promise<void>
49
+ }
50
+
51
+ export interface DenyOptions {
52
+ /**
53
+ * Command the message tells the caller to run, minus the origin and
54
+ * selectors — carries global flags where they matter, e.g.
55
+ * `1sat --chain test permissions grant`.
56
+ */
57
+ grantCommandPrefix?: string
58
+ /** Request log sink. Defaults to `console.log`. */
59
+ log?: (line: string) => void
60
+ }
61
+
62
+ /** Messages recorded while one app-facing call is in flight. */
63
+ const inFlight = new AsyncLocalStorage<string[]>()
64
+
65
+ /** The manager's own code for a refusal, on the error it rejects with. */
66
+ const DENIED_CODE = 'ERR_PERMISSION_DENIED'
67
+
68
+ /**
69
+ * Bind every permission event to an immediate denial.
70
+ *
71
+ * Same set the removed terminal prompts bound: protocol, basket,
72
+ * certificate, spending authorization and the BRC-73 grouped request.
73
+ */
74
+ export function bindPermissionDenials(
75
+ manager: PermissionDenyTarget,
76
+ options: DenyOptions = {},
77
+ ): void {
78
+ const prefix = options.grantCommandPrefix ?? GRANT_COMMAND
79
+ const log = options.log ?? ((line: string) => console.log(line))
80
+
81
+ const record = (origin: string, commands: string[]): void => {
82
+ const message = denialMessage(origin, commands)
83
+ inFlight.getStore()?.push(message)
84
+ log(`[wallet-api] ${message}`)
85
+ }
86
+
87
+ const single: PermissionEventHandler = async (request) => {
88
+ const origin = displayOrigin(request)
89
+ try {
90
+ record(origin, [grantCommand(specFromRequest(request), prefix)])
91
+ } catch (err) {
92
+ // An unreadable request still has to be denied; the app gets the
93
+ // manager's plain message rather than a hung call.
94
+ log(`[wallet-api] could not describe request: ${(err as Error).message}`)
95
+ }
96
+ await safely(log, 'deny', () => manager.denyPermission(request.requestID))
97
+ }
98
+
99
+ const grouped: GroupedPermissionEventHandler = async (request) => {
100
+ const origin = normalizeOriginator(request.originator)
101
+ try {
102
+ record(
103
+ origin,
104
+ specsFromGroupedRequest(request).map((spec) =>
105
+ grantCommand(spec, prefix),
106
+ ),
107
+ )
108
+ } catch (err) {
109
+ log(`[wallet-api] could not describe request: ${(err as Error).message}`)
110
+ }
111
+ await safely(log, 'deny', () =>
112
+ manager.denyGroupedPermission(request.requestID),
113
+ )
114
+ }
115
+
116
+ manager.bindCallback('onProtocolPermissionRequested', single)
117
+ manager.bindCallback('onBasketAccessRequested', single)
118
+ manager.bindCallback('onCertificateAccessRequested', single)
119
+ manager.bindCallback('onSpendingAuthorizationRequested', single)
120
+ manager.bindCallback('onGroupedPermissionRequested', grouped)
121
+ }
122
+
123
+ /**
124
+ * Run one app-facing wallet call, replacing the manager's bare
125
+ * `Permission denied.` with the command that would allow it.
126
+ *
127
+ * Errors that are not denials pass through untouched, as does a denial the
128
+ * handlers could not describe.
129
+ */
130
+ export async function withDenialMessage<T>(fn: () => Promise<T>): Promise<T> {
131
+ const messages: string[] = []
132
+ try {
133
+ return await inFlight.run(messages, fn)
134
+ } catch (err) {
135
+ const code = (err as { code?: unknown })?.code
136
+ if (code !== DENIED_CODE || messages.length === 0) throw err
137
+ const denied = new Error(messages.join(' ')) as Error & { code?: string }
138
+ denied.code = DENIED_CODE
139
+ throw denied
140
+ }
141
+ }
142
+
143
+ /** `permission denied for <origin>: run \`…\` and retry` */
144
+ export function denialMessage(origin: string, commands: string[]): string {
145
+ const quoted = commands.map((c) => `\`${c}\``)
146
+ const list =
147
+ quoted.length > 1
148
+ ? `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`
149
+ : quoted[0]
150
+ return `permission denied for ${origin}: run ${list} and retry`
151
+ }
152
+
153
+ /** The grant a single permission request is asking for. */
154
+ export function specFromRequest(
155
+ request: PermissionRequest & { requestID: string },
156
+ ): GrantSpec {
157
+ const key = permissionKeyFromRequest(
158
+ request as unknown as Parameters<typeof permissionKeyFromRequest>[0],
159
+ )
160
+ return key.type === 'spending'
161
+ ? { key, authorizedAmount: request.spending?.satoshis ?? 0 }
162
+ : { key }
163
+ }
164
+
165
+ /** Every grant a BRC-73 grouped request is asking for. */
166
+ export function specsFromGroupedRequest(
167
+ request: GroupedPermissionRequest,
168
+ ): GrantSpec[] {
169
+ const amount = request.permissions.spendingAuthorization?.amount ?? 0
170
+ return permissionKeysFromGroup(
171
+ request.originator,
172
+ request.permissions as unknown as Parameters<
173
+ typeof permissionKeysFromGroup
174
+ >[1],
175
+ ).map((key: PermissionKey) =>
176
+ key.type === 'spending' ? { key, authorizedAmount: amount } : { key },
177
+ )
178
+ }
179
+
180
+ function displayOrigin(request: PermissionRequest): string {
181
+ return normalizeOriginator(request.originator)
182
+ }
183
+
184
+ async function safely(
185
+ log: (line: string) => void,
186
+ what: string,
187
+ fn: () => Promise<void>,
188
+ ): Promise<void> {
189
+ try {
190
+ await fn()
191
+ } catch (err) {
192
+ log(`[wallet-api] ${what} failed: ${(err as Error).message}`)
193
+ }
194
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The app-facing BRC-100 endpoint `1sat serve wallet-api` runs.
3
+ *
4
+ * The served wallet is a `LocalWalletPermissionsManager` around the CLI's
5
+ * toolbox wallet: each app origin gets only what has been granted to it in
6
+ * the file store, and anything else is denied immediately — there is no
7
+ * prompt and no auto-approve. The denial names the `1sat permissions grant
8
+ * …` command that would allow the call, so the agent driving the app can
9
+ * read the fix out of the app's own error output. The manager's admin
10
+ * originator is reserved for the process itself; the router rejects it on
11
+ * the wire.
12
+ */
13
+
14
+ import { LocalWalletPermissionsManager } from '@1sat/wallet'
15
+ import {
16
+ type BRC100ServerHandle,
17
+ type BRC100WalletHandle,
18
+ startBRC100Server,
19
+ } from '@1sat/wallet-server'
20
+ import type { WalletInterface } from '@bsv/sdk'
21
+ import type { PermissionsManagerConfig } from '@bsv/wallet-toolbox'
22
+ import { CLI_ADMIN_ORIGINATOR } from './admin.js'
23
+ import { bindPermissionDenials, withDenialMessage } from './deny.js'
24
+ import { FilePermissionStore } from './permission-store.js'
25
+
26
+ export interface WalletApiOptions {
27
+ /** The underlying (admin-surface) wallet; never served directly. */
28
+ wallet: WalletInterface
29
+ /** Path of the JSON grant store (see `permissionStorePath`). */
30
+ storePath: string
31
+ host: string
32
+ port: number
33
+ /**
34
+ * Command a denial tells the caller to run, minus origin and selectors.
35
+ * Defaults to `1sat permissions grant`; `serve` adds `--chain test`
36
+ * where it matters.
37
+ */
38
+ grantCommandPrefix?: string
39
+ /** Extra permissions-manager configuration (tests disable manifest lookups). */
40
+ managerConfig?: PermissionsManagerConfig
41
+ /** Request log sink. Defaults to `console.log`. */
42
+ log?: (line: string) => void
43
+ }
44
+
45
+ export interface WalletApiHandle {
46
+ server: BRC100ServerHandle
47
+ close(): Promise<void>
48
+ }
49
+
50
+ /**
51
+ * Expose a `WalletInterface` as a BRC100WalletHandle. The wallet handed in
52
+ * is the permissions manager, so every call is checked against the
53
+ * originator's grants before it reaches the toolbox wallet. A refusal
54
+ * leaves the manager as `Permission denied.`; `withDenialMessage` replaces
55
+ * it with the grant command recorded while the call was in flight.
56
+ */
57
+ export function walletApiHandle(wallet: WalletInterface): BRC100WalletHandle {
58
+ return {
59
+ async call(method, args, origin) {
60
+ const fn = (wallet as unknown as Record<string, unknown>)[method] as (
61
+ a: unknown,
62
+ o: string,
63
+ ) => Promise<unknown>
64
+ if (typeof fn !== 'function') {
65
+ throw new Error(`Method not available: ${method}`)
66
+ }
67
+ return withDenialMessage(() => fn.call(wallet, args, origin))
68
+ },
69
+ }
70
+ }
71
+
72
+ /** Wrap the wallet in a permissions manager and serve it over HTTP. */
73
+ export async function startWalletApi(
74
+ options: WalletApiOptions,
75
+ ): Promise<WalletApiHandle> {
76
+ const log = options.log ?? ((line: string) => console.log(line))
77
+ const manager = new LocalWalletPermissionsManager(
78
+ options.wallet,
79
+ CLI_ADMIN_ORIGINATOR,
80
+ // Metadata encryption stays at the toolbox default (on): descriptions
81
+ // an app writes through this endpoint are encrypted at rest. The CLI's
82
+ // own commands read through a manager too (see `adminWallet`), which
83
+ // decrypts them again.
84
+ { ...options.managerConfig },
85
+ { store: new FilePermissionStore(options.storePath) },
86
+ )
87
+ bindPermissionDenials(manager, {
88
+ grantCommandPrefix: options.grantCommandPrefix,
89
+ log,
90
+ })
91
+
92
+ const server = await startBRC100Server({
93
+ host: options.host,
94
+ port: options.port,
95
+ adminOriginator: CLI_ADMIN_ORIGINATOR,
96
+ wallet: walletApiHandle(manager),
97
+ onEvent: (event) => {
98
+ if (event.event !== 'brc100_call') return
99
+ const line = `[wallet-api] ${event.method} from ${event.origin || '(no origin)'} -> ${event.status}`
100
+ log(event.error ? `${line}: ${event.error}` : line)
101
+ },
102
+ })
103
+
104
+ return {
105
+ server,
106
+ close: () => server.close(),
107
+ }
108
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Permission grants as CLI arguments.
3
+ *
4
+ * One place translates between the three shapes a grant takes:
5
+ *
6
+ * - the `PermissionKey` the store is indexed by,
7
+ * - the `1sat permissions grant …` command line a person or an agent types,
8
+ * - the one-line description `1sat permissions list` prints.
9
+ *
10
+ * The wallet-api denies every permission it has not been granted and puts
11
+ * the command that would allow it into the error, so the app driving the
12
+ * wallet can print it verbatim. That command is rendered here from the same
13
+ * `PermissionKey` the store would be asked for, which is what keeps the
14
+ * suggestion and the lookup from drifting apart.
15
+ */
16
+
17
+ import {
18
+ type PermissionKey,
19
+ normalizeOriginator,
20
+ permissionKeyToString,
21
+ } from '@1sat/wallet'
22
+
23
+ /** Protocols named `action label <x>` are how the manager gates action labels. */
24
+ const LABEL_PROTOCOL_PREFIX = 'action label '
25
+
26
+ /** The command word the denial message and help text point at. */
27
+ export const GRANT_COMMAND = '1sat permissions grant'
28
+
29
+ /** A grant as the `permissions` command's flags describe it. */
30
+ export interface GrantSpec {
31
+ key: PermissionKey
32
+ /** DSAP only — the monthly cap in satoshis. */
33
+ authorizedAmount?: number
34
+ }
35
+
36
+ /** Quote a flag value for a shell only when it needs it. */
37
+ export function shellQuote(value: string): string {
38
+ if (value !== '' && /^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value
39
+ return `"${value.replace(/(["\\$`])/g, '\\$1')}"`
40
+ }
41
+
42
+ /**
43
+ * Render the `1sat permissions grant …` command that would allow `key`.
44
+ *
45
+ * `prefix` carries any global flags the caller needs (`1sat --chain test
46
+ * permissions grant`), so the printed command works as-is on that chain.
47
+ */
48
+ export function grantCommand(
49
+ spec: GrantSpec,
50
+ prefix: string = GRANT_COMMAND,
51
+ ): string {
52
+ const key = spec.key
53
+ const parts = [prefix, shellQuote(key.originator)]
54
+ switch (key.type) {
55
+ case 'protocol': {
56
+ if (
57
+ key.protocolLevel === 1 &&
58
+ key.protocolName.startsWith(LABEL_PROTOCOL_PREFIX)
59
+ ) {
60
+ parts.push(
61
+ '--label',
62
+ shellQuote(key.protocolName.slice(LABEL_PROTOCOL_PREFIX.length)),
63
+ )
64
+ break
65
+ }
66
+ parts.push('--protocol', shellQuote(key.protocolName))
67
+ parts.push('--level', String(key.protocolLevel))
68
+ // Level 1 protocols are counterparty-less by definition (the key
69
+ // always holds ''), so naming one would only mislead.
70
+ if (key.protocolLevel !== 1 && key.counterparty) {
71
+ parts.push('--counterparty', shellQuote(key.counterparty))
72
+ }
73
+ if (key.privileged) parts.push('--privileged')
74
+ break
75
+ }
76
+ case 'basket':
77
+ parts.push('--basket', shellQuote(key.basket))
78
+ break
79
+ case 'certificate':
80
+ parts.push('--certificate', shellQuote(key.certType))
81
+ parts.push('--fields', shellQuote(key.fields.join(',')))
82
+ if (key.verifier) {
83
+ parts.push('--counterparty', shellQuote(key.verifier))
84
+ }
85
+ if (key.privileged) parts.push('--privileged')
86
+ break
87
+ case 'spending':
88
+ parts.push('--spending', String(spec.authorizedAmount ?? 0))
89
+ break
90
+ }
91
+ return parts.join(' ')
92
+ }
93
+
94
+ /** One-line description of a grant, for `1sat permissions list`. */
95
+ export function describeKey(spec: GrantSpec): string {
96
+ const key = spec.key
97
+ switch (key.type) {
98
+ case 'protocol': {
99
+ if (
100
+ key.protocolLevel === 1 &&
101
+ key.protocolName.startsWith(LABEL_PROTOCOL_PREFIX)
102
+ ) {
103
+ return `label ${key.protocolName.slice(LABEL_PROTOCOL_PREFIX.length)}`
104
+ }
105
+ const bits = [`"${key.protocolName}" (level ${key.protocolLevel}`]
106
+ if (key.protocolLevel !== 1 && key.counterparty) {
107
+ bits.push(`, counterparty ${key.counterparty}`)
108
+ }
109
+ if (key.privileged) bits.push(', privileged')
110
+ return `protocol ${bits.join('')})`
111
+ }
112
+ case 'basket':
113
+ return `basket "${key.basket}"`
114
+ case 'certificate':
115
+ return `cert "${key.certType}" fields [${key.fields.join(', ')}]${
116
+ key.verifier ? ` to ${key.verifier}` : ''
117
+ }${key.privileged ? ' (privileged)' : ''}`
118
+ case 'spending':
119
+ return `spending up to ${spec.authorizedAmount ?? 0} sat per month`
120
+ }
121
+ }
122
+
123
+ /** Flags `1sat permissions grant` / `revoke` accept, already parsed. */
124
+ export interface GrantFlags {
125
+ protocol?: string
126
+ level?: string
127
+ counterparty?: string
128
+ privileged: boolean
129
+ basket?: string
130
+ label?: string
131
+ certificate?: string
132
+ fields?: string
133
+ spending?: string
134
+ }
135
+
136
+ /**
137
+ * Turn parsed flags into the grants they name.
138
+ *
139
+ * Several selectors in one invocation produce several grants, so an app's
140
+ * whole set can be granted in one line. Throws with a usable message when a
141
+ * selector is incomplete; the caller reports it.
142
+ */
143
+ export function specsFromFlags(origin: string, flags: GrantFlags): GrantSpec[] {
144
+ const originator = normalizeOriginator(origin)
145
+ if (!originator) throw new Error('An app origin is required')
146
+ const specs: GrantSpec[] = []
147
+
148
+ if (flags.protocol !== undefined || flags.label !== undefined) {
149
+ if (flags.protocol !== undefined && flags.label !== undefined) {
150
+ throw new Error('Use either --protocol or --label, not both')
151
+ }
152
+ const isLabel = flags.label !== undefined
153
+ const name = isLabel
154
+ ? `${LABEL_PROTOCOL_PREFIX}${flags.label}`
155
+ : (flags.protocol as string)
156
+ if (!name.trim() || name === LABEL_PROTOCOL_PREFIX) {
157
+ throw new Error(
158
+ isLabel ? '--label requires a name' : '--protocol requires a name',
159
+ )
160
+ }
161
+ // A label is always a level-1 protocol upstream; --level would only
162
+ // let a caller write a key the manager never looks up.
163
+ const level = isLabel ? 1 : parseLevel(flags.level)
164
+ specs.push({
165
+ key: {
166
+ type: 'protocol',
167
+ originator,
168
+ privileged: flags.privileged,
169
+ protocolLevel: level,
170
+ protocolName: name,
171
+ counterparty: level === 1 ? '' : (flags.counterparty ?? 'self'),
172
+ },
173
+ })
174
+ }
175
+
176
+ if (flags.basket !== undefined) {
177
+ if (!flags.basket.trim()) throw new Error('--basket requires a name')
178
+ specs.push({
179
+ key: { type: 'basket', originator, basket: flags.basket },
180
+ })
181
+ }
182
+
183
+ if (flags.certificate !== undefined) {
184
+ if (!flags.certificate.trim()) {
185
+ throw new Error('--certificate requires a certificate type')
186
+ }
187
+ const fields = (flags.fields ?? '')
188
+ .split(',')
189
+ .map((f) => f.trim())
190
+ .filter((f) => f.length > 0)
191
+ if (fields.length === 0) {
192
+ throw new Error('--certificate requires --fields <a,b>')
193
+ }
194
+ specs.push({
195
+ key: {
196
+ type: 'certificate',
197
+ originator,
198
+ privileged: flags.privileged,
199
+ verifier: flags.counterparty ?? '',
200
+ certType: flags.certificate,
201
+ fields: fields.sort(),
202
+ },
203
+ })
204
+ }
205
+
206
+ if (flags.spending !== undefined) {
207
+ const satoshis = Number(flags.spending)
208
+ if (!Number.isInteger(satoshis) || satoshis <= 0) {
209
+ throw new Error('--spending requires a positive whole number of satoshis')
210
+ }
211
+ specs.push({
212
+ key: { type: 'spending', originator },
213
+ authorizedAmount: satoshis,
214
+ })
215
+ }
216
+
217
+ if (specs.length === 0) {
218
+ throw new Error(
219
+ 'Nothing selected. Pass --protocol/--label, --basket, --certificate or --spending',
220
+ )
221
+ }
222
+ return specs
223
+ }
224
+
225
+ function parseLevel(value: string | undefined): 0 | 1 | 2 {
226
+ if (value === undefined) {
227
+ throw new Error('--protocol requires --level <0|1|2>')
228
+ }
229
+ if (value !== '0' && value !== '1' && value !== '2') {
230
+ throw new Error(`--level must be 0, 1 or 2 (got ${value})`)
231
+ }
232
+ return Number(value) as 0 | 1 | 2
233
+ }
234
+
235
+ /** Canonical id of a grant; two selectors naming the same grant share it. */
236
+ export function specId(spec: GrantSpec): string {
237
+ return permissionKeyToString(spec.key)
238
+ }