ananyajoshi commited on
Commit
be469a4
·
verified ·
1 Parent(s): 607beb5

Add encrypted per-browser API-key persistence (Supabase + invisible cookie tag, fail-safe)

Browse files
README.md CHANGED
@@ -33,10 +33,14 @@ The app walks you through five steps in the left sidebar:
33
 
34
  - **Bring your own key.** Each visitor enters their own provider API key
35
  (OpenAI, Anthropic, Gemini, or Groq) on the Setup page.
36
- - **The key is never stored.** It is held only in your browser session's
37
- memory — never written to a file, never put in the server environment, and
38
- never shared with other visitors. It is discarded when you reload or close
39
- the page, so you re-enter it next visit.
 
 
 
 
40
  - **Your files are session-only.** Uploaded dossier files are processed in a
41
  temporary, per-session location and are not persisted on the server.
42
  - Only a small, relevance-filtered slice of your text is ever sent to the AI
 
33
 
34
  - **Bring your own key.** Each visitor enters their own provider API key
35
  (OpenAI, Anthropic, Gemini, or Groq) on the Setup page.
36
+ - **Your key is stored encrypted, remembered on your device.** So you don't
37
+ re-enter it each visit, the key is saved **encrypted** (Fernet) using a
38
+ secret derived from a random, invisible per-browser tag. The storage backend
39
+ (Supabase) only ever holds ciphertext; the tag is never sent to it, so one
40
+ visitor's key can't be read with another's tag, and the key is never put in
41
+ the server environment. Use **Clear Credentials** to remove it. If key
42
+ storage isn't configured, the app falls back to session-only (re-enter each
43
+ visit).
44
  - **Your files are session-only.** Uploaded dossier files are processed in a
45
  temporary, per-session location and are not persisted on the server.
46
  - Only a small, relevance-filtered slice of your text is ever sent to the AI
app/main.py CHANGED
@@ -16,7 +16,9 @@ from app.config import (
16
  APP_TITLE, APP_TAGLINE,
17
  get_settings, reload_settings,
18
  get_saved_dossier_path, save_dossier_path,
 
19
  )
 
20
  from app.services.api_gate import ApiGateError, run_capability_test
21
  from app.services.folder_validator import validate
22
  from app.services.dossier_reader import read_dossier
@@ -243,6 +245,36 @@ def _render_sidebar(show_debug: bool) -> None:
243
  api_usage_panel.render(key_suffix="sidebar")
244
 
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  def _try_smart_landing(settings) -> None:
247
  """On a fresh session, silently restore Setup + Dossier if already done.
248
 
@@ -349,6 +381,12 @@ def main() -> None:
349
  # Job Screenshot so they never have to re-enter their key or folder path.
350
  # We only do this once per session (guarded by the _smart_land_done flag),
351
  # and only when the session default "setup" is still the current step.
 
 
 
 
 
 
352
  if not st.session_state.get("_smart_land_done"):
353
  st.session_state["_smart_land_done"] = True
354
  _try_smart_landing(settings)
 
16
  APP_TITLE, APP_TAGLINE,
17
  get_settings, reload_settings,
18
  get_saved_dossier_path, save_dossier_path,
19
+ apply_session_overrides,
20
  )
21
+ from app.services import browser_tag, key_store
22
  from app.services.api_gate import ApiGateError, run_capability_test
23
  from app.services.folder_validator import validate
24
  from app.services.dossier_reader import read_dossier
 
245
  api_usage_panel.render(key_suffix="sidebar")
246
 
247
 
248
+ def _try_restore_saved_config() -> None:
249
+ """Load this browser's saved (encrypted) Setup config from Supabase.
250
+
251
+ Runs once per session. Only acts when key storage is configured and the
252
+ session has no API key yet. On success it applies the saved provider /
253
+ model / key / vision settings into the session so the rest of the app —
254
+ including smart-landing's API check — proceeds as if the user just set up.
255
+
256
+ Entirely fail-safe (see :mod:`app.services.key_store`): any failure leaves
257
+ the session untouched and the user simply lands on Setup as before.
258
+ """
259
+ ss = st.session_state
260
+ if ss.get("_restore_done"):
261
+ return
262
+ if not key_store.is_configured():
263
+ ss["_restore_done"] = True
264
+ return
265
+ if get_settings().has_api_key:
266
+ ss["_restore_done"] = True
267
+ return
268
+ tag = browser_tag.get_or_create_tag()
269
+ if not tag:
270
+ return # tag not available yet; try again on the next render
271
+ stored = key_store.load_config(tag)
272
+ ss["_restore_done"] = True
273
+ if stored:
274
+ apply_session_overrides(stored)
275
+ ss["_config_restored"] = True
276
+
277
+
278
  def _try_smart_landing(settings) -> None:
279
  """On a fresh session, silently restore Setup + Dossier if already done.
