Skip to content

feat: ship interactive Runbook embed demo flow - #16

Merged
dfed25 merged 3 commits into
mainfrom
feature/embed-widget-mvp
Apr 26, 2026
Merged

dfed25 merged 3 commits into
mainfrom
feature/embed-widget-mvp

Conversation

@dfed25

@dfed25 dfed25 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Turn the embed demo into a functional product path with real chat retrieval, Studio-driven assistant configuration, and resilient LLM fallbacks so the end-to-end onboarding demo works reliably without required external integrations.

Made-with: Cursor

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced embeddable onboarding assistant widget—add to your site via script tag or React component with a single line of code.
    • Added Studio dashboard to configure assistant name, welcome message, color, and knowledge sources.
    • Integrated GitHub OAuth authentication for secure studio access.
    • Enabled GitHub repository indexing to automatically populate assistant knowledge.
    • Launched live preview of configured assistant within Studio.
  • Documentation

    • Updated README with embed setup instructions and configuration guide.

Turn the embed demo into a functional product path with real chat retrieval, Studio-driven assistant configuration, and resilient LLM fallbacks so the end-to-end onboarding demo works reliably without required external integrations.

Made-with: Cursor
@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
run-book Ready Ready Preview, Comment Apr 26, 2026 0:00am
runbook Ready Ready Preview, Comment Apr 26, 2026 0:00am

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@dfed25 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 53 minutes and 4 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 53 minutes and 4 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fb31d032-842f-48b5-87ee-30d1f7589d8c

📥 Commits

Reviewing files that changed from the base of the PR and between fc96997 and 82fe338.

📒 Files selected for processing (4)
  • public/runbook-embed.js
  • src/app/studio/page.tsx
  • src/lib/embedStore.ts
  • src/lib/retrieval.ts
📝 Walkthrough

Walkthrough

This pull request introduces a complete embedded AI onboarding assistant system with GitHub OAuth authentication, project management APIs, document indexing capabilities, and a configurable Studio interface. The implementation includes a JavaScript embed widget, chat API endpoints, session signing, rate limiting, and demo knowledge fallbacks.

Changes

Cohort / File(s) Summary
Configuration & Dependencies
.env.example, package.json
Adds GitHub OAuth client credentials, callback URL, embed session signing secret, and introduces @octokit/rest dependency for GitHub API access.
Public Embed Widget
public/runbook-embed.js
Creates a 311-line standalone embed script that injects a collapsible chat panel, reads configuration from localStorage, sends messages to the embed chat API, and displays responses with steps and sources.
Studio & Demo Pages
src/app/studio/page.tsx, src/app/embed-demo/page.tsx, src/app/layout.tsx, src/app/page.tsx, README.md
Rewrites landing page/header navigation and adds Studio configuration interface with editable assistant settings and manual source management. Embed demo page loads and previews bundle from localStorage. README rewritten from manager-focused runbook to embedded onboarding product description with setup and API documentation.
Embed Chat API
src/app/api/embed/chat/route.ts
Adds POST endpoint supporting CORS, rate-limited demo access, and authenticated project access. Validates input, retrieves indexed documents, calls Gemini LLM with system prompt, extracts structured steps from response, and returns { answer, sources, steps }.
GitHub OAuth Flow
src/app/api/embed/github/start/route.ts, src/app/api/embed/github/callback/route.ts, src/app/api/embed/github/repos/route.ts
Implements three-endpoint OAuth flow: initiates authorization with state/cookie storage, handles callback to exchange code for token and set embed session cookie, and lists authenticated user's repositories using Octokit.
Project Management APIs
src/app/api/embed/projects/route.ts, src/app/api/embed/projects/[projectId]/route.ts, src/app/api/embed/projects/[projectId]/index/route.ts
Adds CRUD endpoints for projects with session authentication. GET/POST projects, retrieve single project with ownership verification, and trigger GitHub repository indexing with validation.
Session Management
src/app/api/embed/me/route.ts
Exposes session verification endpoint returning authenticated user's GitHub identity or 401 response.
UI Component
src/components/EmbeddedRunbookAssistant.tsx
Creates reusable React component rendering a launch button and expandable chat panel. Manages conversation state, fetches responses from embed API, renders answers with formatted steps and source citations.
Core Libraries
src/lib/embedTypes.ts, src/lib/embedStore.ts
Defines TypeScript types for projects, API keys, and GitHub tokens. Implements file-based persistence layer for projects, hashed API key records, and GitHub tokens with queue-based serialization to prevent concurrent writes.
Session & Rate Limiting
src/lib/embedSession.ts, src/lib/embedRateLimit.ts
Implements HMAC-SHA256 embed session token signing/verification with 7-day expiration and optional RUNBOOK_EMBED_SESSION_SECRET environment variable. Provides in-memory per-key rate limiter (40 requests/60s window).
Document Retrieval & Knowledge
src/lib/embedRetrieval.ts, src/lib/embedKeywordRetrieval.ts, src/lib/embedDemoKnowledge.ts, src/lib/embedIndexer.ts, src/lib/embedNorthstarChat.ts
Implements vector and keyword-based document retrieval, deterministic demo knowledge responses, GitHub repository indexing with chunking and embeddings, and Northstar chat orchestration that merges indexed/custom sources with Gemini fallback.
Storage & Prompts
src/lib/studioDemoStorage.ts, src/lib/prompts.ts
Adds localStorage-based demo bundle persistence (assistant config, manual sources) with update event emission. Introduces EMBED_CHAT_SYSTEM_PROMPT for context-constrained LLM guidance.

Sequence Diagrams

sequenceDiagram
    participant User as User Browser
    participant Start as /api/embed/github/start
    participant Callback as /api/embed/github/callback
    participant GitHub as GitHub OAuth
    
    User->>Start: GET /api/embed/github/start
    Start->>Start: Generate state, set cookies
    Start->>User: Redirect to GitHub authorize
    User->>GitHub: User clicks authorize
    GitHub->>Callback: Redirect with code & state
    Callback->>Callback: Verify state, exchange code
    Callback->>GitHub: POST /token
    GitHub->>Callback: Access token
    Callback->>Callback: Fetch user, sign session
    Callback->>User: Set embed_session cookie<br/>Redirect to /studio
    User->>User: Authenticated
Loading
sequenceDiagram
    participant Widget as Embed Widget
    participant API as /api/embed/chat
    participant Retrieval as Document Retrieval
    participant LLM as Gemini API
    
    Widget->>Widget: User sends message
    Widget->>API: POST { projectId, message, pageContext }
    API->>API: Verify auth (if non-demo)
    API->>Retrieval: retrieveDocsForEmbed(question)
    Retrieval->>Retrieval: Generate embedding<br/>Vector search
    Retrieval->>API: Return top 6 documents
    API->>LLM: POST with system prompt<br/>+ indexed excerpts
    LLM->>API: Response with steps JSON
    API->>API: Extract steps, format answer
    API->>Widget: { answer, sources, steps }
    Widget->>Widget: Render response
Loading
sequenceDiagram
    participant User as User (Studio)
    participant Index as /api/embed/projects/[id]/index
    participant GitHub as GitHub API
    participant Indexer as indexGitHubRepo
    participant Embed as generateEmbedding
    participant DB as Supabase
    
    User->>Index: POST (trigger index)
    Index->>Index: Verify session & ownership
    Index->>GitHub: Fetch repo metadata<br/>Get file tree
    GitHub->>Indexer: Tree + file contents
    Indexer->>Indexer: Filter files, chunk content
    loop Per Chunk
        Indexer->>Embed: generateEmbedding(text)
        Embed->>Embed: Call Gemini Embedding API
        Indexer->>DB: Upsert document<br/>+ embedding vector
    end
    Indexer->>Index: { files, chunks, bytes }
    Index->>User: { ok: true, result }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • #6: Modifies src/lib/prompts.ts to add system prompt constants for AI chat flows in the same file being extended here.
  • #7: Adds environment variables to .env.example and introduces embed widget demo functionality, overlapping with OAuth and demo bundle infrastructure.

Poem

🐰 A widget hops through the web so bright,
OAuth dances left and right,
Chat panels bloom with studio care,
Demo knowledge floating there,
One line of code makes magic fair! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: ship interactive Runbook embed demo flow' clearly summarizes the main change: introducing a functional, interactive embed demo with real chat retrieval, Studio configuration, and LLM fallbacks. It directly reflects the primary objective of the PR without vagueness.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/embed-widget-mvp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/lib/embedStore.ts Fixed
Remove effect-driven initialization in Studio and clean up unused catch variables/constants so eslint passes consistently in CI.

Made-with: Cursor
Replace fast SHA-256 hashing for embed API keys with scrypt-based derivation to address CodeQL weak password hash findings.

Made-with: Cursor

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

🧹 Nitpick comments (15)
src/app/api/embed/me/route.ts (1)

6-16: Consider returning 200 (not 401) for the unauthenticated probe.

