|
| 1 | +# shoplit browser extension — v1 design |
| 2 | + |
| 3 | +**Date:** 2026-05-24 |
| 4 | +**Status:** Approved (brainstorm) → pending implementation plan |
| 5 | + |
| 6 | +## Problem |
| 7 | + |
| 8 | +Creators add products to a cart by pasting a retailer URL; the server fetches |
| 9 | +OpenGraph/JSON-LD metadata (title/image/price). In production this fails: |
| 10 | +shoplit's API runs on an AWS host whose datacenter IP is blocked by retailer |
| 11 | +bot-protection (Myntra serves a challenge page, Nykaa returns 403, Amazon omits |
| 12 | +`og:image`). Pasting share-text now lifts the title client-side, but **images |
| 13 | +and price still can't be fetched server-side**. |
| 14 | + |
| 15 | +A browser extension solves this at the root: it runs in the creator's own |
| 16 | +browser — residential IP, logged-in retailer session — so it reads the live |
| 17 | +product page DOM exactly as a real shopper's browser sees it. No IP block, no |
| 18 | +third-party scraping service. |
| 19 | + |
| 20 | +## Goals |
| 21 | + |
| 22 | +- Add a product to one of the creator's shoplit carts directly from a retailer |
| 23 | + product page (Nykaa, Myntra, Amazon, Flipkart, AJIO), capturing title, image, |
| 24 | + price, and the canonical URL. |
| 25 | +- Two entry points: the toolbar popup and an injected on-page button. |
| 26 | +- Authenticate the extension to the shoplit API without relying on cross-site |
| 27 | + cookies. |
| 28 | + |
| 29 | +## Non-goals (v2+) |
| 30 | + |
| 31 | +- Chrome Web Store publishing (v1 is loaded unpacked for dev/testing). |
| 32 | +- Firefox / Safari ports (build MV3, which Edge also runs). |
| 33 | +- Bulk / wishlist import; variant and image-gallery selection. |
| 34 | +- Usage analytics on the extension. |
| 35 | +- A "connected devices" management UI in shoplit settings (token revocation |
| 36 | + exists in the data model; the UI is deferred). |
| 37 | + |
| 38 | +## Architecture |
| 39 | + |
| 40 | +Three parts: the extension, small backend additions to `shoplit-api`, and one |
| 41 | +new frontend page. |
| 42 | + |
| 43 | +``` |
| 44 | + Retailer product page (browser, residential IP) |
| 45 | + │ content script: extract product + inject "+ shoplit" button |
| 46 | + ▼ |
| 47 | + Extension popup ── service worker ──(Bearer token)──▶ shoplit-api (api.* / shoplit.in/api) |
| 48 | + │ shows product + cart picker + note + Add GET /carts, POST /carts/{id}/items |
| 49 | + │ |
| 50 | + Connect flow: shoplit.in/connect-extension ──(externally_connectable)──▶ service worker stores token |
| 51 | +``` |
| 52 | + |
| 53 | +### Component 1 — Extension (Manifest V3) |
| 54 | + |
| 55 | +New top-level `extension/` directory. Plain TypeScript bundled with esbuild into |
| 56 | +three entry points. No framework. |
| 57 | + |
| 58 | +- **content script** — injected on the five retailer domains (`content_scripts` |
| 59 | + matches). Responsibilities: |
| 60 | + - Extract the product (see Extraction). |
| 61 | + - Inject a floating "+ shoplit" button on product pages. Clicking it opens a |
| 62 | + small **in-page panel rendered by the content script** (MV3 can't reliably |
| 63 | + open the toolbar popup programmatically). The panel and the popup share the |
| 64 | + same add UI module and add logic — only the host surface differs. |
| 65 | + - Respond to `extractProduct` messages from the popup. |
| 66 | +- **service worker** (`background.service_worker`) — token storage |
| 67 | + (`chrome.storage.local`), all `fetch` calls to the shoplit API (host |
| 68 | + permission for `shoplit.in`), message routing between popup/content script, |
| 69 | + and `chrome.runtime.onMessageExternal` for the token handoff. |
| 70 | +- **popup** (`action`) — when not connected, shows "Connect to shoplit"; when |
| 71 | + connected, requests the current tab's extracted product from the content |
| 72 | + script, lists the creator's carts (`GET /carts`), and offers cart picker + |
| 73 | + optional note + **Add**. Minimal inline edit of title/price is allowed before |
| 74 | + adding. |
| 75 | + |
| 76 | +**Manifest V3 keys:** |
| 77 | +- `manifest_version: 3` |
| 78 | +- `permissions`: `activeTab`, `scripting`, `storage` |
| 79 | +- `host_permissions`: the five retailer origins + `https://shoplit.in/*` |
| 80 | +- `content_scripts`: matches the five retailer product domains |
| 81 | +- `action`: default popup |
| 82 | +- `externally_connectable.matches`: `https://shoplit.in/*` |
| 83 | + |
| 84 | +### Component 2 — Backend additions (shoplit-api) |
| 85 | + |
| 86 | +- **`extension_tokens` table** (migration): `id`, `user_id` (FK), `token_hash` |
| 87 | + (sha256 of the token — never store the raw token), `created_at`, |
| 88 | + `last_used_at`, `revoked_at` (nullable). Index on `token_hash`. |
| 89 | +- **`POST /api/v1/extension/token`** — authenticated (session cookie). Generates |
| 90 | + a random token (e.g. 32 bytes base64url), stores its hash, returns the raw |
| 91 | + token once. Used by the connect page. |
| 92 | +- **Bearer auth in `RequireUser`** — the middleware first checks the session |
| 93 | + cookie (current behavior); if absent, it accepts `Authorization: Bearer |
| 94 | + <token>`, looks up the (unrevoked) token by hash, resolves the user, and |
| 95 | + updates `last_used_at`. This is additive — existing cookie flows are untouched. |
| 96 | +- **Reused, unchanged:** `GET /api/v1/carts` (picker), `POST |
| 97 | + /api/v1/carts/{id}/items` (the existing explicit-fields add path that mints the |
| 98 | + `/go` link), `GET /api/v1/me`. |
| 99 | + |
| 100 | +### Component 3 — Frontend connect page |
| 101 | + |
| 102 | +`shoplit.in/connect-extension` (authenticated; redirects to `/login` if not). |
| 103 | +On load it calls `POST /api/v1/extension/token`, then hands the token to the |
| 104 | +extension via `chrome.runtime.sendMessage(EXTENSION_ID, { type: "shoplit-token", |
| 105 | +token })`. If the extension isn't detected, it falls back to displaying the token |
| 106 | +as a one-time **copy-paste code** the creator pastes into the extension popup. |
| 107 | +The page warns the token grants cart access and to keep it private. |
| 108 | + |
| 109 | +## Extraction strategy (generic, per-page, in order) |
| 110 | + |
| 111 | +1. **JSON-LD** — parse every `<script type="application/ld+json">`, find an |
| 112 | + object (or `@graph` entry) with `@type: "Product"`; read `name`, `image` |
| 113 | + (string or array → first), `offers.price` + `offers.priceCurrency`, `url`. |
| 114 | + Nykaa, Amazon, and most retailers emit this. |
| 115 | +2. **OG / meta fallback** — `og:title`/`twitter:title`, `og:image`/ |
| 116 | + `twitter:image`, `product:price:amount` + `product:price:currency`. |
| 117 | +3. **Per-site selector fallback** — a small map keyed by hostname for fields the |
| 118 | + above miss (kept intentionally tiny; only added where observed necessary). |
| 119 | + |
| 120 | +Retailer is classified from the hostname (a JS port of the Go `RetailerFromURL`, |
| 121 | +including the `amzn.in`/`amzn.to`/`a.co` short hosts). Canonical URL: `<link |
| 122 | +rel="canonical">` → `og:url` → `location.href`. Price is normalized to a display |
| 123 | +string (e.g. `₹590`). |
| 124 | + |
| 125 | +## Add flow |
| 126 | + |
| 127 | +1. Creator on a product page clicks the toolbar icon or the injected button. |
| 128 | +2. Content script extracts `{ title, imageUrl, priceText, url (canonical), |
| 129 | + retailer }`. |
| 130 | +3. Popup shows the product, fetches `GET /carts` (Bearer) for the picker. |
| 131 | +4. Creator picks a cart, optionally edits title/price or adds a note, clicks Add. |
| 132 | +5. Service worker `POST /api/v1/carts/{id}/items` (Bearer) with the fields. |
| 133 | +6. Backend runs the existing `AddProduct` path (mints the `/go` link, stores the |
| 134 | + item). Returns success. |
| 135 | +7. Popup shows "Added ✓" with a link to the cart. |
| 136 | + |
| 137 | +## Error handling |
| 138 | + |
| 139 | +- **Not a product page / nothing extracted** — popup shows "Couldn't find a |
| 140 | + product here" with manual fields (title/image/price) so the add never |
| 141 | + dead-ends. |
| 142 | +- **Not connected / token revoked or invalid (401)** — popup drops to the |
| 143 | + Connect state and prompts re-connect. |
| 144 | +- **API/network error on add** — inline error + retry; the extracted data stays |
| 145 | + in the popup so nothing is lost. |
| 146 | +- **Multiple JSON-LD products on a page** — prefer the one whose `url` matches |
| 147 | + the canonical URL, else the first. |
| 148 | + |
| 149 | +## Security |
| 150 | + |
| 151 | +- Token stored only in `chrome.storage.local` (extension-private). Backend stores |
| 152 | + only its sha256 hash; supports revocation via `revoked_at`. |
| 153 | +- `externally_connectable` restricts which web origin can message the extension |
| 154 | + to `shoplit.in`. |
| 155 | +- The connect page mints a token only for an authenticated session. |
| 156 | +- Content script footprint on retailer pages is minimal and read-only with |
| 157 | + respect to the host page (aside from the injected button element). |
| 158 | + |
| 159 | +## Testing |
| 160 | + |
| 161 | +- **Backend:** unit/integration tests for token mint and Bearer-auth resolution |
| 162 | + in `RequireUser`, mirroring the existing carts handler tests (testcontainers). |
| 163 | +- **Extension:** extraction unit tests against saved HTML fixtures (real Nykaa / |
| 164 | + Myntra / Amazon / Flipkart / AJIO product pages) asserting title/image/price; |
| 165 | + manual end-to-end on each retailer for the popup + injected-button + add flow. |
| 166 | + |
| 167 | +## Repo layout |
| 168 | + |
| 169 | +``` |
| 170 | +extension/ |
| 171 | + manifest.json |
| 172 | + src/ |
| 173 | + content.ts # extraction + injected button |
| 174 | + service-worker.ts |
| 175 | + popup.ts / popup.html |
| 176 | + extract.ts # JSON-LD/OG/selector logic (unit-tested) |
| 177 | + retailer.ts # hostname → retailer (JS port of RetailerFromURL) |
| 178 | + api.ts # shoplit API client (Bearer) |
| 179 | + build.mjs # esbuild |
| 180 | +internal/db/migrations/000X_extension_tokens.{up,down}.sql |
| 181 | +internal/auth/… # Bearer-token resolution |
| 182 | +web/app/connect-extension/page.tsx |
| 183 | +``` |
0 commit comments