280
 
 
381
  # Job Screenshot so they never have to re-enter their key or folder path.
382
  # We only do this once per session (guarded by the _smart_land_done flag),
383
  # and only when the session default "setup" is still the current step.
384
+ # Restore a previously saved (encrypted) config for this browser BEFORE
385
+ # smart-landing, so a returning visitor's key is in place and they get
386
+ # auto-advanced past Setup without re-entering anything.
387
+ _try_restore_saved_config()
388
+ settings = get_settings()
389
+
390
  if not st.session_state.get("_smart_land_done"):
391
  st.session_state["_smart_land_done"] = True
392
  _try_smart_landing(settings)
app/services/browser_tag.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Invisible per-browser identity — no login, nothing for the user to type.
2
+
3
+ A returning visitor is recognised by a random **first-party cookie**:
4
+
5
+ * It is READ synchronously from the page request via ``st.context.cookies`` —
6
+ reliable on every page load (unlike async localStorage components).
7
+ * For a new browser it is MINTED and WRITTEN once via the same
8
+ ``window.parent`` bridge the theme already uses, so it survives reloads and
9
+ future visits.
10
+
11
+ The minted value is also cached in ``st.session_state`` so the tag is stable
12
+ within a session even before the cookie round-trips on the next reload.
13
+
14
+ This tag is only an opaque identifier. The API key is encrypted *with* it (see
15
+ :mod:`app.services.key_store`) and the tag itself is never sent to Supabase, so
16
+ one browser's tag can't unlock another's stored key.
17
+
18
+ Fully fail-safe: any problem (cookies disabled, API missing) returns ``None``
19
+ and the app falls back to today's session-only behavior.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import secrets
25
+
26
+ import streamlit as st
27
+ import streamlit.components.v1 as components
28
+
29
+ _COOKIE = "ups_uid"
30
+ _SESSION = "_browser_tag"
31
+ _ONE_YEAR = 60 * 60 * 24 * 365
32
+
33
+
34
+ def _read_cookie() -> str | None:
35
+ try:
36
+ value = st.context.cookies.get(_COOKIE)
37
+ except Exception: # noqa: BLE001 - older/edge runtimes without cookie access
38
+ return None
39
+ return value or None
40
+
41
+
42
+ def _write_cookie(value: str) -> None:
43
+ """Persist the tag as a first-party cookie on the app's document.
44
+
45
+ Sets it on ``window.parent.document`` (the real app page) from the 0-height
46
+ component iframe — the same parent bridge ``theme.apply_step_accent`` uses.
47
+ Takes effect on the next page load, which is exactly when we need to
48
+ recognise the returning visitor.
49
+ """
50
+ js = (
51
+ "<script>try{window.parent.document.cookie="
52
+ f"'{_COOKIE}={value}; path=/; max-age={_ONE_YEAR}; SameSite=Lax';"
53
+ "}catch(e){}</script>"
54
+ )
55
+ try:
56
+ components.html(js, height=0)
57
+ except Exception: # noqa: BLE001 - fail-safe
58
+ pass
59
+
60
+
61
+ def get_or_create_tag() -> str | None:
62
+ """Return this browser's stable tag, minting + persisting one if new.
63
+
64
+ Returns ``None`` only in the rare case neither a cookie nor minting is
65
+ possible; callers treat ``None`` as "no persistence this run".
66
+ """
67
+ ss = st.session_state
68
+ cached = ss.get(_SESSION)
69
+ if cached:
70
+ return cached
71
+
72
+ existing = _read_cookie()
73
+ if existing:
74
+ ss[_SESSION] = existing
75
+ return existing
76
+
77
+ # New browser: mint now (usable immediately this session) and persist a
78
+ # cookie so the next visit is recognised.
79
+ tag = secrets.token_urlsafe(32)
80
+ ss[_SESSION] = tag
81
+ _write_cookie(tag)
82
+ return tag
app/services/key_store.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Encrypted, per-browser persistence of the Setup config (incl. API key).
2
+
3
+ Goal: a visitor enters their API key once and, on the same browser, never has
4
+ to re-enter it — without any login or any code they must remember. A random,
5
+ invisible per-browser *tag* identifies the returning visitor (see
6
+ :mod:`app.services.browser_tag`); this module stores their config in Supabase,
7
+ encrypted so that:
8
+
9
+ * Supabase only ever holds **ciphertext** — a DB/storage leak reveals nothing.
10
+ * The encryption secret is derived from the browser tag, which is **never sent
11
+ to Supabase**, so one visitor's stored blob can't be read with another's tag.
12
+ * The storage object id is derived from the tag with a *different* salt than the
13
+ encryption key, so even the id can't be turned back into the decryption key.
14
+
15
+ Everything here is **fail-safe**: if Supabase isn't configured, or any network
16
+ call fails, every function quietly degrades to a no-op / ``None`` and the app
17
+ falls back to today's session-only behavior. It must never raise into the UI.
18
+
19
+ Storage backend: a private Supabase Storage bucket (auto-created on first use),
20
+ one small object per browser tag. Using Storage means no database schema / SQL
21
+ setup is required — the bucket is created via the service key at runtime.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import json
29
+ import os
30
+ from typing import Optional
31
+
32
+ # Bucket that holds the encrypted per-tag config blobs.
33
+ _BUCKET = "apikeys"
34
+ _TIMEOUT = 8.0
35
+ # Salts keep the storage-object id and the encryption key independent, so
36
+ # knowing the id (which Supabase stores) never yields the decryption key.
37
+ _ID_SALT = b"upwork-strategist::object-id::v1"
38
+ _KEY_SALT = b"upwork-strategist::enc-key::v1"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Configuration
43
+ # ---------------------------------------------------------------------------
44
+ def is_configured() -> bool:
45
+ """True only when the Supabase service credentials are present."""
46
+ return bool(_base_url() and _service_key())
47
+
48
+
49
+ def _base_url() -> str:
50
+ return (os.getenv("SUPABASE_URL") or "").rstrip("/")
51
+
52
+
53
+ def _service_key() -> str:
54
+ return os.getenv("SUPABASE_SERVICE_KEY") or ""
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Crypto / id derivation (pure, unit-testable)
59
+ # ---------------------------------------------------------------------------
60
+ def _object_path(tag: str) -> str:
61
+ digest = hashlib.sha256(_ID_SALT + tag.encode("utf-8")).hexdigest()
62
+ return f"{digest}.bin"
63
+
64
+
65
+ def _fernet(tag: str):
66
+ from cryptography.fernet import Fernet
67
+
68
+ raw = hashlib.sha256(_KEY_SALT + tag.encode("utf-8")).digest()
69
+ return Fernet(base64.urlsafe_b64encode(raw))
70
+
71
+
72
+ def encrypt_payload(tag: str, payload: dict) -> bytes:
73
+ """Encrypt a config dict for ``tag`` -> ciphertext bytes (pure)."""
74
+ data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
75
+ return _fernet(tag).encrypt(data)
76
+
77
+
78
+ def decrypt_payload(tag: str, token: bytes) -> Optional[dict]:
79
+ """Decrypt ciphertext for ``tag`` -> config dict, or ``None`` if it can't
80
+ be decrypted with this tag (wrong tag / corrupt / tampered)."""
81
+ from cryptography.fernet import InvalidToken
82
+
83
+ try:
84
+ plain = _fernet(tag).decrypt(token)
85
+ obj = json.loads(plain)
86
+ return obj if isinstance(obj, dict) else None
87
+ except (InvalidToken, ValueError, TypeError):
88
+ return None
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # HTTP transport (single seam — monkeypatched in tests)
93
+ # ---------------------------------------------------------------------------
94
+ def _http(method: str, url: str, headers: dict, content: Optional[bytes] = None):
95
+ """Perform one HTTP request. Returns ``(status_code, body_bytes)``.
96
+
97
+ Isolated so tests can replace it with an in-memory fake. Uses ``httpx``,
98
+ which is already installed (httpx is an OpenAI SDK dependency).
99
+ """
100
+ import httpx
101
+
102
+ with httpx.Client(timeout=_TIMEOUT) as client:
103
+ resp = client.request(method, url, headers=headers, content=content)
104
+ return resp.status_code, resp.content
105
+
106
+
107
+ def _auth_headers(extra: Optional[dict] = None) -> dict:
108
+ key = _service_key()
109
+ headers = {"Authorization": f"Bearer {key}", "apikey": key}
110
+ if extra:
111
+ headers.update(extra)
112
+ return headers
113
+
114
+
115
+ def _ensure_bucket() -> None:
116
+ """Create the private bucket if missing. Idempotent; errors are ignored
117
+ (a 'already exists' response is the normal case after the first run)."""
118
+ url = f"{_base_url()}/storage/v1/bucket"
119
+ body = json.dumps({"id": _BUCKET, "name": _BUCKET, "public": False}).encode()
120
+ try:
121
+ _http("POST", url, _auth_headers({"Content-Type": "application/json"}), body)
122
+ except Exception: # noqa: BLE001 - best-effort; save() will surface real failure
123
+ pass
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Public API (fail-safe)
128
+ # ---------------------------------------------------------------------------
129
+ def save_config(tag: str, payload: dict) -> bool:
130
+ """Encrypt ``payload`` under ``tag`` and upsert it to Supabase.
131
+
132
+ Returns True on success, False on any failure or when not configured.
133
+ Never raises.
134
+ """
135
+ if not (is_configured() and tag and payload):
136
+ return False
137
+ try:
138
+ _ensure_bucket()
139
+ token = encrypt_payload(tag, payload)
140
+ url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{_object_path(tag)}"
141
+ # x-upsert overwrites an existing object for this tag.
142
+ status, _ = _http(
143
+ "POST",
144
+ url,
145
+ _auth_headers(
146
+ {"Content-Type": "application/octet-stream", "x-upsert": "true"}
147
+ ),
148
+ token,
149
+ )
150
+ return 200 <= status < 300
151
+ except Exception: # noqa: BLE001 - fail-safe: never break the UI
152
+ return False
153
+
154
+
155
+ def load_config(tag: str) -> Optional[dict]:
156
+ """Fetch and decrypt the stored config for ``tag``.
157
+
158
+ Returns the config dict, or ``None`` if absent / not configured / any
159
+ failure. Never raises.
160
+ """
161
+ if not (is_configured() and tag):
162
+ return None
163
+ try:
164
+ url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{_object_path(tag)}"
165
+ status, body = _http("GET", url, _auth_headers())
166
+ if not (200 <= status < 300) or not body:
167
+ return None
168
+ return decrypt_payload(tag, body)
169
+ except Exception: # noqa: BLE001 - fail-safe
170
+ return None
171
+
172
+
173
+ def delete_config(tag: str) -> bool:
174
+ """Remove the stored config for ``tag`` (used by Clear Credentials).
175
+
176
+ Returns True on success, False otherwise. Never raises.
177
+ """
178
+ if not (is_configured() and tag):
179
+ return False
180
+ try:
181
+ url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{_object_path(tag)}"
182
+ status, _ = _http("DELETE", url, _auth_headers())
183
+ return 200 <= status < 300
184
+ except Exception: # noqa: BLE001 - fail-safe
185
+ return False
app/ui/setup_screen.py CHANGED
@@ -2,11 +2,14 @@
2
 
