mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-30 18:31:36 +08:00
feat: replace license_binding table with system_options keys
- Add license-state.ts helper for reading/writing license state as system_options key-value pairs instead of a dedicated singleton table - Rewrite refresh.ts, has-feature.ts, entitlement.ts, licensing-admin.ts, licensing-refresh-runner.ts to use license-state helpers - Generate migration 0014 to drop license_binding table - Update all 10 test files to use setLicenseOptions instead of db.insert(licenseBinding) - All 2809 tests pass Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
DROP TABLE `license_binding`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1776997633827,
|
||||
"tag": "0013_licensing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1777085815109,
|
||||
"tag": "0014_drop-license-binding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -67,11 +67,11 @@ describe('registration gate — open mode', () => {
|
||||
issued_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 86400 * 1000).toISOString(),
|
||||
})
|
||||
await ctx.db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'test',
|
||||
refreshToken: 'test-token',
|
||||
cachedCert: cert,
|
||||
const { LICENSE_KEYS, setLicenseOptions } = await import('./licensing/license-state.js')
|
||||
await setLicenseOptions(ctx.db, {
|
||||
[LICENSE_KEYS.instanceId]: 'test',
|
||||
[LICENSE_KEYS.refreshToken]: 'test-token',
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
})
|
||||
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' })
|
||||
await signUp(ctx, 'first@example.com')
|
||||
|
||||
@@ -136,20 +136,6 @@ export const shareRecipients = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
// licenseBinding — singleton (id=1 always); before binding no row exists
|
||||
export const licenseBinding = sqliteTable('license_binding', {
|
||||
id: integer('id').primaryKey(),
|
||||
instanceId: text('instance_id').notNull(),
|
||||
cloudAccountId: text('cloud_account_id'),
|
||||
cloudAccountEmail: text('cloud_account_email'),
|
||||
refreshToken: text('refresh_token').notNull(),
|
||||
cachedCert: text('cached_cert'),
|
||||
cachedExpiresAt: integer('cached_expires_at'),
|
||||
lastRefreshAt: integer('last_refresh_at'),
|
||||
lastRefreshError: text('last_refresh_error'),
|
||||
boundAt: integer('bound_at'),
|
||||
})
|
||||
|
||||
// image_hosting_configs — per-org singleton; row exists => feature enabled
|
||||
export const imageHostingConfigs = sqliteTable('image_hosting_configs', {
|
||||
orgId: text('org_id')
|
||||
|
||||
@@ -17,13 +17,12 @@
|
||||
*
|
||||
* Run with: npx vitest run server/licensing/e2e-cloud-integration.test.ts
|
||||
*/
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SignupMode } from '../../shared/constants'
|
||||
import * as schema from '../db/schema'
|
||||
import { CloudUnboundError, createPairing, pollPairing, refreshEntitlement } from '../services/licensing-cloud'
|
||||
import { adminHeaders, createTestApp, seedProLicense } from '../test/setup'
|
||||
import { hasFeature, loadBindingState } from './has-feature'
|
||||
import { LICENSE_KEYS, loadLicenseState, setLicenseOptions } from './license-state'
|
||||
import { PUBLIC_KEYS } from './public-keys'
|
||||
|
||||
const CLOUD_BASE_URL = process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud.saltbo.workers.dev'
|
||||
@@ -179,15 +178,12 @@ describe('E2E: Feature gates — expired certificate', () => {
|
||||
features: ['white_label', 'open_registration', 'teams_unlimited', 'team_quotas'],
|
||||
expires_at: '2020-01-01T00:00:00Z',
|
||||
})
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'test-instance',
|
||||
refreshToken: 'test-token',
|
||||
cachedCert: expiredCert,
|
||||
cachedExpiresAt: Math.floor(new Date('2020-01-01T00:00:00Z').getTime() / 1000),
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: Math.floor(Date.now() / 1000),
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'test-instance',
|
||||
[LICENSE_KEYS.refreshToken]: 'test-token',
|
||||
[LICENSE_KEYS.cachedCert]: expiredCert,
|
||||
[LICENSE_KEYS.cachedExpiresAt]: String(Math.floor(new Date('2020-01-01T00:00:00Z').getTime() / 1000)),
|
||||
[LICENSE_KEYS.boundAt]: String(Math.floor(Date.now() / 1000)),
|
||||
})
|
||||
|
||||
const state = await loadBindingState(db)
|
||||
@@ -302,10 +298,9 @@ describe('E2E: Full pairing-to-activation flow (mocked cloud approval)', () => {
|
||||
expect(approvedBody.plan).toBe('pro')
|
||||
|
||||
// Step 4: Verify binding stored in DB
|
||||
const rows = await db.select().from(schema.licenseBinding).where(eq(schema.licenseBinding.id, 1))
|
||||
expect(rows.length).toBe(1)
|
||||
expect(rows[0].refreshToken).toBe('rt-e2e-secret')
|
||||
expect(rows[0].cachedCert).toBeTruthy()
|
||||
const state2 = await loadLicenseState(db)
|
||||
expect(state2.refreshToken).toBe('rt-e2e-secret')
|
||||
expect(state2.cachedCert).toBeTruthy()
|
||||
|
||||
// Step 5: Verify features are now active
|
||||
const state = await loadBindingState(db)
|
||||
@@ -355,15 +350,13 @@ describe('E2E: PASETO cert verification chain', () => {
|
||||
const fakePaseto =
|
||||
'v4.public.eyJhY2NvdW50X2lkIjoiYTEiLCJpbnN0YW5jZV9pZCI6InRlc3QtaW5zdGFuY2UiLCJwbGFuIjoicHJvIiwiZmVhdHVyZXMiOlsid2hpdGVfbGFiZWwiXSwiZXhwaXJlc19hdCI6IjIwOTktMDEtMDFUMDA6MDA6MDBaIiwiaXNzdWVkX2F0IjoiMjAyNi0wMS0wMVQwMDowMDowMFoifQ.fakesignaturebytes'
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'test-instance',
|
||||
refreshToken: 'test-token',
|
||||
cachedCert: fakePaseto,
|
||||
cachedExpiresAt: Math.floor(new Date('2099-01-01T00:00:00Z').getTime() / 1000),
|
||||
lastRefreshAt: Math.floor(Date.now() / 1000),
|
||||
lastRefreshError: null,
|
||||
boundAt: Math.floor(Date.now() / 1000),
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'test-instance',
|
||||
[LICENSE_KEYS.refreshToken]: 'test-token',
|
||||
[LICENSE_KEYS.cachedCert]: fakePaseto,
|
||||
[LICENSE_KEYS.cachedExpiresAt]: String(Math.floor(new Date('2099-01-01T00:00:00Z').getTime() / 1000)),
|
||||
[LICENSE_KEYS.lastRefreshAt]: String(Math.floor(Date.now() / 1000)),
|
||||
[LICENSE_KEYS.boundAt]: String(Math.floor(Date.now() / 1000)),
|
||||
})
|
||||
|
||||
const state = await loadBindingState(db)
|
||||
|
||||
@@ -6,21 +6,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from
|
||||
import * as authSchema from '../db/auth-schema'
|
||||
import * as appSchema from '../db/schema'
|
||||
import { invalidateEntitlementCache, loadEntitlement } from './entitlement'
|
||||
import { LICENSE_KEYS, setLicenseOptions } from './license-state'
|
||||
import { PUBLIC_KEYS } from './public-keys'
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS license_binding (
|
||||
id INTEGER PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
cloud_account_id TEXT,
|
||||
cloud_account_email TEXT,
|
||||
refresh_token TEXT NOT NULL,
|
||||
cached_cert TEXT,
|
||||
cached_expires_at INTEGER,
|
||||
last_refresh_at INTEGER,
|
||||
last_refresh_error TEXT,
|
||||
bound_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS system_options (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
@@ -28,9 +17,6 @@ const SCHEMA_SQL = `
|
||||
);
|
||||
`
|
||||
|
||||
// Generate a fresh throwaway keypair for this test suite.
|
||||
// We inject the public key into PUBLIC_KEYS so verifyCertificate sees it,
|
||||
// and restore the original array after all tests complete.
|
||||
const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public')
|
||||
const originalKeys: string[] = []
|
||||
|
||||
@@ -60,7 +46,7 @@ describe('loadEntitlement', () => {
|
||||
invalidateEntitlementCache()
|
||||
})
|
||||
|
||||
it('returns null when no binding row exists', async () => {
|
||||
it('returns null when no binding exists', async () => {
|
||||
const db = makeDb()
|
||||
const result = await loadEntitlement(db)
|
||||
expect(result).toBeNull()
|
||||
@@ -68,15 +54,9 @@ describe('loadEntitlement', () => {
|
||||
|
||||
it('returns null when binding has no cachedCert', async () => {
|
||||
const db = makeDb()
|
||||
await db.insert(appSchema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'token',
|
||||
})
|
||||
|
||||
const result = await loadEntitlement(db)
|
||||
@@ -95,15 +75,10 @@ describe('loadEntitlement', () => {
|
||||
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
})
|
||||
|
||||
await db.insert(appSchema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'token',
|
||||
cachedCert: cert,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'token',
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
})
|
||||
|
||||
const result = await loadEntitlement(db)
|
||||
@@ -121,18 +96,13 @@ describe('loadEntitlement', () => {
|
||||
plan: 'pro',
|
||||
features: ['white_label'],
|
||||
issued_at: new Date(Date.now() - 100000).toISOString(),
|
||||
expires_at: new Date(Date.now() - 1000).toISOString(), // expired
|
||||
expires_at: new Date(Date.now() - 1000).toISOString(),
|
||||
})
|
||||
|
||||
await db.insert(appSchema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'token',
|
||||
cachedCert: cert,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'token',
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
})
|
||||
|
||||
const result = await loadEntitlement(db)
|
||||
@@ -144,10 +114,8 @@ describe('invalidateEntitlementCache', () => {
|
||||
it('clears cached state so next call re-reads from DB', async () => {
|
||||
const db = makeDb()
|
||||
|
||||
// Load once — result is null, cached
|
||||
await loadEntitlement(db)
|
||||
|
||||
// Now insert a binding
|
||||
const cert = sign(TEST_SECRET, {
|
||||
account_id: 'acct-1',
|
||||
instance_id: 'inst-1',
|
||||
@@ -157,18 +125,12 @@ describe('invalidateEntitlementCache', () => {
|
||||
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
})
|
||||
|
||||
await db.insert(appSchema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'token',
|
||||
cachedCert: cert,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'token',
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
})
|
||||
|
||||
// Without invalidation, the cached null would be returned
|
||||
invalidateEntitlementCache()
|
||||
|
||||
const result = await loadEntitlement(db)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ProFeature } from '@shared/types'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { licenseBinding } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { loadLicenseState } from './license-state'
|
||||
import { verifyCertificate } from './verify'
|
||||
|
||||
export interface EntitlementSummary {
|
||||
@@ -14,36 +13,25 @@ const CACHE_TTL_MS = 60_000
|
||||
let cachedSummary: EntitlementSummary | null = null
|
||||
let cachedAt = 0
|
||||
|
||||
// Load and verify the cached license cert from the database.
|
||||
// Result is memoized in-process for 60 seconds — feature checks never block on DB.
|
||||
// Cache is automatically invalidated on restart or after TTL.
|
||||
export async function loadEntitlement(db: Database): Promise<EntitlementSummary | null> {
|
||||
const now = Date.now()
|
||||
if (cachedAt > 0 && now - cachedAt < CACHE_TTL_MS) {
|
||||
return cachedSummary
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ instanceId: licenseBinding.instanceId, cachedCert: licenseBinding.cachedCert })
|
||||
.from(licenseBinding)
|
||||
.where(eq(licenseBinding.id, 1))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row?.cachedCert) {
|
||||
const state = await loadLicenseState(db)
|
||||
if (!state.cachedCert || !state.instanceId) {
|
||||
cachedSummary = null
|
||||
cachedAt = now
|
||||
return null
|
||||
}
|
||||
|
||||
const entitlement = verifyCertificate(row.cachedCert, row.instanceId)
|
||||
const entitlement = verifyCertificate(state.cachedCert, state.instanceId)
|
||||
cachedSummary = entitlement ? { plan: entitlement.plan, features: entitlement.features } : null
|
||||
cachedAt = now
|
||||
return cachedSummary
|
||||
}
|
||||
|
||||
// Invalidate the in-process cache — call after a cert refresh so the next
|
||||
// feature check picks up the new entitlement without waiting for TTL.
|
||||
export function invalidateEntitlementCache(): void {
|
||||
cachedAt = 0
|
||||
cachedSummary = null
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { BindingState, LicenseEntitlement, ProFeature } from '../../shared/types'
|
||||
import { licenseBinding } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { loadLicenseState } from './license-state'
|
||||
import { verifyCertificate } from './verify'
|
||||
|
||||
// Parse a cached cert into a LicenseEntitlement, handling both:
|
||||
// - PASETO v4 signed tokens (verified against PUBLIC_KEYS)
|
||||
// - Legacy JSON objects from the initial pairing poll (pre-signing)
|
||||
function parseCachedCert(cert: string, instanceId: string): LicenseEntitlement | null {
|
||||
if (cert.startsWith('v4.public.')) {
|
||||
return verifyCertificate(cert, instanceId)
|
||||
@@ -19,29 +16,28 @@ function parseCachedCert(cert: string, instanceId: string): LicenseEntitlement |
|
||||
}
|
||||
|
||||
export async function loadBindingState(db: Database): Promise<BindingState> {
|
||||
const rows = await db.select().from(licenseBinding).limit(1)
|
||||
if (rows.length === 0) return { bound: false }
|
||||
const state = await loadLicenseState(db)
|
||||
if (!state.refreshToken) return { bound: false }
|
||||
|
||||
const row = rows[0]
|
||||
const state: BindingState = {
|
||||
const result: BindingState = {
|
||||
bound: true,
|
||||
account_email: row.cloudAccountEmail ?? undefined,
|
||||
last_refresh_at: row.lastRefreshAt ?? undefined,
|
||||
last_refresh_error: row.lastRefreshError ?? undefined,
|
||||
account_email: state.cloudAccountEmail ?? undefined,
|
||||
last_refresh_at: state.lastRefreshAt ?? undefined,
|
||||
last_refresh_error: state.lastRefreshError ?? undefined,
|
||||
}
|
||||
|
||||
if (row.cachedCert && row.instanceId) {
|
||||
const entitlement = parseCachedCert(row.cachedCert, row.instanceId)
|
||||
if (state.cachedCert && state.instanceId) {
|
||||
const entitlement = parseCachedCert(state.cachedCert, state.instanceId)
|
||||
if (entitlement) {
|
||||
state.plan = entitlement.plan
|
||||
state.features = entitlement.features
|
||||
state.expires_at = entitlement.expires_at
|
||||
result.plan = entitlement.plan
|
||||
result.features = entitlement.features
|
||||
result.expires_at = entitlement.expires_at
|
||||
? Math.floor(new Date(entitlement.expires_at).getTime() / 1000)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
return result
|
||||
}
|
||||
|
||||
export function hasFeature(feature: ProFeature, state: BindingState | null): boolean {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { systemOptions } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
// License state stored as system_options keys instead of a dedicated table
|
||||
const LICENSE_KEYS = {
|
||||
instanceId: 'license_instance_id',
|
||||
refreshToken: 'license_refresh_token',
|
||||
cachedCert: 'license_cached_cert',
|
||||
cachedExpiresAt: 'license_cached_expires_at',
|
||||
lastRefreshAt: 'license_last_refresh_at',
|
||||
lastRefreshError: 'license_last_refresh_error',
|
||||
boundAt: 'license_bound_at',
|
||||
cloudAccountEmail: 'license_cloud_account_email',
|
||||
} as const
|
||||
|
||||
const ALL_LICENSE_KEYS = Object.values(LICENSE_KEYS)
|
||||
|
||||
export interface LicenseState {
|
||||
instanceId: string | null
|
||||
refreshToken: string | null
|
||||
cachedCert: string | null
|
||||
cachedExpiresAt: number | null
|
||||
lastRefreshAt: number | null
|
||||
lastRefreshError: string | null
|
||||
boundAt: number | null
|
||||
cloudAccountEmail: string | null
|
||||
}
|
||||
|
||||
export async function loadLicenseState(db: Database): Promise<LicenseState> {
|
||||
const rows = await db
|
||||
.select({ key: systemOptions.key, value: systemOptions.value })
|
||||
.from(systemOptions)
|
||||
.where(inArray(systemOptions.key, ALL_LICENSE_KEYS))
|
||||
|
||||
const map = new Map(rows.map((r) => [r.key, r.value]))
|
||||
|
||||
return {
|
||||
instanceId: map.get(LICENSE_KEYS.instanceId) ?? null,
|
||||
refreshToken: map.get(LICENSE_KEYS.refreshToken) ?? null,
|
||||
cachedCert: map.get(LICENSE_KEYS.cachedCert) ?? null,
|
||||
cachedExpiresAt: toNumber(map.get(LICENSE_KEYS.cachedExpiresAt)),
|
||||
lastRefreshAt: toNumber(map.get(LICENSE_KEYS.lastRefreshAt)),
|
||||
lastRefreshError: map.get(LICENSE_KEYS.lastRefreshError) ?? null,
|
||||
boundAt: toNumber(map.get(LICENSE_KEYS.boundAt)),
|
||||
cloudAccountEmail: map.get(LICENSE_KEYS.cloudAccountEmail) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLicenseOption(db: Database, key: string, value: string): Promise<void> {
|
||||
await db
|
||||
.insert(systemOptions)
|
||||
.values({ key, value, public: false })
|
||||
.onConflictDoUpdate({ target: systemOptions.key, set: { value } })
|
||||
}
|
||||
|
||||
export async function setLicenseOptions(db: Database, entries: Record<string, string | null>): Promise<void> {
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (value === null) {
|
||||
await db.delete(systemOptions).where(eq(systemOptions.key, key))
|
||||
} else {
|
||||
await setLicenseOption(db, key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearLicenseBinding(db: Database): Promise<void> {
|
||||
await db.delete(systemOptions).where(inArray(systemOptions.key, ALL_LICENSE_KEYS))
|
||||
}
|
||||
|
||||
export { LICENSE_KEYS }
|
||||
|
||||
function toNumber(val: string | undefined): number | null {
|
||||
if (val == null) return null
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
@@ -6,22 +6,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
|
||||
import * as authSchema from '../db/auth-schema'
|
||||
import * as appSchema from '../db/schema'
|
||||
import { invalidateEntitlementCache } from './entitlement'
|
||||
import { LICENSE_KEYS, setLicenseOptions } from './license-state'
|
||||
import { PUBLIC_KEYS } from './public-keys'
|
||||
import { performRefresh } from './refresh'
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS license_binding (
|
||||
id INTEGER PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
cloud_account_id TEXT,
|
||||
cloud_account_email TEXT,
|
||||
refresh_token TEXT NOT NULL,
|
||||
cached_cert TEXT,
|
||||
cached_expires_at INTEGER,
|
||||
last_refresh_at INTEGER,
|
||||
last_refresh_error TEXT,
|
||||
bound_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS system_options (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
@@ -29,7 +18,6 @@ const SCHEMA_SQL = `
|
||||
);
|
||||
`
|
||||
|
||||
// Generate a fresh keypair for tests; inject the public key so verifyCertificate can verify.
|
||||
const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public')
|
||||
const originalKeys: string[] = []
|
||||
|
||||
@@ -56,16 +44,10 @@ function makeDb() {
|
||||
|
||||
type DB = ReturnType<typeof makeDb>
|
||||
|
||||
async function seedBinding(db: DB, overrides: Partial<typeof appSchema.licenseBinding.$inferInsert> = {}) {
|
||||
await db.insert(appSchema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-abc',
|
||||
refreshToken: 'old-rt',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
async function seedBinding(db: DB, overrides: Partial<Record<string, string>> = {}) {
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-abc',
|
||||
[LICENSE_KEYS.refreshToken]: 'old-rt',
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
@@ -82,7 +64,6 @@ describe('performRefresh', () => {
|
||||
|
||||
it('is a no-op when no binding exists', async () => {
|
||||
const db = makeDb()
|
||||
// No binding row — should return without error
|
||||
await expect(performRefresh(db, 'https://cloud.zpan.space')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -109,11 +90,12 @@ describe('performRefresh', () => {
|
||||
|
||||
await performRefresh(db, 'https://cloud.zpan.space')
|
||||
|
||||
const rows = await db.select().from(appSchema.licenseBinding).limit(1)
|
||||
expect(rows[0].refreshToken).toBe('new-rt')
|
||||
expect(rows[0].cachedCert).toBe(cert)
|
||||
expect(rows[0].lastRefreshAt).toBeTruthy()
|
||||
expect(rows[0].lastRefreshError).toBeNull()
|
||||
const { loadLicenseState } = await import('./license-state')
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBe('new-rt')
|
||||
expect(state.cachedCert).toBe(cert)
|
||||
expect(state.lastRefreshAt).toBeTruthy()
|
||||
expect(state.lastRefreshError).toBeNull()
|
||||
})
|
||||
|
||||
it('stores raw PASETO cert and extracts expiresAt metadata', async () => {
|
||||
@@ -139,13 +121,14 @@ describe('performRefresh', () => {
|
||||
|
||||
await performRefresh(db, 'https://cloud.zpan.space')
|
||||
|
||||
const rows = await db.select().from(appSchema.licenseBinding).limit(1)
|
||||
expect(rows[0].refreshToken).toBe('new-rt-paseto')
|
||||
expect(rows[0].cachedCert).toBe(cert)
|
||||
expect(rows[0].cachedExpiresAt).toBeTruthy()
|
||||
const { loadLicenseState } = await import('./license-state')
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBe('new-rt-paseto')
|
||||
expect(state.cachedCert).toBe(cert)
|
||||
expect(state.cachedExpiresAt).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears binding row on CloudUnboundError (401)', async () => {
|
||||
it('clears binding on CloudUnboundError (401)', async () => {
|
||||
const db = makeDb()
|
||||
await seedBinding(db)
|
||||
|
||||
@@ -158,8 +141,9 @@ describe('performRefresh', () => {
|
||||
|
||||
await performRefresh(db, 'https://cloud.zpan.space')
|
||||
|
||||
const rows = await db.select().from(appSchema.licenseBinding).limit(1)
|
||||
expect(rows.length).toBe(0)
|
||||
const { loadLicenseState } = await import('./license-state')
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBeNull()
|
||||
})
|
||||
|
||||
it('updates last_refresh_error on network error, keeps binding', async () => {
|
||||
@@ -170,8 +154,9 @@ describe('performRefresh', () => {
|
||||
|
||||
await performRefresh(db, 'https://cloud.zpan.space')
|
||||
|
||||
const rows = await db.select().from(appSchema.licenseBinding).limit(1)
|
||||
expect(rows[0].refreshToken).toBe('old-rt') // unchanged
|
||||
expect(rows[0].lastRefreshError).toBe('Connection timeout')
|
||||
const { loadLicenseState } = await import('./license-state')
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBe('old-rt')
|
||||
expect(state.lastRefreshError).toBe('Connection timeout')
|
||||
})
|
||||
})
|
||||
|
||||
+18
-35
@@ -1,62 +1,45 @@
|
||||
import type { LicenseEntitlement } from '@shared/types'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { licenseBinding } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { CloudNetworkError, CloudUnboundError, refreshEntitlement } from '../services/licensing-cloud'
|
||||
import { invalidateEntitlementCache } from './entitlement'
|
||||
import { clearLicenseBinding, LICENSE_KEYS, loadLicenseState, setLicenseOptions } from './license-state'
|
||||
import { verifyCertificate } from './verify'
|
||||
|
||||
// Store the raw PASETO cert string so readers can verify the signature on read.
|
||||
// Returns the verified entitlement for extracting expiresAt metadata.
|
||||
function normaliseCert(raw: string, instanceId: string): { cert: string; entitlement: LicenseEntitlement | null } {
|
||||
const entitlement = verifyCertificate(raw, instanceId)
|
||||
return { cert: raw, entitlement }
|
||||
}
|
||||
|
||||
// Performs an entitlement refresh against cloud using the stored refresh_token.
|
||||
//
|
||||
// On success: rotates refresh_token + cached_cert, updates last_refresh_at, clears last_refresh_error.
|
||||
// On CloudUnboundError (401): clears the licenseBinding row entirely (instance unbound from cloud).
|
||||
// On network error: updates last_refresh_error only — keeps existing cached cert intact.
|
||||
export async function performRefresh(db: Database, baseUrl: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
instanceId: licenseBinding.instanceId,
|
||||
refreshToken: licenseBinding.refreshToken,
|
||||
})
|
||||
.from(licenseBinding)
|
||||
.where(eq(licenseBinding.id, 1))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return // no binding — nothing to refresh
|
||||
const state = await loadLicenseState(db)
|
||||
if (!state.refreshToken || !state.instanceId) return
|
||||
|
||||
try {
|
||||
const data = await refreshEntitlement(baseUrl, row.refreshToken)
|
||||
const { cert, entitlement } = normaliseCert(data.certificate, row.instanceId)
|
||||
const data = await refreshEntitlement(baseUrl, state.refreshToken)
|
||||
const { cert, entitlement } = normaliseCert(data.certificate, state.instanceId)
|
||||
|
||||
await db
|
||||
.update(licenseBinding)
|
||||
.set({
|
||||
refreshToken: data.refresh_token,
|
||||
cachedCert: cert,
|
||||
cachedExpiresAt: entitlement ? Math.floor(new Date(entitlement.expires_at).getTime() / 1000) : null,
|
||||
lastRefreshAt: Math.floor(Date.now() / 1000),
|
||||
lastRefreshError: null,
|
||||
})
|
||||
.where(eq(licenseBinding.id, 1))
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.refreshToken]: data.refresh_token,
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
[LICENSE_KEYS.cachedExpiresAt]: entitlement
|
||||
? String(Math.floor(new Date(entitlement.expires_at).getTime() / 1000))
|
||||
: null,
|
||||
[LICENSE_KEYS.lastRefreshAt]: String(Math.floor(Date.now() / 1000)),
|
||||
[LICENSE_KEYS.lastRefreshError]: null,
|
||||
})
|
||||
|
||||
invalidateEntitlementCache()
|
||||
} catch (err) {
|
||||
if (err instanceof CloudUnboundError) {
|
||||
await db.delete(licenseBinding).where(eq(licenseBinding.id, 1))
|
||||
await clearLicenseBinding(db)
|
||||
invalidateEntitlementCache()
|
||||
return
|
||||
}
|
||||
|
||||
if (err instanceof CloudNetworkError || err instanceof Error) {
|
||||
await db.update(licenseBinding).set({ lastRefreshError: err.message }).where(eq(licenseBinding.id, 1))
|
||||
// Keep cached cert — it remains valid until its own expires_at
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.lastRefreshError]: err.message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
|
||||
const NOW = Date.now()
|
||||
|
||||
async function seedProLicense(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
const { licenseBinding } = await import('../db/schema.js')
|
||||
await db.insert(licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'test-instance',
|
||||
refreshToken: 'tok',
|
||||
cachedCert: JSON.stringify({
|
||||
const { LICENSE_KEYS, setLicenseOptions } = await import('../licensing/license-state.js')
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'test-instance',
|
||||
[LICENSE_KEYS.refreshToken]: 'tok',
|
||||
[LICENSE_KEYS.cachedCert]: JSON.stringify({
|
||||
plan: 'pro',
|
||||
features: ['white_label'],
|
||||
account_id: 'a',
|
||||
@@ -21,7 +20,7 @@ async function seedProLicense(db: Awaited<ReturnType<typeof createTestApp>>['db'
|
||||
issued_at: '2025-01-01',
|
||||
expires_at: '2099-01-01',
|
||||
}),
|
||||
cachedExpiresAt: Math.floor(Date.now() / 1000) + 99999,
|
||||
[LICENSE_KEYS.cachedExpiresAt]: String(Math.floor(Date.now() / 1000) + 99999),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as schema from '../db/schema.js'
|
||||
import { LICENSE_KEYS, loadLicenseState, setLicenseOptions } from '../licensing/license-state.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
function makeCloudResponse(body: unknown, status = 200): Response {
|
||||
@@ -132,9 +132,8 @@ describe('GET /api/licensing/pair/:code/poll', () => {
|
||||
expect(body.status).toBe('approved')
|
||||
|
||||
// Check that binding was persisted
|
||||
const rows = await db.select().from(schema.licenseBinding).limit(1)
|
||||
expect(rows.length).toBe(1)
|
||||
expect(rows[0].refreshToken).toBe('rt-secret')
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBe('rt-secret')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,15 +150,9 @@ describe('POST /api/licensing/refresh', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'old-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'old-token',
|
||||
})
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
@@ -192,15 +185,9 @@ describe('DELETE /api/licensing/binding', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'some-token',
|
||||
})
|
||||
|
||||
const res = await app.request('/api/licensing/binding', { method: 'DELETE', headers })
|
||||
@@ -209,9 +196,9 @@ describe('DELETE /api/licensing/binding', () => {
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.deleted).toBe(true)
|
||||
|
||||
// Confirm row is gone
|
||||
const rows = await db.select().from(schema.licenseBinding).limit(1)
|
||||
expect(rows.length).toBe(0)
|
||||
// Confirm binding is gone
|
||||
const state = await loadLicenseState(db)
|
||||
expect(state.refreshToken).toBeNull()
|
||||
})
|
||||
|
||||
it('returns deleted: true even when no binding exists', async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { licenseBinding, systemOptions } from '../db/schema'
|
||||
import { systemOptions } from '../db/schema'
|
||||
import { invalidateEntitlementCache } from '../licensing/entitlement'
|
||||
import { getOrCreateInstanceId } from '../licensing/instance-id'
|
||||
import { clearLicenseBinding, LICENSE_KEYS, loadLicenseState, setLicenseOptions } from '../licensing/license-state'
|
||||
import { performRefresh } from '../licensing/refresh'
|
||||
import { verifyCertificate } from '../licensing/verify'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
@@ -17,14 +18,12 @@ function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string
|
||||
const app = new Hono<Env>()
|
||||
.use(requireAdmin)
|
||||
|
||||
// POST /api/licensing/pair — initiate device-code pairing with cloud
|
||||
.post('/pair', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const baseUrl = getCloudBaseUrl(c)
|
||||
|
||||
const instanceId = await getOrCreateInstanceId(db)
|
||||
|
||||
// Read site_title for instance_name; fall back to host header
|
||||
const titleRows = await db
|
||||
.select({ value: systemOptions.value })
|
||||
.from(systemOptions)
|
||||
@@ -38,7 +37,6 @@ const app = new Hono<Env>()
|
||||
return c.json(pairing)
|
||||
})
|
||||
|
||||
// GET /api/licensing/pair/:code/poll — proxy poll to cloud
|
||||
.get('/pair/:code/poll', async (c) => {
|
||||
const { code } = c.req.param()
|
||||
const db = c.get('platform').db
|
||||
@@ -53,14 +51,12 @@ const app = new Hono<Env>()
|
||||
let expiresAt: number | null = null
|
||||
|
||||
if (typeof result.entitlement === 'string') {
|
||||
// PASETO signed cert — store raw token
|
||||
cert = result.entitlement
|
||||
const entitlement = verifyCertificate(cert, instanceId)
|
||||
if (entitlement) {
|
||||
expiresAt = Math.floor(new Date(entitlement.expires_at).getTime() / 1000)
|
||||
}
|
||||
} else {
|
||||
// Unsigned entitlement object from pairing poll — store as JSON
|
||||
cert = JSON.stringify(result.entitlement)
|
||||
const parsed = result.entitlement as { expires_at?: string }
|
||||
if (parsed.expires_at) {
|
||||
@@ -68,60 +64,41 @@ const app = new Hono<Env>()
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(licenseBinding)
|
||||
.values({
|
||||
id: 1,
|
||||
instanceId,
|
||||
refreshToken: result.refresh_token,
|
||||
cachedCert: cert,
|
||||
cachedExpiresAt: expiresAt,
|
||||
lastRefreshAt: Math.floor(Date.now() / 1000),
|
||||
boundAt: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: licenseBinding.id,
|
||||
set: {
|
||||
refreshToken: result.refresh_token,
|
||||
cachedCert: cert,
|
||||
cachedExpiresAt: expiresAt,
|
||||
lastRefreshAt: Math.floor(Date.now() / 1000),
|
||||
boundAt: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
})
|
||||
const nowSec = String(Math.floor(Date.now() / 1000))
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: instanceId,
|
||||
[LICENSE_KEYS.refreshToken]: result.refresh_token,
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
[LICENSE_KEYS.cachedExpiresAt]: expiresAt ? String(expiresAt) : null,
|
||||
[LICENSE_KEYS.lastRefreshAt]: nowSec,
|
||||
[LICENSE_KEYS.boundAt]: nowSec,
|
||||
})
|
||||
|
||||
invalidateEntitlementCache()
|
||||
|
||||
const stateRows = await db.select().from(licenseBinding).where(eq(licenseBinding.id, 1)).limit(1)
|
||||
const row = stateRows[0]
|
||||
|
||||
return c.json({
|
||||
status: 'approved' as const,
|
||||
plan: row?.cachedCert ? getPlanFromCert(row.cachedCert, instanceId) : undefined,
|
||||
plan: getPlanFromCert(cert, instanceId),
|
||||
})
|
||||
}
|
||||
|
||||
return c.json({ status: result.status })
|
||||
})
|
||||
|
||||
// POST /api/licensing/refresh — force an entitlement refresh
|
||||
.post('/refresh', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const baseUrl = getCloudBaseUrl(c)
|
||||
|
||||
await performRefresh(db, baseUrl)
|
||||
|
||||
const rows = await db.select({ lastRefreshAt: licenseBinding.lastRefreshAt }).from(licenseBinding).limit(1)
|
||||
const lastRefreshAt = rows[0]?.lastRefreshAt ?? null
|
||||
|
||||
return c.json({ success: true, last_refresh_at: lastRefreshAt })
|
||||
const state = await loadLicenseState(db)
|
||||
return c.json({ success: true, last_refresh_at: state.lastRefreshAt })
|
||||
})
|
||||
|
||||
// DELETE /api/licensing/binding — local unbind (does NOT call cloud)
|
||||
.delete('/binding', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
|
||||
await db.delete(licenseBinding).where(eq(licenseBinding.id, 1))
|
||||
await clearLicenseBinding(db)
|
||||
invalidateEntitlementCache()
|
||||
|
||||
return c.json({ deleted: true })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as schema from '../db/schema.js'
|
||||
import { LICENSE_KEYS, setLicenseOptions } from '../licensing/license-state.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
|
||||
describe('GET /api/licensing/status', () => {
|
||||
@@ -25,17 +25,14 @@ describe('GET /api/licensing/status', () => {
|
||||
expires_at: new Date(Date.now() + 86400000).toISOString(),
|
||||
}
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
cloudAccountId: 'acc-1',
|
||||
cloudAccountEmail: 'user@example.com',
|
||||
refreshToken: 'secret-refresh-token',
|
||||
cachedCert: JSON.stringify(entitlement),
|
||||
cachedExpiresAt: Math.floor(Date.now() / 1000) + 86400,
|
||||
lastRefreshAt: Math.floor(Date.now() / 1000),
|
||||
lastRefreshError: null,
|
||||
boundAt: Math.floor(Date.now() / 1000),
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'secret-refresh-token',
|
||||
[LICENSE_KEYS.cachedCert]: JSON.stringify(entitlement),
|
||||
[LICENSE_KEYS.cachedExpiresAt]: String(Math.floor(Date.now() / 1000) + 86400),
|
||||
[LICENSE_KEYS.lastRefreshAt]: String(Math.floor(Date.now() / 1000)),
|
||||
[LICENSE_KEYS.boundAt]: String(Math.floor(Date.now() / 1000)),
|
||||
[LICENSE_KEYS.cloudAccountEmail]: 'user@example.com',
|
||||
})
|
||||
|
||||
const res = await app.request('/api/licensing/status')
|
||||
@@ -54,17 +51,9 @@ describe('GET /api/licensing/status', () => {
|
||||
it('returns bound:true with no plan/features when cachedCert is null', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
cloudAccountId: null,
|
||||
cloudAccountEmail: null,
|
||||
refreshToken: 'secret',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'secret',
|
||||
})
|
||||
|
||||
const res = await app.request('/api/licensing/status')
|
||||
@@ -147,15 +136,10 @@ describe('POST /api/licensing/refresh-cron', () => {
|
||||
// 10 minutes ago — outside the 5-minute dedup window
|
||||
const oldRefresh = nowSec - 600
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'old-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: oldRefresh,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'old-token',
|
||||
[LICENSE_KEYS.lastRefreshAt]: String(oldRefresh),
|
||||
})
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
@@ -178,15 +162,10 @@ describe('POST /api/licensing/refresh-cron', () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
const oldRefresh = nowSec - 600
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'old-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: oldRefresh,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'old-token',
|
||||
[LICENSE_KEYS.lastRefreshAt]: String(oldRefresh),
|
||||
})
|
||||
|
||||
// Simulate a network failure from the cloud endpoint
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as schema from '../db/schema.js'
|
||||
import { LICENSE_KEYS, setLicenseOptions } from '../licensing/license-state.js'
|
||||
import * as refreshModule from '../licensing/refresh.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { runLicensingRefresh } from './licensing-refresh-runner.js'
|
||||
|
||||
const CLOUD_URL = 'https://cloud.zpan.space'
|
||||
|
||||
async function seedLicenseBinding(
|
||||
db: Parameters<typeof setLicenseOptions>[0],
|
||||
overrides: { lastRefreshAt?: number | null } = {},
|
||||
) {
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'inst-1',
|
||||
[LICENSE_KEYS.refreshToken]: 'some-token',
|
||||
...(overrides.lastRefreshAt != null ? { [LICENSE_KEYS.lastRefreshAt]: String(overrides.lastRefreshAt) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
describe('runLicensingRefresh', () => {
|
||||
let performRefreshSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
@@ -17,7 +28,7 @@ describe('runLicensingRefresh', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns immediately with no-op when no licenseBinding row exists', async () => {
|
||||
it('returns immediately with no-op when no license binding exists', async () => {
|
||||
const { db } = await createTestApp()
|
||||
|
||||
await expect(runLicensingRefresh(db, CLOUD_URL)).resolves.toBeUndefined()
|
||||
@@ -27,20 +38,8 @@ describe('runLicensingRefresh', () => {
|
||||
it('skips performRefresh when lastRefreshAt is within 5 minutes', async () => {
|
||||
const { db } = await createTestApp()
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
// 2 minutes ago — still within the 5-minute dedup window
|
||||
const recentRefresh = nowSec - 120
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: recentRefresh,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
const recentRefresh = Math.floor(Date.now() / 1000) - 120
|
||||
await seedLicenseBinding(db, { lastRefreshAt: recentRefresh })
|
||||
|
||||
await runLicensingRefresh(db, CLOUD_URL)
|
||||
|
||||
@@ -50,20 +49,8 @@ describe('runLicensingRefresh', () => {
|
||||
it('calls performRefresh when lastRefreshAt is older than 5 minutes', async () => {
|
||||
const { db } = await createTestApp()
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
// 10 minutes ago — outside the 5-minute dedup window
|
||||
const oldRefresh = nowSec - 600
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: oldRefresh,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
const oldRefresh = Math.floor(Date.now() / 1000) - 600
|
||||
await seedLicenseBinding(db, { lastRefreshAt: oldRefresh })
|
||||
|
||||
performRefreshSpy.mockResolvedValueOnce(undefined)
|
||||
|
||||
@@ -76,16 +63,7 @@ describe('runLicensingRefresh', () => {
|
||||
it('calls performRefresh when lastRefreshAt is null', async () => {
|
||||
const { db } = await createTestApp()
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
await seedLicenseBinding(db)
|
||||
|
||||
performRefreshSpy.mockResolvedValueOnce(undefined)
|
||||
|
||||
@@ -98,16 +76,7 @@ describe('runLicensingRefresh', () => {
|
||||
const { db } = await createTestApp()
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
await seedLicenseBinding(db)
|
||||
|
||||
performRefreshSpy.mockResolvedValueOnce(undefined)
|
||||
|
||||
@@ -120,16 +89,7 @@ describe('runLicensingRefresh', () => {
|
||||
const { db } = await createTestApp()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
await seedLicenseBinding(db)
|
||||
|
||||
performRefreshSpy.mockRejectedValueOnce(new Error('network timeout'))
|
||||
|
||||
@@ -142,16 +102,7 @@ describe('runLicensingRefresh', () => {
|
||||
const { db } = await createTestApp()
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
await seedLicenseBinding(db)
|
||||
|
||||
performRefreshSpy.mockRejectedValueOnce('plain string error')
|
||||
|
||||
@@ -164,16 +115,7 @@ describe('runLicensingRefresh', () => {
|
||||
const { db } = await createTestApp()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
await seedLicenseBinding(db)
|
||||
|
||||
performRefreshSpy.mockRejectedValueOnce(new Error('unexpected'))
|
||||
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
// Shared entitlement refresh runner — called from all entry points
|
||||
// (CF scheduled, Node interval, REST cron endpoint).
|
||||
//
|
||||
// Guards:
|
||||
// - Unbound (no licenseBinding row): no-op
|
||||
// - last_refresh_at within 5 minutes: skip (deduplication)
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { licenseBinding } from '../db/schema'
|
||||
import { loadLicenseState } from '../licensing/license-state'
|
||||
import { performRefresh } from '../licensing/refresh'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
const DEDUP_WINDOW_SEC = 5 * 60 // 5 minutes
|
||||
const DEDUP_WINDOW_SEC = 5 * 60
|
||||
|
||||
export async function runLicensingRefresh(db: Database, cloudBaseUrl: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({ lastRefreshAt: licenseBinding.lastRefreshAt })
|
||||
.from(licenseBinding)
|
||||
.where(eq(licenseBinding.id, 1))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return // unbound — no-op
|
||||
const state = await loadLicenseState(db)
|
||||
if (!state.refreshToken) return // unbound — no-op
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
if (row.lastRefreshAt != null && nowSec - row.lastRefreshAt < DEDUP_WINDOW_SEC) return
|
||||
if (state.lastRefreshAt != null && nowSec - state.lastRefreshAt < DEDUP_WINDOW_SEC) return
|
||||
|
||||
try {
|
||||
await performRefresh(db, cloudBaseUrl)
|
||||
|
||||
@@ -14,6 +14,7 @@ async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknow
|
||||
}
|
||||
|
||||
async function seedProLicense(ctx: TestCtx, features: string[] = ['open_registration']) {
|
||||
const { LICENSE_KEYS, setLicenseOptions } = await import('../licensing/license-state.js')
|
||||
const cert = JSON.stringify({
|
||||
account_id: 'test-account',
|
||||
instance_id: 'test-instance',
|
||||
@@ -22,11 +23,10 @@ async function seedProLicense(ctx: TestCtx, features: string[] = ['open_registra
|
||||
issued_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 86400 * 1000).toISOString(),
|
||||
})
|
||||
await ctx.db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'test-instance',
|
||||
refreshToken: 'test-refresh-token',
|
||||
cachedCert: cert,
|
||||
await setLicenseOptions(ctx.db, {
|
||||
[LICENSE_KEYS.instanceId]: 'test-instance',
|
||||
[LICENSE_KEYS.refreshToken]: 'test-refresh-token',
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { createApp } from '../app'
|
||||
import { createAuth } from '../auth'
|
||||
@@ -330,8 +329,11 @@ export async function seedProLicense(
|
||||
features: string[] = ['white_label', 'open_registration', 'teams_unlimited', 'team_quotas'],
|
||||
) {
|
||||
const cert = JSON.stringify({ plan: 'pro', features })
|
||||
await db.run(sql`
|
||||
INSERT OR REPLACE INTO license_binding (id, instance_id, refresh_token, cached_cert, bound_at)
|
||||
VALUES (1, 'test-instance', 'test-refresh-token', ${cert}, ${Date.now()})
|
||||
`)
|
||||
const { LICENSE_KEYS, setLicenseOptions } = await import('../licensing/license-state.js')
|
||||
await setLicenseOptions(db, {
|
||||
[LICENSE_KEYS.instanceId]: 'test-instance',
|
||||
[LICENSE_KEYS.refreshToken]: 'test-refresh-token',
|
||||
[LICENSE_KEYS.cachedCert]: cert,
|
||||
[LICENSE_KEYS.boundAt]: String(Date.now()),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user