Commit Graph

15 Commits

Author SHA1 Message Date
Keysat 5540b71446 Module split: license gate + Pro gates + license routes → server/license-middleware.js
• LIC                              — exported live binding (ESM)
  • setupLicenseMiddleware(app)      — registers activation gate + Pro
                                       feature gates (must run before any
                                       /api/* route)
  • setupLicenseRoutes(app)          — /api/license-status, /api/license/
                                       activate, /api/license/deactivate
  • startLicenseRefresh()            — startup + 6h periodic online check
  • refreshLicenseOnline(reason)     — ad-hoc refresh (e.g., during activate)
  • isFreeUser()                     — 'no license || no core entitlement'
  • tryAcquireFreeSlot() / releaseFreeSlot() — the free-tier concurrency
                                                 lock previously open-coded
                                                 in /api/process

Local 'const isFreeUser = ...' in /api/process renamed to 'isFree' to
avoid shadowing the imported helper. Open-coded freeJobInFlight reads/
writes replaced with the slot helpers.

server/index.js: 2461 → 2300 lines.

Smoke tested: server boots; /api/license-status, /api/health, /api/
process (rejects with 400 'No API key' as expected for unlicensed +
no key) all behave as before.
2026-05-08 17:05:35 -05:00
Keysat 7ab2a3249a Module split: extract API key + live reload to server/config.js
• initConfig({ dataDir })           — boot-time setup; mkdir's configDir,
                                        reads initial value, kicks off the
                                        3 s poll loop
  • serverApiKey                      — exported as a 'let' binding (ESM
                                        live binding) so importers see the
                                        current value
  • resolveApiKey(clientKey)          — picks per-request key (BYO vs
                                        server)
  • getEnvPath()                      — exposes /data/.env path so the
                                        cookies module can read its own
                                        legacy YT_COOKIES_FROM setting

Bug fix uncovered during this extraction: /api/health was directly
referencing ytCookiesFileExists / ytCookiesFilePath (module-scoped
vars I'd already moved to cookies.js). The route silently 500'd on the
first request after the cookies extraction. Now uses ytCookieMethod()
and getCookieFilePath() instead.

server/index.js: 2510 → 2461 lines.

Smoke tested: server boots; /api/health responds; updating
/data/config/startos-config.json flips hasServerKey from false → true
within 3 s ([config] server API key loaded log line confirms).
2026-05-08 17:01:45 -05:00
Keysat 2c655dc9ee Module split: extract cookies state + helpers + routes to server/cookies.js
• initCookies({ dataDir, envPath })  — boot-time setup; reads .env's
                                         YT_COOKIES_FROM and probes for
                                         cookies.txt
  • ytCookieArgs() / ytExtraArgs()     — yt-dlp arg builders
  • ytCookieMethod()                   — human-readable active method
  • setupCookieRoutes(app)             — registers the four /api/cookies/*
                                         routes (upload / delete / test /
                                         status)

Module owns its private state (browser-name, file-exists, file-path).
Upload and delete routes flip the file-exists flag inside the same
module, so subsequent yt-dlp calls reflect the change immediately
without callers re-reading.

server/index.js: 2614 → 2510 lines.

Smoke tested: server boots; /api/license-status and /api/health
respond. /api/cookies/status returns the existing license-gated 402
for unlicensed callers (unchanged behavior).
2026-05-08 16:57:03 -05:00
Keysat 9a82fede7a Module split: extract yt-dlp lifecycle helpers to server/ytdlp.js
• checkYtdlp()                  — version + GitHub-latest check (24h-cached)
  • autoUpdateYtdlp(dataDir)      — multi-strategy update (-U, pip, brew, binary)

Module-private memoization (ytdlpVersion, ytdlpLastCheck) now stays
inside ytdlp.js where it belongs. autoUpdateYtdlp gained an explicit
dataDir parameter — strategy 4 (StartOS binary download to /data/bin/)
needs it; passing it in keeps the module pure of caller-side state.

server/index.js: 2694 → 2614 lines.

Smoke tested: server boots; /api/license-status, /api/health respond.
No behavior change.
2026-05-08 16:54:51 -05:00
Keysat 4c3cb6a077 Module split: extract audio I/O helpers to server/audio.js
• getAudioDuration(path)         — ffprobe wrapper, returns seconds | null
  • splitAudioFile(in, dir, secs)  — ffmpeg -acodec copy chunking
  • downloadPodcastAudio(url, dst) — streams HTTP audio to disk

Also moved fetchUrl into util.js (alongside the other stateless
helpers) — it's a generic HTTP-GET-with-redirects used by RSS parsing
and channel discovery, not strictly audio.

server/index.js: 2758 → 2694 lines.

Smoke tested: server boots; /api/license-status, /api/health, /
respond. No behavior change.
2026-05-08 16:53:06 -05:00
Keysat 1c78e46ebd Module split: extract Gemini-specific helpers to server/gemini-helpers.js
• PRICING table             — per-1M-token rates by model
  • calcCost(model, usage)    — Gemini usage object → cost record
  • buildAnalysisPrompt(...)  — JSON-output topic-analysis prompt

These all share the Gemini contract — pricing schema, usage shape, and
prompt format. When we add other providers, each gets its own
provider-specific helpers file; this becomes the basis of the Gemini
provider implementation.

server/index.js: 2828 → 2758 lines.

Smoke tested: server boots; /api/license-status, /api/health, and /
(frontend) all respond. No behavior change.
2026-05-08 16:50:34 -05:00
Keysat ffc8c31130 Module split: extract pure helpers to server/util.js
First step of breaking up the 2914-line server/index.js. Pulled out the
zero-state, no-side-effects helpers:

  • sendEvent(res, event, data)         — writes one SSE frame
  • extractVideoId(url)                 — YouTube URL → 11-char id
  • formatTime(seconds)                 — seconds → 'M:SS' or 'H:MM:SS'
  • parseTimestampedTranscript(text)    — Gemini transcript text → entries[]
  • safeText(result)                    — robust .text getter for Gemini responses
  • retryGemini(fn, opts)               — 503/429/network retry with linear backoff

server/index.js: 2914 → 2828 lines.
server/util.js : new, 113 lines.

Smoke tested: server boots, /api/license-status responds. No behavior
change.
2026-05-08 16:48:40 -05:00
Keysat b5a066750a Live-reload Gemini API key config + fix vendor module resolution
Two related changes that ship together because the second was uncovered
while testing the first.

1. Live config reload (the ostensible feature):

   The "Set Gemini API Key" StartOS action writes to /data/config/
   startos-config.json. The server used to read that file once at
   startup (and via a separate Python read in docker_entrypoint.sh
   before that), which meant a key change required a service restart
   to take effect. Now the server polls the file every 3 s
   (RECAP_CONFIG_POLL_MS, env-overridable) and updates serverApiKey
   in place. fs.watch was tried first and dropped — it's flaky on
   macOS (FSEvents single-file quirks) and behaves inconsistently with
   atomic-rename writes the SDK file model uses. Polling is dead
   simple and a stat call every 3 s is free.

   Also dropped the Python config read from docker_entrypoint.sh; the
   server now handles it natively. Entrypoint still loads /data/.env
   for arbitrary env vars (RECAP_*, etc.).

2. Vendor module resolution (the silently-broken thing):

   The earlier vendor change (move @keysat/licensing-client from a
   git+https dep to a file: dep at vendor/) created a symlink in
   server/node_modules. That symlink to the vendor dir was getting
   resolved by Node, so the keysat client tried to import @noble/
   ed25519 from /app/vendor/keysat-licensing-client/dist/, walked up
   to /app/vendor/, then /app/, neither of which had node_modules.

   Result: v0.2.0 and v0.2.1 would crash at startup with
   ERR_MODULE_NOT_FOUND on @noble/ed25519. The Docker BUILD succeeded
   because npm install with file: deps doesn't pull transitive deps
   into the parent node_modules — but the runtime would have failed
   the moment server/license.js ran.

   Fix:
     • Dockerfile builder now `npm install`s inside vendor/keysat-
       licensing-client/ so @noble/* lands in its own node_modules,
       where Node's resolver finds it.
     • Dockerfile runner now COPYs vendor/ to the runner image
       (previously not copied — the symlink in server/node_modules
       would have pointed at nothing).
     • vendor/keysat-licensing-client/package-lock.json is committed
       so the in-Docker install is reproducible.
2026-05-08 16:38:33 -05:00
Keysat 9282440143 Rename project: youtube-summarizer → recap
The product was always more than YouTube — it handles podcast feeds
too, and the upcoming multi-provider work makes it less Gemini-
specific. New name: Recap.

This is a coordinated identity change across:

  • StartOS package id: youtube-summarizer → recap
    (manifest.id; the .s9pk filename, Docker image namespace, and
    install path under StartOS all derive from this automatically)
  • Display name: "YouTube Summarizer" → "Recap"
    (manifest title, activation screen heading, page <title>, console
    log on boot, i18n strings, ABOUT.md, Dockerfile header,
    docker_entrypoint banner)
  • Keysat product slug: youtube-summarizer → recap
    (server/license.js PRODUCT_SLUG; frontend fallback strings)
  • Daemon subscription id: youtube-summarizer-sub → recap-sub
  • Env var prefix: YT_SUMMARIZER_* → RECAP_*
    (LICENSE_KEY, LICENSE_KEY_PATH, MAX_OFFLINE_DAYS,
    VALIDATE_INTERVAL_MS)
  • localStorage keys: yt-summarizer-* → recap-*
    (gemini-key, activation-skipped, clips)
  • Library export filename: youtube-summarizer-library.json →
    recap-library.json
  • npm package names: youtube-summarizer-{startos,server} → recap-*
  • Deploy paths: youtube-summarizer_x86_64.s9pk → recap_x86_64.s9pk
    (default values in bin/deploy.sh; .deploy.env on dev machine
    needs the same update before next push)
  • Self-hosted registry directory: startos-registry/packages/
    youtube-summarizer → .../recap (with package.json + INSTRUCTIONS
    rewritten)

What does NOT change:
  • Filesystem repo path (still /Users/.../youtube-summarizer/)
  • Git history / commit messages
  • Existing version files in startos/versions/ (kept as-is — the
    version chain belongs to the package's own history regardless of
    its display name)

User-side follow-ups required:
  1. Create "recap" product in Keysat admin, set up Core/Pro tier
     policies (same entitlements as before), mint a fresh test
     license. Old "youtube-summarizer" licenses won't activate
     against the new slug.
  2. Update .deploy.env (gitignored) so FILEBROWSER_PATH and
     REGISTRY_PUBLIC_URL point at recap_x86_64.s9pk.

StartOS will treat this as a brand-new app on install — existing
youtube-summarizer installs will not auto-migrate (acknowledged
intentional given no real users yet).
2026-05-08 13:35:27 -05:00
Keysat a226113a10 Drop transcription/analysis cost lines from logs
The cost breakdowns (token counts + dollar figures) are tied to the
hardcoded Gemini PRICING table, which won't make sense once we add
OpenAI/Claude/local providers — different APIs report tokens
differently, some are free, and the pricing table can't keep up. Drop
the cost and token-count lines from the activity log so the per-step
timing is the durable signal we keep.

Removed:
  • "Total cost: <in> in / <out> out — cost: $X" (chunked transcription)
  • "Transcription tokens: <in>/<out> — cost: $X" (single-shot)
  • "Analysis tokens: <in>/<out>/<thinking> — cost: $X"
  • "Pipeline finished in Ns — total cost: $X (Y tokens)" → now just
    "Pipeline finished in Ns"

Cost calculation helpers (calcCost, PRICING) stay in the codebase for
now — they may come back via a per-provider plugin layer later. They
just no longer write to the activity-log stream.
2026-05-08 12:50:10 -05:00
Keysat 1e030a24c6 Free tier: drop spurious BYO key gate; clarify bundled vs BYO
The previous free-tier commit (c0975fe) blocked USE_SERVER_KEY for
unlicensed users on the theory that this protected a "bundled key."
That conflated two different things:

  • USE_SERVER_KEY = the user's OWN Gemini key, just stored server-side
    via the StartOS configuration action (vs. browser localStorage).
    Both paths are BYO — the user pays Google directly either way.

  • Bundled key = a future relay where paid users' /api/process requests
    are proxied through the operator's service and the operator absorbs
    the API cost. Sketched in UPGRADE-DESIGN.md (deleted, in git history)
    but NOT YET BUILT.

Blocking USE_SERVER_KEY broke a legitimate flow: a free user installs
the app on their own StartOS, sets their Gemini key via the config
action, then summarizes from the web UI without re-entering it.

This commit:
  • Drops the BYO/USE_SERVER_KEY rejection in /api/process. Free users
    can use a key from either path; the existing `if (!apiKey)` check
    still catches the no-key-anywhere case with a helpful message.
  • Reverts the frontend submit-button and handleSubmit checks to the
    same key requirement for both tiers (state.apiKey OR state.hasServerKey).
  • Drops "bundled API key" from the activation-screen subtitle and
    "bring your own Gemini key" from the free-mode banner. Until the
    relay is built, paid users still BYO too — promising otherwise in
    upgrade copy is misleading.
  • Keeps the parts that ARE legitimate free-vs-paid differentiators:
    the one-at-a-time concurrency lock and skipping saveToHistory.

Also fixes the `make deploy` redundancy:
  • bin/bump-version.sh accepts --from-deploy. When set, if there is no
    .release-notes-pending.txt (consumed by a prior bump or never
    written), exit 0 without prompting — the current version is already
    fresh.
  • Makefile passes --from-deploy from the deploy target. Standalone
    `make bump` is unchanged (always prompts).

Result: `make bump` then `make deploy` no longer double-prompts. And
calling `make deploy` twice in a row (no new work) is idempotent on
the bump step.
2026-05-08 11:32:30 -05:00
Keysat 25b1c3a366 Add free tier (unlicensed users get one-at-a-time summarization)
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.
2026-05-08 11:16:02 -05:00
Keysat 2621f2cdbe Add online license revocation check (Keysat /v1/validate)
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.
2026-05-08 10:39:11 -05:00
Keysat 574a16d9fa Save in-progress keysat integration and StartOS 0.4 work
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.
2026-05-08 09:39:17 -05:00
MacPro 68ec875ee7 Add StartOS 0.4.0 packaging 2026-04-09 15:03:31 -05:00