3
  This is the only onboarding step a user needs. They pick a provider, model,
4
  and (vision) model and paste their API key directly into the **Configure AI
5
- Service** form; clicking *Save Configuration* keeps those values in this
6
- browser session's memory only — the API key is never written to disk or to
7
- ``os.environ`` (so it can't persist or leak to another visitor on a shared
8
- deployment) and is discarded on page reload. They then click *Run API Check*
9
- to confirm the service is reachable, which unlocks the rest of the flow.
 
 
 
10
 
11
  Internal data-handling rules (kept in code/README, not surfaced as a
12
  required UI step):
@@ -41,6 +44,7 @@ from app.config import (
41
  reload_settings,
42
  )
43
  from app.models.provider_models import get_text_models, get_vision_models
 
44
  from app.services.api_gate import ApiGateError, run_capability_test
45
  from app.ui import theme
46
 
@@ -216,11 +220,16 @@ def _handle_config_save(
216
  proposal_max_output_tokens=proposal_max_output_tokens,
217
  log_retention_days=log_retention_days,
218
  )
219
- # ``updates`` holds the raw key — stored in this browser session's memory
220
- # only (never on disk or in os.environ), so it is gone on page reload and
221
- # never leaks to another visitor on a shared deployment. Never logged.
222
  apply_session_overrides(updates)
