From 2a3d8f4de4ed2e533068d83cb9f6b314d9a40a9e Mon Sep 17 00:00:00 2001 From: jckail Date: Thu, 9 Jul 2026 09:21:48 -0700 Subject: [PATCH] Add a Chrome (MV3) extension: capture balances from provider pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 multi-surface item — the second consumer of @pointup/api-client. Reads the loyalty balance a provider page already shows the signed-in user and records it as a manual snapshot in PointUp; no credentials shared. - apps/extension: MV3 extension bundled with esbuild. - src/extraction.ts: pure, unit-tested provider detection + balance extraction (no DOM, no chrome APIs) with a per-provider host/regex registry. - content script (scrape visible text) → background worker (match provider → account, POST manual balance via the typed client) → popup (settings + latest capture + one-click record). - Bearer-token auth (same path as mobile); no api-client refactor needed — it already supports baseUrl + headers. - 11 tests, incl. a guard that the manifest content_scripts matches stay in sync with the extraction rules. - CI builds the extension; docs/extension.md; README layout + docs list. Verification: 123 tests pass (92 core + 20 bot + 11 extension); typecheck + lint clean; extension build produces a loadable dist/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 3 + README.md | 4 +- apps/extension/package.json | 22 +++++ apps/extension/public/manifest.json | 25 ++++++ apps/extension/public/popup.html | 91 +++++++++++++++++++ apps/extension/src/background.ts | 85 ++++++++++++++++++ apps/extension/src/config.ts | 33 +++++++ apps/extension/src/content.ts | 27 ++++++ apps/extension/src/extraction.ts | 116 +++++++++++++++++++++++++ apps/extension/src/messages.ts | 24 +++++ apps/extension/src/popup.ts | 53 +++++++++++ apps/extension/test/extraction.test.ts | 94 ++++++++++++++++++++ apps/extension/tsconfig.json | 8 ++ docs/extension.md | 64 ++++++++++++++ package-lock.json | 53 +++++++++++ 15 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 apps/extension/package.json create mode 100644 apps/extension/public/manifest.json create mode 100644 apps/extension/public/popup.html create mode 100644 apps/extension/src/background.ts create mode 100644 apps/extension/src/config.ts create mode 100644 apps/extension/src/content.ts create mode 100644 apps/extension/src/extraction.ts create mode 100644 apps/extension/src/messages.ts create mode 100644 apps/extension/src/popup.ts create mode 100644 apps/extension/test/extraction.test.ts create mode 100644 apps/extension/tsconfig.json create mode 100644 docs/extension.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f118675..a6577ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: - name: Build bot bundle run: npm run build --workspace @pointup/bot + - name: Build extension + run: npm run build --workspace @pointup/extension + infra: name: Typecheck & synth infrastructure runs-on: ubuntu-latest diff --git a/README.md b/README.md index b7f67c0..7928807 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Track airline miles, hotel points, credit card rewards, and every other loyalty - [docs/brand.md](./docs/brand.md) — brand kit: logo assets, color tokens, typography, voice - [docs/migration-from-pointup.md](./docs/migration-from-pointup.md) — how the modernization was ported into `point_bot`, feature-parity checklist, and the Bedrock assistant - [docs/bot.md](./docs/bot.md) — the PointBot chat surface: Slack/Discord commands, the `Notifier` port, digests, and deployment +- [docs/extension.md](./docs/extension.md) — the Chrome extension: capture balances from provider pages via `@pointup/api-client` ## Repository layout @@ -39,7 +40,8 @@ Track airline miles, hotel points, credit card rewards, and every other loyalty │ │ ├── src/components/ # branded UI components (logo, cards, forms) │ │ └── public/brand/ # brand kit assets (SVG logomarks, lockup) │ ├── worker/ # Background jobs: scheduled syncs + email + chat digests -│ └── bot/ # PointBot chat surface: Slack/Discord commands over the core +│ ├── bot/ # PointBot chat surface: Slack/Discord commands over the core +│ └── extension/ # Chrome (MV3) extension: capture balances from provider pages ├── packages/ │ ├── core/ # Domain + application + infrastructure (framework-free) │ │ ├── src/domain/ # entities, provider catalog, repository ports, errors diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 0000000..6221399 --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,22 @@ +{ + "name": "@pointup/extension", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "PointUp Chrome extension: capture loyalty balances from provider pages you visit and record them as manual snapshots — sync without credentials", + "scripts": { + "build": "esbuild src/background.ts src/content.ts src/popup.ts --bundle --format=iife --target=chrome110 --outdir=dist && cp public/manifest.json public/popup.html dist/", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@pointup/api-client": "*", + "@pointup/core": "*" + }, + "devDependencies": { + "@types/chrome": "^0.0.287", + "esbuild": "^0.25.0", + "typescript": "^5.9.0", + "vitest": "^3.0.0" + } +} diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json new file mode 100644 index 0000000..2144c11 --- /dev/null +++ b/apps/extension/public/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "PointUp balance capture", + "version": "1.0.0", + "description": "Capture loyalty balances from provider pages you visit and record them in PointUp — sync without sharing credentials.", + "permissions": ["storage"], + "host_permissions": ["https://*/*", "http://localhost/*"], + "background": { "service_worker": "background.js" }, + "action": { "default_popup": "popup.html", "default_title": "PointUp" }, + "content_scripts": [ + { + "matches": [ + "https://*.united.com/*", + "https://*.delta.com/*", + "https://*.aa.com/*", + "https://*.southwest.com/*", + "https://*.marriott.com/*", + "https://*.hyatt.com/*", + "https://*.hilton.com/*" + ], + "js": ["content.js"], + "run_at": "document_idle" + } + ] +} diff --git a/apps/extension/public/popup.html b/apps/extension/public/popup.html new file mode 100644 index 0000000..c8de99e --- /dev/null +++ b/apps/extension/public/popup.html @@ -0,0 +1,91 @@ + + + + + + PointUp + + + +

