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>
This commit is contained in:
Jasper Van
2026-06-14 16:42:47 -04:00
committed by GitHub
parent 191ee0a07d
commit fbec74747e
23 changed files with 486 additions and 861 deletions
+1 -2
View File
@@ -40,8 +40,7 @@ import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/passwo
import { createDbProxy, createPlatformProxy } from './platform/context'
import type { Database, Platform } from './platform/interface'
import { loadCaptchaConfig } from './usecases/captcha'
import { getEffectiveSignupMode } from './usecases/signup-mode'
import { checkTeamLimit } from './usecases/team-count'
import { checkTeamLimit, getEffectiveSignupMode } from './usecases/licensing'
// better-auth's default password hasher is pure-JS scrypt from @noble/hashes,
// which blows past Cloudflare Workers' CPU budget and triggers error 1102.
+2 -2
View File
@@ -14,10 +14,10 @@ import { type DeployPlatform, setDeployPlatform } from './runtime-platform'
import { syncPendingCloudTrafficReports } from './usecases/cloud-traffic-metering'
import { buildCloudInstanceInfo, runtimeInfo } from './usecases/instance-info'
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './usecases/instance-telemetry'
import { runLicensingRefresh } from './usecases/licensing-refresh-runner'
import { runLicensingRefresh } from './usecases/licensing'
import { purgeExpiredTrash, resolveTrashRetentionDays } from './usecases/purge'
import { syncPendingRemoteDownloadUsageReports } from './usecases/remote-download-usage'
import { getSitePublicOrigin } from './usecases/site-public-origin'
import { purgeExpiredTrash, resolveTrashRetentionDays } from './usecases/trash-retention'
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
+1 -1
View File
@@ -2,7 +2,7 @@ import { cloudOrderQuotaChangeSchema } from '@shared/schemas'
import { Hono } from 'hono'
import type { Env } from '../../middleware/platform'
import { requireFeature } from '../../middleware/require-feature'
import { verifyCloudEventToken } from '../../usecases/license-certificate'
import { verifyCloudEventToken } from '../../usecases/licensing'
import { getCloudBaseUrl, parseJson, sha256Hex } from '../cloud-store-helpers'
export const cloudStoreWebhooks = new Hono<Env>().use(requireFeature('quota_store')).post('/webhook', async (c) => {
+1 -6
View File
@@ -4,9 +4,7 @@ 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, verifyCertificateResult } from '../usecases/license-certificate'
import { invalidateEntitlementCache } from '../usecases/license-entitlement'
import { performRefresh } from '../usecases/license-refresh'
import { normalizeHost, performRefresh, verifyCertificateResult } from '../usecases/licensing'
import type { PairingPollResponse } from '../usecases/ports'
import { getSitePublicOrigin } from '../usecases/site-public-origin'
@@ -105,8 +103,6 @@ const app = new Hono<Env>()
lastRefreshAt: Math.floor(Date.now() / 1000),
})
invalidateEntitlementCache()
// 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
@@ -178,7 +174,6 @@ const app = new Hono<Env>()
}
await c.get('deps').licenseBinding.clearLicenseBinding()
invalidateEntitlementCache()
await c.get('deps').activity.record({
orgId,
+1 -3
View File
@@ -7,9 +7,7 @@ import { originFromRequestUrl } from '../domain/site-public-origin'
import type { Env } from '../middleware/platform'
import { syncPendingCloudTrafficReports } from '../usecases/cloud-traffic-metering'
import { buildCloudInstanceInfo, runtimeInfo } from '../usecases/instance-info'
import { normalizeHost } from '../usecases/license-certificate'
import { loadBindingState } from '../usecases/licensing'
import { runLicensingRefresh } from '../usecases/licensing-refresh-runner'
import { loadBindingState, normalizeHost, runLicensingRefresh } from '../usecases/licensing'
import { syncPendingRemoteDownloadUsageReports } from '../usecases/remote-download-usage'
import { getSitePublicOrigin } from '../usecases/site-public-origin'
+1 -2
View File
@@ -3,8 +3,7 @@ import type { Context } from 'hono'
import { createMiddleware } from 'hono/factory'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
import { hasFeature } from '../domain/licensing'
import { normalizeHost } from '../usecases/license-certificate'
import { loadBindingState } from '../usecases/licensing'
import { loadBindingState, normalizeHost } from '../usecases/licensing'
import { getSitePublicOrigin } from '../usecases/site-public-origin'
import type { Env } from './platform'
+2 -2
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { syncPendingCloudTrafficReports } from '../server/usecases/cloud-traffic-metering'
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/usecases/instance-telemetry'
import { runLicensingRefresh } from '../server/usecases/licensing-refresh-runner'
import { runLicensingRefresh } from '../server/usecases/licensing'
import { syncPendingRemoteDownloadUsageReports } from '../server/usecases/remote-download-usage'
import { handleScheduled } from '../workers/scheduled'
@@ -37,7 +37,7 @@ vi.mock('../server/usecases/instance-telemetry', () => ({
reportInstanceTelemetry: vi.fn(),
}))
vi.mock('../server/usecases/licensing-refresh-runner', () => ({
vi.mock('../server/usecases/licensing', () => ({
runLicensingRefresh: vi.fn(),
}))
+1 -1
View File
@@ -2,7 +2,7 @@
import { generateKeys, sign } from 'paseto-ts/v4'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { PUBLIC_KEYS } from '../domain/license-keys'
import { verifyCertificate, verifyCertificateResult } from './license-certificate'
import { verifyCertificate, verifyCertificateResult } from './licensing'
const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public')
const originalKeys: string[] = []
-173
View File
@@ -1,173 +0,0 @@
import { ZPAN_CLOUD_URL_DEFAULT } from '@shared/constants'
import type { LicenseAssertion } from '@shared/types'
import { verify } from 'paseto-ts/v4'
import { z } from 'zod'
import { getTrustedPublicKeys } from '../domain/license-keys'
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
}
}
-208
View File
@@ -1,208 +0,0 @@
// @vitest-environment node
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { generateKeys, sign } from 'paseto-ts/v4'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLicenseBindingRepo } from '../adapters/repos/license-binding'
import * as authSchema from '../db/auth-schema'
import * as appSchema from '../db/schema'
import { PUBLIC_KEYS } from '../domain/license-keys'
import { effectiveFeatures } from '../domain/licensing'
import { invalidateEntitlementCache, loadEntitlement } from './license-entitlement'
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS license_bindings (
id TEXT PRIMARY KEY,
cloud_binding_id TEXT NOT NULL,
cloud_store_id TEXT,
instance_id TEXT NOT NULL,
cloud_account_id TEXT NOT NULL,
cloud_account_email TEXT,
status TEXT NOT NULL,
refresh_token TEXT,
cached_certificate TEXT,
cached_certificate_expires_at INTEGER,
bound_at INTEGER NOT NULL,
disconnected_at INTEGER,
last_refresh_at INTEGER,
last_refresh_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS license_bindings_active_uniq ON license_bindings(status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS license_bindings_cloud_binding_idx ON license_bindings(cloud_binding_id);
CREATE INDEX IF NOT EXISTS license_bindings_instance_idx ON license_bindings(instance_id);
`
const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public')
const originalKeys: string[] = []
beforeAll(() => {
originalKeys.push(...PUBLIC_KEYS)
PUBLIC_KEYS.length = 0
PUBLIC_KEYS.push(TEST_PUBLIC)
})
afterAll(() => {
PUBLIC_KEYS.length = 0
for (const k of originalKeys) PUBLIC_KEYS.push(k)
})
function makeDb() {
const sqlite = new Database(':memory:')
sqlite.exec(SCHEMA_SQL)
return drizzle(sqlite, { schema: { ...appSchema, ...authSchema } })
}
type DB = ReturnType<typeof makeDb>
function nowSec(): number {
return Math.floor(Date.now() / 1000)
}
function signAssertion(overrides: Record<string, unknown> = {}): string {
const now = nowSec()
return sign(TEST_SECRET, {
type: 'zpan.license',
issuer: 'https://cloud.zpan.space',
subject: 'bind-1',
accountId: 'acct-1',
instanceId: 'inst-1',
storeId: 'store-1',
edition: 'pro',
authorizedHosts: [],
licenseValidUntil: now + 365 * 24 * 60 * 60,
issuedAt: now,
notBefore: now,
expiresAt: now + 3600,
...overrides,
})
}
async function seedBinding(db: DB, cachedCert: string | null) {
const now = nowSec()
await createLicenseBindingRepo(db).createLicenseBinding({
cloudBindingId: 'bind-1',
instanceId: 'inst-1',
cloudAccountId: 'acct-1',
cloudStoreId: 'store-1',
refreshToken: 'token',
cachedCert: cachedCert ?? '',
cachedExpiresAt: now + 3600,
lastRefreshAt: now,
})
if (cachedCert === null) {
await db.update(appSchema.licenseBindings).set({ cachedCertificate: null })
}
}
describe('loadEntitlement', () => {
beforeEach(() => {
invalidateEntitlementCache()
})
afterEach(() => {
invalidateEntitlementCache()
})
it('returns null when no binding exists', async () => {
const db = makeDb()
const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(result).toBeNull()
})
it('returns null when binding has no cachedCert', async () => {
const db = makeDb()
await seedBinding(db, null)
const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(result).toBeNull()
})
it('returns entitlement summary for a valid PASETO assertion', async () => {
const db = makeDb()
const licenseValidUntil = nowSec() + 365 * 24 * 60 * 60
const certificateExpiresAt = nowSec() + 3600
await seedBinding(
db,
signAssertion({
licenseId: 'lic-1',
licenseValidUntil,
expiresAt: certificateExpiresAt,
}),
)
const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(result).not.toBeNull()
expect(result?.edition).toBe('pro')
expect(result?.features).toEqual(effectiveFeatures('pro'))
expect(result?.licenseId).toBe('lic-1')
expect(result?.licenseValidUntil).toBe(licenseValidUntil)
expect(result?.certificateExpiresAt).toBe(certificateExpiresAt)
})
it('returns business entitlement summary metadata', async () => {
const db = makeDb()
await seedBinding(
db,
signAssertion({
edition: 'business',
licenseId: 'lic-business',
}),
)
const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(result).toMatchObject({
edition: 'business',
features: effectiveFeatures('business'),
licenseId: 'lic-business',
})
})
it('stops serving a cached entitlement once the certificate expires mid-window', async () => {
vi.useFakeTimers()
try {
const db = makeDb()
await seedBinding(db, signAssertion({ expiresAt: nowSec() + 30 }))
const first = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(first?.edition).toBe('pro')
// Advance past the cert's 30s expiry but within the 60s cache TTL.
vi.advanceTimersByTime(40_000)
const second = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(second).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('returns null for an expired PASETO assertion', async () => {
const db = makeDb()
await seedBinding(
db,
signAssertion({ issuedAt: nowSec() - 100, notBefore: nowSec() - 100, expiresAt: nowSec() - 1 }),
)
const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(result).toBeNull()
})
})
describe('invalidateEntitlementCache', () => {
it('clears cached state so next call re-reads from DB', async () => {
const db = makeDb()
await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
await seedBinding(db, signAssertion())
invalidateEntitlementCache()
const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) })
expect(result?.edition).toBe('pro')
})
})
-58
View File
@@ -1,58 +0,0 @@
import type { LicenseFeature } from '@shared/types'
import { effectiveFeatures } from '../domain/licensing'
import { verifyCertificate } from './license-certificate'
import type { LicenseBindingRepo } from './ports'
export interface EntitlementSummary {
edition: 'pro' | 'business'
features: LicenseFeature[]
licenseId?: string
certificateExpiresAt: number
licenseValidUntil: number
}
const CACHE_TTL_MS = 60_000
let cachedSummary: EntitlementSummary | null = null
let cachedAt = 0
export async function loadEntitlement(deps: {
licenseBinding: LicenseBindingRepo
}): Promise<EntitlementSummary | null> {
const now = Date.now()
if (cachedAt > 0 && now - cachedAt < CACHE_TTL_MS) {
// The 60s TTL must not outlive the certificate itself: a cert that expires
// mid-window would otherwise keep granting features until the cache lapses.
if (!cachedSummary) return null
const nowSeconds = Math.floor(now / 1000)
if (nowSeconds < cachedSummary.certificateExpiresAt && nowSeconds < cachedSummary.licenseValidUntil) {
return cachedSummary
}
// Cached cert has since expired — fall through to re-verify (yields null).
}
const state = await deps.licenseBinding.loadLicenseState()
if (!state.cachedCert || !state.instanceId) {
cachedSummary = null
cachedAt = now
return null
}
const assertion = verifyCertificate(state.cachedCert, { instanceId: state.instanceId })
cachedSummary = assertion
? {
edition: assertion.edition,
features: effectiveFeatures(assertion.edition),
licenseId: assertion.licenseId,
certificateExpiresAt: assertion.expiresAt,
licenseValidUntil: assertion.licenseValidUntil,
}
: null
cachedAt = now
return cachedSummary
}
export function invalidateEntitlementCache(): void {
cachedAt = 0
cachedSummary = null
}
@@ -1,21 +1,11 @@
import { FREE_TEAM_LIMIT } from '@shared/constants'
import { nanoid } from 'nanoid'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { createLicenseBindingRepo } from '../adapters/repos/license-binding.js'
import { createMemberCountRepo } from '../adapters/repos/member-count.js'
import * as authSchema from '../db/auth-schema.js'
import { createTestApp } from '../test/setup.js'
import { checkTeamLimit } from './team-count.js'
// ---------------------------------------------------------------------------
// Mock the licensing layer — we test the guard logic, not the license DB reads
// ---------------------------------------------------------------------------
vi.mock('./licensing', () => ({ loadBindingState: vi.fn() }))
vi.mock('../domain/licensing', () => ({ hasFeature: vi.fn() }))
import { hasFeature } from '../domain/licensing'
import { loadBindingState } from './licensing'
import { createTestApp, seedProLicense } from '../test/setup.js'
import { checkTeamLimit } from './licensing.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -116,15 +106,6 @@ describe('countUserOrgs', () => {
// ---------------------------------------------------------------------------
describe('checkTeamLimit', () => {
beforeEach(() => {
vi.mocked(loadBindingState).mockResolvedValue({ bound: false })
vi.mocked(hasFeature).mockReturnValue(false)
})
afterEach(() => {
vi.clearAllMocks()
})
it('always returns limit equal to FREE_TEAM_LIMIT constant (2)', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
@@ -134,7 +115,7 @@ describe('checkTeamLimit', () => {
expect(result.limit).toBe(2)
})
it('allowed=true when user has 0 orgs and no teams_unlimited feature', async () => {
it('allowed=true when user has 0 orgs and no license', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
@@ -143,7 +124,7 @@ describe('checkTeamLimit', () => {
expect(result.count).toBe(0)
})
it('allowed=true when user has 1 org and no teams_unlimited feature', async () => {
it('allowed=true when user has 1 org and no license', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const orgA = await insertOrg(db)
@@ -154,7 +135,7 @@ describe('checkTeamLimit', () => {
expect(result.count).toBe(1)
})
it('allowed=false when user has 2 orgs and no teams_unlimited feature', async () => {
it('allowed=false when user has 2 orgs (at the free limit) and no license', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const orgA = await insertOrg(db)
@@ -167,7 +148,7 @@ describe('checkTeamLimit', () => {
expect(result.count).toBe(2)
})
it('allowed=false when user has exactly 3 orgs and no teams_unlimited feature', async () => {
it('allowed=false when user has 3 orgs and no license', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
for (let i = 0; i < 3; i++) {
@@ -180,22 +161,9 @@ describe('checkTeamLimit', () => {
expect(result.count).toBe(3)
})
it('allowed=false when user has 4 orgs and no teams_unlimited feature', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
for (let i = 0; i < 4; i++) {
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId)
}
const result = await checkTeamLimit(depsFor(db), userId)
expect(result.allowed).toBe(false)
expect(result.count).toBe(4)
})
it('allowed=true when user has 2 orgs but has teams_unlimited feature', async () => {
vi.mocked(hasFeature).mockReturnValue(true)
it('allowed=true when user has 2 orgs but holds a license granting teams_unlimited', async () => {
const { db } = await createTestApp()
await seedProLicense(db)
const userId = await insertUser(db)
for (let i = 0; i < 2; i++) {
const orgId = await insertOrg(db)
@@ -207,9 +175,9 @@ describe('checkTeamLimit', () => {
expect(result.count).toBe(2)
})
it('allowed=true when user has 10 orgs with teams_unlimited feature', async () => {
vi.mocked(hasFeature).mockReturnValue(true)
it('allowed=true when user has 10 orgs with a teams_unlimited license', async () => {
const { db } = await createTestApp()
await seedProLicense(db)
const userId = await insertUser(db)
for (let i = 0; i < 10; i++) {
const orgId = await insertOrg(db)
@@ -220,34 +188,4 @@ describe('checkTeamLimit', () => {
expect(result.allowed).toBe(true)
expect(result.count).toBe(10)
})
it('calls loadBindingState to determine licensing state', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
await checkTeamLimit(depsFor(db), userId)
expect(loadBindingState).toHaveBeenCalled()
})
it('calls hasFeature with teams_unlimited and the binding state', async () => {
const mockState = { bound: true, features: ['teams_unlimited'] }
vi.mocked(loadBindingState).mockResolvedValue(mockState as Awaited<ReturnType<typeof loadBindingState>>)
const { db } = await createTestApp()
const userId = await insertUser(db)
await checkTeamLimit(depsFor(db), userId)
expect(hasFeature).toHaveBeenCalledWith('teams_unlimited', mockState)
})
it('returns correct count in the result', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId)
const result = await checkTeamLimit(depsFor(db), userId)
expect(result.count).toBe(1)
})
})
+108 -4
View File
@@ -8,9 +8,8 @@ import { createLicenseBindingRepo } from '../adapters/repos/license-binding'
import * as authSchema from '../db/auth-schema'
import * as appSchema from '../db/schema'
import { PUBLIC_KEYS } from '../domain/license-keys'
import { invalidateEntitlementCache } from './license-entitlement'
import { performRefresh } from './license-refresh'
import type { CreateLicenseBindingInput } from './ports'
import { performRefresh, runLicensingRefresh } from './licensing'
import type { CreateLicenseBindingInput, EntitlementRefreshResponse, LicensingCloudGateway } from './ports'
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS license_bindings (
@@ -103,7 +102,6 @@ async function seedBinding(db: DB, overrides: Partial<CreateLicenseBindingInput>
describe('performRefresh', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
invalidateEntitlementCache()
})
afterEach(() => {
@@ -277,3 +275,109 @@ describe('performRefresh', () => {
expect(state.lastRefreshError).toBe('Cloud response missing certificate')
})
})
describe('runLicensingRefresh', () => {
const CLOUD_URL = 'https://cloud.zpan.space'
function fakeCloud(refreshEntitlement: LicensingCloudGateway['refreshEntitlement']): LicensingCloudGateway {
return { refreshEntitlement } as unknown as LicensingCloudGateway
}
function successPayload(): EntitlementRefreshResponse {
return {
refreshToken: 'new-rt',
certificate: signAssertion({ expiresAt: nowSec() + 86400 }),
binding: { id: 'bind-1', instanceId: 'inst-abc', storeId: 'store-new', authorizedHosts: [] },
account: { id: 'acct-1', email: 'acct@example.com' },
}
}
it('is a no-op (never calls cloud) when no binding exists', async () => {
const db = makeDb()
const refresh = vi.fn(async () => successPayload())
await expect(
runLicensingRefresh(
{ licenseBinding: createLicenseBindingRepo(db), licensingCloud: fakeCloud(refresh) },
CLOUD_URL,
),
).resolves.toBeUndefined()
expect(refresh).not.toHaveBeenCalled()
})
it('skips the refresh when lastRefreshAt is within the 5-minute dedup window', async () => {
const db = makeDb()
await seedBinding(db, { lastRefreshAt: nowSec() - 120 })
const refresh = vi.fn(async () => successPayload())
await runLicensingRefresh(
{ licenseBinding: createLicenseBindingRepo(db), licensingCloud: fakeCloud(refresh) },
CLOUD_URL,
)
expect(refresh).not.toHaveBeenCalled()
})
it('refreshes when lastRefreshAt is older than the dedup window', async () => {
const db = makeDb()
await seedBinding(db, { lastRefreshAt: nowSec() - 600 })
const refresh = vi.fn(async () => successPayload())
await runLicensingRefresh(
{ licenseBinding: createLicenseBindingRepo(db), licensingCloud: fakeCloud(refresh) },
CLOUD_URL,
)
expect(refresh).toHaveBeenCalledOnce()
})
it('refreshes when lastRefreshAt is null', async () => {
const db = makeDb()
await seedBinding(db)
await db.update(appSchema.licenseBindings).set({ lastRefreshAt: null })
const refresh = vi.fn(async () => successPayload())
await runLicensingRefresh(
{ licenseBinding: createLicenseBindingRepo(db), licensingCloud: fakeCloud(refresh) },
CLOUD_URL,
)
expect(refresh).toHaveBeenCalledOnce()
})
it('logs licensing.refresh.ok on a successful refresh', async () => {
const db = makeDb()
await seedBinding(db, { lastRefreshAt: nowSec() - 600 })
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const refresh = vi.fn(async () => successPayload())
await runLicensingRefresh(
{ licenseBinding: createLicenseBindingRepo(db), licensingCloud: fakeCloud(refresh) },
CLOUD_URL,
)
expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.ok')
consoleSpy.mockRestore()
})
it('swallows and logs licensing.refresh.error when the refresh propagates a failure', async () => {
const db = makeDb()
await seedBinding(db, { lastRefreshAt: nowSec() - 600 })
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// A non-Error rejection is the only failure performRefresh re-throws; an Error
// is swallowed internally as a refresh-error on the binding.
const refresh = vi.fn(async () => {
throw 'cloud exploded'
})
await expect(
runLicensingRefresh(
{ licenseBinding: createLicenseBindingRepo(db), licensingCloud: fakeCloud(refresh) },
CLOUD_URL,
),
).resolves.toBeUndefined()
expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.error code=cloud exploded')
consoleSpy.mockRestore()
})
})
-71
View File
@@ -1,71 +0,0 @@
import { verifyCertificate } from './license-certificate'
import { invalidateEntitlementCache } from './license-entitlement'
import {
type CloudInstanceInfo,
CloudInvalidResponseError,
CloudNetworkError,
CloudUnboundError,
type LicenseBindingRepo,
type LicensingCloudGateway,
} from './ports'
const INVALID_CERTIFICATE_ERROR = 'Invalid certificate from cloud'
const INVALID_ENTITLEMENT_RESPONSE_ERROR = 'Invalid entitlement response from cloud'
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),
})
invalidateEntitlementCache()
} catch (err) {
if (err instanceof CloudUnboundError) {
await deps.licenseBinding.clearLicenseBinding('revoked')
invalidateEntitlementCache()
return
}
if (err instanceof CloudInvalidResponseError || err instanceof CloudNetworkError || err instanceof Error) {
await deps.licenseBinding.setLicenseRefreshError(state.id, err.message)
return
}
throw err
}
}
@@ -1,140 +0,0 @@
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createLicensingCloudGateway } from '../adapters/gateways/licensing-cloud.js'
import { createLicenseBindingRepo } from '../adapters/repos/license-binding.js'
import { licenseBindings } from '../db/schema.js'
import { createTestApp } from '../test/setup.js'
import * as refreshModule from './license-refresh.js'
import { runLicensingRefresh } from './licensing-refresh-runner.js'
const CLOUD_URL = 'https://cloud.zpan.space'
function makeDeps(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
return { licenseBinding: createLicenseBindingRepo(db), licensingCloud: createLicensingCloudGateway() }
}
async function seedLicenseBinding(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
overrides: { lastRefreshAt?: number | null } = {},
) {
await createLicenseBindingRepo(db).createLicenseBinding({
cloudBindingId: 'bind-1',
cloudStoreId: 'store-1',
instanceId: 'inst-1',
cloudAccountId: 'acct-1',
refreshToken: 'some-token',
cachedCert: 'test-cert',
cachedExpiresAt: Math.floor(Date.now() / 1000) + 3600,
lastRefreshAt: overrides.lastRefreshAt ?? 0,
})
if (overrides.lastRefreshAt === null) {
await db.update(licenseBindings).set({ lastRefreshAt: null }).where(eq(licenseBindings.cloudBindingId, 'bind-1'))
}
}
describe('runLicensingRefresh', () => {
let performRefreshSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
performRefreshSpy = vi.spyOn(refreshModule, 'performRefresh')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns immediately with no-op when no license binding exists', async () => {
const { db } = await createTestApp()
await expect(runLicensingRefresh(makeDeps(db), CLOUD_URL)).resolves.toBeUndefined()
expect(performRefreshSpy).not.toHaveBeenCalled()
})
it('skips performRefresh when lastRefreshAt is within 5 minutes', async () => {
const { db } = await createTestApp()
const recentRefresh = Math.floor(Date.now() / 1000) - 120
await seedLicenseBinding(db, { lastRefreshAt: recentRefresh })
await runLicensingRefresh(makeDeps(db), CLOUD_URL)
expect(performRefreshSpy).not.toHaveBeenCalled()
})
it('calls performRefresh when lastRefreshAt is older than 5 minutes', async () => {
const { db } = await createTestApp()
const oldRefresh = Math.floor(Date.now() / 1000) - 600
await seedLicenseBinding(db, { lastRefreshAt: oldRefresh })
performRefreshSpy.mockResolvedValueOnce(undefined)
const deps = makeDeps(db)
await runLicensingRefresh(deps, CLOUD_URL)
expect(performRefreshSpy).toHaveBeenCalledOnce()
expect(performRefreshSpy).toHaveBeenCalledWith(deps, CLOUD_URL)
})
it('calls performRefresh when lastRefreshAt is null', async () => {
const { db } = await createTestApp()
await seedLicenseBinding(db)
performRefreshSpy.mockResolvedValueOnce(undefined)
await runLicensingRefresh(makeDeps(db), CLOUD_URL)
expect(performRefreshSpy).toHaveBeenCalledOnce()
})
it('logs licensing.refresh.ok on successful performRefresh', async () => {
const { db } = await createTestApp()
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await seedLicenseBinding(db)
performRefreshSpy.mockResolvedValueOnce(undefined)
await runLicensingRefresh(makeDeps(db), CLOUD_URL)
expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.ok')
})
it('logs licensing.refresh.error with Error message when performRefresh throws an Error', async () => {
const { db } = await createTestApp()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await seedLicenseBinding(db)
performRefreshSpy.mockRejectedValueOnce(new Error('network timeout'))
await runLicensingRefresh(makeDeps(db), CLOUD_URL)
expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.error code=network timeout')
})
it('logs licensing.refresh.error with stringified value when performRefresh throws a non-Error', async () => {
const { db } = await createTestApp()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await seedLicenseBinding(db)
performRefreshSpy.mockRejectedValueOnce('plain string error')
await runLicensingRefresh(makeDeps(db), CLOUD_URL)
expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.error code=plain string error')
})
it('does not throw when performRefresh throws', async () => {
const { db } = await createTestApp()
vi.spyOn(console, 'error').mockImplementation(() => {})
await seedLicenseBinding(db)
performRefreshSpy.mockRejectedValueOnce(new Error('unexpected'))
await expect(runLicensingRefresh(makeDeps(db), CLOUD_URL)).resolves.toBeUndefined()
})
})
@@ -1,30 +0,0 @@
import { performRefresh } from './license-refresh'
import type { CloudInstanceInfo, LicenseBindingRepo, LicensingCloudGateway } from './ports'
const DEDUP_WINDOW_SEC = 5 * 60
export type LicensingRefreshDeps = { licenseBinding: LicenseBindingRepo; licensingCloud: LicensingCloudGateway }
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}`)
}
}
+320 -4
View File
@@ -1,7 +1,196 @@
import type { BindingState } from '@shared/types'
import { effectiveFeatures } from '../domain/licensing'
import { verifyCertificate } from './license-certificate'
import type { LicenseBindingRepo } from './ports'
// 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
@@ -43,3 +232,130 @@ export async function loadBindingState(
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
}
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { S3Service } from '../adapters/gateways/s3.js'
import { createMatterRepo } from '../adapters/repos/matter.js'
import { createTestApp } from '../test/setup.js'
import { DEFAULT_TRASH_RETENTION_DAYS, purgeExpiredTrash, resolveTrashRetentionDays } from './trash-retention.js'
import { DEFAULT_TRASH_RETENTION_DAYS, purgeExpiredTrash, resolveTrashRetentionDays } from './purge.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
+34
View File
@@ -1,6 +1,9 @@
import { DirType } from '@shared/constants'
import type { Matter, MatterRepo, S3Gateway, ShareRepo, StorageRecord, StorageRepo, StorageUsageRepo } from './ports'
const DAY_MS = 24 * 60 * 60 * 1000
export const DEFAULT_TRASH_RETENTION_DAYS = 30
export type PurgeDeps = {
s3: S3Gateway
storages: StorageRepo
@@ -45,3 +48,34 @@ export async function purgeRecursively(deps: PurgeDeps, orgId: string, matters:
if (totalBytes > 0) await deps.storageUsage.reconcile(orgId, bytesByStorage.keys())
return matters.length
}
/** Parses ZPAN_TRASH_RETENTION_DAYS; falls back to the default, 0 disables purge. */
export function resolveTrashRetentionDays(raw: string | undefined): number {
if (raw === undefined || raw.trim() === '') return DEFAULT_TRASH_RETENTION_DAYS
const days = Number(raw)
if (!Number.isFinite(days) || days < 0) return DEFAULT_TRASH_RETENTION_DAYS
return Math.floor(days)
}
/**
* Permanently purges trashed items older than `retentionDays` across all orgs,
* reclaiming their quota. Retention of 0 disables auto-purge. Runs subtree at a
* time via the same purge path as emptying the trash manually.
*/
export async function purgeExpiredTrash(deps: PurgeDeps, retentionDays: number, now = Date.now()): Promise<number> {
if (retentionDays <= 0) return 0
const cutoff = now - retentionDays * DAY_MS
const orgIds = await deps.matter.listOrgIdsWithExpiredTrash(cutoff)
let purged = 0
for (const orgId of orgIds) {
const roots = await deps.matter.listTrashedRoots(orgId)
for (const root of roots) {
if ((root.trashedAt ?? 0) >= cutoff) continue
const matters = await deps.matter.collectForPurge(orgId, root.id)
if (!matters) continue
purged += await purgeRecursively(deps, orgId, matters)
}
}
return purged
}
-25
View File
@@ -1,25 +0,0 @@
import { SignupMode } from '@shared/constants'
import { hasFeature } from '../domain/licensing'
import { loadBindingState } from './licensing'
import type { LicenseBindingRepo, SystemOptionsRepo } from './ports'
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
}
-18
View File
@@ -1,18 +0,0 @@
import { FREE_TEAM_LIMIT } from '@shared/constants'
import { hasFeature } from '../domain/licensing'
import { loadBindingState } from './licensing'
import type { LicenseBindingRepo, MemberCountRepo } from './ports'
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 }
}
-35
View File
@@ -1,35 +0,0 @@
import { type PurgeDeps, purgeRecursively } from './purge'
const DAY_MS = 24 * 60 * 60 * 1000
export const DEFAULT_TRASH_RETENTION_DAYS = 30
/** Parses ZPAN_TRASH_RETENTION_DAYS; falls back to the default, 0 disables purge. */
export function resolveTrashRetentionDays(raw: string | undefined): number {
if (raw === undefined || raw.trim() === '') return DEFAULT_TRASH_RETENTION_DAYS
const days = Number(raw)
if (!Number.isFinite(days) || days < 0) return DEFAULT_TRASH_RETENTION_DAYS
return Math.floor(days)
}
/**
* Permanently purges trashed items older than `retentionDays` across all orgs,
* reclaiming their quota. Retention of 0 disables auto-purge. Runs subtree at a
* time via the same purge path as emptying the trash manually.
*/
export async function purgeExpiredTrash(deps: PurgeDeps, retentionDays: number, now = Date.now()): Promise<number> {
if (retentionDays <= 0) return 0
const cutoff = now - retentionDays * DAY_MS
const orgIds = await deps.matter.listOrgIdsWithExpiredTrash(cutoff)
let purged = 0
for (const orgId of orgIds) {
const roots = await deps.matter.listTrashedRoots(orgId)
for (const root of roots) {
if ((root.trashedAt ?? 0) >= cutoff) continue
const matters = await deps.matter.collectForPurge(orgId, root.id)
if (!matters) continue
purged += await purgeRecursively(deps, orgId, matters)
}
}
return purged
}
+2 -2
View File
@@ -5,9 +5,9 @@ import { createDeps } from '../server/composition'
import { createCloudflarePlatform } from '../server/platform/cloudflare'
import { syncPendingCloudTrafficReports } from '../server/usecases/cloud-traffic-metering'
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/usecases/instance-telemetry'
import { runLicensingRefresh } from '../server/usecases/licensing-refresh-runner'
import { runLicensingRefresh } from '../server/usecases/licensing'
import { purgeExpiredTrash, resolveTrashRetentionDays } from '../server/usecases/purge'
import { syncPendingRemoteDownloadUsageReports } from '../server/usecases/remote-download-usage'
import { purgeExpiredTrash, resolveTrashRetentionDays } from '../server/usecases/trash-retention'
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
// Subset of the worker Env used by the scheduled handler.