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.
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.