PointUp — capture balance

+ +
Open a provider page to capture a balance.
+ +
+ +
+ + + + + + + + + +
+ + + + diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts new file mode 100644 index 0000000..258c168 --- /dev/null +++ b/apps/extension/src/background.ts @@ -0,0 +1,85 @@ +import { PointUpApiError, PointUpClient } from "@pointup/api-client"; + +import { + loadConfig, + loadLatestCapture, + saveLatestCapture, +} from "./config"; +import type { ExtractedBalance } from "./extraction"; +import type { ExtensionMessage, RecordResult } from "./messages"; + +function client(baseUrl: string, token: string): PointUpClient { + return new PointUpClient({ + baseUrl, + headers: { Authorization: `Bearer ${token}` }, + }); +} + +/** Record the capture against the user's matching account. */ +async function record(capture: ExtractedBalance): Promise { + const config = await loadConfig(); + if (!config.baseUrl || !config.token) { + return { ok: false, message: "Set the API URL and token in the popup first." }; + } + + const api = client(config.baseUrl, config.token); + try { + const accounts = await api.listLoyaltyAccounts(); + const account = accounts.find((a) => a.provider.id === capture.providerId); + if (!account) { + return { + ok: false, + message: `No linked ${capture.providerId} account — link it in PointUp first.`, + }; + } + await api.recordManualBalance(account.id, { points: capture.points }); + return { + ok: true, + message: `Recorded ${capture.points.toLocaleString("en-US")} for ${account.provider.displayName}.`, + }; + } catch (error) { + if (error instanceof PointUpApiError) { + return { ok: false, message: `API error (${error.status}): ${error.message}` }; + } + return { ok: false, message: "Could not reach the PointUp API." }; + } +} + +async function updateBadge(capture: ExtractedBalance | null): Promise { + await chrome.action.setBadgeText({ text: capture ? "1" : "" }); + if (capture) { + await chrome.action.setBadgeBackgroundColor({ color: "#7C5CFF" }); + } +} + +chrome.runtime.onMessage.addListener( + (message: ExtensionMessage, _sender, sendResponse) => { + if (message.type === "capture") { + void saveLatestCapture(message.capture).then(() => + updateBadge(message.capture), + ); + return; // no async response needed + } + if (message.type === "getLatest") { + void loadLatestCapture().then((capture) => sendResponse(capture)); + return true; // async response + } + if (message.type === "record") { + void loadLatestCapture() + .then((capture) => + capture + ? record(capture) + : Promise.resolve({ + ok: false, + message: "Nothing captured yet — open a provider page.", + }), + ) + .then((result) => { + if (result.ok) void saveLatestCapture(null).then(() => updateBadge(null)); + sendResponse(result); + }); + return true; // async response + } + return undefined; + }, +); diff --git a/apps/extension/src/config.ts b/apps/extension/src/config.ts new file mode 100644 index 0000000..f1b92fe --- /dev/null +++ b/apps/extension/src/config.ts @@ -0,0 +1,33 @@ +import type { ExtractedBalance } from "./extraction"; + +/** User settings, stored in chrome.storage.local. */ +export interface ExtensionConfig { + /** PointUp API base URL, e.g. https://app.example.com */ + readonly baseUrl: string; + /** Clerk session token (bearer). See docs/multi-surface.md. */ + readonly token: string; +} + +export async function loadConfig(): Promise { + const stored = await chrome.storage.local.get(["baseUrl", "token"]); + return { + baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : "", + token: typeof stored.token === "string" ? stored.token : "", + }; +} + +export async function saveConfig(config: ExtensionConfig): Promise { + await chrome.storage.local.set(config); +} + +/** The most recently captured balance (persisted so the popup can show it). */ +export async function saveLatestCapture( + capture: ExtractedBalance | null, +): Promise { + await chrome.storage.local.set({ latestCapture: capture }); +} + +export async function loadLatestCapture(): Promise { + const stored = await chrome.storage.local.get("latestCapture"); + return (stored.latestCapture as ExtractedBalance | null) ?? null; +} diff --git a/apps/extension/src/content.ts b/apps/extension/src/content.ts new file mode 100644 index 0000000..3037a4e --- /dev/null +++ b/apps/extension/src/content.ts @@ -0,0 +1,27 @@ +import { extractBalance } from "./extraction"; +import type { CaptureMessage } from "./messages"; + +/** + * Runs on known provider pages. Reads the visible page text, extracts the + * loyalty balance, and hands it to the background worker. No credentials are + * ever read — only the balance number the page already shows the signed-in user. + */ +function capture(): void { + const capture = extractBalance({ + url: location.href, + text: document.body?.innerText ?? "", + }); + if (!capture) return; + + const message: CaptureMessage = { type: "capture", capture }; + void chrome.runtime.sendMessage(message); +} + +// Provider dashboards render balances after hydration; retry a few times. +capture(); +let attempts = 0; +const timer = setInterval(() => { + attempts += 1; + capture(); + if (attempts >= 5) clearInterval(timer); +}, 2000); diff --git a/apps/extension/src/extraction.ts b/apps/extension/src/extraction.ts new file mode 100644 index 0000000..68f5f42 --- /dev/null +++ b/apps/extension/src/extraction.ts @@ -0,0 +1,116 @@ +/** + * Pure, framework-free balance extraction. Given a page's hostname and visible + * text, detect which loyalty provider the page belongs to and pull the balance + * out of it — no DOM, no chrome APIs — so it can be unit-tested exhaustively. + * + * These are best-effort heuristics keyed on each program's balance wording; + * they're intentionally conservative (a keyword must be present) to avoid + * grabbing an unrelated number. Tune the patterns per provider over time. + */ + +export interface ProviderPageRule { + readonly providerId: string; + /** Hostname substrings that identify this provider's site. */ + readonly hosts: readonly string[]; + /** Ordered regexes; first one whose capture group holds a number wins. */ + readonly patterns: readonly RegExp[]; +} + +export const PROVIDER_PAGE_RULES: readonly ProviderPageRule[] = [ + { + providerId: "united", + hosts: ["united.com"], + patterns: [/([\d,]+)\s*miles/i], + }, + { + providerId: "delta", + hosts: ["delta.com"], + patterns: [/([\d,]+)\s*miles/i], + }, + { + providerId: "american", + hosts: ["aa.com"], + patterns: [/([\d,]+)\s*(?:aadvantage\s*)?miles/i], + }, + { + providerId: "southwest", + hosts: ["southwest.com"], + patterns: [/([\d,]+)\s*(?:rapid\s*rewards\s*)?points/i], + }, + { + providerId: "marriott", + hosts: ["marriott.com"], + patterns: [/([\d,]+)\s*(?:bonvoy\s*)?points/i], + }, + { + providerId: "hyatt", + hosts: ["hyatt.com"], + patterns: [/([\d,]+)\s*points/i], + }, + { + providerId: "hilton", + hosts: ["hilton.com"], + patterns: [/([\d,]+)\s*points/i], + }, +]; + +/** Hosts the content script should run on (for the manifest matches). */ +export function providerHostGlobs(): string[] { + return PROVIDER_PAGE_RULES.flatMap((rule) => + rule.hosts.map((host) => `https://*.${host}/*`), + ); +} + +export function detectProvider(hostname: string): ProviderPageRule | null { + const host = hostname.toLowerCase(); + return ( + PROVIDER_PAGE_RULES.find((rule) => + rule.hosts.some((h) => host === h || host.endsWith(`.${h}`)), + ) ?? null + ); +} + +/** Parse "1,234,567" → 1234567; returns null for non-numbers. */ +export function parsePoints(raw: string): number | null { + const digits = raw.replace(/,/g, ""); + if (!/^\d+$/.test(digits)) return null; + const value = Number(digits); + return Number.isSafeInteger(value) ? value : null; +} + +export function extractPointsWithRule( + rule: ProviderPageRule, + text: string, +): number | null { + for (const pattern of rule.patterns) { + const match = pattern.exec(text); + const points = match?.[1] ? parsePoints(match[1]) : null; + if (points !== null) return points; + } + return null; +} + +export interface ExtractedBalance { + readonly providerId: string; + readonly points: number; +} + +/** + * End-to-end: from a page URL + visible text to a {providerId, points} + * capture, or null when the page isn't a known provider or no balance is found. + */ +export function extractBalance(input: { + readonly url: string; + readonly text: string; +}): ExtractedBalance | null { + let hostname: string; + try { + hostname = new URL(input.url).hostname; + } catch { + return null; + } + const rule = detectProvider(hostname); + if (!rule) return null; + const points = extractPointsWithRule(rule, input.text); + return points === null ? null : { providerId: rule.providerId, points }; +} diff --git a/apps/extension/src/messages.ts b/apps/extension/src/messages.ts new file mode 100644 index 0000000..c44b3b6 --- /dev/null +++ b/apps/extension/src/messages.ts @@ -0,0 +1,24 @@ +import type { ExtractedBalance } from "./extraction"; + +/** content script → background: a balance was scraped from a provider page. */ +export interface CaptureMessage { + readonly type: "capture"; + readonly capture: ExtractedBalance; +} + +/** popup → background: record the latest capture against the user's account. */ +export interface RecordMessage { + readonly type: "record"; +} + +/** popup → background: fetch the latest capture to display. */ +export interface GetLatestMessage { + readonly type: "getLatest"; +} + +export type ExtensionMessage = CaptureMessage | RecordMessage | GetLatestMessage; + +export interface RecordResult { + readonly ok: boolean; + readonly message: string; +} diff --git a/apps/extension/src/popup.ts b/apps/extension/src/popup.ts new file mode 100644 index 0000000..6a29ce1 --- /dev/null +++ b/apps/extension/src/popup.ts @@ -0,0 +1,53 @@ +import { loadConfig, saveConfig } from "./config"; +import type { ExtractedBalance } from "./extraction"; +import type { RecordResult } from "./messages"; + +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el; +} + +async function refreshLatest(): Promise { + const capture = (await chrome.runtime.sendMessage({ + type: "getLatest", + })) as ExtractedBalance | null; + const box = $("latest"); + const recordBtn = $("record") as HTMLButtonElement; + if (capture) { + box.textContent = `${capture.providerId}: ${capture.points.toLocaleString("en-US")}`; + recordBtn.disabled = false; + } else { + box.textContent = "Open a provider page to capture a balance."; + recordBtn.disabled = true; + } +} + +async function init(): Promise { + const config = await loadConfig(); + ($("baseUrl") as HTMLInputElement).value = config.baseUrl; + ($("token") as HTMLInputElement).value = config.token; + + $("save").addEventListener("click", () => { + void saveConfig({ + baseUrl: ($("baseUrl") as HTMLInputElement).value.trim(), + token: ($("token") as HTMLInputElement).value.trim(), + }).then(() => { + $("status").textContent = "Saved."; + }); + }); + + $("record").addEventListener("click", () => { + $("status").textContent = "Recording…"; + void chrome.runtime + .sendMessage({ type: "record" }) + .then((result: RecordResult) => { + $("status").textContent = result.message; + return refreshLatest(); + }); + }); + + await refreshLatest(); +} + +void init(); diff --git a/apps/extension/test/extraction.test.ts b/apps/extension/test/extraction.test.ts new file mode 100644 index 0000000..9c22b75 --- /dev/null +++ b/apps/extension/test/extraction.test.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + detectProvider, + extractBalance, + extractPointsWithRule, + parsePoints, + PROVIDER_PAGE_RULES, + providerHostGlobs, +} from "../src/extraction"; + +describe("parsePoints", () => { + it("parses comma-grouped integers", () => { + expect(parsePoints("1,234,567")).toBe(1234567); + expect(parsePoints("500")).toBe(500); + }); + it("rejects non-integers", () => { + expect(parsePoints("12.5")).toBeNull(); + expect(parsePoints("abc")).toBeNull(); + expect(parsePoints("")).toBeNull(); + }); +}); + +describe("detectProvider", () => { + it("matches exact and subdomain hosts", () => { + expect(detectProvider("www.united.com")?.providerId).toBe("united"); + expect(detectProvider("united.com")?.providerId).toBe("united"); + expect(detectProvider("account.marriott.com")?.providerId).toBe("marriott"); + }); + it("returns null for unknown hosts", () => { + expect(detectProvider("example.com")).toBeNull(); + // Guard against a naive substring match on a look-alike domain. + expect(detectProvider("notunited.com")).toBeNull(); + }); +}); + +describe("extractBalance", () => { + it("pulls miles from an airline page", () => { + expect( + extractBalance({ + url: "https://www.united.com/en/us/account", + text: "MileagePlus\nAvailable balance\n124,300 miles", + }), + ).toEqual({ providerId: "united", points: 124_300 }); + }); + + it("pulls Bonvoy points from a hotel page", () => { + expect( + extractBalance({ + url: "https://www.marriott.com/loyalty/myAccount.mi", + text: "Your Bonvoy points: 88,200 points available", + }), + ).toEqual({ providerId: "marriott", points: 88_200 }); + }); + + it("returns null on a provider page with no balance", () => { + expect( + extractBalance({ url: "https://www.hyatt.com/", text: "Book a hotel" }), + ).toBeNull(); + }); + + it("returns null off a known provider", () => { + expect( + extractBalance({ url: "https://news.example.com/", text: "1,000 points" }), + ).toBeNull(); + }); + + it("returns null for a malformed url", () => { + expect(extractBalance({ url: "not a url", text: "1 miles" })).toBeNull(); + }); +}); + +describe("extractPointsWithRule", () => { + it("tries patterns in order and requires the keyword", () => { + const rule = PROVIDER_PAGE_RULES.find((r) => r.providerId === "american")!; + expect(extractPointsWithRule(rule, "45,000 AAdvantage miles")).toBe(45_000); + expect(extractPointsWithRule(rule, "45,000 dollars")).toBeNull(); + }); +}); + +describe("manifest stays in sync with the rules", () => { + it("content_scripts matches equal providerHostGlobs()", () => { + const manifestPath = fileURLToPath( + new URL("../public/manifest.json", import.meta.url), + ); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + content_scripts: { matches: string[] }[]; + }; + const matches = manifest.content_scripts[0]!.matches; + expect([...matches].sort()).toEqual([...providerHostGlobs()].sort()); + }); +}); diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 0000000..be23147 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["chrome"], + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/docs/extension.md b/docs/extension.md new file mode 100644 index 0000000..73dcaf6 --- /dev/null +++ b/docs/extension.md @@ -0,0 +1,64 @@ +# PointUp Chrome extension + +`apps/extension` is a Manifest V3 Chrome extension that captures loyalty +balances from provider pages **you're already signed in to** and records them +in PointUp as manual snapshots — sync without ever sharing your program +credentials with PointUp. + +It's the second consumer of `@pointup/api-client` (after the web app), which is +exactly what the multi-surface architecture was built for — see +[multi-surface.md](./multi-surface.md). + +## How it works + +``` +provider page ──content script──▶ background worker ──@pointup/api-client──▶ PointUp API + (reads the visible (matches provider → account, + balance, no creds) records a manual balance) +``` + +1. A **content script** runs only on known provider domains (United, Delta, + American, Southwest, Marriott, Hyatt, Hilton). It reads the page's visible + text and extracts the balance. +2. The **background service worker** stores the latest capture and, when you + click **Record balance** in the popup, looks up your matching linked account + and posts a manual balance via the API. +3. The **popup** holds settings (API URL + session token) and shows the latest + capture. + +The extraction logic (`src/extraction.ts`) is **pure and unit-tested** — no DOM, +no `chrome` APIs — so provider patterns can be validated exhaustively. A test +also asserts the manifest's `content_scripts` matches stay in sync with the +provider rules, so adding a provider can't silently miss the manifest. + +## What it never does + +- It never reads passwords, cookies, or credential fields — only the balance + number the page already displays to the signed-in user. +- It has no provider automation; capture is a one-click, user-initiated action. + +## Auth + +The extension authenticates to the API with a **Clerk session token** as a +bearer header (the same path mobile uses — see [multi-surface.md](./multi-surface.md)). +Paste the token and your API URL into the popup's settings. A full +`@clerk/chrome-extension` sign-in flow is the natural follow-up; the client and +capture pipeline are already token-driven. + +## Build & load + +```bash +npm run build --workspace @pointup/extension +# Then in Chrome: chrome://extensions → Developer mode → Load unpacked → +# select apps/extension/dist +``` + +`npm run build` bundles `background`, `content`, and `popup` with esbuild and +copies `manifest.json` + `popup.html` into `dist/`. + +## Adding a provider + +Add a rule to `PROVIDER_PAGE_RULES` in `src/extraction.ts` (host + balance +regex) **and** the matching `https://*./*` entry to +`public/manifest.json` `content_scripts[0].matches`. The sync test will fail if +you forget the manifest. diff --git a/package-lock.json b/package-lock.json index d032acb..1b7329b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,20 @@ "vitest": "^3.0.0" } }, + "apps/extension": { + "name": "@pointup/extension", + "version": "1.0.0", + "dependencies": { + "@pointup/api-client": "*", + "@pointup/core": "*" + }, + "devDependencies": { + "@types/chrome": "^0.0.287", + "esbuild": "^0.25.0", + "typescript": "^5.9.0", + "vitest": "^3.0.0" + } + }, "apps/web": { "name": "@pointup/web", "version": "1.0.0", @@ -2291,6 +2305,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@pointup/extension": { + "resolved": "apps/extension", + "link": true + }, "node_modules/@pointup/web": { "resolved": "apps/web", "link": true @@ -2910,6 +2928,17 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/chrome": { + "version": "0.0.287", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2924,6 +2953,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",