@panaversity/ksor 0.0.11 → 0.0.12

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,112 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.12
4
+
5
+ ### Patch Changes
6
+
7
+ - 36e4a4c: The scaffold documents what a CLIENT has to do to reach a public MCP door
8
+
9
+ `ksor serve` implements the OAuth Resource Server handshake — an
10
+ unauthenticated request gets a 401 carrying
11
+ `WWW-Authenticate: Bearer resource_metadata="…"`, and that document names the
12
+ record's resource identifier and its authorization server, so a client discovers
13
+ where to authenticate instead of being told. None of it was written down
14
+ anywhere an adopter or their agent reads. The operator half was documented (the
15
+ three environment variables); the half their agents actually execute was not.
16
+
17
+ The scaffold's `AGENTS.md` now walks the three steps, and names the failure that
18
+ goes wrong quietly: a token minted for a different audience is a perfectly valid
19
+ token, and this door rejects it, so `aud` against `KSOR_JWT_ALLOWED_AUDIENCES` is
20
+ the first thing to compare when a client authenticates fine and still gets 401.
21
+ It also records the two behaviours a client author has to know and could not have
22
+ guessed — RS256 only, no opaque-token introspection, and an unknown key id
23
+ answering 503 rather than 401, because during a key rotation the token is
24
+ probably good and retrying beats sending the user back through a login.
25
+
26
+ This closes one of the three items named in issue #26; the worked provider
27
+ recipes and the introspection/rotation policy remain open there.
28
+
29
+ - 125970c: Every 401 from the MCP door carries its `WWW-Authenticate` challenge, not just the first
30
+
31
+ Only the missing-token branch emitted `WWW-Authenticate: Bearer
32
+ resource_metadata="…"`. A token that failed verification — expired, wrong
33
+ audience, no subject, bad signature — came back as a bare 401. That is the most
34
+ common 401 a real client will ever see, because tokens expire mid-conversation,
35
+ and it left the client with no pointer back to the resource-metadata document:
36
+ it could not re-discover the authorization server it had just been talking to.
37
+ Only a caller that had never sent a token was told where to go.
38
+
39
+ The MCP authorization spec requires `WWW-Authenticate` on a 401 without
40
+ qualification. Every 401 now carries it, with RFC 6750's `error="invalid_token"`
41
+ so a client refreshes rather than retrying the dead token.
42
+
43
+ A **503** stays deliberately unchallenged: an unreachable key set is our outage,
44
+ not the token's fault, and challenging there would send a user whose token is
45
+ perfectly good back through a login over a key-fetch failure.
46
+
47
+ Found by adversarially checking the release that documented this door. The
48
+ adversarial auth suite missed it by asserting the STATUS of each rejection and
49
+ never the header — it now sweeps every 401-producing token and asserts the
50
+ challenge on each, with the 503 as the negative control.
51
+
52
+ - 0a94e31: A 503 refusal no longer puts the database host and user on the wire
53
+
54
+ When the deferred boot checks fail, `/mcp` refuses with the thrown error's
55
+ message in full under `data.detail`. For the three authored failures that is the
56
+ point — a too-old schema, a governance violation and a text-search mismatch each
57
+ carry a multi-line remedy the operator has to act on. But the catch treated every
58
+ error alike, and `pg` writes the host, its resolved address, the port and the
59
+ database user into its connection and authentication failures. Those went out
60
+ verbatim to any caller who could reach the door.
61
+
62
+ What may leave is now decided in one place and by TYPE, not by inspecting
63
+ message text: a class we wrote is a class whose words we control. A driver error
64
+ is refused with its class named and its text withheld, and the caller is told
65
+ which kind of failure it is — infrastructure, not their request.
66
+
67
+ The full text still reaches the operator, deliberately: the refusal says the
68
+ reason is in the server's logs, and the deferred-boot line recorded only the
69
+ error's NAME, so before this the real message existed nowhere. That is also why
70
+ the boot checks are not sanitised at their source — reducing a driver error to a
71
+ class name early would destroy the one copy anyone can act on.
72
+
73
+ **The test that covered this was holding it in place.** It asserted that
74
+ `http.ts` contains the literal string `data: { detail: message }` — so the leak
75
+ was pinned by an assertion with reasoning attached. Grepping source is the right
76
+ instrument for "does this check run before dispatch", because position is a
77
+ property of source, and the wrong one for "what does the response contain".
78
+ Response contents are now asserted against real bodies, including a `pg`-shaped
79
+ connection failure whose host, address, port and user must all be absent.
80
+
81
+ Verified live: a gateway pointed at an unreachable database answers
82
+ `the content store is unavailable (Error)` with no host, port, user or database
83
+ name anywhere in the body, while the server log carries
84
+ `connect ECONNREFUSED 127.0.0.1:59999` in full.
85
+
86
+ - 1dd6211: The dimension ceiling says which shape it applies to, instead of blaming pgvector
87
+
88
+ `ksor schema` refuses an embedding dimension above 2000 with
89
+ "(pgvector vector + HNSW ceiling)". The refusal is right and the reason was
90
+ wrong: pgvector indexes a `vector` to 2000, but a **`halfvec` to 4000**, via an
91
+ expression index on the cast — verified live against a real database, where
92
+ `hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops)` plans an Index Scan.
93
+
94
+ The old wording read as pgvector's own limit, so an adopter whose model emits
95
+ more than 2000 dimensions could conclude it was unusable here, over a wall that
96
+ is not one. The message now names the shape the ceiling belongs to — this schema
97
+ declares `VECTOR(dim)` columns and indexes one directly — and the constant
98
+ carries why raising it is a decision rather than an edit: every query site would
99
+ have to use the same cast as the index or fall silently back to a sequential
100
+ scan, and the halfvec arm's float16 rounding lands on the score the abstention
101
+ gate reads.
102
+
103
+ The same claim is corrected in the scaffold's `AGENTS.md`, which gains the reason
104
+ `dim: 1536` is the shipped default: `gemini-embedding-001` emits 3072 and ksor
105
+ asks it for 1536, which per Google's published MTEB table costs nothing
106
+ measurable — 1536 scores 68.17 against 2048's 68.16.
107
+
108
+ The 2000 refusal is unchanged. Issue #49 records the decision it now points at.
109
+
3
110
  ## 0.0.11
