src/services/console.ts
Rama main · réplica de lectura del repositorio original.
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type { CreditMetricType, CreditPricingStep } from './creditPricing'
import type { ConsoleClient } from '~/services/consoleClient'
import type { Database } from '~/types/database.types'
import { ref } from 'vue'
import { invokeCapgoApi } from '~/services/capgoApi'
import { createConsoleAuthClient, resolveConsoleApiBase } from '~/services/consoleAuth'
import { createConsoleClient } from '~/services/consoleClient'
import { sortCreditPricingSteps } from './creditPricing'
let consoleClient: ConsoleClient<Database> = null as any
export const defaultApiHost = import.meta.env.VITE_API_HOST as string
export const PROD_API_URL = 'https://api.capgo.app'
export interface CapgoConfig {
/** Capgo API this build talks to; everything (data, auth, files) goes through it. */
apiHost: string
host: string
hostWeb: string
stripeEnabled?: boolean
}
/** Anything that is not the Capgo cloud API (local, preprod, dev, self-host). */
export function isLocal(apiHost: string) {
return apiHost !== PROD_API_URL
}
export function getLocalConfig() {
const stripeEnabledEnv = import.meta.env.VITE_STRIPE_ENABLED as string | undefined
return {
apiHost: defaultApiHost,
host: import.meta.env.VITE_APP_URL as string,
hostWeb: import.meta.env.LANDING_URL as string,
stripeEnabled: stripeEnabledEnv === undefined ? true : stripeEnabledEnv !== 'false',
} as CapgoConfig
}
let config: CapgoConfig = getLocalConfig()
export const stripeEnabled = ref<boolean>(config.stripeEnabled ?? true)
interface SpoofedAdminSession {
jwt: string
refreshToken: string
}
const SPOOF_ADMIN_TOKEN_REFRESH_WINDOW_SECONDS = 60
function getSpoofedAdminStorageKey() {
return 'capgo.console.spoof_admin_jwt'
}
function saveSpoofedAdminSession(session: SpoofedAdminSession) {
return localStorage.setItem(getSpoofedAdminStorageKey(), JSON.stringify({ jwt: session.jwt, refreshToken: session.refreshToken }))
}
function getSpoofedAdminSession(): SpoofedAdminSession | null {
const textData = localStorage.getItem(getSpoofedAdminStorageKey())
if (!textData)
return null
try {
const parsed = JSON.parse(textData) as Partial<SpoofedAdminSession>
if (typeof parsed.jwt !== 'string' || !parsed.jwt)
return null
if (typeof parsed.refreshToken !== 'string' || !parsed.refreshToken)
return null
return { jwt: parsed.jwt, refreshToken: parsed.refreshToken }
}
catch {
return null
}
}
function decodeBase64Url(value: string) {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
return globalThis.atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='))
}
function getJwtExpiresAt(jwt: string) {
const payload = jwt.split('.')[1]
if (!payload)
return null
try {
const parsed = JSON.parse(decodeBase64Url(payload)) as { exp?: unknown }
return typeof parsed.exp === 'number' ? parsed.exp : null
}
catch {
return null
}
}
function shouldRefreshSpoofedAdminJwt(jwt: string) {
const expiresAt = getJwtExpiresAt(jwt)
if (!expiresAt)
return true
return expiresAt - Date.now() / 1000 <= SPOOF_ADMIN_TOKEN_REFRESH_WINDOW_SECONDS
}
function isSpoofedAdminJwtUsable(jwt: string) {
const expiresAt = getJwtExpiresAt(jwt)
return expiresAt !== null && expiresAt > Date.now() / 1000
}
// A throwaway auth client: the admin session must never replace the stored user session.
function createSpoofAdminAuth() {
return createConsoleAuthClient({ baseURL: resolveConsoleApiBase(defaultApiHost), persistSession: false })
}
async function refreshSpoofedAdminSession(session: SpoofedAdminSession): Promise<SpoofedAdminSession | null> {
const { data, error } = await createSpoofAdminAuth().refreshSession({ refresh_token: session.refreshToken })
if (error || !data.session?.access_token || !data.session.refresh_token)
return null
return { jwt: data.session.access_token, refreshToken: data.session.refresh_token }
}
export function mergeRemoteConfig(localConfig: CapgoConfig, remoteConfig: Partial<CapgoConfig> | null | undefined): CapgoConfig {
return {
...localConfig,
host: typeof remoteConfig?.host === 'string' ? remoteConfig.host : localConfig.host,
hostWeb: typeof remoteConfig?.hostWeb === 'string' ? remoteConfig.hostWeb : localConfig.hostWeb,
stripeEnabled: typeof remoteConfig?.stripeEnabled === 'boolean' ? remoteConfig.stripeEnabled : localConfig.stripeEnabled,
}
}
export async function getRemoteConfig() {
const localConfig = getLocalConfig()
if (import.meta.env.MODE === 'development')
return localConfig
try {
const response = await fetch(`${defaultApiHost}/private/config`)
if (!response.ok) {
console.log('Local config', localConfig)
stripeEnabled.value = localConfig.stripeEnabled ?? stripeEnabled.value
return localConfig as CapgoConfig
}
const data = await response.json() as Partial<CapgoConfig>
const merged = mergeRemoteConfig(localConfig, data)
config = merged
stripeEnabled.value = merged.stripeEnabled ?? stripeEnabled.value
return merged
}
catch {
console.log('Local config', localConfig)
stripeEnabled.value = localConfig.stripeEnabled ?? stripeEnabled.value
return localConfig as CapgoConfig
}
}
export function useConsole() {
if (consoleClient)
return consoleClient
// Every data, storage, realtime, function and auth call goes through the Capgo API.
consoleClient = createConsoleClient()
return consoleClient
}
export function isSpoofed() {
return !!getSpoofedAdminSession()
}
export function saveSpoof(jwt: string, refreshToken: string) {
return saveSpoofedAdminSession({ jwt, refreshToken })
}
export function clearSpoof() {
localStorage.removeItem(getSpoofedAdminStorageKey())
}
export async function getSpoofedAdminJwt() {
const spoofedAdminSession = getSpoofedAdminSession()
if (!spoofedAdminSession)
return null
if (!shouldRefreshSpoofedAdminJwt(spoofedAdminSession.jwt))
return spoofedAdminSession.jwt
try {
const refreshedSession = await refreshSpoofedAdminSession(spoofedAdminSession)
if (refreshedSession) {
saveSpoofedAdminSession(refreshedSession)
return refreshedSession.jwt
}
}
catch {
console.error('Failed to refresh spoofed admin session')
}
const expiresAt = getJwtExpiresAt(spoofedAdminSession.jwt)
if (expiresAt && expiresAt > Date.now() / 1000)
return spoofedAdminSession.jwt
clearSpoof()
return null
}
export async function hashEmail(email: string) {
const encoder = new TextEncoder()
const data = encoder.encode(email)
const hashBuffer = await window.crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('')
return hashHex
}
export async function unspoofUser() {
const spoofedAdminSession = getSpoofedAdminSession()
if (!spoofedAdminSession)
return false
// A refresh token may have been invalidated while its paired access token is
// still valid. Restore that access token directly so stopping impersonation
// does not strand the user in the spoofed session.
const restoredAdminSession = isSpoofedAdminJwtUsable(spoofedAdminSession.jwt)
? spoofedAdminSession
: await refreshSpoofedAdminSession(spoofedAdminSession).catch(() => null)
if (!restoredAdminSession) {
clearSpoof()
return false
}
if (restoredAdminSession !== spoofedAdminSession)
saveSpoofedAdminSession(restoredAdminSession)
const capgo = useConsole()
let data: { session?: unknown } | null | undefined
let error: unknown
try {
({ data, error } = await capgo.auth.setSession({ access_token: restoredAdminSession.jwt, refresh_token: restoredAdminSession.refreshToken }))
}
catch {
return false
}
if (error || !data?.session)
return false
clearSpoof()
return true
}
export async function downloadUrl(provider: string, userId: string, appId: string, id: number): Promise<string> {
const data = {
user_id: userId,
app_id: appId,
storage_provider: provider,
id,
}
const { data: currentSession } = await useConsole().auth.getSession()!
if (!currentSession.session)
return ''
const currentJwt = currentSession.session.access_token
try {
const response = await fetch(`${defaultApiHost}/files/download_link`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentJwt}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`downloadUrl error: HTTP ${response.status}`)
}
const res = await response.json() as { url: string }
return res.url
}
catch (e) {
// A transient browser network drop surfaces as a `TypeError` whose wording
// differs per engine (Chrome "Failed to fetch", WebKit "Load failed",
// Firefox "NetworkError when attempting to fetch resource."). Rethrow it
// untouched so transient-network detection can still recognise the message.
if (e instanceof TypeError)
throw e
throw new Error(`downloadUrl error: ${e instanceof Error ? e.message : String(e)}`, { cause: e })
}
}
export async function autoAuth(route: RouteLocationNormalizedLoaded) {
const capgo = useConsole()
const { data: session } = await capgo.auth.getSession()!
if (session.session || !route.hash)
return null
const queryString = route.hash.replace('#', '')
const urlParams = new URLSearchParams(queryString)
const refresh_token = urlParams.get('refresh_token')
if (!refresh_token)
return null
const { data: logSession } = await capgo.auth.refreshSession({
refresh_token,
})
return logSession
}
export interface AppUsageByApp {
app_id: string
date: string
mau: number
storage: number
storage_byte_hours?: number
bandwidth: number
build_time_seconds: number
get: number
}
export interface AppUsageGlobal {
date: string
bandwidth: number
mau: number
storage: number
storage_byte_hours?: number
build_time_seconds: number
get: number
}
export interface AppUsageGlobalByApp {
global: AppUsageGlobal[]
byApp: AppUsageByApp[]
}
function parseDashboardRangeDate(value?: string) {
if (!value)
return null
const dateParts = /^(\d{4})-(\d{2})-(\d{2})(?:$|T)/.exec(value)
if (!dateParts)
return null
const [year, month, day] = dateParts.slice(1).map(Number)
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime()))
return null
if (
parsed.getUTCFullYear() !== year
|| parsed.getUTCMonth() + 1 !== month
|| parsed.getUTCDate() !== day
) {
return null
}
return parsed
}
export function normalizeDashboardDateRange(startDate?: string, endDate?: string, now: Date = new Date()) {
// Exclusive end = next UTC midnight so the current UTC day is fully included
const fallbackEnd = new Date(now)
fallbackEnd.setUTCHours(0, 0, 0, 0)
fallbackEnd.setUTCDate(fallbackEnd.getUTCDate() + 1)
const fallbackStart = new Date(fallbackEnd)
fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 30)
const parsedStart = parseDashboardRangeDate(startDate)
const parsedEnd = parseDashboardRangeDate(endDate)
const resolvedStart = parsedStart && parsedEnd ? parsedStart : fallbackStart
const resolvedEnd = parsedStart && parsedEnd ? parsedEnd : fallbackEnd
if (resolvedStart.getTime() > resolvedEnd.getTime()) {
return {
start: fallbackStart.toISOString(),
end: fallbackEnd.toISOString(),
}
}
return {
start: resolvedStart.toISOString(),
end: resolvedEnd.toISOString(),
}
}
export async function getAllDashboard(orgId: string, startDate?: string, endDate?: string): Promise<AppUsageGlobalByApp> {
try {
const { start, end } = normalizeDashboardDateRange(startDate, endDate)
const dateRange = `?from=${start}&to=${end}&breakdown=true&noAccumulate=true`
// 🚀 SUPER OPTIMIZED: Single API call returns both aggregated AND per-app breakdown (with daily values, not accumulated)
const response = await invokeCapgoApi(`statistics/org/${orgId}${dateRange}`, {
method: 'GET',
})
if (response.error) {
throw new Error(response.error.message)
}
const { global, byApp } = response.data as {
global: { mau: number, storage: number, storage_byte_hours?: number, bandwidth: number, build_time_seconds: number, date: string, get: number }[]
byApp: { app_id: string, mau: number, storage: number, storage_byte_hours?: number, bandwidth: number, build_time_seconds: number, date: string, get: number }[]
}
return {
// Copy then sort: older native WebViews lack Array.prototype.toSorted.
global: [...global].sort((a, b) => a.date.localeCompare(b.date)),
byApp: [...byApp].sort((a, b) => a.date.localeCompare(b.date)),
}
}
catch (error) {
console.error('Error in getAllDashboard:', error)
throw error
}
}
export async function getTotalStorage(orgId?: string): Promise<number> {
if (!orgId)
return 0
const { data, error } = await useConsole()
.rpc('get_total_storage_size_org', { org_id: orgId })
.single()
if (error)
throw new Error(error.message)
return data ?? 0
}
// Canonical frontend platform-admin verification.
// Use this only for platform-rights checks in the UI flow; no other path should use
// user-id based admin function checks from the browser.
export async function isPlatformAdmin(): Promise<boolean> {
const rpc = useConsole().rpc('is_platform_admin')
const { data, error } = await rpc.single()
if (error)
throw new Error(error.message)
return data ?? false
}
export async function isPayingOrg(orgId: string): Promise<boolean> {
const { data, error } = await useConsole()
.rpc('is_paying_org', { orgid: orgId })
.single()
if (error)
console.error('isPayingOrg error', orgId, error)
return data ?? false
}
// regional_price_ids is server-only (checkout picks the price); its Json type also exceeds Vue's ref unwrapping depth.
export type Plan = Omit<Database['public']['Tables']['plans']['Row'], 'regional_price_ids'>
export async function getPlans(): Promise<Plan[]> {
try {
const response = await fetch(`${defaultApiHost}/private/plans`)
if (!response.ok) {
return []
}
return await response.json() as Plan[]
}
catch {
return []
}
}
export type UsageCreditLedgerRow = Database['public']['Views']['usage_credit_ledger']['Row']
export interface CreditTierUsage {
tier_id: number
step_min: number
step_max: number
unit_factor: number
units_used: number
price_per_unit: number
cost: number
}
export interface CreditMetricBreakdown {
cost: number
tiers: CreditTierUsage[]
}
export interface CreditCostCalculationRequest {
mau: number
bandwidth: number
storage: number
build_time?: number
org_id?: string
// Plan-included usage per metric; overage is priced from this point of the tier ladder.
included?: Partial<Record<'mau' | 'bandwidth' | 'storage' | 'build_time', number>>
}
export interface CreditCostCalculationResponse {
total_cost: number
breakdown: Record<CreditMetricType, CreditMetricBreakdown>
usage: {
mau: number
bandwidth: number
storage: number
build_time: number
}
}
export async function getCreditPricingSteps(orgId?: string): Promise<CreditPricingStep[]> {
try {
const capgo = useConsole()
const { data: currentSession } = await capgo.auth.getSession()
const endpoint = new URL(`${defaultApiHost}/private/credits`)
if (orgId)
endpoint.searchParams.set('org_id', orgId)
const response = await fetch(endpoint.toString(), {
headers: currentSession.session?.access_token
? {
Authorization: `Bearer ${currentSession.session.access_token}`,
}
: undefined,
})
if (!response.ok)
throw new Error(`Failed to fetch credit pricing: HTTP ${response.status}`)
const data = await response.json() as CreditPricingStep[]
return sortCreditPricingSteps(data ?? [])
}
catch (err) {
console.error('getCreditPricingSteps error', err)
return []
}
}
/**
* Overage deductions of an organization, newest first. Pass `since` (the billing
* cycle start) to load only deductions processed in the current cycle instead of
* the whole history: a cycle's deductions are never processed before it starts.
*/
export async function getUsageCreditDeductions(orgId: string, since?: string): Promise<UsageCreditLedgerRow[]> {
if (!orgId)
return []
try {
let query = useConsole()
.from('usage_credit_ledger')
.select('*')
.eq('org_id', orgId)
.eq('transaction_type', 'deduction')
if (since)
query = query.gte('occurred_at', since)
const { data, error } = await query.order('occurred_at', { ascending: false })
if (error)
throw new Error(error.message)
return data ?? []
}
catch (err) {
console.error('getUsageCreditDeductions error', err)
return []
}
}
export async function calculateCreditCost(request: CreditCostCalculationRequest): Promise<CreditCostCalculationResponse> {
const response = await invokeCapgoApi('private/credits', {
body: {
...request,
build_time: request.build_time ?? 0,
},
})
if (response.error)
throw new Error(response.error.message)
return response.data as CreditCostCalculationResponse
}
interface PlanUsage {
total_percent: number
mau_percent: number
bandwidth_percent: number
storage_percent: number
build_time_percent: number
}
export async function getPlanUsagePercent(orgId?: string): Promise<PlanUsage> {
if (!orgId) {
return {
total_percent: 0,
mau_percent: 0,
bandwidth_percent: 0,
storage_percent: 0,
build_time_percent: 0,
}
}
const { data, error } = await useConsole()
.rpc('get_plan_usage_percent_detailed', { orgid: orgId })
.single()
if (error)
throw new Error(error.message)
return data
}
const DEFAULT_PLAN_NAME = 'Solo'
export async function getCurrentPlanNameOrg(orgId?: string): Promise<string> {
if (!orgId)
return DEFAULT_PLAN_NAME
const { data, error } = await useConsole()
.rpc('get_current_plan_name_org', { orgid: orgId })
.single()
if (error)
throw new Error(error.message)
return data ?? DEFAULT_PLAN_NAME
}
export async function findBestPlan(stats: Database['public']['Functions']['find_best_plan_v3']['Args']): Promise<string> {
// console.log('findBestPlan', stats)
// const storage = bytesToGb(stats.storage)
// const bandwidth = bytesToGb(stats.bandwidth)
const { data, error } = await useConsole()
.rpc('find_best_plan_v3', {
mau: stats.mau ?? 0,
bandwidth: stats.bandwidth,
storage: stats.storage,
})
.single()
if (error)
throw new Error(error.message)
return data
}