223
 
 
 
 
 
 
 
224
  # Clear stale gate state so the next step re-locks until the new config
225
  # passes the API check.
226
  for key in _API_STATE_KEYS:
@@ -231,10 +240,38 @@ def _handle_config_save(
231
  st.rerun()
232
 
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  def _handle_clear_credentials() -> None:
235
  """Wipe every API key from this session's memory and re-lock the API gate."""
236
  config.clear_session_api_keys()
237
 
 
 
 
 
 
 
 
238
  # Drop any typed key from widget state so nothing is re-rendered, then
239
  # re-lock the gate: with no key saved, the rest of the flow must stay
240
  # locked until a new key is saved and the check passes again.
@@ -332,9 +369,10 @@ def _render_config_form(settings) -> None:
332
  theme.section_label("Configure AI Service")
333
  st.write(
334
  "Enter your AI service details and click Save Configuration. "
335
- "Your API key is kept in this browser session's memory only — it "
336
- "is never written to a file and is discarded when you reload or "
337
- "close the page. You'll re-enter it next time you open the app."
 
338
  )
339
  if just_saved:
340
  st.success("Configuration saved. Run API Check to continue.")
@@ -342,7 +380,7 @@ def _render_config_form(settings) -> None:
342
  st.success("Saved credentials cleared. Enter a new API key to continue.")
343
  masked = _saved_key_status(settings)
344
  if masked:
345
- st.caption(f"Key entered this session for the current provider: {masked}")
346
 
347
  # --- Text / LLM settings ---------------------------------------
348
  provider = st.selectbox(
@@ -366,8 +404,8 @@ def _render_config_form(settings) -> None:
366
  key=_FORM_API_KEY,
367
  type="password",
368
  help=(
369
- "Held in this session's memory only, never saved to disk. "
370
- "Leave blank to keep the key already entered this session."
371
  ),
372
  )
373
 
 
2
 
3
  This is the only onboarding step a user needs. They pick a provider, model,
4
  and (vision) model and paste their API key directly into the **Configure AI
5
+ Service** form; clicking *Save Configuration* applies those values to the
6
+ session and, when key storage is configured, also remembers them across visits
7
+ by saving them to Supabase **encrypted with this browser's invisible tag**
8
+ (Supabase only ever holds ciphertext; the key is never written to ``os.environ``
9
+ and never readable by another visitor). They then click *Run API Check* to
10
+ confirm the service is reachable, which unlocks the rest of the flow. On a
11
+ return visit the saved config is decrypted and restored automatically, so they
12
+ skip Setup entirely. See :mod:`app.services.key_store` / `browser_tag`.
13
 
14
  Internal data-handling rules (kept in code/README, not surfaced as a
15
  required UI step):
 
44
  reload_settings,
45
  )
