@panaversity/ksor 0.0.32 → 0.0.34
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/CHANGELOG.md +162 -0
- package/docs/authorization.md +364 -21
- package/docs/deploying.md +51 -1
- package/docs/index.md +3 -3
- package/docs/ingesting.md +65 -19
- package/package.json +1 -1
- package/templates/scaffold/AGENTS.md +2 -2
- package/templates/scaffold/system/site/app/api/search/route.ts +15 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,167 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.34
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e18ea08: The second site shell is retired (decision 9 revision, owner). Nothing an
|
|
8
|
+
adopter runs changes: `ksor init` has always emitted Fumadocs and never offered
|
|
9
|
+
a selector.
|
|
10
|
+
|
|
11
|
+
The proof did its job. It was built to answer whether the shell is really a slot
|
|
12
|
+
or whether the surface contract was just a description of what Fumadocs happens
|
|
13
|
+
to do, and it kept that contract honest through the visibility model,
|
|
14
|
+
attachments and the staging lock. What it costs now exceeds that: every surface
|
|
15
|
+
the record grows — quizzes, decks, slides, code tabs — has to be built twice or
|
|
16
|
+
the conformance suite goes red, and the second build is one nobody ships.
|
|
17
|
+
Maintaining a shell no adopter runs, to prove a property no adopter exercises,
|
|
18
|
+
is the "code is liability" test failing.
|
|
19
|
+
|
|
20
|
+
The five-clause surface contract survives unchanged and is still asserted,
|
|
21
|
+
against one implementation. The other clauses decision 9 always leaned on —
|
|
22
|
+
adopter ownership of `system/site`, registry-distributed shells later — are what
|
|
23
|
+
carry replaceability now. The swap recipe lives in git history; an adopter
|
|
24
|
+
actually swapping a shell restores the property as something live rather than
|
|
25
|
+
hypothetical.
|
|
26
|
+
|
|
27
|
+
## 0.0.33
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- 8f8d7b5: A third worked authorization recipe: Auth0, the hosted provider with a free
|
|
32
|
+
tier — written around the confusions rather than the happy path, because every
|
|
33
|
+
step in it is one that was got wrong first on a real tenant.
|
|
34
|
+
|
|
35
|
+
The recipe leads with the thing that causes the trouble: **Auth0's "API" is your
|
|
36
|
+
ksor door, and Auth0's "Application" is whoever calls it.** From there it covers
|
|
37
|
+
what a scripted caller needs versus an interactive one (they are two different
|
|
38
|
+
applications, because a machine-to-machine app has no browser and filling in its
|
|
39
|
+
callback field changes nothing), and the authorization step that hides — it
|
|
40
|
+
lives on the API rather than the application, and it is a `Grant Access` button
|
|
41
|
+
inside an `Edit` panel, not the toggle the table appears to offer.
|
|
42
|
+
|
|
43
|
+
Also records what Auth0 gets right: it honours RFC 8707, so an MCP client sending
|
|
44
|
+
`resource=<your mcp url>` receives a token audienced there with no vendor
|
|
45
|
+
parameter and no mapper.
|
|
46
|
+
|
|
47
|
+
Also answers the question that comes BEFORE any recipe and that the page never
|
|
48
|
+
addressed: **will your provider work at all?** Three checks — does it issue
|
|
49
|
+
RS256 JWTs rather than opaque tokens (the door verifies signatures itself and
|
|
50
|
+
makes no introspection call), does it publish RFC 8414 or OIDC metadata so the
|
|
51
|
+
keys can be discovered, and can it mint a token audienced at your identifier.
|
|
52
|
+
A provider failing any one of them cannot be used, and today that is discovered
|
|
53
|
+
several screens into a vendor console rather than in the first minute.
|
|
54
|
+
|
|
55
|
+
The page was then read cold by someone who had never used any of the three
|
|
56
|
+
providers, and their report is the rest of this change. It found the page
|
|
57
|
+
answered neither of the two questions a deployer has first, and contradicted
|
|
58
|
+
itself on a third:
|
|
59
|
+
|
|
60
|
+
- **What does this protect?** Only the MCP door. The website is a separate
|
|
61
|
+
surface and stays exactly as public as it was — now stated before anything
|
|
62
|
+
else, along with the fact that this is one gate rather than per-user rules
|
|
63
|
+
(the door reads no scopes; different readers seeing different documents is the
|
|
64
|
+
record's `audiences:` model, a different mechanism).
|
|
65
|
+
- **`KSOR_MCP_RESOURCE_URL` "never has to resolve"** was wrong. The authorization
|
|
66
|
+
server never fetches it, but a client does — it is where `www-authenticate`
|
|
67
|
+
points. An invented value boots green and breaks discovery silently.
|
|
68
|
+
- **`KSOR_AUTH` appeared only as "delete any"**, undefined, inside one recipe, so
|
|
69
|
+
readers of the other two never saw it — while a scaffold ships it SET. It is
|
|
70
|
+
now defined once, up front, as the first thing to remove.
|
|
71
|
+
- **`KSOR_SSO_URL` "is the issuer"** contradicted a later warning that the two
|
|
72
|
+
are deliberately different strings. The general rule is now stated once: one
|
|
73
|
+
is a base for path joining, the other is compared byte-exact.
|
|
74
|
+
- **"Three variables"** introduced a four-row table, and three more were
|
|
75
|
+
scattered across the page. Six now, in one table, with formats.
|
|
76
|
+
- **Nothing verified against the door.** A new section decodes the token
|
|
77
|
+
(`aud`/`iss` — the debugging step for this page's own stated failure mode) and
|
|
78
|
+
checks both the refusal and the acceptance, in that order.
|
|
79
|
+
- The Auth0 recipe dead-ended at "Save Changes" without saying what the client
|
|
80
|
+
credentials were for, and its step 3 read as permission to skip step 5 — the
|
|
81
|
+
step it calls "the one that hides".
|
|
82
|
+
|
|
83
|
+
- 8f8d7b5: `deploying.md` and `ingesting.md` were each read by someone who had never used
|
|
84
|
+
the tool, told to find where the document stranded them. Both were, in the
|
|
85
|
+
reviewer's phrase, "the second half of a guide whose first half doesn't exist" —
|
|
86
|
+
prose by someone who had forgotten what it is like not to have the environment
|
|
87
|
+
already working. This is that first half, plus the contradictions the read
|
|
88
|
+
surfaced.
|
|
89
|
+
|
|
90
|
+
**A prerequisites block on both pages.** `ingesting.md` used the word "provider"
|
|
91
|
+
five times without ever naming Gemini, saying which variable holds the key, or
|
|
92
|
+
where to get one — a hard blocker on line 1. Neither page said the database needs
|
|
93
|
+
pgvector, where `knowledge/` lives, or which directory the commands run from.
|
|
94
|
+
`deploying.md` now opens with the four things that must exist and the order they
|
|
95
|
+
happen in, because its own text described skipping ingest as "the single most
|
|
96
|
+
common 'it deployed but does not work'" while telling the reader publishing was
|
|
97
|
+
"not on this page's critical path".
|
|
98
|
+
|
|
99
|
+
**A wrong claim about `gc`, corrected.** `ingesting.md` said `gc` "reaps the ones
|
|
100
|
+
nothing points at any more" directly after promising the previous generation as a
|
|
101
|
+
rollback target — reading as though the routine `pnpm refresh` destroys the safety
|
|
102
|
+
net it just created. It does not: `gc` never collects the active generation, the
|
|
103
|
+
rollback generation, or any generation a live snapshot token could pin, and always
|
|
104
|
+
leaves at least two standing.
|
|
105
|
+
|
|
106
|
+
**A verification section on both.** Neither page showed how to tell a working
|
|
107
|
+
record from a broken one — the failure `ingesting.md` opens by warning about had
|
|
108
|
+
no instrument. `deploying.md` gained the same for auth.
|
|
109
|
+
|
|
110
|
+
**Corrections found by the read:** the local `docker run` example could not work
|
|
111
|
+
as written (a container binds `0.0.0.0`, so it needs `KSOR_AUTH=disabled-public`
|
|
112
|
+
even on a laptop); the summary table sold the site as "upload a folder to any
|
|
113
|
+
static host" while its build refuses without a DSN; `KSOR_AUTH` had no documented
|
|
114
|
+
value for the SSO path (you unset it); `KSOR_ALLOWED_HOSTS`, `KSOR_ALLOWED_ORIGINS`
|
|
115
|
+
and snapshot-key rotation had no formats; `KSOR_MCP_RESOURCE_URL` was ambiguous
|
|
116
|
+
about the `/mcp` path; and an ordinary ingest needs a site rebuild too, which was
|
|
117
|
+
stated only for takedowns.
|
|
118
|
+
|
|
119
|
+
The pooler section — the longest technical passage in `ingesting.md`, about a
|
|
120
|
+
classification the same section calls informational — is cut to four sentences.
|
|
121
|
+
|
|
122
|
+
Adds a fourth recipe: **Better Auth**, an organization's own SSO — the case that
|
|
123
|
+
matters most for "vendor-free is the ownership argument", because it is the one
|
|
124
|
+
with no vendor in it. It is also the simplest shape on the page: a static public
|
|
125
|
+
client with PKCE, **no client secret**, no dynamic client registration, and no
|
|
126
|
+
authorize-this-client-for-that-resource step at all — the step that costs an
|
|
127
|
+
afternoon elsewhere simply does not exist.
|
|
128
|
+
|
|
129
|
+
Both it and Auth0 were connected to the same assistant against the same record,
|
|
130
|
+
changing only environment variables. That is the neutrality claim in its testable
|
|
131
|
+
form: **moving authorization servers is an environment change, not a code
|
|
132
|
+
change**, and the two audience variables do not change at all because they
|
|
133
|
+
describe the record rather than the provider.
|
|
134
|
+
|
|
135
|
+
Also names the vendor behind the JWKS fallback (`/api/auth/jwks` is Better
|
|
136
|
+
Auth's layout) — the cold read flagged it as "a vendor default and the vendor is
|
|
137
|
+
never named", and it turns out to be the same stack the door was first written
|
|
138
|
+
against.
|
|
139
|
+
|
|
140
|
+
- 48f2a3c: Search the record in the language it is written in.
|
|
141
|
+
|
|
142
|
+
The scaffolded site pinned its search index to English tokenization. That is a
|
|
143
|
+
per-language splitter regex, and English's is Latin-only — so an Urdu, Chinese,
|
|
144
|
+
Japanese or Korean document indexed to **zero tokens** and could not be found,
|
|
145
|
+
while its page still rendered, still appeared in the sidebar, and still appeared
|
|
146
|
+
in `llms.txt`. Nothing went red. A record written in a non-Latin script was
|
|
147
|
+
published complete and silently unsearchable, which broke ksor's own claim to
|
|
148
|
+
hold "plain markdown, in any language you write in".
|
|
149
|
+
|
|
150
|
+
The pin bought nothing in exchange. Since fumadocs-core 16.14.0 the engine is
|
|
151
|
+
ZBSearch, which disables stemming and installs empty stopwords by default, so
|
|
152
|
+
`english` and `multilingual` return identical results on English text —
|
|
153
|
+
including the same miss (`recordings` does not find `recording`) under both.
|
|
154
|
+
|
|
155
|
+
The option is removed, so the engine keeps its own `multilingual` default and
|
|
156
|
+
segments every script. Existing English records are unaffected apart from a
|
|
157
|
+
small index-size change: the multilingual segmenter splits hyphenated
|
|
158
|
+
identifiers that the English splitter kept whole, which grows the exported index
|
|
159
|
+
by roughly 2% on a technical corpus.
|
|
160
|
+
|
|
161
|
+
Restoring stemming for any language remains available and is a separate,
|
|
162
|
+
deliberate change — it needs a stemmer dependency and the same tokenizer handed
|
|
163
|
+
to the browser, not a one-word option.
|
|
164
|
+
|
|
3
165
|
## 0.0.32
|
|
4
166
|
|
|
5
167
|
### Patch Changes
|
package/docs/authorization.md
CHANGED
|
@@ -9,28 +9,79 @@ status: draft
|
|
|
9
9
|
posture, and it means the last step of a deployment is standing up an
|
|
10
10
|
authorization server and pointing the door at it.
|
|
11
11
|
|
|
12
|
-
This page is
|
|
12
|
+
This page is four worked recipes, all executed against real servers rather than
|
|
13
13
|
written from their documentation, plus what an agent does to obtain a token. The
|
|
14
|
-
mechanism is standard OAuth 2.0 — nothing here is specific to
|
|
15
|
-
that is the point:
|
|
16
|
-
proves nothing about neutrality.
|
|
14
|
+
mechanism is standard OAuth 2.0 — nothing here is specific to any one product,
|
|
15
|
+
and that is the point: three different implementations are shown because a
|
|
16
|
+
single one proves nothing about neutrality. Two are self-hosted (one `docker
|
|
17
|
+
run` each, no account), one is a hosted commercial provider with a free tier,
|
|
18
|
+
and one is an organization's own SSO — which is the case that matters most,
|
|
19
|
+
because it is the one where no vendor is involved at all.
|
|
20
|
+
|
|
21
|
+
The claim they exist to support is narrow and testable: **moving between
|
|
22
|
+
authorization servers is an environment change, not a code change.** Three
|
|
23
|
+
variables point at a different provider and the door does not know the
|
|
24
|
+
difference — no rebuild, no redeploy of the container, and the two audience
|
|
25
|
+
variables do not even change, because they describe your record rather than
|
|
26
|
+
the provider.
|
|
27
|
+
|
|
28
|
+
## What this protects — and what it does not
|
|
29
|
+
|
|
30
|
+
Read this before spending an afternoon on a provider's console.
|
|
31
|
+
|
|
32
|
+
**It protects the MCP door, not the website.** Everything on this page is a
|
|
33
|
+
bearer token on `POST /mcp`. Your static site is a separate surface, served by
|
|
34
|
+
whatever hosts it, and configuring auth here leaves it exactly as public as it
|
|
35
|
+
was. If people must not read the record at all, the site needs its own access
|
|
36
|
+
control — or must not be published.
|
|
37
|
+
|
|
38
|
+
**It is one gate, not per-user rules.** The door checks that a token was signed
|
|
39
|
+
by the issuer you named and audienced at this record. It reads no scopes, no
|
|
40
|
+
roles, no groups. **Any caller holding a valid token gets the whole record**, to
|
|
41
|
+
the extent the audience tier allows. If different readers must see different
|
|
42
|
+
documents, that is the record's `audiences:` / `visibility:` model, and it is a
|
|
43
|
+
different mechanism from this page — see the scaffold's AGENTS.md.
|
|
44
|
+
|
|
45
|
+
So: this page answers _"can a stranger read my record over MCP?"_ It does not
|
|
46
|
+
answer _"can Alice read what Bob can."_
|
|
17
47
|
|
|
18
48
|
## What the door needs
|
|
19
49
|
|
|
20
|
-
Three variables
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
record
|
|
50
|
+
**Three** variables turn auth on. The rest are hardening: add them once it
|
|
51
|
+
works, not while you are trying to make it work.
|
|
52
|
+
|
|
53
|
+
| variable | what it is | where the value comes from |
|
|
54
|
+
| ---------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
|
55
|
+
| `KSOR_SSO_URL` | your authorization server's base URL, used to FIND its metadata document | the AS itself |
|
|
56
|
+
| `KSOR_MCP_RESOURCE_URL` | **this record's** canonical URL — the identifier a token must be audienced at | you choose it; it is the public URL agents reach the door on |
|
|
57
|
+
| `KSOR_JWT_ALLOWED_AUDIENCES` | which audiences are accepted, comma-separated | normally exactly `KSOR_MCP_RESOURCE_URL` |
|
|
58
|
+
| `KSOR_SSO_ISSUER` | the issuer to enforce | the `iss` claim of a real token — see below |
|
|
59
|
+
| `KSOR_ALLOWED_HOSTS` | Host header allow-list, comma-separated `host:port` (bare host on 80/443) | the hostname you serve on |
|
|
60
|
+
| `KSOR_SNAPSHOT_KEYS` | `kid=secret[,kid2=secret2]`, first active; identical on every replica | `openssl rand -hex 32` — used as literal text, never hex-decoded |
|
|
61
|
+
|
|
62
|
+
**First, delete `KSOR_AUTH` if it is set.** It is the _auth-off_ posture —
|
|
63
|
+
`disabled-local` for a loopback dev run, `disabled-public` to serve the record
|
|
64
|
+
to anyone who can reach the port — and it wins over everything below. A scaffold
|
|
65
|
+
ships with `KSOR_AUTH=disabled-local` in its `.env.example`, so a deployment
|
|
66
|
+
that copied that file has it set and will stay unauthenticated no matter how
|
|
67
|
+
carefully you configure the four variables above. Configuring the SSO door is
|
|
68
|
+
what turns auth **on**; removing `KSOR_AUTH` is what stops it being off.
|
|
69
|
+
|
|
70
|
+
Every variable here is read from the environment of the `ksor serve` process —
|
|
71
|
+
your host's environment panel, or a `.env` beside `instance.md`. **Changing any
|
|
72
|
+
of them requires restarting the door**; nothing is re-read at runtime.
|
|
73
|
+
|
|
74
|
+
`KSOR_MCP_RESOURCE_URL` is this record's **name**, in the RFC 8707 sense: a
|
|
75
|
+
token minted for a different resource is refused even when the signature is
|
|
76
|
+
perfect and the issuer is right. That is what stops a token issued for some
|
|
77
|
+
other service being replayed at your record.
|
|
78
|
+
|
|
79
|
+
**Use the real URL agents reach the door on.** Your authorization server never
|
|
80
|
+
fetches it — but a CLIENT does. An unauthorized request answers `401` with
|
|
81
|
+
`www-authenticate: Bearer resource_metadata="…"`, and the client follows that to
|
|
82
|
+
a document the door serves at its own host. Invent a value that does not resolve
|
|
83
|
+
and the boot report will still look green while every standards-following client
|
|
84
|
+
fails to discover where to authenticate.
|
|
34
85
|
|
|
35
86
|
The door finds the signing keys by discovery, in this order, and says at boot
|
|
36
87
|
which one it used:
|
|
@@ -39,15 +90,106 @@ which one it used:
|
|
|
39
90
|
1. KSOR_JWKS_URL you stated the URI outright
|
|
40
91
|
2. /.well-known/oauth-authorization-server RFC 8414 metadata
|
|
41
92
|
3. /.well-known/openid-configuration OIDC discovery
|
|
42
|
-
4. <KSOR_SSO_URL>/api/auth/jwks
|
|
93
|
+
4. <KSOR_SSO_URL>/api/auth/jwks Better Auth's layout, reported as a GUESS
|
|
43
94
|
```
|
|
44
95
|
|
|
45
|
-
|
|
96
|
+
**`KSOR_SSO_URL` and `KSOR_SSO_ISSUER` are different strings, and often differ
|
|
97
|
+
by a trailing slash.** The first is a base that paths are joined onto; the
|
|
98
|
+
second is compared **byte-exact** against the token's `iss` claim. Setting both
|
|
99
|
+
to the same value is the commonest cause of a 401 on a perfectly good token.
|
|
100
|
+
Mint one token, decode it, and copy `iss` out of it verbatim.
|
|
101
|
+
|
|
102
|
+
**`KSOR_SSO_ISSUER` is optional, and you cannot set it correctly until auth
|
|
103
|
+
already works** — it comes from a token you must first be able to mint. Get the
|
|
104
|
+
three required variables working, decode a token, then add it. Unset, the issuer
|
|
105
|
+
is simply not checked (`auth.ts:398`); the signature, the audience and the
|
|
106
|
+
expiry all still are, and audience binding is what actually refuses a foreign
|
|
107
|
+
token.
|
|
108
|
+
|
|
109
|
+
What it buys is a better ERROR. Without it, a token from a _different_ authorization
|
|
46
110
|
server produces an unknown key id, which is indistinguishable from key-rotation
|
|
47
111
|
lag — so the door answers `503`, the client retries a credential that can never
|
|
48
112
|
work, and a misconfiguration reads as an outage. With the issuer declared, that
|
|
49
113
|
same token is refused `401` before any key is fetched.
|
|
50
114
|
|
|
115
|
+
## Connecting an assistant — the way most people will use this
|
|
116
|
+
|
|
117
|
+
The recipes below all end in a token you fetch with `curl`. That proves the door
|
|
118
|
+
verifies tokens, and it is not what you actually want: you want to open an
|
|
119
|
+
assistant and have it read your record. That path is the same for every provider,
|
|
120
|
+
so it is written once, here.
|
|
121
|
+
|
|
122
|
+
**What it needs from your provider — one browser client, separate from any
|
|
123
|
+
machine one.** A `client_credentials` application has no browser and no redirect;
|
|
124
|
+
filling in its callback field changes nothing. Create a second client that does
|
|
125
|
+
`authorization_code`, and set its callback to the one your assistant uses. For
|
|
126
|
+
Claude's hosted surfaces:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
https://claude.ai/api/mcp/auth_callback
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then **authorize that client for your record's resource**. Every provider spells
|
|
133
|
+
this differently — Auth0 calls it Application Access, Keycloak grants it through
|
|
134
|
+
scope and audience mapping — and it is the step that most often looks done and
|
|
135
|
+
is not. Skipping it gives an error at the authorization endpoint rather than at
|
|
136
|
+
the token endpoint, so the login never even reaches your record:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
Client "…" is not authorized to access resource server "https://your-host/mcp"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Finally, add the connector: the door's URL (`https://your-host/mcp`), plus the
|
|
143
|
+
browser client's ID and secret. Those credentials go **into the assistant**,
|
|
144
|
+
never into ksor — the door holds no client credentials and cannot mint a token
|
|
145
|
+
for itself.
|
|
146
|
+
|
|
147
|
+
**What you should see.** The assistant sends you to your provider's login page,
|
|
148
|
+
you sign in, and it returns with the record's tools available. If instead you get
|
|
149
|
+
a 401 from the door after a successful login, decode the token and compare `aud`
|
|
150
|
+
against `KSOR_MCP_RESOURCE_URL` before looking anywhere else — that is the one
|
|
151
|
+
failure that looks like a broken server and is a mismatched name.
|
|
152
|
+
|
|
153
|
+
Standards-following clients send RFC 8707 (`resource=https://your-host/mcp`) on
|
|
154
|
+
the authorization request, so a provider that honours it needs no vendor-specific
|
|
155
|
+
audience parameter and no mapper.
|
|
156
|
+
|
|
157
|
+
## Will your provider work? Three questions, before you start
|
|
158
|
+
|
|
159
|
+
Answer these before creating anything. A provider that fails any one of them
|
|
160
|
+
cannot be used, and you will not discover that until you are several screens
|
|
161
|
+
into its console.
|
|
162
|
+
|
|
163
|
+
**1. Does it issue RS256 JWTs, not opaque tokens?**
|
|
164
|
+
The door verifies signatures itself (`algorithms: ["RS256"]`) and makes **no
|
|
165
|
+
introspection call** — there is no code path that asks your provider whether a
|
|
166
|
+
token is good. An opaque token it cannot read is a token it must refuse.
|
|
167
|
+
|
|
168
|
+
**2. Does it publish a metadata document?**
|
|
169
|
+
Keys are DISCOVERED, in this order: `KSOR_JWKS_URL` if you set it, then RFC 8414
|
|
170
|
+
(`/.well-known/oauth-authorization-server`), then OpenID Discovery
|
|
171
|
+
(`/.well-known/openid-configuration`). Any standards-compliant authorization
|
|
172
|
+
server advertises `jwks_uri` in one of those. If yours publishes neither, you
|
|
173
|
+
must supply `KSOR_JWKS_URL` yourself and hope it is stable.
|
|
174
|
+
|
|
175
|
+
**3. Can it mint a token audienced at YOUR identifier?**
|
|
176
|
+
Either through RFC 8707 (`resource=https://your-host/mcp` on the authorization
|
|
177
|
+
request) or a vendor parameter (`audience=` on Auth0, an audience mapper on
|
|
178
|
+
Keycloak). A provider that only ever issues tokens audienced at its own userinfo
|
|
179
|
+
endpoint cannot express "this token is for that record", and audience binding is
|
|
180
|
+
the whole point of the resource-server posture.
|
|
181
|
+
|
|
182
|
+
### A provider that fails these
|
|
183
|
+
|
|
184
|
+
Products built for **user sessions in your own app** frequently do. Their
|
|
185
|
+
machine-to-machine tokens are minted and verified by calling _their_ backend —
|
|
186
|
+
opaque by default, no OAuth token endpoint taking a custom audience, no metadata
|
|
187
|
+
document. That is a coherent design; it is simply a different one, and adapting
|
|
188
|
+
it means writing the verification layer ksor already is.
|
|
189
|
+
|
|
190
|
+
The three recipes below all pass. If yours does too, they will read as the same
|
|
191
|
+
recipe with different button names — because underneath they are.
|
|
192
|
+
|
|
51
193
|
## Recipe: Keycloak
|
|
52
194
|
|
|
53
195
|
Run it:
|
|
@@ -140,6 +282,204 @@ Hydra publishes its keys at `/.well-known/jwks.json` rather than Keycloak's
|
|
|
140
282
|
`/protocol/openid-connect/certs`. Nothing in ksor knows that; discovery reads it
|
|
141
283
|
from the metadata document, which is why the door works against both unmodified.
|
|
142
284
|
|
|
285
|
+
## Recipe: Auth0
|
|
286
|
+
|
|
287
|
+
A hosted provider with a free tier, and the one whose vocabulary causes the most
|
|
288
|
+
trouble — so this recipe is written around the confusions rather than around the
|
|
289
|
+
happy path. Every step below is one that was got wrong first, on a real tenant.
|
|
290
|
+
|
|
291
|
+
**Auth0's "API" is your ksor door. Auth0's "Application" is whoever calls it.**
|
|
292
|
+
Nothing else in this recipe makes sense until that lands. You are not building
|
|
293
|
+
an API; you are describing the one you already have so Auth0 can mint tokens
|
|
294
|
+
aimed at it.
|
|
295
|
+
|
|
296
|
+
### 1. Describe the door
|
|
297
|
+
|
|
298
|
+
**Applications → APIs → Create API.** The **Identifier** you type becomes the
|
|
299
|
+
audience — use your record's MCP URL. It is a name, not a fetch target; it never
|
|
300
|
+
has to resolve.
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
Name: my-record
|
|
304
|
+
Identifier: https://your-host.example.com/mcp
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Creating it also creates a machine-to-machine **test application** named
|
|
308
|
+
`<API> (Test Application)`. That is your first caller — you do not need to make
|
|
309
|
+
one.
|
|
310
|
+
|
|
311
|
+
### 2. Point the door at the tenant
|
|
312
|
+
|
|
313
|
+
```sh
|
|
314
|
+
KSOR_SSO_URL=https://YOUR_TENANT.us.auth0.com
|
|
315
|
+
KSOR_SSO_ISSUER=https://YOUR_TENANT.us.auth0.com/
|
|
316
|
+
KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
|
|
317
|
+
KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Mind the trailing slash on the issuer.** Auth0's `iss` carries one and
|
|
321
|
+
`KSOR_SSO_URL` does not; they are deliberately different strings. Copy `iss`
|
|
322
|
+
out of a real token rather than typing it.
|
|
323
|
+
|
|
324
|
+
**Delete any `KSOR_AUTH`.** Configuring the SSO door is what turns auth on;
|
|
325
|
+
leaving a disabled posture set keeps it off no matter what else is configured.
|
|
326
|
+
|
|
327
|
+
### 3. A scripted caller (machine-to-machine)
|
|
328
|
+
|
|
329
|
+
The test application already works **for this grant only** — creating the API
|
|
330
|
+
authorized it. Do not read that as permission to skip step 5: any application
|
|
331
|
+
you create yourself starts unauthorized, and that is where the next hour goes.
|
|
332
|
+
|
|
333
|
+
Its credentials live under
|
|
334
|
+
**Applications → Applications → `<API> (Test Application)` → Settings**.
|
|
335
|
+
|
|
336
|
+
```sh
|
|
337
|
+
curl -s -X POST https://YOUR_TENANT.us.auth0.com/oauth/token \
|
|
338
|
+
-H 'content-type: application/json' \
|
|
339
|
+
-d '{"grant_type":"client_credentials","client_id":"…","client_secret":"…",
|
|
340
|
+
"audience":"https://your-host.example.com/mcp"}'
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### 4. An interactive caller (a person, through a browser)
|
|
344
|
+
|
|
345
|
+
An assistant that logs a human in needs a **different application**, because a
|
|
346
|
+
machine-to-machine application has no browser and no redirect — filling in its
|
|
347
|
+
callback field changes nothing.
|
|
348
|
+
|
|
349
|
+
**Applications → Create Application → Regular Web Application**, then on it:
|
|
350
|
+
|
|
351
|
+
- **Allowed Callback URLs**: the client's callback. For Claude's hosted
|
|
352
|
+
surfaces that is `https://claude.ai/api/mcp/auth_callback`.
|
|
353
|
+
- **Save Changes** — the button is at the very bottom and nothing autosaves.
|
|
354
|
+
|
|
355
|
+
Its **Client ID and Secret go into the client**, not into ksor — the connector
|
|
356
|
+
form of whatever assistant you are configuring. The door never holds client
|
|
357
|
+
credentials and cannot mint a token for itself; it only verifies what it is
|
|
358
|
+
handed. Then do step 5, or the login will succeed and the token request will
|
|
359
|
+
not.
|
|
360
|
+
|
|
361
|
+
### 5. Authorize the caller for the door — the step that hides
|
|
362
|
+
|
|
363
|
+
A new application is not allowed to request your API. Auth0 refuses with:
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
Client "…" is not authorized to access resource server "https://your-host.example.com/mcp"
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The fix is on the **API**, not the application, and **it is not a toggle**:
|
|
370
|
+
|
|
371
|
+
**APIs → your API → Application Access → find the application → `Edit` →
|
|
372
|
+
`Grant Access`.**
|
|
373
|
+
|
|
374
|
+
The greyed pills in that table are progress bars. The control is inside the side
|
|
375
|
+
panel the `Edit` button opens, and `Grant ID: No per-app authorization grant`
|
|
376
|
+
underneath confirms whether one exists.
|
|
377
|
+
|
|
378
|
+
Pick the right column:
|
|
379
|
+
|
|
380
|
+
| column | grant | used by |
|
|
381
|
+
| ------------------------- | -------------------- | ----------------------------------------- |
|
|
382
|
+
| **User-delegated Access** | `authorization_code` | an assistant acting as a signed-in person |
|
|
383
|
+
| **Client Access** | `client_credentials` | a script, worker or backend agent |
|
|
384
|
+
|
|
385
|
+
An application needs only the one it uses. Granting also ticks **"Always grant
|
|
386
|
+
all permissions"**, which matters only if you later add scopes to this API —
|
|
387
|
+
ksor checks issuer and audience, never scopes.
|
|
388
|
+
|
|
389
|
+
### What Auth0 gets right
|
|
390
|
+
|
|
391
|
+
It honours RFC 8707. An MCP client sending
|
|
392
|
+
`resource=https://your-host.example.com/mcp` gets a token audienced there, with
|
|
393
|
+
no `audience=` parameter and no mapper — which is why the authorization request
|
|
394
|
+
works unmodified once the grant exists.
|
|
395
|
+
|
|
396
|
+
## Recipe: Better Auth
|
|
397
|
+
|
|
398
|
+
The simplest of the four, and the one the door was originally written against —
|
|
399
|
+
the JWKS fallback in `auth.ts` is Better Auth's layout (`/api/auth/jwks`), which
|
|
400
|
+
is why an unconfigured `KSOR_JWKS_URL` still finds keys on a Better Auth
|
|
401
|
+
deployment even if discovery never runs.
|
|
402
|
+
|
|
403
|
+
It is also the only recipe here with **no client secret**, because a static
|
|
404
|
+
public client with PKCE is enough:
|
|
405
|
+
|
|
406
|
+
```sh
|
|
407
|
+
KSOR_SSO_URL=https://auth.your-org.example
|
|
408
|
+
KSOR_MCP_RESOURCE_URL=https://your-host.example.com/mcp
|
|
409
|
+
KSOR_JWT_ALLOWED_AUDIENCES=https://your-host.example.com/mcp
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Three variables, no fourth. Register one OAuth client with:
|
|
413
|
+
|
|
414
|
+
- **PKCE required**, `token_endpoint_auth_method: none` — a public client, so
|
|
415
|
+
there is no secret to distribute, rotate, or leak into a config file
|
|
416
|
+
- the assistant's callback in its redirect list
|
|
417
|
+
(`https://claude.ai/api/mcp/auth_callback` for Claude's hosted surfaces)
|
|
418
|
+
|
|
419
|
+
### Why this shape is worth preferring
|
|
420
|
+
|
|
421
|
+
**Nothing to authorize afterwards.** The step that costs an afternoon on Auth0 —
|
|
422
|
+
finding where a client is granted access to a resource server — does not exist
|
|
423
|
+
here. A registered client can request your resource.
|
|
424
|
+
|
|
425
|
+
**No dynamic client registration.** DCR is what an assistant falls back to when
|
|
426
|
+
you have not given it a client, and it brings its own tenant-wide toggles and
|
|
427
|
+
third-party permission defaults. A statically-registered public client skips all
|
|
428
|
+
of it.
|
|
429
|
+
|
|
430
|
+
**No secret in the assistant's config.** PKCE proves the caller is the same one
|
|
431
|
+
that started the flow, without a shared secret. There is simply less to get
|
|
432
|
+
wrong, and less to leak.
|
|
433
|
+
|
|
434
|
+
If your organization runs its own SSO, this is the shape to ask for.
|
|
435
|
+
|
|
436
|
+
## Verify it — against the door, not the provider
|
|
437
|
+
|
|
438
|
+
If you connected an assistant and it read your record, auth works — that is the
|
|
439
|
+
end-to-end proof and you can stop here.
|
|
440
|
+
|
|
441
|
+
Use the commands below when it did NOT work, or when the caller is a script
|
|
442
|
+
rather than a person. A token from your provider proves the provider works; it
|
|
443
|
+
does not prove the door does. Both halves matter, and the refusal matters more.
|
|
444
|
+
|
|
445
|
+
**1. Decode the token before using it.** This is the single most useful
|
|
446
|
+
debugging step on this page, because a valid token audienced at the wrong thing
|
|
447
|
+
looks identical to a broken one:
|
|
448
|
+
|
|
449
|
+
```sh
|
|
450
|
+
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, exp}'
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
- `aud` must equal your `KSOR_MCP_RESOURCE_URL`. If it is your provider's
|
|
454
|
+
`/userinfo` endpoint, the audience never carried — fix that before anything
|
|
455
|
+
else.
|
|
456
|
+
- `iss` is what belongs in `KSOR_SSO_ISSUER`, character for character.
|
|
457
|
+
|
|
458
|
+
**2. No token must be REFUSED.** Run this first; it is the half that proves auth
|
|
459
|
+
is on at all:
|
|
460
|
+
|
|
461
|
+
```sh
|
|
462
|
+
curl -s -i -X POST https://your-host.example.com/mcp \
|
|
463
|
+
-H 'content-type: application/json' -d '{}' | head -5
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Expect `401`, and a `www-authenticate: Bearer resource_metadata="…"` header. A
|
|
467
|
+
`200` here means auth is off — check `KSOR_AUTH` is unset and that you restarted.
|
|
468
|
+
|
|
469
|
+
**3. A good token must be ACCEPTED:**
|
|
470
|
+
|
|
471
|
+
```sh
|
|
472
|
+
curl -s -X POST https://your-host.example.com/mcp \
|
|
473
|
+
-H "authorization: Bearer $TOKEN" \
|
|
474
|
+
-H 'content-type: application/json' \
|
|
475
|
+
-H 'accept: application/json, text/event-stream' \
|
|
476
|
+
-H 'mcp-protocol-version: 2025-11-25' \
|
|
477
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Expect the record's tools. A `401` here with a `200` above means the token is
|
|
481
|
+
being read and rejected — go back to step 1 and compare `aud` and `iss`.
|
|
482
|
+
|
|
143
483
|
## What an agent does
|
|
144
484
|
|
|
145
485
|
Ask the token endpoint for a token audienced at the record, then send it as an
|
|
@@ -187,7 +527,10 @@ A genuine key-rotation lag stays a `503` on purpose: it is transient, retrying i
|
|
|
187
527
|
the right response, and the refusal is never cached — a valid bearer is
|
|
188
528
|
re-admitted the instant the key set catches up.
|
|
189
529
|
|
|
190
|
-
## Before a public bind
|
|
530
|
+
## Before a public bind — recap
|
|
531
|
+
|
|
532
|
+
Everything here is stated above; it is repeated because it is the checklist you
|
|
533
|
+
want open while deploying.
|
|
191
534
|
|
|
192
535
|
- Auth configured as above, **or** `KSOR_AUTH=disabled-public` set
|
|
193
536
|
deliberately — the door will not come up on a public address without one of
|
package/docs/deploying.md
CHANGED
|
@@ -5,6 +5,36 @@ status: draft
|
|
|
5
5
|
|
|
6
6
|
# Deploying a Knowledge System of Record
|
|
7
7
|
|
|
8
|
+
## Before you start
|
|
9
|
+
|
|
10
|
+
Four things must exist, and the order matters. Nothing below works without them,
|
|
11
|
+
and three of the four are outside this page.
|
|
12
|
+
|
|
13
|
+
| | what | where |
|
|
14
|
+
| --- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
15
|
+
| 1 | **A Postgres with pgvector** — a managed one is fine | your provider; `CREATE EXTENSION vector;` |
|
|
16
|
+
| 2 | **A provider key** for embeddings — `GEMINI_API_KEY` | [aistudio.google.com](https://aistudio.google.com/apikey) — free tier is enough to start |
|
|
17
|
+
| 3 | **The schema applied, and ingest authorized** | `pnpm provision` (runs `ksor schema` + `ksor grant`) |
|
|
18
|
+
| 4 | **A published generation** | `pnpm refresh` (runs `ksor ingest --flip`) |
|
|
19
|
+
|
|
20
|
+
Steps 3 and 4 are [ingesting.md](./ingesting.md), and **they come before your
|
|
21
|
+
first deploy, not after**. A door with no published generation boots healthy and
|
|
22
|
+
serves an empty record — the single most common "it deployed but does not work".
|
|
23
|
+
|
|
24
|
+
The whole sequence, end to end:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
provision the database → ksor schema → ksor grant → ksor ingest --flip
|
|
28
|
+
→ set environment variables → deploy → verify → calibrate
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Calibration is last on purpose: it measures the corpus, so the corpus has to be
|
|
32
|
+
in there first. Until you run it, `/health` will report `abstain OFF` and the
|
|
33
|
+
record will answer out-of-corpus questions instead of declining — honest, and
|
|
34
|
+
not what most people want to ship.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
8
38
|
A KSoR publishes two surfaces from one record, and they deploy differently
|
|
9
39
|
because they are different things:
|
|
10
40
|
|
|
@@ -50,6 +80,14 @@ docker build -t my-record .
|
|
|
50
80
|
docker run --rm -p 8080:80 --env-file .env my-record
|
|
51
81
|
```
|
|
52
82
|
|
|
83
|
+
Two things about that command, both of which bite:
|
|
84
|
+
|
|
85
|
+
- **The image listens on 80**, which is why the mapping is `8080:80`. If your
|
|
86
|
+
`.env` sets `PORT`, change the right-hand side to match.
|
|
87
|
+
- **`.env` must contain `KSOR_AUTH=disabled-public`, even locally.** A container
|
|
88
|
+
gets `$PORT` and therefore binds `0.0.0.0` — a public bind — and
|
|
89
|
+
`disabled-local` refuses there by design. Your laptop is not the exception.
|
|
90
|
+
|
|
53
91
|
That runs on Cloud Run, Fly, Render, ECS, Kubernetes, or a VPS with no changes.
|
|
54
92
|
`vercel.json` **points at this same file** rather than replacing it, which is
|
|
55
93
|
what keeps the host a choice — the artifact is yours, and moving it is a
|
|
@@ -121,6 +159,11 @@ KSOR_AUTH=disabled-local # no auth, loopback only — a public bind REFUSES
|
|
|
121
159
|
KSOR_AUTH=disabled-public # no auth, and served to anyone who can reach the port
|
|
122
160
|
```
|
|
123
161
|
|
|
162
|
+
`KSOR_AUTH` has no "on" value. Auth is on when the SSO door is configured and
|
|
163
|
+
`KSOR_AUTH` is **absent**; setting it to either value turns auth off. If you
|
|
164
|
+
move from a disabled posture to a real provider, deleting the variable is part
|
|
165
|
+
of the change.
|
|
166
|
+
|
|
124
167
|
**A container sets `$PORT`, so the door binds `0.0.0.0` — a public bind.**
|
|
125
168
|
`disabled-local` refuses there, deliberately: copying a dev `.env` into a hosting
|
|
126
169
|
dashboard must not quietly open your record to the internet. `disabled-public` is
|
|
@@ -152,7 +195,14 @@ KSOR_SNAPSHOT_KEYS="k1=$(openssl rand -hex 32)"
|
|
|
152
195
|
```
|
|
153
196
|
|
|
154
197
|
`k1` is a label, not a secret — it appears in the token as `key_id` so you can
|
|
155
|
-
rotate later.
|
|
198
|
+
rotate later. Rotation is comma-separated, newest first, keeping the old key
|
|
199
|
+
until outstanding tokens age out (30 minutes):
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
KSOR_SNAPSHOT_KEYS="k2=<new secret>,k1=<old secret>"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The secret is used as literal text, never hex-decoded, and must be
|
|
156
206
|
byte-identical across every instance of one deployment. Set it once and leave it
|
|
157
207
|
alone: rotating invalidates every outstanding pin, and a compromised snapshot key
|
|
158
208
|
cannot read a withdrawn document, cross an audience boundary, or authenticate
|
package/docs/index.md
CHANGED
|
@@ -68,9 +68,9 @@ instance declares an `audiences:` model, documents may carry a
|
|
|
68
68
|
`visibility:` key and per-audience builds (`KSOR_AUDIENCE=<tier> pnpm
|
|
69
69
|
build`) stage only what that tier may see — publication, not authorship:
|
|
70
70
|
anyone who can clone reads everything. The site shell
|
|
71
|
-
at `system/site/` is replaceable behind a five-clause surface contract
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
at `system/site/` is replaceable behind a five-clause surface contract — the
|
|
72
|
+
shell is a slot, and the contract (render the record, `llms.txt`, per-page md
|
|
73
|
+
artifacts, browser smoke, no authored content) is what a replacement must meet.
|
|
74
74
|
|
|
75
75
|
## Where truth lives
|
|
76
76
|
|
package/docs/ingesting.md
CHANGED
|
@@ -14,6 +14,23 @@ embedding cost on every cold start and would need write credentials at runtime.
|
|
|
14
14
|
So **a first deploy with no ingest serves an empty record.** It is not broken;
|
|
15
15
|
nothing was ever published to it.
|
|
16
16
|
|
|
17
|
+
## Before the first command
|
|
18
|
+
|
|
19
|
+
Ingest reads your markdown, sends each new chunk to an embedding provider, and
|
|
20
|
+
writes the result to Postgres. So four things must be true, and none of them is
|
|
21
|
+
created for you.
|
|
22
|
+
|
|
23
|
+
| | what | how |
|
|
24
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
25
|
+
| **The corpus** | `knowledge/` at your repo root — CommonMark `.md`, one document per file, `title` and `status` in frontmatter | `pnpm check` validates it and explains any violation |
|
|
26
|
+
| **The database** | Postgres with **pgvector** — `CREATE EXTENSION vector;` | any managed host; the DDL below needs a role that can create tables |
|
|
27
|
+
| **The provider key** | `GEMINI_API_KEY` — the default embedding provider is `gemini-embedding-001` | [aistudio.google.com](https://aistudio.google.com/apikey); the free tier covers a first corpus |
|
|
28
|
+
| **The DSN** | `KSOR_DB_URL`, named by `instance.md`'s `database.dsn_env` | uncomment the `database:` block in `instance.md` first |
|
|
29
|
+
|
|
30
|
+
Both variables go in `.env` beside `instance.md` — `ksor` reads it automatically,
|
|
31
|
+
and `.env` is gitignored. Every command below is run **from your repository
|
|
32
|
+
root**, where `instance.md` and `package.json` live.
|
|
33
|
+
|
|
17
34
|
## The order, once
|
|
18
35
|
|
|
19
36
|
```sh
|
|
@@ -29,17 +46,29 @@ existing grant says "already granted".
|
|
|
29
46
|
Then, after every change to `knowledge/`:
|
|
30
47
|
|
|
31
48
|
```sh
|
|
32
|
-
pnpm refresh
|
|
49
|
+
pnpm refresh # publishes to the agent surface
|
|
50
|
+
pnpm build # rebuilds the website from the same corpus
|
|
33
51
|
```
|
|
34
52
|
|
|
53
|
+
**Both surfaces, every time.** `refresh` publishes a generation the MCP door
|
|
54
|
+
serves immediately; the website is a static build and only changes when you
|
|
55
|
+
rebuild and redeploy it. Ingest alone leaves the human surface showing the old
|
|
56
|
+
content, which reads as a half-failed ingest and is not one.
|
|
57
|
+
|
|
35
58
|
## What a generation is
|
|
36
59
|
|
|
37
60
|
Each ingest builds a **fresh generation** — invisible until activated — and
|
|
38
61
|
carries every unchanged embedding forward from the last complete one, matched by
|
|
39
62
|
content hash. Only changed or previously-failed chunks are re-embedded.
|
|
40
63
|
|
|
41
|
-
`--flip` swaps the active pointer
|
|
42
|
-
target
|
|
64
|
+
`--flip` swaps the active pointer, and the previous generation stays as a
|
|
65
|
+
rollback target.
|
|
66
|
+
|
|
67
|
+
**`gc` will not take it.** It never collects the active generation, the rollback
|
|
68
|
+
generation, or any generation a live snapshot token could still pin, and it
|
|
69
|
+
always leaves at least two complete generations standing. That is why `pnpm
|
|
70
|
+
refresh` can safely run `ingest` and `gc` back to back — the routine command
|
|
71
|
+
does not eat the safety net it just created.
|
|
43
72
|
|
|
44
73
|
Three consequences worth knowing:
|
|
45
74
|
|
|
@@ -56,6 +85,31 @@ A flip that would drop more than `KSOR_MAX_SHRINK` of the record (default
|
|
|
56
85
|
`0.15`, i.e. 15%) **refuses**. When a large deletion is intended, say so:
|
|
57
86
|
`KSOR_ALLOW_SHRINK=1`.
|
|
58
87
|
|
|
88
|
+
## Did it work?
|
|
89
|
+
|
|
90
|
+
The failure this page opens with — a healthy door serving an empty record — is
|
|
91
|
+
invisible unless you look. Three checks, cheapest first:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
# 1. the door knows which corpus it serves, and its embedding space is intact
|
|
95
|
+
curl -s http://127.0.0.1:8080/health
|
|
96
|
+
|
|
97
|
+
# 2. something is actually published — ask for the record's structure
|
|
98
|
+
curl -s -X POST http://127.0.0.1:8080/mcp \
|
|
99
|
+
-H 'content-type: application/json' \
|
|
100
|
+
-H 'accept: application/json, text/event-stream' \
|
|
101
|
+
-H 'mcp-protocol-version: 2025-11-25' \
|
|
102
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"outline","arguments":{}}}'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
An empty `nodes` array means nothing was published — the ingest did not run, or
|
|
106
|
+
ran without `--flip`. Then search for a phrase you know is in the record and
|
|
107
|
+
check the hits carry `provenance.stable_id` and `generation`.
|
|
108
|
+
|
|
109
|
+
**`provenance.stable_id` is also how you name a document to `takedown`.** For
|
|
110
|
+
most documents it is `knowledge/<path-without-.md>`; a search result is the
|
|
111
|
+
reliable way to read one off rather than guessing.
|
|
112
|
+
|
|
59
113
|
## Where ingest runs — not on the host
|
|
60
114
|
|
|
61
115
|
Ingest is a long job. It embeds every new chunk through the provider, and that
|
|
@@ -88,22 +142,14 @@ carries forward everything that was already embedded, including from a
|
|
|
88
142
|
generation that was still `building` when it died, so a resumed run pays only
|
|
89
143
|
for what the first one had not reached.
|
|
90
144
|
|
|
91
|
-
## Endpoints
|
|
145
|
+
## Endpoints and poolers
|
|
92
146
|
|
|
93
147
|
If your provider offers both a **pooled** and a **direct** endpoint (Neon's
|
|
94
|
-
`-pooler` host, or
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
For the record: the 6,963-chunk ingest above ran through a **pooled** endpoint
|
|
101
|
-
without incident, and the same DSN serves. Use whichever your provider gives
|
|
102
|
-
you, and reach for the direct endpoint only if you actually hit pooler
|
|
103
|
-
connection limits under a parallel ingest — not pre-emptively.
|
|
104
|
-
|
|
105
|
-
`KSOR_DB_POOLED_ENDPOINT=1` forces the classification when your host name does
|
|
106
|
-
not announce itself.
|
|
148
|
+
`-pooler` host, or port 6432), use whichever it gives you. ksor detects which and
|
|
149
|
+
says so at boot, but the line is informational — it classifies, it never
|
|
150
|
+
transforms, and the hazard it descends from cannot arise here. The 6,963-chunk
|
|
151
|
+
ingest measured above ran through a pooled endpoint without incident. Reach for
|
|
152
|
+
the direct endpoint only if you actually hit pooler connection limits.
|
|
107
153
|
|
|
108
154
|
## Turning the abstention gate on
|
|
109
155
|
|
|
@@ -115,8 +161,8 @@ measure until the corpus is in there.
|
|
|
115
161
|
pnpm exec ksor calibrate --instance instance.md
|
|
116
162
|
```
|
|
117
163
|
|
|
118
|
-
It prints a recommended `vector_floor`. Paste it
|
|
119
|
-
it,
|
|
164
|
+
It prints a recommended `vector_floor`. Paste it into **`instance.md`** with the
|
|
165
|
+
date you measured it, then restart `ksor serve` — the floor is read at boot:
|
|
120
166
|
|
|
121
167
|
```yaml
|
|
122
168
|
retrieval:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.34",
|
|
4
4
|
"description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abstention",
|
|
@@ -708,8 +708,8 @@ You own `system/site/` outright — these are the seams, cheapest first:
|
|
|
708
708
|
documentation shell around it stay one palette.
|
|
709
709
|
- **Anything deeper** — edit the site like the Next.js app it is; the only
|
|
710
710
|
rule that survives customization is critical rule 1. The whole shell is
|
|
711
|
-
replaceable behind a five-clause contract
|
|
712
|
-
|
|
711
|
+
replaceable behind a five-clause contract: render the record, `llms.txt`,
|
|
712
|
+
per-page md artifacts, browser smoke, and no authored content.
|
|
713
713
|
|
|
714
714
|
## What this project owns
|
|
715
715
|
|
|
@@ -5,7 +5,18 @@ import { createFromSource } from "fumadocs-core/search/server";
|
|
|
5
5
|
// search dialog downloads and queries client-side (see app/layout.tsx).
|
|
6
6
|
export const revalidate = false;
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
// NO `language:` option, deliberately. The engine's own default is
|
|
9
|
+
// `multilingual`, which segments with `Intl.Segmenter` and indexes every script;
|
|
10
|
+
// naming a language selects a per-language splitter regex instead. `english`'s
|
|
11
|
+
// is Latin-only, so an Urdu or Chinese document indexed under it produces ZERO
|
|
12
|
+
// tokens and is unreachable by search — while its page still renders, still sits
|
|
13
|
+
// in the sidebar and still appears in llms.txt. Nothing goes red. That silently
|
|
14
|
+
// broke the record's own claim to hold "plain markdown, in any language they
|
|
15
|
+
// write in" (found by reading the shipped tokenizer, 2026-08-24).
|
|
16
|
+
//
|
|
17
|
+
// It bought nothing in exchange: since fumadocs-core 16.14.0 the engine is
|
|
18
|
+
// ZBSearch, which disables stemming and ships empty stopwords by default, so
|
|
19
|
+
// `english` and `multilingual` return identical results on English text.
|
|
20
|
+
// Restoring stemming is a separate, larger decision — it needs a stemmer
|
|
21
|
+
// dependency AND the same tokenizer handed to the browser through `initDB`.
|
|
22
|
+
export const { staticGET: GET } = createFromSource(source);
|