diff --git a/app/manifest.json b/app/manifest.json index 081f1be..38cbd82 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -4,14 +4,14 @@ "short_name": "Optia", "description": "Analyze page SEO and get AI-powered recommendations", "version": "0.1.0", - "minimum_chrome_version": "116", + "minimum_chrome_version": "137", "icons": { "16": "icons/icon-16.png", "32": "icons/icon-32.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" }, - "permissions": ["tabs", "sidePanel", "storage", "scripting"], + "permissions": ["tabs", "sidePanel", "storage", "scripting", "alarms"], "host_permissions": [""], "background": { "service_worker": "src/background/service-worker.ts", diff --git a/app/package.json b/app/package.json index 51dac05..44ddb05 100644 --- a/app/package.json +++ b/app/package.json @@ -22,6 +22,7 @@ "dependencies": { "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", + "jose": "^6.2.3", "lucide-react": "^0.511.0", "openai": "^4.97.0", "react": "^19.1.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 82ed86c..2a8b850 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + jose: + specifier: ^6.2.3 + version: 6.2.3 lucide-react: specifier: ^0.511.0 version: 0.511.0(react@19.2.4) @@ -502,89 +505,105 @@ packages: resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.1': resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.1': resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.1': resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.1': resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.1': resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.3.1': resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.1': resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.35.2': resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.35.2': resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.35.2': resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.35.2': resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.35.2': resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.35.2': resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.35.2': resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.35.2': resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.35.2': resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} @@ -694,66 +713,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1273,10 +1305,12 @@ packages: conventional-changelog-atom@2.0.8: resolution: {integrity: sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-codemirror@2.0.8: resolution: {integrity: sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-config-spec@2.1.0: resolution: {integrity: sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ==} @@ -1288,26 +1322,32 @@ packages: conventional-changelog-core@4.2.4: resolution: {integrity: sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg==} engines: {node: '>=10'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-ember@2.0.9: resolution: {integrity: sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-eslint@3.0.9: resolution: {integrity: sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-express@2.0.6: resolution: {integrity: sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jquery@3.0.11: resolution: {integrity: sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jshint@2.0.9: resolution: {integrity: sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-preset-loader@2.3.4: resolution: {integrity: sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==} @@ -1780,7 +1820,7 @@ packages: git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -1790,7 +1830,7 @@ packages: git-semver-tags@4.1.1: resolution: {integrity: sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true gitconfiglocal@1.0.0: @@ -2098,6 +2138,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5458,6 +5501,8 @@ snapshots: jiti@1.21.7: {} + jose@6.2.3: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} diff --git a/app/pnpm-workspace.yaml b/app/pnpm-workspace.yaml index 4a0c0ff..5a9b9ee 100644 --- a/app/pnpm-workspace.yaml +++ b/app/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +allowBuilds: + esbuild: true + sharp: true # Approve the dependencies allowed to run install/build scripts during # `pnpm install` (pnpm blocks dependency build scripts by default). This is # pnpm v10's documented mechanism — https://pnpm.io/settings#onlybuiltdependencies diff --git a/app/src/background/entitlement-alarm.test.ts b/app/src/background/entitlement-alarm.test.ts new file mode 100644 index 0000000..fbe4b65 --- /dev/null +++ b/app/src/background/entitlement-alarm.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { EntitlementClaims } from "@/lib/entitlement"; + +// Mock the entitlement lib entirely (constants included) so this suite drives +// the scheduling logic without touching jose or storage. +vi.mock("@/lib/entitlement", () => ({ + ENTITLEMENT_TOKEN_KEY: "entitlement_token", + LICENSE_KEY_KEY: "license_key", + refreshNow: vi.fn(), + getValidEntitlement: vi.fn(), + hasStoredLicenseKey: vi.fn(), + getRefreshFailureCount: vi.fn(), +})); + +import { + getRefreshFailureCount, + getValidEntitlement, + hasStoredLicenseKey, + refreshNow, +} from "@/lib/entitlement"; +import { registerEntitlementAlarms, ensureScheduled, ENTITLEMENT_ALARM } from "./entitlement-alarm"; + +const refreshNowMock = vi.mocked(refreshNow); +const getValidEntitlementMock = vi.mocked(getValidEntitlement); +const hasStoredLicenseKeyMock = vi.mocked(hasStoredLicenseKey); +const getRefreshFailureCountMock = vi.mocked(getRefreshFailureCount); + +const NOW = 1_800_000_000_000; // fixed ms epoch +const HOUR = 3_600_000; + +function claimsFor(lifetimeHours: number, elapsedHours = 0): EntitlementClaims { + const iat = Math.floor((NOW - elapsedHours * HOUR) / 1000); + return { + sub: "lic_1", + subjectType: "license", + tier: "pro", + quotaLimit: 100, + period: "2026-07", + iat, + exp: iat + lifetimeHours * 3600, + }; +} + +function alarmCreateMock() { + return vi.mocked(chrome.alarms.create); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + hasStoredLicenseKeyMock.mockResolvedValue(true); + getRefreshFailureCountMock.mockResolvedValue(0); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ensureScheduled", () => { + it("clears the alarm when no license key is stored", async () => { + hasStoredLicenseKeyMock.mockResolvedValue(false); + + await ensureScheduled(); + + expect(chrome.alarms.clear).toHaveBeenCalledWith(ENTITLEMENT_ALARM); + expect(chrome.alarms.create).not.toHaveBeenCalled(); + }); + + it("arms the alarm at 75% of the token lifetime for a fresh token", async () => { + getValidEntitlementMock.mockResolvedValue(claimsFor(24)); + + await ensureScheduled(); + + expect(refreshNowMock).not.toHaveBeenCalled(); + const [name, info] = alarmCreateMock().mock.calls[0]; + expect(name).toBe(ENTITLEMENT_ALARM); + expect(info?.when).toBe(NOW + 18 * HOUR); // 75% of 24h + }); + + it("never schedules later than 1h before expiry", async () => { + // 2h lifetime: 75% = +1.5h but exp−1h = +1h wins + getValidEntitlementMock.mockResolvedValue(claimsFor(2)); + + await ensureScheduled(); + + expect(alarmCreateMock().mock.calls[0][1]?.when).toBe(NOW + 1 * HOUR); + }); + + it("refreshes immediately when the token is past its refresh point", async () => { + const claims = claimsFor(24, 20); // 20h into a 24h token + getValidEntitlementMock.mockResolvedValue(claims); + refreshNowMock.mockResolvedValue(claimsFor(24)); + + await ensureScheduled(); + + expect(refreshNowMock).toHaveBeenCalled(); + expect(alarmCreateMock().mock.calls[0][1]?.when).toBe(NOW + 18 * HOUR); + }); + + it("refreshes immediately when there is a license key but no valid token", async () => { + getValidEntitlementMock.mockResolvedValue(null); + refreshNowMock.mockResolvedValue(claimsFor(24)); + + await ensureScheduled(); + + expect(refreshNowMock).toHaveBeenCalled(); + expect(chrome.alarms.create).toHaveBeenCalled(); + }); +}); + +describe("refresh failure handling", () => { + it("backs off exponentially while keeping the cached token", async () => { + getValidEntitlementMock.mockResolvedValue(null); + refreshNowMock.mockResolvedValue(claimsFor(24)); // cached fallback claims + getRefreshFailureCountMock.mockResolvedValue(3); + + await ensureScheduled(); + + // 5 · 2^(3−1) = 20 minutes + expect(alarmCreateMock().mock.calls[0][1]?.when).toBe(NOW + 20 * 60_000); + }); + + it("caps the backoff at 6 hours", async () => { + getValidEntitlementMock.mockResolvedValue(null); + refreshNowMock.mockResolvedValue(null); + getRefreshFailureCountMock.mockResolvedValue(12); + + await ensureScheduled(); + + expect(alarmCreateMock().mock.calls[0][1]?.when).toBe(NOW + 6 * HOUR); + }); + + it("retries no later than 5min before a still-valid token expires", async () => { + getValidEntitlementMock.mockResolvedValue(null); + refreshNowMock.mockResolvedValue(claimsFor(24, 23.9)); // ~6min left + getRefreshFailureCountMock.mockResolvedValue(4); // would be 40min + + await ensureScheduled(); + + const when = alarmCreateMock().mock.calls[0][1]?.when as number; + expect(when).toBe(NOW + 60_000); // clamped to the 1min floor + }); + + it("clears the alarm after a revoked license wiped local state", async () => { + getValidEntitlementMock.mockResolvedValue(null); + refreshNowMock.mockImplementation(async () => { + hasStoredLicenseKeyMock.mockResolvedValue(false); // refreshNow cleared state + return null; + }); + + await ensureScheduled(); + + expect(chrome.alarms.clear).toHaveBeenCalledWith(ENTITLEMENT_ALARM); + expect(chrome.alarms.create).not.toHaveBeenCalled(); + }); +}); + +describe("registerEntitlementAlarms", () => { + it("registers alarm, lifecycle, and storage listeners", () => { + registerEntitlementAlarms(); + + expect(chrome.alarms.onAlarm.addListener).toHaveBeenCalledTimes(1); + expect(chrome.runtime.onInstalled.addListener).toHaveBeenCalledTimes(1); + expect(chrome.runtime.onStartup.addListener).toHaveBeenCalledTimes(1); + expect(chrome.storage.onChanged.addListener).toHaveBeenCalledTimes(1); + }); + + it("refreshes and re-arms when the alarm fires", async () => { + registerEntitlementAlarms(); + refreshNowMock.mockResolvedValue(claimsFor(24)); + + const onAlarm = vi.mocked(chrome.alarms.onAlarm.addListener).mock.calls[0][0]; + onAlarm({ name: ENTITLEMENT_ALARM, scheduledTime: NOW, periodInMinutes: undefined }); + await vi.waitFor(() => expect(chrome.alarms.create).toHaveBeenCalled()); + + expect(alarmCreateMock().mock.calls[0][1]?.when).toBe(NOW + 18 * HOUR); + }); + + it("ignores other alarms", async () => { + registerEntitlementAlarms(); + const onAlarm = vi.mocked(chrome.alarms.onAlarm.addListener).mock.calls[0][0]; + + onAlarm({ name: "other-alarm", scheduledTime: NOW, periodInMinutes: undefined }); + await Promise.resolve(); + + expect(refreshNowMock).not.toHaveBeenCalled(); + }); + + it("re-arms via storage change when a token is written elsewhere", async () => { + registerEntitlementAlarms(); + getValidEntitlementMock.mockResolvedValue(claimsFor(24)); + + const onChanged = vi.mocked(chrome.storage.onChanged.addListener).mock.calls[0][0]; + onChanged({ entitlement_token: { newValue: "t" } }, "local"); + await vi.waitFor(() => expect(chrome.alarms.create).toHaveBeenCalled()); + + onChanged({ unrelated: { newValue: 1 } }, "local"); + onChanged({ entitlement_token: { newValue: "t" } }, "session"); + expect(getValidEntitlementMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/background/entitlement-alarm.ts b/app/src/background/entitlement-alarm.ts new file mode 100644 index 0000000..27ba3d9 --- /dev/null +++ b/app/src/background/entitlement-alarm.ts @@ -0,0 +1,98 @@ +import { + getRefreshFailureCount, + getValidEntitlement, + hasStoredLicenseKey, + refreshNow, + ENTITLEMENT_TOKEN_KEY, + LICENSE_KEY_KEY, + type EntitlementClaims, +} from "@/lib/entitlement"; + +// Background auto-refresh: keeps the cached entitlement fresh so the UI never +// has to refresh inline. Tokens live ≤24h; we refresh at 75% of the lifetime +// (never later than 1h before expiry) and back off exponentially on failure, +// keeping the last valid token until its exp actually passes. + +export const ENTITLEMENT_ALARM = "entitlement-refresh"; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const ASSUMED_LIFETIME_MS = 24 * HOUR_MS; // when a token carries no iat +const MAX_RETRY_MINUTES = 360; + +/** When to refresh: 75% through the token's lifetime, at latest exp − 1h. */ +function refreshTimeMs(claims: EntitlementClaims): number { + const expMs = claims.exp * 1000; + const issuedMs = claims.iat ? claims.iat * 1000 : expMs - ASSUMED_LIFETIME_MS; + return Math.min(expMs - HOUR_MS, issuedMs + 0.75 * (expMs - issuedMs)); +} + +function scheduleFromClaims(claims: EntitlementClaims): void { + const when = Math.max(Date.now() + MINUTE_MS, refreshTimeMs(claims)); + chrome.alarms.create(ENTITLEMENT_ALARM, { when }); +} + +/** + * Backoff after a soft failure (offline, 5xx, 429): 5min · 2^(n−1), capped at + * 6h — and never later than 5min before a still-valid token expires, so we get + * a last refresh attempt in before downgrading to free. + */ +function scheduleRetry(failures: number, claims: EntitlementClaims | null): void { + const delayMs = Math.min(5 * 2 ** Math.max(failures - 1, 0), MAX_RETRY_MINUTES) * MINUTE_MS; + let when = Date.now() + delayMs; + if (claims) { + when = Math.min(when, claims.exp * 1000 - 5 * MINUTE_MS); + } + chrome.alarms.create(ENTITLEMENT_ALARM, { when: Math.max(when, Date.now() + MINUTE_MS) }); +} + +async function refreshAndReschedule(): Promise { + const claims = await refreshNow(); + const failures = await getRefreshFailureCount(); + if (failures > 0) { + // Soft failure: claims (if any) are the cached, still-valid entitlement + if (await hasStoredLicenseKey()) { + scheduleRetry(failures, claims); + } else { + await chrome.alarms.clear(ENTITLEMENT_ALARM); + } + return; + } + if (claims) { + scheduleFromClaims(claims); + return; + } + // Revoked/unknown license (state was cleared) or no license at all + await chrome.alarms.clear(ENTITLEMENT_ALARM); +} + +/** Arms the alarm from current state; refreshes immediately when overdue. */ +export async function ensureScheduled(): Promise { + if (!(await hasStoredLicenseKey())) { + await chrome.alarms.clear(ENTITLEMENT_ALARM); + return; + } + const claims = await getValidEntitlement(); + if (!claims || Date.now() >= refreshTimeMs(claims)) { + await refreshAndReschedule(); + return; + } + scheduleFromClaims(claims); +} + +/** Registers all background listeners; call once at service-worker top level. */ +export function registerEntitlementAlarms(): void { + chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === ENTITLEMENT_ALARM) void refreshAndReschedule(); + }); + chrome.runtime.onInstalled.addListener(() => void ensureScheduled()); + chrome.runtime.onStartup.addListener(() => void ensureScheduled()); + // Activation/deactivation in the options page arms or clears the alarm here — + // no message passing needed, storage is the shared channel. + chrome.storage.onChanged.addListener((changes, area) => { + if (area !== "local") return; + if (changes[ENTITLEMENT_TOKEN_KEY] || changes[LICENSE_KEY_KEY]) { + void ensureScheduled(); + } + }); +} diff --git a/app/src/background/service-worker.test.ts b/app/src/background/service-worker.test.ts index 80b36c2..c658103 100644 --- a/app/src/background/service-worker.test.ts +++ b/app/src/background/service-worker.test.ts @@ -58,8 +58,16 @@ beforeEach(() => { }, ), }, + onInstalled: { addListener: vi.fn() }, + onStartup: { addListener: vi.fn() }, lastError: null, }, + alarms: { + create: vi.fn(), + clear: vi.fn().mockResolvedValue(true), + get: vi.fn().mockResolvedValue(undefined), + onAlarm: { addListener: vi.fn() }, + }, scripting: { executeScript: vi.fn(), }, @@ -74,6 +82,7 @@ beforeEach(() => { set: vi.fn().mockResolvedValue(undefined), remove: vi.fn().mockResolvedValue(undefined), }, + onChanged: { addListener: vi.fn() }, }, }; diff --git a/app/src/background/service-worker.ts b/app/src/background/service-worker.ts index c183b56..bfded25 100644 --- a/app/src/background/service-worker.ts +++ b/app/src/background/service-worker.ts @@ -1,7 +1,11 @@ import { extractPageDataInline } from "@/lib/extract-page-data-inline"; +import { registerEntitlementAlarms } from "@/background/entitlement-alarm"; console.log("[Optia] Service worker initializing..."); +// Entitlement auto-refresh (alarms + install/startup + storage sync) +registerEntitlementAlarms(); + // Disable automatic panel opening - we'll handle it manually for per-tab scoping chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }); diff --git a/app/src/components/EditableRecommendation.test.tsx b/app/src/components/EditableRecommendation.test.tsx index ade78bc..964929f 100644 --- a/app/src/components/EditableRecommendation.test.tsx +++ b/app/src/components/EditableRecommendation.test.tsx @@ -76,17 +76,17 @@ describe("EditableRecommendation", () => { expect(defaultProps.onToast).toHaveBeenCalledWith("Failed to regenerate"); }); - describe("when apiKeyMissing is true", () => { + describe("when aiDisabled is true", () => { it("disables the regenerate button", () => { - render(); - const btn = screen.getByTitle("Set up API key in options"); + render(); + const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options"); expect(btn).toBeDisabled(); }); it("shows a message about setting up the API key", () => { - render(); + render(); expect( - screen.getByText("Set up your OpenAI API key in options to use AI suggestions."), + screen.getByText("Add your OpenAI API key or activate Optia Pro in options to use AI suggestions."), ).toBeInTheDocument(); }); @@ -94,9 +94,9 @@ describe("EditableRecommendation", () => { const onRegenerate = vi.fn(); const user = userEvent.setup(); render( - , + , ); - const btn = screen.getByTitle("Set up API key in options"); + const btn = screen.getByTitle("Add an OpenAI API key or activate Optia Pro in options"); await user.click(btn); expect(onRegenerate).not.toHaveBeenCalled(); }); diff --git a/app/src/components/EditableRecommendation.tsx b/app/src/components/EditableRecommendation.tsx index af4dfb0..21f08ee 100644 --- a/app/src/components/EditableRecommendation.tsx +++ b/app/src/components/EditableRecommendation.tsx @@ -7,7 +7,7 @@ interface EditableRecommendationProps { initialValue: string; onRegenerate: () => Promise; onToast: (message: string) => void; - apiKeyMissing?: boolean; + aiDisabled?: boolean; className?: string; } @@ -16,7 +16,7 @@ export function EditableRecommendation({ initialValue, onRegenerate, onToast, - apiKeyMissing = false, + aiDisabled = false, className, }: EditableRecommendationProps) { const [text, setText] = useState(initialValue); @@ -87,9 +87,9 @@ export function EditableRecommendation({ - {apiKeyMissing && ( + {aiDisabled && (

- Set up your OpenAI API key in options to use AI suggestions. + Add your OpenAI API key or activate Optia Pro in options to use AI suggestions.

)} @@ -194,9 +194,9 @@ export function H2SelectionList({ + + ) : ( +
+

+ Enter your Optia Pro license key to unlock AI without an OpenAI key and advanced + analysis. +

+
+ + setLicenseKey(e.target.value)} + className="rounded-input border border-border bg-surface px-3.5 py-3 text-body text-ink shadow-card placeholder:text-faint outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/30" + /> +
+ {activationError && ( +

+ {activationError} +

+ )} + +
+ )} + + ); +} const languages = SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, @@ -95,6 +201,8 @@ export function Options() { {saved &&

Settings saved.

} + + ); diff --git a/app/src/options/main.tsx b/app/src/options/main.tsx index 6d740d8..f9a680b 100644 --- a/app/src/options/main.tsx +++ b/app/src/options/main.tsx @@ -2,11 +2,15 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { Options } from "./Options"; import { loadTheme } from "@/lib/theme"; +import { initEntitlementSync } from "@/lib/entitlement-store"; import "@/styles/globals.css"; // Apply persisted theme (light default) before first paint settles. void loadTheme(); +// Keep entitlement flags in sync with the sidepanel and background refresh. +initEntitlementSync(); + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/app/src/sidepanel/App.tsx b/app/src/sidepanel/App.tsx index e80e706..162e18f 100644 --- a/app/src/sidepanel/App.tsx +++ b/app/src/sidepanel/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useCallback } from "react"; import { useStore } from "@/lib/store"; +import { useEntitlementStore } from "@/lib/entitlement-store"; import { runSEOChecks } from "@/lib/seo-analyzer"; import { calculateAnalysis } from "@/lib/scoring"; import { fetchAndAnalyzePage } from "@/lib/fetch-page"; @@ -203,9 +204,12 @@ export default function App() { const { view, setView, setAnalysis, setError, settings, setSettings, loadApiKey, hideToast, toast, reset } = useStore(); + const hydrateEntitlement = useEntitlementStore((state) => state.hydrateEntitlement); + useEffect(() => { loadApiKey(); - }, [loadApiKey]); + void hydrateEntitlement(); + }, [loadApiKey, hydrateEntitlement]); // Register this tab with the service worker for per-tab panel scoping useEffect(() => { diff --git a/app/src/sidepanel/main.tsx b/app/src/sidepanel/main.tsx index 6de5c2e..9845653 100644 --- a/app/src/sidepanel/main.tsx +++ b/app/src/sidepanel/main.tsx @@ -2,11 +2,15 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import { loadTheme } from "@/lib/theme"; +import { initEntitlementSync } from "@/lib/entitlement-store"; import "@/styles/globals.css"; // Apply persisted theme (light default) before first paint settles. void loadTheme(); +// Keep entitlement flags in sync with the options page and background refresh. +initEntitlementSync(); + createRoot(document.getElementById("root")!).render( diff --git a/app/src/sidepanel/pages/SetupPage.test.tsx b/app/src/sidepanel/pages/SetupPage.test.tsx index 3faf43f..fbe74a5 100644 --- a/app/src/sidepanel/pages/SetupPage.test.tsx +++ b/app/src/sidepanel/pages/SetupPage.test.tsx @@ -1,5 +1,6 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { useStore } from "@/lib/store"; +import { useEntitlementStore } from "@/lib/entitlement-store"; import { SetupPage } from "./SetupPage"; vi.mock("@/lib/storage", () => ({ @@ -9,7 +10,26 @@ vi.mock("@/lib/storage", () => ({ setStorageItem: vi.fn().mockResolvedValue(undefined), })); +function setProEntitlement() { + useEntitlementStore.setState({ + isPro: true, + tier: "pro", + canUseAdvancedOptions: true, + aiQuotaRemaining: 100, + quotaLimit: 100, + }); +} + beforeEach(() => { + useEntitlementStore.setState({ + isPro: false, + tier: "free", + expiresAt: null, + quotaLimit: 0, + aiQuotaRemaining: 0, + canUseAdvancedOptions: false, + hasLicenseKey: false, + }); useStore.setState({ view: "setup", analysis: null, @@ -89,6 +109,7 @@ describe("SetupPage", () => { }); it("shows advanced fields when advancedMode is true", () => { + setProEntitlement(); useStore.setState({ settings: { keyword: "", @@ -107,6 +128,7 @@ describe("SetupPage", () => { }); it("shows page type select in advanced mode", () => { + setProEntitlement(); useStore.setState({ settings: { keyword: "", @@ -138,6 +160,7 @@ describe("SetupPage", () => { }); it("shows secondary keywords textarea in advanced mode", () => { + setProEntitlement(); useStore.setState({ settings: { keyword: "", @@ -155,6 +178,7 @@ describe("SetupPage", () => { }); it("shows character counter for secondary keywords", () => { + setProEntitlement(); useStore.setState({ settings: { keyword: "", @@ -173,4 +197,46 @@ describe("SetupPage", () => { render(); expect(screen.getByLabelText(/page url to analyze/i)).toBeInTheDocument(); }); + + it("disables the Advanced Analysis toggle with a Pro badge for free users", () => { + render(); + expect(screen.getByRole("checkbox")).toBeDisabled(); + expect(screen.getByText("Pro")).toBeInTheDocument(); + expect(screen.getByText(/activate an optia pro license/i)).toBeInTheDocument(); + }); + + it("enables the Advanced Analysis toggle for Pro users", () => { + setProEntitlement(); + render(); + expect(screen.getByRole("checkbox")).toBeEnabled(); + expect(screen.getByText(/optional/i)).toBeInTheDocument(); + }); + + it("forces advancedMode off when there is no Pro entitlement", async () => { + useStore.setState({ + settings: { + keyword: "", + secondaryKeywords: "", + pageType: "homepage", + language: "en", + advancedMode: true, + targetUrl: "", + }, + }); + render(); + await waitFor(() => { + expect(useStore.getState().settings.advancedMode).toBe(false); + }); + expect(screen.queryByLabelText(/page type/i)).not.toBeInTheDocument(); + }); + + it("shows the plan status row in the settings panel", async () => { + const { userEvent } = await import("@testing-library/user-event"); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /settings/i })); + expect(screen.getByText("Plan")).toBeInTheDocument(); + expect(screen.getByText("Free")).toBeInTheDocument(); + expect(screen.getByText(/manage your license/i)).toBeInTheDocument(); + }); }); diff --git a/app/src/sidepanel/pages/SetupPage.tsx b/app/src/sidepanel/pages/SetupPage.tsx index 2d149a3..8d7d915 100644 --- a/app/src/sidepanel/pages/SetupPage.tsx +++ b/app/src/sidepanel/pages/SetupPage.tsx @@ -7,6 +7,7 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle"; import { OptiaWordmark } from "@/components/ui/Logo"; import { Footer } from "@/components/Footer"; import { useStore } from "@/lib/store"; +import { useEntitlementStore } from "@/lib/entitlement-store"; import { SUPPORTED_LANGUAGES } from "@/lib/languages"; import { getKeywordForUrl, getAdvancedOptions } from "@/lib/storage"; import { Settings, X } from "lucide-react"; @@ -86,6 +87,9 @@ function useProgrammaticInputSync( export function SetupPage({ onAnalyze }: SetupPageProps) { const { settings, setSettings, apiKey, setApiKey, error } = useStore(); + const isPro = useEntitlementStore((state) => state.isPro); + const canUseAdvancedOptions = useEntitlementStore((state) => state.canUseAdvancedOptions); + const expiresAt = useEntitlementStore((state) => state.expiresAt); const [showSettings, setShowSettings] = useState(false); const [localApiKey, setLocalApiKey] = useState(apiKey); const [settingsSaved, setSettingsSaved] = useState(false); @@ -94,6 +98,13 @@ export function SetupPage({ onAnalyze }: SetupPageProps) { setLocalApiKey(apiKey); }, [apiKey]); + // Advanced Analysis is a Pro feature — never leave it enabled without entitlement + useEffect(() => { + if (!canUseAdvancedOptions && settings.advancedMode) { + setSettings({ advancedMode: false }); + } + }, [canUseAdvancedOptions, settings.advancedMode, setSettings]); + const handleSaveSettings = async () => { await setApiKey(localApiKey); setSettingsSaved(true); @@ -123,7 +134,7 @@ export function SetupPage({ onAnalyze }: SetupPageProps) { pageType: savedOptions.pageType, secondaryKeywords: savedOptions.secondaryKeywords, language: savedOptions.language, - advancedMode: true, + advancedMode: useEntitlementStore.getState().canUseAdvancedOptions, }); } } catch { @@ -200,6 +211,27 @@ export function SetupPage({ onAnalyze }: SetupPageProps) { {settingsSaved &&

