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

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

Leader 1 6
calendar_today agoschedule4 min read
— Originally published at dev.to

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:

  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
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Europe Just Dropped the Hammer on AI: A Wake-Up Call?

PrabashanaDev - Jul 15

5 Web Dev Pitfalls That Are Silently Killing Your Projects (With Real Fixes)

Dharanidharan - Mar 3

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelski - Mar 19

The Privacy Gap: Why sending financial ledgers to OpenAI is broken

Pocket Portfolio - Feb 23
chevron_left
760 Points7 Badges
Hamilton, ONtridib.vercel.app
2Posts
1Comments
3Connections
Software Engineering Level IV Student

Commenters (This Week)

1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!