Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
/**
* Authenticated fetch wrapper for dev API endpoints.
* Adds Authorization: Bearer <token> header when VITE_DEV_API_TOKEN is set.
*/
const DEV_TOKEN = import.meta.env.VITE_DEV_API_TOKEN as string | undefined
export function apiFetch(url: string, init?: RequestInit): Promise<Response> {
if (DEV_TOKEN) {
const headers = new Headers(init?.headers)
headers.set('Authorization', `Bearer ${DEV_TOKEN}`)
return fetch(url, { ...init, headers })
}
return fetch(url, init)
}
+163
View File
@@ -0,0 +1,163 @@
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
const CHARSET_MAP: Record<string, number> = {}
for (let i = 0; i < CHARSET.length; i++) CHARSET_MAP[CHARSET[i]] = i
function polymod(values: number[]): number {
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
let chk = 1
for (const v of values) {
const b = chk >> 25
chk = ((chk & 0x1ffffff) << 5) ^ v
for (let i = 0; i < 5; i++) {
if ((b >> i) & 1) chk ^= GEN[i]
}
}
return chk
}
function hrpExpand(hrp: string): number[] {
const ret: number[] = []
for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5)
ret.push(0)
for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31)
return ret
}
function convertBits(data: number[], fromBits: number, toBits: number, pad: boolean): number[] | null {
let acc = 0
let bits = 0
const ret: number[] = []
const maxv = (1 << toBits) - 1
for (const value of data) {
if (value < 0 || value >> fromBits) return null
acc = (acc << fromBits) | value
bits += fromBits
while (bits >= toBits) {
bits -= toBits
ret.push((acc >> bits) & maxv)
}
}
if (pad) {
if (bits > 0) ret.push((acc << (toBits - bits)) & maxv)
} else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv)) {
return null
}
return ret
}
export interface Bech32Decoded {
hrp: string
data: Uint8Array
}
export function bech32Decode(str: string): Bech32Decoded | null {
const lower = str.toLowerCase()
const sepPos = lower.lastIndexOf('1')
if (sepPos < 1 || sepPos + 7 > lower.length || lower.length > 90) return null
const hrp = lower.slice(0, sepPos)
const dataChars = lower.slice(sepPos + 1)
const values: number[] = []
for (const c of dataChars) {
const v = CHARSET_MAP[c]
if (v === undefined) return null
values.push(v)
}
if (polymod([...hrpExpand(hrp), ...values]) !== 1) return null
const data5bit = values.slice(0, -6)
const bytes = convertBits(data5bit, 5, 8, false)
if (!bytes) return null
return { hrp, data: new Uint8Array(bytes) }
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
function createChecksum(hrp: string, data: number[]): number[] {
const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]
const mod = polymod(values) ^ 1
const ret: number[] = []
for (let i = 0; i < 6; i++) ret.push((mod >> (5 * (5 - i))) & 31)
return ret
}
export function bech32Encode(hrp: string, data: Uint8Array): string {
const fiveBit = convertBits(Array.from(data), 8, 5, true)
if (!fiveBit) throw new Error('Failed to convert bits')
const checksum = createChecksum(hrp, fiveBit)
return hrp + '1' + [...fiveBit, ...checksum].map(d => CHARSET[d]).join('')
}
/** Encode a hex public key as npub1... */
export function encodeNpub(hexPubkey: string): string {
return bech32Encode('npub', hexToBytes(hexPubkey))
}
/** Decode an npub1... string to a hex public key */
export function decodeNpub(npubStr: string): string {
const decoded = bech32Decode(npubStr)
if (!decoded || decoded.hrp !== 'npub' || decoded.data.length !== 32) {
throw new Error('Invalid npub')
}
return bytesToHex(decoded.data)
}
export interface NIP19Decoded {
type: 'npub' | 'note' | 'nevent' | 'nprofile' | 'unknown'
hex: string
relays?: string[]
}
export function decodeNIP19(bech32Str: string): NIP19Decoded | null {
const decoded = bech32Decode(bech32Str)
if (!decoded) return null
const { hrp, data } = decoded
if (hrp === 'npub' && data.length === 32) {
return { type: 'npub', hex: bytesToHex(data) }
}
if (hrp === 'note' && data.length === 32) {
return { type: 'note', hex: bytesToHex(data) }
}
// TLV decoding for nevent/nprofile
if (hrp === 'nevent' || hrp === 'nprofile') {
let hex = ''
const relays: string[] = []
let i = 0
while (i < data.length) {
const tag = data[i]
const len = data[i + 1]
if (len === undefined) break
const value = data.slice(i + 2, i + 2 + len)
if (tag === 0) hex = bytesToHex(value)
if (tag === 1) relays.push(new TextDecoder().decode(value))
i += 2 + len
}
return {
type: hrp === 'nevent' ? 'nevent' : 'nprofile',
hex,
relays: relays.length > 0 ? relays : undefined,
}
}
return { type: 'unknown', hex: bytesToHex(data) }
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Cashu ecash token parsing and display utilities.
* AIUI is never a wallet — only parses, displays, and deep-links to external wallets.
*/
interface CashuProof {
amount: number
id: string
secret: string
C: string
}
interface CashuTokenEntry {
mint: string
proofs: CashuProof[]
}
interface CashuTokenData {
token: CashuTokenEntry[]
unit?: string
memo?: string
}
export interface ParsedCashuToken {
mint: string
amount: number
unit: string
memo?: string
raw: string
}
/** Check if a string looks like a Cashu token */
export function isCashuToken(text: string): boolean {
return text.trim().startsWith('cashuA')
}
/** Extract Cashu tokens from a text string */
export function extractCashuTokens(text: string): string[] {
const regex = /cashuA[A-Za-z0-9_-]+/g
return [...text.matchAll(regex)].map(m => m[0])
}
/** Parse a Cashu token string (cashuA...) into structured data */
export function parseCashuToken(token: string): ParsedCashuToken | null {
if (!isCashuToken(token)) return null
try {
// Remove 'cashuA' prefix and decode base64url
const encoded = token.slice(6)
const padded = encoded.replace(/-/g, '+').replace(/_/g, '/')
const json = atob(padded)
const data = JSON.parse(json) as CashuTokenData
if (!data.token || !Array.isArray(data.token) || data.token.length === 0) {
return null
}
const entry = data.token[0]
const totalAmount = entry.proofs.reduce((sum, p) => sum + p.amount, 0)
return {
mint: entry.mint,
amount: totalAmount,
unit: data.unit ?? 'sat',
memo: data.memo,
raw: token,
}
} catch {
return null
}
}
/** Format a mint URL for display (truncate) */
export function formatMintUrl(url: string): string {
try {
const parsed = new URL(url)
const host = parsed.hostname
if (host.length > 30) {
return host.slice(0, 15) + '...' + host.slice(-12)
}
return host
} catch {
if (url.length > 30) {
return url.slice(0, 15) + '...' + url.slice(-12)
}
return url
}
}
/** Format amount with unit */
export function formatCashuAmount(amount: number, unit: string): string {
if (unit === 'sat' || unit === 'sats') {
if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(2)}M sats`
if (amount >= 1_000) return `${(amount / 1_000).toFixed(amount >= 10_000 ? 0 : 1)}k sats`
return `${amount} sats`
}
return `${amount} ${unit}`
}
@@ -0,0 +1,90 @@
import type { Conversation } from '@aiui/core/types/message'
export type ExportFormat = 'markdown' | 'json' | 'text'
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleString()
}
export function exportAsMarkdown(conv: Conversation): string {
const lines = [`# ${conv.title}\n`, `_Exported ${formatTimestamp(Date.now())}_\n`]
for (const msg of conv.messages) {
const role = msg.role === 'user' ? '**You**' : '**Assistant**'
const time = formatTimestamp(msg.timestamp)
lines.push(`### ${role}${time}\n`)
lines.push(msg.content + '\n')
}
return lines.join('\n')
}
export function exportAsJSON(conv: Conversation): string {
return JSON.stringify(conv, null, 2)
}
export function exportAsText(conv: Conversation): string {
const lines = [conv.title, '='.repeat(conv.title.length), '']
for (const msg of conv.messages) {
const role = msg.role === 'user' ? 'You' : 'Assistant'
lines.push(`[${role}] ${formatTimestamp(msg.timestamp)}`)
lines.push(msg.content)
lines.push('')
}
return lines.join('\n')
}
function getExtension(format: ExportFormat): string {
switch (format) {
case 'markdown': return '.md'
case 'json': return '.json'
case 'text': return '.txt'
}
}
function getMimeType(format: ExportFormat): string {
switch (format) {
case 'markdown': return 'text/markdown'
case 'json': return 'application/json'
case 'text': return 'text/plain'
}
}
export async function downloadConversation(conv: Conversation, format: ExportFormat): Promise<void> {
let content: string
switch (format) {
case 'markdown': content = exportAsMarkdown(conv); break
case 'json': content = exportAsJSON(conv); break
case 'text': content = exportAsText(conv); break
}
const filename = `${conv.title.replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/\s+/g, '-').toLowerCase()}${getExtension(format)}`
const blob = new Blob([content], { type: getMimeType(format) })
// Try File System Access API first
if ('showSaveFilePicker' in window) {
try {
const handle = await (window as unknown as { showSaveFilePicker: (opts: unknown) => Promise<FileSystemFileHandle> }).showSaveFilePicker({
suggestedName: filename,
types: [{
description: format === 'markdown' ? 'Markdown' : format === 'json' ? 'JSON' : 'Text',
accept: { [getMimeType(format)]: [getExtension(format)] },
}],
})
const writable = await handle.createWritable()
await writable.write(blob)
await writable.close()
return
} catch {
// User cancelled or API not supported — fall through to download
}
}
// Fallback: <a download>
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
@@ -0,0 +1,98 @@
import type { Conversation, Message } from '@aiui/core/types/message'
function generateId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
interface ImportResult {
conversations: Conversation[]
format: 'aiui' | 'claude' | 'unknown'
error?: string
}
/** Try to parse as AIUI JSON export (single conversation) */
function parseAIUIFormat(data: unknown): Conversation | null {
if (!data || typeof data !== 'object') return null
const obj = data as Record<string, unknown>
if (typeof obj.id === 'string' && typeof obj.title === 'string' && Array.isArray(obj.messages)) {
return obj as unknown as Conversation
}
return null
}
/** Try to parse Claude.ai export format */
function parseClaudeFormat(data: unknown): Conversation[] {
if (!Array.isArray(data)) return []
const conversations: Conversation[] = []
for (const item of data) {
if (!item || typeof item !== 'object') continue
const obj = item as Record<string, unknown>
// Claude.ai exports have { uuid, name, chat_messages: [...] }
if (typeof obj.uuid === 'string' && typeof obj.name === 'string' && Array.isArray(obj.chat_messages)) {
const messages: Message[] = []
for (const cm of obj.chat_messages as Record<string, unknown>[]) {
if (!cm || typeof cm !== 'object') continue
const role = cm.sender === 'human' ? 'user' as const : 'assistant' as const
const content = typeof cm.text === 'string' ? cm.text : ''
messages.push({
id: generateId(),
role,
content,
timestamp: typeof cm.created_at === 'string' ? new Date(cm.created_at as string).getTime() : Date.now(),
})
}
conversations.push({
id: generateId(),
title: obj.name as string,
messages,
createdAt: typeof obj.created_at === 'string' ? new Date(obj.created_at as string).getTime() : Date.now(),
updatedAt: typeof obj.updated_at === 'string' ? new Date(obj.updated_at as string).getTime() : Date.now(),
})
}
}
return conversations
}
export function parseImportFile(jsonString: string): ImportResult {
try {
const data = JSON.parse(jsonString)
// Try AIUI single conversation
const aiui = parseAIUIFormat(data)
if (aiui) {
return { conversations: [aiui], format: 'aiui' }
}
// Try Claude.ai export (array of conversations)
const claude = parseClaudeFormat(data)
if (claude.length > 0) {
return { conversations: claude, format: 'claude' }
}
// Try AIUI array format
if (Array.isArray(data)) {
const aiuiConvs: Conversation[] = []
for (const item of data) {
const c = parseAIUIFormat(item)
if (c) aiuiConvs.push(c)
}
if (aiuiConvs.length > 0) {
return { conversations: aiuiConvs, format: 'aiui' }
}
}
return { conversations: [], format: 'unknown', error: 'Unrecognized format' }
} catch {
return { conversations: [], format: 'unknown', error: 'Invalid JSON' }
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* AES-256-GCM encryption utilities using Web Crypto API.
* PBKDF2 key derivation with 100K+ iterations.
*/
const PBKDF2_ITERATIONS = 100_000
const SALT_LENGTH = 16
const IV_LENGTH = 12
export async function generateSalt(): Promise<Uint8Array> {
return crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
}
export async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
'raw',
enc.encode(password),
'PBKDF2',
false,
['deriveKey'],
)
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
)
}
export interface EncryptedPayload {
ciphertext: ArrayBuffer
iv: Uint8Array
}
export async function encrypt(data: string, key: CryptoKey): Promise<EncryptedPayload> {
const enc = new TextEncoder()
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
enc.encode(data),
)
return { ciphertext, iv }
}
export async function decrypt(
ciphertext: ArrayBuffer,
iv: Uint8Array,
key: CryptoKey,
): Promise<string> {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext,
)
return new TextDecoder().decode(plaintext)
}
/**
* Convenience: encrypt a string and return a storable format.
* Returns base64-encoded JSON with iv + ciphertext.
*/
export async function encryptToString(data: string, key: CryptoKey): Promise<string> {
const { ciphertext, iv } = await encrypt(data, key)
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv)
combined.set(new Uint8Array(ciphertext), iv.length)
return bufferToBase64(combined)
}
/**
* Convenience: decrypt a base64-encoded encrypted string.
*/
export async function decryptFromString(encoded: string, key: CryptoKey): Promise<string> {
const combined = base64ToBuffer(encoded)
const iv = combined.slice(0, IV_LENGTH)
const ciphertext = combined.slice(IV_LENGTH)
return decrypt(ciphertext.buffer, iv, key)
}
function bufferToBase64(buffer: Uint8Array): string {
let binary = ''
for (let i = 0; i < buffer.length; i++) {
binary += String.fromCharCode(buffer[i])
}
return btoa(binary)
}
function base64ToBuffer(base64: string): Uint8Array {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/**
* Check if encryption should be enabled.
* Disabled in dev mode with VITE_DISABLE_CRYPTO=true, or when
* Web Crypto API is unavailable (HTTP on non-localhost origins).
*/
export function isCryptoEnabled(): boolean {
try {
if (import.meta.env.VITE_DISABLE_CRYPTO === 'true') return false
// crypto.subtle is only available in secure contexts (HTTPS or localhost)
if (typeof globalThis.crypto?.subtle === 'undefined') return false
return true
} catch {
return false
}
}
/**
* Session key management — held in memory only.
*/
let sessionKey: CryptoKey | null = null
let sessionSalt: Uint8Array | null = null
export function setSessionKey(key: CryptoKey, salt: Uint8Array): void {
sessionKey = key
sessionSalt = salt
}
export function getSessionKey(): CryptoKey | null {
return sessionKey
}
export function getSessionSalt(): Uint8Array | null {
return sessionSalt
}
export function clearSessionKey(): void {
sessionKey = null
sessionSalt = null
}
export function hasSessionKey(): boolean {
return sessionKey !== null
}
@@ -0,0 +1,11 @@
/**
* Demo-content gate (operator decision 2026-08-07): seed/mock showcase
* conversations exist for the demo.archipelago-foundation.org website ONLY
* and must not ship in the node build. Demo builds set
* `VITE_DEMO_CONTENT=true`; local dev keeps them for development.
*
* Anything behind this flag is fabricated example content — never present
* it as real node data in a shipped build.
*/
export const DEMO_CONTENT_ENABLED =
import.meta.env.DEV || import.meta.env.VITE_DEMO_CONTENT === 'true'
+40
View File
@@ -0,0 +1,40 @@
/** Check if a URL has a safe http(s) protocol */
export function isSafeImgSrc(src: string): boolean {
try {
const u = new URL(src)
return /^https?:$/i.test(u.protocol)
} catch {
return false
}
}
/** Type-guard variant: check if value is a defined, safe http(s) URL */
export function isSafeUrl(u: string | undefined): u is string {
return !!u && typeof u === 'string' && /^https?:\/\//i.test(u.trim())
}
import DOMPurify from 'dompurify'
const ALLOWED_TAGS_LIST = ['p', 'br', 'a', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'span', 'div']
/** Sanitize HTML using DOMPurify with restricted tag set */
export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ALLOWED_TAGS_LIST,
ALLOWED_ATTR: ['href', 'src', 'target', 'rel'],
})
}
/** Escape HTML entities for safe rendering in a text context */
export function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/** Extract a domain from a URL, stripping www prefix */
export function formatDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { Conversation } from '@aiui/core/types/message'
import {
isCryptoEnabled,
getSessionKey,
encryptToString,
decryptFromString,
} from './crypto'
import { toRaw } from 'vue'
const DB_NAME = 'aiui-store'
const DB_VERSION = 1
const STORE_NAME = 'conversations'
let dbPromise: Promise<IDBDatabase> | null = null
export function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
store.createIndex('updatedAt', 'updatedAt', { unique: false })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => {
dbPromise = null
reject(request.error)
}
})
return dbPromise
}
interface EncryptedRecord {
id: string
updatedAt: number
encrypted: string
}
async function encryptConversation(conv: Conversation): Promise<EncryptedRecord | Conversation> {
const key = getSessionKey()
if (!isCryptoEnabled() || !key) return conv
const encrypted = await encryptToString(JSON.stringify(conv), key)
return { id: conv.id, updatedAt: conv.updatedAt, encrypted }
}
async function decryptConversation(record: EncryptedRecord | Conversation): Promise<Conversation | null> {
if (!('encrypted' in record)) return record as Conversation
const key = getSessionKey()
if (!key) return null
try {
const json = await decryptFromString(record.encrypted, key)
return JSON.parse(json) as Conversation
} catch {
return null // Wrong passphrase or corrupted data
}
}
/**
* Deep-unwrap Vue reactive proxies before storing in IDB.
* structuredClone on a Proxy can silently produce empty objects or throw.
*/
function toPlain(conv: Conversation): Conversation {
return JSON.parse(JSON.stringify(toRaw(conv)))
}
export async function saveConversation(conv: Conversation): Promise<void> {
const db = await openDB()
const plain = toPlain(conv)
const record = await encryptConversation(plain)
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(record)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
export async function loadAllConversations(): Promise<Map<string, Conversation>> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const request = tx.objectStore(STORE_NAME).index('updatedAt').getAll()
request.onsuccess = async () => {
const map = new Map<string, Conversation>()
for (const record of request.result) {
const conv = await decryptConversation(record)
if (conv) map.set(conv.id, conv)
}
resolve(map)
}
request.onerror = () => reject(request.error)
})
}
export async function deleteConversation(id: string): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(id)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
export function isIDBAvailable(): boolean {
return typeof indexedDB !== 'undefined'
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Encrypted API key vault using IndexedDB + AES-256-GCM.
* Keys are encrypted with the session key derived from passphrase.
* Refuses to store keys when encryption is unavailable.
*/
import {
isCryptoEnabled,
getSessionKey,
encryptToString,
decryptFromString,
} from './crypto'
const DB_NAME = 'aiui-vault'
const DB_VERSION = 1
const STORE_NAME = 'api-keys'
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'provider' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
interface VaultRecord {
provider: string
value: string // encrypted or plaintext API key
encrypted: boolean
}
export async function storeApiKey(provider: string, key: string): Promise<void> {
if (typeof indexedDB === 'undefined') return
const db = await openDB()
const sessionKey = getSessionKey()
if (!isCryptoEnabled() || !sessionKey) {
throw new Error('Encryption required: cannot store API keys without an active session key. Please set a passphrase first.')
}
const value = await encryptToString(key, sessionKey)
const record: VaultRecord = { provider, value, encrypted: true }
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(record)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
export async function getApiKey(provider: string): Promise<string | null> {
if (typeof indexedDB === 'undefined') return null
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(provider)
req.onsuccess = async () => {
const record = req.result as VaultRecord | undefined
if (!record) {
resolve(null)
return
}
if (record.encrypted) {
const sessionKey = getSessionKey()
if (!sessionKey) {
resolve(null)
return
}
try {
const decrypted = await decryptFromString(record.value, sessionKey)
resolve(decrypted)
} catch {
resolve(null)
}
} else {
resolve(record.value)
}
}
req.onerror = () => reject(req.error)
})
}
export async function deleteApiKey(provider: string): Promise<void> {
if (typeof indexedDB === 'undefined') return
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(provider)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
export async function listProviders(): Promise<string[]> {
if (typeof indexedDB === 'undefined') return []
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).getAllKeys()
req.onsuccess = () => resolve(req.result as string[])
req.onerror = () => reject(req.error)
})
}
export function maskApiKey(key: string): string {
if (key.length <= 4) return '****'
return '****' + key.slice(-4)
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Lightning Network utilities for deep-linking to external wallets.
* AIUI is never a wallet — only generates URIs to open external wallets.
*/
/** Parse a BOLT11 invoice to extract amount (if present) and expiry */
export function parseBolt11(invoice: string): {
amount: number | null
expiry: number | null
timestamp: number | null
} {
const lower = invoice.toLowerCase()
if (!lower.startsWith('lnbc') && !lower.startsWith('lntb') && !lower.startsWith('lnbcrt')) {
return { amount: null, expiry: null, timestamp: null }
}
// Extract amount from human-readable part (after ln prefix, before '1' separator)
const hrpMatch = lower.match(/^ln(?:bc|tb|bcrt)(\d+)([munp]?)/)
let amount: number | null = null
if (hrpMatch && hrpMatch[1]) {
const num = parseInt(hrpMatch[1], 10)
const multiplier = hrpMatch[2]
// Convert to millisats then to sats
const btcMultipliers: Record<string, number> = {
'': 1e8, // BTC to sats
m: 1e5, // milli-BTC to sats
u: 1e2, // micro-BTC to sats
n: 0.1, // nano-BTC to sats
p: 0.0001, // pico-BTC to sats
}
amount = Math.round(num * (btcMultipliers[multiplier] ?? 1e8))
}
return { amount, expiry: null, timestamp: null }
}
/** Create a Lightning: URI for deep-linking to wallet apps */
export function createLightningUri(invoice: string): string {
return `lightning:${invoice}`
}
/** Create a BIP21 bitcoin: URI with optional Lightning invoice */
export function createBip21Uri(
address: string,
options?: { amount?: number; label?: string; lightning?: string },
): string {
const params = new URLSearchParams()
if (options?.amount) params.set('amount', (options.amount / 1e8).toFixed(8))
if (options?.label) params.set('label', options.label)
if (options?.lightning) params.set('lightning', options.lightning)
const qs = params.toString()
return `bitcoin:${address}${qs ? '?' + qs : ''}`
}
/** Create an LNURL-pay link */
export function createLnurlPayUri(lnurl: string): string {
return `lightning:${lnurl}`
}
/** Format satoshi amount for display */
export function formatSats(sats: number): string {
if (sats >= 1_000_000) {
return `${(sats / 1_000_000).toFixed(2)}M sats`
}
if (sats >= 1_000) {
return `${(sats / 1_000).toFixed(sats >= 10_000 ? 0 : 1)}k sats`
}
return `${sats} sats`
}
/** Check if a string looks like a BOLT11 invoice */
export function isBolt11(text: string): boolean {
const lower = text.toLowerCase().trim()
return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt')
}
/** Check if a string looks like an LNURL */
export function isLnurl(text: string): boolean {
const lower = text.toLowerCase().trim()
return lower.startsWith('lnurl')
}
@@ -0,0 +1,38 @@
/** Map common genre names to Wavlake chart genre param (top-level from list.csv) */
export const WAVLAKE_GENRE_MAP: Record<string, string> = {
'math rock': 'alternative',
'indie rock': 'alternative',
'post-rock': 'alternative',
'prog rock': 'rock',
'progressive rock': 'rock',
'rock': 'rock',
'alternative': 'alternative',
'emo': 'alternative',
'post-hardcore': 'alternative',
'hip-hop': 'hip-hop/rap',
'hip hop': 'hip-hop/rap',
'rap': 'hip-hop/rap',
'electronic': 'electronic',
'ambient': 'electronic',
'house': 'dance/edm',
'techno': 'dance/edm',
'jazz': 'jazz',
'blues': 'blues',
'folk': 'singer/songwriter',
'country': 'country',
'classical': 'classical',
'pop': 'pop',
'r&b': 'r&b/soul',
'soul': 'r&b/soul',
'reggae': 'reggae',
'world': 'world',
}
export function mapToWavlakeGenre(genres: string[]): string | null {
for (const g of genres) {
const key = g.toLowerCase().trim()
const mapped = WAVLAKE_GENRE_MAP[key]
if (mapped) return mapped
}
return null
}