@panaversity/ksor 0.0.18 → 0.0.20

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,88 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.20
4
+
5
+ ### Patch Changes
6
+
7
+ - f25e963: A token from another authorization server is refused, not reported as an outage
8
+
9
+ An unknown key id raises the same error whether the cause is key-rotation lag or
10
+ a token minted by an entirely different authorization server. Both were treated
11
+ as transient, so a client presenting a credential that can never work got `503
12
+ service unavailable` — and retried it, forever, while the misconfiguration read
13
+ as an outage in every dashboard.
14
+
15
+ Reproduced across two real servers: a door configured for Ory Hydra, presented
16
+ with a genuine Keycloak token, answered 503. It now answers 401, before it
17
+ fetches a key at all.
18
+
19
+ The check runs only when `KSOR_SSO_ISSUER` is set, because only then has the
20
+ operator stated what the issuer should be. It reads the issuer from an unverified
21
+ payload, which is sound for exactly one purpose — refusing. A token that passes
22
+ it still has its signature verified in full, so a lie there buys nothing.
23
+
24
+ Genuine rotation lag is still transient, still uncached, and a valid bearer is
25
+ still re-admitted the instant the key set catches up.
26
+
27
+ **New: `docs/authorization.md`**, shipped in the package — two worked recipes for
28
+ putting a record behind an authorization server, both executed against real
29
+ servers rather than written from their documentation, plus what an agent does to
30
+ obtain a token and what each refusal means.
31
+
32
+ ## 0.0.19
33
+
34
+ ### Patch Changes
35
+
36
+ - aa4bdce: Record why the vector index is unused, and what fixing it would cost
37
+
38
+ Diagnosis only — no serving behaviour changes. Answers are unaffected, and were
39
+ already correct: the query plans a sequential scan, and a sequential scan is
40
+ EXACT. What grows with the corpus is the work, not the error.
41
+
42
+ The cause recorded until now — a window function, then joins and predicates
43
+ Postgres cannot estimate — was incomplete. Testing each clause on its own shows
44
+ a cost mispricing underneath: a full sequential pass over 20,000 chunks,
45
+ including 20,000 1536-dimension distance computations, is priced at 1904 for
46
+ work that takes ~130 ms, while the HNSW scan's startup cost alone is 2137.
47
+
48
+ A restructured arm reaches 36 ms against 648 ms — but only with `ef_search` at
49
+ pgvector's default, which is the setting where the index missed the true nearest
50
+ neighbour for 1 query in 100 on a bed with real cluster structure, dropping the
51
+ top-1 similarity by 0.99. Against this record's ~0.01 abstention separation,
52
+ that flips an abstention: the corpus holds the answer and the door says it does
53
+ not. The speed and the approximation cannot be separated, so taking them is an
54
+ owner decision rather than a tuning change.
55
+
56
+ Both the current plan and the fix path are now pinned by tests, so neither can
57
+ drift unnoticed.
58
+
59
+ - b9f3d00: A citation pin no longer outlives a restriction
60
+
61
+ A snapshot token pins a generation so a citation keeps resolving to the same
62
+ bytes. It was also deciding the _audience_ question — evaluating `visibility` on
63
+ the pinned row — so a document restricted after the token was issued kept reading
64
+ in full for the token's life, to a caller the record had just closed it to.
65
+
66
+ Three routes refused it and one served it, on the same surface, in the same
67
+ second: `outline` omitted it, `search` filtered it, an unpinned `read` refused it,
68
+ and `read` with a pre-flip token returned the whole document.
69
+
70
+ The generation pointers are why the obvious guard missed it. A flip sets
71
+ `rollback_generation` to the generation just superseded, so a pre-flip pin is
72
+ exactly the rollback pointer — servable by design, and the check that narrows a
73
+ pin to {active, rollback} passed it.
74
+
75
+ **Governance is now read from the record as it stands.** A pin still decides
76
+ which generation's content is served; it no longer decides whether the caller may
77
+ have it. A document the record no longer contains cannot be resurrected by one
78
+ either. Unpinned reads are unaffected — with nothing pinned, the two generations
79
+ are the same one and the check is an identity.
80
+
81
+ The cost is deliberate: a citation can stop resolving within the token's 30
82
+ minutes when the record restricts what it points at. That is what "the record
83
+ changed" should look like. The alternative is a window in which a withdrawal is
84
+ not a withdrawal.
85
+
3
86
  ## 0.0.18
4
87
 
5
88
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { bodyLimit } from "hono/body-limit";
15
15
  import { execFileSync, spawnSync } from "node:child_process";
16
16
  import { parseArgs } from "node:util";
17
17
  import { readFile, readdir, stat } from "node:fs/promises";
18
- //#region ../content-gateway/dist/main-Deg5kd9y.mjs
18
+ //#region ../content-gateway/dist/main-9UsxyoZa.mjs
19
19
  /**
20
20
  * A connection could not be ESTABLISHED in time — retryable.
21
21
  *
@@ -2053,6 +2053,28 @@ g AS (
2053
2053
  (SELECT active_generation FROM corpora
2054
2054
  WHERE tenant_id = $1 AND corpus_id = $2)
2055
2055
  ) AS gen
2056
+ ),
2057
+ -- The generation the record is on RIGHT NOW, which decides governance even when
2058
+ -- content is served from a pinned one.
2059
+ --
2060
+ -- A snapshot pin exists so a citation keeps resolving to the same bytes. It used
2061
+ -- to decide the audience question too, by evaluating visibility on the pinned
2062
+ -- row — so a document restricted after the token was issued kept reading in full
2063
+ -- for the token's life, while outline, search and an unpinned read all
2064
+ -- refused it in the same second. servableGenerations could not catch it: a
2065
+ -- flip sets rollback_generation to the generation just superseded, so a pre-flip
2066
+ -- pin IS the rollback pointer and is servable by design (issue #87).
2067
+ --
2068
+ -- Pins yield. A citation may stop resolving within the token's life, which is
2069
+ -- what "the record changed" should look like — the alternative is a window in
2070
+ -- which a withdrawal is not a withdrawal, and decision 19 says a surface that
2071
+ -- refuses must refuse everywhere, which includes its own fourth route.
2072
+ --
2073
+ -- When nothing is pinned this is the SAME generation as g, so the join is an
2074
+ -- identity and no unpinned read changes behaviour.
2075
+ live AS (
2076
+ SELECT active_generation AS gen FROM corpora
2077
+ WHERE tenant_id = $1 AND corpus_id = $2
2056
2078
  )`;
2057
2079
  /** Candidates by LEAF slug ($4), each with its full root path (for suffix disambiguation). */
2058
2080
  const NODE_BY_SLUG_SQL = `
@@ -2082,7 +2104,12 @@ SELECT n.node_id, n.slug, n.title, n.stable_id, n.path, n.generation, n.permalin
2082
2104
  FROM tree n
2083
2105
  JOIN content_nodes self ON self.node_id = n.node_id AND self.tenant_id = $1
2084
2106
  AND self.generation = n.generation
2085
- WHERE n.slug = $4 AND ${DENY$1} AND ${audienceAllowed$1("self")}
2107
+ -- INNER join, so a document the record no longer contains cannot be
2108
+ -- resurrected by a pin either: no live row, no read.
2109
+ JOIN live ON TRUE
2110
+ JOIN content_nodes now ON now.tenant_id = $1 AND now.generation = live.gen
2111
+ AND now.stable_id = self.stable_id
2112
+ WHERE n.slug = $4 AND ${DENY$1} AND ${audienceAllowed$1("now")}
2086
2113
  ORDER BY n.path`;
2087
2114
  const ALIAS_SQL = `
2088
2115
  WITH ${GEN}
@@ -2099,8 +2126,11 @@ const NODE_BY_STABLE_ID_SQL = `
2099
2126
  WITH RECURSIVE ${GEN}, ${DENIED_CTE$1}
2100
2127
  SELECT n.node_id, n.slug, n.title, n.stable_id, n.stable_id::text AS path, n.generation, n.permalink
2101
2128
  FROM content_nodes n JOIN g ON n.generation = g.gen
2129
+ JOIN live ON TRUE
2130
+ JOIN content_nodes now ON now.tenant_id = $1 AND now.generation = live.gen
2131
+ AND now.stable_id = n.stable_id
2102
2132
  WHERE n.tenant_id = $1 AND n.stable_id = $4 AND n.status = 'published' AND ${DENY$1}
