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:
defiQUG
2026-03-11 12:57:41 -07:00
parent 33b02b636b
commit 8436e22f4c
45 changed files with 4308 additions and 17 deletions
@@ -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()