@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 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
@@ -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 two worked recipes, both executed against real servers rather than
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 either product, and
15
- that is the point: two different implementations are shown because a single one
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, and one more you should set even though it is optional:
21
-
22
- | variable | what it is | where the value comes from |
23
- | ---------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
24
- | `KSOR_SSO_URL` | your authorization server's base URL | the AS itself; for OIDC it is the issuer, the URL whose `/.well-known/openid-configuration` answers |
25
- | `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 |
26
- | `KSOR_JWT_ALLOWED_AUDIENCES` | which audiences are accepted, comma-separated | normally exactly `KSOR_MCP_RESOURCE_URL` |
27
- | `KSOR_SSO_ISSUER` | the issuer to enforce | the `issuer` field of the AS's discovery document |
28
-
29
- `KSOR_MCP_RESOURCE_URL` is **not** a place the door fetches anything from. It is
30
- the name of this resource, in the RFC 8707 sense: a token minted for a different
31
- resource is refused even when the signature is perfect and the issuer is right.
32
- That is what stops a token issued for some other service being replayed at your
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 a vendor default, reported as a GUESS
93
+ 4. <KSOR_SSO_URL>/api/auth/jwks Better Auth's layout, reported as a GUESS
43
94
  ```
44
95
 
45
- **Set `KSOR_SSO_ISSUER`.** Without it, a token from a _different_ authorization
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. The secret is used as literal text, never hex-decoded, and must be
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; a
72
- Docusaurus conformance shell lives in the ksor repository under
73
- `workbench/shells/docusaurus/` with its swap recipe.
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. The previous generation stays as a rollback
42
- target; `ksor gc` reaps the ones nothing points at any more.
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, poolers, and what actually matters
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 anything on port 6432), ksor detects which one you gave it
95
- and says so in the boot report. That line is **informational**: it classifies,
96
- it never transforms. The hazard it descends from a transaction pooler and
97
- server-side prepared statements cannot arise here, because node-postgres does
98
- not auto-prepare.
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 in with the date you measured
119
- it, and restart:
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.32",
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 (a themed Docusaurus shell with
712
- a swap recipe lives in the ksor repository under `workbench/shells/`).
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
- export const { staticGET: GET } = createFromSource(source, {
9
- // https://docs.orama.com/docs/orama-js/supported-languages
10
- language: "english",
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);