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>
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
// All licensing application logic in one module: certificate/token verification,
|
|
// active-binding state derivation, the cloud refresh cycle, and the license-gated
|
|
// policy decisions built on top. Everything reaches the outside world through the
|
|
// LicenseBinding / LicensingCloud ports; the verification helpers are pure.
|
|
|
|
import { FREE_TEAM_LIMIT, SignupMode, ZPAN_CLOUD_URL_DEFAULT } from '@shared/constants'
|
|
import type { BindingState, LicenseAssertion } from '@shared/types'
|
|
import { verify } from 'paseto-ts/v4'
|
|
import { z } from 'zod'
|
|
import { getTrustedPublicKeys } from '../domain/license-keys'
|
|
import { effectiveFeatures, hasFeature } from '../domain/licensing'
|
|
import {
|
|
type CloudInstanceInfo,
|
|
CloudInvalidResponseError,
|
|
CloudNetworkError,
|
|
CloudUnboundError,
|
|
type LicenseBindingRepo,
|
|
type LicensingCloudGateway,
|
|
type MemberCountRepo,
|
|
type SystemOptionsRepo,
|
|
} from './ports'
|
|
|
|
// ─── Certificate / token verification (pure) ─────────────────────────────────
|
|
|
|
export interface VerifyCertificateOptions {
|
|
instanceId: string
|
|
currentHost?: string | null
|
|
cloudBaseUrl?: string | null
|
|
}
|
|
|
|
// Why a certificate was rejected. 'signature' means no trusted public key could
|
|
// verify the token — i.e. the cloud signed with a key ZPan doesn't trust (the most
|
|
// common cause: a rotated/mismatched signing key). The rest mean the signature was
|
|
// valid but a claim did not match.
|
|
export type CertificateRejectionReason =
|
|
| 'signature'
|
|
| 'type'
|
|
| 'issuer'
|
|
| 'instance'
|
|
| 'edition'
|
|
| 'not_yet_valid'
|
|
| 'expired'
|
|
| 'host'
|
|
|
|
export type VerifyCertificateResult =
|
|
| { ok: true; assertion: LicenseAssertion }
|
|
| { ok: false; reason: CertificateRejectionReason }
|
|
|
|
export function trustedIssuerFromCloudUrl(baseUrl: string | null | undefined): string {
|
|
const raw = baseUrl || ZPAN_CLOUD_URL_DEFAULT
|
|
try {
|
|
return new URL(raw).origin
|
|
} catch {
|
|
return new URL(ZPAN_CLOUD_URL_DEFAULT).origin
|
|
}
|
|
}
|
|
|
|
export function normalizeHost(host: string | null | undefined): string | null {
|
|
if (!host) return null
|
|
try {
|
|
return new URL(host.includes('://') ? host : `http://${host}`).host.toLowerCase()
|
|
} catch {
|
|
return host.split('/')[0]?.toLowerCase() || null
|
|
}
|
|
}
|
|
|
|
export function verifyCertificate(cert: string, options: VerifyCertificateOptions): LicenseAssertion | null {
|
|
const result = verifyCertificateResult(cert, options)
|
|
return result.ok ? result.assertion : null
|
|
}
|
|
|
|
// Detailed variant: returns the specific rejection reason so callers (e.g. the
|
|
// pairing poll handler) can surface why a certificate failed instead of a bare null.
|
|
export function verifyCertificateResult(cert: string, options: VerifyCertificateOptions): VerifyCertificateResult {
|
|
let claimReason: CertificateRejectionReason | null = null
|
|
|
|
for (const key of getTrustedPublicKeys()) {
|
|
const outcome = tryVerify(cert, key, options)
|
|
if (outcome.ok) return outcome
|
|
// Signature passed for this key but a claim failed — remember the first such
|
|
// reason. We keep looping in case another key yields a fully valid assertion.
|
|
if (outcome.reason !== 'signature' && claimReason === null) {
|
|
claimReason = outcome.reason
|
|
}
|
|
}
|
|
|
|
// A claim reason outranks 'signature': if any key validated the signature, the
|
|
// real problem is the claim, not a key mismatch.
|
|
return { ok: false, reason: claimReason ?? 'signature' }
|
|
}
|
|
|
|
function tryVerify(cert: string, publicKey: string, options: VerifyCertificateOptions): VerifyCertificateResult {
|
|
let payload: LicenseAssertion
|
|
try {
|
|
;({ payload } = verify<LicenseAssertion>(publicKey, cert, { validatePayload: false }))
|
|
} catch {
|
|
return { ok: false, reason: 'signature' }
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const currentHost = normalizeHost(options.currentHost)
|
|
const authorizedHosts = Array.isArray(payload.authorizedHosts)
|
|
? payload.authorizedHosts.map((host) => normalizeHost(host)).filter((host): host is string => Boolean(host))
|
|
: []
|
|
|
|
if (payload.type !== 'zpan.license') {
|
|
return { ok: false, reason: 'type' }
|
|
}
|
|
if (payload.issuer !== trustedIssuerFromCloudUrl(options.cloudBaseUrl)) {
|
|
return { ok: false, reason: 'issuer' }
|
|
}
|
|
if (payload.instanceId !== options.instanceId) {
|
|
return { ok: false, reason: 'instance' }
|
|
}
|
|
if (payload.edition !== 'pro' && payload.edition !== 'business') {
|
|
return { ok: false, reason: 'edition' }
|
|
}
|
|
if (payload.notBefore > now) {
|
|
return { ok: false, reason: 'not_yet_valid' }
|
|
}
|
|
if (payload.expiresAt <= now) {
|
|
return { ok: false, reason: 'expired' }
|
|
}
|
|
if (currentHost && !authorizedHosts.includes(currentHost)) {
|
|
return { ok: false, reason: 'host' }
|
|
}
|
|
|
|
return { ok: true, assertion: { ...payload, authorizedHosts } }
|
|
}
|
|
|
|
const CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS = 5 * 60
|
|
|
|
const cloudEventTokenSchema = z.object({
|
|
type: z.literal('commerce.fulfillment.token'),
|
|
purpose: z.literal('store.delivery'),
|
|
issuer: z.string().min(1),
|
|
audience: z.string().min(1),
|
|
boundLicenseId: z.string().min(1),
|
|
eventId: z.string().min(1),
|
|
payloadHash: z
|
|
.string()
|
|
.regex(/^[0-9a-f]{64}$/i)
|
|
.optional(),
|
|
issuedAt: z.number().int(),
|
|
notBefore: z.number().int().optional(),
|
|
expiresAt: z.number().int(),
|
|
})
|
|
|
|
export type CloudEventToken = z.infer<typeof cloudEventTokenSchema>
|
|
|
|
export interface VerifyCloudEventTokenOptions {
|
|
cloudBaseUrl: string
|
|
instanceId: string
|
|
boundLicenseId: string
|
|
payloadHash: string
|
|
}
|
|
|
|
export function verifyCloudEventToken(token: string, options: VerifyCloudEventTokenOptions): CloudEventToken | null {
|
|
for (const key of getTrustedPublicKeys()) {
|
|
const event = tryVerifyCloudEventToken(token, key, options)
|
|
if (event) return event
|
|
}
|
|
return null
|
|
}
|
|
|
|
function tryVerifyCloudEventToken(
|
|
token: string,
|
|
publicKey: string,
|
|
options: VerifyCloudEventTokenOptions,
|
|
): CloudEventToken | null {
|
|
try {
|
|
const { payload } = verify<Record<string, unknown>>(publicKey, token, { validatePayload: false })
|
|
const parsed = cloudEventTokenSchema.safeParse(payload)
|
|
if (!parsed.success) return null
|
|
|
|
const event = parsed.data
|
|
const now = Math.floor(Date.now() / 1000)
|
|
if (event.issuer !== trustedIssuerFromCloudUrl(options.cloudBaseUrl)) return null
|
|
if (event.audience !== options.instanceId && event.audience !== options.boundLicenseId) return null
|
|
if (event.boundLicenseId !== options.boundLicenseId) return null
|
|
if (event.payloadHash && event.payloadHash !== options.payloadHash) return null
|
|
if (event.issuedAt > now) return null
|
|
if (event.notBefore && event.notBefore > now) return null
|
|
if (event.expiresAt <= now) return null
|
|
if (event.expiresAt - event.issuedAt > CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS) return null
|
|
|
|
return event
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ─── Active-binding state ────────────────────────────────────────────────────
|
|
|
|
export interface BindingStateOptions {
|
|
currentHost?: string | null
|
|
cloudBaseUrl?: string | null
|
|
}
|
|
|
|
// Reads the active license binding and derives the runtime BindingState by
|
|
// verifying the cached certificate. Orchestration: repo read + pure verify.
|
|
export async function loadBindingState(
|
|
deps: { licenseBinding: LicenseBindingRepo },
|
|
options: BindingStateOptions = {},
|
|
): Promise<BindingState> {
|
|
const state = await deps.licenseBinding.loadLicenseState()
|
|
if (!state.refreshToken) return { bound: false }
|
|
|
|
const result: BindingState = {
|
|
bound: true,
|
|
active: false,
|
|
account_email: state.cloudAccountEmail ?? undefined,
|
|
last_refresh_at: state.lastRefreshAt ?? undefined,
|
|
last_refresh_error: state.lastRefreshError ?? undefined,
|
|
}
|
|
|
|
if (state.cachedCert && state.instanceId) {
|
|
const assertion = verifyCertificate(state.cachedCert, {
|
|
instanceId: state.instanceId,
|
|
currentHost: options.currentHost,
|
|
cloudBaseUrl: options.cloudBaseUrl,
|
|
})
|
|
if (assertion) {
|
|
result.active = true
|
|
result.edition = assertion.edition
|
|
result.features = effectiveFeatures(assertion.edition)
|
|
result.license_id = assertion.licenseId
|
|
result.license_valid_until = assertion.licenseValidUntil
|
|
result.certificate_expires_at = assertion.expiresAt
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ─── Cloud refresh cycle ─────────────────────────────────────────────────────
|
|
|
|
const INVALID_CERTIFICATE_ERROR = 'Invalid certificate from cloud'
|
|
const INVALID_ENTITLEMENT_RESPONSE_ERROR = 'Invalid entitlement response from cloud'
|
|
const DEDUP_WINDOW_SEC = 5 * 60
|
|
|
|
function normaliseCert(
|
|
raw: string,
|
|
options: { instanceId: string; cloudBaseUrl: string },
|
|
): { cert: string; certificateExpiresAt: number | null } {
|
|
const assertion = verifyCertificate(raw, { instanceId: options.instanceId, cloudBaseUrl: options.cloudBaseUrl })
|
|
return { cert: raw, certificateExpiresAt: assertion?.expiresAt ?? null }
|
|
}
|
|
|
|
export async function performRefresh(
|
|
deps: { licensingCloud: LicensingCloudGateway; licenseBinding: LicenseBindingRepo },
|
|
baseUrl: string,
|
|
instance?: CloudInstanceInfo,
|
|
): Promise<void> {
|
|
const state = await deps.licenseBinding.loadLicenseState()
|
|
if (!state.refreshToken || !state.instanceId) return
|
|
|
|
try {
|
|
const data = await deps.licensingCloud.refreshEntitlement(baseUrl, state.refreshToken, instance)
|
|
const { cert, certificateExpiresAt } = normaliseCert(data.certificate, {
|
|
instanceId: state.instanceId,
|
|
cloudBaseUrl: baseUrl,
|
|
})
|
|
if (!certificateExpiresAt) {
|
|
await deps.licenseBinding.setLicenseRefreshError(state.id, INVALID_CERTIFICATE_ERROR)
|
|
return
|
|
}
|
|
if (!data.binding?.storeId || !data.account) {
|
|
await deps.licenseBinding.setLicenseRefreshError(state.id, INVALID_ENTITLEMENT_RESPONSE_ERROR)
|
|
return
|
|
}
|
|
|
|
await deps.licenseBinding.updateLicenseBindingAfterRefresh({
|
|
id: state.id,
|
|
refreshToken: data.refreshToken,
|
|
cloudStoreId: data.binding.storeId,
|
|
cachedCert: cert,
|
|
cachedExpiresAt: certificateExpiresAt,
|
|
cloudAccountEmail: data.account.email,
|
|
lastRefreshAt: Math.floor(Date.now() / 1000),
|
|
})
|
|
} catch (err) {
|
|
if (err instanceof CloudUnboundError) {
|
|
await deps.licenseBinding.clearLicenseBinding('revoked')
|
|
return
|
|
}
|
|
|
|
if (err instanceof CloudInvalidResponseError || err instanceof CloudNetworkError || err instanceof Error) {
|
|
await deps.licenseBinding.setLicenseRefreshError(state.id, err.message)
|
|
return
|
|
}
|
|
|
|
throw err
|
|
}
|
|
}
|
|
|
|
export type LicensingRefreshDeps = { licenseBinding: LicenseBindingRepo; licensingCloud: LicensingCloudGateway }
|
|
|
|
// Cron/route-safe entry over performRefresh: skips when unbound or refreshed
|
|
// within the dedup window, and swallows every error (the scheduler must never
|
|
// see a rejection). performRefresh itself owns the network/cert handling.
|
|
export async function runLicensingRefresh(
|
|
deps: LicensingRefreshDeps,
|
|
cloudBaseUrl: string,
|
|
instance?: CloudInstanceInfo,
|
|
): Promise<void> {
|
|
const state = await deps.licenseBinding.loadLicenseState()
|
|
if (!state.refreshToken) return // unbound — no-op
|
|
|
|
const nowSec = Math.floor(Date.now() / 1000)
|
|
if (state.lastRefreshAt != null && nowSec - state.lastRefreshAt < DEDUP_WINDOW_SEC) return
|
|
|
|
try {
|
|
if (instance) {
|
|
await performRefresh(deps, cloudBaseUrl, instance)
|
|
} else {
|
|
await performRefresh(deps, cloudBaseUrl)
|
|
}
|
|
console.log('licensing.refresh.ok')
|
|
} catch (err) {
|
|
const code = err instanceof Error ? err.message : String(err)
|
|
console.error(`licensing.refresh.error code=${code}`)
|
|
}
|
|
}
|
|
|
|
// ─── License-gated policy ────────────────────────────────────────────────────
|
|
|
|
export type TeamCountDeps = { memberCount: MemberCountRepo; licenseBinding: LicenseBindingRepo }
|
|
|
|
export async function checkTeamLimit(
|
|
deps: TeamCountDeps,
|
|
userId: string,
|
|
): Promise<{ allowed: boolean; count: number; limit: number }> {
|
|
const [count, state] = await Promise.all([
|
|
deps.memberCount.countUserOrgs(userId),
|
|
loadBindingState({ licenseBinding: deps.licenseBinding }),
|
|
])
|
|
const unlimited = hasFeature('teams_unlimited', state)
|
|
return { allowed: unlimited || count < FREE_TEAM_LIMIT, count, limit: FREE_TEAM_LIMIT }
|
|
}
|
|
|
|
export type SignupModeDeps = { systemOptions: SystemOptionsRepo; licenseBinding: LicenseBindingRepo }
|
|
|
|
/**
|
|
* Returns the effective signup mode.
|
|
*
|
|
* Rule: `open` requires the `open_registration` Pro feature. Without it the
|
|
* effective mode falls back to `invite-only`. All other stored values
|
|
* (invite_only, closed) are returned unchanged. Unknown/empty values retain
|
|
* the existing default-to-open behaviour and are not subject to the Pro check.
|
|
*/
|
|
export async function getEffectiveSignupMode(deps: SignupModeDeps): Promise<SignupMode> {
|
|
const raw = await deps.systemOptions.getValue('auth_signup_mode')
|
|
|
|
if (raw === SignupMode.INVITE_ONLY || raw === SignupMode.CLOSED) return raw
|
|
if (raw !== SignupMode.OPEN) return SignupMode.OPEN // unknown/empty → open (existing behaviour)
|
|
|
|
// Stored value is explicitly 'open' — gate behind Pro feature
|
|
const state = await loadBindingState({ licenseBinding: deps.licenseBinding })
|
|
return hasFeature('open_registration', state) ? SignupMode.OPEN : SignupMode.INVITE_ONLY
|
|
}
|