2103
- AND ${AUDIENCE_ALLOWED$1}`;
2133
+ AND ${audienceAllowed$1("now")}`;
2104
2134
  const DOCUMENT_CHUNKS_SQL = `
2105
2135
  WITH ${GEN}
2106
2136
  SELECT c.ordinal, COALESCE(c.heading_path_text, ''), c.content
@@ -3478,6 +3508,27 @@ const POS_TTL_S = 60;
3478
3508
  function isBadToken(err) {
3479
3509
  return err instanceof errors.JWTExpired || err instanceof errors.JWTClaimValidationFailed || err instanceof errors.JWTInvalid || err instanceof errors.JWSInvalid || err instanceof errors.JWSSignatureVerificationFailed || err instanceof errors.JOSEAlgNotAllowed || err instanceof errors.JOSENotSupported;
3480
3510
  }
3511
+ /**
3512
+ * The `iss` a token CLAIMS, read without verifying anything.
3513
+ *
3514
+ * Sound for exactly one purpose: REFUSING. A token that passes this check still
3515
+ * has its signature verified in full, so an attacker gains nothing by lying here
3516
+ * — the worst they achieve is being refused for a different reason. It must
3517
+ * never be used to admit anything.
3518
+ *
3519
+ * Returns null when the payload is not readable JSON, which sends the token down
3520
+ * the ordinary path rather than inventing a verdict about it.
3521
+ */
3522
+ function claimedIssuer(token) {
3523
+ try {
3524
+ const payload = token.split(".")[1];
3525
+ if (payload === void 0 || payload === "") return null;
3526
+ const iss = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).iss;
3527
+ return typeof iss === "string" && iss !== "" ? iss : null;
3528
+ } catch {
3529
+ return null;
3530
+ }
3531
+ }
3481
3532
  function describeError(err) {
3482
3533
  return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
3483
3534
  }
@@ -3541,6 +3592,13 @@ function createVerify(config, deps, jwksOf) {
3541
3592
  if (hit !== void 0 && hit.until > now()) return hit.identity;
3542
3593
  let claims = null;
3543
3594
  if (token.split(".").length === 3) {
3595
+ if (config.issuer !== null) {
3596
+ const claimed = claimedIssuer(token);
3597
+ if (claimed !== null && claimed !== config.issuer) {
3598
+ reject(key);
3599
+ throw new TokenVerifyError(`token iss ${JSON.stringify(claimed)} is not this record's authorization server ${JSON.stringify(config.issuer)}`, { transient: false });
3600
+ }
3601
+ }
3544
3602
  try {
3545
3603
  claims = await verifyJwt(token);
3546
3604
  } catch (err) {
@@ -0,0 +1,197 @@
1
+ ---
2
+ title: Authorization
3
+ status: draft
4
+ ---
5
+
6
+ # Putting the record behind an authorization server
7
+
8
+ `ksor serve` refuses to boot unauthenticated on a public bind. That is the whole
9
+ posture, and it means the last step of a deployment is standing up an
10
+ authorization server and pointing the door at it.
11
+
12
+ This page is two worked recipes, both executed against real servers rather than
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.
17
+
18
+ ## What the door needs
19
+
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.
34
+
35
+ The door finds the signing keys by discovery, in this order, and says at boot
36
+ which one it used:
37
+
38
+ ```
39
+ 1. KSOR_JWKS_URL you stated the URI outright
40
+ 2. /.well-known/oauth-authorization-server RFC 8414 metadata
41
+ 3. /.well-known/openid-configuration OIDC discovery
42
+ 4. <KSOR_SSO_URL>/api/auth/jwks a vendor default, reported as a GUESS
43
+ ```
44
+
45
+ **Set `KSOR_SSO_ISSUER`.** Without it, a token from a _different_ authorization
46
+ server produces an unknown key id, which is indistinguishable from key-rotation
47
+ lag — so the door answers `503`, the client retries a credential that can never
48
+ work, and a misconfiguration reads as an outage. With the issuer declared, that
49
+ same token is refused `401` before any key is fetched.
50
+
51
+ ## Recipe: Keycloak
52
+
53
+ Run it:
54
+
55
+ ```sh
56
+ docker run -d --name kc -p 8180:8080 \
57
+ -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
58
+ quay.io/keycloak/keycloak:26.0 start-dev
59
+ ```
60
+
61
+ Get an admin token, then create a client for the agent. `client_credentials` is
62
+ the machine-to-machine shape — an agent is not a person and has no browser:
63
+
64
+ ```sh
65
+ KC=http://127.0.0.1:8180
66
+ ADM=$(curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \
67
+ -d client_id=admin-cli -d username=admin -d password=admin -d grant_type=password \
68
+ | jq -r .access_token)
69
+
70
+ curl -s -X POST "$KC/admin/realms/master/clients" -H "Authorization: Bearer $ADM" \
71
+ -H 'Content-Type: application/json' -d '{
72
+ "clientId":"ksor-agent","protocol":"openid-connect","publicClient":false,
73
+ "serviceAccountsEnabled":true,"standardFlowEnabled":false,"secret":"agent-secret"}'
74
+ ```
75
+
76
+ Keycloak does not put your resource in the token's `aud` on its own. Add an
77
+ audience mapper — this is the step people miss, and its absence looks exactly
78
+ like a rejected token:
79
+
80
+ ```sh
81
+ CID=$(curl -s "$KC/admin/realms/master/clients?clientId=ksor-agent" \
82
+ -H "Authorization: Bearer $ADM" | jq -r '.[0].id')
83
+
84
+ curl -s -X POST "$KC/admin/realms/master/clients/$CID/protocol-mappers/models" \
85
+ -H "Authorization: Bearer $ADM" -H 'Content-Type: application/json' -d '{
86
+ "name":"ksor-resource-audience","protocol":"openid-connect",
87
+ "protocolMapper":"oidc-audience-mapper",
88
+ "config":{"included.custom.audience":"https://records.example.com/mcp",
89
+ "access.token.claim":"true"}}'
90
+ ```
91
+
92
+ Point the door at it:
93
+
94
+ ```sh
95
+ export KSOR_SSO_URL=http://127.0.0.1:8180/realms/master
96
+ export KSOR_SSO_ISSUER=http://127.0.0.1:8180/realms/master
97
+ export KSOR_MCP_RESOURCE_URL=https://records.example.com/mcp
98
+ export KSOR_JWT_ALLOWED_AUDIENCES=https://records.example.com/mcp
99
+ ksor serve --instance instance.md
100
+ ```
101
+
102
+ The boot block tells you whether discovery worked:
103
+
104
+ ```
105
+ auth bearer tokens, verified against the record's authorization server
106
+ keys openid-configuration — http://127.0.0.1:8180/realms/master/protocol/openid-connect/certs
107
+ ```
108
+
109
+ If that line says `guess` instead of naming a discovery document, the AS did not
110
+ publish metadata where the door looked, and you should set `KSOR_JWKS_URL`
111
+ yourself rather than rely on the vendor default.
112
+
113
+ ## Recipe: Ory Hydra
114
+
115
+ A different implementation, and a different way of asking for the audience —
116
+ which is the neutrality proof. Hydra takes the RFC 8707 `audience` parameter on
117
+ the token request, so no mapper is involved:
118
+
119
+ ```sh
120
+ docker run -d --name hydra -p 4444:4444 -p 4445:4445 \
121
+ -e DSN=memory -e URLS_SELF_ISSUER=http://127.0.0.1:4444 \
122
+ -e SECRETS_SYSTEM=change-me-0000000000000000000000 \
123
+ -e STRATEGIES_ACCESS_TOKEN=jwt \
124
+ oryd/hydra:v2.2.0 serve all --dev
125
+
126
+ curl -s -X POST http://127.0.0.1:4445/admin/clients -H 'Content-Type: application/json' -d '{
127
+ "client_id":"ksor-agent","client_secret":"agent-secret",
128
+ "grant_types":["client_credentials"],"token_endpoint_auth_method":"client_secret_post",
129
+ "audience":["https://records.example.com/mcp"],"access_token_strategy":"jwt"}'
130
+ ```
131
+
132
+ Only the two SSO variables change:
133
+
134
+ ```sh
135
+ export KSOR_SSO_URL=http://127.0.0.1:4444
136
+ export KSOR_SSO_ISSUER=http://127.0.0.1:4444
137
+ ```
138
+
139
+ Hydra publishes its keys at `/.well-known/jwks.json` rather than Keycloak's
140
+ `/protocol/openid-connect/certs`. Nothing in ksor knows that; discovery reads it
141
+ from the metadata document, which is why the door works against both unmodified.
142
+
143
+ ## What an agent does
144
+
145
+ Ask the token endpoint for a token audienced at the record, then send it as an
146
+ ordinary bearer:
147
+
148
+ ```sh
149
+ # Hydra — the audience is a request parameter
150
+ curl -s -X POST http://127.0.0.1:4444/oauth2/token \
151
+ -d grant_type=client_credentials -d client_id=ksor-agent -d client_secret=agent-secret \
152
+ -d audience=https://records.example.com/mcp
153
+
154
+ # Keycloak — the audience comes from the mapper, so the request is plain
155
+ curl -s -X POST "$KC/realms/master/protocol/openid-connect/token" \
156
+ -d client_id=ksor-agent -d client_secret=agent-secret -d grant_type=client_credentials
157
+ ```
158
+
159
+ With the MCP TypeScript SDK:
160
+
161
+ ```ts
162
+ const transport = new StreamableHTTPClientTransport(new URL("https://records.example.com/mcp"), {
163
+ requestInit: { headers: { authorization: `Bearer ${accessToken}` } },
164
+ });
165
+ ```
166
+
167
+ A client that does not know where to authenticate can find out: an unauthorized
168
+ request answers `401` with a pointer to this record's metadata, per RFC 9728.
169
+
170
+ ```
171
+ www-authenticate: Bearer resource_metadata="https://records.example.com/.well-known/oauth-protected-resource/mcp"
172
+ ```
173
+
174
+ ## What the door refuses, and how it says so
175
+
176
+ | what you send | answer |
177
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
178
+ | no token | `401`, with the `resource_metadata` pointer |
179
+ | a malformed or unsigned token | `401`, `error="invalid_token"` |
180
+ | a token for a **different resource** | `401` — the audience binding, and the reason it exists |
181
+ | a token from a **different issuer** (with `KSOR_SSO_ISSUER` set) | `401`, before any key is fetched |
182
+ | a token from a different issuer (issuer NOT set) | `503` — indistinguishable from rotation lag, which is why you should set it |
183
+ | an expired token | `401` |
184
+ | a valid token | the record answers, and the answer carries its citations |
185
+
186
+ A genuine key-rotation lag stays a `503` on purpose: it is transient, retrying is
187
+ the right response, and the refusal is never cached — a valid bearer is
188
+ re-admitted the instant the key set catches up.
189
+
190
+ ## Before a public bind
191
+
192
+ - Auth configured as above, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` set
193
+ deliberately — the door will not come up on a public address without one of
194
+ them, and the second is a decision, not a default.
195
+ - `KSOR_ALLOWED_HOSTS` set to the host you serve on.
196
+ - `KSOR_SNAPSHOT_KEYS` shared across every replica. Unset means a key per
197
+ process, so a citation minted by one replica fails on another.
package/docs/index.md CHANGED
@@ -32,6 +32,10 @@ instead of their training memory. The corpus grows with each implemented verb.
32
32
  export the manifest the site build reads), `ksor calibrate` (measure the
33
33
  abstention floor) and `ksor gc` (reap retired generations). Only `ksor dev` and `ksor build` remain designed, not
34
34
  implemented: each prints an honest notice and exits `2`.
35
+ - **[authorization.md](./authorization.md)** — putting the record behind an
36
+ authorization server, with worked recipes for two of them, executed rather
37
+ than written. `ksor serve` refuses to boot unauthenticated on a public bind,
38
+ so this is the last step of a deployment, not an optional hardening pass.
35
39
  - Exit codes are a contract: `1` refused (first stderr line is a stable
36
40
  slug such as `error: bad-name`, followed by a remedy), `2` designed but
37
41
  not implemented, `3` the environment cannot run ksor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
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",
@@ -66,8 +66,8 @@
66
66
  "tsdown": "0.22.14",
67
67
  "typescript": "7.0.2",
68
68
  "vitest": "^4.1.10",
69
- "@panaversity/ksor-content": "0.0.0",
70
- "@panaversity/ksor-content-gateway": "0.0.0"
69
+ "@panaversity/ksor-content-gateway": "0.0.0",
70
+ "@panaversity/ksor-content": "0.0.0"
71
71
  },
72
72
  "engines": {
73
73
  "node": ">=24"