@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.
- package/dist/cli.js +689 -61
- package/package.json +5 -4
- package/src/commands/permissions.ts +269 -0
- package/src/commands/serve.ts +25 -62
- package/src/config.ts +5 -4
- package/src/context.ts +21 -1
- package/src/help.ts +82 -1
- package/src/main.ts +5 -0
- package/src/wallet-api/admin.ts +107 -0
- package/src/wallet-api/deny.ts +194 -0
- package/src/wallet-api/endpoint.ts +108 -0
- package/src/wallet-api/grants.ts +238 -0
- package/src/wallet-api/permission-store.ts +106 -0
|
@@ -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
|
+
}
|