46
  from app.models.provider_models import get_text_models, get_vision_models
47
+ from app.services import browser_tag, key_store
48
  from app.services.api_gate import ApiGateError, run_capability_test
49
  from app.ui import theme
50
 
 
220
  proposal_max_output_tokens=proposal_max_output_tokens,
221
  log_retention_days=log_retention_days,
222
  )
223
+ # ``updates`` holds the raw key — applied to this browser session's memory
224
+ # (never on disk or in os.environ). Never logged.
 
225
  apply_session_overrides(updates)
226
 
227
+ # Remember it for next time: persist the config to Supabase, ENCRYPTED with
228
+ # this browser's invisible tag (Supabase only ever holds ciphertext). Fully
229
+ # fail-safe — if storage isn't configured or the call fails, nothing happens
230
+ # and the app simply behaves session-only.
231
+ _persist_config_to_store()
232
+
233
  # Clear stale gate state so the next step re-locks until the new config
234
  # passes the API check.
235
  for key in _API_STATE_KEYS:
 
240
  st.rerun()
241
 
242
 
243
+ def _persist_config_to_store() -> None:
244
+ """Save the current session config to Supabase, encrypted per-browser.
245
+
246
+ No-op unless key storage is configured AND there is an API key to remember.
247
+ Never raises (see :mod:`app.services.key_store`).
248
+ """
249
+ if not key_store.is_configured():
250
+ return
251
+ overrides = config.export_session_overrides()
252
+ payload = {
253
+ k: v
254
+ for k, v in overrides.items()
255
+ if isinstance(v, str) and k.isupper() and k != "DOSSIER_FOLDER_PATH"
256
+ }
257
+ if not any(payload.get(name) for name in config._PROVIDER_API_KEY_ENV):
258
+ return # nothing worth remembering yet
259
+ tag = browser_tag.get_or_create_tag()
260
+ if tag:
261
+ key_store.save_config(tag, payload)
262
+
263
+
264
  def _handle_clear_credentials() -> None:
265
  """Wipe every API key from this session's memory and re-lock the API gate."""
266
  config.clear_session_api_keys()
267
 
268
+ # Also forget the encrypted copy stored for this browser, so "Clear
269
+ # Credentials" truly removes it everywhere.
270
+ if key_store.is_configured():
271
+ tag = browser_tag.get_or_create_tag()
272
+ if tag:
273
+ key_store.delete_config(tag)
274
+
275
  # Drop any typed key from widget state so nothing is re-rendered, then
276
  # re-lock the gate: with no key saved, the rest of the flow must stay
277
  # locked until a new key is saved and the check passes again.
 
369
  theme.section_label("Configure AI Service")
370
  st.write(
371
  "Enter your AI service details and click Save Configuration. "
372
+ "Your API key is **encrypted and remembered on this device**, so "
373
+ "you won't need to re-enter it next time. It is stored encrypted "
374
+ "(never in plain text), never shared with other visitors, and you "
375
+ "can remove it any time with Clear Credentials."
376
  )
377
  if just_saved:
378
  st.success("Configuration saved. Run API Check to continue.")
 
380
  st.success("Saved credentials cleared. Enter a new API key to continue.")
381
  masked = _saved_key_status(settings)
382
  if masked:
383
+ st.caption(f"Key remembered on this device for the current provider: {masked}")
384
 
385
  # --- Text / LLM settings ---------------------------------------
386
  provider = st.selectbox(
 
404
  key=_FORM_API_KEY,
405
  type="password",
406
  help=(
407
+ "Stored encrypted and remembered on this device. "
408
+ "Leave blank to keep the key you already saved."
409
  ),
410
  )
411
 
requirements.txt CHANGED
@@ -12,6 +12,13 @@ streamlit==1.55.0
12
  pydantic==2.12.5
13
  python-dotenv==1.2.2
14
 
 
 
 
 
 
 
 
15
  # --- Optional UI: clipboard paste on the Job Screenshot screen ---
16
  # Lets a user paste a copied screenshot directly into the app. The screen
17
  # degrades gracefully to drag-drop/browse if this is absent (the import is
 
12
  pydantic==2.12.5
13
  python-dotenv==1.2.2
14
 
15
+ # --- Encrypted per-browser key storage (Supabase-backed) ---
16
+ # `cryptography` encrypts the saved Setup config (Fernet); `httpx` (already a
17
+ # dependency of the OpenAI SDK) makes the Supabase Storage REST calls. Both are
18
+ # imported directly by app/services/key_store.py, so they are pinned here.
19
+ cryptography==46.0.6
20
+ httpx==0.28.1
21
+
22
  # --- Optional UI: clipboard paste on the Job Screenshot screen ---
23
  # Lets a user paste a copied screenshot directly into the app. The screen
24
  # degrades gracefully to drag-drop/browse if this is absent (the import is