DEV Community

Cover image for I built a Chrome extension that grades stocks with deterministic rules; here's the privacy architecture
Tridib Banik
Tridib Banik

Posted on

I built a Chrome extension that grades stocks with deterministic rules; here's the privacy architecture

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 }
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Construct outbound payloads from scratch (don't filter existing state)
  2. Assert at runtime before every network call (catches future regressions)
  3. 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

Top comments (0)