Files
zpan/server/http/licensing-admin.ts
T
Jasper Van fbec74747e refactor(usecases): consolidate licensing into one file, merge tiny usecases, drop dead code (#434)
Group all license application logic into a single usecases/licensing.ts
(certificate/token verification, binding-state, cloud refresh, license-gated
policy) and collapse small single-purpose usecases that belonged together.

- delete license-entitlement.ts: a write-only cache nobody read (loadEntitlement
  had zero live consumers); remove its invalidate* call-sites
- merge licensing-refresh-runner -> license-refresh, then fold
  license-certificate + license-refresh + license-policy + binding-state into
  one licensing.ts (internal cert<-state<-refresh<-policy edges become in-file)
- merge team-count + signup-mode -> license-policy (then into licensing.ts)
- merge trash-retention -> purge (manual purge + scheduled retention sweep)

Tests follow the source: the runner tests are rewritten against a fake
LicensingCloud port (the old module-spy on performRefresh can't survive a
same-module call), and the team-limit test seeds a real pro license instead of
mocking the licensing module. Net 486+/861-. typecheck clean; unit+integration
green (158 files / 3797 tests).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:42:47 -04:00

191 lines
7.1 KiB
TypeScript

import { Hono } from 'hono'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { originFromRequestUrl } from '../domain/site-public-origin'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { buildCloudInstanceInfo, runtimeInfo } from '../usecases/instance-info'
import { normalizeHost, performRefresh, verifyCertificateResult } from '../usecases/licensing'
import type { PairingPollResponse } from '../usecases/ports'
import { getSitePublicOrigin } from '../usecases/site-public-origin'
function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string {
return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
}
async function getInstanceOrigin(c: {
get(key: 'deps'): Env['Variables']['deps']
req: { url: string; header(name: string): string | undefined }
}): Promise<string> {
const configured = await getSitePublicOrigin(c.get('deps'))
if (configured) return configured
return originFromRequestUrl(c.req.url) ?? new URL(c.req.url).origin
}
async function getRequestHost(c: {
get(key: 'deps'): Env['Variables']['deps']
req: { url: string; header(name: string): string | undefined }
}): 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
}
// Best-effort release of a cloud binding ZPan couldn't accept. Returns the failure
// message (surfaced for diagnostics) or null. Leaving the cloud binding orphaned is
// the safe direction — ZPan stays unbound either way — so a failure here does not
// change the user-facing outcome.
async function rollbackCloudBinding(
licensingCloud: Env['Variables']['deps']['licensingCloud'],
baseUrl: string,
result: PairingPollResponse,
): Promise<string | null> {
if (!result.refreshToken || !result.binding?.id) return null
try {
await licensingCloud.unbindCloudLicense(baseUrl, result.binding.id, result.refreshToken)
return null
} catch (error) {
return error instanceof Error ? error.message : 'Cloud unbind failed'
}
}
const app = new Hono<Env>()
.use(requireAdmin)
.post('/pair', async (c) => {
const baseUrl = getCloudBaseUrl(c)
const instance = await buildCloudInstanceInfo(c.get('deps'), {
url: await getInstanceOrigin(c),
runtime: runtimeInfo(c.get('platform')),
})
const pairing = await c.get('deps').licensingCloud.createPairing(baseUrl, instance)
return c.json(pairing)
})
.get('/pair/:code/poll', async (c) => {
const { code } = c.req.param()
const baseUrl = getCloudBaseUrl(c)
const result = await c.get('deps').licensingCloud.pollPairing(baseUrl, code)
if (result.status === 'approved') {
const instanceId = await c.get('deps').instance.getOrCreateInstanceId()
const verification = result.certificate
? verifyCertificateResult(result.certificate, {
instanceId,
currentHost: await getRequestHost(c),
cloudBaseUrl: baseUrl,
})
: null
if (!verification?.ok || !result.refreshToken || !result.binding?.storeId || !result.account) {
// The cloud approved and created a binding, but ZPan can't accept this
// certificate (most often: signed by a key ZPan doesn't trust). Roll back
// the orphaned cloud binding so the two sides don't drift and retries don't
// pile up dangling bindings.
const cloudUnbindError = await rollbackCloudBinding(c.get('deps').licensingCloud, baseUrl, result)
const reason = verification ? (verification.ok ? 'incomplete_response' : verification.reason) : 'no_certificate'
return c.json({ error: 'invalid_certificate', reason, cloud_unbind_error: cloudUnbindError }, 502)
}
const assertion = verification.assertion
await c.get('deps').licenseBinding.createLicenseBinding({
cloudBindingId: result.binding.id,
cloudStoreId: result.binding.storeId,
instanceId,
cloudAccountId: result.account.id,
cloudAccountEmail: result.account.email,
refreshToken: result.refreshToken,
cachedCert: result.certificate!,
cachedExpiresAt: assertion.expiresAt,
lastRefreshAt: Math.floor(Date.now() / 1000),
})
// Report back that we verified + stored the certificate, so the cloud pairing
// page resolves to success instead of claiming it at approval time. Best-effort:
// the binding is already active locally, so a failed confirm only leaves the
// cloud page waiting — it does not break licensing here.
try {
await c.get('deps').licensingCloud.confirmCloudLicense(baseUrl, result.binding.id, result.refreshToken)
} catch {
// ignore — binding works regardless; cloud page falls back to its timeout state
}
const userId = c.get('userId')!
const orgId = c.get('orgId')!
await c.get('deps').activity.record({
orgId,
userId,
action: 'license_pair',
targetType: 'license',
targetName: result.account.email ?? result.account.id,
metadata: { edition: assertion.edition, cloudAccountId: result.account.id },
})
return c.json({
status: 'approved' as const,
edition: assertion.edition,
cloud_store_id: result.binding.storeId,
})
}
return c.json({ status: result.status })
})
.post('/refresh', async (c) => {
const userId = c.get('userId')!
const orgId = c.get('orgId')!
const baseUrl = getCloudBaseUrl(c)
const instance = await buildCloudInstanceInfo(c.get('deps'), {
url: await getInstanceOrigin(c),
runtime: runtimeInfo(c.get('platform')),
})
await performRefresh(c.get('deps'), baseUrl, instance)
const state = await c.get('deps').licenseBinding.loadLicenseState()
await c.get('deps').activity.record({
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 userId = c.get('userId')!
const orgId = c.get('orgId')!
const baseUrl = getCloudBaseUrl(c)
const state = await c.get('deps').licenseBinding.loadLicenseState()
let cloudUnbindError: string | null = null
if (state.refreshToken) {
try {
await c.get('deps').licensingCloud.unbindCloudLicense(baseUrl, state.cloudBindingId, state.refreshToken)
} catch (error) {
cloudUnbindError = error instanceof Error ? error.message : 'Cloud unbind failed'
}
}
await c.get('deps').licenseBinding.clearLicenseBinding()
await c.get('deps').activity.record({
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