feat(machine): open a verified Bolt Card session at tap-to-enter
Entry now spends the tap's single-use SUN once, on the card server's new /session endpoint (aiolabs/boltcards feat/card-session-endpoint), instead of parsing the lnurlw locally and deferring every check to Complete. The server proves a genuine, non-replayed card and returns the wallet balance plus the hit-keyed LUD-03 withdraw and LUD-06 pay second steps — the same single-use bearer /scan and /pay hand out — so Complete still needs no second tap and the ATM holds no p/c for the visit. - electron/boltcard-session.ts: /scan → /session URL derivation, response parsing, 404 → 'card server does not support sessions'. - lnurl-withdraw / lnurl-pay: the second steps are now callable on their own (executeWithdrawCallback, resolveInvoiceFromPayStep); the tap paths are unchanged and reuse them. - IPC: lnurl:open-card-session, lnurl:withdraw-session, lnurl:pay-session. - store: handleBoltCardEntry opens the session then authorizes the server-returned external_id; the payment handlers take a source (raw tap or session); a withheld withdraw step declines with the server's reason. The boltcard AccessScan no longer carries the lnurlw. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
ce4b5a5dc6
commit
735132b032
12 changed files with 639 additions and 63 deletions
125
apps/machine/electron/boltcard-session.test.ts
Normal file
125
apps/machine/electron/boltcard-session.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { openCardSession, scanUrlToSessionUrl } from './boltcard-session'
|
||||
|
||||
const LNURLW =
|
||||
'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788'
|
||||
|
||||
/** Mock fetch returning the given JSON bodies per call, in order (status 200). */
|
||||
function mockFetch(bodies: unknown[], status = 200) {
|
||||
const calls: string[] = []
|
||||
const impl = vi.fn(async (url: string | URL) => {
|
||||
calls.push(url.toString())
|
||||
const body = bodies[calls.length - 1]
|
||||
return { status, json: async () => body } as Response
|
||||
})
|
||||
return { impl: impl as unknown as typeof fetch, calls }
|
||||
}
|
||||
|
||||
const SESSION = {
|
||||
authenticated: true,
|
||||
external_id: 'abc123',
|
||||
card_name: 'Alice',
|
||||
balance_msat: 123_456_789,
|
||||
currency: 'usd',
|
||||
fiat: 98.76,
|
||||
withdraw: {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/hit1',
|
||||
k1: 'hit1',
|
||||
minWithdrawable: 1000,
|
||||
maxWithdrawable: 50_000_000,
|
||||
},
|
||||
withdraw_blocked_reason: null,
|
||||
pay: {
|
||||
callback: 'https://lnbits.l484.com/boltcards/api/v1/pay/cb/hit1',
|
||||
minSendable: 1000,
|
||||
maxSendable: 50_000_000,
|
||||
metadata: '[["text/plain","Bolt Card top-up"]]',
|
||||
},
|
||||
}
|
||||
|
||||
describe('scanUrlToSessionUrl', () => {
|
||||
it('rewrites /scan/ to /session/ and preserves p + c', () => {
|
||||
const u = scanUrlToSessionUrl(LNURLW)
|
||||
expect(u).toContain('https://lnbits.l484.com/boltcards/api/v1/session/abc123')
|
||||
expect(u).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF')
|
||||
expect(u).toContain('c=1122334455667788')
|
||||
})
|
||||
it('returns null for a non-scan URL', () => {
|
||||
expect(scanUrlToSessionUrl('lnurlw://host/somethingelse?p=1&c=2')).toBeNull()
|
||||
expect(scanUrlToSessionUrl('http://host/boltcards/api/v1/scan/x')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('openCardSession', () => {
|
||||
it('opens a session: balance in sats, upper-cased currency, both steps', async () => {
|
||||
const f = mockFetch([SESSION])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(f.calls).toHaveLength(1)
|
||||
expect(f.calls[0]).toContain('/session/abc123')
|
||||
expect(out).toEqual({
|
||||
ok: true,
|
||||
session: {
|
||||
externalId: 'abc123',
|
||||
cardName: 'Alice',
|
||||
balanceSats: 123_456,
|
||||
currency: 'USD',
|
||||
fiat: 98.76,
|
||||
withdraw: SESSION.withdraw,
|
||||
withdrawBlockedReason: null,
|
||||
pay: SESSION.pay,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a withheld withdraw step with its reason', async () => {
|
||||
const f = mockFetch([
|
||||
{ ...SESSION, withdraw: null, withdraw_blocked_reason: 'Max daily limit spent.' },
|
||||
])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
expect(out.session.withdraw).toBeNull()
|
||||
expect(out.session.withdrawBlockedReason).toBe('Max daily limit spent.')
|
||||
expect(out.session.pay.callback).toBe(SESSION.pay.callback)
|
||||
})
|
||||
|
||||
it('has no fiat when the server sent no currency', async () => {
|
||||
const f = mockFetch([{ ...SESSION, currency: null, fiat: null }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out.ok && out.session.currency).toBeNull()
|
||||
expect(out.ok && out.session.fiat).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces the server reason on a rejected tap', async () => {
|
||||
const f = mockFetch([{ authenticated: false, reason: 'This link is already used.' }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'This link is already used.' })
|
||||
})
|
||||
|
||||
it('rejects an incomplete session (no pay step)', async () => {
|
||||
const f = mockFetch([{ ...SESSION, pay: undefined }])
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'card server returned an incomplete session' })
|
||||
})
|
||||
|
||||
it('names an old card server that has no /session', async () => {
|
||||
const f = mockFetch([{ detail: 'Not Found' }], 404)
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: f.impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'card server does not support sessions' })
|
||||
})
|
||||
|
||||
it('rejects a non-card tag without a network call', async () => {
|
||||
const f = mockFetch([])
|
||||
const out = await openCardSession('https://host/not/a/card', { fetchImpl: f.impl })
|
||||
expect(out.ok).toBe(false)
|
||||
expect(f.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports an unreachable card server', async () => {
|
||||
const impl = vi.fn(async () => {
|
||||
throw new TypeError('fetch failed')
|
||||
}) as unknown as typeof fetch
|
||||
const out = await openCardSession(LNURLW, { fetchImpl: impl })
|
||||
expect(out).toEqual({ ok: false, reason: 'could not reach the card: fetch failed' })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue