Skip to content

feat(license): cloud license auto-import and device write-off - #246

Merged
apapi merged 3 commits into
mainfrom
feat/csglite-license-device-write-off
Nov 12, 2026
Merged

apapi merged 3 commits into
mainfrom
feat/csglite-license-device-write-off

Conversation

@apapi

@apapi apapi commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

License import now performs a device write-off. A license is limited to 10 devices; when online, an import first asks the server for a seat and is rejected if the limit is reached. Offline import still works: it only records the device locally and reports it once the machine is back online. The settings page gains cloud subscription status and a "Import from OpenCSG" button.

Closes #240
Server-side change: product/starhub/starhub-server!3356 (GET /api/v1/licenses/csglite/status, POST /api/v1/licenses/csglite/activations)

User-visible changes

  • The license card in Settings shows cloud state: sign-in state, subscription status, expiry, grace period, and device usage used / 10. Device usage replaces the old Seats row.
  • An "Import from OpenCSG" button appears when all of the following hold: signed in to OpenCSG, online, the cloud reports a valid license, and no license is installed locally (or it was deleted). It only runs when clicked; nothing imports automatically. Once a license has been imported (automatically or manually) and not deleted, the prompt does not reappear.
  • Online import (automatic and manual alike) writes off the device before installing. Exceeding the device limit returns LICENSE-ERR-7 with "This license has exceeded its device limit; new devices cannot be imported", and no license is installed.
  • Offline import (manual file upload) is unchanged: the license is installed, the device is recorded as pending, and it is reported on the next start or successful sign-in. After a successful report the record becomes reported and is never re-sent.
  • A cancelled or expired subscription is surfaced in the UI, but an installed license is never revoked; after expiry there is still a 14-day grace period.
  • csghub-lite license install follows the same flow and notes that the device will be synced later when the install was offline.
  • Older servers (without the new endpoints) are fully supported: the auto-import button is shown as unavailable, and manual import falls back to the offline path with a 24-hour retry backoff. Nothing errors and startup is never blocked.

Local HTTP API changes

  • New GET /api/license/cloud: always 200, returns logged_in, online, supported, state, has_license, not_started, license_key, expire_time, grace_until, subscription_status, used, limit, env_managed, and reason. Cached for 10 seconds.
  • New POST /api/license/cloud/import: download, verify, write off, and install. Error codes: login_required, token_invalid, no_license, already_current, cloud_not_started, env_managed, rate_limited, bad_request, unreachable, unsupported.
  • PUT /api/license responses gain offline_import (import responses only) so the UI can say the device will be synced later.
  • openapi/local-api.json and internal/server/static/openapi/local-api.json are updated.

Invariants

  • License validity checks are unchanged. This PR only adds a device-limit check after a license has been found valid.
  • The license file format is unchanged. license.activation is a separate sidecar state file.
  • A license managed by CSGHUB_LITE_LICENSE is read-only: it is not auto-imported, overwritten, or written off.
  • An installed license is never revoked because of a failed write-off, an exceeded limit, being offline, or a cancelled subscription.
  • Offline behavior is unchanged: import, startup, and inference all work without a network.
  • CE / non-SaaS builds are unaffected.

Non-goals

  • No device unbinding and no device management UI. Seats are cumulative and never released.
  • No anti-copy protection; this is a ledger only (copying the license and state file still works).
  • csghub-portal is not touched.
  • Windows/Linux and desktop-app verification on real hardware is out of scope (unit tests only).

Compatibility

Scenario Behavior
New Lite + new server Automatic and manual imports both write off first; over-limit imports are rejected
New Lite + old server supported=false, auto-import unavailable; manual import installs offline with pending + 24h backoff
Old Lite + new server Unaware of the new endpoints; behavior unchanged
CE / EE builds New code paths are not compiled; behavior unchanged
Env-managed license Read-only; no import, no write-off

Risks

  • Server first, Lite later: no risk, old Lite never calls the new endpoints.
  • Lite first, server later: auto-import is unavailable and manual imports use the offline path, so the device count may briefly exceed 10. It stops growing once reported, and no installed license is revoked.
  • When no machine identifier is available (containers, minimal Linux without machine-id), the device id falls back to a UUID under the storage root, so switching storage roots consumes a new seat.
  • Deleting the state file is equivalent to "not written off" and triggers one more report. The server is idempotent per key+device, so no extra seat is consumed.

Verification

Run locally on macOS arm64:

  • gofmt -l internal pkg cmd: no formatting issues in the changed files
  • go build ./...: passes
  • go test ./internal/deviceid ./internal/csghub ./internal/license ./internal/server ./internal/cli: passes
  • cd web && npx tsc --noEmit: passes
  • cd web && npx vitest run: 23 files, 99 tests pass

End-to-end against real processes (server and Lite both running locally):

  • New server + new Lite: status query → auto-import → write-off → install → restart without a duplicate report; full path passes
  • Old server (built from main) + new Lite: supported=false, manual import uses pending + 24h, no duplicate report after restart
  • Auto-import works with a real access token through the real sign-in path (user service)
  • Concurrency: 20 distinct devices claiming at once → 10×200 and 10×409; 20 claims for the same device → all 200 with a single ledger row
  • Per-key rate limit: 61st request returns 429; per-IP rate limit: 11th request returns 429
  • Redis unavailable: fail-open, imports still succeed
  • Input validation: missing or invalid fields return 400
  • Local state file: corrupt, orphaned, and legacy formats all degrade gracefully; deleting a license also removes the activation record
  • Env-managed license (CSGHUB_LITE_LICENSE): startup and sign-in reporting send no request and write no activation record

Not covered: Windows/Linux on real hardware, desktop-app UI verification.

Breaking changes

None. New endpoints and response fields only; existing endpoint semantics are unchanged. LicenseState.Seats was renamed to Devices (internal type and web UI only, not externally exposed).

Import a license from OpenCSG in Settings, write the device off against the
license, and cap each license at 10 devices when online. Offline imports keep
working and report the device after the next start or sign-in.

- add GET /api/license/cloud and POST /api/license/cloud/import
- record write-off state in license.activation and report it on start/sign-in
- derive a stable device id from the machine identifier, with a storage-root
  fallback
- keep older servers working through the offline path with a 24h retry
- surface cloud subscription status and device usage in Settings and the CLI

Fixes #240.
The cloud status reports has_license=false for an expired license, so the
disabled import button explained it as "no active subscription" even when
the license row exists. Check the state first and reuse the expired hint.
Comment thread internal/server/license_cloud.go Resolved
CSGHUB_LITE_LICENSE is read-only, so startup and sign-in reporting must
skip it: no activation record and no activation request. Containers and CI
would otherwise consume a permanent seat on every start.

Retarget the raw-PEM test at a file-managed license and add a test that an
env-managed license sends nothing.
@apapi
apapi merged commit 56318dd into main Nov 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lite 订阅 自动下载 核销

2 participants