Settings saved.

} + + {/* License status */} +
+
+ Plan + {isPro ? ( + + Pro + + ) : ( + + Free + + )} +
+ + {isPro && expiresAt + ? `renews by ${new Date(expiresAt).toLocaleDateString()}` + : "Manage your license in extension options"} + +
)} @@ -236,16 +268,25 @@ export function SetupPage({ onAnalyze }: SetupPageProps) {
Advanced Analysis - - optional - + {canUseAdvancedOptions ? ( + + optional + + ) : ( + + Pro + + )}

- Get smarter, page-specific recommendations based on your page context. + {canUseAdvancedOptions + ? "Get smarter, page-specific recommendations based on your page context." + : "Activate an Optia Pro license in extension options to unlock page-specific recommendations."}

setSettings({ advancedMode: checked })} />
diff --git a/app/src/sidepanel/pages/SubscoresPage.tsx b/app/src/sidepanel/pages/SubscoresPage.tsx index f25b311..8607cd1 100644 --- a/app/src/sidepanel/pages/SubscoresPage.tsx +++ b/app/src/sidepanel/pages/SubscoresPage.tsx @@ -8,6 +8,7 @@ import { SchemaDisplay } from "@/components/SchemaDisplay"; import { Toast } from "@/components/ui/Toast"; import { Footer } from "@/components/Footer"; import { useStore } from "@/lib/store"; +import { useCanUseAI, isMeteredAiCall, useEntitlementStore } from "@/lib/entitlement-store"; import { generateRecommendation, generateH2Suggestion, @@ -48,6 +49,7 @@ function sortByPriority(checks: SEOCheck[]): SEOCheck[] { export function SubscoresPage() { const { analysis, activeCategory, setActiveCategory, apiKey, settings, toast, showToast, hideToast } = useStore(); + const aiDisabled = !useCanUseAI(); // Scroll to top when entering this page useEffect(() => { @@ -66,6 +68,8 @@ export function SubscoresPage() { const failed = category.total - category.passed; const keyword = analysis.keyword; const sortedChecks = sortByPriority(category.checks); + // Generation still runs on the user's OpenAI key; the entitlement-gated AI + // proxy for keyless Pro users hooks in here when it lands. const noApiKey = !apiKey; const advancedOptions = settings.advancedMode @@ -78,6 +82,13 @@ export function SubscoresPage() { const rejectNoKey = () => Promise.reject(new Error("No API key configured")); + // Records metered (Pro-without-key) usage after a successful generation + const meterAi = async (generation: Promise): Promise => { + const result = await generation; + if (isMeteredAiCall()) void useEntitlementStore.getState().consumeAiQuota(); + return result; + }; + const renderCheckRecommendation = (check: SEOCheck) => { if (check.status === "pass") return null; @@ -90,21 +101,23 @@ export function SubscoresPage() { noApiKey ? rejectNoKey : (_index, h2Text) => - generateH2Suggestion(apiKey, h2Text, keyword, advancedOptions) + meterAi(generateH2Suggestion(apiKey, h2Text, keyword, advancedOptions)) } onRegenerateAll={ noApiKey ? () => rejectNoKey() as Promise : () => - generateAllH2Suggestions( - apiKey, - check.h2Recommendations!.map((h) => h.text), - keyword, - advancedOptions, + meterAi( + generateAllH2Suggestions( + apiKey, + check.h2Recommendations!.map((h) => h.text), + keyword, + advancedOptions, + ), ) } onToast={onToast} - apiKeyMissing={noApiKey} + aiDisabled={aiDisabled} />
); @@ -118,10 +131,10 @@ export function SubscoresPage() { onGenerate={ noApiKey ? rejectNoKey - : (src) => generateAltText(apiKey, src, keyword, advancedOptions) + : (src) => meterAi(generateAltText(apiKey, src, keyword, advancedOptions)) } onToast={onToast} - apiKeyMissing={noApiKey} + aiDisabled={aiDisabled} /> ); @@ -174,10 +187,12 @@ export function SubscoresPage() { noApiKey ? rejectNoKey : () => - generateRecommendation(apiKey, check.id, keyword, context, advancedOptions) + meterAi( + generateRecommendation(apiKey, check.id, keyword, context, advancedOptions), + ) } onToast={onToast} - apiKeyMissing={noApiKey} + aiDisabled={aiDisabled} /> ); diff --git a/app/src/test/entitlement-fixtures.ts b/app/src/test/entitlement-fixtures.ts new file mode 100644 index 0000000..5eba980 --- /dev/null +++ b/app/src/test/entitlement-fixtures.ts @@ -0,0 +1,114 @@ +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import type { EntitlementJwk } from "@/lib/entitlement-keys"; + +// Signed-token fixtures for entitlement tests: a fresh Ed25519 keypair per +// suite, so no secrets are ever checked in. + +export const TEST_KID = "test-kid"; + +export interface TestSigningKeys { + privateKey: CryptoKey; + jwk: EntitlementJwk; +} + +export async function createTestKeys(kid: string = TEST_KID): Promise { + const { publicKey, privateKey } = await generateKeyPair("EdDSA", { + crv: "Ed25519", + extractable: true, + }); + const exported = await exportJWK(publicKey); + const jwk: EntitlementJwk = { + kty: "OKP", + crv: "Ed25519", + x: exported.x as string, + kid, + alg: "EdDSA", + use: "sig", + }; + return { privateKey: privateKey as CryptoKey, jwk }; +} + +export interface TestClaimOverrides { + iss?: string; + aud?: string; + sub?: string; + subjectType?: string; + tier?: string; + quotaLimit?: number; + period?: string; + exp?: number; + iat?: number; +} + +export const TEST_PERIOD = "2026-07"; + +export async function signTestToken( + keys: TestSigningKeys, + overrides: TestClaimOverrides = {}, +): Promise { + const now = Math.floor(Date.now() / 1000); + const claims = { + iss: "optia-backend", + aud: "optia-extension", + sub: "lic_test_123", + subjectType: "license", + tier: "pro", + quotaLimit: 100, + period: TEST_PERIOD, + iat: now, + exp: now + 3600, + ...overrides, + }; + return new SignJWT(claims) + .setProtectedHeader({ alg: "EdDSA", kid: keys.jwk.kid }) + .sign(keys.privateKey); +} + +/** Corrupts the payload segment while keeping the JWS structurally valid. */ +export function tamperWithToken(token: string): string { + const [header, payload, signature] = token.split("."); + const flipped = payload[0] === "A" ? "B" : "A"; + return `${header}.${flipped}${payload.slice(1)}.${signature}`; +} + +function base64url(value: string): string { + return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** An unsigned token claiming alg "none" — must never verify. */ +export function unsignedToken(kid: string = TEST_KID): string { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: "none", kid })); + const payload = base64url( + JSON.stringify({ + iss: "optia-backend", + aud: "optia-extension", + sub: "lic_test_123", + subjectType: "license", + tier: "pro", + quotaLimit: 100, + period: TEST_PERIOD, + iat: now, + exp: now + 3600, + }), + ); + return `${header}.${payload}.`; +} + +/** An HS256-signed token reusing the trusted kid — alg confusion must fail. */ +export async function hs256Token(kid: string = TEST_KID): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ + iss: "optia-backend", + aud: "optia-extension", + sub: "lic_test_123", + subjectType: "license", + tier: "pro", + quotaLimit: 100, + period: TEST_PERIOD, + iat: now, + exp: now + 3600, + }) + .setProtectedHeader({ alg: "HS256", kid }) + .sign(new TextEncoder().encode("not-a-real-secret-not-a-real-secret")); +} diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index db78b48..fc7d97e 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -1,4 +1,13 @@ import "@testing-library/jest-dom/vitest"; +import { webcrypto } from "node:crypto"; + +// jsdom has no SubtleCrypto; jose needs WebCrypto (incl. Ed25519) for JWS verification +if (!globalThis.crypto?.subtle) { + Object.defineProperty(globalThis, "crypto", { + value: webcrypto, + configurable: true, + }); +} // Mock chrome.storage API for tests const storage: Record = {}; @@ -33,6 +42,7 @@ function createStorageMock(store: Record) { const chromeStorageMock = { local: createStorageMock(storage), session: createStorageMock(sessionStorage), + onChanged: { addListener: vi.fn(), removeListener: vi.fn() }, }; Object.defineProperty(globalThis, "chrome", { @@ -41,6 +51,14 @@ Object.defineProperty(globalThis, "chrome", { runtime: { sendMessage: vi.fn(), onMessage: { addListener: vi.fn() }, + onInstalled: { addListener: vi.fn() }, + onStartup: { addListener: vi.fn() }, + }, + alarms: { + create: vi.fn(), + clear: vi.fn().mockResolvedValue(true), + get: vi.fn().mockResolvedValue(undefined), + onAlarm: { addListener: vi.fn() }, }, }, writable: true, diff --git a/app/tsconfig.json b/app/tsconfig.json index 6677de2..a897394 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -16,7 +16,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true, - "types": ["vitest/globals", "chrome", "@testing-library/jest-dom"], + "types": ["vite/client", "vitest/globals", "chrome", "@testing-library/jest-dom"], "baseUrl": ".", "paths": { "@/*": ["src/*"]