Files
zpan/server/routes/licensing-admin.ts
T

196 lines
6.3 KiB
TypeScript

import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { systemOptions } from '../db/schema'
import { invalidateEntitlementCache } from '../licensing/entitlement'
import { getOrCreateInstanceId } from '../licensing/instance-id'
import { clearLicenseBinding, createLicenseBinding, loadLicenseState } from '../licensing/license-state'
import { performRefresh } from '../licensing/refresh'
import { normalizeHost, verifyCertificate } from '../licensing/verify'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { recordActivity } from '../services/activity'
import { createPairing, pollPairing, unbindCloudLicense } from '../services/licensing-cloud'
function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string {
return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
}
function configuredPublicOrigin(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string | null {
const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL')
if (!value) return null
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.origin
} catch {
return null
}
}
function getInstanceOrigin(c: {
get(key: 'platform'): { getEnv(k: string): string | undefined }
req: { url: string; header(name: string): string | undefined }
}): string {
const configured = configuredPublicOrigin(c)
if (configured) return configured
const requestUrl = new URL(c.req.url)
const forwardedProto = c.req.header('x-forwarded-proto')
const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host')
if (forwardedProto && forwardedHost) {
return `${forwardedProto}://${forwardedHost}`
}
return requestUrl.origin
}
function getRequestHost(c: {
get(key: 'platform'): { getEnv(k: string): string | undefined }
req: { url: string; header(name: string): string | undefined }
}): string {
const configured = configuredPublicOrigin(c)
if (configured) return new URL(configured).host
const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host')
return normalizeHost(forwardedHost) ?? new URL(c.req.url).host
}
const app = new Hono<Env>()
.use(requireAdmin)
.post('/pair', async (c) => {
const db = c.get('platform').db
const baseUrl = getCloudBaseUrl(c)
const instanceId = await getOrCreateInstanceId(db)
const titleRows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, 'site_title'))
.limit(1)
const instanceName = titleRows[0]?.value ?? 'ZPan'
const instanceHost = getInstanceOrigin(c)
const pairing = await createPairing(baseUrl, instanceId, instanceName, instanceHost)
return c.json(pairing)
})
.get('/pair/:code/poll', async (c) => {
const { code } = c.req.param()
const db = c.get('platform').db
const baseUrl = getCloudBaseUrl(c)
const result = await pollPairing(baseUrl, code)
if (result.status === 'approved' && result.refreshToken && result.certificate) {
const entitlement = {
refreshToken: result.refreshToken,
certificate: result.certificate,
binding: result.binding,
account: result.account,
}
const instanceId = await getOrCreateInstanceId(db)
const cert = entitlement.certificate
const assertion = verifyCertificate(cert, {
instanceId,
currentHost: getRequestHost(c),
cloudBaseUrl: baseUrl,
})
if (!assertion || !entitlement.binding?.storeId || !entitlement.account) {
return c.json({ error: 'invalid_certificate' }, 502)
}
await createLicenseBinding(db, {
cloudBindingId: entitlement.binding.id,
cloudStoreId: entitlement.binding.storeId,
instanceId,
cloudAccountId: entitlement.account.id,
cloudAccountEmail: entitlement.account.email,
refreshToken: entitlement.refreshToken,
cachedCert: cert,
cachedExpiresAt: assertion.expiresAt,
lastRefreshAt: Math.floor(Date.now() / 1000),
})
invalidateEntitlementCache()
const userId = c.get('userId')!
const orgId = c.get('orgId')!
await recordActivity(db, {
orgId,
userId,
action: 'license_pair',
targetType: 'license',
targetName: entitlement.account.email ?? entitlement.account.id,
metadata: { edition: assertion.edition, cloudAccountId: entitlement.account.id },
})
return c.json({
status: 'approved' as const,
edition: assertion.edition,
})
}
if (result.status === 'approved') {
return c.json({ error: 'invalid_pairing_response' }, 502)
}
return c.json({ status: result.status })
})
.post('/refresh', async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const orgId = c.get('orgId')!
const baseUrl = getCloudBaseUrl(c)
await performRefresh(db, baseUrl)
const state = await loadLicenseState(db)
await recordActivity(db, {
orgId,
userId,
action: 'license_refresh',
targetType: 'license',
targetName: 'license binding',
})
return c.json({ success: true, last_refresh_at: state.lastRefreshAt })
})
.delete('/binding', async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const orgId = c.get('orgId')!
const baseUrl = getCloudBaseUrl(c)
const state = await loadLicenseState(db)
let cloudUnbindError: string | null = null
if (state.refreshToken) {
try {
await unbindCloudLicense(baseUrl, state.cloudBindingId, state.refreshToken)
} catch (error) {
cloudUnbindError = error instanceof Error ? error.message : 'Cloud unbind failed'
}
}
await clearLicenseBinding(db)
invalidateEntitlementCache()
await recordActivity(db, {
orgId,
userId,
action: 'license_disconnect',
targetType: 'license',
targetName: 'license binding',
metadata: cloudUnbindError ? { cloudUnbindError } : undefined,
})
return c.json({ deleted: true, cloud_unbind_error: cloudUnbindError })
})
export default app