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:
Jasper Van
2026-04-24 08:22:10 -04:00
committed by GitHub
parent d24c388150
commit 29102e623d
15 changed files with 566 additions and 9 deletions
+27
View File
@@ -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.
+29
View File
@@ -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.
+31
View File
@@ -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.
+25
View File
@@ -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.
+31
View File
@@ -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.
+12
View File
@@ -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)
+2 -3
View File
@@ -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>()
+128 -1
View File
@@ -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)
})
})
+34 -5
View File
@@ -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}`)
}
}
+2
View File
@@ -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'
+5
View File
@@ -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 {
+20
View File
@@ -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)
}
+3
View File
@@ -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.