@panaversity/ksor 0.0.2 → 0.0.4
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 +87 -0
- package/README.md +6 -4
- package/dist/cli.mjs +6348 -8
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +1 -36
- package/dist/src-CpDIVudJ.mjs +43 -0
- package/docs/index.md +10 -4
- package/package.json +18 -5
- package/schema/schema.sql +316 -0
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +4 -1
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +8 -1
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +241 -9
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +27 -7
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +4 -1
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +8 -1
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +241 -9
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +27 -7
- package/templates/scaffold/AGENTS.md +137 -14
- package/templates/scaffold/README.md +55 -3
- package/templates/scaffold/gitignore +3 -0
- package/templates/scaffold/instance.md +3 -2
- package/templates/scaffold/package.json +8 -1
- package/templates/scaffold/pnpm-workspace.yaml +21 -5
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/docs/layout.tsx +2 -2
- package/templates/scaffold/system/site/components/footer-mark.tsx +22 -0
- package/templates/scaffold/system/site/lib/audience.ts +178 -0
- package/templates/scaffold/system/site/lib/shared.ts +14 -5
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +301 -0
- package/templates/scaffold/system/site/source.config.ts +7 -1
- package/templates/scaffold/vercel.json +8 -0
|
@@ -18,7 +18,32 @@ pnpm dev # browse the knowledge at http://localhost:3000
|
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
No pnpm? Run `npm install -g pnpm` — or `corepack enable pnpm` on Node
|
|
21
|
-
versions that bundle corepack.
|
|
21
|
+
versions that bundle corepack. The first `pnpm install` also fetches the
|
|
22
|
+
`ksor` tool (pinned in `package.json`) and writes it into your lockfile —
|
|
23
|
+
commit the updated lockfile.
|
|
24
|
+
|
|
25
|
+
### Serving to agents
|
|
26
|
+
|
|
27
|
+
The record's other surface is an MCP server for AI agents — the same
|
|
28
|
+
knowledge, cited, with honest abstention. It is the climbed rung: it needs a
|
|
29
|
+
Postgres store (with pgvector) and an embedding provider key, so it is not
|
|
30
|
+
part of `pnpm dev`. The ordered path is:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
export KSOR_DB_URL='postgresql://…' # the DSN var your instance.md names
|
|
34
|
+
export GEMINI_API_KEY='…' # the embedding provider key
|
|
35
|
+
pnpm schema # apply the database schema (once)
|
|
36
|
+
pnpm grant # authorize ingest for this corpus (once)
|
|
37
|
+
pnpm ingest # embed knowledge/ into a generation and activate it
|
|
38
|
+
pnpm serve # run the MCP server over the record
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
One setup step comes before this — adding the `database:`/`embedding:` blocks
|
|
42
|
+
to `instance.md` — and there is more to know about the generation model and the
|
|
43
|
+
fail-closed security posture. `AGENTS.md` → "Serving to agents" is the
|
|
44
|
+
full runbook; your coding agent reads it first. `pnpm serve` binds loopback
|
|
45
|
+
with auth off for local use; a public bind fails closed unless auth is
|
|
46
|
+
configured. Any other operation is `pnpm exec ksor <verb>`.
|
|
22
47
|
|
|
23
48
|
Then talk to your coding agent — `AGENTS.md` carries the working rules, and
|
|
24
49
|
the agent kit in `.agents/skills/` knows how to interview you
|
|
@@ -34,7 +59,7 @@ different coding agent's way of finding the same working contract.
|
|
|
34
59
|
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
60
|
| `knowledge/` | **the record** — your governed markdown. The product; everything else serves it. |
|
|
36
61
|
| `system/` | the code that serves the record: the site today, more as you need it. |
|
|
37
|
-
| `instance.md` | what this record is authoritative for; its `name:` is the identity every surface publishes (read at server/build start — restart `pnpm dev` after renaming).
|
|
62
|
+
| `instance.md` | what this record is authoritative for; its `name:` is the identity every surface publishes (read at server/build start — restart `pnpm dev` after renaming). This prose IS the agent surface's system prompt — `ksor serve` wires it into the MCP server's instructions. |
|
|
38
63
|
| `AGENTS.md` | the working contract every coding agent reads first — the rules for writing knowledge here. |
|
|
39
64
|
| `CLAUDE.md` | one line, pointing at `AGENTS.md`. Claude Code looks for this filename, not that one. |
|
|
40
65
|
| `.agents/skills/` | the agent kit: `intake-interview` (define the record with you), `add-sources` (turn source material into governed documents), `format-checker` (the rules, as a program). |
|
|
@@ -43,7 +68,7 @@ different coding agent's way of finding the same working contract.
|
|
|
43
68
|
| `.github/workflows/validate.yml` | your CI: runs the same checker on every pull request and push to main. |
|
|
44
69
|
| `.gitattributes` | markdown is checked out byte-stable on every platform, so the same commit hashes the same everywhere. |
|
|
45
70
|
| `.gitignore` | keeps build output, `node_modules/`, and `.env*` out of the record's history. |
|
|
46
|
-
| `package.json` | the `pnpm dev` / `pnpm build` / `pnpm check` commands, and the pnpm version this project pins.
|
|
71
|
+
| `package.json` | the `pnpm dev` / `pnpm build` / `pnpm check` / `pnpm schema` / `pnpm grant` / `pnpm ingest` / `pnpm serve` commands, the pinned `@panaversity/ksor` tool, and the pnpm version this project pins. |
|
|
47
72
|
| `pnpm-workspace.yaml` | where the workspace looks for code (`system/site`, plus reserved `system/gateways/*` and `system/packages/*`), and the supply-chain policy for installs. |
|
|
48
73
|
| `pnpm-lock.yaml` | the exact dependency versions — the reason two machines build the same site. |
|
|
49
74
|
|
|
@@ -55,6 +80,33 @@ and how to fix it.
|
|
|
55
80
|
Everything here is yours to change. The kit exists so that any coding agent can
|
|
56
81
|
operate this project without being taught it first.
|
|
57
82
|
|
|
83
|
+
## Deploying
|
|
84
|
+
|
|
85
|
+
The built site is a folder of files — 2 MB of HTML, JS and CSS with zero
|
|
86
|
+
host-specific dependencies. `pnpm build` writes it to `system/site/out/`,
|
|
87
|
+
and anything that can serve files can serve it.
|
|
88
|
+
|
|
89
|
+
- **Vercel** — connect the repository (or run `vercel`); the shipped
|
|
90
|
+
`vercel.json` answers the setup interview: deploy from the repo root
|
|
91
|
+
(never pin `system/site` as the root directory — the record lives
|
|
92
|
+
outside it), build with `pnpm build`, serve `system/site/out/`. If the
|
|
93
|
+
build image's pnpm predates the `packageManager` pin, set the
|
|
94
|
+
`ENABLE_EXPERIMENTAL_COREPACK=1` build environment variable.
|
|
95
|
+
- **GitHub Pages, nginx, S3, anything static** — run `pnpm build` and
|
|
96
|
+
upload `system/site/out/`. Hosted under a sub-path (like
|
|
97
|
+
`user.github.io/repo`)? Build with `KSOR_BASE_PATH=/repo pnpm build`.
|
|
98
|
+
- **Verify any deploy** the same way: the home page, one document page,
|
|
99
|
+
and `/llms.txt` all load; nothing else is required.
|
|
100
|
+
|
|
101
|
+
If `instance.md` declares `audiences:`, what you deploy is a **tier**.
|
|
102
|
+
Plain `pnpm build` always builds the public tier — safe for any host.
|
|
103
|
+
`KSOR_AUDIENCE=<audience> pnpm build` builds a wider tier for that
|
|
104
|
+
audience's own deployment, and that build carries an
|
|
105
|
+
"— not for publication" label because it must never reach a public host:
|
|
106
|
+
put it behind access control you already trust (VPN, SSO proxy,
|
|
107
|
+
authenticated host). The tiers govern what a build contains; where each
|
|
108
|
+
build may be served is yours to enforce.
|
|
109
|
+
|
|
58
110
|
## Ownership
|
|
59
111
|
|
|
60
112
|
Everything here is yours. The scaffold was generated by
|
|
@@ -5,6 +5,9 @@ node_modules/
|
|
|
5
5
|
system/site/.next/
|
|
6
6
|
system/site/.source/
|
|
7
7
|
system/site/out/
|
|
8
|
+
# the per-audience copy of the record a build stages — a filtered derivative,
|
|
9
|
+
# never a second record; committing it would publish what a build excluded
|
|
10
|
+
system/site/.staged-knowledge/
|
|
8
11
|
*.tsbuildinfo
|
|
9
12
|
|
|
10
13
|
# secrets never enter the record — system/ is their future home (serve)
|
|
@@ -18,8 +18,9 @@ the single most important sentence in the project._
|
|
|
18
18
|
|
|
19
19
|
Everything below this frontmatter is the identity of this instance: what the
|
|
20
20
|
corpus covers, who it serves, and how strictly it should decline questions it
|
|
21
|
-
does not cover.
|
|
22
|
-
|
|
21
|
+
does not cover. This prose IS the agent surface's system prompt — `ksor serve`
|
|
22
|
+
wires it into the MCP server's instructions — so write it for a reader who must
|
|
23
|
+
act on it.
|
|
23
24
|
|
|
24
25
|
Ask your coding agent to run the **intake interview** (it knows how — see
|
|
25
26
|
`.agents/skills/intake-interview/`), answer its questions, and let it write
|
|
@@ -6,7 +6,14 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "pnpm -C system/site dev",
|
|
8
8
|
"build": "pnpm -C system/site build",
|
|
9
|
-
"check": "node .agents/skills/format-checker/check.mjs"
|
|
9
|
+
"check": "node .agents/skills/format-checker/check.mjs",
|
|
10
|
+
"schema": "ksor schema --instance instance.md --apply",
|
|
11
|
+
"grant": "ksor grant --instance instance.md",
|
|
12
|
+
"ingest": "ksor ingest --instance instance.md --knowledge knowledge --flip",
|
|
13
|
+
"serve": "ksor serve"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@panaversity/ksor": "KSOR-STAMP-VERSION"
|
|
10
17
|
},
|
|
11
18
|
"engines": {
|
|
12
19
|
"node": ">=24"
|
|
@@ -8,12 +8,28 @@ packages:
|
|
|
8
8
|
# `pnpm install` here never picks up a day-zero compromised release.
|
|
9
9
|
minimumReleaseAge: 2880
|
|
10
10
|
|
|
11
|
+
# The one exception is the tool that scaffolded this project: `ksor init` pins
|
|
12
|
+
# `@panaversity/ksor` to the EXACT version the adopter deliberately ran, so the
|
|
13
|
+
# quarantine (a defense against unknown third-party releases) must not block the
|
|
14
|
+
# first install for 48 hours after every ksor release. Remove this line only if
|
|
15
|
+
# you unpin the dependency.
|
|
16
|
+
minimumReleaseAgeExclude:
|
|
17
|
+
- "@panaversity/ksor"
|
|
18
|
+
|
|
11
19
|
# Dependency install scripts are denied by default. Flip an entry to true
|
|
12
|
-
# only with a comment naming what breaks without it.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
20
|
+
# only with a comment naming what breaks without it. pnpm 11 exits 1 on every
|
|
21
|
+
# install until each build script is explicitly decided (found live:
|
|
22
|
+
# fresh-scaffold pnpm dev, 2026-08-18). All four below are reviewed and stay
|
|
23
|
+
# denied:
|
|
24
|
+
# esbuild, sharp — prebuilt platform binaries ship as optionalDependencies,
|
|
25
|
+
# so their install scripts are download fallbacks the site never needs.
|
|
26
|
+
# @google/genai, protobufjs — pulled in by the `@panaversity/ksor` serve
|
|
27
|
+
# tool. genai's flagged script is its own dev `prepare` (nothing the
|
|
28
|
+
# published tarball needs built); protobufjs's postinstall is a
|
|
29
|
+
# version-compat warning, nothing built. (Same review as the root
|
|
30
|
+
# workspace, 2026-08-19; serve works with both denied — verified live.)
|
|
17
31
|
allowBuilds:
|
|
18
32
|
esbuild: false
|
|
19
33
|
sharp: false
|
|
34
|
+
"@google/genai": false
|
|
35
|
+
protobufjs: false
|
|
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|
|
3
3
|
// The same file Next serves as the favicon (app/icon.png) — one mark, one
|
|
4
4
|
// asset. Replace it with your own and the tab icon changes with the page.
|
|
5
5
|
import mark from "@/app/icon.png";
|
|
6
|
-
import {
|
|
6
|
+
import { FooterMark } from "@/components/footer-mark";
|
|
7
7
|
import { appName, appTitle } from "@/lib/shared";
|
|
8
8
|
import { basePath, getSortedPages } from "@/lib/source";
|
|
9
9
|
|
|
@@ -75,7 +75,7 @@ export default function HomePage() {
|
|
|
75
75
|
|
|
76
76
|
<footer className="mx-auto w-full max-w-2xl px-6 pb-10">
|
|
77
77
|
<p className="border-t border-fd-border pt-6 text-xs">
|
|
78
|
-
<
|
|
78
|
+
<FooterMark />
|
|
79
79
|
</p>
|
|
80
80
|
</footer>
|
|
81
81
|
</main>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getSortedPageTree } from "@/lib/source";
|
|
2
2
|
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
|
3
3
|
import { baseOptions } from "@/lib/layout.shared";
|
|
4
|
-
import {
|
|
4
|
+
import { FooterMark } from "@/components/footer-mark";
|
|
5
5
|
|
|
6
6
|
export default function Layout({ children }: LayoutProps<"/docs">) {
|
|
7
7
|
return (
|
|
@@ -13,7 +13,7 @@ export default function Layout({ children }: LayoutProps<"/docs">) {
|
|
|
13
13
|
sidebar={{
|
|
14
14
|
footer: (
|
|
15
15
|
<p className="mt-3 text-xs">
|
|
16
|
-
<
|
|
16
|
+
<FooterMark />
|
|
17
17
|
</p>
|
|
18
18
|
),
|
|
19
19
|
}}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { BuiltWith } from "@/components/built-with";
|
|
3
|
+
import { audienceNotice } from "@/lib/audience";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The foot of the site chrome: who built it, and — on any build below the
|
|
7
|
+
* public tier — which audience that build was for, so a screenshot of an
|
|
8
|
+
* internal site names itself and a page that escapes carries its own
|
|
9
|
+
* warning.
|
|
10
|
+
*
|
|
11
|
+
* The public build renders the attribution ALONE, exactly as a site with no
|
|
12
|
+
* audience model does: the one build with nothing to disclose must not even
|
|
13
|
+
* carry the shape of a disclosure.
|
|
14
|
+
*/
|
|
15
|
+
export function FooterMark(): ReactElement {
|
|
16
|
+
if (audienceNotice === null) return <BuiltWith />;
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<BuiltWith /> · <span className="text-fd-muted-foreground">{audienceNotice}</span>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { instanceFrontmatter } from "./shared";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The audience model, declared in instance.md — the record says who its
|
|
5
|
+
* readers are, and the build enforces it:
|
|
6
|
+
*
|
|
7
|
+
* audiences:
|
|
8
|
+
* - public
|
|
9
|
+
* - internal
|
|
10
|
+
* - restricted
|
|
11
|
+
* default_visibility: public
|
|
12
|
+
*
|
|
13
|
+
* Ordered least- to most-restricted, so "build the internal site" means
|
|
14
|
+
* "public and internal included" with no further configuration. A record
|
|
15
|
+
* that declares no audiences has no model and publishes every document —
|
|
16
|
+
* the behaviour of every instance written before this key existed.
|
|
17
|
+
*/
|
|
18
|
+
export interface AudienceModel {
|
|
19
|
+
/** Least- to most-restricted, `public` first. */
|
|
20
|
+
readonly audiences: readonly string[];
|
|
21
|
+
/** The tier of a document that declares no `visibility:`. */
|
|
22
|
+
readonly defaultVisibility: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unquote(raw: string): string {
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
return /^(['"])(.*)\1$/.exec(trimmed)?.[2] ?? trimmed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every refusal this feature makes: a slug a pipeline can match, then the remedy. */
|
|
31
|
+
export function refuse(slug: string, what: string, why: string, fix: string): never {
|
|
32
|
+
// The slug leads, so a pipeline can match on it, and the three lines below
|
|
33
|
+
// it are the whole remedy — an operator never has to read this file.
|
|
34
|
+
throw new Error(`${slug}: ${what}\n why: ${why}\n fix: ${fix}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readAudienceModel(): AudienceModel | null {
|
|
38
|
+
const block = instanceFrontmatter();
|
|
39
|
+
// Top-level key only: `^` under /m cannot match an indented child.
|
|
40
|
+
if (!/^audiences:/m.test(block)) return null;
|
|
41
|
+
|
|
42
|
+
// The grammar mirrors the checker's exactly — CRLF-tolerant, list items at
|
|
43
|
+
// ANY indent (YAML allows unindented block sequences), and a ` #` comment
|
|
44
|
+
// ends an unquoted entry (all three found live 2026-08-18: records the
|
|
45
|
+
// checker blessed either failed this build or silently lost a tier).
|
|
46
|
+
const stripComment = (value: string): string =>
|
|
47
|
+
/^["']/.test(value.trim()) ? value : value.replace(/\s+#.*$/, "");
|
|
48
|
+
// A line scanner, not a block regex: a blank line among the items or a
|
|
49
|
+
// comment on the key line broke the block capture and refused every build
|
|
50
|
+
// of a checker-green record (review finding, 2026-08-19).
|
|
51
|
+
const flow = /^audiences:[ \t]*\[(.*)\][ \t]*(?:#.*)?$/m.exec(block)?.[1];
|
|
52
|
+
let items: string[] = [];
|
|
53
|
+
if (flow !== undefined) {
|
|
54
|
+
items = flow.split(",");
|
|
55
|
+
} else {
|
|
56
|
+
const lines = block.split("\n");
|
|
57
|
+
const start = lines.findIndex((line) => /^audiences:[ \t]*(?:#.*)?$/.test(line));
|
|
58
|
+
if (start !== -1) {
|
|
59
|
+
for (const line of lines.slice(start + 1)) {
|
|
60
|
+
if (line.trim() === "") continue;
|
|
61
|
+
const item = /^[ \t]*-[ \t]+(.*)$/.exec(line);
|
|
62
|
+
if (item === null) break;
|
|
63
|
+
items.push(item[1] ?? "");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const audiences = items
|
|
68
|
+
.map(stripComment)
|
|
69
|
+
.map(unquote)
|
|
70
|
+
.filter((value) => value !== "");
|
|
71
|
+
|
|
72
|
+
// A declared-but-unreadable model must never read as "no model": that is
|
|
73
|
+
// the one parse failure that publishes the whole record.
|
|
74
|
+
if (audiences.length === 0) {
|
|
75
|
+
refuse(
|
|
76
|
+
"ksor-audiences-unreadable",
|
|
77
|
+
"instance.md declares `audiences:` but no audience could be read from it",
|
|
78
|
+
"an unreadable model reads as no model, and no model publishes every document — the one parse failure that leaks",
|
|
79
|
+
"write the audiences as a list, least-restricted first:\n audiences:\n - public\n - internal",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The staging never depends on the checker having run: a
|
|
84
|
+
// most-restrictive-first model would make plain `pnpm build` publish
|
|
85
|
+
// every restricted document with no label (review finding, 2026-08-18).
|
|
86
|
+
if (audiences[0] !== "public") {
|
|
87
|
+
refuse(
|
|
88
|
+
"ksor-audiences-misordered",
|
|
89
|
+
`audiences: must start with public (it starts with "${audiences[0]}")`,
|
|
90
|
+
"the list is ordered least- to most-restricted, and an unset KSOR_AUDIENCE builds the FIRST entry — any other first entry makes the default build the leak",
|
|
91
|
+
"reorder audiences: with public first",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (new Set(audiences).size !== audiences.length) {
|
|
95
|
+
refuse(
|
|
96
|
+
"ksor-audiences-duplicate",
|
|
97
|
+
`audiences: declares a tier twice (${audiences.join(", ")})`,
|
|
98
|
+
"a duplicated tier has two positions in the ordering, and which one a build honours is undefined",
|
|
99
|
+
"remove the duplicate entry",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const defaultVisibility = unquote(
|
|
103
|
+
stripComment(/^default_visibility:[ \t]*(.*)$/m.exec(block)?.[1] ?? ""),
|
|
104
|
+
);
|
|
105
|
+
if (defaultVisibility === "") {
|
|
106
|
+
refuse(
|
|
107
|
+
"ksor-default-visibility-missing",
|
|
108
|
+
"instance.md declares `audiences:` without `default_visibility:`",
|
|
109
|
+
"there is no safe guess: assuming the widest tier leaks on the first document that forgets the key, assuming the narrowest hides the record",
|
|
110
|
+
`add the tier a document without a visibility: key belongs to, e.g. default_visibility: ${audiences[0]}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!audiences.includes(defaultVisibility)) {
|
|
114
|
+
refuse(
|
|
115
|
+
"ksor-default-visibility-undeclared",
|
|
116
|
+
`default_visibility: ${defaultVisibility} is not one of the declared audiences (${audiences.join(", ")})`,
|
|
117
|
+
"every document without a visibility: key belongs to this tier — a tier no build understands is a record no build can publish honestly",
|
|
118
|
+
`set default_visibility: to one of ${audiences.join(", ")}, or declare ${defaultVisibility} in audiences:`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { audiences, defaultVisibility };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The declared model, or null when this record declares none. */
|
|
126
|
+
export const audienceModel: AudienceModel | null = readAudienceModel();
|
|
127
|
+
|
|
128
|
+
function resolveBuildAudience(model: AudienceModel | null): string {
|
|
129
|
+
const requested = process.env.KSOR_AUDIENCE?.trim() ?? "";
|
|
130
|
+
if (model === null) {
|
|
131
|
+
if (requested !== "") {
|
|
132
|
+
refuse(
|
|
133
|
+
"ksor-audiences-not-declared",
|
|
134
|
+
`KSOR_AUDIENCE="${requested}" was requested, but instance.md declares no audiences`,
|
|
135
|
+
"this build would publish every document — a build that cannot filter must never look like one that did",
|
|
136
|
+
"declare the model in instance.md (audiences: + default_visibility:), or build without KSOR_AUDIENCE",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
// Unset means the least-restricted tier: the only default that cannot leak,
|
|
142
|
+
// so `pnpm build` keeps publishing the public site out of the box.
|
|
143
|
+
if (requested === "") return model.audiences[0] as string;
|
|
144
|
+
if (!model.audiences.includes(requested)) {
|
|
145
|
+
refuse(
|
|
146
|
+
"ksor-audience-undeclared",
|
|
147
|
+
`KSOR_AUDIENCE="${requested}" is not an audience this record declares (${model.audiences.join(", ")})`,
|
|
148
|
+
"an unrecognized audience could only be honoured by publishing more than the record names — so it refuses instead of widening",
|
|
149
|
+
`build with one of ${model.audiences.join(", ")}, or add "${requested}" to instance.md's audiences: list`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return requested;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The audience this build publishes for; "" when the record has no model. */
|
|
156
|
+
export const buildAudience: string = resolveBuildAudience(audienceModel);
|
|
157
|
+
|
|
158
|
+
/** Whether a document of this visibility belongs in THIS build. */
|
|
159
|
+
export function visibleInBuild(visibility: string | null): boolean {
|
|
160
|
+
if (audienceModel === null) return true;
|
|
161
|
+
const value =
|
|
162
|
+
visibility === null || visibility === "" ? audienceModel.defaultVisibility : visibility;
|
|
163
|
+
const rank = audienceModel.audiences.indexOf(value);
|
|
164
|
+
// An undeclared visibility is refused, never published: a value no build
|
|
165
|
+
// understands is a typo, and a typo reads as a restriction.
|
|
166
|
+
if (rank === -1) return false;
|
|
167
|
+
return rank <= audienceModel.audiences.indexOf(buildAudience);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a non-public build calls itself, in the site chrome — so a leaked
|
|
172
|
+
* screenshot of an internal site says which audience it was built for. The
|
|
173
|
+
* public build (the least-restricted tier) says nothing new.
|
|
174
|
+
*/
|
|
175
|
+
export const audienceNotice: string | null =
|
|
176
|
+
audienceModel === null || buildAudience === audienceModel.audiences[0]
|
|
177
|
+
? null
|
|
178
|
+
: `${buildAudience} build — not for publication`;
|
|
@@ -20,12 +20,21 @@ function findInstance(start: string): string {
|
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* instance.md's frontmatter block — the configuration every surface reads
|
|
25
|
+
* (identity here, the audience model in lib/audience.ts). Only this block:
|
|
26
|
+
* body prose that looks like a key must never become configuration (review
|
|
27
|
+
* finding, 2026-08-18).
|
|
28
|
+
*/
|
|
29
|
+
export function instanceFrontmatter(): string {
|
|
24
30
|
const text = readFileSync(findInstance(process.cwd()), "utf8");
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
// The checker's boundary exactly: BOM stripped, CRLF normalized, lax close.
|
|
32
|
+
const normalized = text.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
|
|
33
|
+
return /^---\n([\s\S]*?)\n---/.exec(normalized)?.[1] ?? "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readInstanceName(): string {
|
|
37
|
+
const raw = /^name:[ \t]*(.*)$/m.exec(instanceFrontmatter())?.[1]?.trim() ?? "";
|
|
29
38
|
const unquoted = /^(['"])(.*)\1$/.exec(raw);
|
|
30
39
|
const name = unquoted?.[2] ?? raw;
|
|
31
40
|
if (name === "") {
|