|
| 1 | +# search/ — full-text search over a pod |
| 2 | + |
| 3 | +A new feature built straight onto the #206 loader plugin api: full-text |
| 4 | +search over the resources under a pod container. |
| 5 | + |
| 6 | +``` |
| 7 | +GET /search?q=<terms>&container=/path/ |
| 8 | + → { "query": "...", "hits": [ { "url", "title", "snippet", "score } ] } |
| 9 | +``` |
| 10 | + |
| 11 | +The container subtree is walked over **loopback HTTP** carrying the caller's |
| 12 | +own `Authorization` header (the pattern notifications/, sparql/, rss/ and |
| 13 | +webdav/ established), each member's text is extracted, tokenised, and ranked |
| 14 | +by TF-IDF over the query terms. No npm dependencies: the tokeniser, the |
| 15 | +text extractor, and the ranker are hand-rolled on node builtins. |
| 16 | + |
| 17 | +## Usage |
| 18 | + |
| 19 | +```js |
| 20 | +plugins: [{ |
| 21 | + module: 'search/plugin.js', |
| 22 | + prefix: '/search', |
| 23 | + config: { |
| 24 | + baseUrl: 'http://localhost:3000', // required: the server's public origin |
| 25 | + // loopbackUrl: 'http://127.0.0.1:3000', // where the plugin reaches its host |
| 26 | + // defaultContainer: '/alice/', // scope when the request names none |
| 27 | + // maxHits: 20, // result cap |
| 28 | + // maxResources: 200, // loopback fetches per crawl |
| 29 | + // maxDepth: 3, // container recursion below the scope |
| 30 | + // snippetRadius: 80, // chars either side of the first match |
| 31 | + // index: false, // cached-index mode (see Findings) |
| 32 | + // podsRoot: './data', // OPTIONAL fs.watch refresh (index mode) |
| 33 | + // reindexMs: 60000, // cached-index TTL |
| 34 | + }, |
| 35 | +}] |
| 36 | +``` |
| 37 | + |
| 38 | +```bash |
| 39 | +curl "http://localhost:3000/search?q=fishing%20boat&container=/alice/notes/" \ |
| 40 | + -H "Authorization: Bearer $TOKEN" |
| 41 | +``` |
| 42 | + |
| 43 | +```json |
| 44 | +{ |
| 45 | + "query": "fishing boat", |
| 46 | + "hits": [ |
| 47 | + { "url": "http://localhost:3000/alice/notes/boats.jsonld", |
| 48 | + "title": "The old fishing boat", |
| 49 | + "snippet": "An old fishing boat rests in the harbor. The fishing crew …", |
| 50 | + "score": 0.7421 } |
| 51 | + ] |
| 52 | +} |
| 53 | +``` |
| 54 | + |
| 55 | +**Scope** — the caller names which container subtree to search: |
| 56 | +`?container=/path/` (this server's origin) or `config.defaultContainer`. The |
| 57 | +plugin walks it recursively over loopback: containers via their `ldp:contains` |
| 58 | +listing, leaf members fetched and scanned (`.acl`/`.meta` sidecars and |
| 59 | +non-text bodies skipped), bounded by `maxDepth`/`maxResources`. When the walk |
| 60 | +is cut short the response carries `X-Search-Scope-Truncated: true`; |
| 61 | +`X-Search-Scope-Resources` always counts the resources visited, and |
| 62 | +`X-Search-Mode` reports `read-time` or `cached-index`. |
| 63 | + |
| 64 | +**Authorization** — every loopback fetch carries the caller's own |
| 65 | +`Authorization`, so the searchable set is exactly what the caller could `GET` |
| 66 | +themselves. Anonymous callers search only public data; WAC can never disagree |
| 67 | +with the server, because the server itself answers every fetch. |
| 68 | + |
| 69 | +**CORS** — the endpoint sets `Access-Control-Allow-Origin: *` and answers the |
| 70 | +`OPTIONS` preflight (allowing the `Authorization` header), so a browser search |
| 71 | +UI on another origin can call it. A bearer token is an explicit request |
| 72 | +header, not a cookie, so the wildcard origin is safe here. |
| 73 | + |
| 74 | +## Ranking |
| 75 | + |
| 76 | +Deliberately small and exactly this: |
| 77 | + |
| 78 | +- **Tokenising** — Unicode-aware, lowercased, split on non-alphanumerics. |
| 79 | + `?q=` is parsed into terms: a `"quoted run"` is a **phrase** term (matched |
| 80 | + as a normalised-whitespace substring), bare words are single terms. |
| 81 | +- **AND semantics** — a document is a hit only when it contains **every** |
| 82 | + query term; a term absent from a doc drops it. |
| 83 | +- **TF-IDF** — per term, `idf = ln((N+1)/(df+1)) + 1` (smoothed, always |
| 84 | + positive) over the `N` crawled docs; per doc, a sublinear, length-normalised |
| 85 | + term frequency `(1 + ln(tf)) / sqrt(docLength)`. Score is the sum across |
| 86 | + terms. Hits sort by score descending (ties broken by URL for stability). |
| 87 | +- **Snippet** — a window of `snippetRadius` chars either side of the first |
| 88 | + matching term in the original text, whitespace-collapsed, elided with `…`. |
| 89 | + |
| 90 | +**Text extraction** — from JSON/JSON-LD, the text-ish props matched by *local |
| 91 | +name* so any vocabulary works (`schema:`, `dcterms:`, a bare term): `title`, |
| 92 | +`name`, `headline`, `label`, `content`, `articleBody`, `text`, `description`, |
| 93 | +`body`, `summary`, `abstract` (arrays, `@value`, `@graph` and one level of |
| 94 | +nested objects unwrapped); title from the first title-ish of those. From |
| 95 | +`text/html`, tags are crudely stripped (`<script>`/`<style>`/comments dropped |
| 96 | +first, a few entities decoded) and the title taken from `<title>`. From |
| 97 | +`text/plain` (and markdown/xml) the body *is* the text; the title falls back |
| 98 | +to the filename. |
| 99 | + |
| 100 | +## Read-time vs cached-index |
| 101 | + |
| 102 | +| | **read-time** (default) | **cached-index** (`config.index: true`) | |
| 103 | +|---|---|---| |
| 104 | +| how | walk + scan the container per query | in-memory inverted index (persisted under `pluginDir`), rebuilt on TTL / `fs.watch` | |
| 105 | +| correctness | always fresh, WAC-correct by construction | **can be stale or wrong** — see Findings | |
| 106 | +| cost | O(N) loopback fetches per query | O(1) after the first crawl, + a per-hit HEAD recheck | |
| 107 | +| default | ✅ (deterministic; what the tests exercise) | off | |
| 108 | + |
| 109 | +Cached mode restores WAC on its *output* with a per-hit loopback `HEAD` |
| 110 | +against the current caller (the index is a cross-agent store, so it can't be |
| 111 | +WAC-correct by itself). Staleness of the *content* it cannot fix — that is |
| 112 | +the finding below. |
| 113 | + |
| 114 | +## Test |
| 115 | + |
| 116 | +```bash |
| 117 | +node --test --test-concurrency=1 search/test.js |
| 118 | +``` |
| 119 | + |
| 120 | +Boots a real JSS from npm with `idp: true`, registers two pods, seeds |
| 121 | +`/alice/notes/` with JSON-LD articles, a plaintext file and an HTML page over |
| 122 | +authenticated `PUT`, then exercises: relevance ranking (the densest doc ranks |
| 123 | +first) and snippets, single-term recall, a non-matching term (empty), phrase |
| 124 | +queries (adjacent-only), multi-term AND, HTML title + tag stripping (script |
| 125 | +text not indexed), the WAC property (owner sees her notes; anonymous and a |
| 126 | +second registered agent see nothing), the `400`s and CORS preflight, and |
| 127 | +**cached-index mode on the same server** (a second plugin entry at `/isearch` |
| 128 | +with `index: true`) proving cached ranking matches read-time and does not leak |
| 129 | +private docs across agents. **11 tests.** Both index modes run on ONE server |
| 130 | +(a second entry, not a second boot) to sidestep the process-global |
| 131 | +`DATA_ROOT` footgun; the misconfiguration test is ordered first for the same |
| 132 | +reason. |
| 133 | + |
| 134 | +## Findings |
| 135 | + |
| 136 | +1. **Search is the THIRD — and sharpest — consumer of the missing |
| 137 | + `api.events.onResourceChange` seam.** After notifications/ (late |
| 138 | + notifications) and sparql/ (wrong query results), search makes the seam |
| 139 | + *most user-visible*: freshness is the one property users expect of search |
| 140 | + above all else, and it is exactly what a plugin cannot guarantee. The |
| 141 | + read-time path is correct because it re-crawls every query; the cached |
| 142 | + index — the mode users would actually turn on for a large pod — goes |
| 143 | + **stale** (a write the OS coalesces past `fs.watch`, or a non-filesystem |
| 144 | + storage backend the watcher can't see at all) or **wrong** (a shared |
| 145 | + in-memory index holds one agent's text and would serve it to another). See |
| 146 | + [sparql/'s finding 1](../sparql/README.md#findings) — this is its |
| 147 | + write-index sibling; the two ports together make the seam's rank in |
| 148 | + [NOTES §2](../NOTES.md) empirical, not speculative: two independent |
| 149 | + read-over-pod features both hit the same wall, and search is the one whose |
| 150 | + failure a user notices first. |
| 151 | +2. **`fs.watch` on a config-supplied `podsRoot` is a workaround, not a |
| 152 | + fix** — the same one notifications/ uses. It can drop or coalesce events, |
| 153 | + fires only for the filesystem backend, and gives no per-resource ACL |
| 154 | + context, so cache invalidation is guesswork. This plugin therefore keeps |
| 155 | + read-time as the default and treats the watcher as *optional* best-effort |
| 156 | + refresh on top of a TTL; the honest answer to "is my search fresh?" is |
| 157 | + "only in read-time mode." |
| 158 | +3. **A cross-agent cache cannot be WAC-correct without help.** The index is |
| 159 | + built under whichever caller first triggered it, so its contents span |
| 160 | + whatever *that* agent could read. Serving it to a second agent would leak |
| 161 | + private text. The plugin patches the *output* with a per-hit loopback |
| 162 | + `HEAD` recheck against the current caller — correct, but it re-introduces |
| 163 | + per-request HTTP (partly defeating the cache) and still leaves the *stored* |
| 164 | + text as a cross-tenant blob. A faithful cached search wants either |
| 165 | + per-agent indexes keyed by identity, or `api.events` carrying the ACL |
| 166 | + context to invalidate precisely — neither is expressible today. Read-time's |
| 167 | + forward-the-Authorization crawl gets this right for free (same property |
| 168 | + sparql/ and rss/ rely on). |
| 169 | +4. **`config.baseUrl` again** — same `api.serverInfo` finding as |
| 170 | + notifications/sparql/rss: the plugin needs its host's public origin (the |
| 171 | + `?container=` origin check, the loopback target, absolute hit URLs) and |
| 172 | + must be told it in config; the test does the probe-port-then-boot dance. |
| 173 | + `config.serverInfo` repetition now spans a dozen ports (see |
| 174 | + [NOTES §3](../NOTES.md)). |
| 175 | +5. **Process-global `DATA_ROOT` bites multi-boot tests** (host-side quirk, |
| 176 | + not a plugin-api gap): the misconfiguration test boots a second, failing |
| 177 | + `createServer`, which repoints the env var every `createServer` shares, so |
| 178 | + it must be ordered *before* the long-lived boot or the main server's token |
| 179 | + verification silently reads the wrong keys directory (this port learned it |
| 180 | + the hard way — a `before()` hook runs first and poisoned every authed |
| 181 | + query until the boot was moved into an ordered `it`). Both index modes are |
| 182 | + proven on one server precisely to avoid a second long-lived boot. |
0 commit comments