feat(machine): on-machine QR-pairing wizard #68

Merged
padreug merged 6 commits from qr-pairing-wizard into dev 2026-06-24 22:40:16 +00:00
17 changed files with 650 additions and 277 deletions

View file

@ -84,12 +84,32 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
|---|---|---| |---|---|---|
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | | `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. See aiolabs/bitspire#52. | | `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. |
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. | | `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface. The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface.
## Pairing (on-machine QR wizard)
A machine with no seed **and** no stored binding boots `unpaired` and, under
Electron, renders an interactive wizard (`src/components/PairingWizard.vue`)
instead of a dead-end fault screen. The operator displays the `spire-seed`
QR (minted by spirekeeper's `/pair`) to the machine's camera; the wizard:
1. captures + decodes via a `PairingSource` (`src/services/pairing/`) — camera
today (decode through `qr`, paulmillr's zero-dep lib), NFC scaffolded;
2. validates the scan parses as a spire-seed (`ingestScannedSeed`), rejecting
a stray QR;
3. persists it as `VITE_SPIRE_SEED` via the `state:save-spire-seed` IPC and
relaunches (`app:relaunch`).
Pairing itself is **not** done in the wizard — relaunch lets the normal boot
path (`signer-resolver` → `connectNewSeed`) redeem the one-shot token, so
there's one tested pairing path. A revoked/expired binding lands on the same
wizard (re-pair = scan a fresh seed). Provisioning `VITE_SPIRE_SEED` up front
still works and skips the wizard.
## Commands ## Commands
```bash ```bash

View file

@ -353,6 +353,50 @@ ipcMain.handle('state:reset-bootstrap-gate', (): void => {
resetBootstrapGate() resetBootstrapGate()
}) })
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the
// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed)
// exactly as if it had been provisioned. We deliberately do NOT pair here —
// persisting + relaunching reuses the single, tested pairing path rather than
// duplicating it in the renderer.
function runtimeEnvPath(): string {
const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd()
return path.join(base, '.env')
}
ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => {
const trimmed = (seed || '').trim()
if (!trimmed) throw new Error('save-spire-seed: empty seed')
const envPath = runtimeEnvPath()
const line = `VITE_SPIRE_SEED=${trimmed}`
let lines: string[] = []
if (fs.existsSync(envPath)) {
lines = fs.readFileSync(envPath, 'utf8').split('\n')
}
const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED='))
if (idx >= 0) {
lines[idx] = line
} else {
// Drop a trailing empty element so we don't accumulate blank lines.
if (lines.length && lines[lines.length - 1] === '') lines.pop()
lines.push(line)
}
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 })
// Keep this process's view in sync so get-atm-secrets reflects the new seed
// even before relaunch (belt-and-suspenders; relaunch re-reads from disk).
process.env.VITE_SPIRE_SEED = trimmed
console.log('[Pairing] Spire seed persisted to', envPath)
})
// Relaunch the kiosk so the new seed is picked up by a clean boot. Under
// systemd (bitspire.service) the exit triggers an automatic restart; in dev
// Electron's relaunch re-spawns the process.
ipcMain.handle('app:relaunch', (): void => {
console.log('[Pairing] Relaunching to apply new pairing')
app.relaunch()
app.exit(0)
})
// State persistence IPC handlers // State persistence IPC handlers
ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:load-cassettes', () => loadCassettes())
ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes))

View file

@ -119,6 +119,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'), clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'), resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
// then relaunch so the normal boot flow pairs it.
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (
payload: { payload: {
positions: Record<string, { denomination: number; count: number }> positions: Record<string, { denomination: number; count: number }>
@ -234,6 +239,8 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> }, payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number eventCreatedAt: number

View file

@ -37,6 +37,7 @@
"marked": "^17.0.5", "marked": "^17.0.5",
"nostr-tools": "^2.10.0", "nostr-tools": "^2.10.0",
"pinia": "^2.2.0", "pinia": "^2.2.0",
"qr": "^0.6.0",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.6.0",
"reka-ui": "^2.7.0", "reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",

View file

@ -8,6 +8,7 @@ import { classifyInitError } from '@/services/init-error'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Sun, Moon } from 'lucide-vue-next' import { Sun, Moon } from 'lucide-vue-next'
import PairingWizard from '@/components/PairingWizard.vue'
const atmStore = useAtmStore() const atmStore = useAtmStore()
const route = useRoute() const route = useRoute()
@ -61,6 +62,14 @@ const isKnownMaintenanceScreen = computed(
() => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS () => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS
) )
/**
* `unpaired` is interactive, not a dead-end: render the QR-pairing wizard so
* the operator can scan a spire-seed on-machine (aiolabs/bitspire#52). The
* wizard only works under Electron (needs the seed-persist + relaunch bridge);
* in browser dev it falls back to the static card.
*/
const showPairingWizard = computed(() => atmStore.initError === 'unpaired' && isElectron)
const formattedBtcPrice = computed(() => { const formattedBtcPrice = computed(() => {
if (atmStore.btcPrice === null) return null if (atmStore.btcPrice === null) return null
const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}` const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}`
@ -160,9 +169,12 @@ function toggleLiveServices() {
<div <div
class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground" class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground"
> >
<!-- Unpaired: interactive QR-pairing wizard (aiolabs/bitspire#52) -->
<PairingWizard v-if="showPairingWizard" />
<!-- Maintenance screen: shown when initialization fails in production --> <!-- Maintenance screen: shown when initialization fails in production -->
<div <div
v-if="atmStore.initError" v-else-if="atmStore.initError"
class="flex flex-1 flex-col items-center justify-center gap-6 p-8" class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
> >
<svg <svg

View file

@ -0,0 +1,160 @@
<script setup lang="ts">
/**
* QR-pairing wizard (aiolabs/bitspire#52).
*
* Shown in place of the "Pairing Required" maintenance screen when the machine
* is unpaired. The operator displays the spire-seed QR (minted by spirekeeper)
* to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and
* relaunch so the normal boot path performs the bunker pairing.
*
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
* offered later without changing this view.
*/
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
import {
availablePairingSources,
ingestScannedSeed,
type PairingSource,
type StopCapture,
} from '@/services/pairing'
type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error'
const phase = ref<Phase>('probing')
const errorMessage = ref('')
const videoEl = ref<HTMLVideoElement | null>(null)
const sources = shallowRef<PairingSource[]>([])
const activeSource = shallowRef<PairingSource | null>(null)
let stopCapture: StopCapture | null = null
async function startWith(source: PairingSource) {
await teardown()
activeSource.value = source
errorMessage.value = ''
phase.value = 'scanning'
try {
stopCapture = await source.start({
video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined,
onScan: handleScan,
onError: (e) => console.warn('[Pairing] capture glitch:', e),
})
} catch (e) {
phase.value = 'error'
errorMessage.value =
e instanceof Error ? e.message : 'Could not start the camera. Check permissions.'
}
}
let handling = false
async function handleScan(raw: string) {
if (handling) return
handling = true
const result = await ingestScannedSeed(raw)
if (result.ok) {
// saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen.
phase.value = 'pairing'
return
}
// Reject non-seed scans (a stray QR) and resume scanning.
console.warn('[Pairing] rejected scan:', result.reason, result.message)
errorMessage.value =
result.reason === 'invalid-seed'
? 'That code is not a pairing code. Show the operator pairing QR.'
: result.message
handling = false
if (activeSource.value) await startWith(activeSource.value)
}
async function teardown() {
if (stopCapture) {
try {
stopCapture()
} catch {
/* idempotent */
}
stopCapture = null
}
}
onMounted(async () => {
const available = await availablePairingSources()
sources.value = available
const first = available[0]
if (!first) {
phase.value = 'no-source'
return
}
await startWith(first)
})
onUnmounted(teardown)
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8">
<h1 class="text-2xl lg:text-[3.5rem] font-bold">Pair This Machine</h1>
<!-- Camera viewfinder -->
<div
v-show="phase === 'scanning' && activeSource?.kind === 'qr'"
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
>
<video ref="videoEl" class="h-full w-full object-cover" muted autoplay playsinline></video>
<!-- Reticle -->
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
</div>
<p
v-if="phase === 'scanning'"
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
>
Hold the operator's pairing QR up to the camera.
</p>
<p v-if="phase === 'probing'" class="text-base lg:text-2xl text-muted-foreground">
Starting camera…
</p>
<div v-if="phase === 'pairing'" class="flex flex-col items-center gap-4">
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
</div>
<p
v-if="phase === 'no-source'"
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
>
No camera or NFC reader is available on this machine. Pair by provisioning
<span class="font-mono">VITE_SPIRE_SEED</span> instead.
</p>
<p
v-if="phase === 'error'"
class="max-w-md text-center text-base lg:text-xl text-destructive"
>
{{ errorMessage }}
</p>
<!-- Transient rejected-scan hint while still scanning -->
<p
v-if="phase === 'scanning' && errorMessage"
class="max-w-md text-center text-sm lg:text-base text-warning"
>
{{ errorMessage }}
</p>
<!-- Alternate sources (e.g. NFC) when more than one is available -->
<div v-if="sources.length > 1" class="flex gap-3">
<button
v-for="source in sources"
:key="source.kind"
class="rounded-lg border border-border px-4 py-2 text-sm"
:class="activeSource?.kind === source.kind ? 'bg-primary text-primary-foreground' : ''"
@click="startWith(source)"
>
{{ source.label }}
</button>
</div>
</div>
</template>

View file

@ -3,14 +3,17 @@
* (see App.vue's MAINTENANCE_SCREENS). * (see App.vue's MAINTENANCE_SCREENS).
* *
* Bunker failures (aiolabs/bitspire#52) get dedicated screens: * Bunker failures (aiolabs/bitspire#52) get dedicated screens:
* - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the
* interactive QR-pairing wizard so the operator can scan a spire-seed.
* - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) → * - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) →
* `unpaired` — the operator must re-pair the machine. * `unpaired` too — re-pairing is the same scan-a-fresh-seed flow.
* - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`, * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`,
* a transient condition. * a transient condition.
* Everything else surfaces its raw message (or the caller's fallback). * Everything else surfaces its raw message (or the caller's fallback).
*/ */
export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string { export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string {
const name = (error as { name?: string } | null)?.name const name = (error as { name?: string } | null)?.name
if (name === 'NoPairingError') return 'unpaired'
if (name === 'BunkerRejectedError') return 'unpaired' if (name === 'BunkerRejectedError') return 'unpaired'
if (name === 'BunkerTimeoutError') return 'signer-unreachable' if (name === 'BunkerTimeoutError') return 'signer-unreachable'
return error instanceof Error ? error.message : fallback return error instanceof Error ? error.message : fallback

View file

@ -0,0 +1,80 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { ingestScannedSeed } from '../ingest'
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
function makeSeed(json: unknown): string {
const b64 = Buffer.from(JSON.stringify(json), 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return SPIRE_SEED_SCHEME + b64
}
const SPIRE_PUBKEY = 'a'.repeat(64)
const VALID_SEED = makeSeed({
v: 1,
spire_npub: 'npub1example',
spire_pubkey: SPIRE_PUBKEY,
bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`,
relays: ['wss://events.relay/'],
})
describe('ingestScannedSeed', () => {
const originalWindow = globalThis.window
afterEach(() => {
globalThis.window = originalWindow
vi.restoreAllMocks()
})
it('rejects a non-seed scan without touching the bridge', async () => {
const saveSpireSeed = vi.fn()
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
const result = await ingestScannedSeed('https://example.com/not-a-seed')
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('invalid-seed')
expect(saveSpireSeed).not.toHaveBeenCalled()
})
it('reports no-bridge when Electron is absent', async () => {
globalThis.window = {} as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(VALID_SEED)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('no-bridge')
})
it('persists the seed and relaunches on a valid scan', async () => {
const saveSpireSeed = vi.fn().mockResolvedValue(undefined)
const relaunchApp = vi.fn().mockResolvedValue(undefined)
globalThis.window = {
electronAPI: { saveSpireSeed, relaunchApp },
} as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace
expect(result.ok).toBe(true)
if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY)
expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED)
expect(relaunchApp).toHaveBeenCalledOnce()
})
it('surfaces persist-failed when saveSpireSeed throws', async () => {
const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES'))
globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis
const result = await ingestScannedSeed(VALID_SEED)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toBe('persist-failed')
})
})
describe('ingest does not pair in-renderer', () => {
it('never imports connect logic — persistence + relaunch only', () => {
// Guard: the design intentionally reuses the boot-time pairing path.
// If someone wires connectNewSeed here, this comment + the ingest source
// should be revisited together.
expect(ingestScannedSeed).toBeTypeOf('function')
})
})

