@panaversity/ksor 0.0.27 → 0.0.29

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/cli.mjs +108 -14
  3. package/dist/{gateway-api-BF06IsJ--D-eI--yB.mjs → gateway-api-8lNruq9e-CuohjtoK.mjs} +1 -1
  4. package/dist/gateway.mjs +1 -1
  5. package/docs/authorization.md +1 -1
  6. package/docs/deploying.md +71 -20
  7. package/package.json +3 -3
  8. package/templates/scaffold/.agents/skills/format-checker/check.mjs +83 -2
  9. package/templates/scaffold/.claude/skills/format-checker/check.mjs +83 -2
  10. package/templates/scaffold/AGENTS.md +54 -5
  11. package/templates/scaffold/README.md +6 -5
  12. package/templates/scaffold/env.example +24 -8
  13. package/templates/scaffold/knowledge/what-is-a-ksor.flashcards.yaml +25 -0
  14. package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +15 -0
  15. package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +45 -7
  16. package/templates/scaffold/system/site/components/document-actions.tsx +106 -0
  17. package/templates/scaffold/system/site/components/flashcards.tsx +743 -0
  18. package/templates/scaffold/system/site/components/governance.tsx +17 -25
  19. package/templates/scaffold/system/site/components/record-views.tsx +241 -0
  20. package/templates/scaffold/system/site/components/study-aids.tsx +61 -0
  21. package/templates/scaffold/system/site/components/ui/card.tsx +76 -0
  22. package/templates/scaffold/system/site/components/ui/dropdown-menu.tsx +229 -0
  23. package/templates/scaffold/system/site/components/ui/progress.tsx +29 -0
  24. package/templates/scaffold/system/site/lib/attachment-rule.ts +124 -0
  25. package/templates/scaffold/system/site/lib/attachments.ts +105 -0
  26. package/templates/scaffold/system/site/lib/deck.ts +59 -0
  27. package/templates/scaffold/system/site/lib/reading-time.ts +45 -0
  28. package/templates/scaffold/system/site/lib/srs.ts +219 -0
  29. package/templates/scaffold/system/site/lib/stage-knowledge.ts +68 -0
  30. package/templates/scaffold/system/site/source.config.ts +54 -1
  31. package/templates/scaffold/system/site/components/copy-markdown.tsx +0 -70
package/CHANGELOG.md CHANGED
@@ -1,5 +1,92 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.29
4
+
5
+ ### Patch Changes
6
+
7
+ - f7e15cd: **Breaking:** `KSOR_AUTH_DISABLED` and `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED` are
8
+ replaced by one variable, `KSOR_AUTH`, whose value is the decision:
9
+
10
+ ```sh
11
+ KSOR_AUTH=disabled-local # no auth, loopback only — a public bind REFUSES
12
+ KSOR_AUTH=disabled-public # no auth, served to anyone who can reach the port
13
+ ```
14
+
15
+ Two booleans that had to agree to express one decision produced a state neither
16
+ name could tell you, and a fourth combination that meant nothing. `AUTH_DISABLED`
17
+ sounds like it already means "auth is off", so being told you also need
18
+ `ALLOW_PUBLIC_UNAUTHENTICATED` read as the tool asking you to say the same thing
19
+ twice. The guarantee is unchanged and unweakened — a copied `.env` carrying
20
+ `disabled-local` still refuses on a container, which is the leak that pair
21
+ existed to catch. Setting either retired variable now refuses at boot and names
22
+ its replacement.
23
+
24
+ **The boot report no longer stays silent about ephemeral snapshot keys.** Unset
25
+ `KSOR_SNAPSHOT_KEYS` mints a per-process signing key — honest for one process,
26
+ and wrong for the container hosts we ship a Dockerfile for. A generation pin
27
+ issued by one instance is then unverifiable by the next, so `read` silently drops
28
+ to the active generation and reports `refreshed (invalid)`. It fails soft, so
29
+ nothing errors and nothing logs; the only symptom is an agent reading a
30
+ generation it did not search. Found on a real deployment by noticing one read in
31
+ three come back unpinned. On a public bind the door now says so:
32
+
33
+ ```
34
+ snapshot EPHEMERAL key — generation pins will NOT survive a restart or a
35
+ second instance; set KSOR_SNAPSHOT_KEYS to a value shared by every replica
36
+ ```
37
+
38
+ Not a refusal — a loopback dev run and a genuine single-instance deployment are
39
+ both legitimate — but no longer silent where the assumption stops holding.
40
+
41
+ `docs/deploying.md` splits its configuration table into three tiers: required to
42
+ boot, set on any container host, and set once auth is on. Listing six variables
43
+ as one table read as "set all of these or you are doing it wrong", and only the
44
+ first tier was ever true.
45
+
46
+ ## 0.0.28
47
+
48
+ ### Patch Changes
49
+
50
+ - 24ec8c3: Each document page now says how long it takes to read.
51
+
52
+ The figure is counted when the site is built, from the document's own markdown,
53
+ so it is in the shipped HTML — a reader whose bundle failed, a crawler and an
54
+ agent parsing the page all get it. Fenced code and frontmatter are left out of
55
+ the count, so a short page carrying a long example is not reported as a
56
+ twenty-minute read.
57
+
58
+ Nothing to author: it is derived from the words already there. Where a document
59
+ has a summary, both tabs carry their own figure, so a reader can see what the
60
+ summary saves them before opening it.
61
+
62
+ - 6abcf1f: Summaries and flashcard decks, as governed attachments of a document.
63
+
64
+ A document in `knowledge/` may now carry two companions named after it —
65
+ `<doc>.summary.md` and `<doc>.flashcards.yaml`. The summary joins the record's
66
+ own words as a second tab; the deck renders at the end of the page, with spaced
67
+ review kept in the reader's browser and Shuffle / Guide / Download beneath it.
68
+ `ksor init` ships one of each so a fresh project shows the shape rather than
69
+ describing it.
70
+
71
+ An attachment is **part of its document, not a document**. It gets no URL, no
72
+ sidebar row, no `llms.txt` line, no markdown twin, no search entry — and no
73
+ stable id, so an agent can never cite it as a source in its own right. It takes
74
+ its `visibility:` and its takedown from its parent: restrict or withdraw the
75
+ document and its summary and deck go with it. An attachment declaring
76
+ frontmatter, or one whose document is missing, is refused by `pnpm check` and by
77
+ `pnpm build`.
78
+
79
+ **If your record already has `.summary.md` files and you serve over MCP, read
80
+ this.** They were previously ingested as ordinary documents, each with its own
81
+ id and its own governance defaults. They no longer are. After upgrading, run
82
+ `pnpm refresh`; if a takedown names one of those ids, `ksor serve` will refuse
83
+ to boot until the denial is pointed at the parent document or retired
84
+ deliberately. That refusal is the fix working — those rows governed a node that
85
+ should never have existed.
86
+
87
+ Review scheduling is a two-grade SM-2 variant (`ksor-sm2-v1`). It is not FSRS
88
+ and claims no retention target.
89
+
3
90
  ## 0.0.27
