Files
zpan/server/licensing/instance-id.ts
T
Jasper VanandBob 86fab7716b feat(licensing): cloud client + binding API (pair, poll, refresh, disconnect) (#343)
* feat(licensing): cloud client + binding API (pair, poll, refresh, disconnect)

- server/licensing/public-keys.ts — DEV PASERK placeholder (production key lands via C5 cross-repo PR)
- server/licensing/verify.ts — verifyCertificate() using paseto-ts/v4, returns LicenseEntitlement | null
- server/licensing/entitlement.ts — loadEntitlement() with 60s in-process memoization + invalidateEntitlementCache()
- server/licensing/has-feature.ts — loadBindingState() + hasFeature() pure sync check
- server/licensing/instance-id.ts — getOrCreateInstanceId() lazily persisted in systemOptions under 'instance_id'
- server/licensing/refresh.ts — performRefresh(): calls cloud, verifies cert, rotates DB row; handles CloudUnboundError (clear binding) and CloudNetworkError (update error log, keep cached cert)
- server/services/licensing-cloud.ts — createPairing(), pollPairing(), refreshEntitlement() with 10s timeout; CloudUnboundError + CloudNetworkError for typed error handling
- server/routes/licensing.ts — public GET /api/licensing/status (no auth required)
- server/routes/licensing-admin.ts — admin-only: POST /pair, GET /pair/:code/poll, POST /refresh, DELETE /binding
- server/middleware/require-feature.ts — requireFeature(name) middleware, returns 402 when feature missing
- server/app.ts — mount /api/licensing (public) + /api/licensing (admin) + export route types
- src/lib/rpc.ts — licensingApi + licensingAdminApi RPC clients
- src/lib/api.ts — getLicensingStatus(), connectCloud(), pollPairing(), refreshLicense(), disconnectCloud()
- src/lib/api.test.ts — 17 new tests covering all 5 new api.ts wrappers
- shared/types/licensing.ts — update LicenseEntitlement.issued_at/expires_at to string (ISO-8601)
- paseto-ts dependency added for PASETO v4 public verification

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix(licensing): fix biome lint issues — remove unused imports, format test file

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix(licensing): apply biome format fixes to refresh, require-feature, licensing-cloud

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* test(licensing): add unit and integration tests for all new licensing modules

- server/licensing/public-keys.test.ts — PUBLIC_KEYS format validation
- server/licensing/verify.test.ts — verifyCertificate: valid cert, invalid sig, expired, instance mismatch, key rotation
- server/licensing/has-feature.test.ts — hasFeature: null/unbound/empty/expired/future states
- server/services/licensing-cloud.test.ts — createPairing, pollPairing, refreshEntitlement: success, 401 Unbound, network error
- server/routes/licensing.integration.test.ts — GET /api/licensing/status: unbound, bound+cert, bound+no-cert, public access
- server/routes/licensing-admin.integration.test.ts — auth guards (401/403) + POST /pair, GET /pair/:code/poll, POST /refresh, DELETE /binding
- server/test/setup.ts — add license_binding table to in-memory schema

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* test(licensing): add entitlement cache and refresh orchestration unit tests

- entitlement.test.ts — loadEntitlement: no row, no cert, valid PASETO cert, expired cert; invalidateEntitlementCache: re-reads from DB after invalidation
- refresh.test.ts — performRefresh: no-op when unbound, rotates token (pre-C5 object), rotates token (PASETO string), clears binding on 401 Unbound, updates error log on network failure

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
2026-04-24 07:37:30 -04:00

27 lines
842 B
TypeScript

import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { systemOptions } from '../db/schema'
import type { Database } from '../platform/interface'
const INSTANCE_ID_KEY = 'instance_id'
// Returns the instance UUID, creating and persisting one if it does not exist.
// The ID is stored in systemOptions under key 'instance_id'.
export async function getOrCreateInstanceId(db: Database): Promise<string> {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, INSTANCE_ID_KEY))
.limit(1)
if (rows[0]?.value) return rows[0].value
const id = nanoid(21)
await db
.insert(systemOptions)
.values({ key: INSTANCE_ID_KEY, value: id, public: false })
.onConflictDoUpdate({ target: systemOptions.key, set: { value: id } })
return id
}