From fa943ca8b36bed71e837e8989ac0ad800fcd7cf3 Mon Sep 17 00:00:00 2001 From: saltbo Date: Fri, 24 Apr 2026 22:58:13 -0400 Subject: [PATCH] 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> --- migrations/0014_drop-license-binding.sql | 1 + migrations/meta/0014_snapshot.json | 1943 +++++++++++++++++ migrations/meta/_journal.json | 7 + server/auth.integration.test.ts | 10 +- server/db/schema.ts | 14 - .../licensing/e2e-cloud-integration.test.ts | 41 +- server/licensing/entitlement.test.ts | 74 +- server/licensing/entitlement.ts | 20 +- server/licensing/has-feature.ts | 30 +- server/licensing/license-state.ts | 77 + server/licensing/refresh.test.ts | 63 +- server/licensing/refresh.ts | 53 +- server/routes/branding.integration.test.ts | 13 +- .../licensing-admin.integration.test.ts | 37 +- server/routes/licensing-admin.ts | 53 +- server/routes/licensing.integration.test.ts | 61 +- .../services/licensing-refresh-runner.test.ts | 102 +- server/services/licensing-refresh-runner.ts | 24 +- .../signup-mode-guard.integration.test.ts | 10 +- server/test/setup.ts | 12 +- 20 files changed, 2219 insertions(+), 426 deletions(-) create mode 100644 migrations/0014_drop-license-binding.sql create mode 100644 migrations/meta/0014_snapshot.json create mode 100644 server/licensing/license-state.ts diff --git a/migrations/0014_drop-license-binding.sql b/migrations/0014_drop-license-binding.sql new file mode 100644 index 00000000..505dbf1a --- /dev/null +++ b/migrations/0014_drop-license-binding.sql @@ -0,0 +1 @@ +DROP TABLE `license_binding`; \ No newline at end of file diff --git a/migrations/meta/0014_snapshot.json b/migrations/meta/0014_snapshot.json new file mode 100644 index 00000000..d36810ec --- /dev/null +++ b/migrations/meta/0014_snapshot.json @@ -0,0 +1,1943 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3984fae9-a0b6-49bc-bd20-9c4300eefbe2", + "prevId": "4ea2bc15-e04f-4183-ae10-b8535dfa1fa2", + "tables": { + "activity_events": { + "name": "activity_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_name": { + "name": "target_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hosting_configs": { + "name": "image_hosting_configs", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_verified_at": { + "name": "domain_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referer_allowlist": { + "name": "referer_allowlist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hosting_configs_custom_domain_unique": { + "name": "image_hosting_configs_custom_domain_unique", + "columns": [ + "custom_domain" + ], + "isUnique": true + } + }, + "foreignKeys": { + "image_hosting_configs_org_id_organization_id_fk": { + "name": "image_hosting_configs_org_id_organization_id_fk", + "tableFrom": "image_hosting_configs", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hostings": { + "name": "image_hostings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hostings_token_unique": { + "name": "image_hostings_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "image_hostings_org_path_uniq": { + "name": "image_hostings_org_path_uniq", + "columns": [ + "org_id", + "path" + ], + "isUnique": true + }, + "image_hostings_org_created_idx": { + "name": "image_hostings_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "image_hostings_token_idx": { + "name": "image_hostings_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "image_hostings_org_id_organization_id_fk": { + "name": "image_hostings_org_id_organization_id_fk", + "tableFrom": "image_hostings", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_hostings_storage_id_storages_id_fk": { + "name": "image_hostings_storage_id_storages_id_fk", + "tableFrom": "image_hostings", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite_codes": { + "name": "invite_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_by": { + "name": "used_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_codes_code_unique": { + "name": "invite_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "matters": { + "name": "matters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "dirtype": { + "name": "dirtype", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent": { + "name": "parent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "trashed_at": { + "name": "trashed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "matters_alias_unique": { + "name": "matters_alias_unique", + "columns": [ + "alias" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "notifications_user_read_idx": { + "name": "notifications_user_read_idx", + "columns": [ + "user_id", + "read_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quotas": { + "name": "org_quotas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quota": { + "name": "quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_recipients": { + "name": "share_recipients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "share_recipients_share_id_idx": { + "name": "share_recipients_share_id_idx", + "columns": [ + "share_id" + ], + "isUnique": false + }, + "share_recipients_user_id_idx": { + "name": "share_recipients_user_id_idx", + "columns": [ + "recipient_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shares": { + "name": "shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matter_id": { + "name": "matter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_limit": { + "name": "download_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "downloads": { + "name": "downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "shares_token_unique": { + "name": "shares_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "shares_creator_status_created_idx": { + "name": "shares_creator_status_created_idx", + "columns": [ + "creator_id", + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storages": { + "name": "storages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "access_key": { + "name": "access_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_key": { + "name": "secret_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "custom_host": { + "name": "custom_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_options": { + "name": "system_options", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_invite_links": { + "name": "team_invite_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_invite_links_token_unique": { + "name": "team_invite_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 304b8cea..6189977c 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1776997633827, "tag": "0013_licensing", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1777085815109, + "tag": "0014_drop-license-binding", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index cf9f7c4d..963bace8 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -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') diff --git a/server/db/schema.ts b/server/db/schema.ts index 2d8c9a8d..7b6b3141 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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') diff --git a/server/licensing/e2e-cloud-integration.test.ts b/server/licensing/e2e-cloud-integration.test.ts index d775e86b..01825a6b 100644 --- a/server/licensing/e2e-cloud-integration.test.ts +++ b/server/licensing/e2e-cloud-integration.test.ts @@ -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) diff --git a/server/licensing/entitlement.test.ts b/server/licensing/entitlement.test.ts index bfdc8229..3d4238e1 100644 --- a/server/licensing/entitlement.test.ts +++ b/server/licensing/entitlement.test.ts @@ -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) diff --git a/server/licensing/entitlement.ts b/server/licensing/entitlement.ts index 0e6e79bd..2b72df80 100644 --- a/server/licensing/entitlement.ts +++ b/server/licensing/entitlement.ts @@ -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 { 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 diff --git a/server/licensing/has-feature.ts b/server/licensing/has-feature.ts index 17b17722..15fb540b 100644 --- a/server/licensing/has-feature.ts +++ b/server/licensing/has-feature.ts @@ -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 { - 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 { diff --git a/server/licensing/license-state.ts b/server/licensing/license-state.ts new file mode 100644 index 00000000..9b81e9d3 --- /dev/null +++ b/server/licensing/license-state.ts @@ -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 { + 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 { + await db + .insert(systemOptions) + .values({ key, value, public: false }) + .onConflictDoUpdate({ target: systemOptions.key, set: { value } }) +} + +export async function setLicenseOptions(db: Database, entries: Record): Promise { + 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 { + 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 +} diff --git a/server/licensing/refresh.test.ts b/server/licensing/refresh.test.ts index 42bfadfa..a8c3b2bb 100644 --- a/server/licensing/refresh.test.ts +++ b/server/licensing/refresh.test.ts @@ -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 -async function seedBinding(db: DB, overrides: Partial = {}) { - 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> = {}) { + 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') }) }) diff --git a/server/licensing/refresh.ts b/server/licensing/refresh.ts index f9b9d916..774ad3f7 100644 --- a/server/licensing/refresh.ts +++ b/server/licensing/refresh.ts @@ -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 { - 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 } diff --git a/server/routes/branding.integration.test.ts b/server/routes/branding.integration.test.ts index 9093ae2e..aaf53fa8 100644 --- a/server/routes/branding.integration.test.ts +++ b/server/routes/branding.integration.test.ts @@ -8,12 +8,11 @@ import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' const NOW = Date.now() async function seedProLicense(db: Awaited>['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>['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), }) } diff --git a/server/routes/licensing-admin.integration.test.ts b/server/routes/licensing-admin.integration.test.ts index 5cee91c5..ba4af0ab 100644 --- a/server/routes/licensing-admin.integration.test.ts +++ b/server/routes/licensing-admin.integration.test.ts @@ -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 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 () => { diff --git a/server/routes/licensing-admin.ts b/server/routes/licensing-admin.ts index c48a1821..1b95c5c8 100644 --- a/server/routes/licensing-admin.ts +++ b/server/routes/licensing-admin.ts @@ -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() .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() 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() 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() } } - 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 }) diff --git a/server/routes/licensing.integration.test.ts b/server/routes/licensing.integration.test.ts index 6d3bba44..bc9e944d 100644 --- a/server/routes/licensing.integration.test.ts +++ b/server/routes/licensing.integration.test.ts @@ -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 diff --git a/server/services/licensing-refresh-runner.test.ts b/server/services/licensing-refresh-runner.test.ts index dc59795b..7ac40454 100644 --- a/server/services/licensing-refresh-runner.test.ts +++ b/server/services/licensing-refresh-runner.test.ts @@ -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[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 @@ -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')) diff --git a/server/services/licensing-refresh-runner.ts b/server/services/licensing-refresh-runner.ts index 85cc236b..207c35e5 100644 --- a/server/services/licensing-refresh-runner.ts +++ b/server/services/licensing-refresh-runner.ts @@ -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 { - 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) diff --git a/server/services/signup-mode-guard.integration.test.ts b/server/services/signup-mode-guard.integration.test.ts index 51087928..fa2f289d 100644 --- a/server/services/signup-mode-guard.integration.test.ts +++ b/server/services/signup-mode-guard.integration.test.ts @@ -14,6 +14,7 @@ async function signUp(ctx: TestCtx, email: string, extra?: Record