From 04d5c53e202f8644b47c0f347d768a1ad40a337e Mon Sep 17 00:00:00 2001 From: shamAnimates <145093437+shamAnimates@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:49:03 +0530 Subject: [PATCH] fix(raycast): allow scoped keys on supported commands --- .github/workflows/ci.yml | 8 + apps/raycast-extension/package.json | 1 + apps/raycast-extension/src/api.ts | 6 - .../raycast-extension/src/search-projects.tsx | 4 +- .../raycast-extension/src/withSupermemory.tsx | 19 +- apps/raycast-extension/tests/api.test.ts | 183 ++++++++++++++++++ 6 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 apps/raycast-extension/tests/api.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80600ae5..f3161df7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,13 @@ jobs: - name: Run TypeScript type checking run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph' + - name: Install Raycast extension dependencies + working-directory: apps/raycast-extension + run: npm ci --ignore-scripts + + - name: Test Raycast extension + working-directory: apps/raycast-extension + run: npm test + - name: Run Biome CI (format & lint on changed files) run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched diff --git a/apps/raycast-extension/package.json b/apps/raycast-extension/package.json index ed821577..8a9f441b 100644 --- a/apps/raycast-extension/package.json +++ b/apps/raycast-extension/package.json @@ -64,6 +64,7 @@ "typescript": "^5.8.2" }, "scripts": { + "test": "bun test tests", "build": "ray build", "dev": "ray develop", "fix-lint": "ray lint --fix", diff --git a/apps/raycast-extension/src/api.ts b/apps/raycast-extension/src/api.ts index f36d82fb..87899bc3 100644 --- a/apps/raycast-extension/src/api.ts +++ b/apps/raycast-extension/src/api.ts @@ -214,9 +214,3 @@ export async function searchMemories( throw error } } - -// Helper function to check if API key is configured and valid -export async function fetchSettings(): Promise { - const response = await makeAuthenticatedRequest("/v3/settings") - return response -} diff --git a/apps/raycast-extension/src/search-projects.tsx b/apps/raycast-extension/src/search-projects.tsx index 02b423d5..e0d3fe5e 100644 --- a/apps/raycast-extension/src/search-projects.tsx +++ b/apps/raycast-extension/src/search-projects.tsx @@ -11,15 +11,15 @@ import { fetchProjects, addProject } from "./api" import { FormValidation, showFailureToast, - useCachedPromise, useForm, + usePromise, } from "@raycast/utils" import { withSupermemory } from "./withSupermemory" export default withSupermemory(Command) function Command() { - const { isLoading, data: projects, mutate } = useCachedPromise(fetchProjects) + const { isLoading, data: projects, mutate } = usePromise(fetchProjects) return ( diff --git a/apps/raycast-extension/src/withSupermemory.tsx b/apps/raycast-extension/src/withSupermemory.tsx index 9c789751..01043c3f 100644 --- a/apps/raycast-extension/src/withSupermemory.tsx +++ b/apps/raycast-extension/src/withSupermemory.tsx @@ -1,9 +1,7 @@ -import { usePromise } from "@raycast/utils" -import { fetchSettings } from "./api" import { Action, ActionPanel, - Detail, + getPreferenceValues, Icon, List, openExtensionPreferences, @@ -12,18 +10,11 @@ import type { ComponentType } from "react" export function withSupermemory