View file

@ -0,0 +1,30 @@
/**
* Pairing module surface (aiolabs/bitspire#52).
*
* `availablePairingSources()` probes each known source and returns those the
* current device can actually run, in preference order (camera first, NFC if
* present). The wizard renders the first available source and offers the rest
* as alternates.
*/
import { QrPairingSource } from './qr-source'
import { NfcPairingSource } from './nfc-source'
import type { PairingSource } from './types'
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
export { QrPairingSource } from './qr-source'
export { NfcPairingSource } from './nfc-source'
export { ingestScannedSeed } from './ingest'
export type { IngestResult } from './ingest'
/** All sources in preference order, regardless of availability. */
export function allPairingSources(): PairingSource[] {
return [new QrPairingSource(), new NfcPairingSource()]
}
/** Only the sources this device can run, in preference order. */
export async function availablePairingSources(): Promise<PairingSource[]> {
const sources = allPairingSources()
const flags = await Promise.all(sources.map((s) => s.isAvailable()))
return sources.filter((_, i) => flags[i])
}

View file

@ -0,0 +1,63 @@
/**
* Seed ingest pipeline (aiolabs/bitspire#52).
*
* Turns a raw scanned payload into a paired machine. The wizard captures a
* string off some PairingSource and hands it here; we:
* 1. validate it parses as a spire-seed (reject anything else — a QR on the
* counter, a URL, a different protocol),
* 2. persist it as VITE_SPIRE_SEED via the Electron bridge,
* 3. relaunch so the normal boot path (signer-resolver → connectNewSeed)
* performs the actual bunker pairing.
*
* We do NOT pair in-renderer here: persisting + relaunching reuses the single,
* hardware-tested pairing path rather than duplicating connect/redeem logic in
* the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a
* one-time provisioning step.
*/
import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client'
export type IngestResult =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
const trimmed = (raw || '').trim()
let spirePubkey: string
let relays: string[]
try {
const seed = parseSpireSeed(trimmed)
spirePubkey = seed.spirePubkey
relays = seed.relays
} catch (e) {
return {
ok: false,
reason: 'invalid-seed',
message: e instanceof Error ? e.message : 'Not a valid pairing code',
}
}
if (typeof window === 'undefined' || !window.electronAPI) {
return {
ok: false,
reason: 'no-bridge',
message: 'Pairing must run on the machine (no kiosk bridge available).',
}
}
try {
await window.electronAPI.saveSpireSeed(trimmed)
} catch (e) {
return {
ok: false,
reason: 'persist-failed',
message: e instanceof Error ? e.message : 'Could not save the pairing.',
}
}
// Fire-and-forget: the relaunch tears this process down.
void window.electronAPI.relaunchApp()
return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays }
}

