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

@ -41,6 +41,14 @@ VITE_LIGHTNING_PUB_API_URL=http://localhost:1776
# If not set, generates ephemeral identity on each restart # If not set, generates ephemeral identity on each restart
VITE_ATM_PRIVATE_KEY= VITE_ATM_PRIVATE_KEY=
# =============================================================================
# Operator Identity
# =============================================================================
# Comma-separated list of Nostr hex pubkeys authorized to send operator commands
# (manual dispense, remote management). Decoupled from Lightning.Pub identity.
# VITE_OPERATOR_PUBKEYS=abcd1234...,ef567890...
# ============================================================================= # =============================================================================
# Mock Fallback (Production Safety) # Mock Fallback (Production Safety)
# ============================================================================= # =============================================================================

View file

@ -17,6 +17,7 @@ import {
setCassettes, setCassettes,
getInventory, getInventory,
recordTransaction, recordTransaction,
remediateTransaction,
emptyCashbox, emptyCashbox,
getCashbox, getCashbox,
} from './state-store.js' } from './state-store.js'
@ -180,6 +181,9 @@ ipcMain.handle('get-config', () => {
// SECURITY: In production (packaged app), mock fallback is always disabled. // SECURITY: In production (packaged app), mock fallback is always disabled.
// Only allow it in development mode, and only when explicitly opted in via env. // Only allow it in development mode, and only when explicitly opted in via env.
allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true', allowMockFallback: isDev && process.env.VITE_ALLOW_MOCK_FALLBACK === 'true',
// Operator identity (comma-separated hex pubkeys)
operatorPubkeys: process.env.VITE_OPERATOR_PUBKEYS || '',
} }
}) })
@ -213,6 +217,9 @@ ipcMain.handle('state:get-inventory', () => getInventory())
ipcMain.handle('state:get-cashbox', () => getCashbox()) ipcMain.handle('state:get-cashbox', () => getCashbox())
ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx)) ipcMain.handle('state:record-transaction', (_event, tx) => recordTransaction(tx))
ipcMain.handle('state:empty-cashbox', () => emptyCashbox()) ipcMain.handle('state:empty-cashbox', () => emptyCashbox())
ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedByTxid: string) =>
remediateTransaction(txid, remediatedByTxid)
)
// ============================================================================= // =============================================================================
// HAL Hardware IPC Handlers // HAL Hardware IPC Handlers

View file

@ -25,6 +25,7 @@ export interface RuntimeConfig {
dispenserDevice?: string dispenserDevice?: string
cassettes?: string cassettes?: string
allowMockFallback: boolean allowMockFallback: boolean
operatorPubkeys: string
} }
/** /**
@ -54,8 +55,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'), getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
recordTransaction: (tx: { recordTransaction: (tx: {
txid: string txid: string
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number fiatCents: number
sats: number sats: number
feeSats: number feeSats: number
@ -74,6 +75,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
error?: string | null error?: string | null
}) => ipcRenderer.invoke('state:record-transaction', tx), }) => ipcRenderer.invoke('state:record-transaction', tx),
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'), emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
// HAL hardware (runs in main process, exposed via IPC) // HAL hardware (runs in main process, exposed via IPC)
halInit: (config: any): Promise<{ success: boolean; error?: string }> => halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
@ -147,6 +150,7 @@ declare global {
error?: string | null error?: string | null
}) => Promise<void> }) => Promise<void>
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
// HAL hardware IPC // HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }> halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any> halDispense: (amounts: any) => Promise<any>

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null let db: Database.Database | null = null
const SCHEMA_VERSION = '4' const SCHEMA_VERSION = '5'
function getDbPath(): string { function getDbPath(): string {
const prodDir = '/var/lib/lamassu-atm' const prodDir = '/var/lib/lamassu-atm'
@ -65,7 +65,7 @@ export function initDatabase(dbPath?: string): void {
CREATE TABLE IF NOT EXISTS transactions ( CREATE TABLE IF NOT EXISTS transactions (
txid TEXT PRIMARY KEY, txid TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out')), type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
fiat_cents INTEGER NOT NULL, fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL, sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0, fee_sats INTEGER NOT NULL DEFAULT 0,
@ -74,6 +74,7 @@ export function initDatabase(dbPath?: string): void {
currency TEXT NOT NULL DEFAULT 'GTQ', currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'complete', status TEXT NOT NULL DEFAULT 'complete',
error TEXT, error TEXT,
remediated_by TEXT,
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@ -143,6 +144,34 @@ export function initDatabase(dbPath?: string): void {
`) `)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('4', 'schema_version')
console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)') console.log('[StateStore] Migrated schema v3 → v4 (added status, error, cassette_bills)')
existing.value = '4'
}
if (existing && existing.value === '4') {
// Migration v4 → v5: add manual_dispense type and remediated_by column
// SQLite cannot ALTER CHECK constraints, so recreate the table
db.exec(`
CREATE TABLE transactions_new (
txid TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('cash_in', 'cash_out', 'manual_dispense')),
fiat_cents INTEGER NOT NULL,
sats INTEGER NOT NULL,
fee_sats INTEGER NOT NULL DEFAULT 0,
fee_percent REAL NOT NULL DEFAULT 0,
exchange_rate REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'GTQ',
status TEXT NOT NULL DEFAULT 'complete',
error TEXT,
remediated_by TEXT,
created_at INTEGER NOT NULL
);
INSERT INTO transactions_new SELECT txid, type, fiat_cents, sats, fee_sats, fee_percent,
exchange_rate, currency, status, error, NULL, created_at FROM transactions;
DROP TABLE transactions;
ALTER TABLE transactions_new RENAME TO transactions;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('5', 'schema_version')
console.log('[StateStore] Migrated schema v4 → v5 (added manual_dispense type, remediated_by)')
} }
const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get() const cashboxRow = db.prepare('SELECT id FROM cashbox WHERE id = 1').get()
@ -269,8 +298,8 @@ export function emptyCashbox(): void {
interface TransactionInput { interface TransactionInput {
txid: string txid: string
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number fiatCents: number
sats: number sats: number
feeSats: number feeSats: number
@ -372,6 +401,26 @@ export function recordTransaction(tx: TransactionInput): void {
console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`) console.log('[StateStore] Recorded transaction:', tx.txid, tx.type, `(${tx.status})`)
} }
/**
* Mark a failed transaction as remediated by a manual dispense.
* Only updates transactions with status 'dispense_error' or 'partial'.
* Returns true if the transaction was updated, false if not found or not in error state.
*/
export function remediateTransaction(txid: string, remediatedByTxid: string): boolean {
if (!db) throw new Error('Database not initialized')
const result = db
.prepare(
'UPDATE transactions SET status = ?, remediated_by = ? WHERE txid = ? AND status IN (?, ?)'
)
.run('remediated', remediatedByTxid, txid, 'dispense_error', 'partial')
if (result.changes > 0) {
console.log('[StateStore] Remediated transaction:', txid, '→', remediatedByTxid)
}
return result.changes > 0
}
/** /**
* Close the database (for clean shutdown). * Close the database (for clean shutdown).
*/ */

View file

