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",