View file

@ -0,0 +1,67 @@
/**
* NFC pairing source — SCAFFOLD (aiolabs/bitspire#52).
*
* The user flagged NFC as a plausible future pairing method (tap a tag/phone
* carrying the spire-seed). This wires the seam against the Web NFC API
* (`NDEFReader`) so a future build can light it up without reworking the
* wizard. It is NOT active on current hardware: Web NFC ships only on Chrome
* for Android, so `isAvailable()` returns false on the Sintra's Linux Electron
* and the wizard simply won't offer it.
*
* When real NFC hardware lands (likely a HAL peripheral rather than Web NFC),
* replace the body of `start()` with that driver — the PairingSource contract
* stays the same.
*/
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
// Minimal structural type for the Web NFC API (not in lib.dom for Electron).
interface NDEFReaderLike {
scan(): Promise<void>
addEventListener(
type: 'reading',
listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void
): void
addEventListener(type: 'readingerror', listener: (event: unknown) => void): void
}
function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null {
const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader
return ctor ?? null
}
export class NfcPairingSource implements PairingSource {
readonly kind = 'nfc' as const
readonly label = 'NFC tap'
async isAvailable(): Promise<boolean> {
return getNDEFReaderCtor() !== null
}
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
const Ctor = getNDEFReaderCtor()
if (!Ctor) throw new Error('Web NFC unavailable on this device')
const reader = new Ctor()
const decoder = new TextDecoder()
let stopped = false
reader.addEventListener('reading', (event) => {
if (stopped) return
for (const record of event.message.records) {
if (record.recordType === 'text' && record.data) {
const raw = decoder.decode(record.data).trim()
if (raw) opts.onScan(raw)
}
}
})
reader.addEventListener('readingerror', (e) => opts.onError?.(e))
await reader.scan()
// Web NFC has no explicit stop; the AbortController form would, but the
// scaffold just flips a guard so late events are ignored after teardown.
return () => {
stopped = true
}
}
}