/api/embed/me is a "who am I?" probe rather than an authorization-required resource. Returning 401 will:

  • Show as a red error in DevTools network/console on every signed-out page load.
  • Trip generic fetch interceptors / monitoring tools that treat 401 as an auth failure to surface.

Most /me-style endpoints return 200 { authenticated: false } for the unauthenticated case and reserve 401 for endpoints that genuinely require auth.

♻️ Suggested change
   const session = verifyEmbedSession(req.cookies.get("embed_session")?.value);
   if (!session) {
-    return NextResponse.json({ authenticated: false }, { status: 401 });
+    return NextResponse.json({ authenticated: false });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/embed/me/route.ts` around lines 6 - 16, The GET handler currently
returns a 401 when verifyEmbedSession(req.cookies.get("embed_session")?.value)
yields no session; change this to return a 200 with { authenticated: false }
instead so /api/embed/me behaves as a probe rather than an auth-gated
resource—update the early return in the GET function that uses
NextResponse.json(...) to use a 200 status (or omit the status) while keeping
the same response body and leave the authenticated:true branch unchanged.
src/lib/studioDemoStorage.ts (1)

59-67: Silently swallowing quota errors hides save failures from Studio users.

If a user pastes a large manual source and localStorage rejects with QuotaExceededError, Studio will appear to "save" but the next reload will revert. Consider returning a boolean / throwing so the caller can surface a toast, or at least log to console.warn.

♻️ Minimal change
-export function saveDemoBundle(bundle: DemoBundle): void {
+export function saveDemoBundle(bundle: DemoBundle): boolean {
   if (typeof window === "undefined") return;
   try {
     localStorage.setItem(RUNBOOK_DEMO_BUNDLE_KEY, JSON.stringify(bundle));
     window.dispatchEvent(new Event("runbook-demo-update"));
+    return true;
   } catch {
-    /* quota */
+    console.warn("[runbook] Failed to persist demo bundle (quota?)");
+    return false;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/studioDemoStorage.ts` around lines 59 - 67, The current
saveDemoBundle function silently swallows storage quota errors; update
saveDemoBundle to surface failures by changing its signature to return a boolean
(true on success, false on failure) and in the catch block log the error with
console.warn including the caught error and the RUNBOOK_DEMO_BUNDLE_KEY (or
rethrow if caller expects exceptions). Keep the existing behavior of setting
localStorage and dispatching the "runbook-demo-update" event on success, but on
failure ensure you log the full error (e.g., mentioning QuotaExceededError) and
return false so callers can show a toast or handle the failure.
src/lib/embedKeywordRetrieval.ts (1)

23-31: Clarify scoring expression and avoid split for counting.

hay.split(t).length - 1 + 2 is the per-term contribution: match-count plus a flat +2 bonus per matched term. The intent isn't obvious at a glance, and split allocates an array of substrings just to count occurrences. A small refactor makes intent explicit and is cheaper:

♻️ Suggested rewrite
   const scored = docs.map((doc) => {
     const hay = `${doc.title}\n${doc.content}`.toLowerCase();
     let score = 0;
     for (const t of terms) {
-      if (hay.includes(t)) score += hay.split(t).length - 1 + 2;
+      let occurrences = 0;
+      let idx = hay.indexOf(t);
+      while (idx !== -1) {
+        occurrences += 1;
+        idx = hay.indexOf(t, idx + t.length);
+      }
+      if (occurrences > 0) score += occurrences + 2; // +2 per matched term
     }
     if (doc.title.toLowerCase().split(/\s+/).some((w) => terms.includes(w))) score += 4;
     return { doc, score };
   });

Also note: substring matching causes "go" to match inside "google" — fine for a demo, but worth a \b…\b regex if you ever want stricter matching.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedKeywordRetrieval.ts` around lines 23 - 31, The scoring code
inside the docs.map that builds scored is unclear and inefficient: replace the
expression using hay.split(t).length - 1 + 2 with an explicit occurrence count
and a separate per-term bonus so the intent (count occurrences + flat +2 bonus
per matched term) is obvious; inside the map over docs compute occurrences for
each term using a simple indexOf loop or a global RegExp to count matches (or
use word-boundary RegExp /\bterm\b/ if stricter matching is desired), add
occurrences to score and then add the +2 bonus only when occurrences > 0, and
keep the title-word exact match bonus logic (the
doc.title.toLowerCase().split...) unchanged.
src/lib/prompts.ts (1)

1-8: System prompt should document the RUNBOOK_STEPS_JSON contract for consistency.

The RUNBOOK_STEPS_JSON instruction is already provided in the user message template at src/app/api/embed/chat/route.ts (line 153), so the system prompt in src/lib/prompts.ts is not functionally broken. However, this system prompt should explicitly mention the contract to avoid confusion and ensure clarity about the expected output format. Consider adding:

- After your answer, on a new final line: RUNBOOK_STEPS_JSON: ["imperative step 1", "step 2", ...] (3-6 steps, or empty array [] if not applicable).

This aligns the system prompt documentation with the actual parsing logic and the contract enforced via the user message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/prompts.ts` around lines 1 - 8, Update the EMBED_CHAT_SYSTEM_PROMPT
constant to explicitly document the RUNBOOK_STEPS_JSON output contract: state
that after the answer the assistant must append a final line starting with
RUNBOOK_STEPS_JSON: followed by a JSON array of 3–6 imperative steps (or [] if
none). Modify the string in EMBED_CHAT_SYSTEM_PROMPT so it includes that exact
instruction text, ensuring consistency with the user message template and
parsing logic used in route handler (referencing EMBED_CHAT_SYSTEM_PROMPT and
RUNBOOK_STEPS_JSON to locate where to change).
src/app/api/embed/github/start/route.ts (1)

26-37: Set secure: true on OAuth helper cookies in production.

The callback route conditionally sets secure based on the request protocol; these helper cookies should follow the same pattern so the OAuth state and returnTo are not transmitted over plain HTTP in mixed environments.

♻️ Proposed fix
+  const secure = req.nextUrl.protocol === "https:";
   res.cookies.set("embed_oauth_state", state, {
     httpOnly: true,
     sameSite: "lax",
+    secure,
     path: "/",
     maxAge: 600
   });
   res.cookies.set("embed_oauth_return", returnTo, {
     httpOnly: true,
     sameSite: "lax",
+    secure,
     path: "/",
     maxAge: 600
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/embed/github/start/route.ts` around lines 26 - 37, The helper
cookies "embed_oauth_state" and "embed_oauth_return" are missing the secure
flag; update the two res.cookies.set calls that set those cookies so they
include secure: true when the request is secure (same condition used in the
callback route), e.g. mirror the protocol-check logic used elsewhere in this
file to compute a boolean (request.secure or protocol === "https") and pass
secure: isSecure to both res.cookies.set invocations so the cookies are only
sent over HTTPS in mixed/production environments.
src/lib/embedNorthstarChat.ts (1)

33-50: parseSteps is brittle to common LLM formatting drift.

Despite the system prompt forbidding code fences, Gemini occasionally emits ```json ... ``` or trailing prose after the JSON array, in which case JSON.parse throws and steps silently falls back. A small amount of normalization would make this a lot more reliable:

♻️ Proposed hardening
   if (idx >= 0) {
-    const jsonPart = answer.slice(idx + "RUNBOOK_STEPS_JSON:".length).trim();
+    let jsonPart = answer
+      .slice(idx + "RUNBOOK_STEPS_JSON:".length)
+      .trim()
+      .replace(/^```(?:json)?\s*/i, "")
+      .replace(/```$/i, "")
+      .trim();
+    // Keep only the first JSON array if the model added trailing prose.
+    const arrMatch = jsonPart.match(/\[[\s\S]*\]/);
+    if (arrMatch) jsonPart = arrMatch[0];
     answer = answer.slice(0, idx).trim();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedNorthstarChat.ts` around lines 33 - 50, The parseSteps function
is brittle to LLM formatting drift: modify parseSteps to normalize jsonPart
before JSON.parse by removing surrounding code fences and optional language tags
(e.g., leading ```json or ```), trimming trailing closing fences, and extracting
only the first JSON array using a regex like /\[[\s\S]*?\]/ so any trailing
prose is ignored; then parse that cleaned array and proceed with the existing
Array.isArray and string filtering logic, keeping the existing try/catch
behavior for safety.
src/app/api/embed/github/repos/route.ts (1)

19-27: Silent truncation at 500 repos; consider octokit.paginate.

The hard cap of 5 pages × 100 silently drops anything beyond 500 repositories with no signal to the caller. Users with many repos would never see older ones (mitigated only by sort: "updated"). octokit.paginate(octokit.repos.listForAuthenticatedUser, { per_page: 100, sort: "updated" }) is the idiomatic Octokit pattern and avoids the manual loop; if you want to keep a cap, return a truncated: true flag so the UI can warn.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/embed/github/repos/route.ts` around lines 19 - 27, The current
manual paging loop that collects into repos using
octokit.repos.listForAuthenticatedUser with a fixed 5-page cap silently
truncates results beyond 500 items; replace the loop with
octokit.paginate(octokit.repos.listForAuthenticatedUser, { per_page: 100, sort:
"updated" }) to fetch all pages reliably and map each item into the existing
repos array (preserving full_name and default_branch), or if you intentionally
want a cap keep the manual loop but add a truncated: true boolean in the
response when you hit the cap so callers can surface a warning.
src/lib/embedIndexer.ts (1)

168-219: Per-chunk sequential embedding will be the long pole.

Each chunk awaits generateEmbedding then awaits the Supabase upsert before the next chunk starts. With up to 80 files × multiple chunks each, latency dominates — this is the main reason the indexing call is at risk of timing out on the request thread (see the related comment on the route). If/when you move this to a background job, batching embeddings (Gemini supports batch embedContent) and Promise.all-bounded concurrency for upserts (e.g., 4–8 in flight) will cut runtime by an order of magnitude. Optional for now if reliability is addressed first.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedIndexer.ts` around lines 168 - 219, The loop currently calls
generateEmbedding and then performs the Supabase upsert sequentially per chunk
(see chunkContent, generateEmbedding, and
supabaseAdmin.from("runbook_documents").upsert), causing high latency; refactor
to collect chunks per file (or across files), call Gemini's batch embedding API
(e.g., embedContent) once for many contents to get embeddings in bulk, then
upsert rows concurrently with a bounded concurrency pool (Promise.all with a
limiter such as p-limit set to ~4–8) instead of awaiting each upsert serially;
ensure you preserve external_id/title/content construction (externalId, titled,
content) and error handling for failed embeddings/upserts and accumulate errors
as before.
README.md (1)

19-22: Optional copy polish.

Static analysis flagged the three consecutive “Open …” sentences in the demo flow. Trivial and safe to ignore, but a small variation (e.g., “Visit /studio…”, “Then go to /embed-demo…”) reads a bit better.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 19 - 22, Change the three consecutive "Open …" lines
to vary phrasing for better flow: update the lines referencing `/`, `/studio`,
and `/embed-demo` (and the Runbook example) to use mixed verbs such as "Visit
`/` — marketing landing.", "Go to `/studio` — knowledge sources (...)", and
"Then open `/embed-demo` — sample 'Northstar' docs page..." so the sequence
reads less repetitive while keeping the same destinations and descriptions.
src/app/api/embed/chat/route.ts (1)

187-196: Step parsing diverges from parseSteps in embedNorthstarChat.

embedNorthstarChat.parseSteps filters empty strings and trims each step; this route only filters by typeof x === "string", so blank/whitespace entries from the model can leak through and render as empty <li>s. Align the two paths so the demo and keyed routes produce the same shape.

♻️ Proposed alignment
-        if (Array.isArray(parsed)) {
-          steps = parsed.filter((x): x is string => typeof x === "string");
-        }
+        if (Array.isArray(parsed)) {
+          steps = parsed
+            .filter((x): x is string => typeof x === "string" && x.trim().length > 0)
+            .map((s) => s.trim());
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/embed/chat/route.ts` around lines 187 - 196, The route's
step-parsing currently only checks typeof on parsed values, allowing
blank/whitespace strings to pass; update the parsing in route.ts to mirror
embedNorthstarChat.parseSteps by trimming each string and filtering out empty
results (e.g., call .map(s => s.trim()) and then filter(Boolean) or equivalent)
so that variable steps contains only non-empty trimmed strings and matches the
demo/keyed behavior.
public/runbook-embed.js (1)

230-243: Rename the body payload variable to avoid shadowing the message-list element.

The outer body (line 144) is the chat container DOM node used by addBot/addUser. Declaring var body = { … } here is function-scoped to doSend, so behavior is correct, but the name collision is a footgun if anyone later moves logic between scopes or refactors to ESM/let. A trivial rename removes the trap.

♻️ Proposed rename
-        var body = {
+        var payload = {
           projectId: projectId,
           message: q,
           pageContext: pageContext()
         };
         if (projectId === DEMO_ID) {
           var custom = manualSourcesFromBundle(bundle);
-          if (custom.length) body.customSources = custom;
+          if (custom.length) payload.customSources = custom;
         }
         var res = await fetch(base + "/api/embed/chat", {
           method: "POST",
           headers: headers,
-          body: JSON.stringify(body)
+          body: JSON.stringify(payload)
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@public/runbook-embed.js` around lines 230 - 243, The request payload object
named "body" inside doSend shadows the outer DOM variable used as the chat
container (message-list) and risks future refactor bugs; rename this local
payload (e.g., to "payload" or "reqBody") wherever it's created and used —
update the var body = { ... } to var payload = { projectId: projectId, message:
q, pageContext: pageContext() }, change any conditional assignment
body.customSources to payload.customSources, and change JSON.stringify(body) to
JSON.stringify(payload) in the fetch to base + "/api/embed/chat"; keep other
logic (manualSourcesFromBundle, addBot/addUser, headers) unchanged.
src/lib/embedStore.ts (2)

26-29: writeJson is non-atomic — a crash mid-write can leave a corrupt JSON file.

fs.writeFile truncates and streams; an interrupted process can leave embed-projects.json / embed-api-keys.json half-written, which then breaks every subsequent readJson. Write to a temp file in the same directory and rename to swap atomically.

♻️ Proposed fix
 async function writeJson<T>(file: string, data: T): Promise<void> {
   await ensureDir();
-  await fs.writeFile(file, JSON.stringify(data, null, 2), "utf-8");
+  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
+  await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf-8");
+  await fs.rename(tmp, file);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedStore.ts` around lines 26 - 29, The writeJson function is
non-atomic and can leave corrupt JSON if interrupted; modify writeJson<T>(file:
string, data: T) to first ensureDir(), then write the JSON to a temporary file
in the same directory (e.g., using path.dirname(file) + a unique temp suffix or
pid/random), fsync/close the temp file if you use low-level handles, and finally
fs.rename(tempFile, file) to atomically replace the target (this keeps existing
permissions and ensures embed-projects.json / embed-api-keys.json are never
half-written); keep ensureDir() as-is and reference writeJson when implementing
the temp->rename flow.

6-15: File-backed persistence won’t survive a serverless deployment.

Writing JSON under process.cwd()/.runbook-data works locally but breaks the moment this ships to Vercel/Lambda/Cloud Run: the working dir is read-only (only /tmp is writable, and only per-instance/ephemeral), and runMutation is process-local — two warm Lambdas writing concurrently will race and clobber each other regardless of the queue. For the demo this is acceptable; before any production embed onboarding, plan to back this with a real store (Supabase/Postgres, Upstash, KV, etc.). Calling out so this doesn’t silently regress when promoted from the demo path.

Also applies to: 39-51

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedStore.ts` around lines 6 - 15, The current file-backed
persistence using DATA_DIR = path.join(process.cwd(), ".runbook-data") will fail
in serverless/read-only environments and race across instances; change DATA_DIR
to be configurable via an environment variable (e.g.,
process.env.EMBED_DATA_DIR) and fall back to the system temp directory
(require('os').tmpdir()) so the code using PROJECTS_FILE / KEYS_FILE /
TOKENS_FILE and functions like ensureDir and the mutationQueue still work
locally for the demo but won’t try to write into a read-only CWD; also add a
TODO comment in this module to replace the file-backed store with a real
external store (Supabase/Postgres/Upstash/KV) before production deployment.
src/components/EmbeddedRunbookAssistant.tsx (2)

82-100: dangerouslySetInnerHTML is fine here, but lock down escapeHtml for future-proofing.

ast-grep flags line 181, but every dynamic value flowing in is wrapped with escapeHtml, and all of them land inside element-text positions (no attribute interpolation), so this is currently safe. That said, escapeHtml doesn’t escape ' — if anyone later puts an escaped value inside a single-quoted attribute, it becomes XSS. Add ' (and `) for defense in depth.

🛡️ Proposed fix
 function escapeHtml(s: string): string {
   return s
     .replace(/&/g, "&amp;")
     .replace(/</g, "&lt;")
     .replace(/>/g, "&gt;")
-    .replace(/"/g, "&quot;");
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#39;")
+    .replace(/`/g, "&#96;");
 }

Also applies to: 217-223

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/EmbeddedRunbookAssistant.tsx` around lines 82 - 100, The
injected HTML in EmbeddedRunbookAssistant.tsx currently relies on escapeHtml but
that function does not escape single-quote (') or backtick (`), which could
allow XSS if values are later used inside single-quoted attributes; update the
escapeHtml implementation (the shared escapeHtml helper used by the
HTML-building block that appends to setMessages in EmbeddedRunbookAssistant.tsx)
to also replace ' with &#39; and ` with &#96; (in addition to &, <, >, " ) so
all dynamic values inserted into the template strings are fully escaped; ensure
the same updated escapeHtml is used for the other usages noted (around the other
HTML-building block mentioned in the comment).

67-76: pageContext differs from public/runbook-embed.js.

The shipped widget includes <meta name="description"> content in pageContext; this in-app preview only sends URL + document.title. If you’re using this component to validate Studio-configured behavior, the model will see less context here than in the production embed and produce divergent answers between preview and live.

♻️ Proposed fix
-          pageContext: typeof window !== "undefined" ? `${window.location.href}\n${document.title}` : "",
+          pageContext:
+            typeof window !== "undefined"
+              ? [
+                  window.location.href,
+                  document.title,
+                  document
+                    .querySelector('meta[name="description"]')
+                    ?.getAttribute("content") || ""
+                ]
+                  .filter(Boolean)
+                  .join("\n")
+              : "",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/EmbeddedRunbookAssistant.tsx` around lines 67 - 76, The
pageContext sent from EmbeddedRunbookAssistant.tsx to `${base}/api/embed/chat`
currently includes only URL and document.title, which differs from the
production embed and causes preview/live divergence; update the pageContext
construction in the fetch body to also collect and include the page meta
description (the content of <meta name="description">) — locate the pageContext
creation near the fetch call in EmbeddedRunbookAssistant.tsx and concatenate the
meta description (if present) along with window.location.href and document.title
before JSON.stringify so the API receives the same context as
public/runbook-embed.js.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Line 30: Add a trailing newline at the end of the .env.example file so the
final line "RUNBOOK_EMBED_SESSION_SECRET=" is terminated with a newline
character; this resolves the dotenv-linter EndingBlankLine warning for the
RUNBOOK_EMBED_SESSION_SECRET entry.

In `@public/runbook-embed.js`:
- Around line 38-40: Replace the unused catch binding "catch (e) { return null;
}" with an optional catch binding "catch { return null; }" in the two locations
where that pattern appears (the catch blocks currently declaring variable "e");
this removes the unused-variable warnings from `@typescript-eslint/no-unused-vars`
while preserving behavior—ensure both occurrences are updated to use "catch {
... }".

In `@src/app/api/embed/chat/route.ts`:
- Around line 26-29: The originAllowed function uses naive substring checks that
are exploitable; update originAllowed to parse both inputs as URLs (or extract
canonical hostnames), normalize to lowercase and strip ports, then compare
either exact hostname equality or perform a suffix match anchored on dot
boundaries (e.g., ensure projectSiteHost === originHost or originHost
endsWith('.' + projectSiteHost)) instead of using String.includes; this ensures
attacks like "evil-example.com.attacker.com" do not pass.
- Around line 94-106: The current demo uses a single global bucket via
checkEmbedRateLimit("northstar-demo-public"), allowing one noisy client to
exhaust the quota; change the call to scope rate limits per remote address by
extracting the caller IP (use the first hop of the x-forwarded-for header or
request.ip as available) and pass a key like
`northstar-demo-public:${remoteAddr}` to checkEmbedRateLimit; ensure you
fallback to the original static key if no remote address is found and keep the
rest of the flow (sanitizeCustomSources, runNorthstarEmbedChat,
NextResponse.json, corsHeaders) unchanged.

In `@src/app/api/embed/github/callback/route.ts`:
- Line 74: The redirect logic using returnTo allows protocol-relative URLs like
//attacker.com because returnTo.startsWith("/") returns true; update the
callback handler that computes dest (the returnTo variable and new URL(...)
usage in route.ts) to first reject inputs with backslashes or leading
double-slashes (e.g., /^\\|^\/\/+/), then parse returnTo with new URL(returnTo,
req.nextUrl.origin) and verify the resulting URL.origin === req.nextUrl.origin
before honoring it; if any check fails, fall back to the safe default (new
URL("/studio", req.nextUrl.origin)).
- Around line 55-61: The GitHub API calls are missing a required User-Agent
header and the JSON parsing of tokenRes and userRes can throw on non-JSON
responses; update the fetch options used when exchanging the token (tokenRes)
and when calling the user endpoint (userRes) to include a User-Agent header
(e.g., "User-Agent": "your-app-name"), and guard the JSON parsing for tokenJson
and user by checking response.ok and wrapping await tokenRes.json() and await
userRes.json() in try/catch (or conditional parsing) so failures fall back to
the existing safe redirect path instead of throwing; locate these changes around
tokenRes, tokenJson, userRes, and user in the route handler and ensure the error
paths use the same redirect logic already implemented.

In `@src/app/api/embed/github/start/route.ts`:
- Line 17: Validate the returnTo value obtained from
req.nextUrl.searchParams.get("return") before using it: in the route handler
where returnTo is assigned, reject and fallback to the default "/studio" if the
value starts with "//", contains "\" (backslash), or parses as an absolute URL
(has a scheme/host); i.e., perform a defensive check on returnTo and only allow
a safe relative path, otherwise set returnTo = "/studio" so the malicious value
never reaches the cookie or callback.

In `@src/app/api/embed/projects/`[projectId]/index/route.ts:
- Around line 38-48: The current POST handler calls indexGitHubRepo
synchronously, which can block the request thread and cause timeouts and
concurrent runs; change the route handler to enqueue a background job (or call
an async worker/queue) instead of awaiting indexGitHubRepo directly, return a
202 Accepted immediately from the route, and implement a per-project running
guard that checks and sets project.lastIndexStatus === "running" (the same
status checked/updated in embedIndexer.ts around where lastIndexStatus is set)
before enqueuing, rejecting new starts if it is already running; ensure the
worker pulls the job and invokes indexGitHubRepo(project, gh.accessToken) and
updates lastIndexStatus on completion/failure so deletes/upserts and embeddings
happen off-request with proper locking.

In `@src/app/api/embed/projects/route.ts`:
- Around line 26-40: The code currently accepts siteUrl raw and persists it via
createProject (see siteUrl variable and createProject call in route.ts), which
allows unsafe schemes (e.g., javascript:, data:) leading to XSS when rendered as
links; validate siteUrl after trimming by parsing or checking its scheme and
only allow empty or http/https (e.g., new URL(siteUrl).protocol === 'http:' ||
'https:') and return a 400 JSON error if the scheme is disallowed, then pass
siteUrl || undefined to createProject as before.

In `@src/app/embed-demo/page.tsx`:
- Line 8: The state initializer currently calls loadDemoBundle() during SSR and
client render causing hydration mismatch; change the useState call to initialize
with DEFAULT_DEMO_BUNDLE (not loadDemoBundle), then in a useEffect invoke
loadDemoBundle() and call setBundle(...) with its result so the real stored
bundle is loaded only on the client; update the code around
useState<DemoBundle>(() => loadDemoBundle()) and add a useEffect that runs once
to replace the bundle for EmbeddedRunbookAssistant props.

In `@src/app/studio/page.tsx`:
- Around line 30-39: The effect in useEffect currently calls multiple setState
functions (setOrigin, setAssistantName, setWelcome, setPrimaryColor,
setSuggestedRaw, setManualSources, setHydrated) which triggers the eslint
react-hooks/set-state-in-effect error; fix by batching these values into a
single state update (e.g., create a boot state like boot and setBoot({ origin,
assistantName, welcome, primaryColor, suggestedRaw, manualSources, hydrated:
true }) and update component usages to read from boot or provide per-field
setters that spread into setBoot), or if you prefer the minimal change add a
localized eslint-disable-next-line comment above the useEffect with a brief
rationale mentioning window/localStorage hydration is post-mount and
intentional; update references to loadDemoBundle(), setSuggestedRaw logic, and
usages of origin/assistantName/etc. accordingly so only one setter is called
inside the effect.

In `@src/lib/embedDemoKnowledge.ts`:
- Around line 16-18: The function excerptFromContent currently normalizes
whitespace with content.replace(...).trim() for slicing but compares the
original content.length to max, causing incorrect ellipses; change
excerptFromContent to first compute a single normalized string (e.g., let
normalized = content.replace(/\s+/g, " ").trim()), then use normalized.slice(0,
max) and check normalized.length > max to decide whether to append "…", ensuring
both slicing and the ellipsis decision use the same normalized text.

In `@src/lib/embedIndexer.ts`:
- Around line 76-84: The current deleteExistingEmbedDocs function deletes all
existing project chunks before writing new ones; change the flow so you
upsert/write new chunks first (use
supabaseAdmin.from("runbook_documents").upsert(...) or insert with on_conflict)
and only afterwards delete stale rows for the project by deleting rows whose
external_id starts with the project prefix but is NOT in the set of external_ids
you just wrote; implement this in deleteExistingEmbedDocs (or a new cleanup
function) by accepting the array of newly-written external_ids, skipping
deletion entirely if that array is empty (to avoid wiping on partial failures),
and using a NOT IN filter (e.g., .not("external_id", "in", "(id1,id2,...)") or
equivalent) to remove only stale rows; apply same change to the other similar
delete usage mentioned.
- Around line 200-208: The fallback insert after a failed upsert in
embedIndexer.ts is incorrect: if
supabaseAdmin.from("runbook_documents").upsert(row, { onConflict:
"provider,external_id" }) returns an error it won’t be fixed by re-running
insert (unique conflict or other issues persist); change the logic in the block
handling the upsert result so that on error you record the error (push to errors
with insErr.message or error.message) and do not attempt the duplicate insert,
or implement a proper retry/backoff for transient failures; update the uses of
variables row, stored, errors and the upsert/insert call sites so stored is only
set true on successful upsert/insert and ensure the logged error comes from the
original upsert error when present.

In `@src/lib/embedNorthstarChat.ts`:
- Around line 57-62: The code maps all entries in input.customSources into
extraDocs without limiting the number of items, which lets a caller submit
arbitrarily many docs and exhaust CPU/LLM tokens; before mapping, cap the array
(e.g. const capped = input.customSources.slice(0, 32)) and map capped instead of
input.customSources so extraDocs has a fixed upper bound; update any downstream
references (retrieveKeywordSources and prompt-building that consume extraDocs)
to use the capped list and document the chosen max items constant near the
extraDocs creation.

In `@src/lib/embedRateLimit.ts`:
- Around line 3-19: The in-memory Map-based limiter (buckets, WINDOW_MS,
MAX_PER_WINDOW, and function checkEmbedRateLimit) causes per-instance limits and
unbounded growth; replace the module-scoped Map with a shared store (e.g.,
Upstash/Redis) using INCR + EXPIRE per keyId to enforce a global MAX_PER_WINDOW
across instances and correct TTL-based resets, and if you must keep a local
fallback keep a bounded Map size (e.g., MAX_BUCKETS constant) and implement
opportunistic eviction in checkEmbedRateLimit to prune expired entries and
remove oldest/newest entries when the cap is exceeded to avoid memory leaks.

In `@src/lib/embedRetrieval.ts`:
- Around line 35-50: The current call to supabaseAdmin.rpc("match_documents",
...) requests TOP_K * 8 candidates and then filters client-side via
matchesEmbedScope(d.content, projectId), which can drop all results if
other-project docs dominate; fix by either (preferred) extending the
match_documents RPC to accept a project_id (or embed scope) and apply the filter
in SQL and then call it from this code (update the rpc parameters where
match_documents is invoked), or (stopgap) implement paging: repeatedly call
supabaseAdmin.rpc("match_documents", { query_embedding:
`[${embedding.join(",")}]`, match_threshold: DEFAULT_MATCH_THRESHOLD,
match_count: pageSize, page_offset: offset }) aggregating unique documents,
apply matchesEmbedScope(projectId) to the accumulated set, and stop when
scoped.length >= TOP_K or a safe max iterations is reached, then sort by
similarity and return the top TOP_K; ensure you update uses of TOP_K,
match_count, match_documents, matchesEmbedScope, embedding, and projectId
accordingly.

In `@src/lib/embedSession.ts`:
- Around line 5-11: The sessionSecret() function must not silently fall back to
a public hardcoded secret or reuse SUPABASE_SERVICE_ROLE_KEY in production;
change it so it returns RUNBOOK_EMBED_SESSION_SECRET if present, and otherwise
throws (fail-closed) when not running in development (e.g., NODE_ENV !==
'development' or VERCEL_ENV !== 'development'); allow an explicit, documented
fallback only for local/dev runs (optionally use SUPABASE_SERVICE_ROLE_KEY or
the dev literal only when NODE_ENV === 'development') and add a clear error
message referencing sessionSecret() so the app fails to start if the dedicated
env is missing in non-dev environments.

---

Nitpick comments:
In `@public/runbook-embed.js`:
- Around line 230-243: The request payload object named "body" inside doSend
shadows the outer DOM variable used as the chat container (message-list) and
risks future refactor bugs; rename this local payload (e.g., to "payload" or
"reqBody") wherever it's created and used — update the var body = { ... } to var
payload = { projectId: projectId, message: q, pageContext: pageContext() },
change any conditional assignment body.customSources to payload.customSources,
and change JSON.stringify(body) to JSON.stringify(payload) in the fetch to base
+ "/api/embed/chat"; keep other logic (manualSourcesFromBundle, addBot/addUser,
headers) unchanged.

In `@README.md`:
- Around line 19-22: Change the three consecutive "Open …" lines to vary
phrasing for better flow: update the lines referencing `/`, `/studio`, and
`/embed-demo` (and the Runbook example) to use mixed verbs such as "Visit `/` —
marketing landing.", "Go to `/studio` — knowledge sources (...)", and "Then open
`/embed-demo` — sample 'Northstar' docs page..." so the sequence reads less
repetitive while keeping the same destinations and descriptions.

In `@src/app/api/embed/chat/route.ts`:
- Around line 187-196: The route's step-parsing currently only checks typeof on
parsed values, allowing blank/whitespace strings to pass; update the parsing in
route.ts to mirror embedNorthstarChat.parseSteps by trimming each string and
filtering out empty results (e.g., call .map(s => s.trim()) and then
filter(Boolean) or equivalent) so that variable steps contains only non-empty
trimmed strings and matches the demo/keyed behavior.

In `@src/app/api/embed/github/repos/route.ts`:
- Around line 19-27: The current manual paging loop that collects into repos
using octokit.repos.listForAuthenticatedUser with a fixed 5-page cap silently
truncates results beyond 500 items; replace the loop with
octokit.paginate(octokit.repos.listForAuthenticatedUser, { per_page: 100, sort:
"updated" }) to fetch all pages reliably and map each item into the existing
repos array (preserving full_name and default_branch), or if you intentionally
want a cap keep the manual loop but add a truncated: true boolean in the
response when you hit the cap so callers can surface a warning.

In `@src/app/api/embed/github/start/route.ts`:
- Around line 26-37: The helper cookies "embed_oauth_state" and
"embed_oauth_return" are missing the secure flag; update the two res.cookies.set
calls that set those cookies so they include secure: true when the request is
secure (same condition used in the callback route), e.g. mirror the
protocol-check logic used elsewhere in this file to compute a boolean
(request.secure or protocol === "https") and pass secure: isSecure to both
res.cookies.set invocations so the cookies are only sent over HTTPS in
mixed/production environments.

In `@src/app/api/embed/me/route.ts`:
- Around line 6-16: The GET handler currently returns a 401 when
verifyEmbedSession(req.cookies.get("embed_session")?.value) yields no session;
change this to return a 200 with { authenticated: false } instead so
/api/embed/me behaves as a probe rather than an auth-gated resource—update the
early return in the GET function that uses NextResponse.json(...) to use a 200
status (or omit the status) while keeping the same response body and leave the
authenticated:true branch unchanged.

In `@src/components/EmbeddedRunbookAssistant.tsx`:
- Around line 82-100: The injected HTML in EmbeddedRunbookAssistant.tsx
currently relies on escapeHtml but that function does not escape single-quote
(') or backtick (`), which could allow XSS if values are later used inside
single-quoted attributes; update the escapeHtml implementation (the shared
escapeHtml helper used by the HTML-building block that appends to setMessages in
EmbeddedRunbookAssistant.tsx) to also replace ' with &#39; and ` with &#96; (in
addition to &, <, >, " ) so all dynamic values inserted into the template
strings are fully escaped; ensure the same updated escapeHtml is used for the
other usages noted (around the other HTML-building block mentioned in the
comment).
- Around line 67-76: The pageContext sent from EmbeddedRunbookAssistant.tsx to
`${base}/api/embed/chat` currently includes only URL and document.title, which
differs from the production embed and causes preview/live divergence; update the
pageContext construction in the fetch body to also collect and include the page
meta description (the content of <meta name="description">) — locate the
pageContext creation near the fetch call in EmbeddedRunbookAssistant.tsx and
concatenate the meta description (if present) along with window.location.href
and document.title before JSON.stringify so the API receives the same context as
public/runbook-embed.js.

In `@src/lib/embedIndexer.ts`:
- Around line 168-219: The loop currently calls generateEmbedding and then
performs the Supabase upsert sequentially per chunk (see chunkContent,
generateEmbedding, and supabaseAdmin.from("runbook_documents").upsert), causing
high latency; refactor to collect chunks per file (or across files), call
Gemini's batch embedding API (e.g., embedContent) once for many contents to get
embeddings in bulk, then upsert rows concurrently with a bounded concurrency
pool (Promise.all with a limiter such as p-limit set to ~4–8) instead of
awaiting each upsert serially; ensure you preserve external_id/title/content
construction (externalId, titled, content) and error handling for failed
embeddings/upserts and accumulate errors as before.

In `@src/lib/embedKeywordRetrieval.ts`:
- Around line 23-31: The scoring code inside the docs.map that builds scored is
unclear and inefficient: replace the expression using hay.split(t).length - 1 +
2 with an explicit occurrence count and a separate per-term bonus so the intent
(count occurrences + flat +2 bonus per matched term) is obvious; inside the map
over docs compute occurrences for each term using a simple indexOf loop or a
global RegExp to count matches (or use word-boundary RegExp /\bterm\b/ if
stricter matching is desired), add occurrences to score and then add the +2
bonus only when occurrences > 0, and keep the title-word exact match bonus logic
(the doc.title.toLowerCase().split...) unchanged.

In `@src/lib/embedNorthstarChat.ts`:
- Around line 33-50: The parseSteps function is brittle to LLM formatting drift:
modify parseSteps to normalize jsonPart before JSON.parse by removing
surrounding code fences and optional language tags (e.g., leading ```json or
```), trimming trailing closing fences, and extracting only the first JSON array
using a regex like /\[[\s\S]*?\]/ so any trailing prose is ignored; then parse
that cleaned array and proceed with the existing Array.isArray and string
filtering logic, keeping the existing try/catch behavior for safety.

In `@src/lib/embedStore.ts`:
- Around line 26-29: The writeJson function is non-atomic and can leave corrupt
JSON if interrupted; modify writeJson<T>(file: string, data: T) to first
ensureDir(), then write the JSON to a temporary file in the same directory
(e.g., using path.dirname(file) + a unique temp suffix or pid/random),
fsync/close the temp file if you use low-level handles, and finally
fs.rename(tempFile, file) to atomically replace the target (this keeps existing
permissions and ensures embed-projects.json / embed-api-keys.json are never
half-written); keep ensureDir() as-is and reference writeJson when implementing
the temp->rename flow.
- Around line 6-15: The current file-backed persistence using DATA_DIR =
path.join(process.cwd(), ".runbook-data") will fail in serverless/read-only
environments and race across instances; change DATA_DIR to be configurable via
an environment variable (e.g., process.env.EMBED_DATA_DIR) and fall back to the
system temp directory (require('os').tmpdir()) so the code using PROJECTS_FILE /
KEYS_FILE / TOKENS_FILE and functions like ensureDir and the mutationQueue still
work locally for the demo but won’t try to write into a read-only CWD; also add
a TODO comment in this module to replace the file-backed store with a real
external store (Supabase/Postgres/Upstash/KV) before production deployment.

In `@src/lib/prompts.ts`:
- Around line 1-8: Update the EMBED_CHAT_SYSTEM_PROMPT constant to explicitly
document the RUNBOOK_STEPS_JSON output contract: state that after the answer the
assistant must append a final line starting with RUNBOOK_STEPS_JSON: followed by
a JSON array of 3–6 imperative steps (or [] if none). Modify the string in
EMBED_CHAT_SYSTEM_PROMPT so it includes that exact instruction text, ensuring
consistency with the user message template and parsing logic used in route
handler (referencing EMBED_CHAT_SYSTEM_PROMPT and RUNBOOK_STEPS_JSON to locate
where to change).

In `@src/lib/studioDemoStorage.ts`:
- Around line 59-67: The current saveDemoBundle function silently swallows
storage quota errors; update saveDemoBundle to surface failures by changing its
signature to return a boolean (true on success, false on failure) and in the
catch block log the error with console.warn including the caught error and the
RUNBOOK_DEMO_BUNDLE_KEY (or rethrow if caller expects exceptions). Keep the
existing behavior of setting localStorage and dispatching the
"runbook-demo-update" event on success, but on failure ensure you log the full
error (e.g., mentioning QuotaExceededError) and return false so callers can show
a toast or handle the failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 73f2e324-1c7c-4589-8ef9-d3043be412a0

📥 Commits

Reviewing files that changed from the base of the PR and between d265e50 and fc96997.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (28)
  • .env.example
  • README.md
  • package.json
  • public/runbook-embed.js
  • src/app/api/embed/chat/route.ts
  • src/app/api/embed/github/callback/route.ts
  • src/app/api/embed/github/repos/route.ts
  • src/app/api/embed/github/start/route.ts
  • src/app/api/embed/me/route.ts
  • src/app/api/embed/projects/[projectId]/index/route.ts
  • src/app/api/embed/projects/[projectId]/route.ts
  • src/app/api/embed/projects/route.ts
  • src/app/embed-demo/page.tsx
  • src/app/layout.tsx
  • src/app/page.tsx
  • src/app/studio/page.tsx
  • src/components/EmbeddedRunbookAssistant.tsx
  • src/lib/embedDemoKnowledge.ts
  • src/lib/embedIndexer.ts
  • src/lib/embedKeywordRetrieval.ts
  • src/lib/embedNorthstarChat.ts
  • src/lib/embedRateLimit.ts
  • src/lib/embedRetrieval.ts
  • src/lib/embedSession.ts
  • src/lib/embedStore.ts
  • src/lib/embedTypes.ts
  • src/lib/prompts.ts
  • src/lib/studioDemoStorage.ts

Comment thread .env.example
# Must match the Authorization callback URL in the GitHub OAuth App (e.g. http://127.0.0.1:3000/api/embed/github/callback)
GITHUB_OAUTH_REDIRECT_URL=
# HMAC secret for signed HttpOnly `embed_session` cookie (generate a long random string)
RUNBOOK_EMBED_SESSION_SECRET= No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a trailing newline.

dotenv-linter flagged EndingBlankLine. Easy fix:

 # HMAC secret for signed HttpOnly `embed_session` cookie (generate a long random string)
 RUNBOOK_EMBED_SESSION_SECRET=
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
RUNBOOK_EMBED_SESSION_SECRET=
# HMAC secret for signed HttpOnly `embed_session` cookie (generate a long random string)
RUNBOOK_EMBED_SESSION_SECRET=
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 30-30: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example at line 30, Add a trailing newline at the end of the
.env.example file so the final line "RUNBOOK_EMBED_SESSION_SECRET=" is
terminated with a newline character; this resolves the dotenv-linter
EndingBlankLine warning for the RUNBOOK_EMBED_SESSION_SECRET entry.

Comment thread public/runbook-embed.js Outdated
Comment on lines +26 to +29
function originAllowed(projectSite: string | undefined, origin: string | null): boolean {
if (!projectSite || !origin) return true;
return origin.includes(projectSite) || projectSite.includes(origin.replace(/^https?:\/\//, ""));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Origin allowlist substring match is exploitable.

originAllowed uses naive String.includes in both directions. An attacker-controlled origin like https://evil-example.com.attacker.com will satisfy origin.includes("example.com") and pass the check, defeating the per-project origin restriction. Parse both values to a canonical hostname and compare with strict equality (or a suffix match anchored on . boundaries).

🔒 Proposed fix
-function originAllowed(projectSite: string | undefined, origin: string | null): boolean {
-  if (!projectSite || !origin) return true;
-  return origin.includes(projectSite) || projectSite.includes(origin.replace(/^https?:\/\//, ""));
-}
+function hostFrom(value: string): string | null {
+  try {
+    return new URL(value.includes("://") ? value : `https://${value}`).hostname.toLowerCase();
+  } catch {
+    return null;
+  }
+}
+
+function originAllowed(projectSite: string | undefined, origin: string | null): boolean {
+  if (!projectSite || !origin) return true;
+  const allowed = hostFrom(projectSite);
+  const got = hostFrom(origin);
+  if (!allowed || !got) return false;
+  return got === allowed || got.endsWith(`.${allowed}`);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function originAllowed(projectSite: string | undefined, origin: string | null): boolean {
if (!projectSite || !origin) return true;
return origin.includes(projectSite) || projectSite.includes(origin.replace(/^https?:\/\//, ""));
}
function hostFrom(value: string): string | null {
try {
return new URL(value.includes("://") ? value : `https://${value}`).hostname.toLowerCase();
} catch {
return null;
}
}
function originAllowed(projectSite: string | undefined, origin: string | null): boolean {
if (!projectSite || !origin) return true;
const allowed = hostFrom(projectSite);
const got = hostFrom(origin);
if (!allowed || !got) return false;
return got === allowed || got.endsWith(`.${allowed}`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/embed/chat/route.ts` around lines 26 - 29, The originAllowed
function uses naive substring checks that are exploitable; update originAllowed
to parse both inputs as URLs (or extract canonical hostnames), normalize to
lowercase and strip ports, then compare either exact hostname equality or
perform a suffix match anchored on dot boundaries (e.g., ensure projectSiteHost
=== originHost or originHost endsWith('.' + projectSiteHost)) instead of using
String.includes; this ensures attacks like "evil-example.com.attacker.com" do
not pass.

Comment on lines +94 to +106
if (projectId === NORTHSTAR_DEMO_PROJECT_ID) {
const rl = checkEmbedRateLimit("northstar-demo-public");
if (!rl.ok) {
return NextResponse.json(
{ error: "Rate limited", retryAfter: rl.retryAfter },
{ status: 429, headers: { ...corsHeaders(origin), "Retry-After": String(rl.retryAfter) } }
);
}

const customSources = sanitizeCustomSources(body.customSources);
const payload = await runNorthstarEmbedChat({ message, pageContext, customSources });
return NextResponse.json(payload, { headers: corsHeaders(origin) });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Public demo rate limit is shared by every visitor.

checkEmbedRateLimit("northstar-demo-public") uses one global bucket, so a single noisy client can exhaust the quota and 429 every other demo user (the comment even claims it’s “IP-ish” but no IP is mixed in). Bucket per remote address (e.g. x-forwarded-for first hop) so this scales by caller.

🛡️ Proposed fix
-  if (projectId === NORTHSTAR_DEMO_PROJECT_ID) {
-    const rl = checkEmbedRateLimit("northstar-demo-public");
+  if (projectId === NORTHSTAR_DEMO_PROJECT_ID) {
+    const ip =
+      (req.headers.get("x-forwarded-for") || "").split(",")[0]?.trim() ||
+      req.headers.get("x-real-ip") ||
+      "anon";
+    const rl = checkEmbedRateLimit(`northstar-demo:${ip}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/embed/chat/route.ts` around lines 94 - 106, The current demo uses
a single global bucket via checkEmbedRateLimit("northstar-demo-public"),
allowing one noisy client to exhaust the quota; change the call to scope rate
limits per remote address by extracting the caller IP (use the first hop of the
x-forwarded-for header or request.ip as available) and pass a key like
`northstar-demo-public:${remoteAddr}` to checkEmbedRateLimit; ensure you
fallback to the original static key if no remote address is found and keep the
rest of the flow (sanitizeCustomSources, runNorthstarEmbedChat,
NextResponse.json, corsHeaders) unchanged.

Comment on lines +55 to +61
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${tokenJson.access_token}`,
Accept: "application/vnd.github+json"
}
});
const user = (await userRes.json()) as { id?: number; login?: string };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does GitHub's REST API require a User-Agent header for unauthenticated /user requests using a bearer token?

💡 Result:

Yes. GitHub’s REST API requires a valid User-Agent header on all API requests (including requests to GET /user made with a Bearer token). Requests with no User-Agent header are rejected with 403 Forbidden, regardless of whether you authenticate. Separately, whether you include the Bearer token affects rate limits: authenticated requests have much higher primary rate limits than unauthenticated ones.

Citations:


🏁 Script executed:

# First, let's check if the file exists and read the relevant sections
cat -n src/app/api/embed/github/callback/route.ts | head -70

Repository: dfed25/RunBook

Length of output: 3090


Add missing User-Agent headers and harden JSON parsing against failures.

GitHub's REST API requires a User-Agent header on all requests; requests without one are rejected with 403 Forbidden. Both the token-exchange request (lines 40–47) and the user API request (lines 55–61) lack this header. Additionally, tokenRes.json() and userRes.json() will throw on non-JSON responses (e.g., 5xx HTML errors), causing unhandled errors instead of the safer redirect path used elsewhere in this handler.

♻️ Suggested hardening
  const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
    method: "POST",
    headers: {
      Accept: "application/json",
-     "Content-Type": "application/x-www-form-urlencoded"
+     "Content-Type": "application/x-www-form-urlencoded",
+     "User-Agent": "runbook-embed"
    },
    body: body.toString()
  });

  const userRes = await fetch("https://api.github.com/user", {
    headers: {
      Authorization: `Bearer ${tokenJson.access_token}`,
-     Accept: "application/vnd.github+json"
+     Accept: "application/vnd.github+json",
+     "User-Agent": "runbook-embed"
    }
  });
-  const user = (await userRes.json()) as { id?: number; login?: string };
+  let user: { id?: number; login?: string } = {};
+  try {
+    user = (await userRes.json()) as { id?: number; login?: string };
+  } catch {
+    return NextResponse.redirect(new URL("/studio?error=github_user", req.nextUrl.origin));
+  }

Consider wrapping tokenRes.json() in a try-catch as well for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/embed/github/callback/route.ts` around lines 55 - 61, The GitHub
API calls are missing a required User-Agent header and the JSON parsing of
tokenRes and userRes can throw on non-JSON responses; update the fetch options
used when exchanging the token (tokenRes) and when calling the user endpoint
(userRes) to include a User-Agent header (e.g., "User-Agent": "your-app-name"),
and guard the JSON parsing for tokenJson and user by checking response.ok and
wrapping await tokenRes.json() and await userRes.json() in try/catch (or
conditional parsing) so failures fall back to the existing safe redirect path
instead of throwing; locate these changes around tokenRes, tokenJson, userRes,
and user in the route handler and ensure the error paths use the same redirect
logic already implemented.

Comment thread src/lib/embedIndexer.ts
Comment on lines +200 to +208
const { error } = await supabaseAdmin.from("runbook_documents").upsert(row, { onConflict: "provider,external_id" });
let stored = false;
if (error) {
const { error: insErr } = await supabaseAdmin.from("runbook_documents").insert(row);
if (insErr) errors.push(`${filePath}#${cIdx}: ${insErr.message}`);
else stored = true;
} else {
stored = true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fallback insert after a failed upsert won't recover the conflict case.

upsert(..., { onConflict: "provider,external_id" }) already handles the duplicate-key path by updating, so a non-null error here means something else went wrong (RLS, schema, network, embedding column shape, etc.). A plain insert of the same row will hit the same UNIQUE constraint and fail again. Either drop the fallback and just record the error, or replace it with a more meaningful retry/backoff:

♻️ Proposed simplification
-        const { error } = await supabaseAdmin.from("runbook_documents").upsert(row, { onConflict: "provider,external_id" });
-        let stored = false;
-        if (error) {
-          const { error: insErr } = await supabaseAdmin.from("runbook_documents").insert(row);
-          if (insErr) errors.push(`${filePath}#${cIdx}: ${insErr.message}`);
-          else stored = true;
-        } else {
-          stored = true;
-        }
+        const { error } = await supabaseAdmin
+          .from("runbook_documents")
+          .upsert(row, { onConflict: "provider,external_id" });
+        const stored = !error;
+        if (error) errors.push(`${filePath}#${cIdx}: ${error.message}`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { error } = await supabaseAdmin.from("runbook_documents").upsert(row, { onConflict: "provider,external_id" });
let stored = false;
if (error) {
const { error: insErr } = await supabaseAdmin.from("runbook_documents").insert(row);
if (insErr) errors.push(`${filePath}#${cIdx}: ${insErr.message}`);
else stored = true;
} else {
stored = true;
}
const { error } = await supabaseAdmin
.from("runbook_documents")
.upsert(row, { onConflict: "provider,external_id" });
const stored = !error;
if (error) errors.push(`${filePath}#${cIdx}: ${error.message}`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedIndexer.ts` around lines 200 - 208, The fallback insert after a
failed upsert in embedIndexer.ts is incorrect: if
supabaseAdmin.from("runbook_documents").upsert(row, { onConflict:
"provider,external_id" }) returns an error it won’t be fixed by re-running
insert (unique conflict or other issues persist); change the logic in the block
handling the upsert result so that on error you record the error (push to errors
with insErr.message or error.message) and do not attempt the duplicate insert,
or implement a proper retry/backoff for transient failures; update the uses of
variables row, stored, errors and the upsert/insert call sites so stored is only
set true on successful upsert/insert and ensure the logged error comes from the
original upsert error when present.

Comment on lines +57 to +62
const extraDocs: SourceDoc[] = input.customSources.map((s, i) => ({
id: `manual-${i}-${s.title.slice(0, 20)}`,
title: s.title.trim().slice(0, 200),
content: s.content.trim().slice(0, 12_000),
sourceType: "text" as const
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cap customSources count, not just per-item size.

Each item is truncated to 12k characters, but there's no bound on input.customSources.length. A malicious or buggy caller can submit thousands of entries, which then flow into retrieveKeywordSources and the prompt-building below, blowing up CPU and (potentially) the LLM token budget. Consider input.customSources.slice(0, 32) (or whatever ceiling fits the demo) before mapping.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedNorthstarChat.ts` around lines 57 - 62, The code maps all
entries in input.customSources into extraDocs without limiting the number of
items, which lets a caller submit arbitrarily many docs and exhaust CPU/LLM
tokens; before mapping, cap the array (e.g. const capped =
input.customSources.slice(0, 32)) and map capped instead of input.customSources
so extraDocs has a fixed upper bound; update any downstream references
(retrieveKeywordSources and prompt-building that consume extraDocs) to use the
capped list and document the chosen max items constant near the extraDocs
creation.

Comment thread src/lib/embedRateLimit.ts
Comment on lines +3 to +19
const buckets = new Map<string, Bucket>();
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 40;

export function checkEmbedRateLimit(keyId: string): { ok: true } | { ok: false; retryAfter: number } {
const now = Date.now();
let b = buckets.get(keyId);
if (!b || now > b.reset) {
b = { count: 0, reset: now + WINDOW_MS };
buckets.set(keyId, b);
}
if (b.count >= MAX_PER_WINDOW) {
return { ok: false, retryAfter: Math.ceil((b.reset - now) / 1000) };
}
b.count += 1;
return { ok: true };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

In-memory limiter has two production hazards: per-instance state and unbounded growth.

  1. State lives in a module-scoped Map, so on Vercel/serverless or any horizontally-scaled deploy each runtime instance has its own buckets. The effective limit becomes MAX_PER_WINDOW × #instances``, which materially weakens abuse protection on the public embed/chat path.
  2. Buckets are never evicted — only overwritten when the same keyId is seen again after expiry. Distinct keys (incl. the demo path mixed with random/spoofed ones) accumulate forever, leaking memory in long-lived Node processes.

For the demo this is probably acceptable, but please at least bound the map and consider a shared store (Upstash/Redis) before public traffic.

♻️ Suggested minimal mitigation (opportunistic eviction + size cap)
 const buckets = new Map<string, Bucket>();
 const WINDOW_MS = 60_000;
 const MAX_PER_WINDOW = 40;
+const MAX_TRACKED_KEYS = 10_000;

 export function checkEmbedRateLimit(keyId: string): { ok: true } | { ok: false; retryAfter: number } {
   const now = Date.now();
+  // Opportunistic cleanup to avoid unbounded growth.
+  if (buckets.size > MAX_TRACKED_KEYS) {
+    for (const [k, v] of buckets) {
+      if (now > v.reset) buckets.delete(k);
+    }
+    // If still oversized, drop oldest insertion-order entries.
+    while (buckets.size > MAX_TRACKED_KEYS) {
+      const firstKey = buckets.keys().next().value;
+      if (firstKey === undefined) break;
+      buckets.delete(firstKey);
+    }
+  }
   let b = buckets.get(keyId);

For multi-instance correctness, swap this for a shared backing store (e.g., Upstash Redis INCR+EXPIRE) keyed by keyId.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const buckets = new Map<string, Bucket>();
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 40;
export function checkEmbedRateLimit(keyId: string): { ok: true } | { ok: false; retryAfter: number } {
const now = Date.now();
let b = buckets.get(keyId);
if (!b || now > b.reset) {
b = { count: 0, reset: now + WINDOW_MS };
buckets.set(keyId, b);
}
if (b.count >= MAX_PER_WINDOW) {
return { ok: false, retryAfter: Math.ceil((b.reset - now) / 1000) };
}
b.count += 1;
return { ok: true };
}
const buckets = new Map<string, Bucket>();
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 40;
const MAX_TRACKED_KEYS = 10_000;
export function checkEmbedRateLimit(keyId: string): { ok: true } | { ok: false; retryAfter: number } {
const now = Date.now();
// Opportunistic cleanup to avoid unbounded growth.
if (buckets.size > MAX_TRACKED_KEYS) {
for (const [k, v] of buckets) {
if (now > v.reset) buckets.delete(k);
}
// If still oversized, drop oldest insertion-order entries.
while (buckets.size > MAX_TRACKED_KEYS) {
const firstKey = buckets.keys().next().value;
if (firstKey === undefined) break;
buckets.delete(firstKey);
}
}
let b = buckets.get(keyId);
if (!b || now > b.reset) {
b = { count: 0, reset: now + WINDOW_MS };
buckets.set(keyId, b);
}
if (b.count >= MAX_PER_WINDOW) {
return { ok: false, retryAfter: Math.ceil((b.reset - now) / 1000) };
}
b.count += 1;
return { ok: true };
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedRateLimit.ts` around lines 3 - 19, The in-memory Map-based
limiter (buckets, WINDOW_MS, MAX_PER_WINDOW, and function checkEmbedRateLimit)
causes per-instance limits and unbounded growth; replace the module-scoped Map
with a shared store (e.g., Upstash/Redis) using INCR + EXPIRE per keyId to
enforce a global MAX_PER_WINDOW across instances and correct TTL-based resets,
and if you must keep a local fallback keep a bounded Map size (e.g., MAX_BUCKETS
constant) and implement opportunistic eviction in checkEmbedRateLimit to prune
expired entries and remove oldest/newest entries when the cap is exceeded to
avoid memory leaks.

Comment thread src/lib/embedRetrieval.ts
Comment on lines +35 to +50
const { data: documents, error } = await supabaseAdmin.rpc("match_documents", {
query_embedding: `[${embedding.join(",")}]`,
match_threshold: DEFAULT_MATCH_THRESHOLD,
match_count: TOP_K * 8
});

if (error) {
console.error("Embed vector search error:", error);
return [];
}

const batch = (documents || []) as MatchedDocument[];
const scoped = batch.filter((d) => matchesEmbedScope(d.content, projectId));
return scoped
.sort((a, b) => b.similarity - a.similarity)
.slice(0, TOP_K)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether match_documents accepts a project filter parameter
fd -e sql . supabase | xargs rg -nC2 'match_documents'

Repository: dfed25/RunBook

Length of output: 510


🏁 Script executed:

# Get full match_documents function definition
sed -n '19,80p' supabase/migrations/00000000000000_init_vector_db.sql

Repository: dfed25/RunBook

Length of output: 895


🏁 Script executed:

# Find TOP_K constant and matchesEmbedScope implementation
rg -n 'TOP_K|matchesEmbedScope' src/lib/embedRetrieval.ts -A 3 -B 3

Repository: dfed25/RunBook

Length of output: 1151


🏁 Script executed:

# Check the entire embedRetrieval.ts file to understand the context
wc -l src/lib/embedRetrieval.ts

Repository: dfed25/RunBook

Length of output: 87


Client-side project filtering may starve results in multi-tenant deployments.

The match_documents SQL function has no project filter parameter and returns the top match_count documents from all projects by similarity. You then filter these 48 candidates (TOP_K * 8 where TOP_K = 6) client-side by checking for the [embed:${projectId}] marker in content. If documents from other projects dominate the top-48 nearest neighbors, the scoped result set can be empty even when relevant docs exist deeper in the full ranking.

Consider either (a) modifying match_documents to accept and filter by projectId at the SQL level (most robust), or (b) raising match_count substantially. As a stopgap, paginate via additional RPC calls until scoped.length >= TOP_K or a hard limit is reached.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedRetrieval.ts` around lines 35 - 50, The current call to
supabaseAdmin.rpc("match_documents", ...) requests TOP_K * 8 candidates and then
filters client-side via matchesEmbedScope(d.content, projectId), which can drop
all results if other-project docs dominate; fix by either (preferred) extending
the match_documents RPC to accept a project_id (or embed scope) and apply the
filter in SQL and then call it from this code (update the rpc parameters where
match_documents is invoked), or (stopgap) implement paging: repeatedly call
supabaseAdmin.rpc("match_documents", { query_embedding:
`[${embedding.join(",")}]`, match_threshold: DEFAULT_MATCH_THRESHOLD,
match_count: pageSize, page_offset: offset }) aggregating unique documents,
apply matchesEmbedScope(projectId) to the accumulated set, and stop when
scoped.length >= TOP_K or a safe max iterations is reached, then sort by
similarity and return the top TOP_K; ensure you update uses of TOP_K,
match_count, match_documents, matchesEmbedScope, embedding, and projectId
accordingly.

Comment thread src/lib/embedSession.ts
Comment on lines +5 to +11
function sessionSecret(): string {
return (
process.env.RUNBOOK_EMBED_SESSION_SECRET ||
process.env.SUPABASE_SERVICE_ROLE_KEY?.slice(0, 48) ||
"runbook-embed-dev-secret-change-in-production"
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't silently fall back to a hardcoded session secret in production.

If RUNBOOK_EMBED_SESSION_SECRET and SUPABASE_SERVICE_ROLE_KEY are both unset, the HMAC key becomes the literal string "runbook-embed-dev-secret-change-in-production". That secret is in the public repo, so anyone can mint a valid embed_session token for any githubId/login and bypass verifyEmbedSession — i.e., full impersonation against GET /api/embed/projects, POST /api/embed/projects/:id/index, etc. Reusing SUPABASE_SERVICE_ROLE_KEY as a signing key is also undesirable (key reuse across purposes; service-role keys often live in client-readable config in some setups).

Recommend: require the dedicated env in non-dev and fail closed.

🛡️ Proposed fix
 function sessionSecret(): string {
-  return (
-    process.env.RUNBOOK_EMBED_SESSION_SECRET ||
-    process.env.SUPABASE_SERVICE_ROLE_KEY?.slice(0, 48) ||
-    "runbook-embed-dev-secret-change-in-production"
-  );
+  const secret =
+    process.env.RUNBOOK_EMBED_SESSION_SECRET?.trim() ||
+    process.env.SUPABASE_SERVICE_ROLE_KEY?.slice(0, 48);
+  if (!secret) {
+    if (process.env.NODE_ENV === "production") {
+      throw new Error("RUNBOOK_EMBED_SESSION_SECRET is required in production");
+    }
+    return "runbook-embed-dev-secret-change-in-production";
+  }
+  return secret;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/embedSession.ts` around lines 5 - 11, The sessionSecret() function
must not silently fall back to a public hardcoded secret or reuse
SUPABASE_SERVICE_ROLE_KEY in production; change it so it returns
RUNBOOK_EMBED_SESSION_SECRET if present, and otherwise throws (fail-closed) when
not running in development (e.g., NODE_ENV !== 'development' or VERCEL_ENV !==
'development'); allow an explicit, documented fallback only for local/dev runs
(optionally use SUPABASE_SERVICE_ROLE_KEY or the dev literal only when NODE_ENV
=== 'development') and add a clear error message referencing sessionSecret() so
the app fails to start if the dedicated env is missing in non-dev environments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants