Archipelago — open-source initial import
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user