(Component: ComponentType

) { return function SupermemoryWrappedComponent(props: P) { - const { isLoading, data } = usePromise(fetchSettings, [], { - failureToastOptions: { - title: "Invalid API Key", - message: - "Invalid API key. Please check your API key in preferences. Get a new one from https://supermemory.link/raycast", - }, - }) + // Let each command enforce its own endpoint-specific permissions and rate limit. + const { apiKey } = getPreferenceValues() - if (!data) { - return isLoading ? ( - - ) : ( + if (!apiKey.trim()) { + return ( undefined) +const useCachedPromise = mock(() => ({ + data: [], + isLoading: false, + mutate, +})) +const usePromise = mock(() => ({ data: [], isLoading: false, mutate })) + +mock.module("@raycast/api", () => ({ + Action, + ActionPanel: RaycastComponent, + Detail: RaycastComponent, + Form, + getPreferenceValues: () => ({ apiKey }), + Icon: { + ExclamationMark: "exclamation-mark", + Gear: "gear", + }, + List, + openExtensionPreferences: () => undefined, + showToast: async () => undefined, + Toast: { + Style: { + Failure: "failure", + Success: "success", + }, + }, + useNavigation: () => ({ pop: () => undefined }), +})) + +mock.module("@raycast/utils", () => ({ + FormValidation: { Required: "required" }, + showFailureToast: async () => undefined, + useCachedPromise, + useForm: () => ({ handleSubmit: () => undefined, itemProps: {} }), + usePromise, +})) + +const apiModule = import("../src/api") +const wrapperModule = import("../src/withSupermemory") +const searchProjectsModule = import("../src/search-projects") + +function installScopedKeyContract({ searchStatus = 200 } = {}) { + const requests: Array<{ url: string; init?: RequestInit }> = [] + globalThis.fetch = (async (input, init) => { + const url = String(input) + requests.push({ url, init }) + + if (url.endsWith("/v3/settings")) { + return Response.json( + { message: "Endpoint not allowed for scoped API key" }, + { status: 403 }, + ) + } + + if (url.endsWith("/v3/search")) { + if (searchStatus !== 200) { + return Response.json({ message: "Invalid API key" }, { status: 401 }) + } + return Response.json({ results: [], timing: 0, total: 0 }) + } + + return Response.json({ message: "Unexpected endpoint" }, { status: 404 }) + }) as typeof fetch + return requests +} + +describe("Raycast scoped API keys", () => { + beforeEach(() => { + apiKey = "scoped-key" + useCachedPromise.mockClear() + usePromise.mockClear() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test.serial( + "renders a configured command without a remote preflight", + async () => { + const fetchMock = mock(() => { + throw new Error("The wrapper must not make an API request") + }) + globalThis.fetch = fetchMock as typeof fetch + const { withSupermemory } = await wrapperModule + function Command() { + return null + } + + const rendered = withSupermemory(Command)({}) + + expect(rendered.type).toBe(Command) + expect(fetchMock).not.toHaveBeenCalled() + expect(usePromise).not.toHaveBeenCalled() + }, + ) + + test.serial("keeps the preferences prompt for a missing key", async () => { + apiKey = " " + const { withSupermemory } = await wrapperModule + function Command() { + return null + } + + const rendered = withSupermemory(Command)({}) + + expect(rendered.type).toBe(List) + }) + + test.serial( + "does not reuse project data cached under another key", + async () => { + const { default: WrappedSearchProjects } = await searchProjectsModule + const wrappedResult = WrappedSearchProjects({}) + const SearchProjects = wrappedResult.type as () => unknown + + SearchProjects() + + expect(usePromise).toHaveBeenCalledTimes(1) + expect(useCachedPromise).not.toHaveBeenCalled() + }, + ) + + test.serial( + "searches with a scoped key without probing settings", + async () => { + const requests = installScopedKeyContract() + const { searchMemories } = await apiModule + + await expect(searchMemories({ q: "planning" })).resolves.toEqual([]) + expect(requests.map(({ url }) => new URL(url).pathname)).toEqual([ + "/v3/search", + ]) + expect(requests[0]?.init?.headers).toMatchObject({ + Authorization: "Bearer scoped-key", + }) + }, + ) + + test.serial( + "still rejects an invalid key during the requested operation", + async () => { + installScopedKeyContract({ searchStatus: 401 }) + const { searchMemories } = await apiModule + + await expect(searchMemories({ q: "planning" })).rejects.toThrow( + "Invalid API key", + ) + }, + ) +}) + +afterAll(() => { + mock.restore() +})