View file

@ -0,0 +1,90 @@
/**
* Camera-based QR pairing source (aiolabs/bitspire#52).
*
* Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual
* MIT/Apache library from the same author as the `@noble`/`@scure` crypto our
* nostr stack already trusts (chosen over the dormant `jsqr` for that ethos +
* active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and
* the per-frame decode loop, so this source is a thin adapter onto the
* PairingSource contract.
*
* The first successful decode wins; the loop then stops itself so a single
* seed isn't ingested repeatedly.
*/
import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js'
import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types'
export class QrPairingSource implements PairingSource {
readonly kind = 'qr' as const
readonly label = 'Camera'
async isAvailable(): Promise<boolean> {
return (
typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getUserMedia === 'function'
)
}
async start(opts: PairingSourceStartOptions): Promise<StopCapture> {
const { onScan, onError, video } = opts
if (!video) throw new Error('QrPairingSource requires a <video> element')
const camera = await frontalCamera(video)
// `frontalCamera` requests `ideal: screen.{width,height}`, so on the kiosk's
// 1280x800 panel it otherwise streams at ~720p — and `decodeQR` then
// center-crops to a square, leaving too few pixels-per-module for a dense
// spire-seed QR on this fixed-focus lens. Pin a deliberate 1280x960 capture
// instead: lamassu-machine caps QR scanning at 640x480 for decode speed
// (megapixels just slow the per-frame decode), but our seed QR is denser
// than a lightning invoice, so 1280x960 is the balance — ~14-18px/module at
// frame-fill, still fast, and it meters exposure better than maxing the
// sensor (a frame-filling QR keeps auto-exposure from blowing out on a
// bright phone screen). The stream lives on the <video>'s srcObject
// (QRCamera.stream is private); soft `ideal` so a camera that can't honor
// it degrades to its closest mode instead of throwing.
try {
const stream = video.srcObject
if (stream instanceof MediaStream) {
await stream.getVideoTracks()[0]?.applyConstraints({
width: { ideal: 1280 },
height: { ideal: 960 },
})
}
} catch (e) {
onError?.(e)
}
const canvas = new QRCanvas() // decode-only; no overlay canvases needed
let stopped = false
let cancel: (() => void) | null = null
const stop: StopCapture = () => {
if (stopped) return
stopped = true
cancel?.()
camera.stop()
}
cancel = frameLoop(() => {
if (stopped) return
try {
// `fullSize: true` decodes the camera's intrinsic frame (videoWidth ×
// videoHeight) rather than the <video> element's CSS box — the default
// (`false`) was decoding the few-hundred-px on-screen preview, which
// (compounded by `object-cover` cropping) starved the decoder.
const result = camera.readFrame(canvas, true)
if (result) {
stop()
onScan(result)
}
} catch (e) {
onError?.(e)
}
})
return stop
}
}