4
91
 
5
92
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { n as resolveCommand, r as verbs, t as exitCodes } from "./src-pl4aOpVs.mjs";
3
- import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-BF06IsJ--D-eI--yB.mjs";
3
+ import { A as runProbe$1, C as keyRingFromEnv, D as prewarmPool, E as pooledEndpointFor, F as withPgRetry$1, I as withProbeDeadline$1, L as z$1, M as storedTextSearchConfig, N as tlsPosture, O as readHandler, P as visibleTiers$1, S as embedQueryVlit, T as parseInstanceText$1, _ as buildShippedProvider$1, a as MAX_OUTLINE_LIMIT, b as contentPool$1, c as MissingProviderKeyError$1, d as READ_OUTPUT, f as SEARCH_OUTPUT, g as assertSchemaCompatible, h as assertGovernanceServable$1, i as GovernanceGateError$1, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, m as TextSearchConfigMismatch, n as EmbeddingSpaceMismatch$1, p as SchemaVersionError, r as FLOOR, s as McpServer$1, t as ContentStoreError$1, u as READ_ONLY, v as checkEmbeddingSpace$1, w as outlineHandler, x as contentPoolMin, y as composeInstructions } from "./gateway-api-8lNruq9e-CuohjtoK.mjs";
4
4
  import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { InMemoryTransport, LATEST_PROTOCOL_VERSION, createMcpHandler } from "@modelcontextprotocol/server";
@@ -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-DE9PhWEx.mjs
19
+ //#region ../content-gateway/dist/main-C-OMrGGt.mjs
20
20
  /**
21
21
  * The default registration — and the ORIGINAL of the file `ksor init` emits.
22
22
  *
@@ -280,13 +280,18 @@ function configFromEnv(env) {
280
280
  */
