diff --git a/.fleet/design-review.json b/.fleet/design-review.json
index 21f2bbb..e386eea 100644
--- a/.fleet/design-review.json
+++ b/.fleet/design-review.json
@@ -2,7 +2,7 @@
"$schema": "fleet.design-review.v1",
"version": 1,
"project": "starboard",
- "target": "projects shared app shell",
+ "target": "core discovery hardening and shared repository shell",
"mode": "preserve",
"register": "product",
"context": {
@@ -14,29 +14,29 @@
"probes": [],
"selected": "existing-design",
"approval": "not-required",
- "before": ".fleet/evidence/projects-shell/before-1440.png"
+ "before": ".fleet/evidence/core-discovery-hardening/before-1440.png"
},
"evidence": {
"screenshots": [
{
"width": 390,
- "path": ".fleet/evidence/projects-shell/after-390.png"
+ "path": ".fleet/evidence/core-discovery-hardening/after-390.png"
},
{
"width": 768,
- "path": ".fleet/evidence/projects-shell/after-768.png"
+ "path": ".fleet/evidence/core-discovery-hardening/after-768.png"
},
{
"width": 1440,
- "path": ".fleet/evidence/projects-shell/after-1440.png"
+ "path": ".fleet/evidence/core-discovery-hardening/after-1440.png"
}
],
"projectCheck": {
- "command": "pnpm build",
+ "command": "pnpm build:cf",
"status": "pass"
},
"critique": {
- "score": 32,
+ "score": 35,
"maximum": 40
},
"audit": {
@@ -53,7 +53,7 @@
}
},
"ownerFeedback": {
- "decision": "keep",
- "note": "Owner approved the shared-shell Projects correction on 2026-08-09."
+ "decision": "delegated",
+ "note": "Owner delegated the final implementation and testability judgment by asking to fix the full audited surface before their own test pass."
}
}
diff --git a/.fleet/evidence/core-discovery-hardening/after-1440.png b/.fleet/evidence/core-discovery-hardening/after-1440.png
new file mode 100644
index 0000000..46f5e88
Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/after-1440.png differ
diff --git a/.fleet/evidence/core-discovery-hardening/after-390.png b/.fleet/evidence/core-discovery-hardening/after-390.png
new file mode 100644
index 0000000..dab4aad
Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/after-390.png differ
diff --git a/.fleet/evidence/core-discovery-hardening/after-768.png b/.fleet/evidence/core-discovery-hardening/after-768.png
new file mode 100644
index 0000000..af7b4f1
Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/after-768.png differ
diff --git a/.fleet/evidence/core-discovery-hardening/before-1440.png b/.fleet/evidence/core-discovery-hardening/before-1440.png
new file mode 100644
index 0000000..dae08ed
Binary files /dev/null and b/.fleet/evidence/core-discovery-hardening/before-1440.png differ
diff --git a/.fleet/reviews/2026-08-09-projects-shared-app-shell.json b/.fleet/reviews/2026-08-09-projects-shared-app-shell.json
new file mode 100644
index 0000000..21f2bbb
--- /dev/null
+++ b/.fleet/reviews/2026-08-09-projects-shared-app-shell.json
@@ -0,0 +1,59 @@
+{
+ "$schema": "fleet.design-review.v1",
+ "version": 1,
+ "project": "starboard",
+ "target": "projects shared app shell",
+ "mode": "preserve",
+ "register": "product",
+ "context": {
+ "product": "PRODUCT.md",
+ "design": "src/app/globals.css"
+ },
+ "direction": {
+ "references": [],
+ "probes": [],
+ "selected": "existing-design",
+ "approval": "not-required",
+ "before": ".fleet/evidence/projects-shell/before-1440.png"
+ },
+ "evidence": {
+ "screenshots": [
+ {
+ "width": 390,
+ "path": ".fleet/evidence/projects-shell/after-390.png"
+ },
+ {
+ "width": 768,
+ "path": ".fleet/evidence/projects-shell/after-768.png"
+ },
+ {
+ "width": 1440,
+ "path": ".fleet/evidence/projects-shell/after-1440.png"
+ }
+ ],
+ "projectCheck": {
+ "command": "pnpm build",
+ "status": "pass"
+ },
+ "critique": {
+ "score": 32,
+ "maximum": 40
+ },
+ "audit": {
+ "score": 18,
+ "maximum": 20
+ },
+ "unresolved": {
+ "p0": 0,
+ "p1": 0
+ },
+ "detector": {
+ "posture": "advisory",
+ "findings": []
+ }
+ },
+ "ownerFeedback": {
+ "decision": "keep",
+ "note": "Owner approved the shared-shell Projects correction on 2026-08-09."
+ }
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1e97562..0a0bc04 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,4 +26,8 @@ jobs:
- run: pnpm test:coverage
- - run: pnpm build
+ - run: pnpm build:e2e
+
+ - run: pnpm exec playwright install --with-deps chromium
+
+ - run: pnpm test:e2e
diff --git a/AGENTS.md b/AGENTS.md
index fad8b1f..19266ba 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -244,3 +244,13 @@ S69 Starboard — migrate deployment from Vercel/Turso to Cloudflare Workers via
Access 284k tokens of past work via get_observations([IDs]) or mem-search skill.
+
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md
index d6a2384..d92c235 100644
--- a/PROJECT_STATUS.md
+++ b/PROJECT_STATUS.md
@@ -32,13 +32,13 @@ installation, alerts, reports, digest email, and stack generation.
**Local dev:** `pnpm install && cp .env.example .env.local && pnpm dev` → http://localhost:3000
-**Key checks:** `pnpm test:coverage` · `pnpm build` · `pnpm build:cf` (Cloudflare path)
+**Key checks:** `pnpm test:coverage` · `pnpm test:e2e` · `pnpm build:cf` (Cloudflare path)
```
GitHub OAuth (NextAuth)
│
▼
-Star sync + public project connection ──► D1 (users, repos, user_repos, user_projects, lists, comments, votes)
+Star sync + public project connection ──► D1 (users, repos, user_repos, user_projects, lists)
│
├── Full-text + facet search (GET /api/stars)
├── Semantic search: knowledgebase Worker; lexical-only when shared RAG is unavailable
@@ -72,6 +72,15 @@ provenance. The workflow is free and has no billing or entitlement gate.
## Timeline
+- **2026-08-09 (core discovery hardening, local)** — Stabilized aborted filter
+ requests, added hybrid public Discover relevance with lexical fallback,
+ tightened peer/tool evidence thresholds, protected uncataloged previews with
+ the user's GitHub token, paginated Tool Intelligence evidence, moved
+ repository intelligence into the shared shell, and retired its discussion
+ controls while retaining legacy data. The production-accurate Playwright
+ suite now covers the Astro landing and core public journeys at desktop and
+ mobile widths. This work is locally testable and not yet released.
+
- **2026-08-09 (shared lint baseline)** — Adopted the Fleet Ultracite baseline
for core TypeScript, React, Next.js, and Vitest code. Explicit compatibility
exceptions preserve current behavior while 206 files pass with zero
@@ -177,7 +186,7 @@ provenance. The workflow is free and has no billing or entitlement gate.
| Phase | Milestone |
|-------|-----------|
| Foundation | GitHub OAuth (NextAuth v5), OpenNext Cloudflare deploy, core dashboard with sync, tags, collections, full-text search, virtual scroll |
-| Repo intelligence | Repo detail (`/explore`), comments/votes, public shared lists, legal/marketing shell |
+| Repo intelligence | Shared-shell repository detail (`/explore`), similar projects, tool evidence, public shared lists, legal/marketing shell |
| Semantic search | knowledgebase Worker integration for relevance search; README-backed sync ingest; local embeddings retained for non-RAG Starboard features |
| Connected projects | Shipped public GitHub project connections, public preview, GitHub picker, and evidence-based repository and tool recommendations |
| Discovery & tools | Public Discover, daily bounded seed/enrich/embed with manual dispatch, stored growth sorting, and Tool Intelligence |
@@ -212,7 +221,9 @@ provenance. The workflow is free and has no billing or entitlement gate.
- GitHub star-list ingestion via HTML scraping where no official API exists.
- Main dashboard: smart categories, custom colored tags, named collections, full-text search, language/category/tag/collection filters, sort (recently starred, most stars, recently updated, A-Z), grid/list toggle, virtual scroll for 1000+ repos.
- URL-shareable filter/sort state through nuqs.
-- Repo detail (`/explore`): comments, votes, likes, similar repos, list assignment, tag picker.
+- Repo detail (`/explore`): public similar repositories, detected-tool evidence,
+ and stored star history in the shared application shell. Legacy discussion
+ data is retained but has no active product controls.
- Public shared lists at `/lists/[slug]` with SSR and `list.json` export route.
- Legal/marketing shell: about, privacy, terms, sitemap, robots, OG image, security.txt, humans.txt, PWA manifest.
@@ -225,9 +236,9 @@ provenance. The workflow is free and has no billing or entitlement gate.
credentials in GitHub.
### Connected projects and public preview
-- Guests can preview a public GitHub repository without sign-in or a user-data
- write, then carry the normalized repository through sign-in for explicit
- connection.
+- Guests can preview a cataloged public GitHub repository without sign-in or a
+ user-data write. Uncataloged lookups require the existing GitHub session token
+ and preserve the repository through sign-in for explicit connection.
- Authenticated users can connect and disconnect public GitHub repositories
by URL or an on-demand public-repository picker without a broader OAuth scope.
- `user_projects` isolates project connections per user while reusing shared
@@ -238,13 +249,15 @@ provenance. The workflow is free and has no billing or entitlement gate.
### Discovery and intelligence surfaces
- Public Discover page and `/api/discover` for the seeded popular repository
- corpus; authentication adds saved state and collection controls but is not
- required to browse, search, sort, filter, paginate, or open repo details.
+ corpus; relevance search fuses bounded semantic and lexical candidates and
+ falls back honestly to lexical search. Authentication adds saved state and
+ collection controls but is not required to browse, search, sort, filter,
+ paginate, or open repo details.
- Discover supports paginated 30-day growth ordering and detected-tool facets from indexed local snapshot/tool tables.
- Daily bounded GitHub Actions seed/enrich popular repos in D1 and embed through
native Worker bindings; manual dispatch remains available for operator checks.
- Star history and fastest-grower APIs/surfaces: `/api/repos/[repoId]/star-history`, `/api/growth`, Discover growth sorting, and repo-detail mini history from stored `repo_star_snapshots`.
-- Tool Intelligence: additive `repo_tools` index, `/api/tools`, `/api/repos/[repoId]/tools`, `/tools`, and `pnpm db:enrich-tools` for bounded SBOM/tree/manifest-based detection with source/confidence labels. Accuracy disclaimer is shown in-product because manifest/SBOM evidence is stronger than README/topic/metadata inference and C/C++ monorepos vary.
+- Tool Intelligence: additive `repo_tools` index, `/api/tools`, `/api/repos/[repoId]/tools`, `/tools`, and `pnpm db:enrich-tools` for bounded SBOM/tree/manifest-based detection with source/confidence labels. Repository evidence is server-filtered and paginated in 48-item pages. Accuracy disclaimer is shown in-product because manifest/SBOM evidence is stronger than README/topic/metadata inference and C/C++ monorepos vary.
- SaaS Maker feedback widget integrated; product analytics run directly through PostHog.
- First-run UX, sample prioritized stars board, semantic search, GitHub
permission trust note, and public discovery paths remain.
@@ -253,7 +266,9 @@ provenance. The workflow is free and has no billing or entitlement gate.
- Shared Ultracite lint baseline with a clean 206-file check.
- Checked-in `.env.example` documents required local variables without secrets.
-- Vitest unit tests with v8 coverage thresholds (80% lines/functions/statements, 70% branches) on `github-projects`, `project-recommendations`, `search`, and `starboard-rag-documents`; Playwright e2e path documented in README.
+- Vitest unit tests with v8 coverage thresholds (80% lines/functions/statements,
+ 70% branches) on selected core logic; production-accurate Playwright journeys
+ cover the Astro landing and public app at desktop and mobile widths.
- Pre-push lint hook.
- TypeScript config and Astro landing tooling made self-contained for green Cloudflare builds.
diff --git a/README.md b/README.md
index 0a6e5ba..34ecf9b 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,9 @@ matches, and keeps personal stars searchable with tags and collections.
## Features
-- **Public Project Preview** — paste a public GitHub repository and inspect a
- read-only recommendation sample before sign-in
+- **Public Project Preview** — inspect a read-only recommendation sample for a
+ cataloged repository before sign-in; uncataloged lookups use the user's
+ existing GitHub session
- **GitHub OAuth** — Sign in and sync your starred repos
- **Smart Categories** — Auto-categorize repos (AI/ML, Frontend, DevOps, etc.)
- **Custom Tags** — Create and assign colored tags to repos
diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md
index 199982d..7c8305d 100644
--- a/docs/architecture/data-flow.md
+++ b/docs/architecture/data-flow.md
@@ -31,7 +31,8 @@ history and remains empty when insufficient samples exist.
```text
public GitHub URL or owner/repository
→ normalize and check the local catalog
- → resolve one public GitHub repository on a catalog miss
+ → require the existing GitHub session token on a catalog miss
+ → resolve one public GitHub repository with authenticated quota
→ public preview: no user-owned write
→ authenticated connection only after explicit confirmation
→ upsert shared repos row
diff --git a/docs/development/commands.md b/docs/development/commands.md
index 185d069..9cd3d4d 100644
--- a/docs/development/commands.md
+++ b/docs/development/commands.md
@@ -23,6 +23,7 @@ Source of truth: `scripts` in `package.json`. This page annotates intent; run
| --- | --- |
| `pnpm cf:build` | `next build --webpack` + inline critical CSS + `opennextjs-cloudflare build --skipNextBuild` + `populateCache local` + landing-astro build + overlay |
| `pnpm build:cf` | Alias for `cf:build` |
+| `pnpm build:e2e` | Credential-free OpenNext build plus Astro landing overlay for browser tests |
| `pnpm preview:cf` | `build:cf` + `opennextjs-cloudflare preview` |
| `pnpm deploy:cf` | `build:cf` + SHA-tagged `opennextjs-cloudflare deploy` (manual) |
| `pnpm cf:typegen` | `wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts` |
diff --git a/docs/development/testing.md b/docs/development/testing.md
index bee6049..137c5fa 100644
--- a/docs/development/testing.md
+++ b/docs/development/testing.md
@@ -34,8 +34,19 @@ pnpm test:coverage # vitest run --coverage
## End-to-end (Playwright)
- **Runner:** Playwright. Config in `playwright.config.ts`. Tests in `e2e/`.
-- `pnpm test:e2e` — desktop project.
-- `pnpm test:e2e:mobile` — mobile project.
+- `pnpm test:e2e` — runs `build:e2e` locally to produce a credential-free
+ OpenNext artifact, overlays the real Astro landing, then exercises landing
+ and mocked public-product journeys at desktop and mobile widths. CI builds
+ that artifact once before starting Playwright.
+- Playwright starts the artifact with `wrangler.e2e.jsonc`, a local-only
+ binding set and applies migrations to its disposable local D1 before the
+ preview starts. The config omits Cloudflare AI, Vectorize, and service
+ bindings, so the suite needs no operator credentials and cannot call those
+ production resources.
+- Covered journeys: public project CTA, Discover search stability, bounded Tool
+ Intelligence pagination, the shared repository-intelligence shell, and the
+ uncataloged-preview sign-in boundary.
+- `pnpm test:e2e:mobile` — the mobile Next.js application journeys only.
## Smoke checks
@@ -47,7 +58,7 @@ pnpm test:coverage # vitest run --coverage
## CI
- `.github/workflows/ci.yml` — push/PR: `pnpm install --frozen-lockfile` →
- `lint` → `test:coverage` → `build`.
+ `lint` → `test:coverage` → `build:e2e` → Chromium install → `test:e2e`.
- `.github/workflows/weekly.yml` — Mondays 09:00 UTC: lint, typecheck, test,
build (catches drift that doesn't surface on push CI).
- `.github/workflows/docs.yml` — push/PR on docs-touching paths: runs
diff --git a/docs/operations/ci-cd.md b/docs/operations/ci-cd.md
index 999ca14..64b1299 100644
--- a/docs/operations/ci-cd.md
+++ b/docs/operations/ci-cd.md
@@ -7,7 +7,11 @@ in [jobs.md](jobs.md); this page covers the push/PR and deploy pipelines.
- **Triggers:** push to `main`/`master`; PRs against `main`/`master`.
- **Steps:** checkout → pnpm setup → Node 22 → `pnpm install --frozen-lockfile`
- → `pnpm lint` → `pnpm test:coverage` → `pnpm build`.
+ → `pnpm lint` → `pnpm test:coverage` → `pnpm build:e2e` → Chromium install
+ → `pnpm test:e2e`. `build:e2e` creates the OpenNext and Astro-overlaid
+ artifact without Cloudflare cache population or operator credentials. CI
+ previews that artifact so the browser suite verifies the landing and app
+ together.
- **Permissions:** default.
## Deploy (`.github/workflows/deploy.yml`)
diff --git a/docs/operations/data-map.md b/docs/operations/data-map.md
index 0c46ff7..f3b4b04 100644
--- a/docs/operations/data-map.md
+++ b/docs/operations/data-map.md
@@ -32,7 +32,7 @@ retirement requires separate approval.
| D1 `user_repos` (starred/saved state) | irreplaceable-user | GitHub sync via `/api/stars/sync` | Re-sync from GitHub starred list (ETag + HTML scrape) | ~seconds per user | 2026-08-02 |
| D1 `user_projects` | irreplaceable-user | Project connection UI/API | Reconnect public GitHub repositories manually | ~seconds per user | 2026-08-08 |
| D1 `user_lists`, `user_repo_lists` | irreplaceable-user | User UI actions | Not reconstructable — user-curated collections | n/a — export required | 2026-08-02 |
-| D1 `comments`, `likes`, `comment_votes` | irreplaceable-user | User UI actions | Not reconstructable — user-generated content | n/a — export required | 2026-08-02 |
+| D1 `comments`, `likes`, `comment_votes` | historical inactive user storage | Removed discussion UI; legacy user actions | Not reconstructable — retained for export until a separately approved retention migration | n/a — export required | 2026-08-09 |
| D1 `repo_embeddings` hashes + Vectorize `starboard-repos` values | derived | Worker binding embedding jobs | Re-embed from `repos` + `repo_ai_metadata` text via Workers AI | ~minutes (Workers AI quota) | 2026-08-02 |
| D1 `repo_ai_metadata` | derived | `scripts/enrich-repos.ts` (free-ai gateway) | Re-enrich from `repos` metadata via AI | ~minutes per batch | 2026-08-02 |
| D1 `repo_tools` | derived | `scripts/enrich-tools.ts` | Re-detect from GH tree/manifest/SBOM | ~minutes per batch | 2026-08-02 |
diff --git a/docs/product/features.md b/docs/product/features.md
index 9462bf2..52c21d9 100644
--- a/docs/product/features.md
+++ b/docs/product/features.md
@@ -5,9 +5,10 @@ reasons, see [../architecture/decisions/](../architecture/decisions/).
## Project-aware discovery
-- Guests can preview a public GitHub repository before sign-in. Cataloged
- repositories use stored evidence; uncataloged repositories use one public
- GitHub metadata lookup. Preview creates no user-owned row.
+- Guests can preview a cataloged public GitHub repository before sign-in using
+ stored evidence. An uncataloged lookup requires the user's existing GitHub
+ session token so anonymous traffic cannot exhaust a shared quota. Preview
+ creates no user-owned row in either case.
- Authenticated users can connect a public GitHub repository by URL or
`owner/repository`, or choose from a bounded on-demand list of public GitHub
repositories, without broadening the current OAuth scope.
@@ -27,10 +28,13 @@ reasons, see [../architecture/decisions/](../architecture/decisions/).
## Public discovery and tool intelligence
-- Discover is public and supports search, pagination, language facets, detected
- tool facets, and stored 30-day growth ordering.
+- Discover is public and supports hybrid semantic-plus-lexical relevance
+ search with lexical fallback, pagination, language facets, detected-tool
+ facets, and stored 30-day growth ordering.
- Tool Intelligence aggregates normalized `repo_tools` records across the
seeded corpus and, for authenticated users, the personal library.
+- Tool evidence is paginated in bounded 48-repository pages and filtered on the
+ server, avoiding a 500-card initial render.
- Tool detections preserve category, confidence, and source evidence. The UI
states that detection is evidence-based but not guaranteed complete.
- Repository detail pages include tool evidence, similar repositories, and
@@ -59,8 +63,9 @@ reasons, see [../architecture/decisions/](../architecture/decisions/).
- Daily and manually dispatched seed, metadata-enrichment, tool-enrichment, and
embedding jobs with explicit per-run bounds.
- Additive ordered D1 migrations; raw SQL with no ORM.
-- Vitest unit/integration coverage, Playwright path, Biome checks, docs
- validation, and OpenNext Cloudflare builds.
+- Selective core-logic Vitest coverage, Playwright journeys against the actual
+ Astro-overlaid Cloudflare preview, Biome checks, docs validation, and
+ OpenNext Cloudflare builds.
## Deliberately removed
diff --git a/docs/product/overview.md b/docs/product/overview.md
index 3360043..6faf1bf 100644
--- a/docs/product/overview.md
+++ b/docs/product/overview.md
@@ -7,8 +7,10 @@ developer's public GitHub projects to a seeded open-source repository catalog,
then explains which repositories and tools may fit the work being built.
Similar repositories form the grounding layer: tool recommendations come only
from normalized detections on those peers and name their repository sources.
-Guests can preview this value with a public repository before sign-in; a durable
-project connection remains an explicit authenticated action.
+Guests can preview this value for repositories already in the public catalog.
+An uncataloged repository lookup uses the user's existing authenticated GitHub
+session rather than a shared anonymous quota. A durable project connection
+remains a separate explicit action.
The product also keeps a personal starred-repository library with search,
lists, tags, saved state, and notes. Discover and Tool Intelligence remain
diff --git a/docs/product/surfaces.md b/docs/product/surfaces.md
index e93ff3d..0b9b965 100644
--- a/docs/product/surfaces.md
+++ b/docs/product/surfaces.md
@@ -5,7 +5,7 @@
| Route | Purpose | Access |
| --- | --- | --- |
| `/` | Product landing page | Public |
-| `/project-preview` | Read-only project-context preview before sign-in | Public |
+| `/project-preview` | Read-only project-context preview; catalog hits are public and uncataloged lookups require GitHub sign-in | Conditional public |
| `/discover` | Search and filter the seeded repository corpus | Public |
| `/explore/[...slug]` | Repository detail, tool evidence, history, and similar repos | Public read; signed-in actions |
| `/tools` | Aggregate tool intelligence | Public |
diff --git a/e2e/landing-mobile.spec.ts b/e2e/landing-mobile.spec.ts
index 0fb2c92..386e2da 100644
--- a/e2e/landing-mobile.spec.ts
+++ b/e2e/landing-mobile.spec.ts
@@ -1,30 +1,24 @@
import { expect, test } from '@playwright/test';
-/**
- * Mobile-viewport smoke test for the public landing page.
- *
- * Runs under both the `desktop` and `mobile` Playwright projects (see
- * playwright.config.ts). The `mobile` project uses a 390px iPhone 13
- * viewport — the fleet mobile target — so layout regressions there fail CI.
- *
- * The primary signed-in flow (the virtualized repo grid) is verified manually
- * against the mobile conventions doc.
- */
-test.describe('landing page', () => {
+test.describe('production Astro landing page', () => {
test('renders the hero and key sections with no horizontal scroll', async ({ page }) => {
await page.goto('/');
- // Hero value prop is visible.
await expect(
- page.getByRole('heading', { name: 'GitHub stars, ranked for the work you ship.', level: 1 })
+ page.getByRole('heading', {
+ name: 'Start with your project. Find the tools that fit it.',
+ level: 1,
+ })
+ ).toBeVisible();
+ await expect(
+ page.getByText(/paste a public github repository\. starboard finds/i)
).toBeVisible();
- await expect(page.getByText(/connect a public github project/i)).toBeVisible();
- // The single primary CTA is reachable.
- await expect(page.getByRole('button', { name: /continue with github/i })).toBeVisible();
+ const repository = page.getByRole('textbox', { name: /public github repository/i });
+ await expect(repository).toBeVisible();
+ await expect(page.getByRole('button', { name: /preview project/i })).toBeVisible();
- // The guest alternative must lead to a genuinely public surface.
- await expect(page.getByRole('link', { name: /browse public repos/i })).toHaveAttribute(
+ await expect(page.getByRole('link', { name: /browse the public catalog/i })).toHaveAttribute(
'href',
'/discover'
);
@@ -38,10 +32,17 @@ test.describe('landing page', () => {
test('the primary CTA is a large enough touch target', async ({ page }) => {
await page.goto('/');
- const cta = page.getByRole('button', { name: /continue with github/i });
+ const cta = page.getByRole('button', { name: /preview project/i });
const box = await cta.boundingBox();
expect(box).not.toBeNull();
- // WCAG 2.5.5 / iOS HIG: tap targets must be at least 44x44px.
expect(box!.height).toBeGreaterThanOrEqual(44);
});
+
+ test('submits a repository through the public preview route', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('textbox', { name: /public github repository/i }).fill('acme/store');
+ await page.getByRole('button', { name: /preview project/i }).click();
+
+ await expect(page).toHaveURL(/\/project-preview\?repository=acme%2Fstore$/);
+ });
});
diff --git a/e2e/public-app.spec.ts b/e2e/public-app.spec.ts
new file mode 100644
index 0000000..84cb694
--- /dev/null
+++ b/e2e/public-app.spec.ts
@@ -0,0 +1,229 @@
+import { expect, type Page, test } from '@playwright/test';
+
+const repo = {
+ id: 1,
+ name: 'starboard',
+ full_name: 'codevetter/starboard',
+ owner_login: 'codevetter',
+ owner_avatar: 'https://avatars.githubusercontent.com/u/1?v=4',
+ owner: { login: 'codevetter', avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' },
+ html_url: 'https://github.com/codevetter/starboard',
+ description: 'Project-aware open-source discovery and tool intelligence.',
+ language: 'TypeScript',
+ stargazers_count: 12_500,
+ archived: false,
+ topics: ['github', 'discovery'],
+ repo_created_at: '2026-01-01T00:00:00Z',
+ repo_updated_at: '2026-08-01T00:00:00Z',
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+ list_id: null,
+ collection_ids: [],
+ tags: [],
+ notes: null,
+ starred_at: null,
+ is_starred: false,
+ is_saved: false,
+ star_growth_30d: 420,
+};
+
+async function mockGuestSession(page: Page) {
+ await page.route('**/api/auth/session', (route) =>
+ route.fulfill({ status: 200, contentType: 'application/json', body: 'null' })
+ );
+}
+
+test.beforeEach(async ({ page }) => {
+ await mockGuestSession(page);
+});
+
+test('Discover keeps the first search request alive and shows matching repositories', async ({
+ page,
+}) => {
+ await page.route('**/discover/data**', (route) =>
+ route.fulfill({
+ json: {
+ repos: [repo],
+ total: 1,
+ facets: {
+ languages: [['TypeScript', 1]],
+ lists: [],
+ tags: [],
+ tools: [{ key: 'nextjs', name: 'Next.js', count: 1 }],
+ },
+ minStars: 5000,
+ },
+ })
+ );
+
+ await page.goto('/discover');
+ await page.getByPlaceholder('Search repos...').fill('tool intelligence');
+
+ await expect(page.getByRole('link', { name: /codevetter\/starboard/i })).toBeVisible();
+ await expect(page.getByText(/couldn't load discover results/i)).toHaveCount(0);
+ await expect(page).toHaveURL(/q=tool\+intelligence|q=tool%20intelligence/);
+
+ if ((page.viewportSize()?.width ?? 0) >= 1024) {
+ const repositoryCount = page.getByRole('button', {
+ name: /how discover selects repositories/i,
+ });
+ await repositoryCount.hover();
+ await expect(
+ page.getByText(/discover starts with public github repositories at 5,000\+ stars/i)
+ ).toBeVisible();
+ }
+});
+
+test('tool intelligence renders one bounded page and loads more on demand', async ({ page }) => {
+ await page.route('**/api/tools?**', async (route) => {
+ const url = new URL(route.request().url());
+ const offset = Number(url.searchParams.get('offset') ?? 0);
+ const count = offset === 0 ? 48 : 1;
+ const repos = Array.from({ length: count }, (_, index) => {
+ const id = offset + index + 1;
+ return {
+ ...repo,
+ id,
+ name: `repo-${id}`,
+ full_name: `acme/repo-${id}`,
+ html_url: `https://github.com/acme/repo-${id}`,
+ tool: {
+ toolKey: 'vitest',
+ toolName: 'Vitest',
+ category: 'testing',
+ url: 'https://vitest.dev',
+ confidence: 96,
+ sources: ['package.json'],
+ },
+ };
+ });
+ await route.fulfill({
+ json: {
+ scope: 'discover',
+ disclaimer: 'Detections come from repository manifests and may need verification.',
+ tool: {
+ toolKey: 'vitest',
+ toolName: 'Vitest',
+ category: 'testing',
+ url: 'https://vitest.dev',
+ repoCount: 49,
+ avgConfidence: 96,
+ maxConfidence: 100,
+ },
+ repos,
+ page: { offset, limit: 48, hasMore: offset === 0 },
+ },
+ });
+ });
+
+ await page.goto('/tools/vitest');
+ await expect(page.getByRole('heading', { name: 'Vitest', level: 1 })).toBeVisible();
+ await expect(page.getByText('Showing 48 of 49 matching repositories.')).toBeVisible();
+ await expect(page.getByRole('link', { name: /^acme\/repo-/ })).toHaveCount(48);
+
+ await page.getByRole('button', { name: 'Load 48 more' }).click();
+ await expect(page.getByText('Showing 49 of 49 matching repositories.')).toBeVisible();
+ await expect(page.getByRole('link', { name: /^acme\/repo-/ })).toHaveCount(49);
+});
+
+test('repository intelligence reuses the app shell and omits discussion controls', async ({
+ page,
+}, testInfo) => {
+ await page.route('**/api/repos/lookup?**', (route) => route.fulfill({ json: { repo } }));
+ await page.route('**/api/repos/1/similar?**', (route) =>
+ route.fulfill({
+ json: {
+ similar: [
+ {
+ ...repo,
+ id: 2,
+ name: 'peer',
+ full_name: 'acme/peer',
+ html_url: 'https://github.com/acme/peer',
+ similarity: 0.88,
+ },
+ ],
+ },
+ })
+ );
+ await page.route('**/api/repos/1/tools', (route) =>
+ route.fulfill({
+ json: {
+ disclaimer: 'Manifest evidence',
+ tools: [
+ {
+ toolKey: 'vitest',
+ toolName: 'Vitest',
+ category: 'testing',
+ url: 'https://vitest.dev',
+ confidence: 96,
+ sources: ['package.json'],
+ },
+ ],
+ },
+ })
+ );
+ await page.route('**/api/repos/1/star-history?**', (route) =>
+ route.fulfill({
+ json: {
+ points: [
+ { stargazersCount: 12_000, capturedAt: '2026-07-01T00:00:00Z' },
+ { stargazersCount: 12_500, capturedAt: '2026-08-01T00:00:00Z' },
+ ],
+ growth: { starsGained: 500, percentGrowth: 4.2, enoughHistory: true },
+ },
+ })
+ );
+
+ await page.goto('/explore/codevetter/starboard');
+ await expect(page.getByRole('heading', { name: 'Repository Intelligence' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'starboard', level: 1 })).toBeVisible();
+ if ((page.viewportSize()?.width ?? 0) < 640) {
+ await expect(page.getByRole('button', { name: 'Open product navigation' })).toBeVisible();
+ } else {
+ await expect(page.getByRole('link', { name: 'Discover', exact: true })).toBeVisible();
+ }
+ await expect(page.getByRole('heading', { name: 'Similar projects' })).toBeVisible();
+ await expect(page.getByText('Discussion', { exact: true })).toHaveCount(0);
+
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth
+ );
+ expect(overflow).toBe(false);
+
+ if (!process.env.CI && testInfo.project.name === 'desktop') {
+ for (const width of [390, 768, 1440]) {
+ await page.setViewportSize({ width, height: 900 });
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth
+ )
+ ).toBe(false);
+ await page.screenshot({
+ path: `.fleet/evidence/core-discovery-hardening/after-${width}.png`,
+ fullPage: true,
+ });
+ }
+ }
+});
+
+test('uncataloged public preview asks for sign-in without presenting a rate-limit failure', async ({
+ page,
+}) => {
+ await page.route('**/api/project-preview?**', (route) =>
+ route.fulfill({
+ status: 401,
+ json: {
+ error: 'Sign in to preview a repository that is not in the Starboard catalog yet.',
+ loginRequired: true,
+ },
+ })
+ );
+
+ await page.goto('/project-preview?repository=acme%2Fprivate-to-catalog');
+ await expect(page.locator('section[role="alert"]')).toContainText(
+ 'Sign in to preview a repository that is not in the Starboard catalog yet.'
+ );
+ await expect(page.getByRole('link', { name: 'Sign in to preview' })).toBeVisible();
+ await expect(page.getByText(/rate limit/i)).toHaveCount(0);
+});
diff --git a/landing-astro/src/pages/index.astro b/landing-astro/src/pages/index.astro
index 1f9b159..e0fd552 100644
--- a/landing-astro/src/pages/index.astro
+++ b/landing-astro/src/pages/index.astro
@@ -82,7 +82,7 @@ const DESCRIPTION =
- No sign-in to preview · public repositories only · nothing saved until you connect
+ Catalog matches preview without sign-in · uncataloged repositories use your signed-in GitHub session · nothing saved until you connect
@@ -194,7 +194,7 @@ const DESCRIPTION =
1
Preview a project
-
Paste any public GitHub repository. No account or write happens yet.
+
Paste a public GitHub repository. Catalog matches need no account; new lookups use your signed-in GitHub session. Nothing is saved yet.
2
diff --git a/next.config.ts b/next.config.ts
index d4e8e3f..296777a 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -16,7 +16,7 @@ const securityHeaders = [
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://us-assets.i.posthog.com", // unsafe-inline/eval required by Next.js
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://avatars.githubusercontent.com https://github.com",
- "connect-src 'self' https://api.github.com https://us.i.posthog.com https://us-assets.i.posthog.com",
+ "connect-src 'self' https://api.github.com https://us.i.posthog.com https://us-assets.i.posthog.com https://vitals.fleet.workers.dev",
"frame-ancestors 'none'",
].join('; '),
},
diff --git a/openspec/changes/prove-project-recommendation-value/.openspec.yaml b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/.openspec.yaml
similarity index 100%
rename from openspec/changes/prove-project-recommendation-value/.openspec.yaml
rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/.openspec.yaml
diff --git a/openspec/changes/prove-project-recommendation-value/design.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/design.md
similarity index 72%
rename from openspec/changes/prove-project-recommendation-value/design.md
rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/design.md
index 171ad66..bfff22f 100644
--- a/openspec/changes/prove-project-recommendation-value/design.md
+++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/design.md
@@ -66,11 +66,13 @@ not depend on Workers AI availability.
The static landing submits a GET form to `/project-preview`. The Next.js preview
surface calls a public GET API with the normalized public repository value.
-Resolution checks the local catalog first; only a cache miss calls GitHub's
-single-repository endpoint, with bounded caching and no token for guests. The
-response never writes `user_projects` or other user-owned tables. The connect
-CTA signs in and returns to `/projects?repository=...`, where the manual form is
-prefilled for an explicit final connection.
+Resolution checks the local catalog first. Catalog hits remain public. A cache
+miss no longer calls GitHub anonymously from the shared Worker egress path: an
+authenticated session token may resolve the repository, while a guest receives
+a sign-in handoff that preserves the repository value. The response never
+writes `user_projects` or other user-owned tables. The connect CTA signs in and
+returns to `/projects?repository=...`, where the manual form is prefilled for an
+explicit final connection.
Alternative: connect automatically after OAuth. Rejected because the user
should still see and confirm the durable write boundary.
@@ -104,11 +106,11 @@ dashboard.
- **Workers AI or Vectorize is temporarily unavailable** → Continue through FTS
and language candidates, report the retrieval mode, and never turn the error
into a failed project connection.
-- **Unauthenticated GitHub quota is exhausted or preview is abused** → Resolve
- cataloged repositories without GitHub, cache successful public resolution,
- make exactly one repository request on a miss, and return a retryable state.
- A broader edge rate limiter is explicitly outside this change because it
- previously affected unrelated product requests.
+- **Unauthenticated GitHub quota is exhausted or preview is abused** → Never
+ spend shared anonymous GitHub quota on a guest request. Resolve cataloged
+ repositories locally and require the existing minimal GitHub session for an
+ uncataloged lookup. A broader edge rate limiter remains outside this change
+ because it previously affected unrelated product requests.
- **Minimal OAuth token returns fewer repositories than expected** → Present the
picker as a convenience, label it public-only, and keep manual URL entry
primary and fully functional.
@@ -127,3 +129,33 @@ dashboard.
and responsive visual review before release.
4. Roll back by reverting this change; it adds no schema, dependency, OAuth
scope, secret, or production binding migration.
+
+## Hardening decisions after production audit
+
+### 7. Search requests own only their request lifetime
+
+The SWR fetcher aborts the previous request before installing a new controller.
+Filter effects reset pagination but never abort the request that SWR just
+started. Discover fuses bounded Vectorize matches with FTS matches and degrades
+to lexical results when Workers AI or Vectorize is unavailable.
+
+### 8. Recommendation evidence has an admission floor
+
+Primary-language equality remains contextual evidence but cannot admit a peer
+by itself. A peer needs a meaningful shared topic, exact tool, tool-area, or
+multiple contextual signals. Tool additions exclude language metadata and
+low-confidence detections, and need corroboration from at least two admitted
+peers.
+
+### 9. Large evidence collections paginate at the API boundary
+
+Tool detail requests use a small bounded page with offset metadata and an
+explicit Load more action. Filtering is server-side so pagination does not make
+search incomplete. The DOM therefore grows only when the user asks for more.
+
+### 10. Core product routes share one shell
+
+Repository detail uses `AppShell` and `TopBar`, exposes a real main landmark and
+page heading, and returns guests to Discover. The discussion UI is removed so
+the surface focuses on repository evidence, similar projects, and detected
+tools; existing database records are preserved.
diff --git a/openspec/changes/prove-project-recommendation-value/proposal.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/proposal.md
similarity index 78%
rename from openspec/changes/prove-project-recommendation-value/proposal.md
rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/proposal.md
index 56b6238..54c3946 100644
--- a/openspec/changes/prove-project-recommendation-value/proposal.md
+++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/proposal.md
@@ -25,6 +25,17 @@ and make the grounding set representative of the full eligible catalog.
recommendation sentiment. No repository identity or query text is emitted.
- Clarify tool cards with support strength, source confidence, and a direct path
to the existing Tool Intelligence detail surface.
+- Harden the shipped first-value path: prevent client searches from cancelling
+ themselves, combine semantic and lexical Discover candidates, and keep an
+ uncataloged guest preview from consuming shared unauthenticated GitHub quota.
+- Tighten the recommendation contract so primary-language overlap alone cannot
+ admit a peer and language or weak one-source detections cannot become tool
+ recommendations.
+- Paginate repository evidence on Tool Intelligence detail pages and bring the
+ core repository-detail route into the shared application shell.
+- Remove the peripheral repository discussion UI, repair production telemetry,
+ and add browser journeys for the actual Astro landing plus core public app
+ surfaces.
- Update active product/status documentation and add a deterministic retrieval
evaluation fixture for the core recommendation path.
@@ -53,4 +64,4 @@ and make the grounding set representative of the full eligible catalog.
- Reuses the existing D1 full-text indexes, Cloudflare Vectorize binding,
Workers AI embedding adapter, GitHub OAuth token, and UI components.
- Adds no production dependency, billing/entitlement gate, private-repository
- scope, raw-SQL proxy, deployment, or production configuration change.
+ scope, raw-SQL proxy, deployment, secret, or production binding change.
diff --git a/openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/github-project-connections/spec.md
similarity index 100%
rename from openspec/changes/prove-project-recommendation-value/specs/github-project-connections/spec.md
rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/github-project-connections/spec.md
diff --git a/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-discovery/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-discovery/spec.md
new file mode 100644
index 0000000..52a8469
--- /dev/null
+++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-discovery/spec.md
@@ -0,0 +1,44 @@
+## MODIFIED Requirements
+
+### Requirement: Public landing handoff
+
+The Starboard landing page SHALL make a public project preview the primary
+first-value action and SHALL preserve direct public Discover browsing as an
+alternative.
+
+#### Scenario: Guest submits a cataloged public project
+
+- **WHEN** a visitor enters a cataloged GitHub URL or `owner/repository` on the
+ landing page
+- **THEN** the visitor reaches a working read-only project preview without
+ first being required to sign in
+
+#### Scenario: Guest submits an uncataloged public project
+
+- **WHEN** a visitor enters a repository that is not yet in Starboard's catalog
+- **THEN** Starboard preserves the repository and asks for GitHub sign-in before
+ making the external lookup
+
+#### Scenario: Guest follows the public CTA
+
+- **WHEN** a visitor selects the landing-page public browsing action
+- **THEN** the visitor reaches `/discover` and can use the shared corpus
+
+## ADDED Requirements
+
+### Requirement: Public search is stable and meaning-aware
+
+Discover SHALL combine bounded semantic and lexical candidates for relevance
+search and SHALL NOT surface an error merely because its own filter state
+changed.
+
+#### Scenario: Visitor changes a search or filter
+
+- **WHEN** a new first-page request replaces an older Discover request
+- **THEN** Starboard cancels only the stale request and renders the newest result
+ without requiring a manual retry
+
+#### Scenario: Semantic retrieval is unavailable
+
+- **WHEN** Workers AI or Vectorize cannot serve a Discover query
+- **THEN** Starboard returns bounded lexical results and does not fail the page
diff --git a/openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-project-preview/spec.md
similarity index 83%
rename from openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md
rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-project-preview/spec.md
index b93829d..df0dc4e 100644
--- a/openspec/changes/prove-project-recommendation-value/specs/public-project-preview/spec.md
+++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/public-project-preview/spec.md
@@ -20,8 +20,14 @@ preview without creating user-owned data.
#### Scenario: Uncataloged public repository
- **WHEN** a guest previews a valid public repository not yet in the catalog
-- **THEN** Starboard resolves its public GitHub metadata and shows the best
- available explained preview without persisting a project connection
+- **THEN** Starboard asks for the existing minimal GitHub sign-in, preserves the
+ repository value, and performs no anonymous GitHub API lookup
+
+#### Scenario: Signed-in uncataloged public repository
+
+- **WHEN** a signed-in user previews a public repository not yet in the catalog
+- **THEN** Starboard resolves it with the user's existing GitHub token and shows
+ the best available explained preview without persisting a project connection
#### Scenario: Invalid or unavailable repository
diff --git a/openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md
similarity index 76%
rename from openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md
rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md
index 3f84dbc..4d833dc 100644
--- a/openspec/changes/prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md
+++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/specs/repo-tool-intelligence/spec.md
@@ -25,6 +25,17 @@ recommended tool only from normalized detections on those ranked peers.
- **THEN** Starboard recommends those tools with the exact peer repositories
and detection confidence that support the recommendation
+#### Scenario: Only primary language overlaps
+
+- **WHEN** a candidate shares only the connected project's primary language
+- **THEN** Starboard does not admit it as a grounded similar project
+
+#### Scenario: Tool evidence is weak or metadata-only
+
+- **WHEN** a peer detection is repository language metadata, below the grounded
+ confidence floor, or supported by only one peer
+- **THEN** Starboard does not present it as a tool to evaluate
+
#### Scenario: Tool is already used by the connected project
- **WHEN** a peer repository contains a tool already detected in the connected
@@ -66,3 +77,14 @@ not useful without sending repository identity or free-form text to analytics.
- **WHEN** a user selects useful or not useful
- **THEN** Starboard records only recommendation kind, sentiment, rank bucket,
retrieval mode, and aggregate evidence buckets
+
+### Requirement: Tool evidence collections remain bounded
+
+Tool Intelligence SHALL paginate repository evidence and SHALL apply repository
+filtering before pagination.
+
+#### Scenario: A tool appears in hundreds of repositories
+
+- **WHEN** a user opens that tool's detail page
+- **THEN** Starboard renders one bounded page and offers an explicit way to load
+ more without placing the full collection in the DOM
diff --git a/openspec/changes/prove-project-recommendation-value/tasks.md b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/tasks.md
similarity index 61%
rename from openspec/changes/prove-project-recommendation-value/tasks.md
rename to openspec/changes/archive/2026-08-09-prove-project-recommendation-value/tasks.md
index ea51f25..8d8d4d1 100644
--- a/openspec/changes/prove-project-recommendation-value/tasks.md
+++ b/openspec/changes/archive/2026-08-09-prove-project-recommendation-value/tasks.md
@@ -27,4 +27,17 @@
- [x] 5.1 Update current product, status, architecture, route, and analytics documentation; remove remaining shipped-state and digest-era inconsistencies.
- [x] 5.2 Run targeted tests, typecheck, lint, docs checks, strict OpenSpec validation, and the production Cloudflare build.
-- [ ] 5.3 Complete preserve-lane responsive review at 390, 768, and 1440 pixels, critique, polish, audit, detector review, and the design receipt.
+- [x] 5.3 Complete preserve-lane responsive review at 390, 768, and 1440 pixels, critique, polish, audit, detector review, and the design receipt.
+
+## 6. Production-audit hardening
+
+- [x] 6.1 Fix Discover and Library request cancellation and add a regression guard for stable filter changes.
+- [x] 6.2 Fuse bounded semantic and lexical candidates for public Discover with an honest lexical fallback.
+- [x] 6.3 Require meaningful peer evidence, exclude language and weak detections, and require corroboration for grounded tool recommendations.
+- [x] 6.4 Keep catalog previews public while requiring the existing GitHub session token for uncataloged lookups; preserve the post-login repository handoff.
+- [x] 6.5 Add bounded API pagination and server-side filtering to Tool Intelligence repository evidence.
+- [x] 6.6 Move repository detail into the shared shell, repair landmarks/headings/back navigation, and remove the peripheral discussion UI without deleting stored data.
+- [x] 6.7 Prevent the signed-out Projects workspace flash and repair Web Vitals CSP/version observability.
+- [x] 6.8 Expand Playwright coverage to the actual Astro landing and mocked core public journeys at desktop and mobile widths.
+- [x] 6.9 Remove strict-Knip findings and the non-compliant bounce easing advisory.
+- [x] 6.10 Run targeted tests after each lane, then full lint, typecheck, coverage, E2E, docs, strict Knip, Cloudflare build, diff validation, and production-profile browser review.
diff --git a/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md b/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md
deleted file mode 100644
index 902d85a..0000000
--- a/openspec/changes/prove-project-recommendation-value/specs/public-discovery/spec.md
+++ /dev/null
@@ -1,19 +0,0 @@
-## MODIFIED Requirements
-
-### Requirement: Public landing handoff
-
-The Starboard landing page SHALL make a public project preview the primary
-first-value action and SHALL preserve direct public Discover browsing as an
-alternative.
-
-#### Scenario: Guest submits a public project
-
-- **WHEN** a visitor enters a GitHub URL or `owner/repository` on the landing
- page
-- **THEN** the visitor reaches a working read-only project preview without
- first being required to sign in
-
-#### Scenario: Guest follows the public browsing alternative
-
-- **WHEN** a visitor selects the landing-page public browsing action
-- **THEN** the visitor reaches `/discover` and can use the shared corpus
diff --git a/openspec/specs/github-project-connections/spec.md b/openspec/specs/github-project-connections/spec.md
index d1e510b..68ee4f3 100644
--- a/openspec/specs/github-project-connections/spec.md
+++ b/openspec/specs/github-project-connections/spec.md
@@ -4,9 +4,7 @@
Define how developers connect public GitHub repositories as durable project
context for project-aware repository and tool discovery.
-
## Requirements
-
### Requirement: Connect a public GitHub project
Starboard SHALL allow an authenticated user to connect a public GitHub
@@ -88,3 +86,32 @@ entitlement checks.
- **WHEN** an authenticated user connects a supported public repository
- **THEN** the complete project-intelligence workflow is available without a
checkout, plan selection, usage credit, or premium lock
+
+### Requirement: Choose a public repository from GitHub
+
+Starboard SHALL let an authenticated user load and select public repositories
+available through the existing GitHub token while retaining manual URL entry.
+
+#### Scenario: User opens the repository picker
+
+- **WHEN** a signed-in user asks to choose from GitHub
+- **THEN** Starboard fetches a bounded list of public repositories using the
+ existing OAuth token and does not request a broader scope
+
+#### Scenario: Picker is unavailable
+
+- **WHEN** GitHub cannot return the repository list
+- **THEN** Starboard keeps manual public URL entry usable and shows a recovery
+ message for the picker
+
+### Requirement: Privacy-safe project activation measurement
+
+Starboard SHALL measure the project-connection and recommendation activation
+funnel without emitting repository names, repository identifiers, query text,
+or GitHub access tokens.
+
+#### Scenario: Project is connected
+
+- **WHEN** a signed-in user successfully connects a project
+- **THEN** Starboard records the connection source as picker or manual entry
+ without recording repository identity
diff --git a/openspec/specs/public-discovery/spec.md b/openspec/specs/public-discovery/spec.md
index 65771e0..fc2643b 100644
--- a/openspec/specs/public-discovery/spec.md
+++ b/openspec/specs/public-discovery/spec.md
@@ -4,9 +4,7 @@
Define the unauthenticated Starboard discovery experience and the boundary
between public repository browsing and signed-in personalization.
-
## Requirements
-
### Requirement: Public Discover browsing
Starboard SHALL allow unauthenticated visitors to browse, search, sort, filter,
@@ -54,12 +52,26 @@ authenticated while preserving the same public Discover corpus for guests.
### Requirement: Public landing handoff
-The Starboard landing page SHALL link guests directly to the working public
-Discover surface as the alternative to connecting GitHub.
+The Starboard landing page SHALL make a public project preview the primary
+first-value action and SHALL preserve direct public Discover browsing as an
+alternative.
+
+#### Scenario: Guest submits a cataloged public project
+
+- **WHEN** a visitor enters a cataloged GitHub URL or `owner/repository` on the
+ landing page
+- **THEN** the visitor reaches a working read-only project preview without
+ first being required to sign in
+
+#### Scenario: Guest submits an uncataloged public project
+
+- **WHEN** a visitor enters a repository that is not yet in Starboard's catalog
+- **THEN** Starboard preserves the repository and asks for GitHub sign-in before
+ making the external lookup
#### Scenario: Guest follows the public CTA
-- **WHEN** a visitor selects the landing-page public browsing CTA
+- **WHEN** a visitor selects the landing-page public browsing action
- **THEN** the visitor reaches `/discover` and can use the shared corpus
### Requirement: Connected project discovery handoff
@@ -78,3 +90,20 @@ visible.
- **WHEN** a user selects a project recommendation
- **THEN** Starboard opens the existing public repository detail surface
+
+### Requirement: Public search is stable and meaning-aware
+
+Discover SHALL combine bounded semantic and lexical candidates for relevance
+search and SHALL NOT surface an error merely because its own filter state
+changed.
+
+#### Scenario: Visitor changes a search or filter
+
+- **WHEN** a new first-page request replaces an older Discover request
+- **THEN** Starboard cancels only the stale request and renders the newest result
+ without requiring a manual retry
+
+#### Scenario: Semantic retrieval is unavailable
+
+- **WHEN** Workers AI or Vectorize cannot serve a Discover query
+- **THEN** Starboard returns bounded lexical results and does not fail the page
diff --git a/openspec/specs/public-project-preview/spec.md b/openspec/specs/public-project-preview/spec.md
new file mode 100644
index 0000000..cdafc05
--- /dev/null
+++ b/openspec/specs/public-project-preview/spec.md
@@ -0,0 +1,63 @@
+# public-project-preview Specification
+
+## Purpose
+Let a visitor test Starboard's project-aware discovery with public repository
+context before creating an account or persisting a project connection.
+## Requirements
+### Requirement: Preview a public GitHub project before sign-in
+
+Starboard SHALL accept a public GitHub URL or `owner/repository` from an
+unauthenticated visitor and return a bounded, read-only project-intelligence
+preview without creating user-owned data.
+
+#### Scenario: Cataloged public repository
+
+- **WHEN** a guest previews a public repository already present in the catalog
+- **THEN** Starboard uses the cataloged metadata and evidence to show explained
+ similar projects without requiring sign-in
+
+#### Scenario: Uncataloged public repository
+
+- **WHEN** a guest previews a valid public repository not yet in the catalog
+- **THEN** Starboard asks for the existing minimal GitHub sign-in, preserves the
+ repository value, and performs no anonymous GitHub API lookup
+
+#### Scenario: Signed-in uncataloged public repository
+
+- **WHEN** a signed-in user previews a public repository not yet in the catalog
+- **THEN** Starboard resolves it with the user's existing GitHub token and shows
+ the best available explained preview without persisting a project connection
+
+#### Scenario: Invalid or unavailable repository
+
+- **WHEN** the supplied value is invalid, private, missing, or unavailable
+- **THEN** Starboard returns a recovery message that states the accepted public
+ repository format and does not imply private-repository access
+
+### Requirement: Preview preserves an honest connection boundary
+
+Starboard MUST distinguish transient preview context from a durable connected
+project and SHALL require GitHub sign-in before saving the project.
+
+#### Scenario: Guest wants to keep the previewed project
+
+- **WHEN** a guest selects the connect action from a successful preview
+- **THEN** Starboard sends the guest through GitHub sign-in and returns them to
+ Projects with the public repository value ready to connect
+
+#### Scenario: Guest only inspects the preview
+
+- **WHEN** a guest views recommendations and leaves without signing in
+- **THEN** Starboard creates no user project, list, note, or saved-repository row
+
+### Requirement: Preview work is bounded and degradable
+
+Starboard SHALL bound external resolution and recommendation work so a preview
+cannot trigger bulk GitHub or unbounded catalog operations.
+
+#### Scenario: Recommendation infrastructure is partially unavailable
+
+- **WHEN** semantic retrieval or GitHub resolution is temporarily unavailable
+- **THEN** Starboard either uses available catalog evidence or returns a clear
+ retry state without fabricating recommendations
+
diff --git a/openspec/specs/repo-tool-intelligence/spec.md b/openspec/specs/repo-tool-intelligence/spec.md
index 6bb45ae..bb2efa9 100644
--- a/openspec/specs/repo-tool-intelligence/spec.md
+++ b/openspec/specs/repo-tool-intelligence/spec.md
@@ -82,9 +82,21 @@ recommendation context while preserving confidence and evidence limitations.
### Requirement: Similar repositories ground tool recommendations
-The system SHALL rank similar repositories before recommending tools and SHALL
-derive each recommended tool only from normalized detections on those ranked
-peer repositories.
+The system SHALL retrieve candidate peers from the eligible public catalog,
+rank similar repositories before recommending tools, and derive each
+recommended tool only from normalized detections on those ranked peers.
+
+#### Scenario: Semantic and lexical evidence are available
+
+- **WHEN** a project has enough public context for both retrieval paths
+- **THEN** Starboard combines bounded semantic and lexical candidates from the
+ eligible catalog before deterministic evidence reranking
+
+#### Scenario: Semantic retrieval is unavailable
+
+- **WHEN** the semantic index or embedding path cannot serve a project
+- **THEN** Starboard uses bounded lexical and structured catalog candidates and
+ labels any broad fallback honestly
#### Scenario: Peer repositories contain additional tools
@@ -93,6 +105,17 @@ peer repositories.
- **THEN** Starboard recommends those tools with the exact peer repositories
and detection confidence that support the recommendation
+#### Scenario: Only primary language overlaps
+
+- **WHEN** a candidate shares only the connected project's primary language
+- **THEN** Starboard does not admit it as a grounded similar project
+
+#### Scenario: Tool evidence is weak or metadata-only
+
+- **WHEN** a peer detection is repository language metadata, below the grounded
+ confidence floor, or supported by only one peer
+- **THEN** Starboard does not present it as a tool to evaluate
+
#### Scenario: Tool is already used by the connected project
- **WHEN** a peer repository contains a tool already detected in the connected
@@ -104,3 +127,42 @@ peer repositories.
- **WHEN** no project-specific similarity signal exists
- **THEN** Starboard may show broad repository discovery but SHALL NOT present
tools from those repositories as grounded recommendations
+
+### Requirement: Explain retrieval and recommendation strength
+
+Starboard SHALL expose enough non-sensitive context for a user to distinguish a
+specific grounded recommendation from a degraded or broad result.
+
+#### Scenario: Grounded tool is shown
+
+- **WHEN** Starboard recommends a tool from similar repositories
+- **THEN** the interface shows peer support, source confidence, and a direct
+ path to inspect the existing Tool Intelligence detail
+
+#### Scenario: Candidate retrieval degrades
+
+- **WHEN** one retrieval source is unavailable
+- **THEN** the result describes the retrieval mode without claiming a stronger
+ semantic match than the system produced
+
+### Requirement: Privacy-safe recommendation feedback
+
+Starboard SHALL let a user mark repository and tool recommendations useful or
+not useful without sending repository identity or free-form text to analytics.
+
+#### Scenario: User rates a recommendation
+
+- **WHEN** a user selects useful or not useful
+- **THEN** Starboard records only recommendation kind, sentiment, rank bucket,
+ retrieval mode, and aggregate evidence buckets
+
+### Requirement: Tool evidence collections remain bounded
+
+Tool Intelligence SHALL paginate repository evidence and SHALL apply repository
+filtering before pagination.
+
+#### Scenario: A tool appears in hundreds of repositories
+
+- **WHEN** a user opens that tool's detail page
+- **THEN** Starboard renders one bounded page and offers an explicit way to load
+ more without placing the full collection in the DOM
diff --git a/package.json b/package.json
index 10c7c3d..4429d91 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"db:enrich-tools": "tsx scripts/enrich-tools.ts",
"cf:build": "next build --webpack && node scripts/run-inline-critical-css.mjs && opennextjs-cloudflare build --skipNextBuild && opennextjs-cloudflare populateCache local && pnpm --filter ./landing-astro build && node scripts/run-overlay-astro-landing.mjs",
"build:cf": "next build --webpack && node scripts/run-inline-critical-css.mjs && opennextjs-cloudflare build --skipNextBuild && opennextjs-cloudflare populateCache local && pnpm --filter ./landing-astro build && node scripts/run-overlay-astro-landing.mjs",
+ "build:e2e": "next build --webpack && node scripts/run-inline-critical-css.mjs && opennextjs-cloudflare build --skipNextBuild && pnpm --filter ./landing-astro build && node scripts/run-overlay-astro-landing.mjs",
"preview:cf": "pnpm build:cf && opennextjs-cloudflare preview",
"deploy:cf": "pnpm build:cf && opennextjs-cloudflare deploy --tag \"$(git rev-parse HEAD)\"",
"cf:typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts",
diff --git a/playwright.config.ts b/playwright.config.ts
index 4598fd3..2556034 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,37 +1,47 @@
/**
- * Playwright config — desktop + mobile-viewport projects.
- *
- * The `mobile` project uses the iPhone 13 device descriptor (390px wide — the
- * fleet Wave 4 mobile target) so mobile-layout regressions are caught in CI
- * alongside the `desktop` baseline.
- *
- * Run only the mobile project: pnpm exec playwright test --project=mobile
- *
- * NOTE: `@playwright/test` is not yet a dependency. Install it before running:
- * pnpm add -D @playwright/test && pnpm exec playwright install --with-deps
+ * Browser coverage for both production surfaces:
+ * - the Astro landing page that is overlaid at `/` during `build:cf`
+ * - the Next.js application shell and public product journeys
*/
import { defineConfig, devices } from '@playwright/test';
+const appURL = 'http://127.0.0.1:8787';
+const browserPreview =
+ 'pnpm exec wrangler d1 migrations apply starboard-e2e --local --config wrangler.e2e.jsonc && pnpm exec wrangler dev --config wrangler.e2e.jsonc --port 8787 --var AUTH_SECRET:starboard-browser-test-secret-at-least-32-characters --var AUTH_GITHUB_ID:browser-test-client --var AUTH_GITHUB_SECRET:browser-test-secret';
+
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'list',
- use: {
- baseURL: 'http://localhost:3000',
- trace: 'on-first-retry',
- },
+ use: { trace: 'on-first-retry' },
webServer: {
- command: 'pnpm dev',
- url: 'http://localhost:3000',
+ command: process.env.CI ? browserPreview : `pnpm build:e2e && ${browserPreview}`,
+ url: appURL,
reuseExistingServer: !process.env.CI,
- timeout: 120_000,
+ timeout: 240_000,
},
projects: [
- // Desktop baseline.
- { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
- // Mobile-viewport project — iPhone 13 is 390px wide, the mobile target.
- { name: 'mobile', use: { ...devices['iPhone 13'] } },
+ {
+ name: 'desktop',
+ testMatch: /public-app\.spec\.ts/,
+ use: { ...devices['Desktop Chrome'], baseURL: appURL },
+ },
+ {
+ name: 'mobile',
+ testMatch: /public-app\.spec\.ts/,
+ use: { ...devices['iPhone 13'], baseURL: appURL, browserName: 'chromium' },
+ },
+ {
+ name: 'landing-desktop',
+ testMatch: /landing-mobile\.spec\.ts/,
+ use: { ...devices['Desktop Chrome'], baseURL: appURL },
+ },
+ {
+ name: 'landing-mobile',
+ testMatch: /landing-mobile\.spec\.ts/,
+ use: { ...devices['iPhone 13'], baseURL: appURL, browserName: 'chromium' },
+ },
],
});
diff --git a/public/api-ai.json b/public/api-ai.json
index 048b72b..7a10f31 100644
--- a/public/api-ai.json
+++ b/public/api-ai.json
@@ -30,7 +30,7 @@
"url": "https://starboard.codevetter.com/project-preview",
"md": "https://starboard.codevetter.com/project-preview.md",
"kind": "static",
- "description": "Try project-aware recommendations before sign-in without saving data"
+ "description": "Preview cataloged recommendations before sign-in without saving data; uncataloged lookups require GitHub sign-in"
},
{
"id": "tools",
diff --git a/public/index.md b/public/index.md
index 3bb605a..5afad98 100644
--- a/public/index.md
+++ b/public/index.md
@@ -4,7 +4,7 @@ Project-aware GitHub repository discovery and tool intelligence.
## Public without sign-in
-- Preview a public GitHub project without creating an account or saving data
+- Preview a cataloged public GitHub project without creating an account or saving data
- Browse and search the seeded Discover corpus
- Inspect detected tool and framework intelligence
- Read the product history, scope, privacy policy, and terms
diff --git a/public/llms-full.txt b/public/llms-full.txt
index b011225..1b63f40 100644
--- a/public/llms-full.txt
+++ b/public/llms-full.txt
@@ -10,7 +10,7 @@ Project-aware GitHub repository discovery and tool intelligence.
## Public without sign-in
-- Preview a public GitHub project without creating an account or saving data
+- Preview a cataloged public GitHub project without creating an account or saving data
- Browse and search the seeded Discover corpus
- Inspect detected tool and framework intelligence
- Read the product history, scope, privacy policy, and terms
@@ -30,7 +30,7 @@ Project-aware GitHub repository discovery and tool intelligence.
## Product links
- Home: https://starboard.codevetter.com/ — Product
-- Project Preview: https://starboard.codevetter.com/project-preview — Try project-aware recommendations before sign-in
+- Project Preview: https://starboard.codevetter.com/project-preview — Preview cataloged recommendations before sign-in; new lookups require GitHub sign-in
- Discover: https://starboard.codevetter.com/discover — Browse and search the seeded public repository corpus
- Tools: https://starboard.codevetter.com/tools — Detected tool and framework intelligence
- Changelog: https://starboard.codevetter.com/changelog — Verified product history
diff --git a/public/llms.txt b/public/llms.txt
index e2200ed..1d87fd9 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -5,7 +5,7 @@
## Product
- [Home](https://starboard.codevetter.com/): Product
-- [Project Preview](https://starboard.codevetter.com/project-preview): Try project-aware recommendations before sign-in
+- [Project Preview](https://starboard.codevetter.com/project-preview): Preview cataloged recommendations before sign-in; new lookups require GitHub sign-in
- [Discover](https://starboard.codevetter.com/discover): Browse and search the seeded public repository corpus
- [Tools](https://starboard.codevetter.com/tools): Detected tool and framework intelligence
- [Changelog](https://starboard.codevetter.com/changelog): Verified product history
diff --git a/public/project-preview.md b/public/project-preview.md
index 979f782..be21d8c 100644
--- a/public/project-preview.md
+++ b/public/project-preview.md
@@ -1,13 +1,14 @@
# Public Project Preview
Paste a public GitHub URL or `owner/repository` at
- to inspect a bounded sample of
-similar projects and peer-grounded tools before sign-in.
+ to inspect a bounded sample
+of similar projects and peer-grounded tools. Cataloged repositories preview
+before sign-in; uncataloged lookups use the user's existing GitHub session.
The preview checks Starboard's public catalog first and may resolve one public
-GitHub repository on a catalog miss. It creates no project connection, list,
-note, or saved repository. GitHub sign-in is required only when the visitor
-chooses to connect the project.
+GitHub repository on an authenticated catalog miss. It creates no project
+connection, list, note, or saved repository. Connecting remains a separate
+explicit action.
Recommendations combine available semantic, catalog text, and language evidence
before deterministic reranking. Degraded and broad-fallback states are labeled.
diff --git a/src/__tests__/abortable-fetch.test.ts b/src/__tests__/abortable-fetch.test.ts
new file mode 100644
index 0000000..65ae7c9
--- /dev/null
+++ b/src/__tests__/abortable-fetch.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import { replaceAbortableJsonRequest } from '@/lib/abortable-fetch';
+
+describe('replaceAbortableJsonRequest', () => {
+ it('aborts only the stale request and leaves the newest request alive', async () => {
+ const pending: Array<{
+ signal: AbortSignal;
+ resolve: (value: { ok: boolean; json: () => Promise }) => void;
+ }> = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(
+ (_url: string, init?: RequestInit) =>
+ new Promise((resolve) => pending.push({ signal: init!.signal as AbortSignal, resolve }))
+ )
+ );
+ const ref = { current: null as AbortController | null };
+
+ const first = replaceAbortableJsonRequest(ref, '/first');
+ const second = replaceAbortableJsonRequest<{ page: number }>(ref, '/second');
+
+ expect(pending[0].signal.aborted).toBe(true);
+ expect(pending[1].signal.aborted).toBe(false);
+
+ pending[1].resolve({ ok: true, json: async () => ({ page: 2 }) });
+ await expect(second).resolves.toEqual({ page: 2 });
+ expect(ref.current).toBeNull();
+
+ pending[0].resolve({ ok: true, json: async () => ({ page: 1 }) });
+ await expect(first).resolves.toEqual({ page: 1 });
+ });
+});
diff --git a/src/__tests__/discover-api.test.ts b/src/__tests__/discover-api.test.ts
index 2233476..0151375 100644
--- a/src/__tests__/discover-api.test.ts
+++ b/src/__tests__/discover-api.test.ts
@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
auth: vi.fn(),
+ embed: vi.fn(),
execute: vi.fn(),
batch: vi.fn(),
+ vectorQuery: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({ auth: mocks.auth }));
@@ -14,6 +16,10 @@ vi.mock('@/db', () => ({
batch: mocks.batch,
},
}));
+vi.mock('@/lib/embeddings', () => ({ generateEmbeddings: mocks.embed }));
+vi.mock('@/lib/repo-vectors', () => ({
+ repoVectors: () => ({ query: mocks.vectorQuery }),
+}));
import { GET } from '@/app/api/discover/route';
@@ -21,6 +27,8 @@ describe('GET /api/discover', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.auth.mockResolvedValue({ user: { githubId: 'user-1' } });
+ mocks.embed.mockResolvedValue([[0.1, 0.2]]);
+ mocks.vectorQuery.mockResolvedValue([]);
mocks.execute.mockResolvedValue({
rows: [
{
@@ -118,6 +126,36 @@ describe('GET /api/discover', () => {
expect(mocks.batch).not.toHaveBeenCalled();
});
+ it('fuses semantic candidates ahead of lexical-only matches for relevance search', async () => {
+ mocks.vectorQuery.mockResolvedValue([{ repoId: 2, distance: 0.2 }]);
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/discover?q=vector%20database&sort=relevance')
+ );
+
+ expect(response.status).toBe(200);
+ expect(mocks.embed).toHaveBeenCalledWith(['vector database vector database']);
+ const mainQuery = mocks.execute.mock.calls[1]?.[0] as { sql: string; args: unknown[] };
+ expect(mainQuery.sql).toContain('CASE r.id WHEN 2 THEN 0 WHEN 1 THEN 1');
+ expect(mainQuery.args).toContain(JSON.stringify([2, 1]));
+ });
+
+ it('degrades to lexical Discover results when semantic retrieval fails', async () => {
+ mocks.embed.mockRejectedValueOnce(new Error('AI unavailable'));
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+
+ const response = await GET(new NextRequest('http://localhost/api/discover?q=database'));
+
+ expect(response.status).toBe(200);
+ const mainQuery = mocks.execute.mock.calls[1]?.[0] as { sql: string; args: unknown[] };
+ expect(mainQuery.args).toContain(JSON.stringify([1]));
+ expect(warn).toHaveBeenCalledWith(
+ 'Discover semantic retrieval unavailable; using lexical search',
+ expect.any(Error)
+ );
+ warn.mockRestore();
+ });
+
// Regression guard: the eligibility filter must use the index-friendly
// IN (UNION) form, not the OR EXISTS correlated subquery that forced
// O(|repos| × |user_repos|) row reads per request (the 500M-row burn).
diff --git a/src/__tests__/project-activation-ui.test.ts b/src/__tests__/project-activation-ui.test.ts
index 0e7b36f..15a935d 100644
--- a/src/__tests__/project-activation-ui.test.ts
+++ b/src/__tests__/project-activation-ui.test.ts
@@ -12,7 +12,8 @@ describe('project-first activation wiring', () => {
expect(landing).toContain('action="/project-preview"');
expect(landing).toContain('name="repository"');
- expect(landing).toContain('No sign-in to preview');
+ expect(landing).toContain('Catalog matches preview without sign-in');
+ expect(landing).toContain('uncataloged repositories use your signed-in GitHub session');
expect(landing).not.toContain('Library · this week');
expect(landing).not.toContain('>Hot<');
expect(landing).not.toContain('>Watch<');
@@ -41,6 +42,27 @@ describe('project-first activation wiring', () => {
expect(topBar).toContain('className="size-11 shrink-0 sm:hidden"');
});
+ it('reuses the shared shell and navigation across discovery surfaces', () => {
+ const discover = source('src/app/discover/discover-client.tsx');
+ const discoverLayout = source('src/app/discover/layout.tsx');
+ const tools = source('src/app/tools/page.tsx');
+ const toolDetail = source('src/app/tools/[toolKey]/page.tsx');
+ const toolsLayout = source('src/app/tools/layout.tsx');
+ const topBar = source('src/components/top-bar.tsx');
+
+ expect(discoverLayout).toContain('');
+ expect(toolsLayout).toContain('');
+ expect(tools).toContain(' {
const preview = source('src/components/project-preview-workspace.tsx');
const login = source('src/app/login/page.tsx');
diff --git a/src/__tests__/project-preview-api.test.ts b/src/__tests__/project-preview-api.test.ts
index e040c33..1c0e57b 100644
--- a/src/__tests__/project-preview-api.test.ts
+++ b/src/__tests__/project-preview-api.test.ts
@@ -2,18 +2,21 @@ import { NextRequest } from 'next/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
+ auth: vi.fn(),
resolve: vi.fn(),
retrieve: vi.fn(),
}));
vi.mock('@/lib/project-preview', () => ({ resolveProjectPreview: mocks.resolve }));
vi.mock('@/lib/project-intelligence', () => ({ retrieveProjectIntelligence: mocks.retrieve }));
+vi.mock('@/lib/auth', () => ({ auth: mocks.auth }));
import { GET } from '@/app/api/project-preview/route';
describe('public project preview API', () => {
beforeEach(() => {
vi.clearAllMocks();
+ mocks.auth.mockResolvedValue(null);
mocks.retrieve.mockResolvedValue({
similarProjects: [],
recommendedTools: [],
@@ -54,4 +57,26 @@ describe('public project preview API', () => {
expect(payload).toMatchObject({ project, source: 'catalog', retrieval: { mode: 'hybrid' } });
expect(mocks.retrieve).toHaveBeenCalledWith(project, 12);
});
+
+ it('does not spend anonymous GitHub quota on an uncataloged guest preview', async () => {
+ mocks.resolve.mockResolvedValue({ status: 'auth-required' });
+
+ const response = await GET(
+ new NextRequest('http://localhost/api/project-preview?repository=acme%2Fnew-repo')
+ );
+
+ expect(response.status).toBe(401);
+ expect(await response.json()).toMatchObject({ loginRequired: true });
+ expect(mocks.resolve).toHaveBeenCalledWith('acme/new-repo', undefined);
+ expect(mocks.retrieve).not.toHaveBeenCalled();
+ });
+
+ it('passes the existing GitHub token to an uncataloged lookup', async () => {
+ mocks.auth.mockResolvedValue({ accessToken: 'github-token' });
+ mocks.resolve.mockResolvedValue({ status: 'unavailable' });
+
+ await GET(new NextRequest('http://localhost/api/project-preview?repository=acme%2Fmissing'));
+
+ expect(mocks.resolve).toHaveBeenCalledWith('acme/missing', 'github-token');
+ });
});
diff --git a/src/__tests__/project-preview.test.ts b/src/__tests__/project-preview.test.ts
index 65e35e3..3c1a0db 100644
--- a/src/__tests__/project-preview.test.ts
+++ b/src/__tests__/project-preview.test.ts
@@ -49,7 +49,16 @@ describe('public project preview resolution', () => {
expect(fetchProject).not.toHaveBeenCalled();
});
- it('resolves one public GitHub repository on a catalog miss without persisting it', async () => {
+ it('requires authentication before an uncataloged GitHub request', async () => {
+ const execute = vi.fn().mockResolvedValue(result([]));
+ const fetchProject = vi.fn();
+ const resolve = createProjectPreviewResolver({ database: { execute }, fetchProject });
+
+ await expect(resolve('acme/sdk')).resolves.toEqual({ status: 'auth-required' });
+ expect(fetchProject).not.toHaveBeenCalled();
+ });
+
+ it('resolves one public GitHub repository with the existing session token', async () => {
const execute = vi.fn().mockResolvedValue(result([]));
const fetchProject = vi.fn().mockResolvedValue({
id: 2,
@@ -68,12 +77,15 @@ describe('public project preview resolution', () => {
});
const resolve = createProjectPreviewResolver({ database: { execute }, fetchProject });
- await expect(resolve('acme/sdk')).resolves.toMatchObject({
+ await expect(resolve('acme/sdk', 'github-token')).resolves.toMatchObject({
status: 'resolved',
source: 'github',
project: { id: 2, fullName: 'acme/sdk', tools: [] },
});
expect(execute).toHaveBeenCalledTimes(1);
- expect(fetchProject).toHaveBeenCalledTimes(1);
+ expect(fetchProject).toHaveBeenCalledWith(
+ { owner: 'acme', repo: 'sdk', fullName: 'acme/sdk' },
+ 'github-token'
+ );
});
});
diff --git a/src/__tests__/project-recommendations.test.ts b/src/__tests__/project-recommendations.test.ts
index a5fb70c..edec69c 100644
--- a/src/__tests__/project-recommendations.test.ts
+++ b/src/__tests__/project-recommendations.test.ts
@@ -79,17 +79,35 @@ describe('rankProjectRecommendations', () => {
).toEqual([]);
});
+ it('does not admit a peer from primary-language overlap alone', () => {
+ const project = repo({ id: 1, fullName: 'acme/app', language: 'TypeScript' });
+ const unrelated = repo({
+ id: 2,
+ fullName: 'oss/unrelated',
+ language: 'TypeScript',
+ stargazersCount: 100_000,
+ tools: [{ key: 'java', name: 'Java', category: 'language', confidence: 98 }],
+ });
+
+ const result = rankProjectRecommendations(project, [unrelated]);
+
+ expect(result.fallback).toBe(true);
+ expect(result.recommendedTools).toEqual([]);
+ });
+
it('derives new tools from similar repositories with exact provenance', () => {
const project = repo({
id: 1,
fullName: 'acme/webapp',
language: 'TypeScript',
+ topics: ['testing'],
tools: [{ key: 'nextjs', name: 'Next.js', category: 'framework', confidence: 98 }],
});
const peerOne = repo({
id: 2,
fullName: 'oss/peer-one',
language: 'TypeScript',
+ topics: ['testing'],
tools: [
{ key: 'nextjs', name: 'Next.js', category: 'framework', confidence: 96 },
{ key: 'vitest', name: 'Vitest', category: 'testing', confidence: 94 },
@@ -99,6 +117,7 @@ describe('rankProjectRecommendations', () => {
id: 3,
fullName: 'oss/peer-two',
language: 'TypeScript',
+ topics: ['testing'],
tools: [{ key: 'vitest', name: 'Vitest', category: 'testing', confidence: 88 }],
});
@@ -158,6 +177,7 @@ describe('rankProjectRecommendations', () => {
fullName: 'acme/app',
language: 'TypeScript',
topics: ['payments'],
+ tools: [{ key: 'stripe', name: 'Stripe', category: 'payments', confidence: 98 }],
});
const strongest = repo({
id: 2,
@@ -165,17 +185,20 @@ describe('rankProjectRecommendations', () => {
language: 'TypeScript',
topics: ['payments'],
stargazersCount: 1,
+ tools: [{ key: 'stripe', name: 'Stripe', category: 'payments', confidence: 95 }],
});
const popular = repo({
id: 3,
fullName: 'oss/popular',
language: 'TypeScript',
+ topics: ['payments'],
stargazersCount: 100,
});
const alphabetical = repo({
id: 4,
fullName: 'oss/alphabetical',
language: 'TypeScript',
+ topics: ['payments'],
stargazersCount: 100,
});
@@ -186,17 +209,24 @@ describe('rankProjectRecommendations', () => {
).toEqual(['oss/strongest', 'oss/alphabetical', 'oss/popular']);
});
- it('orders grounded tools by peer support, score, and name while deduplicating evidence', () => {
- const project = repo({ id: 1, fullName: 'acme/app', language: 'TypeScript' });
+ it('requires corroborated, confident, non-language tool evidence', () => {
+ const project = repo({
+ id: 1,
+ fullName: 'acme/app',
+ language: 'TypeScript',
+ topics: ['testing'],
+ });
const peerOne = repo({
id: 2,
fullName: 'oss/one',
language: 'TypeScript',
+ topics: ['testing'],
tools: [
{ key: 'alpha', name: 'Alpha', category: 'testing', confidence: 150 },
{ key: 'alpha', name: 'Alpha', category: 'testing', confidence: 150 },
{ key: 'beta', name: 'Beta', category: 'testing', confidence: 90 },
{ key: 'delta', name: 'Delta', category: 'testing', confidence: 90 },
+ { key: 'java', name: 'Java', category: 'language', confidence: 100 },
{ key: '', name: 'Invalid', category: 'testing', confidence: 100 },
],
});
@@ -204,19 +234,64 @@ describe('rankProjectRecommendations', () => {
id: 3,
fullName: 'oss/two',
language: 'TypeScript',
+ topics: ['testing'],
tools: [
- { key: 'alpha', name: 'Alpha', category: 'testing', confidence: -5 },
+ { key: 'alpha', name: 'Alpha', category: 'testing', confidence: 70 },
{ key: 'gamma', name: 'Gamma', category: 'testing', confidence: 10 },
],
});
const tools = rankProjectRecommendations(project, [peerOne, peerTwo]).recommendedTools;
- expect(tools.map((tool) => tool.name)).toEqual(['Alpha', 'Beta', 'Delta', 'Gamma']);
+ expect(tools.map((tool) => tool.name)).toEqual(['Alpha']);
expect(tools[0].supportCount).toBe(2);
expect(tools[0].sources).toHaveLength(2);
});
+ it('does not recommend a competing framework when the project already uses one', () => {
+ const project = repo({
+ id: 1,
+ fullName: 'acme/react-app',
+ topics: ['frontend'],
+ tools: [{ key: 'react', name: 'React', category: 'framework', confidence: 98 }],
+ });
+ const peers = [2, 3].map((id) =>
+ repo({
+ id,
+ fullName: `oss/peer-${id}`,
+ topics: ['frontend'],
+ tools: [{ key: 'angular', name: 'Angular', category: 'framework', confidence: 98 }],
+ })
+ );
+
+ expect(rankProjectRecommendations(project, peers).recommendedTools).toEqual([]);
+ });
+
+ it('orders grounded tools by support, evidence score, then stable name', () => {
+ const project = repo({ id: 1, fullName: 'acme/app', topics: ['testing'] });
+ const peers = [2, 3, 4].map((id) =>
+ repo({
+ id,
+ fullName: `oss/peer-${id}`,
+ topics: ['testing'],
+ tools: [
+ { key: 'zulu', name: 'Zulu', category: 'testing', confidence: 90 },
+ ...(id < 4
+ ? [
+ { key: 'beta', name: 'Beta', category: 'testing', confidence: 90 },
+ { key: 'gamma', name: 'Gamma', category: 'testing', confidence: 80 },
+ { key: 'alpha', name: 'Alpha', category: 'testing', confidence: 80 },
+ ]
+ : []),
+ ],
+ })
+ );
+
+ expect(
+ rankProjectRecommendations(project, peers).recommendedTools.map((tool) => tool.name)
+ ).toEqual(['Zulu', 'Beta', 'Alpha', 'Gamma']);
+ });
+
it('handles empty metadata and clamps the requested peer limit', () => {
const project = repo({ id: 1, fullName: 'acme/empty', name: '' });
const peers = Array.from({ length: 60 }, (_, index) =>
diff --git a/src/__tests__/similar-repos-api.test.ts b/src/__tests__/similar-repos-api.test.ts
new file mode 100644
index 0000000..a8b0587
--- /dev/null
+++ b/src/__tests__/similar-repos-api.test.ts
@@ -0,0 +1,87 @@
+import { NextRequest } from 'next/server';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ auth: vi.fn(),
+ execute: vi.fn(),
+ queryByRepoId: vi.fn(),
+}));
+
+vi.mock('@/lib/auth', () => ({ auth: mocks.auth }));
+vi.mock('@/db', () => ({ db: { execute: mocks.execute } }));
+vi.mock('@/lib/repo-vectors', () => ({
+ repoVectors: () => ({ queryByRepoId: mocks.queryByRepoId }),
+}));
+
+import { GET } from '@/app/api/repos/[repoId]/similar/route';
+
+describe('GET /api/repos/[repoId]/similar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.auth.mockResolvedValue(null);
+ mocks.queryByRepoId.mockResolvedValue([{ repoId: 2, distance: 0.2 }]);
+ mocks.execute
+ .mockResolvedValueOnce({
+ rows: [
+ {
+ name: 'app',
+ full_name: 'acme/app',
+ description: 'A TypeScript commerce application',
+ language: 'TypeScript',
+ topics: '["commerce","nextjs"]',
+ },
+ ],
+ })
+ .mockResolvedValueOnce({
+ rows: [
+ {
+ id: 2,
+ name: 'store',
+ full_name: 'peer/store',
+ owner_login: 'peer',
+ owner_avatar: 'https://example.com/peer.png',
+ html_url: 'https://github.com/peer/store',
+ description: 'A TypeScript commerce platform',
+ language: 'TypeScript',
+ stargazers_count: 12_000,
+ archived: 0,
+ topics: '["commerce"]',
+ repo_updated_at: '2026-08-01T00:00:00Z',
+ },
+ ],
+ });
+ });
+
+ it('serves global similarity to a guest', async () => {
+ const response = await GET(
+ new NextRequest('http://localhost/api/repos/1/similar?scope=global'),
+ { params: Promise.resolve({ repoId: '1' }) }
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ similar: [{ id: 2, full_name: 'peer/store', similarity: 0.8 }],
+ });
+ expect(mocks.queryByRepoId).toHaveBeenCalledWith(1, 100);
+ });
+
+ it('fails closed only when a guest explicitly requests user scope', async () => {
+ const response = await GET(new NextRequest('http://localhost/api/repos/1/similar?scope=user'), {
+ params: Promise.resolve({ repoId: '1' }),
+ });
+
+ expect(response.status).toBe(401);
+ expect(mocks.execute).not.toHaveBeenCalled();
+ expect(mocks.queryByRepoId).not.toHaveBeenCalled();
+ });
+
+ it('keeps the public path available when optional auth lookup fails', async () => {
+ mocks.auth.mockRejectedValueOnce(new Error('Auth unavailable'));
+
+ const response = await GET(new NextRequest('http://localhost/api/repos/1/similar'), {
+ params: Promise.resolve({ repoId: '1' }),
+ });
+
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/src/__tests__/swr-fetcher.test.ts b/src/__tests__/swr-fetcher.test.ts
index ce4cf95..6db115d 100644
--- a/src/__tests__/swr-fetcher.test.ts
+++ b/src/__tests__/swr-fetcher.test.ts
@@ -21,12 +21,14 @@ describe('jsonFetcher', () => {
ok: false,
status: 429,
headers: { get: (name: string) => (name === 'Retry-After' ? '3' : null) },
+ json: async () => ({ error: 'Try again shortly.' }),
})
);
await expect(jsonFetcher('/x')).rejects.toMatchObject({
name: 'FetchHttpError',
status: 429,
retryAfterMs: 3000,
+ message: 'Try again shortly.',
});
});
});
diff --git a/src/__tests__/tools-api.test.ts b/src/__tests__/tools-api.test.ts
new file mode 100644
index 0000000..84441cb
--- /dev/null
+++ b/src/__tests__/tools-api.test.ts
@@ -0,0 +1,89 @@
+import { NextRequest } from 'next/server';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ auth: vi.fn(),
+ execute: vi.fn(),
+}));
+
+vi.mock('@/lib/auth', () => ({ auth: mocks.auth }));
+vi.mock('@/db', () => ({ db: { execute: mocks.execute } }));
+
+import { GET } from '@/app/api/tools/route';
+
+describe('GET /api/tools tool evidence pagination', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.auth.mockResolvedValue(null);
+ mocks.execute
+ .mockResolvedValueOnce({
+ rows: [
+ {
+ tool_key: 'react',
+ tool_name: 'React',
+ category: 'framework',
+ repo_count: 120,
+ avg_confidence: 95,
+ max_confidence: 98,
+ },
+ ],
+ })
+ .mockResolvedValueOnce({
+ rows: [
+ {
+ id: 51,
+ name: 'app',
+ full_name: 'acme/app',
+ owner_login: 'acme',
+ owner_avatar: '',
+ html_url: 'https://github.com/acme/app',
+ description: 'A dashboard',
+ language: 'TypeScript',
+ stargazers_count: 20_000,
+ archived: 0,
+ topics: '[]',
+ repo_created_at: '2025-01-01T00:00:00Z',
+ repo_updated_at: '2026-01-01T00:00:00Z',
+ tool_key: 'react',
+ tool_name: 'React',
+ category: 'framework',
+ confidence: 98,
+ sources: '["package.json"]',
+ },
+ ],
+ });
+ });
+
+ it('filters before applying a bounded limit and offset', async () => {
+ const response = await GET(
+ new NextRequest('http://localhost/api/tools?tool=react&q=TypeScript&limit=500&offset=48')
+ );
+
+ expect(response.status).toBe(200);
+ const resultQuery = mocks.execute.mock.calls[1][0] as { sql: string; args: unknown[] };
+ expect(resultQuery.sql).toContain("LOWER(r.full_name) LIKE ? ESCAPE '\\'");
+ expect(resultQuery.sql).toContain('LIMIT ? OFFSET ?');
+ expect(resultQuery.args.slice(-5)).toEqual([
+ '%typescript%',
+ '%typescript%',
+ '%typescript%',
+ 100,
+ 48,
+ ]);
+
+ expect(await response.json()).toMatchObject({
+ tool: { repoCount: 120 },
+ repos: [{ id: 51 }],
+ page: { offset: 48, limit: 100, hasMore: true },
+ });
+ });
+
+ it('keeps personal tool evidence protected', async () => {
+ mocks.execute.mockReset();
+
+ const response = await GET(new NextRequest('http://localhost/api/tools?tool=react&scope=user'));
+
+ expect(response.status).toBe(401);
+ expect(mocks.execute).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts
index 43676bd..e78a92f 100644
--- a/src/app/api/discover/route.ts
+++ b/src/app/api/discover/route.ts
@@ -3,9 +3,13 @@ import { type NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { auth } from '@/lib/auth';
-import { ftsSearchQuery } from '@/lib/search';
+import { generateEmbeddings } from '@/lib/embeddings';
+import { repoVectors } from '@/lib/repo-vectors';
+import { expandedSearchQuery, ftsSearchQuery, rrfFuse } from '@/lib/search';
const MIN_STARS_FLOOR = 5000;
+const SEMANTIC_TOP_K = 100;
+const SEMANTIC_DISTANCE_MAX = 0.7;
// Index-friendly eligibility filter. The previous form
// (r.stargazers_count >= ? OR EXISTS (SELECT 1 FROM user_repos ...))
// forced SQLite to evaluate a correlated subquery against user_repos for
@@ -51,7 +55,7 @@ export async function GET(request: NextRequest) {
.map((value) => value.trim().toLowerCase())
.filter((value) => /^[a-z0-9][a-z0-9-]{0,63}$/.test(value)) || [];
const listId = params.get('list_id');
- const sort = params.get('sort') || 'stars';
+ const sort = params.get('sort') || (q ? 'relevance' : 'stars');
const limit = Math.min(Math.max(parseInt(params.get('limit') || '50', 10) || 50, 1), 200);
const offset = Math.max(parseInt(params.get('offset') || '0', 10) || 0, 0);
@@ -61,8 +65,8 @@ export async function GET(request: NextRequest) {
let rankedRepoIds: number[] | null = null;
if (q) {
const lexicalQuery = ftsSearchQuery(q);
- const lexIds = lexicalQuery
- ? await db
+ const lexIdsPromise = lexicalQuery
+ ? db
.execute({
sql: `SELECT r.id,
MIN(rank) AS best_rank
@@ -85,12 +89,29 @@ export async function GET(request: NextRequest) {
args: [lexicalQuery, lexicalQuery, MIN_STARS_FLOOR],
})
.then((result) => result.rows.map((r) => r.id as number))
- : [];
+ : Promise.resolve([] as number[]);
- if (lexIds.length > 0) {
- rankedRepoIds = lexIds;
+ const semanticIdsPromise = (async () => {
+ try {
+ const [embedding] = await generateEmbeddings([expandedSearchQuery(q)]);
+ if (!embedding) return [];
+ const matches = await repoVectors().query(embedding, SEMANTIC_TOP_K);
+ return matches
+ .filter((match) => match.distance <= SEMANTIC_DISTANCE_MAX)
+ .map((match) => match.repoId);
+ } catch (error) {
+ console.warn('Discover semantic retrieval unavailable; using lexical search', error);
+ return [];
+ }
+ })();
+
+ const [lexIds, semanticIds] = await Promise.all([lexIdsPromise, semanticIdsPromise]);
+ const searchIds = rrfFuse([semanticIds, lexIds]);
+
+ if (searchIds.length > 0) {
+ rankedRepoIds = searchIds;
whereClauses.push('r.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))');
- whereArgs.push(JSON.stringify(lexIds));
+ whereArgs.push(JSON.stringify(searchIds));
} else {
whereClauses.push('0 = 1');
}
diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts
index afec577..4453d84 100644
--- a/src/app/api/health/route.ts
+++ b/src/app/api/health/route.ts
@@ -1,7 +1,23 @@
+import { getCloudflareContext } from '@opennextjs/cloudflare';
import { NextResponse } from 'next/server';
import { db } from '@/db';
+interface WorkerVersionMetadata {
+ id?: string;
+ tag?: string;
+ timestamp?: string;
+}
+
+function workerVersionMetadata(): WorkerVersionMetadata | null {
+ try {
+ const { env } = getCloudflareContext();
+ return (env as { CF_VERSION_METADATA?: WorkerVersionMetadata }).CF_VERSION_METADATA ?? null;
+ } catch {
+ return null;
+ }
+}
+
/**
* Public health endpoint for the Starboard Cloudflare Worker.
*
@@ -38,7 +54,13 @@ export async function GET() {
console.error('Starboard search health probe failed', err);
}
- const revision = process.env.CF_PAGES_COMMIT_SHA || process.env.NEXT_PUBLIC_REVISION || 'unknown';
+ const version = workerVersionMetadata();
+ const revision =
+ process.env.CF_PAGES_COMMIT_SHA ||
+ process.env.NEXT_PUBLIC_REVISION ||
+ version?.tag ||
+ version?.id ||
+ 'unknown';
const ragConfigured = Boolean(process.env.RAG_SERVICE_KEY && process.env.STARBOARD_RAG_INDEX_ID);
@@ -48,7 +70,7 @@ export async function GET() {
build: {
name: 'starboard',
revision,
- branch: process.env.CF_PAGES_BRANCH ?? 'unknown',
+ branch: process.env.CF_PAGES_BRANCH ?? (version ? 'worker' : 'unknown'),
},
live: true,
revision,
diff --git a/src/app/api/project-preview/route.ts b/src/app/api/project-preview/route.ts
index f8b6507..fffae82 100644
--- a/src/app/api/project-preview/route.ts
+++ b/src/app/api/project-preview/route.ts
@@ -1,13 +1,21 @@
import { type NextRequest, NextResponse } from 'next/server';
import { GitHubProjectApiError } from '@/lib/github-projects';
+import { auth } from '@/lib/auth';
import { retrieveProjectIntelligence } from '@/lib/project-intelligence';
import { resolveProjectPreview } from '@/lib/project-preview';
export async function GET(request: NextRequest) {
const repository = request.nextUrl.searchParams.get('repository') ?? '';
try {
- const resolution = await resolveProjectPreview(repository);
+ let accessToken: string | undefined;
+ try {
+ accessToken = (await auth())?.accessToken;
+ } catch {
+ // Catalog previews are public and must remain available when optional
+ // session hydration is unavailable.
+ }
+ const resolution = await resolveProjectPreview(repository, accessToken);
if (resolution.status === 'invalid') {
return NextResponse.json(
{ error: 'Enter a public GitHub URL or owner/repository.' },
@@ -20,6 +28,15 @@ export async function GET(request: NextRequest) {
{ status: 404 }
);
}
+ if (resolution.status === 'auth-required') {
+ return NextResponse.json(
+ {
+ error: 'Sign in with GitHub to preview a repository outside the public catalog.',
+ loginRequired: true,
+ },
+ { status: 401 }
+ );
+ }
const recommendations = await retrieveProjectIntelligence(resolution.project, 12);
return NextResponse.json(
diff --git a/src/app/api/repos/[repoId]/route.ts b/src/app/api/repos/[repoId]/route.ts
index 097a4c3..a413a5a 100644
--- a/src/app/api/repos/[repoId]/route.ts
+++ b/src/app/api/repos/[repoId]/route.ts
@@ -1,8 +1,6 @@
-import type { InStatement } from '@/db/client';
import { type NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
-import { auth } from '@/lib/auth';
import { resolveRepoId } from '../resolve';
@@ -111,27 +109,6 @@ export async function GET(
const row = repoResult.rows[0];
- const session = await auth();
- const userId = session?.user?.githubId ?? null;
-
- const countQueries: InStatement[] = [
- { sql: 'SELECT COUNT(*) as count FROM likes WHERE repo_id = ?', args: [repoId] },
- { sql: 'SELECT COUNT(*) as count FROM comments WHERE repo_id = ?', args: [repoId] },
- ];
-
- if (userId) {
- countQueries.push({
- sql: 'SELECT 1 as liked FROM likes WHERE user_id = ? AND repo_id = ?',
- args: [userId, repoId],
- });
- }
-
- const batchResults = await db.batch(countQueries);
-
- const likeCount = batchResults[0].rows[0].count as number;
- const commentCount = batchResults[1].rows[0].count as number;
- const userLiked = userId ? batchResults[2].rows.length > 0 : false;
-
return NextResponse.json({
repo: {
id: row.id as number,
@@ -148,9 +125,6 @@ export async function GET(
repo_created_at: row.repo_created_at as string | null,
repo_updated_at: row.repo_updated_at as string | null,
},
- likeCount,
- commentCount,
- userLiked,
});
} catch (error) {
console.error('Failed to fetch repo detail:', error);
diff --git a/src/app/api/repos/[repoId]/similar/route.ts b/src/app/api/repos/[repoId]/similar/route.ts
index 4c789fb..4b697e8 100644
--- a/src/app/api/repos/[repoId]/similar/route.ts
+++ b/src/app/api/repos/[repoId]/similar/route.ts
@@ -34,11 +34,12 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ repoId: string }> }
) {
- const session = await auth();
- if (!session?.user?.githubId) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ let userId: string | null = null;
+ try {
+ userId = (await auth())?.user?.githubId ?? null;
+ } catch {
+ // Global similarity is public. Only the explicit user scope fails closed.
}
- const userId = session.user.githubId;
const { repoId: rawId } = await params;
const repoId = parseInt(rawId, 10);
@@ -49,6 +50,9 @@ export async function GET(
const limitParam = request.nextUrl.searchParams.get('limit');
const limit = Math.min(Math.max(parseInt(limitParam || '', 10) || DEFAULT_LIMIT, 1), 30);
const scope = request.nextUrl.searchParams.get('scope') || 'global'; // "user" | "global"
+ if (scope === 'user' && !userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
try {
// 1. Confirm the relational vector metadata exists and load lightweight
@@ -104,7 +108,7 @@ export async function GET(
FROM user_repos ur
JOIN repos r ON r.id = ur.repo_id
WHERE ur.user_id = ? AND r.id IN (${placeholders})`;
- return db.execute({ sql, args: scope === 'global' ? chunk : [userId, ...chunk] });
+ return db.execute({ sql, args: scope === 'global' ? chunk : [userId!, ...chunk] });
})
);
const hydratedRows = hydratedResults.flatMap((result) => result.rows);
diff --git a/src/app/api/tools/route.ts b/src/app/api/tools/route.ts
index 39d3208..d7751ea 100644
--- a/src/app/api/tools/route.ts
+++ b/src/app/api/tools/route.ts
@@ -20,6 +20,10 @@ function parseScope(value: string | null): ToolScope {
return value === 'user' || value === 'all' ? value : 'discover';
}
+function likePattern(value: string): string {
+ return `%${value.toLowerCase().replace(/[\\%_]/g, '\\$&')}%`;
+}
+
function scopeClause(scope: ToolScope, userId: string | null, minStars: number) {
if (scope === 'user') {
if (!userId) return null;
@@ -66,8 +70,14 @@ export async function GET(request: NextRequest) {
100
);
const minStars = Math.max(parseInt(params.get('min_stars') || '10000', 10) || 10000, 0);
- const limit = Math.min(Math.max(parseInt(params.get('limit') || '80', 10) || 80, 1), 500);
const tool = params.get('tool')?.trim() || null;
+ const limitCeiling = tool ? 100 : 500;
+ const limit = Math.min(
+ Math.max(parseInt(params.get('limit') || '80', 10) || 80, 1),
+ limitCeiling
+ );
+ const offset = Math.max(parseInt(params.get('offset') || '0', 10) || 0, 0);
+ const query = params.get('q')?.trim().slice(0, 120) || null;
const scopeSql = scopeClause(scope, session?.user?.githubId ?? null, minStars);
if (!scopeSql) {
@@ -75,6 +85,14 @@ export async function GET(request: NextRequest) {
}
if (tool) {
+ const querySql = query
+ ? `AND (
+ LOWER(r.full_name) LIKE ? ESCAPE '\\'
+ OR LOWER(COALESCE(r.description, '')) LIKE ? ESCAPE '\\'
+ OR LOWER(COALESCE(r.language, '')) LIKE ? ESCAPE '\\'
+ )`
+ : '';
+ const queryArgs = query ? Array(3).fill(likePattern(query)) : [];
const summary = await db.execute({
sql: `SELECT rt.tool_key,
rt.tool_name,
@@ -89,8 +107,9 @@ export async function GET(request: NextRequest) {
AND rt.confidence >= ?
AND rt.category != 'language'
AND ${scopeSql.where}
+ ${querySql}
GROUP BY rt.tool_key, rt.tool_name, rt.category`,
- args: [...scopeSql.joinArgs, tool, minConfidence, ...scopeSql.whereArgs],
+ args: [...scopeSql.joinArgs, tool, minConfidence, ...scopeSql.whereArgs, ...queryArgs],
});
const summaryRow = summary.rows[0];
const definition = getToolDefinition(tool);
@@ -120,10 +139,20 @@ export async function GET(request: NextRequest) {
AND rt.confidence >= ?
AND rt.category != 'language'
AND ${scopeSql.where}
+ ${querySql}
ORDER BY rt.confidence DESC, r.stargazers_count DESC
- LIMIT ?`,
- args: [...scopeSql.joinArgs, tool, minConfidence, ...scopeSql.whereArgs, limit],
+ LIMIT ? OFFSET ?`,
+ args: [
+ ...scopeSql.joinArgs,
+ tool,
+ minConfidence,
+ ...scopeSql.whereArgs,
+ ...queryArgs,
+ limit,
+ offset,
+ ],
});
+ const repoCount = (summaryRow?.repo_count as number | undefined) ?? 0;
return json({
scope,
@@ -135,7 +164,7 @@ export async function GET(request: NextRequest) {
toolName: (summaryRow?.tool_name as string | undefined) ?? definition?.name ?? tool,
category: (summaryRow?.category as string | undefined) ?? definition?.category ?? 'library',
url: getToolUrl((summaryRow?.tool_key as string | undefined) ?? definition?.key ?? tool),
- repoCount: (summaryRow?.repo_count as number | undefined) ?? 0,
+ repoCount,
avgConfidence: summaryRow ? Math.round(summaryRow.avg_confidence as number) : 0,
maxConfidence: (summaryRow?.max_confidence as number | undefined) ?? 0,
},
@@ -169,6 +198,11 @@ export async function GET(request: NextRequest) {
sources: JSON.parse((row.sources as string) || '[]') as string[],
},
})),
+ page: {
+ offset,
+ limit,
+ hasMore: offset + result.rows.length < repoCount,
+ },
});
}
diff --git a/src/app/discover/discover-client.tsx b/src/app/discover/discover-client.tsx
index 859e638..aeceba4 100644
--- a/src/app/discover/discover-client.tsx
+++ b/src/app/discover/discover-client.tsx
@@ -116,7 +116,9 @@ function DiscoverContent({
const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault(''));
const [sortBy, setSortBy] = useQueryState(
'sort',
- parseAsStringLiteral(sortOptions).withDefault('most-stars')
+ parseAsStringLiteral(sortOptions).withDefault(
+ initialUrl.includes('q=') ? 'relevance' : 'most-stars'
+ )
);
const [selectedLanguages, setSelectedLanguages] = useQueryState(
'lang',
@@ -216,6 +218,17 @@ function DiscoverContent({
setSelectedListId(null);
}, [setSearchQuery, setSelectedLanguages, setSelectedTools, setSelectedListId]);
+ const handleSearchChange = useCallback(
+ (value: string) => {
+ const wasEmpty = searchQuery.trim().length === 0;
+ const isEmpty = value.trim().length === 0;
+ setSearchQuery(value);
+ if (wasEmpty && !isEmpty && sortBy === 'most-stars') setSortBy('relevance');
+ if (!wasEmpty && isEmpty && sortBy === 'relevance') setSortBy('most-stars');
+ },
+ [searchQuery, setSearchQuery, setSortBy, sortBy]
+ );
+
const handleLanguageToggle = useCallback(
(language: string) => {
setSelectedLanguages((prev) =>
@@ -293,7 +306,7 @@ function DiscoverContent({
<>
{
if (sort !== 'recently-starred') setSortBy(sort);
@@ -303,6 +316,7 @@ function DiscoverContent({
onViewModeChange={setViewMode}
onMenuClick={() => setSidebarOpen(true)}
repoCount={total}
+ repoCountDescription="This count shows repositories matching your current search and filters. Discover starts with public GitHub repositories at 5,000+ stars, added and refreshed by a bounded daily job."
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
/>
diff --git a/src/app/discover/layout.tsx b/src/app/discover/layout.tsx
new file mode 100644
index 0000000..1ef186f
--- /dev/null
+++ b/src/app/discover/layout.tsx
@@ -0,0 +1,5 @@
+import { AppShell } from '@/components/app-shell';
+
+export default function DiscoverLayout({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/src/app/explore/[...slug]/page.tsx b/src/app/explore/[...slug]/page.tsx
index c0fbb4d..d36752d 100644
--- a/src/app/explore/[...slug]/page.tsx
+++ b/src/app/explore/[...slug]/page.tsx
@@ -7,11 +7,8 @@ import {
ExternalLink,
GitFork,
Info,
- MessageSquare,
Sparkles,
Star,
- ThumbsDown,
- ThumbsUp,
TrendingUp,
Wrench,
} from 'lucide-react';
@@ -24,6 +21,7 @@ import useSWR from 'swr';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
+import { TopBar } from '@/components/top-bar';
import { useRepoDetail } from '@/hooks/use-repo-detail';
import { useSimilarRepos } from '@/hooks/use-similar-repos';
import { getAvatarImageAttrs } from '@/lib/avatar';
@@ -154,45 +152,51 @@ function MiniStarHistory({ history }: { history: StarHistoryResponse }) {
function PageSkeleton() {
return (
-
+ No detected tools match this scope and filter.
+
+ )}
);
diff --git a/src/components/project-preview-workspace.tsx b/src/components/project-preview-workspace.tsx
index 30c8444..908c97d 100644
--- a/src/components/project-preview-workspace.tsx
+++ b/src/components/project-preview-workspace.tsx
@@ -16,7 +16,7 @@ import { Input } from '@/components/ui/input';
import { trackRecommendationSetViewed, type RecommendationRetrievalMode } from '@/lib/analytics';
import type { ProjectIntelligenceResult } from '@/lib/project-intelligence';
import type { ProjectRecommendationRepo } from '@/lib/project-recommendations';
-import { jsonFetcher } from '@/lib/swr-fetcher';
+import { FetchHttpError, jsonFetcher } from '@/lib/swr-fetcher';
interface PreviewResponse extends ProjectIntelligenceResult {
project: ProjectRecommendationRepo;
@@ -62,6 +62,9 @@ export function ProjectPreviewWorkspace({ initialRepository }: { initialReposito
? `/projects?repository=${encodeURIComponent(data.project.fullName)}`
: '/projects';
const loginHref = `/login?callbackUrl=${encodeURIComponent(connectCallback)}`;
+ const previewLoginHref = `/login?callbackUrl=${encodeURIComponent(
+ `/project-preview?repository=${encodeURIComponent(initialRepository)}`
+ )}`;
return (
@@ -136,7 +139,8 @@ export function ProjectPreviewWorkspace({ initialRepository }: { initialReposito
Start with a repository you know
- Paste a public project to compare it with Starboard's catalog before signing in.
+ Cataloged projects preview immediately. For a new repository, sign in so Starboard can
+ use your GitHub session without spending a shared anonymous quota.