wiki-plugin-similarity 0.6.0 → 0.7.0

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.
@@ -1,4 +1,4 @@
1
- /* wiki-plugin-similarity - 0.6.0 - Sat, 11 Jul 2026 20:14:13 GMT */
1
+ /* wiki-plugin-similarity - 0.7.0 - Mon, 20 Jul 2026 23:51:54 GMT */
2
2
  (()=>{var q={high:.78,medium:.68,low:.58},M=q.medium,_=10,V=t=>{let e=[],n=[],o=[],f=null,b=null,d="search",y=!1,L=!1,E=null,k=null,w=(i,m)=>i===m||i.startsWith(m)&&/^[\s:]/.test(i.slice(m.length)),x=(i,m)=>i.slice(m.length).replace(/^\s*:?\s*/,"").trim();for(let i of t.split(`
3
3
  `)){let m=i.trim();if(!m||m.startsWith("#"))continue;let g=m.toUpperCase();if(w(g,"LIVE")){y=!0;continue}if(w(g,"AUTHOR")){!e.length&&d==="search"&&(d="author");continue}if(w(g,"REPORT")){d==="search"&&(d="report");continue}if(w(g,"KEYWORD")){d==="search"&&(d="keyword");continue}if(w(g,"BUILD")){d==="search"&&(d="build");continue}if(w(g,"FORCE")){L=!0;continue}if(w(g,"GHOST")){E=x(m,"GHOST"),d==="search"&&(d="ghost");continue}if(w(g,"BUTTON")){k=x(m,"BUTTON");continue}if(w(g,"ROSTER")){let S=x(m,"ROSTER");S&&n.push(S);continue}if(w(g,"FARM")){let S=x(m,"FARM");S&&o.push(S);continue}if(w(g,"LIST")){!e.length&&d==="search"&&(d="list");continue}if(w(g,"SIMILAR")){let S=x(g,"SIMILAR").toLowerCase();f=q[S]||M,!e.length&&d==="search"&&(d="similar");continue}if(w(g,"THRESHOLD")){let S=x(m,"THRESHOLD");f=q[S.toLowerCase()]??(parseFloat(S)||M);continue}if(w(g,"LIMIT")){b=parseInt(x(m,"LIMIT"))||_;continue}e.push(["PUBLIC","LOCAL","PRIVATE"].includes(g)?g:m)}return{mode:d,specs:e,rosterRefs:n,farms:o,threshold:f??M,limit:b??_,live:y,force:L,ghostUrl:E,label:k,thresholdSet:f!==null}},et=t=>t.includes("*")||t.includes("?"),nt=t=>t==="PUBLIC"||t==="LOCAL"||t==="PRIVATE",st=t=>t.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,""),P=new Map,it=async(t,e)=>{if(P.has(t))return P.get(t);let n=`${e}/system/indexed-domains.json?pattern=${encodeURIComponent(t)}`,o=await fetch(n);if(!o.ok)throw new Error(`indexed-domains failed: ${o.status}`);let f=await o.json();return P.set(t,f),f},G=async(t,e)=>{t.length||(t=[window.location.hostname]);let n=new Set,o=[];for(let f of t)if(f==="*"||et(f)||nt(f))for(let b of await it(f,e))n.has(b.domain)||(n.add(b.domain),o.push(b));else n.has(f)||(n.add(f),o.push({domain:f,page_count:null}));return o},ot=/^([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+|localhost)(:\d+)?$/,at=/^ROSTER ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,rt=/^REFERENCES ([A-Za-z0-9.\-:]+\/[a-z0-9-]+)$/,B=async t=>{let e=t.indexOf("/"),n=await fetch(`//${t.slice(0,e)}/${t.slice(e+1)}.json`);if(!n.ok)throw new Error(`roster page ${t} failed: ${n.status}`);return n.json()},K=async(t,e,n)=>{if(n.has(t))return;n.add(t);let o;try{o=await B(t)}catch(f){console.warn(f.message);return}for(let f of o.story||[])if(f.type==="roster")for(let b of(f.text||"").split(/\r?\n/)){let d=b.trim();if(!d)continue;let y=d.match(ot);if(y){e.add(y[0]);continue}let L=d.match(at);if(L){await K(L[1],e,n);continue}let E=d.match(rt);if(E)try{let k=await B(E[1]);for(let w of k.story||[])w.type==="reference"&&w.site&&e.add(w.site)}catch(k){console.warn(k.message)}}},lt=async t=>{let e=new Set,n=new Set;for(let o of t)await K(o,e,n);return[...e]},ct=async(t,e)=>e.length?[...t,...await lt(e)]:t,A=new Map,dt=t=>`${window.location.origin}/system/semantic-vectors.json?domain=${encodeURIComponent(t)}`,Z=async t=>{if(A.has(t))return A.get(t);let e=await fetch(dt(t));if(!e.ok)return[];let n=await e.json();return A.set(t,n),n},z=async(t,e)=>{let n=await fetch(`${e}/system/embed.json?text=${encodeURIComponent(t)}`);if(!n.ok)throw new Error(`embed failed: ${n.status}`);return(await n.json()).vector},ft=async(t,e)=>{let o=(await Z(e)).find(f=>f.slug===t);return o?o.vector:null},H=(t,e,{threshold:n,limit:o,excludeSlug:f,excludeDomain:b})=>{let d=[];for(let{domain:y,pages:L}of e)for(let{slug:E,title:k,vector:w}of L){if(E===f&&y===b)continue;let x=0;for(let i=0;i<t.length;i++)x+=t[i]*w[i];x>=n&&d.push({domain:y,slug:E,title:k,score:x})}return d.sort((y,L)=>L.score-y.score),d.slice(0,o)},F=async(t,e)=>{let n=await G(t,e);return(await Promise.all(n.map(async({domain:f})=>({domain:f,pages:await Z(f)})))).filter(f=>f.pages.length>0)},J=t=>`sim-cache-${t}`,mt=t=>{try{let e=JSON.parse(localStorage.getItem(J(t.id))||"null");return e?.text===(t.text||"")?e:null}catch{return null}},O=(t,e)=>{try{localStorage.setItem(J(t.id),JSON.stringify({text:t.text||"",ts:Date.now(),...e}))}catch{}},ut=t=>{let e=Math.floor((Date.now()-t)/1e3);return e<60?`${e}s ago`:e<3600?`${Math.floor(e/60)}m ago`:e<86400?`${Math.floor(e/3600)}h ago`:`${Math.floor(e/86400)}d ago`},I=`
4
4
  .sim-form { display:flex; gap:6px; margin-bottom:8px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-similarity",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Federated Wiki plugin \u2014 semantic similarity search across wiki domains using prebuilt vector indexes",
5
5
  "keywords": [
6
6
  "wiki",
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "type": "markdown",
11
11
  "id": "28edff984cc5334d",
12
- "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `ROSTER site/slug` — add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` — ask a peer farm to continue the search (experimental; requires the peer's `search-peers` roster to allow you)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
12
+ "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `ROSTER site/slug` — add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` — ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
13
13
  },
14
14
  {
15
15
  "type": "markdown",
@@ -54,12 +54,12 @@
54
54
  {
55
55
  "type": "markdown",
56
56
  "id": "d9a96dfebb9d1692",
57
- "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` — keyword search over off-farm federation sites\n- `POST /system/peer-search.json` — answer another farm's search (guarded: allowlist roster, hop limit, replay dedup, rate limit; restricted sites always excluded)\n\nIndexes are `status/semantic-vectors.json` per domain — built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised — byte-compatible with the index builder."
57
+ "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` — keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` — capability probe: version, model, federation status (404 = plugin absent)\n- `POST /system/peer-search.json` — answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain — built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised — byte-compatible with the index builder."
58
58
  },
59
59
  {
60
60
  "type": "markdown",
61
61
  "id": "21eb5354d2adb29e",
62
- "text": "**Version:** 0.6.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
62
+ "text": "**Version:** 0.7.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
63
63
  }
64
64
  ],
65
65
  "plugin": "similarity",
@@ -163,6 +163,36 @@
163
163
  "text": "**Version:** 0.6.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
164
164
  },
165
165
  "date": 1783800718188
166
+ },
167
+ {
168
+ "type": "edit",
169
+ "id": "28edff984cc5334d",
170
+ "item": {
171
+ "type": "markdown",
172
+ "id": "28edff984cc5334d",
173
+ "text": "## The DSL\n\nThe first ALL-CAPS keyword sets the mode; other lines are domain specs (globs, explicit domains, or the scope keywords PUBLIC / LOCAL / PRIVATE):\n\n- (no keyword) — interactive semantic search form\n- `SIMILAR: high|medium|low` — ambient: pages similar to this page\n- `REPORT` — server-side ranked, stub-filtered, fork-bundled report\n- `KEYWORD` — galactic MiniSearch over live site-index.json files\n- `LIST` — table of indexed domains\n- `AUTHOR` — search results as a draggable ghost page\n- `BUILD` / `FORCE` — proxy to the farm indexer, where configured\n- `GHOST url` / `BUTTON label` — open any page-json as a ghost page\n- `ROSTER site/slug` — add a roster page's sites to the scope (off-farm sites searched via the galaxy cache)\n- `FARM domain` — ask a peer farm to continue the search. The peer answers from exactly the sites whose own [[Federated Farm Search]] page grants your origin (`FROM` lines; `FROM *` needs the peer admin's `WIKI_PEER_FEDERATION=open`)\n- `THRESHOLD: 0.72` · `LIMIT: 8` · `LIVE` — tuning"
174
+ },
175
+ "date": 1784591495272
176
+ },
177
+ {
178
+ "type": "edit",
179
+ "id": "d9a96dfebb9d1692",
180
+ "item": {
181
+ "type": "markdown",
182
+ "id": "d9a96dfebb9d1692",
183
+ "text": "## Server Routes\n\nAll same-origin, registered by the plugin's server component:\n\n- `GET /system/indexed-domains.json?pattern=`\n- `GET /system/semantic-vectors.json?domain=`\n- `GET /system/embed.json?text=`\n- `POST /system/search-report.json`\n- `GET /system/farm-search.json?q=&pattern=&limit=`\n- `GET /system/build-index.json?domains=` (proxied to the farm indexer)\n- `GET /system/galaxy-search.json?q=&sites=` — keyword search over off-farm federation sites\n- `GET /system/peer-hello.json` — capability probe: version, model, federation status (404 = plugin absent)\n- `POST /system/peer-search.json` — answer another farm's search. Two keys: the admin's `WIKI_PEER_FEDERATION` ceiling (off / grants / open) and each site's Federated Farm Search grants page; scope = union of granting sites; hop limit, replay dedup, IP-keyed + global rate limits; restricted sites always excluded\n\nIndexes are `status/semantic-vectors.json` per domain — built by the farm indexer, distributed by sync, read from farm disk. Keyword search reads each site's per-edit `status/site-index.json`; no separate index is built. Query embedding is BAAI bge-small-en-v1.5, 384-dim, CLS-pooled, unit-normalised — byte-compatible with the index builder."
184
+ },
185
+ "date": 1784591495272
186
+ },
187
+ {
188
+ "type": "edit",
189
+ "id": "21eb5354d2adb29e",
190
+ "item": {
191
+ "type": "markdown",
192
+ "id": "21eb5354d2adb29e",
193
+ "text": "**Version:** 0.7.0\n\n# Links\n\n- [https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-similarity GitHub repository]\n- [https://www.npmjs.com/package/wiki-plugin-similarity npm package]\n- [https://plugin.fedwiki.club/similarity-plugin.html Plugin documentation]\n- [https://search.fedwiki.club/ Search hub] — architecture, indexing, freshness."
194
+ },
195
+ "date": 1784591495272
166
196
  }