@ -32,7 +32,7 @@ import {
encodeNdebit, encodeNdebit,
formatNdebitUri, formatNdebitUri,
} from '@lamassu/clink' } from '@lamassu/clink'
import type { OfferRequest } from '@lamassu/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@lamassu/clink'
import type { ATMServices, ATMContext } from '@lamassu/state-machine' import type { ATMServices, ATMContext } from '@lamassu/state-machine'
// Import Electron types // Import Electron types
@ -62,6 +62,7 @@ interface LightningConfig {
adminToken: string adminToken: string
atmPrivateKey: string atmPrivateKey: string
appId: string appId: string
operatorPubkeys: string[]
} }
/** /**
@ -81,6 +82,7 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: 'lamassu-dev-admin-token', adminToken: 'lamassu-dev-admin-token',
atmPrivateKey: '', atmPrivateKey: '',
appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID
operatorPubkeys: [],
} }
// In Electron, get runtime config from main process // In Electron, get runtime config from main process
@ -97,6 +99,12 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: secrets.adminToken || defaults.adminToken, adminToken: secrets.adminToken || defaults.adminToken,
atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey, atmPrivateKey: secrets.atmPrivateKey || defaults.atmPrivateKey,
appId: runtimeConfig.appId || defaults.appId, appId: runtimeConfig.appId || defaults.appId,
operatorPubkeys: runtimeConfig.operatorPubkeys
? runtimeConfig.operatorPubkeys
.split(',')
.map((k: string) => k.trim())
.filter(Boolean)
: defaults.operatorPubkeys,
} }
} catch (e) { } catch (e) {
console.warn('[Lightning] Failed to get runtime config from Electron:', e) console.warn('[Lightning] Failed to get runtime config from Electron:', e)
@ -112,6 +120,12 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken, adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken,
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey, atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
appId: import.meta.env.VITE_APP_ID || defaults.appId, appId: import.meta.env.VITE_APP_ID || defaults.appId,
operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS
? (import.meta.env.VITE_OPERATOR_PUBKEYS as string)
.split(',')
.map((k: string) => k.trim())
.filter(Boolean)
: defaults.operatorPubkeys,
} }
} }
@ -669,6 +683,13 @@ interface LightningServices {
onDebitPaymentApproved: (callback: DebitPaymentCallback) => void onDebitPaymentApproved: (callback: DebitPaymentCallback) => void
/** Stop the debit approval service */ /** Stop the debit approval service */
stopDebitApproval: () => void stopDebitApproval: () => void
/** Set callback for operator management commands (Kind 21003) */
onManagement: (
callback: (
request: ManagementRequest,
senderPubkey: string
) => Promise<ManagementResponse | null>
) => void
} }
// ============================================================================ // ============================================================================
@ -896,7 +917,7 @@ export async function initializeLightningServices(options?: {
const clink = new CLINKClient({ const clink = new CLINKClient({
nostrClient, nostrClient,
identity, identity,
operatorPubkey: CONFIG.lightningPubPubkey, // Use Lightning.Pub as operator for dev operatorPubkey: [CONFIG.lightningPubPubkey, ...CONFIG.operatorPubkeys].filter(Boolean),
relays: [CONFIG.relayUrl], relays: [CONFIG.relayUrl],
}) })
@ -904,6 +925,9 @@ export async function initializeLightningServices(options?: {
let offerRequestCallback: OfferRequestCallback | null = null let offerRequestCallback: OfferRequestCallback | null = null
let paymentReceivedCallback: PaymentReceivedCallback | null = null let paymentReceivedCallback: PaymentReceivedCallback | null = null
let debitPaymentCallback: DebitPaymentCallback | null = null let debitPaymentCallback: DebitPaymentCallback | null = null
let managementCallback:
| ((request: ManagementRequest, senderPubkey: string) => Promise<ManagementResponse | null>)
| null = null
// Start the debit approval service // Start the debit approval service
const stopDebitApproval = startDebitApprovalService( const stopDebitApproval = startDebitApprovalService(
@ -968,8 +992,17 @@ export async function initializeLightningServices(options?: {
} }
}) })
// Set up management command handler (Kind 21003, resource='machine')
clink.onManagement(async (request, senderPubkey) => {
console.log('[CLINK] Received management command from', senderPubkey.slice(0, 16) + '...')
if (managementCallback) {
return managementCallback(request, senderPubkey)
}
return null
})
clink.startListening() clink.startListening()
console.log('[Lightning] CLINK client initialized with offer handler') console.log('[Lightning] CLINK client initialized with offer + management handlers')
// Create ATM services using Lightning.Pub's native LNURL-withdraw // Create ATM services using Lightning.Pub's native LNURL-withdraw
const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => { const atmServices = createATMServices(lightningPub, clink, identity, (preimage) => {
@ -994,6 +1027,14 @@ export async function initializeLightningServices(options?: {
debitPaymentCallback = callback debitPaymentCallback = callback
}, },
stopDebitApproval, stopDebitApproval,
onManagement: (
callback: (
request: ManagementRequest,
senderPubkey: string
) => Promise<ManagementResponse | null>
) => {
managementCallback = callback
},
} }
} }

View file

@ -13,7 +13,13 @@ import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning
import type { HalConfig, HalServices } from '@/services/hal' import type { HalConfig, HalServices } from '@/services/hal'
import type { MachineModel } from '@/config' import type { MachineModel } from '@/config'
import type { LightningPubClient } from '@lamassu/lightning' import type { LightningPubClient } from '@lamassu/lightning'
import type { CLINKClient } from '@lamassu/clink' import {
isMachineDispenseRequest,
GFYCode,
type CLINKClient,
type ManagementRequest,
type ManagementResponse,
} from '@lamassu/clink'
import type { TransactionRecord } from '@/types/state' import type { TransactionRecord } from '@/types/state'
// Check if we're running in Electron // Check if we're running in Electron
@ -31,6 +37,85 @@ function computeFeeSats(ctx: ATMContext, isCashIn: boolean): number {
return Math.max(0, feeSats) return Math.max(0, feeSats)
} }
/**
* Handle an operator management command (Kind 21003, resource='machine').
* Performs a direct HAL dispense when idle, bypassing the state machine.
*/
async function handleManagementCommand(
request: ManagementRequest,
dispenseFn: (amounts: { denomination: number; count: number }[]) => Promise<any>,
machineIdle: boolean,
currency: string
): Promise<ManagementResponse | null> {
if (!isMachineDispenseRequest(request)) return null
console.log('[ATM] Manual dispense command received')
if (!machineIdle) {
return {
res: 'GFY',
code: GFYCode.RequestDenied,
error: 'Machine is not idle',
}
}
if (!request.bills || request.bills.length === 0) {
return {
res: 'GFY',
code: GFYCode.InvalidRequest,
error: 'No bills specified',
}
}
try {
const result = await dispenseFn(request.bills)
const txid = `manual-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const totalFiatCents = request.bills.reduce((sum, b) => sum + b.denomination * b.count * 100, 0)
await persistTransaction({
txid,
type: 'manual_dispense',
status: result.dispensed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents,
sats: 0,
feeSats: 0,
feePercent: 0,
exchangeRate: 0,
currency,
bills: request.bills,
cassettes: result.cassettes,
error: result.error,
})
// Remediate referenced failed transaction
let refRemediated = false
if (request.ref_txid && isElectron && window.electronAPI) {
refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid)
if (refRemediated) {
console.log('[ATM] Remediated failed tx:', request.ref_txid)
}
}
return {
res: 'ok',
resource: 'machine',
details: {
txid,
dispensed: result.bills,
error: result.error,
ref_txid_remediated: refRemediated,
},
}
} catch (error) {
console.error('[ATM] Manual dispense failed:', error)
return {
res: 'GFY',
code: GFYCode.TemporaryFailure,
error: error instanceof Error ? error.message : 'Dispense failed',
}
}
}
/** /**
* Load inventory from SQLite via IPC (Electron only). * Load inventory from SQLite via IPC (Electron only).
* Returns empty object in browser dev mode. * Returns empty object in browser dev mode.
@ -430,6 +515,16 @@ export const useAtmStore = defineStore('atm', () => {
services.stopDebitApproval() services.stopDebitApproval()
} }
// Wire operator management commands (Lightning-only mode, mock dispense)
services.onManagement(async (request) => {
return handleManagementCommand(
request,
(amounts) => services.atmServices.dispenseCash(amounts),
isIdle.value,
fiatCode.value
)
})
// Inject DB-backed inventory into services // Inject DB-backed inventory into services
const servicesWithInventory: ATMServices = { const servicesWithInventory: ATMServices = {
...services.atmServices, ...services.atmServices,
@ -657,6 +752,16 @@ export const useAtmStore = defineStore('atm', () => {
lightning.stopDebitApproval() lightning.stopDebitApproval()
} }
// Wire operator management commands (manual dispense via direct HAL)
lightning.onManagement(async (request) => {
return handleManagementCommand(
request,
(amounts) => hal.atmServices.dispenseCash(amounts),
isIdle.value,
fiatCode.value
)
})
// Merge HAL hardware services with Lightning payment services // Merge HAL hardware services with Lightning payment services
const mergedServices: ATMServices = { const mergedServices: ATMServices = {
...lightning.atmServices, ...lightning.atmServices,
@ -897,6 +1002,16 @@ export const useAtmStore = defineStore('atm', () => {
lightning.stopDebitApproval() lightning.stopDebitApproval()
} }
// Wire operator management commands (manual dispense via IPC HAL)
lightning.onManagement(async (request) => {
return handleManagementCommand(
request,
(amounts) => api.halDispense(amounts),
isIdle.value,
fiatCode.value
)
})
// Merge HAL hardware services with Lightning payment services // Merge HAL hardware services with Lightning payment services
const mergedServices: ATMServices = { const mergedServices: ATMServices = {
...lightning.atmServices, ...lightning.atmServices,

View file

@ -14,6 +14,7 @@ export interface RuntimeConfig {
dispenserDevice?: string dispenserDevice?: string
cassettes?: string cassettes?: string
allowMockFallback: boolean allowMockFallback: boolean
operatorPubkeys: string
} }
export interface AtmSecrets { export interface AtmSecrets {
@ -37,8 +38,8 @@ declare global {
}> }>
recordTransaction: (tx: { recordTransaction: (tx: {
txid: string txid: string
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number fiatCents: number
sats: number sats: number
feeSats: number feeSats: number
@ -57,6 +58,7 @@ declare global {
error?: string | null error?: string | null
}) => Promise<void> }) => Promise<void>
emptyCashbox: () => Promise<void> emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
// HAL hardware IPC // HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }> halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any> halDispense: (amounts: any) => Promise<any>

View file

@ -15,8 +15,8 @@ export interface CashboxState {
export interface TransactionRecord { export interface TransactionRecord {
txid: string txid: string
type: 'cash_in' | 'cash_out' type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number fiatCents: number
sats: number sats: number
feeSats: number feeSats: number
@ -33,6 +33,7 @@ export interface TransactionRecord {
rejected: number rejected: number
}[] }[]
error?: string | null error?: string | null
remediatedBy?: string | null
} }
export interface ATMAvailability { export interface ATMAvailability {

View file

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

View file

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

View file

@ -205,12 +205,12 @@ export interface OfferConfig {
} }
/** Management resource types */ /** Management resource types */
export type ManagementResource = 'offer' export type ManagementResource = 'offer' | 'machine'
/** Management request (Kind 21003) - per CLINK Manage spec */ /** Offer management request (resource='offer') */
export interface ManagementRequest { export interface OfferManagementRequest {
/** Resource type being managed */ /** Resource type being managed */
resource: ManagementResource resource: 'offer'
/** Action to perform */ /** Action to perform */
action: ManagementAction action: ManagementAction
/** Pointer ID (optional, for multi-account routing) */ /** 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 */ /** Management success response (Kind 21003) - per CLINK Manage spec */
export interface ManagementSuccessResponse { export interface ManagementSuccessResponse {
/** Success indicator */ /** Success indicator */