Unlicensed users can now summarize a single video at a time using their
own Gemini API key. The result renders in the UI exactly like a paid
summary, but is not persisted — there's no library entry, no history,
and a second submission while one is in flight is rejected.
Server (server/index.js):
• /api/process is now in LICENSE_OPEN_PATHS. The route handler
distinguishes free users (state !== "licensed" || no "core") and:
- rejects USE_SERVER_KEY / empty key with 402 byo_key_required
(so the bundled Gemini key stays paid-only)
- rejects a second concurrent job with 409 processing_in_progress
via a module-level freeJobInFlight flag, released in finally
- skips saveToHistory so the host's library stays clean
• Pro feature gates (history/library/subscriptions) unchanged — still
return 402 feature_not_in_tier for unlicensed callers.
Frontend (public/index.html):
• New state.activationSkipped flag (persisted to localStorage). The
activation screen still appears on first launch, but now offers a
"Skip — use free mode" button alongside Activate / Buy a key.
Once skipped, the main app renders normally.
• Free-mode upgrade banner under the top bar with Upgrade and "I have
a key" buttons (the latter routes back to the activation screen).
• handleLibraryClick / handleSubscribeClick wrappers — for unlicensed
users, the library (clock) icon and the channel-URL Subscribe
submission show a toast explaining the upgrade rather than opening
an empty sidebar / hitting a 402.
• Submit button enforces BYO key for unlicensed users (the bundled
state.hasServerKey doesn't enable submit). handleSubmit shows a
toast when an unlicensed user tries to queue a second video.
Without this, a license revoked in the Keysat admin UI keeps unlocking
the app on the customer's machine — Ed25519 signatures are perpetually
valid, so the offline-only check never sees the revocation.
What this adds:
• license.js: validateOnline() calls licensing.keysat.xyz/v1/validate
via @keysat/licensing-client's Client. Hard rejections (revoked,
suspended, expired, not_found, product_mismatch, fingerprint_mismatch,
too_many_machines, invalid_state) immediately flip state to "invalid"
and persist the verdict to <license>.state.json so it survives
restarts. rate_limited and unknown reasons are treated as transient.
• Network errors keep the prior state for up to MAX_OFFLINE_DAYS
(default 7, env-overridable) since the last successful validate.
Past the ceiling, lock out with reason=validation_overdue. This
avoids breaking customers when Keysat is briefly down while still
catching revocations on machines that go offline forever.
• license.js: deactivate() helper that removes both license.txt and
its sidecar state file (idempotent). publicView() now exposes
lastValidatedAt, serverStatus, graceUntil for the UI.
• index.js: refreshLicenseOnline() runs on startup (async, non-
blocking), every 6h thereafter (env-overridable), and at activation
time with an 8s timeout cap so a slow Keysat doesn't hang the
activation UI. State changes are logged.
• index.js: /api/license/activate now awaits an online confirmation
after the offline signature check passes. A revoked key pasted into
the activation modal fails fast instead of working until the next
poll.
Snapshot of the working tree before cleanup. Captures:
- Keysat licensing: server/license.js, /api/license/* endpoints in
server/index.js, activation modal in public/index.html, embedded
Ed25519 issuer key (assets/issuer.pub).
- StartOS 0.4 expansion: setApiKey action, version files v0.1.1
through v0.1.15, file-models/config.json.ts, manifest updates.
- Self-hosted registry server (startos-registry/).
- Build/deploy scripts (bin/bump-version.sh, bin/deploy.sh, vendored
yt-dlp binary), .gitignore, .deploy.env.example.
- Recent design docs (KEYSAT_INTEGRATION.md, UPGRADE-DESIGN.md) —
retained here so they remain recoverable when removed in the
follow-up cleanup commit.