167
197
  ]
168
198
  }
@@ -1 +1 @@
1
- {"sitemap.json":{"lastModified":"Fri, 10 Jul 2026 05:01:01 GMT","fetchedAt":1783794283569},"site-index.json":{"lastModified":"Fri, 10 Jul 2026 05:01:50 GMT","fetchedAt":1783794283566}}
1
+ {"sitemap.json":{"lastModified":"Fri, 10 Jul 2026 05:01:01 GMT","fetchedAt":1783851444700},"site-index.json":{"lastModified":"Fri, 10 Jul 2026 05:01:50 GMT","fetchedAt":1783851444709}}
@@ -1,41 +1,139 @@
1
1
  // wiki-plugin-similarity — peer-search guards (CommonJS)
2
2
  //
3
- // v1 trust mechanics for the FARM prototype (experimental). A peer request is
4
- // served only when every guard passes:
5
- // - allowlist: the requesting farm appears in the serving site's own
6
- // `search-peers` page (a roster item — configuration as a wiki page)
7
- // - hops: v1 answers locally only, never re-forwards (hops must be 0)
8
- // - dedup: a request-id LRU drops replayed/looped envelopes
9
- // - rate: a small per-origin token bucket protects the embedder
3
+ // Two keys must turn before a farm answers a peer's search:
10
4
  //
11
- // The allowlist seam is deliberately narrow: later versions swap the roster
12
- // check for shared-secret pairs (v2) and trust-graph attestation (v3) without
5
+ // 1. The farm admin's ceiling WIKI_PEER_FEDERATION env:
6
+ // off refuse all peer search (kill switch)
7
+ // grants per-site owner grants honored; FROM * is inert (default)
8
+ // open FROM * takes effect
9
+ // An env, not a page: the plugin cannot verify who wrote a page line,
10
+ // and the environment is the one thing only the admin holds.
11
+ //
12
+ // 2. The site owner's grant — a "Federated Farm Search" page (slug
13
+ // federated-farm-search, override WIKI_PEER_GRANTS_PAGE) whose CODE
14
+ // items carry FROM lines:
15
+ // FROM hitchhikers.earth a named partner
16
+ // FROM *.fedwiki.org a glob family
17
+ // FROM * everyone (needs ceiling = open)
18
+ // Lines are additive. No page and no legacy search-peers roster means
19
+ // the site is closed to peers — opt-in, never opt-out.
20
+ //
21
+ // The answer scope for an origin is exactly the union of sites that grant
22
+ // it (per-site consent — one site's grant never opens the whole farm).
23
+ // Restricted (login-to-view) sites are excluded below this layer,
24
+ // unconditionally.
25
+ //
26
+ // Migration: a site with no grants page but a legacy `search-peers` roster
27
+ // page has its roster lines read as FROM lines for that site only.
28
+ // Remove the fallback at 0.8.
29
+ //
30
+ // Rate limiting is keyed by remote IP, never by the asserted origin (origins
31
+ // are free to invent in v1), with a bounded LRU and a global cap.
32
+ //
33
+ // This guard seam stays narrow on purpose: v2 swaps the grant check for
34
+ // shared-secret HMAC verification, v3 for trust-graph attestation, without
13
35
  // touching the callers.
14
36
 
15
37
  const fs = require('node:fs')
16
38
  const path = require('node:path')
17
39
 
40
+ const { globMatch } = require('./farm-lib')
41
+
18
42
  const SITE_LINE = /^([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+|localhost)(:\d+)?$/
19
43
 
20
- // ── Allowlist: roster item on the serving site's search-peers page ────────────
21
-
22
- const readPeerRoster = (farms, servingDomain, slug = null) => {
23
- slug = slug || process.env.WIKI_SEARCH_PEERS_PAGE || 'search-peers'
24
- for (const [farm] of farms) {
25
- const p = path.join(farm, servingDomain, 'pages', slug)
26
- let page
27
- try { page = JSON.parse(fs.readFileSync(p, 'utf8')) } catch { continue }
28
- const allowed = new Set()
29
- for (const item of page.story || []) {
30
- if (item.type !== 'roster') continue
31
- for (const raw of (item.text || '').split(/\r?\n/)) {
32
- const m = raw.trim().match(SITE_LINE)
33
- if (m) allowed.add(m[0])
34
- }
44
+ const CEILINGS = ['off', 'grants', 'open']
45
+ const ceiling = () => {
46
+ const c = (process.env.WIKI_PEER_FEDERATION || 'grants').toLowerCase()
47
+ return CEILINGS.includes(c) ? c : 'grants'
48
+ }
49
+
50
+ const grantsSlug = () => process.env.WIKI_PEER_GRANTS_PAGE || 'federated-farm-search'
51
+
52
+ // ── Grants parser ─────────────────────────────────────────────────────────────
53
+ // Input: the text of one or more code items. Output:
54
+ // { open: bool, origins: Set<string>, globs: string[] }
55
+ // Unknown UPPERCASE lines are ignored (safe while every command is
56
+ // additive-permissive; a future restricting command must be version-gated
57
+ // via the hello route).
58
+
59
+ const parseGrants = texts => {
60
+ const grants = { open: false, origins: new Set(), globs: [] }
61
+ for (const text of texts) {
62
+ for (const raw of (text || '').split(/\r?\n/)) {
63
+ const line = raw.trim()
64
+ if (!line || line.startsWith('#')) continue
65
+ const m = line.match(/^FROM[\s:]+(.+)$/i)
66
+ if (!m) continue
67
+ const value = m[1].trim()
68
+ if (value === '*') grants.open = true
69
+ else if (value.includes('*') || value.includes('?')) grants.globs.push(value)
70
+ else if (value.match(SITE_LINE)) grants.origins.add(value)
35
71
  }
36
- return allowed
37
72
  }
38
- return null // no peers page at all
73
+ return grants
74
+ }
75
+
76
+ // Does this grant set admit the origin, under the admin ceiling?
77
+ const grantsAdmit = (grants, origin, ceil = ceiling()) => {
78
+ if (!grants || ceil === 'off') return false
79
+ if (grants.origins.has(origin)) return true
80
+ if (grants.globs.some(g => globMatch(g, origin))) return true
81
+ if (grants.open && ceil === 'open') return true
82
+ return false
83
+ }
84
+
85
+ // ── Per-site grant reads, mtime-cached ────────────────────────────────────────
86
+
87
+ const grantCache = new Map() // "farm/domain" → {mtime, grants|null}
88
+
89
+ const readCodeItemTexts = page =>
90
+ (page.story || []).filter(i => i.type === 'code').map(i => i.text || '')
91
+
92
+ const readRosterSiteLines = page => {
93
+ const out = new Set()
94
+ for (const item of page.story || []) {
95
+ if (item.type !== 'roster') continue
96
+ for (const raw of (item.text || '').split(/\r?\n/)) {
97
+ const m = raw.trim().match(SITE_LINE)
98
+ if (m) out.add(m[0])
99
+ }
100
+ }
101
+ return out
102
+ }
103
+
104
+ // Grants for one site: the federated-farm-search page's code items, falling
105
+ // back to the legacy search-peers roster (read as FROM lines for this site).
106
+ // Returns null when the site declares nothing (closed).
107
+ const readSiteGrants = (farm, domain) => {
108
+ const tryPage = (slug, extract) => {
109
+ const p = path.join(farm, domain, 'pages', slug)
110
+ let mtime
111
+ try { mtime = fs.statSync(p).mtimeMs } catch { return undefined } // no page
112
+ const key = `${farm}/${domain}/${slug}`
113
+ const hit = grantCache.get(key)
114
+ if (hit && hit.mtime === mtime) return hit.grants
115
+ let grants = null
116
+ try { grants = extract(JSON.parse(fs.readFileSync(p, 'utf8'))) } catch { /* unreadable */ }
117
+ grantCache.set(key, { mtime, grants })
118
+ if (grantCache.size > 2000) grantCache.delete(grantCache.keys().next().value)
119
+ return grants
120
+ }
121
+
122
+ const fromPage = tryPage(grantsSlug(), page => parseGrants(readCodeItemTexts(page)))
123
+ if (fromPage !== undefined) return fromPage
124
+ const legacy = tryPage('search-peers', page => {
125
+ const origins = readRosterSiteLines(page)
126
+ return origins.size ? { open: false, origins, globs: [] } : null
127
+ })
128
+ return legacy !== undefined ? legacy : null
129
+ }
130
+
131
+ // The union of domains (from [{farm, domain}]) whose own grants admit the
132
+ // origin under the current ceiling.
133
+ const grantingDomains = (domains, origin, ceil = ceiling()) => {
134
+ if (ceil === 'off') return []
135
+ return domains.filter(({ farm, domain }) =>
136
+ grantsAdmit(readSiteGrants(farm, domain), origin, ceil))
39
137
  }
40
138
 
41
139
  // ── Request-id LRU dedup ──────────────────────────────────────────────────────
@@ -51,16 +149,24 @@ const makeDedup = (max = 500) => {
51
149
  }
52
150
  }
53
151
 
54
- // ── Per-origin token bucket ───────────────────────────────────────────────────
152
+ // ── Rate limiting: per-IP token buckets (bounded LRU) + a global bucket ──────
153
+ // Never key limits by the asserted origin: a spoofer rotating origins would
154
+ // both escape the limiter and grow the map without bound.
55
155
 
56
- const makeBucket = ({ capacity = 30, refillPerSec = 0.5 } = {}) => {
156
+ const makeBucket = ({ capacity = 30, refillPerSec = 0.5, maxKeys = 500 } = {}) => {
57
157
  const buckets = new Map()
58
- return origin => {
158
+ return key => {
59
159
  const now = Date.now()
60
- let b = buckets.get(origin)
61
- if (!b) { b = { tokens: capacity, at: now }; buckets.set(origin, b) }
62
- b.tokens = Math.min(capacity, b.tokens + ((now - b.at) / 1000) * refillPerSec)
63
- b.at = now
160
+ let b = buckets.get(key)
161
+ if (!b) {
162
+ b = { tokens: capacity, at: now }
163
+ } else {
164
+ buckets.delete(key) // refresh LRU position
165
+ b.tokens = Math.min(capacity, b.tokens + ((now - b.at) / 1000) * refillPerSec)
166
+ b.at = now
167
+ }
168
+ buckets.set(key, b)
169
+ while (buckets.size > maxKeys) buckets.delete(buckets.keys().next().value)
64
170
  if (b.tokens < 1) return false
65
171
  b.tokens -= 1
66
172
  return true
@@ -68,29 +174,33 @@ const makeBucket = ({ capacity = 30, refillPerSec = 0.5 } = {}) => {
68
174
  }
69
175
 
70
176
  // ── Envelope guard ────────────────────────────────────────────────────────────
177
+ // Shape and abuse checks only — the consent decision (which sites grant this
178
+ // origin) is per-site and belongs to the caller via grantingDomains().
71
179
  // Returns {ok: true} or {ok: false, code, error}.
72
180
 
73
- const guardEnvelope = (envelope, { allowed, isDuplicate, takeToken }) => {
74
- const origin = (envelope.origin || '').trim()
75
- if (!origin) {
76
- return { ok: false, code: 400, error: 'origin required' }
77
- }
78
- if (allowed === null) {
79
- return { ok: false, code: 403, error: 'this site has no search-peers page — peer search is not enabled here' }
181
+ const guardEnvelope = (envelope, { ip, isDuplicate, takeIpToken, takeGlobalToken }) => {
182
+ if (ceiling() === 'off') {
183
+ return { ok: false, code: 403, error: 'peer federation is switched off on this farm' }
80
184
  }
81
- if (!allowed.has(origin)) {
82
- return { ok: false, code: 403, error: `${origin} is not on this site's search-peers roster` }
185
+ if (!(envelope.origin || '').trim()) {
186
+ return { ok: false, code: 400, error: 'origin required' }
83
187
  }
84
188
  if ((envelope.hops || 0) > 0) {
85
- return { ok: false, code: 400, error: 'relayed requests are not accepted (v1 answers locally only)' }
189
+ return { ok: false, code: 400, error: 'relayed requests are not accepted (answers are local only)' }
86
190
  }
87
191
  if (isDuplicate(envelope.requestId)) {
88
192
  return { ok: false, code: 409, error: 'duplicate request-id (replay or loop)' }
89
193
  }
90
- if (!takeToken(origin)) {
91
- return { ok: false, code: 429, error: 'rate limit exceeded for this peer' }
194
+ if (takeGlobalToken && !takeGlobalToken('global')) {
195
+ return { ok: false, code: 429, error: 'this farm is at its peer-search rate limit' }
196
+ }
197
+ if (!takeIpToken(ip || 'unknown')) {
198
+ return { ok: false, code: 429, error: 'rate limit exceeded' }
92
199
  }
93
200
  return { ok: true }
94
201
  }
95
202
 
96
- module.exports = { readPeerRoster, makeDedup, makeBucket, guardEnvelope, SITE_LINE }
203
+ module.exports = {
204
+ ceiling, grantsSlug, parseGrants, grantsAdmit, readSiteGrants, grantingDomains,
205
+ makeDedup, makeBucket, guardEnvelope, SITE_LINE,
206
+ }
package/server/server.js CHANGED
@@ -23,6 +23,13 @@
23
23
  // Proxy to the farm indexer (WIKI_INDEXER_URL) when configured; heavy
24
24
  // embedding is the indexer's job (Pi5 on the Hitchhikers farm), never
25
25
  // the wiki server's.
26
+ // GET /system/peer-hello.json
27
+ // Capability probe: plugin version, embedding model, federation status.
28
+ // 404 here means the plugin is absent from the farm.
29
+ // POST /system/peer-search.json
30
+ // Federated peer search. Two keys: the admin's WIKI_PEER_FEDERATION
31
+ // ceiling (off|grants|open) and each site's Federated Farm Search
32
+ // grants page (FROM lines). Scope = union of granting sites only.
26
33
  //
27
34
  // Farm root is derived from argv.status ({farm}/{domain}/status); extra farm
28
35
  // roots come from WIKI_EXTRA_FARMS (colon-separated absolute paths).
@@ -44,9 +51,12 @@ const embedder = require('./embedder')
44
51
  const { buildReport } = require('./search-report')
45
52
  const { searchFarm, keywordReportPage } = require('./farm-search')
46
53
  const { searchGalaxy } = require('./galaxy-search')
47
- const { readPeerRoster, makeDedup, makeBucket, guardEnvelope } = require('./peer-guard')
54
+ const { ceiling, grantingDomains, makeDedup, makeBucket, guardEnvelope } = require('./peer-guard')
48
55
 
49
56
  const MODEL_META = { model: 'BAAI/bge-small-en-v1.5', dim: 384 }
57
+ const PLUGIN_VERSION = (() => {
58
+ try { return require('../package.json').version } catch { return 'unknown' }
59
+ })()
50
60
 
51
61
  // Optional external embedder (proxy) — unset means embed in-process.
52
62
  const EMBED_URL = process.env.WIKI_EMBED_URL || null
@@ -205,16 +215,74 @@ const startServer = ({ argv, app }) => {
205
215
  for (const route of ['/system/indexed-domains.json', '/system/semantic-vectors.json',
206
216
  '/system/embed.json', '/system/search-report.json',
207
217
  '/system/farm-search.json', '/system/build-index.json',
208
- '/system/galaxy-search.json', '/system/peer-search.json']) {
218
+ '/system/galaxy-search.json', '/system/peer-search.json',
219
+ '/system/peer-hello.json']) {
209
220
  app.options(route, (req, res) => { cors(res); res.sendStatus(204) })
210
221
  }
211
222
 
212
- // FARM prototype guards (shared across peer requests)
213
- const isDuplicate = makeDedup()
214
- const takeToken = makeBucket()
223
+ // Peer federation guards (shared across peer requests). Rate limits are
224
+ // keyed by remote IP — never by the asserted origin — plus a global cap.
225
+ const isDuplicate = makeDedup()
226
+ const takeIpToken = makeBucket()
227
+ const takeGlobalToken = makeBucket({ capacity: 120, refillPerSec: 2, maxKeys: 1 })
228
+
229
+ const requestIp = req =>
230
+ (req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
231
+ req.socket.remoteAddress || 'unknown'
232
+
233
+ // GET JSON from a peer (hello probe) — same transport rules as postToPeer.
234
+ const getFromPeer = (peer, routePath, timeoutMs = 10_000) =>
235
+ new Promise((resolve, reject) => {
236
+ const isLocal = peer.endsWith('.localhost') || peer.startsWith('localhost')
237
+ const mod = isLocal ? http : https
238
+ const opts = {
239
+ hostname: peer.split(':')[0],
240
+ port: peer.includes(':') ? peer.split(':')[1] : (isLocal ? 80 : 443),
241
+ path: routePath,
242
+ timeout: timeoutMs,
243
+ }
244
+ if (isLocal) opts.lookup = (h, o, cb) => cb(null, [{ address: '127.0.0.1', family: 4 }])
245
+ const req = mod.get(opts, res => {
246
+ let data = ''
247
+ res.on('data', chunk => { data += chunk })
248
+ res.on('end', () => {
249
+ try { resolve({ status: res.statusCode, body: JSON.parse(data) }) }
250
+ catch { resolve({ status: res.statusCode, body: null }) }
251
+ })
252
+ })
253
+ req.on('timeout', () => { req.destroy(new Error('peer timeout')) })
254
+ req.on('error', reject)
255
+ })
256
+
257
+ // Hello probe cache: peer → {at, ok, body}. TTL 1h, small LRU.
258
+ const helloCache = new Map()
259
+ const HELLO_TTL = 60 * 60_000
260
+
261
+ const probePeer = async peer => {
262
+ const hit = helloCache.get(peer)
263
+ if (hit && Date.now() - hit.at < HELLO_TTL) return hit
264
+ let entry
265
+ try {
266
+ const { status, body } = await getFromPeer(peer, '/system/peer-hello.json')
267
+ entry = { at: Date.now(), ok: status === 200 && body && body.plugin, body }
268
+ } catch {
269
+ entry = { at: Date.now(), ok: false, body: null }
270
+ }
271
+ helloCache.set(peer, entry)
272
+ if (helloCache.size > 200) helloCache.delete(helloCache.keys().next().value)
273
+ return entry
274
+ }
215
275
 
216
276
  const askPeers = async (peers, envelope) => {
217
277
  const outcomes = await Promise.all(peers.map(async peer => {
278
+ const hello = await probePeer(peer)
279
+ if (!hello.ok) {
280
+ return { peer, status: 0,
281
+ body: { error: 'peer does not answer hello — no similarity federation there' } }
282
+ }
283
+ if (hello.body.federation && hello.body.federation.enabled === false) {
284
+ return { peer, status: 0, body: { error: 'peer has federation switched off' } }
285
+ }
218
286
  try {
219
287
  const { status, body } = await postToPeer(peer, '/system/peer-search.json', envelope)
220
288
  return { peer, status, body }
@@ -365,31 +433,67 @@ const startServer = ({ argv, app }) => {
365
433
  }
366
434
  })
367
435
 
368
- // ── POST /system/peer-search.json — FARM prototype (experimental) ─────────
369
- // Another farm asks this one to continue a search on its own sites. Guarded
370
- // by the serving site's search-peers roster page, a hop limit, request-id
371
- // dedup and a per-origin rate limit. Restricted (PRIVATE) sites are always
372
- // excluded from peer answers.
436
+ // ── GET /system/peer-hello.json — capability probe ────────────────────────
437
+ // "Does this farm run the Similarity plugin, and does it federate?"
438
+ // A 404 (or HTML) here means the plugin is absent. Static JSON, no embedder
439
+ // cost; does not advertise any FROM list.
440
+ app.get('/system/peer-hello.json', (req, res) => {
441
+ cors(res)
442
+ res.setHeader('Cache-Control', 'public, max-age=3600')
443
+ res.json({
444
+ plugin: 'wiki-plugin-similarity',
445
+ version: PLUGIN_VERSION,
446
+ ...MODEL_META,
447
+ site: req.hostname,
448
+ federation: {
449
+ enabled: ceiling() !== 'off',
450
+ kinds: ['report', 'keyword'],
451
+ hopsAccepted: 0,
452
+ },
453
+ })
454
+ })
455
+
456
+ // ── POST /system/peer-search.json — federated peer search ────────────────
457
+ // Two keys must turn: the farm admin's WIKI_PEER_FEDERATION ceiling, and
458
+ // each site's own Federated Farm Search grants page. The answer scope for
459
+ // an origin is exactly the union of sites that granted it — one site's
460
+ // grant never opens the whole farm. Restricted (login-to-view) sites are
461
+ // excluded unconditionally. Abuse guards: hop limit, request-id dedup,
462
+ // IP-keyed + global rate limits.
373
463
  app.post('/system/peer-search.json', async (req, res) => {
374
464
  cors(res)
375
465
  try {
376
466
  const envelope = await readBody(req)
377
- const allowed = readPeerRoster(farms, req.hostname)
378
- const verdict = guardEnvelope(envelope, { allowed, isDuplicate, takeToken })
467
+ const verdict = guardEnvelope(envelope,
468
+ { ip: requestIp(req), isDuplicate, takeIpToken, takeGlobalToken })
379
469
  if (!verdict.ok) return res.status(verdict.code).json({ error: verdict.error })
380
470
  if (!envelope.query) return res.status(400).json({ error: 'query required' })
381
471
 
472
+ const origin = envelope.origin.trim()
473
+ const allDomains = listDomains(farms, ['*'], restricted)
474
+ .filter(d => !restricted.has(d.domain))
475
+ const granted = grantingDomains(allDomains, origin)
476
+ if (!granted.length) {
477
+ return res.status(403).json({
478
+ error: `no site on this farm grants federated search to ${origin}`,
479
+ hint: 'a site opts in with FROM lines on its Federated Farm Search page',
480
+ })
481
+ }
482
+ const grantedNames = granted.map(d => d.domain)
483
+
382
484
  const limit = envelope.limit || 10
383
485
  let page
384
486
  if (envelope.kind === 'keyword') {
385
- const outcome = searchFarm(farms, ['*'], restricted, envelope.query, limit, restricted)
386
- page = keywordReportPage(envelope.query, outcome, limit, ['*'])
487
+ const outcome = searchFarm(farms, grantedNames, restricted,
488
+ envelope.query, limit, restricted)
489
+ page = keywordReportPage(envelope.query, outcome, limit, grantedNames)
387
490
  } else {
388
- page = await buildReport(envelope.query, ['*'], limit,
491
+ page = await buildReport(envelope.query, grantedNames, limit,
389
492
  { ...ctx, exclude: restricted })
390
493
  }
391
494
  const count = page.story.filter(i => i.type === 'reference').length
392
- res.json({ page, meta: { ...MODEL_META, farm: req.hostname, count } })
495
+ res.json({ page, meta: { ...MODEL_META, version: PLUGIN_VERSION,
496
+ farm: req.hostname, sites: grantedNames.length, count } })
393
497
  } catch (e) {
394
498
  console.error('[wiki-plugin-similarity] peer-search error:', e.message)
395
499
  res.status(500).json({ error: `peer-search failed: ${e.message}` })