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.
Symptom: after a long video finishes processing, the screen goes blank
even though the video and chunks save correctly to history (visible
after a refresh + library click).
Cause: the manual SSE parser in processUrl declared `let eventType = ""`
inside the `while (await reader.read())` loop, so the variable reset on
every chunk. The server emits each event as a single write of
`event: X\ndata: Y\n\n`, but for a long video the result payload (entries
+ chunks + logs) is tens of KB and gets split across reader chunks by
the browser. When the split landed between the `event: result\n` line
and the `data: ...` line, the event type was lost and `handleSSE("",
data)` matched no branch — the result event was silently dropped, and
state.chunks stayed at the empty array set during processUrl reset.
Fix: hoist eventType outside the loop so it persists across chunks, and
reset it after each dispatch (per the SSE spec, event type returns to
default after an event is fired).
Short videos with small result payloads fit in a single chunk and so
were unaffected — which is why the bug looked intermittent.
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.