Most stock tools want your broker login, your portfolio data, or give you opaque AI picks you can't verify. I built StockAgent, a Chrome extension that grades your watchlist using transparent, deterministic rules, and I wanted to share how I designed the privacy layer because I think the pattern is useful beyond finance apps.
What StockAgent does (quick context)
Each stock scores 0–5 on five financial metrics:
| Factor | +1 if |
|---|---|
| Debt-to-Equity | Below sector threshold |
| PEG | Below threshold (varies by sector) |
| ROE | Above 15% (or 8–12% for banks/utilities) |
| 200-day SMA | Price above moving average |
| RSI | Below 35 (oversold) |
On top of this, news headlines from Yahoo Finance RSS are scanned for risk keywords and scored by severity:
| Severity | Penalty | Examples |
|---|---|---|
| Severe | -2 per headline (cap -3) | Fraud, bankruptcy, SEC, delisting |
| Moderate | -1 per headline (cap -2) | Lawsuit, downgrade, layoffs, earnings miss |
| Mild | 0 (informational only) | Guidance cut, analyst concern |
Final score: 4–5 = STRONG BUY, 3 = HOLD, 0–2 = AVOID. Same data = same grade, every time. No AI in the scoring loop.
The extension also sends scheduled email digests and optionally uses a BYOK Gemini key to explain grades in plain English (never influences the score).
The privacy problem
Users track stocks with real money. They want grades, but they don't want their share counts or buy prices on someone else's server. So the challenge was: how do you build a useful cloud feature (email reports) without ever seeing the user's actual portfolio?
The solution: three-layer privacy enforcement
Layer 1: Two-tier storage
Everything lives in chrome.storage.local, but the code treats it as two separate tiers:
// PRIVATE (never transmitted)
holdings: { NVDA: { shares: 50, buyPrice: 120.00 } }
geminiApiKey: "sk-..."
autoAnalyze: true
// CLOUD-ELIGIBLE (sent only if user opts into email)
watchlist: ["NVDA", "AAPL", "SHOP.TO"]
delivery: { email: "...", schedule: {...}, enabled: true }
Layer 2: Allowlist payload construction
When the user clicks "Save & Subscribe," I don't copy the local state and strip private fields. That's fragile. One missed field, and you leak data. Instead, the outbound object is reconstructed from scratch using only allowed fields:
export function buildCloudPayload(state) {
const email = String(state?.delivery?.email ?? "").trim().toLowerCase();
const schedule = normalizeSchedule(state?.delivery?.schedule);
const watchlist = (Array.isArray(state?.watchlist) ? state.watchlist : [])
.map(normalizeTicker)
.filter(Boolean)
.slice(0, MAX_WATCHLIST);
return { email, watchlist, schedule, enabled, userId };
}
Holdings and API keys are never referenced here. They can't leak because they're never included.
Layer 3: Runtime blocklist assertion
Even after allowlist construction, every payload passes through assertNoPrivateLeak() before hitting the network:
const FORBIDDEN_CLOUD_KEYS = Object.freeze([
"holdings", "shares", "buyPrice", "avgBuyPrice",
"geminiApiKey", "geminiKey", "apiKey",
"autoAnalyze", "netWorth", "portfolio",
]);
export function assertNoPrivateLeak(payload) {
const stack = [payload];
while (stack.length) {
const node = stack.pop();
for (const [key, value] of Object.entries(node)) {
if (FORBIDDEN_CLOUD_KEYS.includes(key)) {
throw new Error(`Refusing to transmit private field: ${key}`);
}
if (isPlainObject(value)) stack.push(value);
}
}
// Root-level allowlist — reject ANY unexpected field
const allowed = new Set(["email", "watchlist", "schedule", "enabled", "userId"]);
for (const key of Object.keys(payload)) {
if (!allowed.has(key)) {
throw new Error(`Refusing unexpected cloud field: ${key}`);
}
}
return payload;
}
This catches:
- A developer accidentally adding a field to the payload
- A merge conflict introducing a private key
- Any nested object containing forbidden fields
Layer 4 (bonus): Server rejects it anyway
The FastAPI backend uses Pydantic with extra="forbid" on every schema:
class SubscribeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: EmailStr
watchlist: list[str]
schedule: ScheduleConfig
enabled: bool = True
If a payload with holdings or geminiApiKey somehow arrived, the API returns 422 before touching the database. Belt and suspenders.
The call path
User clicks "Save & Subscribe"
→ buildCloudPayload() reconstructs from allowlist
→ assertNoPrivateLeak() walks the object, throws on forbidden keys
→ POST /api/subscribe { email, watchlist, schedule, enabled }
→ Pydantic rejects extra fields (422 if violated)
→ Supabase stores ONLY email, tickers, schedule, timezone
Why this pattern matters beyond finance
If you're building any Chrome extension that handles sensitive data — passwords, health info, personal notes — this three-layer approach works:
- Construct outbound payloads from scratch (don't filter existing state)
- Assert at runtime before every network call (catches future regressions)
- Reject unknown fields server-side (defense in depth)
It's more work upfront than just "don't include the field," but it turns a convention into an invariant. A future contributor can't accidentally break it without seeing a hard error in development.
The rest of StockAgent
The privacy architecture is one piece. The extension also has:
- Deterministic grading engine: 5 financial metrics with sector-aware thresholds (banks, tech, pharma, crypto all scored differently)
- Tiered news risk scoring: Yahoo RSS headlines penalize stocks by severity (fraud = -2, lawsuit = -1, guidance cut = informational)
- Scheduled email digests: AWS EventBridge + Lambda fires every 5 min for punctual delivery
- Optional AI explanations: BYOK Gemini key explains grades in English — never influences the score
- Multi-region: US, Canada, and India exchanges
Links
- Chrome Web Store
- GitHub repo (full source — read storage.js for the privacy layer)
- Short demo video (1 min 30 sec)
- Detailed demo video (5 min)
Top comments (0)