4
111
 
5
112
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
16
16
  import { execFileSync, spawnSync } from "node:child_process";
17
17
  import { parseArgs } from "node:util";
18
18
  import { readFile, readdir, stat } from "node:fs/promises";
19
- //#region ../content-gateway/dist/main-3DN4fOiE.mjs
19
+ //#region ../content-gateway/dist/main-CqOtG-q1.mjs
20
20
  /**
21
21
  * A connection could not be ESTABLISHED in time — retryable.
22
22
  *
@@ -448,6 +448,8 @@ const CHUNK_POLICY$1 = "heading-aware-1500-content-only-v5";
448
448
  * The Markdown BODY below the frontmatter is the authored agent-surface
449
449
  * instructions — byte-preserved, stripped only at the edges.
450
450
  */
451
+ /** Mirrors `schema.ts`'s ceiling, so a bad `dim:` is refused when instance.md is
452
+ * PARSED rather than when the DDL is rendered. The why lives there. */
451
453
  const EMBED_DIM_MAX$1$1 = 2e3;
452
454
  const SUPPORTED_FORMATS$1$1 = [1];
453
455
  var InstanceParseError$1 = class extends Error {
@@ -541,7 +543,7 @@ const groupSchemas$1 = {
541
543
  embedding: z.object({
542
544
  provider: z.string().min(1).default("gemini"),
543
545
  model: z.string().min(1).default(EMBED_MODEL$1),
544
- dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1$1).default(EMBED_DIM$1)
546
+ dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1$1, { error: `at most ${EMBED_DIM_MAX$1$1}: this schema declares VECTOR columns and indexes one directly, and pgvector's HNSW takes a vector to ${EMBED_DIM_MAX$1$1}` }).default(EMBED_DIM$1)
545
547
  }),
546
548
  retrieval: z.object({
547
549
  /**
@@ -3723,6 +3725,54 @@ async function compose(instancePath, version) {
3723
3725
  };
3724
3726
  }
3725
3727
  /**
3728
+ * The body of a deferred-boot refusal, and the one decision inside it: whose
3729
+ * message may go on the wire.
3730
+ *
3731
+ * Three AUTHORED failures carry a remedy written for the operator —
3732
+ * `SchemaVersionError`, `GovernanceGateError`, `TextSearchConfigMismatch`. Their
3733
+ * whole multi-line message is the point; a caller that receives only the first
3734
+ * line has been told a problem exists and not how to end it.
3735
+ *
3736
+ * Everything else reaching this catch is infrastructure — most of it straight
3737
+ * from `pg`, whose connection and authentication failures name the host, the
3738
+ * resolved address, the port and the database user. Those went out verbatim
3739
+ * under `data.detail`, because the catch treated every error alike.
3740
+ *
3741
+ * So the split is by TYPE, not by message inspection: a class we wrote is a
3742
+ * class whose text we control. A driver error is refused with its class named
3743
+ * and its text withheld — the operator finds the real message in the server's
3744
+ * own logs, which is where an infrastructure fault belongs.
3745
+ */
3746
+ /**
3747
+ * Did WE write this error's text?
3748
+ *
3749
+ * Named classes only. Matching on message prose would put the decision back
3750
+ * inside the strings it is meant to police, and a reworded driver error would
3751
+ * quietly re-open the leak.
3752
+ */
3753
+ function isAuthored(error) {
3754
+ return error instanceof SchemaVersionError || error instanceof GovernanceGateError$1 || error instanceof TextSearchConfigMismatch;
3755
+ }
3756
+ function refusalBody(error) {
3757
+ if (isAuthored(error)) return {
3758
+ jsonrpc: "2.0",
3759
+ error: {
3760
+ code: -32001,
3761
+ message: `this record cannot be served: ${error.message.split("\n")[0] ?? ""}`,
3762
+ data: { detail: error.message }
3763
+ },
3764
+ id: null
3765
+ };
3766
+ return {
3767
+ jsonrpc: "2.0",
3768
+ error: {
3769
+ code: -32001,
3770
+ message: `this record cannot be served: the content store is unavailable (${error instanceof Error ? error.name : "Error"}). The reason is in this server's logs; it is withheld here because a driver error names the database host and user.`
3771
+ },
3772
+ id: null
3773
+ };
3774
+ }
3775
+ /**
3726
3776
  * The MCP door: the SDK v2 HTTP entry (Request → Response, stateless)
3727
3777
  * behind Hono, serving the 2026-07-28 revision with 2025-era clients still
3728
3778
  * answered through the stateless fallback. Modern exchanges are buffered JSON;
@@ -3886,16 +3936,8 @@ async function runHttp(composition) {
3886
3936
  if (verifyBoot !== null) try {
3887
3937
  await verifyBoot();
3888
3938
  } catch (error) {
3889
- const message = error instanceof Error ? error.message : String(error);
3890
- return new Response(JSON.stringify({
3891
- jsonrpc: "2.0",
3892
- error: {
3893
- code: -32001,
3894
- message: `this record cannot be served: ${message.split("\n")[0]}`,
3895
- data: { detail: message }
3896
- },
3897
- id: null
3898
- }), {
3939
+ console.error(`refusing requests boot checks failing: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
3940
+ return new Response(JSON.stringify(refusalBody(error)), {
3899
3941
  status: 503,
3900
3942
  headers: { "content-type": "application/json" }
3901
3943
  });
@@ -3939,7 +3981,7 @@ async function runHttp(composition) {
3939
3981
  identity = await auth.verify(token);
3940
3982
  } catch (error) {
3941
3983
  const transient = error instanceof TokenVerifyError && error.transient;
3942
- return c.json({ error: transient ? "token verification temporarily unavailable" : "invalid token" }, transient ? 503 : 401);
3984
+ return c.json({ error: transient ? "token verification temporarily unavailable" : "invalid token" }, transient ? 503 : 401, transient ? {} : { "www-authenticate": `Bearer error="invalid_token", resource_metadata="${resourceMetadataUrl}"` });
3943
3985
  }
3944
3986
  bearer = token;
3945
3987
  }
@@ -4389,7 +4431,7 @@ async function withPgRetry(op, options = {}) {
4389
4431
  throw lastError;
4390
4432
  }
4391
4433
  //#endregion
4392
- //#region ../content/dist/commands-ulU-h9ei.mjs
4434
+ //#region ../content/dist/commands-Cysnkk_R.mjs
4393
4435
  /**
4394
4436
  * EVAL-LOCKED constants, quarried verbatim from the oracle
4395
4437
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -4425,6 +4467,8 @@ const HARD_MAX_CHARS = 4e3;
4425
4467
  * The Markdown BODY below the frontmatter is the authored agent-surface
4426
4468
  * instructions — byte-preserved, stripped only at the edges.
4427
4469
  */
4470
+ /** Mirrors `schema.ts`'s ceiling, so a bad `dim:` is refused when instance.md is
4471
+ * PARSED rather than when the DDL is rendered. The why lives there. */
4428
4472
  const EMBED_DIM_MAX$1 = 2e3;
4429
4473
  const SUPPORTED_FORMATS$1 = [1];
4430
4474
  var InstanceParseError = class extends Error {
@@ -4518,7 +4562,7 @@ const groupSchemas = {
4518
4562
  embedding: z.object({
4519
4563
  provider: z.string().min(1).default("gemini"),
4520
4564
  model: z.string().min(1).default(EMBED_MODEL),
4521
- dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1).default(EMBED_DIM)
4565
+ dim: z.coerce.number().int().min(1).max(EMBED_DIM_MAX$1, { error: `at most ${EMBED_DIM_MAX$1}: this schema declares VECTOR columns and indexes one directly, and pgvector's HNSW takes a vector to ${EMBED_DIM_MAX$1}` }).default(EMBED_DIM)
4522
4566
  }),
4523
4567
  retrieval: z.object({
4524
4568
  /**
@@ -4942,7 +4986,29 @@ async function runIngest(pool, tenantId, op) {
4942
4986
  * fresh DDL is rendered from the instance that will fill it, never
4943
4987
  * hand-edited.
4944
4988
  */
4945
- /** pgvector vector + HNSW ceiling. */
4989
+ /**
4990
+ * The largest embedding dimension this schema will render.
4991
+ *
4992
+ * It is the ceiling for the shape we USE, not pgvector's ceiling: `schema.sql`
4993
+ * declares two `VECTOR(dim)` columns and indexes one of them directly, and
4994
+ * pgvector's HNSW and IVFFlat take a `vector` to 2000. They take a `halfvec` to
4995
+ * **4000**, reachable by indexing an expression — `hnsw ((embedding::halfvec(N))
4996
+ * halfvec_cosine_ops)` — which we do not do, so 2000 binds here.
4997
+ *
4998
+ * Said precisely because the old wording ("pgvector vector + HNSW ceiling")
4999
+ * read as pgvector's own limit and sent a reader off to change providers over a
5000
+ * wall that is not one (verified live against a real database, 2026-08-21:
5001
+ * a halfvec(3072) expression index plans an Index Scan).
5002
+ *
5003
+ * Raising it is a decision, not a constant: every query site would have to use
5004
+ * the same cast as the index or fall silently back to a sequential scan, and
5005
+ * the halfvec arm's float16 rounding lands on the score the abstention gate
5006
+ * reads. Recorded in issue #49, along with the evidence for staying at 1536 —
5007
+ * Google's published MTEB table runs 128..2048 and is FLAT at the top of that
5008
+ * range (1536 scores 68.17, 2048 scores 68.16), so there is no gradient to
5009
+ * climb toward the ceiling. It carries no 3072 row, so the cost of the
5010
+ * truncation itself is unpublished; do not infer one.
5011
+ */
4946
5012
  const EMBED_DIM_MAX = 2e3;
4947
5013
  /** The schema version schema.sql declares — parsed from the DDL so code and
4948
5014
  * the applied database share ONE source (a drift test pins the coupling). */
@@ -4989,7 +5055,7 @@ function verifyTemplate(text, dim) {
4989
5055
  }
4990
5056
  /** The pure core: render the given template text at the given dimension. */
4991
5057
  function renderSchemaText(text, dim, textSearchConfig = SHIPPED_TS_CONFIG) {
4992
- if (!Number.isInteger(dim) || dim < 1 || dim > 2e3) throw new Error(`dim must be an integer in 1..${EMBED_DIM_MAX} (pgvector vector + HNSW ceiling), got ${JSON.stringify(dim)}`);
5058
+ if (!Number.isInteger(dim) || dim < 1 || dim > 2e3) throw new Error(`dim must be an integer in 1..${EMBED_DIM_MAX} this schema indexes a vector column directly, and pgvector's HNSW takes a vector to 2000 — got ${JSON.stringify(dim)}`);
4993
5059
  verifyTemplate(text, EMBED_DIM);
4994
5060
  const withTs = renderTsConfig(text, textSearchConfig);
4995
5061
  if (dim === 1536) return withTs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
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",
@@ -65,8 +65,13 @@ Stand it up in this order (each step's errors explain how to fix themselves):
65
65
  `provider: gemini`, `model: gemini-embedding-001`, `dim: 1536`; write it out
66
66
  only to pin the space explicitly or to change it — and note that model and
67
67
  dim are the PERSISTED identity of the embedding space, so changing either
68
- later means re-embedding the whole corpus. Keep `dim` at or below 2000: the
69
- pgvector HNSW index refuses more, and `gemini-embedding-001` can emit 3072.
68
+ later means re-embedding the whole corpus. Keep `dim` at or below 2000 the
69
+ schema indexes a `vector` column directly and pgvector's HNSW takes a
70
+ `vector` to 2000. `gemini-embedding-001` emits 3072 by default, so ksor asks
71
+ it for 1536. Google's published MTEB table runs 128–2048 and is flat at the
72
+ top of it — 1536 scores 68.17 against 2048's 68.16 — so there is no gradient
73
+ to climb toward the ceiling; going the other way, 768 costs 0.18 if you want
74
+ the storage back.
70
75
 
71
76
  Leave `retrieval:` out for now — the gate is off and the server says so.
72
77
  Turning it on is step 4, AFTER the record is serving.
@@ -227,7 +232,7 @@ bind, set `KSOR_ALLOWED_HOSTS` / `KSOR_ALLOWED_ORIGINS`; on more than one
227
232
  replica, set a shared `KSOR_SNAPSHOT_KEYS` (unset ⇒ a per-process key, so a
228
233
  search token minted by one replica fails on another).
229
234
 
230
- Two things worth being deliberate about:
235
+ Three things worth being deliberate about:
231
236
 
232
237
  - **`KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` serves your whole record to anyone
233
238
  who can reach the port.** It exists for deployments fronted by your own
@@ -244,6 +249,62 @@ Two things worth being deliberate about:
244
249
  `KSOR_JWKS_URL` only to override that, or when your SSO publishes no metadata
245
250
  at all.
246
251
 
252
+ ### What a CLIENT has to do
253
+
254
+ Once the SSO door is configured (the three variables above), the server is an
255
+ OAuth **Resource Server**, which means a client is not told the authorization
256
+ server — it discovers it. Nothing here needs configuring beyond those variables;
257
+ this is what your agents will experience, and what to check when one cannot
258
+ connect. With `KSOR_AUTH_DISABLED=1` — the local default `.env.example` ships —
259
+ none of it applies: there is no challenge and the metadata document answers 404,
260
+ because there is no authorization server to point at.
261
+
262
+ 1. The client calls `POST /mcp` with no token and gets **401** carrying
263
+
264
+ ```
265
+ WWW-Authenticate: Bearer resource_metadata="https://<your-host>/.well-known/oauth-protected-resource/mcp"
266
+ ```
267
+
268
+ That header is the whole handshake: it names a DOCUMENT, not the resource.
269
+
270
+ 2. The client fetches that document and finds the record's resource identifier
271
+ and its authorization server:
272
+
273
+ ```json
274
+ {
275
+ "resource": "https://<your-host>/mcp",
276
+ "authorization_servers": ["https://your-sso.example.com"]
277
+ }
278
+ ```
279
+
280
+ Those two values are `KSOR_MCP_RESOURCE_URL` and `KSOR_SSO_URL`.
281
+
282
+ 3. The client gets a token from that authorization server, asking for THIS
283
+ record as the resource (RFC 8707: `resource=https://<your-host>/mcp`), and
284
+ sends it as `Authorization: Bearer <token>`.
285
+
286
+ Every 401 carries that same header, not just the first one — including the one
287
+ your clients will hit most often, a token that expired mid-conversation. It
288
+ arrives as `Bearer error="invalid_token", resource_metadata="…"`, so a client
289
+ knows to refresh rather than to retry the dead token. A **503** is deliberately
290
+ _not_ challenged: an unreachable key set is our outage, not your token's fault,
291
+ and telling a client to re-authenticate over it would send a perfectly good user
292
+ back through a login.
293
+
294
+ The one thing that goes wrong here goes wrong quietly: a token minted for a
295
+ different audience is a perfectly valid token, and this door rejects it. The
296
+ `aud` claim must match one of `KSOR_JWT_ALLOWED_AUDIENCES` — normally the same
297
+ value as `KSOR_MCP_RESOURCE_URL` — because a bearer accepted for any audience is
298
+ a bearer stolen from one service and replayed against this one. If a client
299
+ authenticates fine and still gets 401, compare its token's `aud` against that
300
+ list before looking anywhere else.
301
+
302
+ Tokens must be signed **RS256**; nothing else is accepted, and opaque tokens are
303
+ not supported (there is no introspection call). When your SSO rotates its
304
+ signing keys, an unknown key id answers **503**, not 401 — the token may well be
305
+ good and the door's key set merely stale, so a client should retry rather than
306
+ send the user back through a login.
307
+
247
308
  ## Withdrawing a document — `ksor takedown`
248
309
 
249
310
  A takedown is the one governance act that must reach EVERY surface at once.