feat: operator command channel via Nostr (manual dispense)

Add a Nostr-native operator command channel using Kind 21003 (CLINK
Manage) events. Operators listed in OPERATOR_PUBKEYS can send encrypted
commands to the machine.

Phase 1 implements manual dispense: operator sends a dispense command,
machine verifies sender, checks it's idle, performs a direct HAL
dispense (bypassing state machine), and records the transaction.

When ref_txid is provided, the referenced failed transaction is updated
to status 'remediated', closing the loop on dispense errors.

Changes:
- CLINK types: add 'machine' resource, MachineDispenseRequest type
- CLINK client: support operator pubkey list (string | string[])
- Runtime config: VITE_OPERATOR_PUBKEYS env var
- Schema v4→v5: manual_dispense type, remediated_by column
- Lightning services: wire onManagement callback
- ATM store: handleManagementCommand with idle check + remediation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-03-23 00:36:55 -04:00
commit e03782803b
11 changed files with 274 additions and 24 deletions

View file

@ -69,8 +69,8 @@ export interface CLINKClientOptions {
nostrClient: NostrClient
/** Machine identity */
identity: MachineIdentity
/** Operator pubkey for management commands */
operatorPubkey: string
/** Operator pubkey(s) for management commands */
operatorPubkey: string | string[]
/** Relays to use for offers */
relays: string[]
/** Invoice generator function */
@ -103,7 +103,7 @@ export type ManagementHandler = (
export class CLINKClient {
private nostrClient: NostrClient
private identity: MachineIdentity
private operatorPubkey: string
private operatorPubkeys: string[]
private relays: string[]
private generateInvoice?: GenerateInvoice
private payInvoice?: PayInvoice
@ -117,7 +117,9 @@ export class CLINKClient {
constructor(options: CLINKClientOptions) {
this.nostrClient = options.nostrClient
this.identity = options.identity
this.operatorPubkey = options.operatorPubkey
this.operatorPubkeys = Array.isArray(options.operatorPubkey)
? options.operatorPubkey
: [options.operatorPubkey]
this.relays = options.relays
this.generateInvoice = options.generateInvoice
this.payInvoice = options.payInvoice
@ -440,8 +442,8 @@ export class CLINKClient {
* Handle management command (Kind 21003)
*/
private async handleManageEvent(event: Event): Promise<void> {
// Only accept from operator
if (event.pubkey !== this.operatorPubkey) {
// Only accept from authorized operators
if (!this.operatorPubkeys.includes(event.pubkey)) {
console.warn('Ignoring management command from non-operator:', event.pubkey)
return
}

View file

@ -63,11 +63,14 @@ export {
type DebitFailureResponse,
// Management types (Kind 21003)
type ManagementRequest,
type OfferManagementRequest,
type MachineDispenseRequest,
type ManagementResponse,
type ManagementSuccessResponse,
type ManagementFailureResponse,
type ManagementAction,
type ManagementResource,
isMachineDispenseRequest,
// Beacon types (Kind 30078)
type ServiceBeacon,
// Noffer types

View file

@ -205,12 +205,12 @@ export interface OfferConfig {
}
/** Management resource types */
export type ManagementResource = 'offer'
export type ManagementResource = 'offer' | 'machine'
/** Management request (Kind 21003) - per CLINK Manage spec */
export interface ManagementRequest {
/** Offer management request (resource='offer') */
export interface OfferManagementRequest {
/** Resource type being managed */
resource: ManagementResource
resource: 'offer'
/** Action to perform */
action: ManagementAction
/** Pointer ID (optional, for multi-account routing) */
@ -232,6 +232,24 @@ export interface ManagementRequest {
}
}
/** Machine dispense request (resource='machine', action='dispense') */
export interface MachineDispenseRequest {
resource: 'machine'
action: 'dispense'
/** Bills to dispense: [{denomination, count}] */
bills: { denomination: number; count: number }[]
/** Optional: txid of a failed transaction to mark as remediated */
ref_txid?: string
}
/** Management request (Kind 21003) - per CLINK Manage spec */
export type ManagementRequest = OfferManagementRequest | MachineDispenseRequest
/** Type guard for machine dispense request */
export function isMachineDispenseRequest(req: ManagementRequest): req is MachineDispenseRequest {
return req.resource === 'machine' && req.action === 'dispense'
}
/** Management success response (Kind 21003) - per CLINK Manage spec */
export interface ManagementSuccessResponse {
/** Success indicator */