API: Phoenix railing proxy, API key auth for /api/v1/*, schema export, docs, migrations, tests
- Phoenix API Railing: proxy to PHOENIX_RAILING_URL, tenant me routes - Tenant-auth: X-API-Key support for /api/v1/* (api_keys table) - Migration 026: api_keys table; 025 sovereign stack marketplace - GET /graphql/schema, GET /graphql-playground, api/docs OpenAPI - Integration tests: phoenix-railing.test.ts - docs/api/API_VERSIONING: /api/v1/ railing alignment - docs/phoenix/PORTAL_RAILING_WIRING Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Phoenix Audit Service
|
||||
* Immutable audit logs, WORM archive, PII boundaries, compliance
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
|
||||
export interface AuditLog {
|
||||
logId: string
|
||||
userId: string | null
|
||||
action: string
|
||||
resourceType: string
|
||||
resourceId: string
|
||||
details: Record<string, any>
|
||||
timestamp: Date
|
||||
ipAddress?: string
|
||||
userAgent?: string
|
||||
}
|
||||
|
||||
export interface AuditQuery {
|
||||
userId?: string
|
||||
action?: string
|
||||
resourceType?: string
|
||||
resourceId?: string
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
limit?: number
|
||||
}
|
||||
|
||||
class AuditService {
|
||||
/**
|
||||
* Create immutable audit log
|
||||
*/
|
||||
async log(
|
||||
action: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
details: Record<string, any>,
|
||||
userId?: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<AuditLog> {
|
||||
const db = getDb()
|
||||
|
||||
// Scrub PII from details
|
||||
const scrubbedDetails = this.scrubPII(details)
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO audit_logs (
|
||||
user_id, action, resource_type, resource_id, details, ip_address, user_agent, timestamp
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
RETURNING *`,
|
||||
[
|
||||
userId || null,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
JSON.stringify(scrubbedDetails),
|
||||
ipAddress || null,
|
||||
userAgent || null
|
||||
]
|
||||
)
|
||||
|
||||
logger.info('Audit log created', { logId: result.rows[0].id, action })
|
||||
|
||||
// Archive to WORM storage if needed
|
||||
await this.archiveToWORM(result.rows[0])
|
||||
|
||||
return this.mapAuditLog(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Query audit logs
|
||||
*/
|
||||
async query(query: AuditQuery): Promise<AuditLog[]> {
|
||||
const db = getDb()
|
||||
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (query.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`)
|
||||
params.push(query.userId)
|
||||
}
|
||||
|
||||
if (query.action) {
|
||||
conditions.push(`action = $${paramIndex++}`)
|
||||
params.push(query.action)
|
||||
}
|
||||
|
||||
if (query.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`)
|
||||
params.push(query.resourceType)
|
||||
}
|
||||
|
||||
if (query.resourceId) {
|
||||
conditions.push(`resource_id = $${paramIndex++}`)
|
||||
params.push(query.resourceId)
|
||||
}
|
||||
|
||||
if (query.startDate) {
|
||||
conditions.push(`timestamp >= $${paramIndex++}`)
|
||||
params.push(query.startDate)
|
||||
}
|
||||
|
||||
if (query.endDate) {
|
||||
conditions.push(`timestamp <= $${paramIndex++}`)
|
||||
params.push(query.endDate)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const limit = query.limit || 1000
|
||||
|
||||
params.push(limit)
|
||||
const result = await db.query(
|
||||
`SELECT * FROM audit_logs
|
||||
${whereClause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $${paramIndex}`,
|
||||
params
|
||||
)
|
||||
|
||||
return result.rows.map(this.mapAuditLog)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export audit logs for compliance
|
||||
*/
|
||||
async exportForCompliance(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
format: 'JSON' | 'CSV' = 'JSON'
|
||||
): Promise<string> {
|
||||
const logs = await this.query({ startDate, endDate, limit: 1000000 })
|
||||
|
||||
if (format === 'JSON') {
|
||||
return JSON.stringify(logs, null, 2)
|
||||
} else {
|
||||
// CSV format
|
||||
const headers = ['logId', 'userId', 'action', 'resourceType', 'resourceId', 'timestamp']
|
||||
const rows = logs.map(log => [
|
||||
log.logId,
|
||||
log.userId || '',
|
||||
log.action,
|
||||
log.resourceType,
|
||||
log.resourceId,
|
||||
log.timestamp.toISOString()
|
||||
])
|
||||
|
||||
return [headers.join(','), ...rows.map(row => row.join(','))].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
private scrubPII(data: Record<string, any>): Record<string, any> {
|
||||
// Placeholder - would implement actual PII scrubbing
|
||||
// Remove SSNs, credit cards, etc. based on PII boundaries
|
||||
const scrubbed = { ...data }
|
||||
|
||||
// Example: remove credit card numbers
|
||||
if (scrubbed.cardNumber) {
|
||||
scrubbed.cardNumber = '***REDACTED***'
|
||||
}
|
||||
|
||||
return scrubbed
|
||||
}
|
||||
|
||||
private async archiveToWORM(log: any): Promise<void> {
|
||||
// Archive to WORM (Write Once Read Many) storage for compliance
|
||||
// This would write to immutable storage (S3 with object lock, etc.)
|
||||
logger.info('Archiving to WORM storage', { logId: log.id })
|
||||
// Placeholder - would implement actual WORM archiving
|
||||
}
|
||||
|
||||
private mapAuditLog(row: any): AuditLog {
|
||||
return {
|
||||
logId: row.id,
|
||||
userId: row.user_id,
|
||||
action: row.action,
|
||||
resourceType: row.resource_type,
|
||||
resourceId: row.resource_id,
|
||||
details: typeof row.details === 'string' ? JSON.parse(row.details) : row.details,
|
||||
timestamp: row.timestamp,
|
||||
ipAddress: row.ip_address,
|
||||
userAgent: row.user_agent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auditService = new AuditService()
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Phoenix Event Bus Service
|
||||
* Durable events, replay, versioning, consumer idempotency
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
|
||||
export interface Event {
|
||||
eventId: string
|
||||
eventType: string
|
||||
aggregateId: string
|
||||
version: number
|
||||
payload: Record<string, any>
|
||||
metadata: Record<string, any>
|
||||
timestamp: Date
|
||||
correlationId: string
|
||||
}
|
||||
|
||||
export interface ConsumerOffset {
|
||||
consumerId: string
|
||||
eventId: string
|
||||
processedAt: Date
|
||||
}
|
||||
|
||||
class EventBusService {
|
||||
/**
|
||||
* Publish an event (via outbox pattern)
|
||||
*/
|
||||
async publishEvent(
|
||||
eventType: string,
|
||||
aggregateId: string,
|
||||
payload: Record<string, any>,
|
||||
correlationId: string,
|
||||
metadata: Record<string, any> = {}
|
||||
): Promise<Event> {
|
||||
const db = getDb()
|
||||
|
||||
// Get next version for this aggregate
|
||||
const versionResult = await db.query(
|
||||
`SELECT COALESCE(MAX(version), 0) + 1 as next_version
|
||||
FROM events
|
||||
WHERE aggregate_id = $1 AND event_type = $2`,
|
||||
[aggregateId, eventType]
|
||||
)
|
||||
const version = parseInt(versionResult.rows[0].next_version)
|
||||
|
||||
// Insert into outbox (atomic with business logic)
|
||||
const result = await db.query(
|
||||
`INSERT INTO event_outbox (
|
||||
event_type, aggregate_id, version, payload, metadata, correlation_id, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 'PENDING')
|
||||
RETURNING *`,
|
||||
[
|
||||
eventType,
|
||||
aggregateId,
|
||||
version,
|
||||
JSON.stringify(payload),
|
||||
JSON.stringify(metadata),
|
||||
correlationId
|
||||
]
|
||||
)
|
||||
|
||||
logger.info('Event published to outbox', {
|
||||
eventId: result.rows[0].id,
|
||||
eventType,
|
||||
correlationId
|
||||
})
|
||||
|
||||
// Process outbox (would be done by background worker)
|
||||
await this.processOutbox()
|
||||
|
||||
return this.mapEvent(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Process outbox (typically run by background worker)
|
||||
*/
|
||||
async processOutbox(): Promise<void> {
|
||||
const db = getDb()
|
||||
|
||||
// Get pending events
|
||||
const pending = await db.query(
|
||||
`SELECT * FROM event_outbox WHERE status = 'PENDING' ORDER BY created_at LIMIT 100`
|
||||
)
|
||||
|
||||
for (const event of pending.rows) {
|
||||
try {
|
||||
// Publish to actual event bus (Kafka/Redpanda/NATS)
|
||||
await this.publishToBus(event)
|
||||
|
||||
// Mark as published
|
||||
await db.query(
|
||||
`UPDATE event_outbox SET status = 'PUBLISHED', published_at = NOW() WHERE id = $1`,
|
||||
[event.id]
|
||||
)
|
||||
|
||||
// Insert into events table
|
||||
await db.query(
|
||||
`INSERT INTO events (
|
||||
event_id, event_type, aggregate_id, version, payload, metadata, correlation_id, timestamp
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (event_id) DO NOTHING`,
|
||||
[
|
||||
event.id,
|
||||
event.event_type,
|
||||
event.aggregate_id,
|
||||
event.version,
|
||||
event.payload,
|
||||
event.metadata,
|
||||
event.correlation_id
|
||||
]
|
||||
)
|
||||
|
||||
logger.info('Event processed from outbox', { eventId: event.id })
|
||||
} catch (error) {
|
||||
logger.error('Failed to process event from outbox', { eventId: event.id, error })
|
||||
// Would implement retry logic here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume events with idempotency
|
||||
*/
|
||||
async consumeEvents(
|
||||
consumerId: string,
|
||||
eventType: string,
|
||||
limit: number = 100
|
||||
): Promise<Event[]> {
|
||||
const db = getDb()
|
||||
|
||||
// Get last processed event
|
||||
const lastOffset = await db.query(
|
||||
`SELECT event_id FROM consumer_offsets
|
||||
WHERE consumer_id = $1 AND event_type = $2
|
||||
ORDER BY processed_at DESC LIMIT 1`,
|
||||
[consumerId, eventType]
|
||||
)
|
||||
|
||||
const lastEventId = lastOffset.rows[0]?.event_id || null
|
||||
|
||||
// Get events after last processed
|
||||
const query = lastEventId
|
||||
? `SELECT * FROM events
|
||||
WHERE event_type = $1 AND id > $2
|
||||
ORDER BY timestamp ASC LIMIT $3`
|
||||
: `SELECT * FROM events
|
||||
WHERE event_type = $1
|
||||
ORDER BY timestamp ASC LIMIT $2`
|
||||
|
||||
const params = lastEventId ? [eventType, lastEventId, limit] : [eventType, limit]
|
||||
const result = await db.query(query, params)
|
||||
|
||||
// Record offsets
|
||||
for (const event of result.rows) {
|
||||
await db.query(
|
||||
`INSERT INTO consumer_offsets (consumer_id, event_id, event_type, processed_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (consumer_id, event_id) DO NOTHING`,
|
||||
[consumerId, event.id, eventType]
|
||||
)
|
||||
}
|
||||
|
||||
return result.rows.map(this.mapEvent)
|
||||
}
|
||||
|
||||
private async publishToBus(event: any): Promise<void> {
|
||||
// This would publish to Kafka/Redpanda/NATS
|
||||
logger.info('Publishing to event bus', { eventId: event.id })
|
||||
// Placeholder - would implement actual bus publishing
|
||||
}
|
||||
|
||||
private mapEvent(row: any): Event {
|
||||
return {
|
||||
eventId: row.id || row.event_id,
|
||||
eventType: row.event_type,
|
||||
aggregateId: row.aggregate_id,
|
||||
version: row.version,
|
||||
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload,
|
||||
metadata: typeof row.metadata === 'string' ? JSON.parse(row.metadata) : row.metadata,
|
||||
timestamp: row.timestamp || row.created_at,
|
||||
correlationId: row.correlation_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBusService = new EventBusService()
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Phoenix Identity Service (Sovereign Stack)
|
||||
* Extends the base identity service with marketplace-specific features
|
||||
* Users, orgs, roles, permissions, device binding, passkeys, OAuth/OIDC
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
import { identityService } from '../identity.js'
|
||||
|
||||
export interface User {
|
||||
userId: string
|
||||
email: string
|
||||
name: string
|
||||
roles: string[]
|
||||
permissions: Record<string, any>
|
||||
orgId: string | null
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
orgId: string
|
||||
name: string
|
||||
domain: string | null
|
||||
status: 'ACTIVE' | 'SUSPENDED'
|
||||
}
|
||||
|
||||
export interface DeviceBinding {
|
||||
deviceId: string
|
||||
userId: string
|
||||
deviceType: string
|
||||
fingerprint: string
|
||||
lastUsed: Date
|
||||
}
|
||||
|
||||
class SovereignIdentityService {
|
||||
/**
|
||||
* Create user
|
||||
*/
|
||||
async createUser(
|
||||
email: string,
|
||||
name: string,
|
||||
orgId?: string
|
||||
): Promise<User> {
|
||||
const db = getDb()
|
||||
|
||||
// Use base identity service for Keycloak integration
|
||||
const keycloakUser = await identityService.createUser(email, name)
|
||||
|
||||
// Store in local DB for marketplace features
|
||||
const result = await db.query(
|
||||
`INSERT INTO marketplace_users (user_id, email, name, org_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
name = EXCLUDED.name,
|
||||
org_id = EXCLUDED.org_id
|
||||
RETURNING *`,
|
||||
[keycloakUser.id, email, name, orgId || null]
|
||||
)
|
||||
|
||||
return this.mapUser(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create organization
|
||||
*/
|
||||
async createOrganization(
|
||||
name: string,
|
||||
domain?: string
|
||||
): Promise<Organization> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO organizations (name, domain, status)
|
||||
VALUES ($1, $2, 'ACTIVE')
|
||||
RETURNING *`,
|
||||
[name, domain || null]
|
||||
)
|
||||
|
||||
logger.info('Organization created', { orgId: result.rows[0].id })
|
||||
return this.mapOrganization(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind device to user
|
||||
*/
|
||||
async bindDevice(
|
||||
userId: string,
|
||||
deviceType: string,
|
||||
fingerprint: string
|
||||
): Promise<DeviceBinding> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO device_bindings (user_id, device_type, fingerprint, last_used)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (user_id, fingerprint) DO UPDATE SET
|
||||
last_used = NOW()
|
||||
RETURNING *`,
|
||||
[userId, deviceType, fingerprint]
|
||||
)
|
||||
|
||||
logger.info('Device bound', { deviceId: result.rows[0].id, userId })
|
||||
return this.mapDeviceBinding(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user with roles and permissions
|
||||
*/
|
||||
async getUser(userId: string): Promise<User | null> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
u.*,
|
||||
o.org_id,
|
||||
ARRAY_AGG(DISTINCT r.role_name) as roles,
|
||||
jsonb_object_agg(DISTINCT p.permission_key, p.permission_value) as permissions
|
||||
FROM marketplace_users u
|
||||
LEFT JOIN organizations o ON u.org_id = o.id
|
||||
LEFT JOIN user_roles r ON u.user_id = r.user_id
|
||||
LEFT JOIN user_permissions p ON u.user_id = p.user_id
|
||||
WHERE u.user_id = $1
|
||||
GROUP BY u.user_id, o.org_id`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.mapUser(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role to user
|
||||
*/
|
||||
async assignRole(userId: string, roleName: string): Promise<void> {
|
||||
const db = getDb()
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO user_roles (user_id, role_name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, role_name) DO NOTHING`,
|
||||
[userId, roleName]
|
||||
)
|
||||
|
||||
logger.info('Role assigned', { userId, roleName })
|
||||
}
|
||||
|
||||
private mapUser(row: any): User {
|
||||
return {
|
||||
userId: row.user_id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
roles: row.roles || [],
|
||||
permissions: row.permissions || {},
|
||||
orgId: row.org_id
|
||||
}
|
||||
}
|
||||
|
||||
private mapOrganization(row: any): Organization {
|
||||
return {
|
||||
orgId: row.id,
|
||||
name: row.name,
|
||||
domain: row.domain,
|
||||
status: row.status
|
||||
}
|
||||
}
|
||||
|
||||
private mapDeviceBinding(row: any): DeviceBinding {
|
||||
return {
|
||||
deviceId: row.id,
|
||||
userId: row.user_id,
|
||||
deviceType: row.device_type,
|
||||
fingerprint: row.fingerprint,
|
||||
lastUsed: row.last_used
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sovereignIdentityService = new SovereignIdentityService()
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Phoenix Ledger Service
|
||||
* Double-entry ledger with virtual accounts, holds, and multi-asset support
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
|
||||
export interface JournalEntry {
|
||||
entryId: string
|
||||
timestamp: Date
|
||||
description: string
|
||||
correlationId: string
|
||||
lines: JournalLine[]
|
||||
}
|
||||
|
||||
export interface JournalLine {
|
||||
accountRef: string
|
||||
debit: number
|
||||
credit: number
|
||||
asset: string
|
||||
}
|
||||
|
||||
export interface VirtualAccount {
|
||||
subaccountId: string
|
||||
accountId: string
|
||||
currency: string
|
||||
asset: string
|
||||
labels: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Hold {
|
||||
holdId: string
|
||||
amount: number
|
||||
asset: string
|
||||
expiry: Date | null
|
||||
status: 'ACTIVE' | 'RELEASED' | 'EXPIRED'
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
accountId: string
|
||||
subaccountId: string | null
|
||||
asset: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
class LedgerService {
|
||||
/**
|
||||
* Create a journal entry (idempotent via correlation_id)
|
||||
*/
|
||||
async createJournalEntry(
|
||||
correlationId: string,
|
||||
description: string,
|
||||
lines: JournalLine[]
|
||||
): Promise<JournalEntry> {
|
||||
const db = getDb()
|
||||
|
||||
// Check idempotency
|
||||
const existing = await db.query(
|
||||
`SELECT * FROM journal_entries WHERE correlation_id = $1`,
|
||||
[correlationId]
|
||||
)
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
logger.info('Journal entry already exists', { correlationId })
|
||||
return this.mapJournalEntry(existing.rows[0])
|
||||
}
|
||||
|
||||
// Validate double-entry balance
|
||||
const totalDebits = lines.reduce((sum, line) => sum + line.debit, 0)
|
||||
const totalCredits = lines.reduce((sum, line) => sum + line.credit, 0)
|
||||
|
||||
if (Math.abs(totalDebits - totalCredits) > 0.01) {
|
||||
throw new Error('Journal entry is not balanced')
|
||||
}
|
||||
|
||||
// Create entry
|
||||
const result = await db.query(
|
||||
`INSERT INTO journal_entries (correlation_id, description, timestamp)
|
||||
VALUES ($1, $2, NOW())
|
||||
RETURNING *`,
|
||||
[correlationId, description]
|
||||
)
|
||||
|
||||
const entryId = result.rows[0].id
|
||||
|
||||
// Create journal lines
|
||||
for (const line of lines) {
|
||||
await db.query(
|
||||
`INSERT INTO journal_lines (entry_id, account_ref, debit, credit, asset)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[entryId, line.accountRef, line.debit, line.credit, line.asset]
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Journal entry created', { entryId, correlationId })
|
||||
return this.mapJournalEntry(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hold (reserve)
|
||||
*/
|
||||
async createHold(
|
||||
accountId: string,
|
||||
amount: number,
|
||||
asset: string,
|
||||
expiry: Date | null = null
|
||||
): Promise<Hold> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO holds (account_id, amount, asset, expiry, status)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE')
|
||||
RETURNING *`,
|
||||
[accountId, amount, asset, expiry]
|
||||
)
|
||||
|
||||
logger.info('Hold created', { holdId: result.rows[0].id })
|
||||
return this.mapHold(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get balance for account/subaccount
|
||||
*/
|
||||
async getBalance(accountId: string, subaccountId?: string, asset?: string): Promise<Balance[]> {
|
||||
const db = getDb()
|
||||
|
||||
// This would query a materialized view or compute from journal_lines
|
||||
const query = `
|
||||
SELECT
|
||||
account_ref as account_id,
|
||||
asset,
|
||||
SUM(debit - credit) as balance
|
||||
FROM journal_lines
|
||||
WHERE account_ref = $1
|
||||
${subaccountId ? 'AND account_ref LIKE $2' : ''}
|
||||
${asset ? 'AND asset = $3' : ''}
|
||||
GROUP BY account_ref, asset
|
||||
`
|
||||
|
||||
const params: any[] = [accountId]
|
||||
if (subaccountId) params.push(`${accountId}:${subaccountId}`)
|
||||
if (asset) params.push(asset)
|
||||
|
||||
const result = await db.query(query, params)
|
||||
return result.rows.map(row => ({
|
||||
accountId: row.account_id,
|
||||
subaccountId: subaccountId || null,
|
||||
asset: row.asset,
|
||||
balance: parseFloat(row.balance)
|
||||
}))
|
||||
}
|
||||
|
||||
private mapJournalEntry(row: any): JournalEntry {
|
||||
return {
|
||||
entryId: row.id,
|
||||
timestamp: row.timestamp,
|
||||
description: row.description,
|
||||
correlationId: row.correlation_id,
|
||||
lines: [] // Would be loaded separately
|
||||
}
|
||||
}
|
||||
|
||||
private mapHold(row: any): Hold {
|
||||
return {
|
||||
holdId: row.id,
|
||||
amount: parseFloat(row.amount),
|
||||
asset: row.asset,
|
||||
expiry: row.expiry,
|
||||
status: row.status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ledgerService = new LedgerService()
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Phoenix Messaging Orchestrator Service
|
||||
* Multi-provider messaging (SMS/voice/email/push) with failover
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
|
||||
export interface MessageRequest {
|
||||
channel: 'SMS' | 'EMAIL' | 'VOICE' | 'PUSH'
|
||||
to: string
|
||||
template: string
|
||||
params: Record<string, any>
|
||||
priority: 'LOW' | 'NORMAL' | 'HIGH'
|
||||
}
|
||||
|
||||
export interface MessageStatus {
|
||||
messageId: string
|
||||
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'FAILED'
|
||||
provider: string
|
||||
deliveryReceipt?: any
|
||||
retryCount: number
|
||||
}
|
||||
|
||||
class MessagingOrchestratorService {
|
||||
/**
|
||||
* Send a message with provider routing and failover
|
||||
*/
|
||||
async sendMessage(request: MessageRequest): Promise<MessageStatus> {
|
||||
const db = getDb()
|
||||
|
||||
// Select provider based on rules (cost, deliverability, region, user preference)
|
||||
const provider = await this.selectProvider(request)
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO messages (channel, recipient, template, params, priority, provider, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'PENDING')
|
||||
RETURNING *`,
|
||||
[
|
||||
request.channel,
|
||||
request.to,
|
||||
request.template,
|
||||
JSON.stringify(request.params),
|
||||
request.priority,
|
||||
provider
|
||||
]
|
||||
)
|
||||
|
||||
const messageId = result.rows[0].id
|
||||
|
||||
try {
|
||||
// Send via provider adapter
|
||||
await this.sendViaProvider(provider, request)
|
||||
|
||||
await db.query(
|
||||
`UPDATE messages SET status = 'SENT' WHERE id = $1`,
|
||||
[messageId]
|
||||
)
|
||||
|
||||
logger.info('Message sent', { messageId, provider })
|
||||
return {
|
||||
messageId,
|
||||
status: 'SENT',
|
||||
provider,
|
||||
retryCount: 0
|
||||
}
|
||||
} catch (error) {
|
||||
// Try failover provider
|
||||
const failoverProvider = await this.selectFailoverProvider(request, provider)
|
||||
|
||||
if (failoverProvider) {
|
||||
logger.info('Retrying with failover provider', { messageId, failoverProvider })
|
||||
return this.sendMessage({ ...request, priority: 'HIGH' })
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE messages SET status = 'FAILED' WHERE id = $1`,
|
||||
[messageId]
|
||||
)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message status
|
||||
*/
|
||||
async getMessageStatus(messageId: string): Promise<MessageStatus> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT * FROM messages WHERE id = $1`,
|
||||
[messageId]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Message not found')
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
messageId: row.id,
|
||||
status: row.status,
|
||||
provider: row.provider,
|
||||
deliveryReceipt: row.delivery_receipt,
|
||||
retryCount: row.retry_count || 0
|
||||
}
|
||||
}
|
||||
|
||||
private async selectProvider(request: MessageRequest): Promise<string> {
|
||||
// Provider selection logic based on cost, deliverability, region, user preference
|
||||
// Placeholder - would implement actual routing rules
|
||||
const providers: Record<string, string[]> = {
|
||||
SMS: ['twilio', 'aws-sns', 'vonage'],
|
||||
EMAIL: ['aws-ses', 'sendgrid'],
|
||||
VOICE: ['twilio', 'vonage'],
|
||||
PUSH: ['fcm', 'apns']
|
||||
}
|
||||
|
||||
return providers[request.channel]?.[0] || 'twilio'
|
||||
}
|
||||
|
||||
private async selectFailoverProvider(request: MessageRequest, failedProvider: string): Promise<string | null> {
|
||||
// Select next provider in failover chain
|
||||
const providers: Record<string, string[]> = {
|
||||
SMS: ['twilio', 'aws-sns', 'vonage'],
|
||||
EMAIL: ['aws-ses', 'sendgrid'],
|
||||
VOICE: ['twilio', 'vonage'],
|
||||
PUSH: ['fcm', 'apns']
|
||||
}
|
||||
|
||||
const chain = providers[request.channel] || []
|
||||
const index = chain.indexOf(failedProvider)
|
||||
return index >= 0 && index < chain.length - 1 ? chain[index + 1] : null
|
||||
}
|
||||
|
||||
private async sendViaProvider(provider: string, request: MessageRequest): Promise<void> {
|
||||
// This would call the appropriate provider adapter
|
||||
logger.info('Sending via provider', { provider, request })
|
||||
// Placeholder - would implement actual provider calls
|
||||
}
|
||||
}
|
||||
|
||||
export const messagingOrchestratorService = new MessagingOrchestratorService()
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Phoenix Observability Stack Service
|
||||
* Distributed tracing, structured logs, SLOs, correlation IDs
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
|
||||
export interface Trace {
|
||||
traceId: string
|
||||
correlationId: string
|
||||
spans: Span[]
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
spanId: string
|
||||
traceId: string
|
||||
parentSpanId: string | null
|
||||
serviceName: string
|
||||
operationName: string
|
||||
startTime: Date
|
||||
endTime: Date
|
||||
duration: number
|
||||
tags: Record<string, any>
|
||||
logs: LogEntry[]
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date
|
||||
level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
|
||||
message: string
|
||||
correlationId: string
|
||||
serviceName: string
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SLO {
|
||||
sloId: string
|
||||
serviceName: string
|
||||
metricName: string
|
||||
target: number
|
||||
window: string
|
||||
currentValue: number
|
||||
status: 'HEALTHY' | 'WARNING' | 'BREACHED'
|
||||
}
|
||||
|
||||
class ObservabilityService {
|
||||
/**
|
||||
* Create a trace
|
||||
*/
|
||||
async createTrace(correlationId: string): Promise<Trace> {
|
||||
const db = getDb()
|
||||
|
||||
const traceId = this.generateTraceId()
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO traces (trace_id, correlation_id, start_time)
|
||||
VALUES ($1, $2, NOW())
|
||||
RETURNING *`,
|
||||
[traceId, correlationId]
|
||||
)
|
||||
|
||||
logger.info('Trace created', { traceId, correlationId })
|
||||
return {
|
||||
traceId,
|
||||
correlationId,
|
||||
spans: [],
|
||||
startTime: result.rows[0].start_time,
|
||||
endTime: result.rows[0].start_time,
|
||||
duration: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add span to trace
|
||||
*/
|
||||
async addSpan(
|
||||
traceId: string,
|
||||
serviceName: string,
|
||||
operationName: string,
|
||||
parentSpanId: string | null,
|
||||
tags: Record<string, any> = {}
|
||||
): Promise<Span> {
|
||||
const db = getDb()
|
||||
|
||||
const spanId = this.generateSpanId()
|
||||
const startTime = new Date()
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO spans (
|
||||
span_id, trace_id, parent_span_id, service_name, operation_name, start_time, tags
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
spanId,
|
||||
traceId,
|
||||
parentSpanId,
|
||||
serviceName,
|
||||
operationName,
|
||||
startTime,
|
||||
JSON.stringify(tags)
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
spanId,
|
||||
traceId,
|
||||
parentSpanId,
|
||||
serviceName,
|
||||
operationName,
|
||||
startTime,
|
||||
endTime: startTime,
|
||||
duration: 0,
|
||||
tags,
|
||||
logs: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a span
|
||||
*/
|
||||
async completeSpan(spanId: string, endTime?: Date): Promise<void> {
|
||||
const db = getDb()
|
||||
|
||||
const span = await db.query(
|
||||
`SELECT * FROM spans WHERE span_id = $1`,
|
||||
[spanId]
|
||||
)
|
||||
|
||||
if (span.rows.length === 0) {
|
||||
throw new Error('Span not found')
|
||||
}
|
||||
|
||||
const finishTime = endTime || new Date()
|
||||
const duration = finishTime.getTime() - span.rows[0].start_time.getTime()
|
||||
|
||||
await db.query(
|
||||
`UPDATE spans SET end_time = $1, duration = $2 WHERE span_id = $3`,
|
||||
[finishTime, duration, spanId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log with correlation ID
|
||||
*/
|
||||
async log(
|
||||
level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR',
|
||||
message: string,
|
||||
correlationId: string,
|
||||
serviceName: string,
|
||||
metadata: Record<string, any> = {}
|
||||
): Promise<void> {
|
||||
const db = getDb()
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO structured_logs (
|
||||
level, message, correlation_id, service_name, metadata, timestamp
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||
[level, message, correlationId, serviceName, JSON.stringify(metadata)]
|
||||
)
|
||||
|
||||
logger[level.toLowerCase()](message, { correlationId, serviceName, ...metadata })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SLO status
|
||||
*/
|
||||
async getSLOStatus(serviceName: string, metricName: string): Promise<SLO | null> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT * FROM slos WHERE service_name = $1 AND metric_name = $2`,
|
||||
[serviceName, metricName]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
const currentValue = await this.getCurrentMetricValue(serviceName, metricName)
|
||||
const status = this.calculateSLOStatus(row.target, currentValue)
|
||||
|
||||
return {
|
||||
sloId: row.id,
|
||||
serviceName: row.service_name,
|
||||
metricName: row.metric_name,
|
||||
target: parseFloat(row.target),
|
||||
window: row.window,
|
||||
currentValue,
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
private async getCurrentMetricValue(serviceName: string, metricName: string): Promise<number> {
|
||||
// Placeholder - would query actual metrics
|
||||
return 0.99 // Example: 99% uptime
|
||||
}
|
||||
|
||||
private calculateSLOStatus(target: number, current: number): 'HEALTHY' | 'WARNING' | 'BREACHED' {
|
||||
if (current >= target) return 'HEALTHY'
|
||||
if (current >= target * 0.95) return 'WARNING'
|
||||
return 'BREACHED'
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
return `trace_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
private generateSpanId(): string {
|
||||
return `span_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
}
|
||||
|
||||
export const observabilityService = new ObservabilityService()
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Phoenix Transaction Orchestrator Service
|
||||
* On-chain/off-chain workflow orchestration with retries and compensations
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
|
||||
export interface Workflow {
|
||||
workflowId: string
|
||||
correlationId: string
|
||||
state: 'INITIATED' | 'AUTHORIZED' | 'CAPTURED' | 'SETTLED' | 'REVERSED' | 'FAILED'
|
||||
steps: WorkflowStep[]
|
||||
retryCount: number
|
||||
maxRetries: number
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
stepId: string
|
||||
type: 'ON_CHAIN' | 'OFF_CHAIN'
|
||||
action: string
|
||||
status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED'
|
||||
retryCount: number
|
||||
compensation?: string
|
||||
}
|
||||
|
||||
class TransactionOrchestratorService {
|
||||
/**
|
||||
* Create a workflow
|
||||
*/
|
||||
async createWorkflow(
|
||||
correlationId: string,
|
||||
steps: Omit<WorkflowStep, 'stepId' | 'status' | 'retryCount'>[]
|
||||
): Promise<Workflow> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO workflows (correlation_id, state, max_retries, metadata)
|
||||
VALUES ($1, 'INITIATED', 3, $2)
|
||||
RETURNING *`,
|
||||
[correlationId, JSON.stringify({ steps })]
|
||||
)
|
||||
|
||||
const workflowId = result.rows[0].id
|
||||
|
||||
// Create workflow steps
|
||||
for (const step of steps) {
|
||||
await db.query(
|
||||
`INSERT INTO workflow_steps (workflow_id, type, action, status, compensation)
|
||||
VALUES ($1, $2, $3, 'PENDING', $4)`,
|
||||
[workflowId, step.type, step.action, step.compensation || null]
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Workflow created', { workflowId, correlationId })
|
||||
return this.mapWorkflow(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute workflow step
|
||||
*/
|
||||
async executeStep(workflowId: string, stepId: string): Promise<void> {
|
||||
const db = getDb()
|
||||
|
||||
// Update step status
|
||||
await db.query(
|
||||
`UPDATE workflow_steps SET status = 'IN_PROGRESS' WHERE id = $1`,
|
||||
[stepId]
|
||||
)
|
||||
|
||||
try {
|
||||
// Execute step logic here
|
||||
// This would route to appropriate provider adapter
|
||||
|
||||
await db.query(
|
||||
`UPDATE workflow_steps SET status = 'COMPLETED' WHERE id = $1`,
|
||||
[stepId]
|
||||
)
|
||||
|
||||
logger.info('Workflow step completed', { workflowId, stepId })
|
||||
} catch (error) {
|
||||
await db.query(
|
||||
`UPDATE workflow_steps SET status = 'FAILED' WHERE id = $1`,
|
||||
[stepId]
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed step
|
||||
*/
|
||||
async retryStep(workflowId: string, stepId: string): Promise<void> {
|
||||
const db = getDb()
|
||||
|
||||
const step = await db.query(
|
||||
`SELECT * FROM workflow_steps WHERE id = $1`,
|
||||
[stepId]
|
||||
)
|
||||
|
||||
if (step.rows[0].retry_count >= 3) {
|
||||
throw new Error('Max retries exceeded')
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE workflow_steps
|
||||
SET status = 'PENDING', retry_count = retry_count + 1
|
||||
WHERE id = $1`,
|
||||
[stepId]
|
||||
)
|
||||
|
||||
await this.executeStep(workflowId, stepId)
|
||||
}
|
||||
|
||||
private mapWorkflow(row: any): Workflow {
|
||||
return {
|
||||
workflowId: row.id,
|
||||
correlationId: row.correlation_id,
|
||||
state: row.state,
|
||||
steps: [],
|
||||
retryCount: row.retry_count || 0,
|
||||
maxRetries: row.max_retries || 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const txOrchestratorService = new TransactionOrchestratorService()
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Phoenix Voice Orchestrator Service
|
||||
* TTS/STT with caching, multi-provider routing, moderation
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export interface VoiceSynthesisRequest {
|
||||
text: string
|
||||
voiceProfile: string
|
||||
format: 'mp3' | 'wav' | 'ogg'
|
||||
latencyClass: 'LOW' | 'STANDARD' | 'HIGH_QUALITY'
|
||||
}
|
||||
|
||||
export interface VoiceSynthesisResult {
|
||||
audioHash: string
|
||||
audioUrl: string
|
||||
duration: number
|
||||
provider: string
|
||||
cached: boolean
|
||||
}
|
||||
|
||||
class VoiceOrchestratorService {
|
||||
/**
|
||||
* Synthesize voice with caching
|
||||
*/
|
||||
async synthesizeVoice(request: VoiceSynthesisRequest): Promise<VoiceSynthesisResult> {
|
||||
const db = getDb()
|
||||
|
||||
// Generate deterministic cache key
|
||||
const cacheKey = this.generateCacheKey(request.text, request.voiceProfile, request.format)
|
||||
|
||||
// Check cache
|
||||
const cached = await db.query(
|
||||
`SELECT * FROM voice_cache WHERE cache_key = $1`,
|
||||
[cacheKey]
|
||||
)
|
||||
|
||||
if (cached.rows.length > 0) {
|
||||
logger.info('Voice synthesis cache hit', { cacheKey })
|
||||
return {
|
||||
audioHash: cached.rows[0].audio_hash,
|
||||
audioUrl: cached.rows[0].audio_url,
|
||||
duration: cached.rows[0].duration,
|
||||
provider: cached.rows[0].provider,
|
||||
cached: true
|
||||
}
|
||||
}
|
||||
|
||||
// Select provider based on latency class
|
||||
const provider = this.selectProvider(request.latencyClass)
|
||||
|
||||
// Scrub PII from text
|
||||
const scrubbedText = this.scrubPII(request.text)
|
||||
|
||||
// Synthesize via provider
|
||||
const synthesis = await this.synthesizeViaProvider(provider, {
|
||||
...request,
|
||||
text: scrubbedText
|
||||
})
|
||||
|
||||
// Store in cache
|
||||
await db.query(
|
||||
`INSERT INTO voice_cache (cache_key, audio_hash, audio_url, duration, provider, text_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[cacheKey, synthesis.audioHash, synthesis.audioUrl, synthesis.duration, provider, cacheKey]
|
||||
)
|
||||
|
||||
logger.info('Voice synthesized', { cacheKey, provider })
|
||||
return {
|
||||
...synthesis,
|
||||
provider,
|
||||
cached: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached audio by hash
|
||||
*/
|
||||
async getAudioByHash(hash: string): Promise<VoiceSynthesisResult | null> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT * FROM voice_cache WHERE audio_hash = $1`,
|
||||
[hash]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
audioHash: row.audio_hash,
|
||||
audioUrl: row.audio_url,
|
||||
duration: row.duration,
|
||||
provider: row.provider,
|
||||
cached: true
|
||||
}
|
||||
}
|
||||
|
||||
private generateCacheKey(text: string, voiceProfile: string, format: string): string {
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(`${text}:${voiceProfile}:${format}`)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
private scrubPII(text: string): string {
|
||||
// Placeholder - would implement actual PII scrubbing
|
||||
// Remove emails, phone numbers, SSNs, etc.
|
||||
return text
|
||||
}
|
||||
|
||||
private selectProvider(latencyClass: string): string {
|
||||
const providers: Record<string, string> = {
|
||||
LOW: 'elevenlabs',
|
||||
STANDARD: 'openai',
|
||||
HIGH_QUALITY: 'elevenlabs'
|
||||
}
|
||||
return providers[latencyClass] || 'elevenlabs'
|
||||
}
|
||||
|
||||
private async synthesizeViaProvider(
|
||||
provider: string,
|
||||
request: VoiceSynthesisRequest
|
||||
): Promise<Omit<VoiceSynthesisResult, 'provider' | 'cached'>> {
|
||||
// This would call the appropriate provider adapter
|
||||
logger.info('Synthesizing via provider', { provider, request })
|
||||
|
||||
// Placeholder - would implement actual provider calls
|
||||
return {
|
||||
audioHash: crypto.randomBytes(32).toString('hex'),
|
||||
audioUrl: `https://cdn.sankofa.nexus/voice/${crypto.randomBytes(16).toString('hex')}.${request.format}`,
|
||||
duration: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceOrchestratorService = new VoiceOrchestratorService()
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Phoenix Wallet Registry Service
|
||||
* Wallet mapping, chain support, policy engine, and recovery
|
||||
*/
|
||||
|
||||
import { getDb } from '../../db/index.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
|
||||
export interface Wallet {
|
||||
walletId: string
|
||||
userId: string
|
||||
orgId: string | null
|
||||
address: string
|
||||
chainId: number
|
||||
custodyType: 'USER' | 'SHARED' | 'PLATFORM'
|
||||
status: 'ACTIVE' | 'SUSPENDED' | 'RECOVERED'
|
||||
}
|
||||
|
||||
export interface TransactionRequest {
|
||||
from: string
|
||||
to: string
|
||||
value: string
|
||||
data?: string
|
||||
chainId: number
|
||||
}
|
||||
|
||||
export interface TransactionSimulation {
|
||||
success: boolean
|
||||
gasEstimate: string
|
||||
error?: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
class WalletRegistryService {
|
||||
/**
|
||||
* Register a wallet
|
||||
*/
|
||||
async registerWallet(
|
||||
userId: string,
|
||||
address: string,
|
||||
chainId: number,
|
||||
custodyType: 'USER' | 'SHARED' | 'PLATFORM',
|
||||
orgId?: string
|
||||
): Promise<Wallet> {
|
||||
const db = getDb()
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO wallets (user_id, org_id, address, chain_id, custody_type, status)
|
||||
VALUES ($1, $2, $3, $4, $5, 'ACTIVE')
|
||||
RETURNING *`,
|
||||
[userId, orgId || null, address, chainId, custodyType]
|
||||
)
|
||||
|
||||
logger.info('Wallet registered', { walletId: result.rows[0].id, address })
|
||||
return this.mapWallet(result.rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a transaction
|
||||
*/
|
||||
async buildTransaction(request: TransactionRequest): Promise<string> {
|
||||
// This would use a transaction builder service with deterministic encoding
|
||||
logger.info('Building transaction', { request })
|
||||
|
||||
// Placeholder - would integrate with actual transaction builder
|
||||
return '0x' // Serialized transaction
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a transaction
|
||||
*/
|
||||
async simulateTransaction(request: TransactionRequest): Promise<TransactionSimulation> {
|
||||
logger.info('Simulating transaction', { request })
|
||||
|
||||
// Placeholder - would call chain RPC for simulation
|
||||
return {
|
||||
success: true,
|
||||
gasEstimate: '21000',
|
||||
warnings: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallets for user
|
||||
*/
|
||||
async getWalletsForUser(userId: string, chainId?: number): Promise<Wallet[]> {
|
||||
const db = getDb()
|
||||
|
||||
const query = chainId
|
||||
? `SELECT * FROM wallets WHERE user_id = $1 AND chain_id = $2`
|
||||
: `SELECT * FROM wallets WHERE user_id = $1`
|
||||
|
||||
const params = chainId ? [userId, chainId] : [userId]
|
||||
const result = await db.query(query, params)
|
||||
|
||||
return result.rows.map(this.mapWallet)
|
||||
}
|
||||
|
||||
private mapWallet(row: any): Wallet {
|
||||
return {
|
||||
walletId: row.id,
|
||||
userId: row.user_id,
|
||||
orgId: row.org_id,
|
||||
address: row.address,
|
||||
chainId: row.chain_id,
|
||||
custodyType: row.custody_type,
|
||||
status: row.status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const walletRegistryService = new WalletRegistryService()
|
||||
Reference in New Issue
Block a user