mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat: v2.6 Z6 — 6h background entitlement refresh (#345)
- Add server/services/licensing-refresh-runner.ts: shared runner with 5-min dedup guard, structured INFO logs, and no-op for unbound state - Add workers/scheduled.ts + export scheduled() in workers/bootstrap.ts for CF Workers cron (every 6 hours) - Add [triggers] crons = ["0 */6 * * *"] to wrangler.toml - Add setInterval refresh on boot in server/entry-node.ts with "licensing.refresh.scheduler.started interval=6h" log - Add POST /api/licensing/refresh-cron?secret=... public endpoint (timing-safe secret comparison) for non-CF platforms - Extract ZPAN_CLOUD_URL_DEFAULT to shared/constants.ts, replacing four duplicated literals - Document REFRESH_CRON_SECRET + scheduler setup in all 5 non-CF deploy guides (vercel, netlify, aws-lambda, azure-functions, cloud-run) Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
@@ -86,3 +86,30 @@ With the AWS free tier and Turso free tier, personal ZPan usage costs $0/month:
|
||||
| Turso | 9 GB storage, 1B row reads / month | Shared across all deployments |
|
||||
|
||||
S3 (or R2/Tigris) for ZPan file storage is billed separately and depends on your usage. ZPan itself does not add server-side bandwidth costs because files transfer directly between client and S3.
|
||||
|
||||
---
|
||||
|
||||
## Entitlement Refresh (License Cert)
|
||||
|
||||
ZPan refreshes its entitlement certificate every 6 hours. On Lambda there is no persistent process, so you need to trigger a refresh via an external scheduler.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Generate a secret:**
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
2. **Add the env var** to the Lambda function configuration (via the SAM template or the AWS Console → Lambda → Configuration → Environment variables):
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `REFRESH_CRON_SECRET` | The random string from step 1 |
|
||||
|
||||
3. **Schedule the call** using [Amazon EventBridge Scheduler](https://docs.aws.amazon.com/scheduler/latest/UserGuide/). Create a schedule with:
|
||||
- **Rate**: `rate(6 hours)` or cron `0 */6 * * ? *`
|
||||
- **Target**: HTTPS `POST` to your Lambda Function URL:
|
||||
```
|
||||
POST https://<your-lambda-url>/api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>
|
||||
```
|
||||
|
||||
If `REFRESH_CRON_SECRET` is not set, the endpoint returns `401` for all requests.
|
||||
|
||||
@@ -142,3 +142,32 @@ func start
|
||||
```
|
||||
|
||||
Requires the [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) and a local `.env` file (or environment variables) with `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, and `BETTER_AUTH_SECRET`.
|
||||
|
||||
---
|
||||
|
||||
## Entitlement Refresh (License Cert)
|
||||
|
||||
ZPan refreshes its entitlement certificate every 6 hours. On Azure Functions there is no persistent process, so you need to trigger a refresh via an external scheduler.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Generate a secret:**
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
2. **Add the env var** in the Azure portal (**Function App → Configuration → Application settings**) or via CLI:
|
||||
```sh
|
||||
az functionapp config appsettings set \
|
||||
--name <your-function-app> \
|
||||
--resource-group <your-rg> \
|
||||
--settings REFRESH_CRON_SECRET=<your-secret>
|
||||
```
|
||||
|
||||
3. **Schedule the call** using [Azure Logic Apps](https://learn.microsoft.com/en-us/azure/logic-apps/) or a Timer Trigger function that makes an HTTP POST to:
|
||||
```
|
||||
POST https://<your-function-app>.azurewebsites.net/api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>
|
||||
```
|
||||
Use a recurrence schedule of `0 */6 * * *` (every 6 hours).
|
||||
|
||||
If `REFRESH_CRON_SECRET` is not set, the endpoint returns `401` for all requests.
|
||||
|
||||
@@ -141,3 +141,34 @@ GCS (Google Cloud Storage) is **not supported** as a storage backend — it uses
|
||||
- **Cloudflare R2** — 10 GB storage free, zero egress fees.
|
||||
|
||||
A personal ZPan instance with light usage fits entirely within free tiers across all services.
|
||||
|
||||
---
|
||||
|
||||
## Entitlement Refresh (License Cert)
|
||||
|
||||
ZPan refreshes its entitlement certificate every 6 hours. On Cloud Run there is no persistent background process between requests, so you need to trigger a refresh via an external scheduler.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Generate a secret:**
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
2. **Add the env var** as a Cloud Run environment variable (or via Secret Manager). With `gcloud`:
|
||||
```sh
|
||||
gcloud run services update zpan \
|
||||
--region <your-region> \
|
||||
--update-env-vars REFRESH_CRON_SECRET=<your-secret>
|
||||
```
|
||||
|
||||
3. **Schedule the call** using [Cloud Scheduler](https://cloud.google.com/scheduler). Create a job:
|
||||
```sh
|
||||
gcloud scheduler jobs create http zpan-license-refresh \
|
||||
--schedule="0 */6 * * *" \
|
||||
--uri="https://<your-cloud-run-url>/api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>" \
|
||||
--http-method=POST \
|
||||
--location=<your-region>
|
||||
```
|
||||
|
||||
If `REFRESH_CRON_SECRET` is not set, the endpoint returns `401` for all requests.
|
||||
|
||||
@@ -125,3 +125,28 @@ npm run db:migrate
|
||||
| Turso Row writes | 25 M/month |
|
||||
|
||||
Upgrade to Netlify Pro ($19/month) or Turso Scaler ($29/month) when you need more.
|
||||
|
||||
---
|
||||
|
||||
## Entitlement Refresh (License Cert)
|
||||
|
||||
ZPan refreshes its entitlement certificate every 6 hours. On Netlify there is no persistent process, so you need to trigger a refresh via an external scheduler.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Generate a secret:**
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
2. **Add the env var** via the Netlify dashboard (**Site configuration → Environment variables**) or CLI:
|
||||
```sh
|
||||
netlify env:set REFRESH_CRON_SECRET <your-secret> --context production
|
||||
```
|
||||
|
||||
3. **Schedule the call** using [Netlify Scheduled Functions](https://docs.netlify.com/functions/scheduled-functions/) or an external cron service (e.g. [cron-job.org](https://cron-job.org)). Make an HTTP POST request every 6 hours to:
|
||||
```
|
||||
POST https://your-site.netlify.app/api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>
|
||||
```
|
||||
|
||||
If `REFRESH_CRON_SECRET` is not set, the endpoint returns `401` for all requests.
|
||||
|
||||
@@ -83,6 +83,37 @@ The app will be available at `http://localhost:3000`.
|
||||
- `/api/*` and `/health` → Vercel Function
|
||||
- All other paths → `dist/index.html` (SPA)
|
||||
|
||||
## Entitlement Refresh (License Cert)
|
||||
|
||||
ZPan refreshes its entitlement certificate every 6 hours. On Vercel there is no persistent process, so you need to trigger a refresh via an external scheduler.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Generate a secret:**
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
2. **Add the env var** in your Vercel project settings (**Project → Settings → Environment Variables**):
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `REFRESH_CRON_SECRET` | The random string from step 1 |
|
||||
|
||||
3. **Schedule the call** using [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs). Add a `crons` entry to your `vercel.json`:
|
||||
```json
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/licensing/refresh-cron?secret=<YOUR_SECRET>",
|
||||
"schedule": "0 */6 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
Replace `<YOUR_SECRET>` with the value of `REFRESH_CRON_SECRET`.
|
||||
|
||||
If `REFRESH_CRON_SECRET` is not set, the endpoint returns `401` for all requests.
|
||||
|
||||
## Pricing Notes
|
||||
|
||||
- **Hobby (free)** — suitable for personal and non-commercial use. 100 GB-hours of function compute per month.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { serve } from '@hono/node-server'
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { Hono } from 'hono'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
|
||||
import { createBootstrap } from './bootstrap'
|
||||
import { createLibsqlPlatform } from './platform/libsql'
|
||||
import { createNodePlatform } from './platform/node'
|
||||
import { runLicensingRefresh } from './services/licensing-refresh-runner'
|
||||
|
||||
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
|
||||
|
||||
const platform = process.env.TURSO_DATABASE_URL
|
||||
? await createLibsqlPlatform({
|
||||
@@ -22,3 +26,11 @@ server.get('/*', serveStatic({ root: './dist', path: 'index.html' }))
|
||||
const port = Number(process.env.PORT) || 8222
|
||||
console.log(`ZPan server running on http://localhost:${port}`)
|
||||
serve({ fetch: server.fetch, port })
|
||||
|
||||
// Start licensing refresh background scheduler
|
||||
const cloudBaseUrl = process.env.ZPAN_CLOUD_URL ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
console.log('licensing.refresh.scheduler.started interval=6h')
|
||||
setInterval(() => {
|
||||
// runLicensingRefresh handles all errors internally and never rejects.
|
||||
void runLicensingRefresh(platform.db, cloudBaseUrl)
|
||||
}, REFRESH_INTERVAL_MS)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { licenseBinding, systemOptions } from '../db/schema'
|
||||
import { invalidateEntitlementCache } from '../licensing/entitlement'
|
||||
import { getOrCreateInstanceId } from '../licensing/instance-id'
|
||||
@@ -9,10 +10,8 @@ import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { createPairing, pollPairing } from '../services/licensing-cloud'
|
||||
|
||||
const CLOUD_BASE_URL_DEFAULT = 'https://cloud.zpan.space'
|
||||
|
||||
function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string {
|
||||
return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? CLOUD_BASE_URL_DEFAULT
|
||||
return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
}
|
||||
|
||||
const app = new Hono<Env>()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as schema from '../db/schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
|
||||
@@ -82,3 +82,130 @@ describe('GET /api/licensing/status', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
function makeCloudResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('POST /api/licensing/refresh-cron', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 when REFRESH_CRON_SECRET env is not set', async () => {
|
||||
const { app } = await createTestApp()
|
||||
|
||||
const res = await app.request('/api/licensing/refresh-cron?secret=anything', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 401 when secret param does not match REFRESH_CRON_SECRET', async () => {
|
||||
const { app } = await createTestApp({ REFRESH_CRON_SECRET: 'correct-secret' })
|
||||
|
||||
const res = await app.request('/api/licensing/refresh-cron?secret=wrong-secret', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 401 when secret query param is missing', async () => {
|
||||
const { app } = await createTestApp({ REFRESH_CRON_SECRET: 'correct-secret' })
|
||||
|
||||
const res = await app.request('/api/licensing/refresh-cron', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 200 with { ok: true } when secret is correct and no binding exists', async () => {
|
||||
const { app } = await createTestApp({ REFRESH_CRON_SECRET: 'correct-secret' })
|
||||
|
||||
const res = await app.request('/api/licensing/refresh-cron?secret=correct-secret', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 200 with { ok: true } and calls refresh when binding exists with old lastRefreshAt', async () => {
|
||||
const { app, db } = await createTestApp({ REFRESH_CRON_SECRET: 'cron-secret' })
|
||||
|
||||
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: 'old-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: oldRefresh,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
makeCloudResponse({
|
||||
refresh_token: 'new-token',
|
||||
entitlement: { plan: 'pro', features: [], expires_at: '2027-01-01T00:00:00Z' },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.request('/api/licensing/refresh-cron?secret=cron-secret', { method: 'POST' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 200 with { ok: true } even when performRefresh throws (error is swallowed)', async () => {
|
||||
const { app, db } = await createTestApp({ REFRESH_CRON_SECRET: 'cron-secret' })
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
// Simulate a network failure from the cloud endpoint
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('network failure'))
|
||||
|
||||
const res = await app.request('/api/licensing/refresh-cron?secret=cron-secret', { method: 'POST' })
|
||||
|
||||
// runLicensingRefresh catches all errors and logs them — never rethrows
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('is accessible without authentication (public route)', async () => {
|
||||
const { app } = await createTestApp({ REFRESH_CRON_SECRET: 'my-secret' })
|
||||
|
||||
const res = await app.request('/api/licensing/refresh-cron?secret=my-secret', { method: 'POST' })
|
||||
|
||||
// Should not return 401 due to missing auth session
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { Hono } from 'hono'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import type { BindingState } from '../../shared/types'
|
||||
import { loadBindingState } from '../licensing/has-feature'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { runLicensingRefresh } from '../services/licensing-refresh-runner'
|
||||
|
||||
const app = new Hono<Env>().get('/status', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const state = await loadBindingState(db)
|
||||
return c.json(state satisfies BindingState)
|
||||
})
|
||||
function secretsMatch(provided: string, expected: string): boolean {
|
||||
if (provided.length !== expected.length) return false
|
||||
const enc = new TextEncoder()
|
||||
return timingSafeEqual(enc.encode(provided), enc.encode(expected))
|
||||
}
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.get('/status', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const state = await loadBindingState(db)
|
||||
return c.json(state satisfies BindingState)
|
||||
})
|
||||
|
||||
// POST /api/licensing/refresh-cron?secret=<REFRESH_CRON_SECRET>
|
||||
// External schedulers (Vercel Cron, Netlify Scheduled Functions, etc.) call
|
||||
// this endpoint every 6 hours instead of running a native cron trigger.
|
||||
// Set REFRESH_CRON_SECRET to a random string (e.g. openssl rand -hex 32)
|
||||
// and pass it as the `secret` query parameter.
|
||||
.post('/refresh-cron', async (c) => {
|
||||
const expectedSecret = c.get('platform').getEnv('REFRESH_CRON_SECRET')
|
||||
const provided = c.req.query('secret') ?? ''
|
||||
if (!expectedSecret || !secretsMatch(provided, expectedSecret)) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
|
||||
const db = c.get('platform').db
|
||||
const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
await runLicensingRefresh(db, cloudBaseUrl)
|
||||
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as schema from '../db/schema.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'
|
||||
|
||||
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 licenseBinding row exists', async () => {
|
||||
const { db } = await createTestApp()
|
||||
|
||||
await expect(runLicensingRefresh(db, CLOUD_URL)).resolves.toBeUndefined()
|
||||
expect(performRefreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
await runLicensingRefresh(db, CLOUD_URL)
|
||||
|
||||
expect(performRefreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
performRefreshSpy.mockResolvedValueOnce(undefined)
|
||||
|
||||
await runLicensingRefresh(db, CLOUD_URL)
|
||||
|
||||
expect(performRefreshSpy).toHaveBeenCalledOnce()
|
||||
expect(performRefreshSpy).toHaveBeenCalledWith(db, CLOUD_URL)
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
performRefreshSpy.mockResolvedValueOnce(undefined)
|
||||
|
||||
await runLicensingRefresh(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 db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
|
||||
performRefreshSpy.mockResolvedValueOnce(undefined)
|
||||
|
||||
await runLicensingRefresh(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 db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
|
||||
performRefreshSpy.mockRejectedValueOnce(new Error('network timeout'))
|
||||
|
||||
await runLicensingRefresh(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 db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
|
||||
performRefreshSpy.mockRejectedValueOnce('plain string error')
|
||||
|
||||
await runLicensingRefresh(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 db.insert(schema.licenseBinding).values({
|
||||
id: 1,
|
||||
instanceId: 'inst-1',
|
||||
refreshToken: 'some-token',
|
||||
cachedCert: null,
|
||||
cachedExpiresAt: null,
|
||||
lastRefreshAt: null,
|
||||
lastRefreshError: null,
|
||||
boundAt: null,
|
||||
})
|
||||
|
||||
performRefreshSpy.mockRejectedValueOnce(new Error('unexpected'))
|
||||
|
||||
await expect(runLicensingRefresh(db, CLOUD_URL)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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 { performRefresh } from '../licensing/refresh'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
const DEDUP_WINDOW_SEC = 5 * 60 // 5 minutes
|
||||
|
||||
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 nowSec = Math.floor(Date.now() / 1000)
|
||||
if (row.lastRefreshAt != null && nowSec - row.lastRefreshAt < DEDUP_WINDOW_SEC) return
|
||||
|
||||
try {
|
||||
await performRefresh(db, cloudBaseUrl)
|
||||
console.log('licensing.refresh.ok')
|
||||
} catch (err) {
|
||||
const code = err instanceof Error ? err.message : String(err)
|
||||
console.error(`licensing.refresh.error code=${code}`)
|
||||
}
|
||||
}
|
||||
@@ -51,3 +51,5 @@ export const ProFeatures = {
|
||||
} as const
|
||||
|
||||
export type ProFeatures = (typeof ProFeatures)[keyof typeof ProFeatures]
|
||||
|
||||
export const ZPAN_CLOUD_URL_DEFAULT = 'https://cloud.zpan.space'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createAuth } from '../server/auth'
|
||||
import { createCloudflarePlatform } from '../server/platform/cloudflare'
|
||||
import { resolveShareByToken } from '../server/services/share'
|
||||
import { DirType } from '../shared/constants'
|
||||
import { handleScheduled } from './scheduled'
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
@@ -47,6 +48,10 @@ export default {
|
||||
|
||||
return createApp(platform, cachedAuth).fetch(request)
|
||||
},
|
||||
|
||||
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
|
||||
await handleScheduled(env)
|
||||
},
|
||||
}
|
||||
|
||||
interface ShareMeta {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// CF Workers scheduled() handler — invoked by the cron trigger every 6 hours.
|
||||
// Delegates to the shared licensing refresh runner.
|
||||
|
||||
import { createCloudflarePlatform } from '../server/platform/cloudflare'
|
||||
import { runLicensingRefresh } from '../server/services/licensing-refresh-runner'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
|
||||
|
||||
// Subset of the worker Env used by the scheduled handler.
|
||||
// The full Env is defined in bootstrap.ts; this avoids circular imports.
|
||||
export interface ScheduledEnv {
|
||||
DB: D1Database
|
||||
ZPAN_CLOUD_URL?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function handleScheduled(env: ScheduledEnv): Promise<void> {
|
||||
const platform = createCloudflarePlatform(env)
|
||||
const cloudBaseUrl = env.ZPAN_CLOUD_URL ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
await runLicensingRefresh(platform.db, cloudBaseUrl)
|
||||
}
|
||||
@@ -24,6 +24,9 @@ bucket_name = "zpan-public-images"
|
||||
[observability]
|
||||
enabled = true
|
||||
|
||||
[triggers]
|
||||
crons = ["0 */6 * * *"]
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Staging environment — used by non-production branch builds.
|
||||
# CLOUDFLARE_ENV=staging is set automatically in the build script.
|
||||
|
||||
Reference in New Issue
Block a user