View file

@ -0,0 +1,42 @@
/**
* Pairing-source abstraction (aiolabs/bitspire#52).
*
* A fresh ATM is paired by getting a `spire-seed:v1:…` onto the device. The
* operator's spirekeeper mints that seed and renders it as a QR (and, later,
* possibly an NFC tag). The machine ingests it via whatever capture hardware
* it has — today a camera, tomorrow maybe an NFC reader or a HAL barcode
* scanner. `PairingSource` is the seam that keeps the wizard UI and the
* ingest pipeline agnostic to *how* the seed arrived.
*
* Implementations live next to this file: `qr-source.ts` (camera + jsQR),
* `nfc-source.ts` (Web NFC scaffold). A HAL-scanner source can be added the
* same way without touching the wizard.
*/
export type PairingSourceKind = 'qr' | 'nfc'
export interface PairingSourceStartOptions {
/** Invoked with each decoded payload (the raw seed string). */
onScan: (raw: string) => void
/** Invoked on a non-fatal capture error (e.g. a frame decode glitch). */
onError?: (error: unknown) => void
/**
* The <video> element the camera preview renders into. Required by
* camera-based sources; ignored by sources that don't show a viewfinder
* (e.g. NFC).
*/
video?: HTMLVideoElement
}
/** Releases capture hardware (camera stream, NFC reader). Idempotent. */
export type StopCapture = () => void
export interface PairingSource {
readonly kind: PairingSourceKind
/** Short label for the wizard's source picker (e.g. "Camera", "NFC tap"). */
readonly label: string
/** Whether this source can run in the current environment. */
isAvailable(): Promise<boolean>
/** Begin capturing; resolves once hardware is live. */
start(opts: PairingSourceStartOptions): Promise<StopCapture>
}

View file

@ -31,6 +31,20 @@ import type { BunkerBindingRecord } from '@/types/electron'
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
/**
* Thrown in strict mode when the machine has no seed and no binding — it is
* genuinely unpaired, not misconfigured. The renderer catches this to show the
* QR-pairing wizard (camera scan of a spire-seed) rather than a fault screen.
* Distinct `.name` so it survives the bundle boundary (instanceof is fragile
* across the electron/renderer split). See services/init-error.ts.
*/
export class NoPairingError extends Error {
override readonly name = 'NoPairingError'
constructor() {
super('[Signer] Machine is unpaired — no spire seed and no bunker binding.')
}
}
export interface ResolveSignerOptions { export interface ResolveSignerOptions {
/** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */ /** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */
allowEphemeral: boolean allowEphemeral: boolean
@ -109,8 +123,5 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
return new LocalSigner(generateIdentity()) return new LocalSigner(generateIdentity())
} }
throw new Error( throw new NoPairingError()
'[Signer] No spire seed and no bunker binding — cannot resolve a signing identity (strict mode). ' +
'Set VITE_SPIRE_SEED or pair the ATM.'
)
} }

View file

@ -98,6 +98,8 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> }, payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number eventCreatedAt: number

View file

