@panaversity/ksor 0.0.19 → 0.0.21
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 +501 -0
- package/dist/cli.mjs +99 -19
- package/docs/authorization.md +197 -0
- package/docs/index.md +4 -0
- package/package.json +1 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +232 -9
- package/templates/scaffold/AGENTS.md +52 -4
- package/templates/scaffold/instance.md +28 -20
- package/templates/scaffold/knowledge/governance-ladder.md +36 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +29 -0
- package/templates/scaffold/knowledge/surfaces/for-people.md +35 -0
- package/templates/scaffold/knowledge/surfaces/index.md +21 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +39 -0
- package/templates/scaffold/pnpm-lock.yaml +1198 -228
- package/templates/scaffold/system/site/app/(home)/layout.tsx +6 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +65 -70
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +122 -14
- package/templates/scaffold/system/site/app/docs/layout.tsx +2 -21
- package/templates/scaffold/system/site/app/global.css +552 -9
- package/templates/scaffold/system/site/app/layout.tsx +23 -4
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +4 -2
- package/templates/scaffold/system/site/app/llms.txt/route.ts +11 -9
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +51 -0
- package/templates/scaffold/system/site/components/copy-markdown.tsx +70 -0
- package/templates/scaffold/system/site/components/governance.tsx +262 -0
- package/templates/scaffold/system/site/components/home-cover.tsx +137 -0
- package/templates/scaffold/system/site/components/record-index.tsx +120 -0
- package/templates/scaffold/system/site/components/record-shell.tsx +68 -0
- package/templates/scaffold/system/site/components/record-stack.tsx +131 -0
- package/templates/scaffold/system/site/components/record-toc.tsx +160 -0
- package/templates/scaffold/system/site/components/search-dialog.tsx +130 -0
- package/templates/scaffold/system/site/components/sidebar-status.tsx +35 -0
- package/templates/scaffold/system/site/components/ui/badge.tsx +46 -0
- package/templates/scaffold/system/site/components/ui/button.tsx +62 -0
- package/templates/scaffold/system/site/components/ui/separator.tsx +28 -0
- package/templates/scaffold/system/site/components.json +25 -0
- package/templates/scaffold/system/site/lib/governance.ts +432 -0
- package/templates/scaffold/system/site/lib/layout.shared.tsx +1 -1
- package/templates/scaffold/system/site/lib/shared.ts +38 -0
- package/templates/scaffold/system/site/lib/source.ts +221 -5
- package/templates/scaffold/system/site/lib/utils.ts +6 -0
- package/templates/scaffold/system/site/package.json +9 -3
- package/templates/scaffold/knowledge/example.md +0 -23
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-
|
|
18
|
+
//#region ../content-gateway/dist/main-DKA8sPwv.mjs
|
|
19
19
|
/**
|
|
20
20
|
* A connection could not be ESTABLISHED in time — retryable.
|
|
21
21
|
*
|
|
@@ -3508,6 +3508,27 @@ const POS_TTL_S = 60;
|
|
|
3508
3508
|
function isBadToken(err) {
|
|
3509
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;
|
|
3510
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
|
+
}
|
|
3511
3532
|
function describeError(err) {
|
|
3512
3533
|
return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
3513
3534
|
}
|
|
@@ -3571,6 +3592,13 @@ function createVerify(config, deps, jwksOf) {
|
|
|
3571
3592
|
if (hit !== void 0 && hit.until > now()) return hit.identity;
|
|
3572
3593
|
let claims = null;
|
|
3573
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
|
+
}
|
|
3574
3602
|
try {
|
|
3575
3603
|
claims = await verifyJwt(token);
|
|
3576
3604
|
} catch (err) {
|
|
@@ -4546,7 +4574,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
4546
4574
|
throw lastError;
|
|
4547
4575
|
}
|
|
4548
4576
|
//#endregion
|
|
4549
|
-
//#region ../content/dist/commands-
|
|
4577
|
+
//#region ../content/dist/commands-FLQD4HUi.mjs
|
|
4550
4578
|
/**
|
|
4551
4579
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
4552
4580
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -6562,19 +6590,42 @@ async function allocateRun(client, opts) {
|
|
|
6562
6590
|
*
|
|
6563
6591
|
* Returns 0 when there is no complete embedded generation — the first ingest.
|
|
6564
6592
|
*/
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6593
|
+
/**
|
|
6594
|
+
* Every generation whose vectors this build may copy, best source FIRST.
|
|
6595
|
+
*
|
|
6596
|
+
* Ordered by how much the source has been vetted, then by recency:
|
|
6597
|
+
*
|
|
6598
|
+
* 1. complete runs (`ready` / `active` / `retired`), newest first
|
|
6599
|
+
* 2. ABANDONED runs (`building`), newest first
|
|
6600
|
+
*
|
|
6601
|
+
* The second group used to be excluded outright, and that threw away work an
|
|
6602
|
+
* operator had already paid for: a killed `ksor ingest` leaves its generation in
|
|
6603
|
+
* `building`, so the rerun carried NOTHING and re-embedded the whole corpus.
|
|
6604
|
+
* Reproduced live — an 81-document book killed at 4,736 of 6,963 chunks, whose
|
|
6605
|
+
* rerun reported `carried 0, pending 6963` (issue #97).
|
|
6606
|
+
*
|
|
6607
|
+
* Nothing about an abandoned run makes its vectors wrong. An embedding is a pure
|
|
6608
|
+
* function of (embed input, model); the match key in `carryForward` establishes
|
|
6609
|
+
* identity on its own; and carry only ever fills `pending` rows, so a later pass
|
|
6610
|
+
* can never overwrite an earlier, better-vetted one. The run's STATE therefore
|
|
6611
|
+
* decides priority, not eligibility — which is what ordering expresses and
|
|
6612
|
+
* exclusion could not.
|
|
6613
|
+
*/
|
|
6614
|
+
async function carrySources(client, opts) {
|
|
6615
|
+
return (await client.query(`
|
|
6616
|
+
SELECT DISTINCT c.generation AS gen,
|
|
6617
|
+
CASE WHEN r.state IN ('ready','active','retired') THEN 0 ELSE 1 END AS rank
|
|
6618
|
+
FROM chunks c
|
|
6619
|
+
JOIN ingestion_runs r ON r.tenant_id = c.tenant_id AND r.generation = c.generation
|
|
6620
|
+
WHERE c.tenant_id = $1 AND r.corpus_id = $2
|
|
6621
|
+
AND r.state IN ('ready', 'active', 'retired', 'building')
|
|
6622
|
+
AND c.generation <> $3 AND c.embedding_status = 'embedded'
|
|
6623
|
+
ORDER BY rank, gen DESC
|
|
6572
6624
|
`, [
|
|
6573
6625
|
opts.tenantId,
|
|
6574
6626
|
opts.corpusId,
|
|
6575
6627
|
opts.excludeGeneration
|
|
6576
|
-
])).rows
|
|
6577
|
-
return gen === null ? 0 : Number(gen);
|
|
6628
|
+
])).rows.map((r) => Number(r.gen));
|
|
6578
6629
|
}
|
|
6579
6630
|
/**
|
|
6580
6631
|
* Copy embeddings for chunks whose ENTIRE embed input is unchanged (hash +
|
|
@@ -8530,17 +8581,19 @@ async function buildStructure(client, opts) {
|
|
|
8530
8581
|
fromGeneration: active,
|
|
8531
8582
|
modelId
|
|
8532
8583
|
});
|
|
8533
|
-
const
|
|
8584
|
+
for (const source of await carrySources(client, {
|
|
8534
8585
|
tenantId,
|
|
8535
8586
|
corpusId: opts.corpusId,
|
|
8536
8587
|
excludeGeneration: generation
|
|
8537
|
-
})
|
|
8538
|
-
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8588
|
+
})) {
|
|
8589
|
+
if (source === active) continue;
|
|
8590
|
+
carried += await carryForward(client, {
|
|
8591
|
+
tenantId,
|
|
8592
|
+
generation,
|
|
8593
|
+
fromGeneration: source,
|
|
8594
|
+
modelId
|
|
8595
|
+
});
|
|
8596
|
+
}
|
|
8544
8597
|
const health = await generationHealth(client, {
|
|
8545
8598
|
tenantId,
|
|
8546
8599
|
generation
|
|
@@ -8852,6 +8905,31 @@ async function buildGeneration(pool, instance, options) {
|
|
|
8852
8905
|
function pct(fraction) {
|
|
8853
8906
|
return `${(fraction * 100).toFixed(0)}%`;
|
|
8854
8907
|
}
|
|
8908
|
+
/**
|
|
8909
|
+
* Out-of-corpus probes scoring at or above the weakest in-corpus question,
|
|
8910
|
+
* worst first — the ones that decided the verdict. Empty when the measurement
|
|
8911
|
+
* separated, because then nothing held it open.
|
|
8912
|
+
*/
|
|
8913
|
+
function overlappingProbes(report) {
|
|
8914
|
+
if (report.separable) return [];
|
|
8915
|
+
const weakest = report.low_tail[0]?.score;
|
|
8916
|
+
if (weakest === void 0) return [];
|
|
8917
|
+
return report.detail.filter((d) => !d.in_corpus && d.score >= weakest).toSorted((a, b) => b.score - a.score);
|
|
8918
|
+
}
|
|
8919
|
+
/**
|
|
8920
|
+
* The guidance itself, or null when there is nothing to say.
|
|
8921
|
+
*
|
|
8922
|
+
* Deliberately names BOTH readings. The overlapping probe is sometimes a
|
|
8923
|
+
* question the record covers — mislabelled, and the measurement is fine once it
|
|
8924
|
+
* moves — and sometimes a genuine near-miss the corpus simply cannot separate,
|
|
8925
|
+
* in which case the floor stays uncalibrated and that is the correct outcome.
|
|
8926
|
+
* Asserting either one alone would send half the readers the wrong way.
|
|
8927
|
+
*/
|
|
8928
|
+
function overlapAdvice(report) {
|
|
8929
|
+
const overlapping = overlappingProbes(report);
|
|
8930
|
+
if (overlapping.length === 0) return null;
|
|
8931
|
+
return "these out-of-corpus probes scored at or above your weakest in-corpus question:\n" + overlapping.map((d) => ` ${d.score.toFixed(3)} ${d.query}\n`).join("") + " ^ look at these first. Either the record COVERS one — move it to the\n in-corpus side, because a probe the record answers is not out of corpus\n — or it genuinely does not separate, and the floor stays uncalibrated.\n";
|
|
8932
|
+
}
|
|
8855
8933
|
/** One transaction (tenant GUC + ingest role): list collectables, then reap each. */
|
|
8856
8934
|
async function runGc(pool, instance, options = {}) {
|
|
8857
8935
|
const dryRun = options.dryRun === true;
|
|
@@ -9329,6 +9407,8 @@ async function calibrateCommand(args) {
|
|
|
9329
9407
|
minChars: values["min-chars"] === void 0 ? void 0 : intFlag("--min-chars", values["min-chars"])
|
|
9330
9408
|
}));
|
|
9331
9409
|
process.stdout.write(renderReport(report) + "\n");
|
|
9410
|
+
const advice = overlapAdvice(report);
|
|
9411
|
+
if (advice !== null) process.stdout.write(advice);
|
|
9332
9412
|
return 0;
|
|
9333
9413
|
}
|
|
9334
9414
|
async function grantCommand(args) {
|
|
@@ -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.
|
|
3
|
+
"version": "0.0.21",
|
|
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",
|