@skyphusion/search-mcp 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # search-mcp
2
+
3
+ [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE)
4
+ [![Typecheck](https://github.com/skyphusion-labs/search-mcp/actions/workflows/typecheck.yml/badge.svg)](https://github.com/skyphusion-labs/search-mcp/actions/workflows/typecheck.yml)
5
+ [![CI](https://github.com/skyphusion-labs/search-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/skyphusion-labs/search-mcp/actions/workflows/ci.yml)
6
+
7
+ Open-source toolkit for [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/):
8
+
9
+ 1. **MCP Worker** (`src/mcp.ts`) -- bearer-gated Streamable-HTTP MCP with a `search` tool for agents.
10
+ 2. **Query Worker** (`src/index.ts`) -- CORS + Turnstile + rate-limited `POST /ask` that streams answers for a browser widget.
11
+ 3. **Corpus sync** (`scripts/sync.mjs`, `scripts/sync-runner.mjs`) -- git-tracked sources to R2, with extension remapping so TypeScript, Dockerfiles, and other text AI Search would otherwise skip get indexed.
12
+
13
+ ```
14
+ git repos -> sync.mjs -> R2 bucket -> AI Search instance -> /ask + /mcp
15
+ ```
16
+
17
+ ## Install (npm)
18
+
19
+ The corpus sync CLIs and ask-widget assets ship on npm as **`@skyphusion/search-mcp`** (the unscoped name `search-mcp` is taken by another project).
20
+
21
+ ```sh
22
+ npm install @skyphusion/search-mcp
23
+ # or run without installing:
24
+ npx --package=@skyphusion/search-mcp search-mcp-sync corpus --dry-run
25
+ ```
26
+
27
+ | Command | Role |
28
+ | --- | --- |
29
+ | `search-mcp-sync` | Upload git-tracked corpus files to R2 for one target |
30
+ | `search-mcp-sync-run` | Clone/fetch repos, sync all targets, optional reindex |
31
+
32
+ Put `targets.json` in your project root (copy from `node_modules/@skyphusion/search-mcp/scripts/targets.json.example`) or set `SEARCH_MCP_TARGETS`. Clone roots default to the current working directory; override with `SYNC_REPO_ROOT`.
33
+
34
+ Widget assets after install:
35
+
36
+ ```sh
37
+ cp node_modules/@skyphusion/search-mcp/public/ask-widget.{js,css} ./docs/
38
+ ```
39
+
40
+ Workers (`src/`) deploy from a git clone; see [docs/DEPLOY.md](docs/DEPLOY.md).
41
+
42
+ ## Quick start (from source)
43
+
44
+ ```sh
45
+ npm install
46
+ cp wrangler.toml.example wrangler.toml
47
+ cp wrangler.mcp.toml.example wrangler.mcp.toml
48
+ cp scripts/targets.json.example scripts/targets.json
49
+ # edit the three files for your account, instance, bucket, and repos
50
+
51
+ npm run typecheck
52
+ npm test
53
+ ```
54
+
55
+ Provision R2 + AI Search, sync your corpus, deploy both Workers. Step-by-step: [docs/DEPLOY.md](docs/DEPLOY.md).
56
+
57
+ ## Skyphusion production
58
+
59
+ This repo is the production home for Skyphusion AI Search (`search.vivijure.com`,
60
+ `search-internal.vivijure.com`). Config is materialized from GitHub Actions secrets at deploy/sync
61
+ time; do not commit `wrangler.toml`, `wrangler.mcp.toml`, or `scripts/targets.json`.
62
+
63
+ - [Operator runbook](docs/skyphusion/OPERATOR.md) -- secrets, topology, bootstrap
64
+ - [Cutover record (2026-07-08)](docs/skyphusion/CUTOVER.md) -- migration history, failure log, archive steps
65
+
66
+ ## Workers
67
+
68
+ | Worker | Entry | Endpoint | Auth |
69
+ | --- | --- | --- | --- |
70
+ | Query | `wrangler.toml` | `POST /ask`, `GET /health` | Turnstile (optional) + CORS allowlist |
71
+ | MCP | `wrangler.mcp.toml` | `POST /mcp`, `GET /health` | `Authorization: Bearer` (fail closed) |
72
+
73
+ Deploy separately so browser traffic and agent traffic can bind different AI Search instances if you want.
74
+
75
+ ```sh
76
+ npm run deploy # query Worker
77
+ npm run deploy:mcp # MCP Worker
78
+ wrangler secret put MCP_TOKEN -c wrangler.mcp.toml
79
+ wrangler secret put TURNSTILE_SECRET # optional; skips verification when unset
80
+ ```
81
+
82
+ ### MCP client wiring
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "search-mcp": {
88
+ "type": "http",
89
+ "url": "https://YOUR_MCP_HOST/mcp",
90
+ "headers": { "Authorization": "Bearer YOUR_TOKEN" }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ `MCP_TOKEN` accepts a single token or comma-separated `name=token` pairs for per-consumer attribution in logs.
97
+
98
+ ## Corpus sync
99
+
100
+ ```sh
101
+ export R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... CLOUDFLARE_ACCOUNT_ID=...
102
+ export CORPUS_GIT_ORG=your-org GITHUB_TOKEN=... # for sync-runner clone auth
103
+
104
+ npm run sync:dry # plan upload for the default `corpus` target
105
+ npm run sync # upload + prune
106
+ npm run sync:run # isolated clone root, sync all targets, optional reindex
107
+ ```
108
+
109
+ The sync remaps non-native extensions (`.ts`, `.tsx`, extensionless `Dockerfile`, `.service`, etc.) to `.txt` keys so AI Search indexes them. See `scripts/sync-ingest.mjs`.
110
+
111
+ If you name a target `public`, optional boundary checks refuse overlap with `restrictedRepos` and can verify GitHub repo visibility before upload.
112
+
113
+ ## Ask widget
114
+
115
+ Copy `public/ask-widget.js` and `public/ask-widget.css` to your docs site:
116
+
117
+ ```html
118
+ <div id="docs-ask"></div>
119
+ <script defer src="/ask-widget.js"
120
+ data-endpoint="https://search.example.com/ask"
121
+ data-target="#docs-ask"
122
+ data-label="Ask the docs"
123
+ data-sitekey="YOUR_TURNSTILE_SITEKEY"></script>
124
+ ```
125
+
126
+ ## Who this is for
127
+
128
+ Operators building documentation search, agent tooling, or internal knowledge bases on [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) with MCP and a browser widget.
129
+
130
+ ## Links
131
+
132
+ - **Deploy guide:** [docs/DEPLOY.md](docs/DEPLOY.md)
133
+ - **Skyphusion Labs:** https://skyphusion.org · **Org:** https://github.com/skyphusion-labs
134
+ - **Related:** [vivijure](https://github.com/skyphusion-labs/vivijure), [postern](https://github.com/skyphusion-labs/postern)
135
+
136
+ ## License
137
+
138
+ AGPL-3.0-only. See [LICENSE](LICENSE).
139
+
140
+ ## Community
141
+
142
+ - [Contributing](CONTRIBUTING.md)
143
+ - [Security policy](SECURITY.md)
144
+ - [Code of Conduct](CODE_OF_CONDUCT.md)
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@skyphusion/search-mcp",
3
+ "version": "0.1.2",
4
+ "description": "Bearer-gated MCP server and streaming query Worker over Cloudflare AI Search, plus a git-to-R2 corpus sync toolkit.",
5
+ "license": "AGPL-3.0-only",
6
+ "author": "Conrad Rockenhaus <conrad@skyphusion.org>",
7
+ "homepage": "https://github.com/skyphusion-labs/search-mcp#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/skyphusion-labs/search-mcp.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/skyphusion-labs/search-mcp/issues"
14
+ },
15
+ "keywords": [
16
+ "cloudflare-workers",
17
+ "mcp",
18
+ "ai-search",
19
+ "rag",
20
+ "semantic-search",
21
+ "model-context-protocol",
22
+ "self-hosted",
23
+ "cloudflare",
24
+ "corpus-sync"
25
+ ],
26
+ "type": "module",
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "files": [
35
+ "scripts/corpus-boundary.mjs",
36
+ "scripts/config-paths.mjs",
37
+ "scripts/sync-ingest.mjs",
38
+ "scripts/sync.mjs",
39
+ "scripts/sync-runner.mjs",
40
+ "scripts/targets.json.example",
41
+ "public/ask-widget.js",
42
+ "public/ask-widget.css",
43
+ "LICENSE",
44
+ "README.md"
45
+ ],
46
+ "bin": {
47
+ "search-mcp-sync": "./scripts/sync.mjs",
48
+ "search-mcp-sync-run": "./scripts/sync-runner.mjs"
49
+ },
50
+ "scripts": {
51
+ "dev": "wrangler dev",
52
+ "dev:mcp": "wrangler dev -c wrangler.mcp.toml",
53
+ "deploy": "wrangler deploy",
54
+ "deploy:mcp": "wrangler deploy -c wrangler.mcp.toml",
55
+ "typecheck": "tsc --noEmit",
56
+ "test": "vitest run",
57
+ "prepack": "npm run typecheck && npm test",
58
+ "sync": "node scripts/sync.mjs corpus",
59
+ "sync:public": "node scripts/sync.mjs public",
60
+ "sync:internal": "node scripts/sync.mjs internal",
61
+ "sync:dry": "node scripts/sync.mjs corpus --dry-run",
62
+ "sync:run": "node scripts/sync-runner.mjs",
63
+ "materialize-config": "node scripts/materialize-config.mjs"
64
+ },
65
+ "dependencies": {
66
+ "@aws-sdk/client-s3": "^3.888.0"
67
+ },
68
+ "devDependencies": {
69
+ "@cloudflare/workers-types": "^4.20260606.1",
70
+ "@vitest/coverage-istanbul": "^4.1.10",
71
+ "typescript": "^6.0.3",
72
+ "vitest": "^4.1.10",
73
+ "wrangler": "^4.103.0"
74
+ }
75
+ }
@@ -0,0 +1,77 @@
1
+ /* Embeddable ask widget. Neutral defaults; override any .vjask-* rule on the host site. */
2
+ .vjask {
3
+ --vjask-fg: #e8ecf4;
4
+ --vjask-muted: #93a0b5;
5
+ --vjask-bg: rgba(255, 255, 255, 0.03);
6
+ --vjask-border: rgba(255, 255, 255, 0.14);
7
+ --vjask-accent: #22d3ee;
8
+ --vjask-radius: 14px;
9
+ font-family: inherit;
10
+ color: var(--vjask-fg);
11
+ }
12
+ .vjask-form {
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: 0.75rem;
16
+ }
17
+ .vjask-label {
18
+ font-weight: 700;
19
+ font-size: 0.95rem;
20
+ }
21
+ .vjask-row {
22
+ display: flex;
23
+ gap: 0.5rem;
24
+ }
25
+ .vjask-input {
26
+ flex: 1;
27
+ min-width: 0;
28
+ padding: 0.7rem 0.9rem;
29
+ border-radius: var(--vjask-radius);
30
+ border: 1px solid var(--vjask-border);
31
+ background: var(--vjask-bg);
32
+ color: var(--vjask-fg);
33
+ font: inherit;
34
+ }
35
+ .vjask-input:focus {
36
+ outline: none;
37
+ border-color: var(--vjask-accent);
38
+ }
39
+ .vjask-btn {
40
+ padding: 0.7rem 1.2rem;
41
+ border-radius: var(--vjask-radius);
42
+ border: 1px solid transparent;
43
+ background: var(--vjask-accent);
44
+ color: #05121a;
45
+ font: inherit;
46
+ font-weight: 700;
47
+ cursor: pointer;
48
+ }
49
+ .vjask-btn:disabled {
50
+ opacity: 0.55;
51
+ cursor: progress;
52
+ }
53
+ .vjask-answer {
54
+ white-space: pre-wrap;
55
+ line-height: 1.7;
56
+ min-height: 1.5rem;
57
+ color: var(--vjask-fg);
58
+ }
59
+ .vjask-sources {
60
+ list-style: none;
61
+ margin: 0;
62
+ padding: 0.75rem 0 0;
63
+ border-top: 1px solid var(--vjask-border);
64
+ font-size: 0.85rem;
65
+ color: var(--vjask-muted);
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ gap: 0.35rem 0.9rem;
69
+ }
70
+ .vjask-sources-title {
71
+ width: 100%;
72
+ font-weight: 700;
73
+ color: var(--vjask-fg);
74
+ }
75
+ .vjask-sources li {
76
+ font-family: ui-monospace, "JetBrains Mono", monospace;
77
+ }
@@ -0,0 +1,165 @@
1
+ // Embeddable ask widget. Vanilla JS, no build step. Streams answers from POST /ask.
2
+ //
3
+ // Usage:
4
+ // <div id="docs-ask"></div>
5
+ // <script defer src="/ask-widget.js"
6
+ // data-endpoint="https://search.example.com/ask"
7
+ // data-target="#docs-ask"
8
+ // data-label="Ask the docs"
9
+ // data-placeholder="How do I deploy?"
10
+ // data-sitekey="<TURNSTILE_SITEKEY>"></script>
11
+ (function () {
12
+ "use strict";
13
+ var script = document.currentScript;
14
+ var endpoint = script.getAttribute("data-endpoint") || "/ask";
15
+ var targetSel = script.getAttribute("data-target") || "#docs-ask";
16
+ var label = script.getAttribute("data-label") || "Ask";
17
+ var placeholder = script.getAttribute("data-placeholder") || "Ask a question about the docs";
18
+ var sitekey = script.getAttribute("data-sitekey") || "";
19
+
20
+ function ready(fn) {
21
+ if (document.readyState !== "loading") fn();
22
+ else document.addEventListener("DOMContentLoaded", fn);
23
+ }
24
+
25
+ ready(function () {
26
+ var root = document.querySelector(targetSel);
27
+ if (!root) return;
28
+ root.classList.add("vjask");
29
+ // Static template only. Label, placeholder, and Turnstile sitekey are applied via
30
+ // the DOM API below so attribute values are never concatenated into innerHTML.
31
+ root.innerHTML =
32
+ '<form class="vjask-form">' +
33
+ ' <label class="vjask-label" for="vjask-input"></label>' +
34
+ ' <div class="vjask-row">' +
35
+ ' <input id="vjask-input" class="vjask-input" type="text" autocomplete="off" maxlength="2000" />' +
36
+ ' <button class="vjask-btn" type="submit">Ask</button>' +
37
+ " </div>" +
38
+ (sitekey ? ' <div class="vjask-turnstile-slot"></div>' : "") +
39
+ ' <div class="vjask-answer" aria-live="polite"></div>' +
40
+ ' <ul class="vjask-sources" hidden></ul>' +
41
+ "</form>";
42
+
43
+ var form = root.querySelector(".vjask-form");
44
+ var input = root.querySelector(".vjask-input");
45
+ var btn = root.querySelector(".vjask-btn");
46
+ var answer = root.querySelector(".vjask-answer");
47
+ var sources = root.querySelector(".vjask-sources");
48
+ var labelEl = root.querySelector(".vjask-label");
49
+ if (labelEl) labelEl.textContent = label;
50
+ if (input) input.setAttribute("placeholder", placeholder);
51
+
52
+ if (sitekey) {
53
+ var slot = root.querySelector(".vjask-turnstile-slot");
54
+ if (slot) {
55
+ var ts = document.createElement("div");
56
+ ts.className = "vjask-turnstile cf-turnstile";
57
+ ts.setAttribute("data-sitekey", sitekey);
58
+ ts.setAttribute("data-size", "flexible");
59
+ slot.replaceWith(ts);
60
+ }
61
+ }
62
+
63
+ function turnstileToken() {
64
+ if (!sitekey || !window.turnstile) return "";
65
+ try {
66
+ return window.turnstile.getResponse() || "";
67
+ } catch (e) {
68
+ return "";
69
+ }
70
+ }
71
+
72
+ function renderSources(chunks) {
73
+ var seen = {};
74
+ var items = [];
75
+ (chunks || []).forEach(function (c) {
76
+ var key = c && c.item && c.item.key;
77
+ if (key && !seen[key]) {
78
+ seen[key] = true;
79
+ items.push(key);
80
+ }
81
+ });
82
+ if (!items.length) return;
83
+ sources.innerHTML = "<li class='vjask-sources-title'>Sources</li>" +
84
+ items.map(function (k) {
85
+ return "<li>" + k.replace(/[&<>]/g, function (ch) {
86
+ return { "&": "&amp;", "<": "&lt;", ">": "&gt;" }[ch];
87
+ }) + "</li>";
88
+ }).join("");
89
+ sources.hidden = false;
90
+ }
91
+
92
+ function handleEvent(block) {
93
+ var isChunks = /(^|\n)event:\s*chunks/.test(block);
94
+ var m = block.match(/(^|\n)data:\s?(.*)$/s);
95
+ if (!m) return;
96
+ var payload = m[2].trim();
97
+ if (payload === "[DONE]") return;
98
+ var data;
99
+ try {
100
+ data = JSON.parse(payload);
101
+ } catch (e) {
102
+ return;
103
+ }
104
+ if (isChunks) {
105
+ renderSources(data);
106
+ return;
107
+ }
108
+ var delta = data && data.choices && data.choices[0] && data.choices[0].delta;
109
+ if (delta && delta.content) answer.textContent += delta.content;
110
+ }
111
+
112
+ form.addEventListener("submit", function (ev) {
113
+ ev.preventDefault();
114
+ var q = input.value.trim();
115
+ if (!q) return;
116
+ btn.disabled = true;
117
+ answer.textContent = "";
118
+ sources.hidden = true;
119
+ sources.innerHTML = "";
120
+
121
+ fetch(endpoint, {
122
+ method: "POST",
123
+ headers: { "content-type": "application/json" },
124
+ body: JSON.stringify({ question: q, turnstileToken: turnstileToken() }),
125
+ })
126
+ .then(function (res) {
127
+ if (!res.ok || !res.body) {
128
+ return res.json().then(
129
+ function (j) {
130
+ throw new Error(j.error || "error " + res.status);
131
+ },
132
+ function () {
133
+ throw new Error("error " + res.status);
134
+ },
135
+ );
136
+ }
137
+ var reader = res.body.getReader();
138
+ var decoder = new TextDecoder();
139
+ var buf = "";
140
+ function pump() {
141
+ return reader.read().then(function (r) {
142
+ if (r.done) return;
143
+ buf += decoder.decode(r.value, { stream: true });
144
+ var parts = buf.split("\n\n");
145
+ buf = parts.pop();
146
+ parts.forEach(handleEvent);
147
+ return pump();
148
+ });
149
+ }
150
+ return pump();
151
+ })
152
+ .catch(function (err) {
153
+ answer.textContent = "Sorry, something went wrong (" + err.message + ").";
154
+ })
155
+ .finally(function () {
156
+ btn.disabled = false;
157
+ if (sitekey && window.turnstile) {
158
+ try {
159
+ window.turnstile.reset();
160
+ } catch (e) {}
161
+ }
162
+ });
163
+ });
164
+ });
165
+ })();
@@ -0,0 +1,40 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+
4
+ /** Resolve targets.json for repo dev or npm-installed CLI usage. */
5
+ export function resolveTargetsPath(scriptsDir) {
6
+ if (process.env.SEARCH_MCP_TARGETS) {
7
+ return process.env.SEARCH_MCP_TARGETS;
8
+ }
9
+ const candidates = [
10
+ join(process.cwd(), "targets.json"),
11
+ join(scriptsDir, "targets.json"),
12
+ ];
13
+ for (const path of candidates) {
14
+ if (existsSync(path)) return path;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ /** Default git clone root for corpus sync. */
20
+ export function defaultRepoRoot(scriptsDir, targetsPath) {
21
+ if (process.env.SYNC_REPO_ROOT) {
22
+ return process.env.SYNC_REPO_ROOT;
23
+ }
24
+ if (!targetsPath) {
25
+ return join(scriptsDir, "..", "..");
26
+ }
27
+ const dir = dirname(targetsPath);
28
+ if (dir === scriptsDir) {
29
+ return join(scriptsDir, "..", "..");
30
+ }
31
+ return dir;
32
+ }
33
+
34
+ export function targetsHelp(scriptsDir) {
35
+ const example = join(scriptsDir, "targets.json.example");
36
+ return [
37
+ "Missing targets.json.",
38
+ `Copy ${example} to ./targets.json (or set SEARCH_MCP_TARGETS).`,
39
+ ].join(" ");
40
+ }
@@ -0,0 +1,131 @@
1
+ // Optional safety checks for scripts/sync.mjs when a target is named `public`.
2
+ //
3
+ // (1) targets.json overlap: assert targets.public.repos ∩ restrictedRepos === ∅.
4
+ // restrictedRepos only drives optional per-file metadata; it does NOT exclude a
5
+ // repo from the public bucket.
6
+ // (2) optional live GitHub visibility: refuse any non-public repo before upload.
7
+
8
+ /** Pure: repos listed in BOTH the public target and restrictedRepos. */
9
+ export function publicRestrictedOverlap(publicRepos, restrictedRepos) {
10
+ const restricted = new Set(restrictedRepos || []);
11
+ return (publicRepos || []).filter((r) => restricted.has(r));
12
+ }
13
+
14
+ /** Fail-closed when target is public and restrictedRepos overlaps the public list. */
15
+ export function assertPublicCorpusBoundary(cfg, targetName) {
16
+ if (targetName !== "public") return;
17
+ const overlap = publicRestrictedOverlap(
18
+ cfg?.targets?.public?.repos,
19
+ cfg?.restrictedRepos,
20
+ );
21
+ if (!overlap.length) return;
22
+ throw new Error(
23
+ "public corpus boundary violation: " +
24
+ overlap.join(", ") +
25
+ " listed in BOTH targets.public.repos and restrictedRepos. " +
26
+ "restrictedRepos only sets metadata; it does NOT exclude a repo from the public bucket. " +
27
+ "Remove each repo from one list before syncing public.",
28
+ );
29
+ }
30
+
31
+ function githubTokenFromEnv(env = process.env) {
32
+ return env.GITHUB_TOKEN || env.GH_TOKEN || "";
33
+ }
34
+
35
+ // GitHub repo/org slug charset; rejects path segments and other URL metacharacters
36
+ // before repo names from targets.json reach the GitHub API fetch.
37
+ const GITHUB_SLUG_RE = /^[A-Za-z0-9._-]{1,100}$/;
38
+
39
+ export function assertGithubSlug(slug, kind) {
40
+ if (typeof slug !== "string" || !GITHUB_SLUG_RE.test(slug)) {
41
+ throw new Error(`invalid GitHub ${kind} for API request: ${String(slug)}`);
42
+ }
43
+ return slug;
44
+ }
45
+
46
+ const GITHUB_API_HEADERS = (token) => ({
47
+ Accept: "application/vnd.github+json",
48
+ Authorization: `Bearer ${token}`,
49
+ "X-GitHub-Api-Version": "2022-11-28",
50
+ "User-Agent": "search-mcp-corpus-boundary",
51
+ });
52
+
53
+ /** Paginated org repo list; URL uses only the org slug (env), never targets.json repo names. */
54
+ async function fetchPublicRepoNames(org, token, fetchImpl) {
55
+ const names = new Set();
56
+ let page = 1;
57
+ while (true) {
58
+ const url = new URL(`/orgs/${org}/repos`, "https://api.github.com");
59
+ url.searchParams.set("type", "public");
60
+ url.searchParams.set("per_page", "100");
61
+ url.searchParams.set("page", String(page));
62
+ const res = await fetchImpl(url.toString(), { headers: GITHUB_API_HEADERS(token) });
63
+ if (res.status === 404) {
64
+ throw new Error(`GitHub org not found: ${org}`);
65
+ }
66
+ if (!res.ok) {
67
+ const snippet = (await res.text()).slice(0, 200);
68
+ throw new Error(`GitHub API ${res.status} listing public repos for ${org}: ${snippet}`);
69
+ }
70
+ const body = await res.json();
71
+ if (!Array.isArray(body) || !body.length) break;
72
+ for (const entry of body) {
73
+ if (entry && typeof entry.name === "string") names.add(entry.name);
74
+ }
75
+ if (body.length < 100) break;
76
+ page += 1;
77
+ }
78
+ return names;
79
+ }
80
+
81
+ /** Live check: each repo must be visibility=public on GitHub. */
82
+ export async function verifyGithubRepoVisibility(repos, opts = {}) {
83
+ const {
84
+ org = "",
85
+ token = "",
86
+ fetchImpl = globalThis.fetch,
87
+ } = opts;
88
+ if (!org) {
89
+ throw new Error("org is required for GitHub visibility verification");
90
+ }
91
+ assertGithubSlug(org, "org");
92
+ if (!token) {
93
+ return { skipped: true, reason: "no_github_token", checked: [], nonPublic: [] };
94
+ }
95
+ if (!fetchImpl) {
96
+ throw new Error("fetch is not available for GitHub visibility verification");
97
+ }
98
+
99
+ const nonPublic = [];
100
+ const checked = [];
101
+ const publicNames = await fetchPublicRepoNames(org, token, fetchImpl);
102
+ for (const repo of repos || []) {
103
+ assertGithubSlug(repo, "repo");
104
+ checked.push(repo);
105
+ if (!publicNames.has(repo)) {
106
+ nonPublic.push({
107
+ repo,
108
+ visibility: "not_public_or_missing",
109
+ });
110
+ }
111
+ }
112
+ return { skipped: false, checked, nonPublic };
113
+ }
114
+
115
+ /** Fail-closed when any public-target repo is not public on GitHub. Skips when no token. */
116
+ export async function assertPublicGithubVisibility(cfg, opts = {}) {
117
+ const repos = cfg?.targets?.public?.repos || [];
118
+ const org = opts.org ?? process.env.CORPUS_GIT_ORG ?? "";
119
+ const token = opts.token ?? githubTokenFromEnv(opts.env);
120
+ const result = await verifyGithubRepoVisibility(repos, { ...opts, org, token });
121
+ if (result.skipped) return result;
122
+ if (!result.nonPublic.length) return result;
123
+ const detail = result.nonPublic
124
+ .map((x) => `${x.repo} (${x.visibility}${x.reason ? ": " + x.reason : ""})`)
125
+ .join(", ");
126
+ throw new Error(
127
+ "public corpus boundary violation: non-public repos on GitHub would sync to the public bucket: " +
128
+ detail +
129
+ ". Flip public on GitHub, remove from targets.public.repos, or sync under a non-public target.",
130
+ );
131
+ }