281
281
  function buildAuth(env = process.env, deps = {}) {
282
282
  const warn = deps.warn ?? defaultWarn;
283
- const disabled = env.KSOR_AUTH_DISABLED === "1";
283
+ const declared = (env.KSOR_AUTH ?? "").trim();
284
284
  const config = configFromEnv(env);
285
- if (disabled) {
286
- if (config !== null) warn("auth DISABLED via KSOR_AUTH_DISABLED despite SSO config — UNAUTHENTICATED (dev)");
287
- return { mode: "disabled" };
285
+ if (declared === "" && (env.KSOR_AUTH_DISABLED !== void 0 || env.KSOR_ALLOW_PUBLIC_UNAUTHENTICATED !== void 0)) throw new AuthConfigError("KSOR_AUTH_DISABLED and KSOR_ALLOW_PUBLIC_UNAUTHENTICATED have been replaced by one variable, KSOR_AUTH. Set KSOR_AUTH=disabled-local for a loopback dev run, or KSOR_AUTH=disabled-public to serve the whole record to anyone who can reach the port.");
286
+ if (declared === "disabled-local" || declared === "disabled-public") {
287
+ if (config !== null) warn(`auth DISABLED via KSOR_AUTH=${declared} despite SSO config — UNAUTHENTICATED`);
288
+ return {
289
+ mode: "disabled",
290
+ publicAllowed: declared === "disabled-public"
291
+ };
288
292
  }
289
- if (config === null) throw new AuthConfigError("auth is not configured (KSOR_SSO_URL / KSOR_MCP_RESOURCE_URL unset) and KSOR_AUTH_DISABLED is not '1' refusing to boot unauthenticated. Set both SSO env vars, or set KSOR_AUTH_DISABLED=1 for a deliberate dev/unauthenticated run.");
293
+ if (declared !== "") throw new AuthConfigError(`KSOR_AUTH=${JSON.stringify(declared)} is not a posture. Use "disabled-local" (loopback dev run) or "disabled-public" (serves the whole record to anyone who can reach the port), or unset it and configure the SSO door.`);
294
+ if (config === null) throw new AuthConfigError("auth is not configured (KSOR_SSO_URL / KSOR_MCP_RESOURCE_URL unset) and KSOR_AUTH is unset — refusing to boot unauthenticated. Set both SSO env vars, or set KSOR_AUTH=disabled-local for a deliberate dev/unauthenticated run.");
290
295
  if (config.allowedAudiences.length === 0) throw new AuthConfigError("auth is ON but KSOR_JWT_ALLOWED_AUDIENCES is empty — set it to this server's MCP URL (fail-closed: an unset audience allowlist would accept any SSO-signed token).");
291
296
  let resolution = null;
292
297
  const jwks = () => {
@@ -540,10 +545,34 @@ function withoutSdkResponseModeWarning(body) {
540
545
  */
541
546
  function authPosture(mode, host, publicUnauthenticated) {
542
547
  if (mode !== "disabled") return "bearer tokens, verified against the record's authorization server";
543
- if (publicUnauthenticated) return `UNAUTHENTICATED and bound to ${host} — KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1 is set, so the whole record is served to anyone who can reach this port`;
548
+ if (publicUnauthenticated) return `UNAUTHENTICATED and bound to ${host} — KSOR_AUTH=disabled-public is set, so the whole record is served to anyone who can reach this port`;
544
549
  return `DISABLED — ${host} only, and a public bind will refuse to boot`;
545
550
  }
546
551
  /**
552
+ * Whether a generation pin will survive this deployment.
553
+ *
554
+ * Unset `KSOR_SNAPSHOT_KEYS` mints an EPHEMERAL per-process key. That is honest
555
+ * for one process — the code that chose it said so — but a container is not one
556
+ * process. On a scale-to-zero host every cold start mints a new key, so a
557
+ * snapshot token issued by one instance is unverifiable by the next and `read`
558
+ * silently drops to the ACTIVE generation, reporting `refreshed (invalid)`.
559
+ *
560
+ * Silently is the problem. It fails SOFT by design, so nothing errors and
561
+ * nothing logs; the only symptom is an agent reading a generation it did not
562
+ * search, which surfaces as roughly one read in three coming back unpinned.
563
+ * Found exactly that way on a real deployment, which is why this line exists —
564
+ * every other posture on this report announced itself and this one did not.
565
+ *
566
+ * Not a refusal: a loopback dev run and a genuine single-instance deployment
567
+ * are both legitimate, and neither is harmed. So the line appears only where
568
+ * the assumption actually stops holding — a public bind.
569
+ */
570
+ function snapshotPosture(activeKeyId, loopback) {
571
+ if (activeKeyId !== "ephemeral") return null;
572
+ if (loopback) return null;
573
+ return "EPHEMERAL key — generation pins will NOT survive a restart or a second instance; set KSOR_SNAPSHOT_KEYS to a value shared by every replica";
574
+ }
575
+ /**
547
576
  * What the record will refuse. `null` means no floor is declared and the gate
548
577
  * is off — which is honest, and is a correct level-0 state, but an agent
549
578
  * pointed at this door will get a confident cited answer to a question the
@@ -968,7 +997,7 @@ async function runHttp(composition) {
968
997
  const resourceMetadataUrl = auth.mode === "public" ? new URL("/.well-known/oauth-protected-resource/mcp", auth.config.resourceUrl).toString() : "";
969
998
  const bind = resolveBind(process.env);
970
999
  const loopback = bind.host === "127.0.0.1" || bind.host === "localhost" || bind.host === "::1";
971
- if (auth.mode === "disabled" && !loopback && process.env["KSOR_ALLOW_PUBLIC_UNAUTHENTICATED"] !== "1") throw new AuthConfigError(`refusing an UNAUTHENTICATED PUBLIC bind (${bind.host}) — KSOR_AUTH_DISABLED is the loopback-dev flag, not a licence to serve the corpus to the internet with no auth. Configure the SSO door (KSOR_SSO_URL + KSOR_MCP_RESOURCE_URL + KSOR_JWT_ALLOWED_AUDIENCES), bind loopback, or set KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1 to accept the risk deliberately.`);
1000
+ if (auth.mode === "disabled" && !loopback && !auth.publicAllowed) throw new AuthConfigError(`refusing an UNAUTHENTICATED PUBLIC bind (${bind.host}) — KSOR_AUTH=disabled-local is the loopback dev posture, not a licence to serve the corpus to the internet with no auth. Configure the SSO door (KSOR_SSO_URL + KSOR_MCP_RESOURCE_URL + KSOR_JWT_ALLOWED_AUDIENCES), bind loopback, or set KSOR_AUTH=disabled-public to accept the risk deliberately.`);
972
1001
  const security = resolveSecurity(bind);
973
1002
  const { ctx, instance, pool, spaceSkipReason, version, verifyBoot } = composition;
974
1003
  const maxBodyBytes = envInt$1(process.env, "KSOR_MAX_BODY_BYTES", 1e6, { minimum: 1024 });
@@ -1142,8 +1171,10 @@ async function runHttp(composition) {
1142
1171
  s.once("error", reject);
1143
1172
  });
1144
1173
  if (recordIsUndescribed(instance.instructions)) console.error(bootLine("identity", UNDESCRIBED_RECORD));
1145
- console.error(bootLine("auth", authPosture(auth.mode, bind.host, process.env["KSOR_ALLOW_PUBLIC_UNAUTHENTICATED"] === "1" && !loopback)));
1174
+ console.error(bootLine("auth", authPosture(auth.mode, bind.host, auth.mode === "disabled" && auth.publicAllowed && !loopback)));
1146
1175
  for (const line of keyLines) console.error(line);
1176
+ const snapshot = snapshotPosture(ctx.ring.active, loopback);
1177
+ if (snapshot !== null) console.error(bootLine("snapshot", snapshot));
1147
1178
  console.error(bootLine("abstain", abstainPosture(instance.abstain.vectorFloor)));
1148
1179
  console.error(bootLine("serving", `http://${bind.host}:${bind.port}/mcp`));
1149
1180
  let draining = false;
@@ -1181,7 +1212,7 @@ async function runHttp(composition) {
1181
1212
  * The bind decides the posture: unset PORT/host → loopback (127.0.0.1),
1182
1213
  * the dev door, DNS-rebind-protected and safe with auth off; a public bind
1183
1214
  * is a deliberate act that fails closed unless auth is configured or
1184
- * KSOR_AUTH_DISABLED=1 is set explicitly.
1215
+ * KSOR_AUTH=disabled-local is set explicitly.
1185
1216
  *
1186
1217
  * Env contract (each `KSOR_*` name descends from an oracle `SOR_*` var):
1187
1218
  * KSOR_INSTANCE path to instance.md (default ./instance.md)
@@ -1190,7 +1221,7 @@ async function runHttp(composition) {
1190
1221
  * KSOR_SNAPSHOT_KEYS kid=secret[,...]; unset = ephemeral key
1191
1222
  * KSOR_MCP_HOST / KSOR_MCP_PORT bind (loopback unless PORT — deliberate)
1192
1223
  * KSOR_SSO_URL + KSOR_MCP_RESOURCE_URL + KSOR_JWT_ALLOWED_AUDIENCES public door
1193
- * KSOR_AUTH_DISABLED=1 the deliberate unauthenticated opt-out
1224
+ * KSOR_AUTH=disabled-local the deliberate unauthenticated opt-out
1194
1225
  *
1195
1226
  * Exit codes follow the ksor CLI contract: refusal 1, environment 3 —
1196
1227
  * always a remedied message, never a stack trace.
@@ -1552,7 +1583,7 @@ async function withPgRetry(op, options = {}) {
1552
1583
  throw lastError;
1553
1584
  }
1554
1585
  //#endregion
1555
- //#region ../content/dist/commands-FLQD4HUi.mjs
1586
+ //#region ../content/dist/commands-DU1nqwZa.mjs
1556
1587
  /**
1557
1588
  * EVAL-LOCKED constants, quarried verbatim from the oracle
1558
1589
  * (sor-agentfactory @ b554f91, config.py) — changing any of these is a
@@ -4148,6 +4179,54 @@ function compareSiblings(a, b) {
4148
4179
  return codePointCompare$1(a.tie, b.tie);
4149
4180
  }
4150
4181
  /**
4182
+ * What makes a file in the record an ATTACHMENT rather than a document.
4183
+ *
4184
+ * A document may carry study attachments named after it — `x.summary.md` and
4185
+ * `x.flashcards.yaml` belong to `x.md` in the same directory. An attachment is
4186
+ * PART OF its parent: no route, no sidebar entry, no llms.txt line, no stable
4187
+ * id, no MCP node, and its parent's governance rather than its own.
4188
+ *
4189
+ * This rule is duplicated by construction — the kernel's ingest decides what
4190
+ * becomes a node, the site's staging decides what is copied, the site's build
4191
+ * decides what is a page, and the record's checker decides what is well-formed.
4192
+ * Four readers of one rule is exactly the shape decision 18 names, so this file
4193
+ * is canonical and every other copy is asserted against it rather than trusted.
4194
+ *
4195
+ * A LEAF: no imports, so any of those four can take it without taking anything
4196
+ * else with it.
4197
+ */
4198
+ /** The suffix that marks each kind, longest-match first. */
4199
+ const ATTACHMENT_SUFFIXES = [
4200
+ {
4201
+ suffix: ".summary.md",
4202
+ kind: "summary"
4203
+ },
4204
+ {
4205
+ suffix: ".summary.mdx",
4206
+ kind: "summary"
4207
+ },
4208
+ {
4209
+ suffix: ".flashcards.yaml",
4210
+ kind: "deck"
4211
+ }
4212
+ ];
4213
+ /**
4214
+ * The attachment kind this file name carries, or null when it is not one.
4215
+ *
4216
+ * Matched on the whole base name, never on a path: `.summary.md` in a directory
4217
+ * called `summary` is not an attachment, and a file called exactly
4218
+ * `.summary.md` (a dotfile with no stem) has no parent to attach to and is not
4219
+ * one either — the same "a dotfile has no suffix" boundary ingest's isDoc uses.
4220
+ */
4221
+ function attachmentKindOf(baseName) {
4222
+ for (const entry of ATTACHMENT_SUFFIXES) if (baseName.length > entry.suffix.length && baseName.endsWith(entry.suffix)) return entry.kind;
4223
+ return null;
4224
+ }
4225
+ /** True when this file name is an attachment of some document. */
4226
+ function isAttachment(baseName) {
4227
+ return attachmentKindOf(baseName) !== null;
4228
+ }
4229
+ /**
4151
4230
  * The plain-tree corpus adapter — ANY folder of Markdown becomes a corpus.
4152
4231
  * Converted from the oracle (sor-agentfactory @ b554f91,
4153
4232
  * ingest/adapters/plain_tree.py); the kernel cannot tell this manifest from
@@ -4360,8 +4439,23 @@ function buildManifestFromTree(root, options) {
4360
4439
  sources
4361
4440
  };
4362
4441
  }
4363
- /** Python `p.suffix in (".md", ".mdx")` parity: a dotfile named exactly ".md" has NO suffix. */
4442
+ /**
4443
+ * Python `p.suffix in (".md", ".mdx")` parity: a dotfile named exactly ".md"
4444
+ * has NO suffix.
4445
+ *
4446
+ * ATTACHMENTS ARE NOT DOCUMENTS. `x.summary.md` ends in `.md`, so without the
4447
+ * attachment rule it ingested as a node of its own — `stable_id`
4448
+ * `knowledge/x.summary`, its own `content_nodes.visibility` coalescing to the
4449
+ * record default, and its own takedown state. That is one cause wearing four
4450
+ * costumes: the door served a summary the site hides; served an internal
4451
+ * parent's summary at the public tier; served a taken-down parent's summary
4452
+ * undenied (per-node denial matches a different id, and the subtree walk goes
4453
+ * through `parent_id`, which is the enclosing SECTION, not the sibling
4454
+ * document); and served an orphan the site refuses. An attachment belongs to
4455
+ * its parent, so it gets no id and is never independently citable.
4456
+ */
4364
4457
  function isDoc(name) {
4458
+ if (isAttachment(name)) return false;
4365
4459
  const dot = name.lastIndexOf(".");
4366
4460
  if (dot <= 0) return false;
4367
4461
  const suffix = name.slice(dot);
@@ -5,7 +5,7 @@ import { z, z as z$1 } from "zod";
5
5
  import pg from "pg";
6
6
  import path from "node:path";
7
7
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
8
- //#region ../content-gateway/dist/gateway-api-BF06IsJ-.mjs
8
+ //#region ../content-gateway/dist/gateway-api-8lNruq9e.mjs
9
9
  /**
10
10
  * The record's system prompt: instance.md's body, preserved beneath a framework
11
11
  * floor. Lifted out of server.ts so the public gateway surface can re-export
package/dist/gateway.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-BF06IsJ--D-eI--yB.mjs";
1
+ import { L as z$1, O as readHandler, a as MAX_OUTLINE_LIMIT, d as READ_OUTPUT, f as SEARCH_OUTPUT, j as searchHandler, k as recordIsUndescribed, l as OUTLINE_OUTPUT, o as MAX_SEARCH_K, r as FLOOR, s as McpServer$1, u as READ_ONLY, w as outlineHandler, y as composeInstructions } from "./gateway-api-8lNruq9e-CuohjtoK.mjs";
2
2
  export { FLOOR, MAX_OUTLINE_LIMIT, MAX_SEARCH_K, McpServer$1 as McpServer, OUTLINE_OUTPUT, READ_ONLY, READ_OUTPUT, SEARCH_OUTPUT, composeInstructions, outlineHandler, readHandler, recordIsUndescribed, searchHandler, z$1 as z };
@@ -189,7 +189,7 @@ re-admitted the instant the key set catches up.
189
189
 
190
190
  ## Before a public bind
191
191
 
192
- - Auth configured as above, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` set
192
+ - Auth configured as above, **or** `KSOR_AUTH=disabled-public` set
193
193
  deliberately — the door will not come up on a public address without one of
194
194
  them, and the second is a decision, not a default.
195
195
  - `KSOR_ALLOWED_HOSTS` set to the host you serve on.
package/docs/deploying.md CHANGED
@@ -103,30 +103,81 @@ setting came out.
103
103
 
104
104
  ## Configuration
105
105
 
106
- The door takes everything from the environment. Set these on the deployment, not
107
- in a file:
108
-
109
- | variable | why |
110
- | ----------------------- | ------------------------------------------------------------------------ |
111
- | `KSOR_DB_URL` | the record's Postgres store |
112
- | `GEMINI_API_KEY` | embeds the incoming query, so retrieval works at all |
113
- | `KSOR_SNAPSHOT_KEYS` | `kid=secret` **required in practice**, see below |
114
- | `KSOR_ALLOWED_HOSTS` | the host you serve on (DNS-rebind defence) |
115
- | `KSOR_MCP_RESOURCE_URL` | this record's canonical URL, e.g. `https://your-host/mcp` |
116
- | auth, one of two | `KSOR_SSO_URL` + audiences, **or** `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1` |
117
-
118
- `KSOR_SNAPSHOT_KEYS` is listed as a production knob but behaves as a
119
- requirement on any host that scales to zero. Unset, the signing key is generated
120
- **per process** so a citation minted before a scale-down stops validating
121
- after it, with a single instance and no replicas involved. Generate one:
106
+ Three tiers, because listing them as one table reads as "set all of these or you
107
+ are doing it wrong" — and only the first tier is true.
108
+
109
+ ### Required — the door will not boot without these
110
+
111
+ | variable | why |
112
+ | ----------------------------------------- | ---------------------------------------------------- |
113
+ | `KSOR_DB_URL` | the record's Postgres store |
114
+ | `GEMINI_API_KEY` | embeds the incoming query, so retrieval works at all |
115
+ | `KSOR_AUTH`, **or** a configured SSO door | see below |
116
+
117
+ `KSOR_AUTH` takes one of two values, and the value IS the decision:
118
+
119
+ ```sh
120
+ KSOR_AUTH=disabled-local # no auth, loopback only a public bind REFUSES
121
+ KSOR_AUTH=disabled-public # no auth, and served to anyone who can reach the port
122
+ ```
123
+
124
+ **A container sets `$PORT`, so the door binds `0.0.0.0` — a public bind.**
125
+ `disabled-local` refuses there, deliberately: copying a dev `.env` into a hosting
126
+ dashboard must not quietly open your record to the internet. `disabled-public` is
127
+ correct for a genuinely public record or one behind your own gateway; it is not a
128
+ way to make a deploy go green.
129
+
130
+ The alternative is a real authorization server — `KSOR_SSO_URL`,
131
+ `KSOR_MCP_RESOURCE_URL`, `KSOR_JWT_ALLOWED_AUDIENCES`, with worked recipes for two
132
+ of them in [authorization.md](./authorization.md).
133
+
134
+ ### Set this on any container host
135
+
136
+ | variable | why |
137
+ | -------------------- | ---------------------------------------- |
138
+ | `KSOR_SNAPSHOT_KEYS` | `kid=secret`, identical on every replica |
139
+
140
+ Unset mints an **ephemeral per-process key**. A `search` hands back a snapshot
141
+ token pinning the generation it answered from; `read` honours that pin so a
142
+ conversation stays on one version of the record. With a per-process key, a token
143
+ minted by one instance is unverifiable by the next — so `read` silently drops to
144
+ the ACTIVE generation and reports `refreshed (invalid)`.
145
+
146
+ It fails **soft**, so nothing errors and nothing logs. The only symptom is an
147
+ agent reading a generation it did not search, seen as roughly one read in three
148
+ coming back unpinned. Generate one with `openssl rand -hex 32`:
122
149
 
123
150
  ```sh
124
151
  KSOR_SNAPSHOT_KEYS="k1=$(openssl rand -hex 32)"
125
152
  ```
126
153
 
127
- The value is `kid=secret`, not `kid:secret`, and the first entry is the active
128
- one. Multiple entries let you rotate without invalidating outstanding
129
- citations.
154
+ `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
156
+ byte-identical across every instance of one deployment. Set it once and leave it
157
+ alone: rotating invalidates every outstanding pin, and a compromised snapshot key
158
+ cannot read a withdrawn document, cross an audience boundary, or authenticate
159
+ anything.
160
+
161
+ The boot report now says so out loud when it matters:
162
+
163
+ ```
164
+ snapshot EPHEMERAL key — generation pins will NOT survive a restart or a
165
+ second instance; set KSOR_SNAPSHOT_KEYS to a value shared by every replica
166
+ ```
167
+
168
+ ### Set these once auth is ON
169
+
170
+ | variable | why |
171
+ | ---------------------- | ------------------------------------------ |
172
+ | `KSOR_ALLOWED_HOSTS` | Host allow-list — DNS-rebind defence |
173
+ | `KSOR_ALLOWED_ORIGINS` | browser Origin allow-list |
174
+ | `KSOR_SSO_ISSUER` | one more check per token, for one variable |
175
+
176
+ Unset, Host validation is simply off on a public bind — permissive, not refusing,
177
+ so it is never the reason a deploy fails. It earns its place when auth is on:
178
+ rebinding is worth an attacker's effort only when it reaches something they could
179
+ not reach directly. With `KSOR_AUTH=disabled-public` the record is already served
180
+ to anyone who types the URL, so there is nothing for rebinding to steal.
130
181
 
131
182
  ### The site build needs the DSN too
132
183
 
@@ -154,7 +205,7 @@ ways past it:
154
205
  - **Configure the SSO door** — `KSOR_SSO_URL`, `KSOR_MCP_RESOURCE_URL`,
155
206
  `KSOR_JWT_ALLOWED_AUDIENCES`. Worked recipes for two different authorization
156
207
  servers: [authorization.md](./authorization.md).
157
- - **Set `KSOR_ALLOW_PUBLIC_UNAUTHENTICATED=1`** — a deliberate decision that
208
+ - **Set `KSOR_AUTH=disabled-public`** — a deliberate decision that
158
209
  serves your whole record to anyone who can reach the port. Correct for a
159
210
  genuinely public record, or behind your own gateway. Never as a way to get a
160
211
  deploy green.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
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",
@@ -71,8 +71,8 @@
71
71
  "tsdown": "0.22.14",
72
72
  "typescript": "7.0.2",
73
73
  "vitest": "^4.1.10",
74
- "@panaversity/ksor-content-gateway": "0.0.0",
75
- "@panaversity/ksor-content": "0.0.0"
74
+ "@panaversity/ksor-content": "0.0.0",
75
+ "@panaversity/ksor-content-gateway": "0.0.0"
76
76
  },
77
77
  "engines": {
78
78
  "node": ">=24"
@@ -35,6 +35,35 @@ const REQUIRED_KEYS = ["title", "status"]; // level 0 — the ladder, not a gate
35
35
  const STATUS_VALUES = new Set(["draft", "review", "approved", "superseded"]);
36
36
  const ASSET_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]);
37
37
 
38
+ // Study attachments: `x.summary.md` and `x.flashcards.yaml` belong to `x.md`.
39
+ // An attachment is PART OF its parent — no route, no stable id, no governance
40
+ // of its own — so it is neither a document nor an asset, and gets its own
41
+ // rules below. This mirrors packages/content/src/lib/attachment-rule.ts, which
42
+ // this dependency-free file cannot import; ATTACHMENT_CASES there is the table
43
+ // both are held to.
44
+ const ATTACHMENT_SUFFIXES = [".summary.md", ".summary.mdx", ".flashcards.yaml"];
45
+ // One character off a real attachment, refused BY NAME: `.yml` reaches the
46
+ // site bundler's `Unknown file type` throw, which names the path and nothing
47
+ // about the rule.
48
+ const ATTACHMENT_NEAR_MISSES = [
49
+ [".flashcards.yml", ".flashcards.yaml"],
50
+ [".flashcards.json", ".flashcards.yaml"],
51
+ [".summary.markdown", ".summary.md"],
52
+ ];
53
+
54
+ /** The attachment suffix this name carries, or null. A dotfile has no stem. */
55
+ function attachmentSuffixOf(base) {
56
+ return ATTACHMENT_SUFFIXES.find((s) => base.length > s.length && base.endsWith(s)) ?? null;
57
+ }
58
+ function isAttachment(base) {
59
+ return attachmentSuffixOf(base) !== null;
60
+ }
61
+ /** The document an attachment belongs to: always `<stem>.md`. */
62
+ function parentDocumentOf(base) {
63
+ const suffix = attachmentSuffixOf(base);
64
+ return suffix === null ? null : `${base.slice(0, -suffix.length)}.md`;
65
+ }
66
+
38
67
  // PNG integrity, dependency-free: signature + per-chunk CRC-32. A damaged
39
68
  // image beside a document is a check-time problem with the file named, never
40
69
  // a build-time 500 with no filename in it.
@@ -381,7 +410,9 @@ if (!existsSync(knowledgeDir)) {
381
410
  }
382
411
  const dirs = walkDirs(knowledgeDir);
383
412
  const all = [...files, ...dirs];
384
- const mdFiles = files.filter((p) => p.endsWith(".md"));
413
+ // Attachments are never documents: they carry no frontmatter, own no route,
414
+ // and are checked by their own rules instead.
415
+ const mdFiles = files.filter((p) => p.endsWith(".md") && !isAttachment(path.basename(p)));
385
416
 
386
417
  if (mdFiles.length === 0) {
387
418
  problem(
@@ -446,7 +477,12 @@ if (!existsSync(knowledgeDir)) {
446
477
  );
447
478
  }
448
479
  seenLower.set(lower, rel);
449
- if (files.includes(p) && !p.endsWith(".md") && !ASSET_EXTENSIONS.has(path.extname(p))) {
480
+ if (
481
+ files.includes(p) &&
482
+ !p.endsWith(".md") &&
483
+ !isAttachment(base) &&
484
+ !ASSET_EXTENSIONS.has(path.extname(p))
485
+ ) {
450
486
  problem(
451
487
  rel,
452
488
  `unexpected file type "${path.extname(p) || base}"`,
@@ -486,6 +522,51 @@ if (!existsSync(knowledgeDir)) {
486
522
  }
487
523
  }
488
524
 
525
+ // Study attachments: bound to a parent, carrying no governance of their own.
526
+ for (const p of files) {
527
+ const base = path.basename(p);
528
+ const rel = path.relative(root, p);
529
+
530
+ const nearMiss = ATTACHMENT_NEAR_MISSES.find(
531
+ ([wrong]) => base.length > wrong.length && base.endsWith(wrong),
532
+ );
533
+ if (nearMiss && !isAttachment(base)) {
534
+ problem(
535
+ rel,
536
+ `${nearMiss[0]} is not an attachment extension`,
537
+ "the site reads decks as YAML and accepts only .yaml — a near miss is not picked up as a deck, and fails the build naming the path but not the rule",
538
+ `rename it to ${base.slice(0, -nearMiss[0].length)}${nearMiss[1]}`,
539
+ );
540
+ continue;
541
+ }
542
+
543
+ if (!isAttachment(base)) continue;
544
+
545
+ const parent = parentDocumentOf(base);
546
+ if (parent && !existsSync(path.join(path.dirname(p), parent))) {
547
+ problem(
548
+ rel,
549
+ `attachment of ${parent}, which is not in the record`,
550
+ "an attachment inherits its parent's governance — with no parent there is nothing to inherit, so it would be published under no tier and covered by no takedown",
551
+ `add ${path.join(path.dirname(rel), parent)}, or remove ${rel}`,
552
+ );
553
+ }
554
+
555
+ if (base.endsWith(".md") || base.endsWith(".mdx")) {
556
+ const text = readFileSync(p, "utf8")
557
+ .replace(/^\uFEFF/, "")
558
+ .replaceAll("\r\n", "\n");
559
+ if (text.startsWith("---\n")) {
560
+ problem(
561
+ rel,
562
+ "attachment declares frontmatter",
563
+ "an attachment is part of its parent and carries none of its own governance — a key here would look like it governs something and would govern nothing (a visibility: on a summary of a restricted document is the shape that matters)",
564
+ `remove the frontmatter block; ${parent ?? "its parent"} is what carries the governance`,
565
+ );
566
+ }
567
+ }
568
+ }
569
+
489
570
  // foo.md vs foo/index.md route collisions
490
571
  for (const p of mdFiles) {
491
572
  const sibling = p.replace(/\.md$/, "");