Files
zpan/server/http/site/licensing.ts
T

232 lines
7.2 KiB
TypeScript

import { OpenAPIHono, z } from '@hono/zod-openapi'
import type { Context } from 'hono'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
import type { BindingState } from '../../../shared/types'
import { originFromRequestUrl } from '../../domain/site-public-origin'
import { requireAdmin, requireAuth } from '../../middleware/auth'
import type { Env } from '../../middleware/platform'
import { runtimeInfo } from '../../usecases/site/instance-info'
import {
initiatePairing,
loadBindingState,
normalizeHost,
pollPairing,
triggerRefresh,
unbindLicense,
} from '../../usecases/site/licensing'
import { getSitePublicOrigin } from '../../usecases/site/public-origin'
import { authRoute, errorResponse, jsonContent } from '../openapi'
function getCloudBaseUrl(c: Context<Env>): string {
return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
}
async function getInstanceOrigin(c: Context<Env>): Promise<string | null> {
return (await getSitePublicOrigin(c.get('deps'))) ?? originFromRequestUrl(c.req.url)
}
async function requireInstanceOrigin(c: Context<Env>): Promise<string> {
return (await getInstanceOrigin(c)) ?? new URL(c.req.url).origin
}
async function configuredPublicHost(c: Context<Env>): Promise<string | null> {
const origin = await getInstanceOrigin(c)
return origin ? new URL(origin).host : null
}
async function getRequestHost(c: Context<Env>): Promise<string> {
const configured = await getSitePublicOrigin(c.get('deps'))
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
}
function cloudDashboardUrl(cloudBaseUrl: string): string {
return `${cloudBaseUrl.replace(/\/$/, '')}/dashboard`
}
const bindingStateSchema = z
.object({
bound: z.boolean(),
active: z.boolean().optional(),
account_email: z.string().optional(),
edition: z.string().optional(),
features: z.array(z.string()).optional(),
license_id: z.string().optional(),
license_valid_until: z.number().int().optional(),
certificate_expires_at: z.number().int().optional(),
last_refresh_at: z.number().int().optional(),
last_refresh_error: z.string().optional(),
cloud_dashboard_url: z.string().optional(),
})
.openapi('LicenseBindingState')
const licenseEntitlementsSchema = z
.object({
bound: z.boolean(),
active: z.boolean(),
edition: z.enum(['pro', 'business']).nullable(),
features: z.array(z.string()),
})
.openapi('LicenseEntitlements')
const pairingSchema = z
.object({ code: z.string(), pairingUrl: z.string(), expiresAt: z.string() })
.openapi('LicensePairing')
const pairingStatusSchema = z
.object({ status: z.string(), edition: z.string().optional(), cloud_store_id: z.string().optional() })
.openapi('LicensePairingStatus')
const entitlementsRoute = authRoute(
{ access: 'session' },
{
operationId: 'getLicenseEntitlements',
summary: 'Get the current user-visible license entitlements',
tags: ['Licensing'],
method: 'get',
path: '/entitlements',
middleware: [requireAuth] as const,
responses: { 200: jsonContent(licenseEntitlementsSchema, 'License entitlements') },
},
)
const bindingRoute = authRoute(
{ access: 'admin' },
{
operationId: 'getLicenseBinding',
summary: 'Get license binding details',
tags: ['Licensing'],
method: 'get',
path: '/binding',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(bindingStateSchema, 'License binding') },
},
)
const initiatePairingRoute = authRoute(
{ access: 'admin' },
{
operationId: 'initiateLicensePairing',
summary: 'Initiate cloud pairing',
tags: ['Licensing'],
method: 'post',
path: '/pairings',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(pairingSchema, 'Pairing') },
},
)
const pollPairingRoute = authRoute(
{ access: 'admin' },
{
operationId: 'pollLicensePairing',
summary: 'Poll cloud pairing status',
tags: ['Licensing'],
method: 'get',
path: '/pairings/{code}',
middleware: [requireAdmin] as const,
request: { params: z.object({ code: z.string() }) },
responses: {
200: jsonContent(pairingStatusSchema, 'Pairing status'),
502: errorResponse('Cloud error'),
},
},
)
const refreshRoute = authRoute(
{ access: 'admin' },
{
operationId: 'refreshLicense',
summary: 'Refresh the license',
tags: ['Licensing'],
method: 'post',
path: '/refresh-runs',
middleware: [requireAdmin] as const,
responses: {
200: jsonContent(z.object({ success: z.boolean(), last_refresh_at: z.number().int().nullable() }), 'Refreshed'),
},
},
)
const unbindRoute = authRoute(
{ access: 'admin' },
{
operationId: 'unbindLicense',
summary: 'Unbind the license',
tags: ['Licensing'],
method: 'delete',
path: '/binding',
middleware: [requireAdmin] as const,
responses: {
204: { description: 'Unbound' },
502: errorResponse('Cloud unbind failed'),
},
},
)
async function loadCurrentBinding(c: Context<Env>) {
const cloudBaseUrl = getCloudBaseUrl(c)
const currentHost =
(await configuredPublicHost(c)) ??
normalizeHost(c.req.header('x-forwarded-host') ?? c.req.header('host')) ??
new URL(c.req.url).host
return {
...(await loadBindingState(c.get('deps'), { currentHost, cloudBaseUrl })),
cloud_dashboard_url: cloudDashboardUrl(cloudBaseUrl),
} satisfies BindingState
}
export const licensing = new OpenAPIHono<Env>().openapi(entitlementsRoute, async (c) => {
const state = await loadCurrentBinding(c)
return c.json(
{
bound: state.bound,
active: state.active ?? false,
edition: state.edition ?? null,
features: state.features ?? [],
},
200,
)
})
const adminApp = new OpenAPIHono<Env>()
export const licensingAdmin = adminApp
.openapi(bindingRoute, async (c) => c.json(await loadCurrentBinding(c), 200))
.openapi(initiatePairingRoute, async (c) => {
const pairing = await initiatePairing(c.get('deps'), {
baseUrl: getCloudBaseUrl(c),
instanceUrl: await requireInstanceOrigin(c),
runtime: runtimeInfo(c.get('platform')),
})
return c.json(pairing, 200)
})
.openapi(pollPairingRoute, async (c) => {
const result = await pollPairing(c.get('deps'), {
baseUrl: getCloudBaseUrl(c),
code: c.req.valid('param').code,
currentHost: await getRequestHost(c),
})
if (!result.ok) throw result.error
if (result.status === 'approved') {
return c.json({ status: 'approved', edition: result.edition, cloud_store_id: result.cloudStoreId }, 200)
}
return c.json({ status: result.status }, 200)
})
.openapi(refreshRoute, async (c) => {
const { lastRefreshAt } = await triggerRefresh(c.get('deps'), {
baseUrl: getCloudBaseUrl(c),
instanceUrl: await requireInstanceOrigin(c),
runtime: runtimeInfo(c.get('platform')),
})
return c.json({ success: true, last_refresh_at: lastRefreshAt }, 200)
})
.openapi(unbindRoute, async (c) => {
const result = await unbindLicense(c.get('deps'), {
baseUrl: getCloudBaseUrl(c),
})
if (!result.ok) throw result.error
return c.body(null, 204)
})