@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: {
inherit (finalAttrs) pname version src pnpmWorkspaces; inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm; inherit pnpm;
fetcherVersion = 3; fetcherVersion = 3;
hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw="; hash = "sha256-03ANBQ7bHJwsqlX2ScA1+1LFuO8njiuU7O4VGOb6cMM=";
}; };
nativeBuildInputs = [ nativeBuildInputs = [

279
pnpm-lock.yaml generated
View file

@ -65,6 +65,9 @@ importers:
pinia: pinia:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) version: 2.3.1(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
qr:
specifier: ^0.6.0
version: 0.6.0
qrcode.vue: qrcode.vue:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0(vue@3.5.27(typescript@5.9.3)) version: 3.6.0(vue@3.5.27(typescript@5.9.3))
@ -353,12 +356,6 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [aix] os: [aix]
'@esbuild/aix-ppc64@0.27.2':
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.27.4': '@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -377,12 +374,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@esbuild/android-arm64@0.27.2':
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.27.4': '@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -401,12 +392,6 @@ packages:
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@esbuild/android-arm@0.27.2':
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.27.4': '@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -425,12 +410,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [android] os: [android]
'@esbuild/android-x64@0.27.2':
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.27.4': '@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -449,12 +428,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@esbuild/darwin-arm64@0.27.2':
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.27.4': '@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -473,12 +446,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@esbuild/darwin-x64@0.27.2':
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4': '@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -497,12 +464,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@esbuild/freebsd-arm64@0.27.2':
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.27.4': '@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -521,12 +482,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@esbuild/freebsd-x64@0.27.2':
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4': '@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -545,12 +500,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@esbuild/linux-arm64@0.27.2':
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.27.4': '@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -569,12 +518,6 @@ packages:
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@esbuild/linux-arm@0.27.2':
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.27.4': '@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -593,12 +536,6 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [linux] os: [linux]
'@esbuild/linux-ia32@0.27.2':
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.27.4': '@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -617,12 +554,6 @@ packages:
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@esbuild/linux-loong64@0.27.2':
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.27.4': '@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -641,12 +572,6 @@ packages:
cpu: [mips64el] cpu: [mips64el]
os: [linux] os: [linux]
'@esbuild/linux-mips64el@0.27.2':
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.27.4': '@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -665,12 +590,6 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@esbuild/linux-ppc64@0.27.2':
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.27.4': '@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -689,12 +608,6 @@ packages:
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@esbuild/linux-riscv64@0.27.2':
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4': '@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -713,12 +626,6 @@ packages:
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@esbuild/linux-s390x@0.27.2':
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.27.4': '@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -737,12 +644,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@esbuild/linux-x64@0.27.2':
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.27.4': '@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -755,12 +656,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [netbsd] os: [netbsd]
'@esbuild/netbsd-arm64@0.27.2':
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.27.4': '@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -779,12 +674,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [netbsd] os: [netbsd]
'@esbuild/netbsd-x64@0.27.2':
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4': '@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -797,12 +686,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openbsd] os: [openbsd]
'@esbuild/openbsd-arm64@0.27.2':
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.27.4': '@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -821,12 +704,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [openbsd] os: [openbsd]
'@esbuild/openbsd-x64@0.27.2':
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4': '@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -839,12 +716,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@esbuild/openharmony-arm64@0.27.2':
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/openharmony-arm64@0.27.4': '@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -863,12 +734,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [sunos] os: [sunos]
'@esbuild/sunos-x64@0.27.2':
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.27.4': '@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -887,12 +752,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@esbuild/win32-arm64@0.27.2':
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.27.4': '@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -911,12 +770,6 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@esbuild/win32-ia32@0.27.2':
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.27.4': '@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -935,12 +788,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@esbuild/win32-x64@0.27.2':
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.27.4': '@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2049,11 +1896,6 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.27.4: esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2860,6 +2702,10 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qr@0.6.0:
resolution: {integrity: sha512-P23VoX7SipHALdiIYG+D+LT/6n22dNKwV92FAb3d+Nlki/5WisSsfLt0UDFz2XEBtuwrECTznvu+chKKFCSYhA==}
engines: {node: '>= 20.19.0'}
qrcode.vue@3.6.0: qrcode.vue@3.6.0:
resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==} resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
peerDependencies: peerDependencies:
@ -3579,9 +3425,6 @@ snapshots:
'@esbuild/aix-ppc64@0.25.12': '@esbuild/aix-ppc64@0.25.12':
optional: true optional: true
'@esbuild/aix-ppc64@0.27.2':
optional: true
'@esbuild/aix-ppc64@0.27.4': '@esbuild/aix-ppc64@0.27.4':
optional: true optional: true
@ -3591,9 +3434,6 @@ snapshots:
'@esbuild/android-arm64@0.25.12': '@esbuild/android-arm64@0.25.12':
optional: true optional: true
'@esbuild/android-arm64@0.27.2':
optional: true
'@esbuild/android-arm64@0.27.4': '@esbuild/android-arm64@0.27.4':
optional: true optional: true
@ -3603,9 +3443,6 @@ snapshots:
'@esbuild/android-arm@0.25.12': '@esbuild/android-arm@0.25.12':
optional: true optional: true
'@esbuild/android-arm@0.27.2':
optional: true
'@esbuild/android-arm@0.27.4': '@esbuild/android-arm@0.27.4':
optional: true optional: true
@ -3615,9 +3452,6 @@ snapshots:
'@esbuild/android-x64@0.25.12': '@esbuild/android-x64@0.25.12':
optional: true optional: true
'@esbuild/android-x64@0.27.2':
optional: true
'@esbuild/android-x64@0.27.4': '@esbuild/android-x64@0.27.4':
optional: true optional: true
@ -3627,9 +3461,6 @@ snapshots:
'@esbuild/darwin-arm64@0.25.12': '@esbuild/darwin-arm64@0.25.12':
optional: true optional: true
'@esbuild/darwin-arm64@0.27.2':
optional: true
'@esbuild/darwin-arm64@0.27.4': '@esbuild/darwin-arm64@0.27.4':
optional: true optional: true
@ -3639,9 +3470,6 @@ snapshots:
'@esbuild/darwin-x64@0.25.12': '@esbuild/darwin-x64@0.25.12':
optional: true optional: true
'@esbuild/darwin-x64@0.27.2':
optional: true
'@esbuild/darwin-x64@0.27.4': '@esbuild/darwin-x64@0.27.4':
optional: true optional: true
@ -3651,9 +3479,6 @@ snapshots:
'@esbuild/freebsd-arm64@0.25.12': '@esbuild/freebsd-arm64@0.25.12':
optional: true optional: true
'@esbuild/freebsd-arm64@0.27.2':
optional: true
'@esbuild/freebsd-arm64@0.27.4': '@esbuild/freebsd-arm64@0.27.4':
optional: true optional: true
@ -3663,9 +3488,6 @@ snapshots:
'@esbuild/freebsd-x64@0.25.12': '@esbuild/freebsd-x64@0.25.12':
optional: true optional: true
'@esbuild/freebsd-x64@0.27.2':
optional: true
'@esbuild/freebsd-x64@0.27.4': '@esbuild/freebsd-x64@0.27.4':
optional: true optional: true
@ -3675,9 +3497,6 @@ snapshots:
'@esbuild/linux-arm64@0.25.12': '@esbuild/linux-arm64@0.25.12':
optional: true optional: true
'@esbuild/linux-arm64@0.27.2':
optional: true
'@esbuild/linux-arm64@0.27.4': '@esbuild/linux-arm64@0.27.4':
optional: true optional: true
@ -3687,9 +3506,6 @@ snapshots:
'@esbuild/linux-arm@0.25.12': '@esbuild/linux-arm@0.25.12':
optional: true optional: true
'@esbuild/linux-arm@0.27.2':
optional: true
'@esbuild/linux-arm@0.27.4': '@esbuild/linux-arm@0.27.4':
optional: true optional: true
@ -3699,9 +3515,6 @@ snapshots:
'@esbuild/linux-ia32@0.25.12': '@esbuild/linux-ia32@0.25.12':
optional: true optional: true
'@esbuild/linux-ia32@0.27.2':
optional: true
'@esbuild/linux-ia32@0.27.4': '@esbuild/linux-ia32@0.27.4':
optional: true optional: true
@ -3711,9 +3524,6 @@ snapshots:
'@esbuild/linux-loong64@0.25.12': '@esbuild/linux-loong64@0.25.12':
optional: true optional: true
'@esbuild/linux-loong64@0.27.2':
optional: true
'@esbuild/linux-loong64@0.27.4': '@esbuild/linux-loong64@0.27.4':
optional: true optional: true
@ -3723,9 +3533,6 @@ snapshots:
'@esbuild/linux-mips64el@0.25.12': '@esbuild/linux-mips64el@0.25.12':
optional: true optional: true
'@esbuild/linux-mips64el@0.27.2':
optional: true
'@esbuild/linux-mips64el@0.27.4': '@esbuild/linux-mips64el@0.27.4':
optional: true optional: true
@ -3735,9 +3542,6 @@ snapshots:
'@esbuild/linux-ppc64@0.25.12': '@esbuild/linux-ppc64@0.25.12':
optional: true optional: true
'@esbuild/linux-ppc64@0.27.2':
optional: true
'@esbuild/linux-ppc64@0.27.4': '@esbuild/linux-ppc64@0.27.4':
optional: true optional: true
@ -3747,9 +3551,6 @@ snapshots:
'@esbuild/linux-riscv64@0.25.12': '@esbuild/linux-riscv64@0.25.12':
optional: true optional: true
'@esbuild/linux-riscv64@0.27.2':
optional: true
'@esbuild/linux-riscv64@0.27.4': '@esbuild/linux-riscv64@0.27.4':
optional: true optional: true
@ -3759,9 +3560,6 @@ snapshots:
'@esbuild/linux-s390x@0.25.12': '@esbuild/linux-s390x@0.25.12':
optional: true optional: true
'@esbuild/linux-s390x@0.27.2':
optional: true
'@esbuild/linux-s390x@0.27.4': '@esbuild/linux-s390x@0.27.4':
optional: true optional: true
@ -3771,18 +3569,12 @@ snapshots:
'@esbuild/linux-x64@0.25.12': '@esbuild/linux-x64@0.25.12':
optional: true optional: true
'@esbuild/linux-x64@0.27.2':
optional: true
'@esbuild/linux-x64@0.27.4': '@esbuild/linux-x64@0.27.4':
optional: true optional: true
'@esbuild/netbsd-arm64@0.25.12': '@esbuild/netbsd-arm64@0.25.12':
optional: true optional: true
'@esbuild/netbsd-arm64@0.27.2':
optional: true
'@esbuild/netbsd-arm64@0.27.4': '@esbuild/netbsd-arm64@0.27.4':
optional: true optional: true
@ -3792,18 +3584,12 @@ snapshots:
'@esbuild/netbsd-x64@0.25.12': '@esbuild/netbsd-x64@0.25.12':
optional: true optional: true
'@esbuild/netbsd-x64@0.27.2':
optional: true
'@esbuild/netbsd-x64@0.27.4': '@esbuild/netbsd-x64@0.27.4':
optional: true optional: true
'@esbuild/openbsd-arm64@0.25.12': '@esbuild/openbsd-arm64@0.25.12':
optional: true optional: true
'@esbuild/openbsd-arm64@0.27.2':
optional: true
'@esbuild/openbsd-arm64@0.27.4': '@esbuild/openbsd-arm64@0.27.4':
optional: true optional: true
@ -3813,18 +3599,12 @@ snapshots:
'@esbuild/openbsd-x64@0.25.12': '@esbuild/openbsd-x64@0.25.12':
optional: true optional: true
'@esbuild/openbsd-x64@0.27.2':
optional: true
'@esbuild/openbsd-x64@0.27.4': '@esbuild/openbsd-x64@0.27.4':
optional: true optional: true
'@esbuild/openharmony-arm64@0.25.12': '@esbuild/openharmony-arm64@0.25.12':
optional: true optional: true
'@esbuild/openharmony-arm64@0.27.2':
optional: true
'@esbuild/openharmony-arm64@0.27.4': '@esbuild/openharmony-arm64@0.27.4':
optional: true optional: true
@ -3834,9 +3614,6 @@ snapshots:
'@esbuild/sunos-x64@0.25.12': '@esbuild/sunos-x64@0.25.12':
optional: true optional: true
'@esbuild/sunos-x64@0.27.2':
optional: true
'@esbuild/sunos-x64@0.27.4': '@esbuild/sunos-x64@0.27.4':
optional: true optional: true
@ -3846,9 +3623,6 @@ snapshots:
'@esbuild/win32-arm64@0.25.12': '@esbuild/win32-arm64@0.25.12':
optional: true optional: true
'@esbuild/win32-arm64@0.27.2':
optional: true
'@esbuild/win32-arm64@0.27.4': '@esbuild/win32-arm64@0.27.4':
optional: true optional: true
@ -3858,9 +3632,6 @@ snapshots:
'@esbuild/win32-ia32@0.25.12': '@esbuild/win32-ia32@0.25.12':
optional: true optional: true
'@esbuild/win32-ia32@0.27.2':
optional: true
'@esbuild/win32-ia32@0.27.4': '@esbuild/win32-ia32@0.27.4':
optional: true optional: true
@ -3870,9 +3641,6 @@ snapshots:
'@esbuild/win32-x64@0.25.12': '@esbuild/win32-x64@0.25.12':
optional: true optional: true
'@esbuild/win32-x64@0.27.2':
optional: true
'@esbuild/win32-x64@0.27.4': '@esbuild/win32-x64@0.27.4':
optional: true optional: true
@ -5133,35 +4901,6 @@ snapshots:
'@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-ia32': 0.25.12
'@esbuild/win32-x64': 0.25.12 '@esbuild/win32-x64': 0.25.12
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
'@esbuild/android-arm': 0.27.2
'@esbuild/android-arm64': 0.27.2
'@esbuild/android-x64': 0.27.2
'@esbuild/darwin-arm64': 0.27.2
'@esbuild/darwin-x64': 0.27.2
'@esbuild/freebsd-arm64': 0.27.2
'@esbuild/freebsd-x64': 0.27.2
'@esbuild/linux-arm': 0.27.2
'@esbuild/linux-arm64': 0.27.2
'@esbuild/linux-ia32': 0.27.2
'@esbuild/linux-loong64': 0.27.2
'@esbuild/linux-mips64el': 0.27.2
'@esbuild/linux-ppc64': 0.27.2
'@esbuild/linux-riscv64': 0.27.2
'@esbuild/linux-s390x': 0.27.2
'@esbuild/linux-x64': 0.27.2
'@esbuild/netbsd-arm64': 0.27.2
'@esbuild/netbsd-x64': 0.27.2
'@esbuild/openbsd-arm64': 0.27.2
'@esbuild/openbsd-x64': 0.27.2
'@esbuild/openharmony-arm64': 0.27.2
'@esbuild/sunos-x64': 0.27.2
'@esbuild/win32-arm64': 0.27.2
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
esbuild@0.27.4: esbuild@0.27.4:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4 '@esbuild/aix-ppc64': 0.27.4
@ -5982,6 +5721,8 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qr@0.6.0: {}
qrcode.vue@3.6.0(vue@3.5.27(typescript@5.9.3)): qrcode.vue@3.6.0(vue@3.5.27(typescript@5.9.3)):
dependencies: dependencies:
vue: 3.5.27(typescript@5.9.3) vue: 3.5.27(typescript@5.9.3)
@ -6346,7 +6087,7 @@ snapshots:
tsx@4.21.0: tsx@4.21.0:
dependencies: dependencies:
esbuild: 0.27.2 esbuild: 0.27.4
get-tsconfig: 4.13.0 get-tsconfig: 4.13.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3