mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
fbec74747e
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>
191 lines
7.1 KiB
TypeScript
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
|