@panaversity/ksor 0.0.34 → 0.0.36

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,100 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.36
4
+
5
+ ### Patch Changes
6
+
7
+ - 617dc46: The scaffold meets your package manager
8
+
9
+ `ksor init` now emits the scaffold for the manager that ran it: `npx
10
+ @panaversity/ksor init` produces an npm project, `bunx` a bun one, `pnpm dlx`
11
+ (or anything unrecognized) the pnpm shape every scaffold got before. Node stays
12
+ the one prerequisite — nobody installs a second package manager to open their
13
+ own knowledge base (issue #28).
14
+
15
+ The whole scaffold speaks the detected manager: scripts, README, AGENTS.md, the
16
+ agent kit, the CLI's own handoff text. npm and bun scaffolds declare
17
+ `workspaces` in the manifest and ship no lockfile — the pinned CLI version
18
+ cannot be pre-resolved into one, so the first install writes it and the README
19
+ says to commit it. The install-script denial carries over (`.npmrc` with
20
+ `ignore-scripts=true` for npm; bun refuses dependency lifecycle scripts by
21
+ default). What npm and bun cannot offer is pnpm's 48-hour quarantine on newly
22
+ published dependency versions — the emitted scaffold discloses that instead of
23
+ staying silent about it.
24
+
25
+ Each manager's shape was proven end to end before shipping — install, `ksor`
26
+ bin resolution, format checker, full static site build — and CI now walks npm
27
+ and bun scaffolds on every change.
28
+
29
+ ## 0.0.35
30
+
31
+ ### Patch Changes
32
+
33
+ - 0fc6bce: Name the reader on the website
34
+
35
+ The scaffolded site can now sign a reader in and show who they are in the
36
+ navbar. It is off until three variables are set — the control does not render at
37
+ all without them, which stays the default.
38
+
39
+ The flow is OAuth 2.0 Authorization Code with PKCE against a public client, with
40
+ no secret anywhere in a build that ships to browsers. Endpoints are discovered
41
+ (RFC 8414, then OIDC), so no vendor is named in the code or in configuration;
42
+ verified end to end against Auth0 and against a Better Auth deployment. The
43
+ session lives in `sessionStorage` for the tab, and no refresh token is requested
44
+ or stored — a token that unlocks nothing on this site should not outlive the
45
+ visit.
46
+
47
+ What it does NOT do is restrict reading, and the documentation leads with that.
48
+ The site is a static export: every published document is a file the host serves
49
+ to whoever asks, so keeping people out is still the origin gate or a per-audience
50
+ build, both unchanged. This names an already-authenticated reader; it is not a
51
+ step toward access control, and treating it as one would be the mistake the
52
+ "Keeping people out of the site" section exists to prevent.
53
+
54
+ Also fixes a real gap it exposed: the site build never read the repository-root
55
+ `.env`, so following the scaffold's own instructions would have set variables
56
+ that silently never reached the bundle.
57
+
58
+ - 4b431b2: Trim the shell-retirement revision in AGENTS.md from 222 words to 129, keeping
59
+ what an agent must act on and moving the reasoning to the commit that carried
60
+ it.
61
+
62
+ Working rule 6 requires a reversed decision to keep its entry and gain a
63
+ revision note, so removing it is not available — and it is not irrelevant
64
+ either: without it an agent looks for a deleted directory with no explanation,
65
+ or restores two-shell assertions thinking they were lost by accident, or reads
66
+ decision 9, sees no obstacle, and treats dropping `output: "export"` as
67
+ unblocked. That last one is the reason it stays.
68
+
69
+ But coding principle 1 applies to this file too — context is liability, and
70
+ AGENTS.md loads every session. The narrative half was 90 words explaining why
71
+ the proof had been valuable, which the commit already records.
72
+
73
+ Also documents the thing that was missing entirely: **how to keep people out of
74
+ the site.** The door's auth had four recipes; the site had nothing, and the
75
+ most common requirement — "everyone signs in before reading anything" — is also
76
+ the easiest, needs no ksor change, and was written down nowhere.
77
+
78
+ Three shapes, separated because they had been muddled: a host-level gate in
79
+ front of the origin (protects every byte, holds against `curl`, and makes a
80
+ site sign-in button redundant rather than complementary); per-audience builds
81
+ for a restricted subset (enforcement by absence, already built); and the
82
+ per-request case, which a static export cannot express and which issue #130
83
+ records rather than implements. Plus what does not work — hiding rendered
84
+ content behind a browser check, which presents rather than protects.
85
+
86
+ The per-request case gets three answers rather than a deferral: **read through
87
+ the door** (already applies audience scope per request and logs an actor per
88
+ read — per-person governance with an audit trail a static site cannot have),
89
+ **split the record** (content needing per-person confidentiality inside one tier
90
+ usually belongs in its own record), or **fork the site**, which an adopter owns
91
+ outright under decision 4.
92
+
93
+ The fork is offered with what it costs stated: ksor's guarantee is enforcement
94
+ by ABSENCE, asserted against a positive control; a request-time filter is a
95
+ different guarantee and becomes the adopter's to test. A filter that is bypassed
96
+ serves the document; an absent file cannot be.
97
+
3
98
  ## 0.0.34
4
99
 
5
100
  ### Patch Changes
package/dist/cli.mjs CHANGED
@@ -6723,6 +6723,152 @@ function isEnvironmentError(value) {
6723
6723
  return code !== null && ENVIRONMENT_CODES.has(code);
6724
6724
  }
6725
6725
  //#endregion
6726
+ //#region src/init/manager.ts
6727
+ /**
6728
+ * Which manager spawned this process, from `npm_config_user_agent`
6729
+ * (e.g. "pnpm/11.22.0 npm/? node/v24.5.0 darwin arm64"). The first token
6730
+ * names the manager; only managers we emit a scaffold for are recognized.
6731
+ */
6732
+ function detectManager(userAgent) {
6733
+ const head = (userAgent ?? "").split("/")[0]?.trim();
6734
+ if (head === "npm") return "npm";
6735
+ if (head === "bun") return "bun";
6736
+ return "pnpm";
6737
+ }
6738
+ /** Template files that belong to exactly one manager's scaffold. */
6739
+ function isSkippedFor(templateName, manager) {
6740
+ if (manager === "pnpm") return false;
6741
+ return templateName === "pnpm-workspace.yaml" || templateName === "pnpm-lock.yaml";
6742
+ }
6743
+ /**
6744
+ * The workspace globs, shared by every manager. pnpm reads them from
6745
+ * pnpm-workspace.yaml; npm and bun read a `workspaces` field. One constant so
6746
+ * the two spellings cannot drift.
6747
+ */
6748
+ const WORKSPACE_GLOBS = [
6749
+ "system/site",
6750
+ "system/gateways/*",
6751
+ "system/packages/*"
6752
+ ];
6753
+ /**
6754
+ * The root scripts, per manager. pnpm's are the template's own bytes; npm and
6755
+ * bun REPLACE the manager-specific bodies and inherit everything else.
6756
+ * npm: `--prefix` is npm's spelling of "run it over there".
6757
+ * bun: cd-chains — see the module comment for why not `--cwd`.
6758
+ */
6759
+ const SCRIPT_BODIES = {
6760
+ npm: {
6761
+ dev: "npm --prefix system/site run dev",
6762
+ build: "npm run export-denylist && npm --prefix system/site run build",
6763
+ provision: "npm run schema && npm run grant",
6764
+ refresh: "npm run ingest && npm run gc"
6765
+ },
6766
+ bun: {
6767
+ dev: "cd system/site && bun run dev",
6768
+ build: "bun run export-denylist && cd system/site && bun run build",
6769
+ provision: "bun run schema && bun run grant",
6770
+ refresh: "bun run ingest && bun run gc"
6771
+ }
6772
+ };
6773
+ /**
6774
+ * Rewrite the scaffold's root package.json for the manager. Structured — a
6775
+ * JSON transform, never string surgery — because the manifest is the one
6776
+ * file where a half-applied spelling map would still parse and then lie.
6777
+ */
6778
+ function transformManifest(source, manager) {
6779
+ if (manager === "pnpm") return source;
6780
+ const parsed = JSON.parse(source);
6781
+ const { packageManager: _dropped, ...rest } = parsed;
6782
+ const out = {
6783
+ ...rest,
6784
+ scripts: {
6785
+ ...parsed.scripts,
6786
+ ...SCRIPT_BODIES[manager]
6787
+ },
6788
+ workspaces: [...WORKSPACE_GLOBS]
6789
+ };
6790
+ return `${JSON.stringify(out, null, 2)}\n`;
6791
+ }
6792
+ /**
6793
+ * Ordered prose translation, longest spelling first so `pnpm install` is
6794
+ * never half-eaten by a shorter rule. Applied to every emitted text file
6795
+ * except package.json (structured above). The conformance test asserts ZERO
6796
+ * surviving "pnpm" tokens outside the quarantine disclosure, so a template
6797
+ * edit that adds a spelling this map misses goes red instead of shipping an
6798
+ * instruction the adopter cannot run.
6799
+ */
6800
+ const SCRIPT_NAMES = [
6801
+ "dev",
6802
+ "build",
6803
+ "check",
6804
+ "serve",
6805
+ "provision",
6806
+ "refresh",
6807
+ "schema",
6808
+ "grant",
6809
+ "ingest",
6810
+ "gc",
6811
+ "export-denylist"
6812
+ ];
6813
+ function spellings(manager) {
6814
+ const run = (script) => manager === "npm" ? `npm run ${script}` : `bun run ${script}`;
6815
+ const pairs = [
6816
+ ["pnpm install --no-frozen-lockfile", manager === "npm" ? "npm install" : "bun install"],
6817
+ ["pnpm install", manager === "npm" ? "npm install" : "bun install"],
6818
+ ["pnpm exec ksor", manager === "npm" ? "npx ksor" : "bunx ksor"],
6819
+ ["pnpm dlx", manager === "npm" ? "npx" : "bunx"],
6820
+ ["pnpm add -D", manager === "npm" ? "npm i -D" : "bun add -d"],
6821
+ ["pnpm -C system/site", manager === "npm" ? "npm --prefix system/site run" : "cd system/site && bun run"]
6822
+ ];
6823
+ for (const script of SCRIPT_NAMES) pairs.push([`pnpm ${script}`, run(script)]);
6824
+ return pairs;
6825
+ }
6826
+ /**
6827
+ * Manager-conditional blocks in markdown templates:
6828
+ *
6829
+ * <!-- ksor:pm pnpm npm -->
6830
+ * ...lines kept only for those managers...
6831
+ * <!-- /ksor:pm -->
6832
+ *
6833
+ * The marker lines themselves never survive into any scaffold, so the pnpm
6834
+ * output stays exactly what an adopter always got.
6835
+ */
6836
+ const BLOCK_OPEN = /^[ \t]*<!-- ksor:pm ([a-z ]+?) -->[ \t]*$/;
6837
+ const BLOCK_CLOSE = /^[ \t]*<!-- \/ksor:pm -->[ \t]*$/;
6838
+ function applyProse(text, manager) {
6839
+ const lines = text.split("\n");
6840
+ const kept = [];
6841
+ let keeping = true;
6842
+ let inBlock = false;
6843
+ for (const line of lines) {
6844
+ const open = BLOCK_OPEN.exec(line);
6845
+ if (open !== null) {
6846
+ inBlock = true;
6847
+ keeping = open[1].split(/\s+/).includes(manager);
6848
+ continue;
6849
+ }
6850
+ if (BLOCK_CLOSE.test(line)) {
6851
+ inBlock = false;
6852
+ keeping = true;
6853
+ continue;
6854
+ }
6855
+ if (!inBlock || keeping) kept.push(line);
6856
+ }
6857
+ let out = kept.join("\n");
6858
+ if (manager !== "pnpm") for (const [from, to] of spellings(manager)) out = out.replaceAll(from, to);
6859
+ return out;
6860
+ }
6861
+ /**
6862
+ * Files a manager's scaffold gains beyond the template tree. npm's `.npmrc`
6863
+ * carries the denial half of the posture and DISCLOSES the missing half; bun
6864
+ * needs no file — denial is bun's own default — so its disclosure lives in
6865
+ * the README's lockfile note.
6866
+ */
6867
+ function extraFiles(manager) {
6868
+ if (manager !== "npm") return [];
6869
+ return [[".npmrc", "# Dependency install scripts are denied — the same posture the pnpm\n# scaffold enforces per-package. Flip to false only with a comment naming\n# what breaks without it.\n#\n# What npm cannot give you is pnpm's 48-hour quarantine on newly\n# published dependency versions (minimumReleaseAge): under npm a routine\n# install can pick up a day-zero compromised release the day it ships.\n# That protection exists only under pnpm.\nignore-scripts=true\n"]];
6870
+ }
6871
+ //#endregion
6726
6872
  //#region src/init/materialize.ts
6727
6873
  const EMITTED_NAMES = /* @__PURE__ */ new Map([
6728
6874
  ["gitignore", ".gitignore"],
@@ -6755,9 +6901,14 @@ function isTextFile(file) {
6755
6901
  * children, so a caller that cannot rename-over (the `init .` form) can undo
6756
6902
  * a half-written tree in reverse order.
6757
6903
  */
6758
- function materialize(templateDir, targetDir, stamps, created = []) {
6904
+ function materialize(templateDir, targetDir, stamps, manager = "pnpm", created = []) {
6905
+ materializeTree(templateDir, targetDir, stamps, manager, created, true);
6906
+ return created;
6907
+ }
6908
+ function materializeTree(templateDir, targetDir, stamps, manager, created, isRoot) {
6759
6909
  for (const entry of readdirSync(templateDir, { withFileTypes: true })) {
6760
6910
  if (entry.name === "node_modules") continue;
6911
+ if (isRoot && isSkippedFor(entry.name, manager)) continue;
6761
6912
  const from = path.join(templateDir, entry.name);
6762
6913
  const to = path.join(targetDir, EMITTED_NAMES.get(entry.name) ?? entry.name);
6763
6914
  if (entry.isDirectory()) {
@@ -6765,9 +6916,10 @@ function materialize(templateDir, targetDir, stamps, created = []) {
6765
6916
  mkdirSync(to, { recursive: true });
6766
6917
  created.push(to);
6767
6918
  }
6768
- materialize(from, to, stamps, created);
6919
+ materializeTree(from, to, stamps, manager, created, false);
6769
6920
  } else if (isTextFile(from)) {
6770
- const text = readFileSync(from, "utf8").replaceAll("KSOR-STAMP-NAME", stamps.name).replaceAll("KSOR-STAMP-VERSION", stamps.version);
6921
+ const stamped = readFileSync(from, "utf8").replaceAll("KSOR-STAMP-NAME", stamps.name).replaceAll("KSOR-STAMP-VERSION", stamps.version);
6922
+ const text = isRoot && entry.name === "package.json" ? transformManifest(stamped, manager) : applyProse(stamped, manager);
6771
6923
  created.push(to);
6772
6924
  writeFileSync(to, text);
6773
6925
  } else {
@@ -6775,6 +6927,14 @@ function materialize(templateDir, targetDir, stamps, created = []) {
6775
6927
  copyFileSync(from, to);
6776
6928
  }
6777
6929
  }
6930
+ }
6931
+ /** Emit the files a manager's scaffold gains beyond the template tree. */
6932
+ function materializeExtras(targetDir, manager, created = []) {
6933
+ for (const [name, content] of extraFiles(manager)) {
6934
+ const to = path.join(targetDir, name);
6935
+ created.push(to);
6936
+ writeFileSync(to, content);
6937
+ }
6778
6938
  return created;
6779
6939
  }
6780
6940
  //#endregion
@@ -6906,14 +7066,22 @@ function gitInit(dir, io) {
6906
7066
  io.err(`note: git init failed: ${detail}\n`);
6907
7067
  }
6908
7068
  }
6909
- function handoff(io, name, targetWasDot) {
7069
+ function handoff(io, name, targetWasDot, manager) {
6910
7070
  const enter = targetWasDot ? "" : ` cd ${name}\n`;
7071
+ const run = (script) => manager === "pnpm" ? `pnpm ${script}` : manager === "npm" ? `npm run ${script}` : `bun run ${script}`;
7072
+ const install = manager === "pnpm" ? "pnpm install" : `${manager} install`;
7073
+ const pnpmHint = manager === "pnpm" ? "no pnpm? run: npm install -g pnpm — or `corepack enable pnpm` on Nodes that bundle corepack\n\n" : "";
6911
7074
  io.out(`${name} is ready — your knowledge, your repo, yours outright.\n
6912
7075
  Next (or just tell your coding agent to take it from here):
6913
- ` + enter + " pnpm install\n pnpm dev # the site, live at http://localhost:3000\n\nThen, for the agent surface (needs Postgres and a provider key):\n pnpm provision # once: uncomment `database:` in instance.md, copy\n # .env.example to .env, then apply the schema\n pnpm refresh # PUBLISH the record — ingest knowledge/ into a generation\n pnpm serve # the MCP server, over what you just published\n\nno pnpm? run: npm install -g pnpm — or `corepack enable pnpm` on Nodes that bundle corepack\n\nStart in knowledge/ — AGENTS.md carries the working rules.\n");
7076
+ ` + enter + ` ${install}\n ${run("dev").padEnd(15)} # the site, live at http://localhost:3000\n
7077
+ Then, for the agent surface (needs Postgres and a provider key):
7078
+ ${run("provision").padEnd(15)} # once: uncomment \`database:\` in instance.md, copy\n # .env.example to .env, then apply the schema
7079
+ ${run("refresh").padEnd(15)} # PUBLISH the record — ingest knowledge/ into a generation\n ${run("serve").padEnd(15)} # the MCP server, over what you just published\n
7080
+ ` + pnpmHint + "Start in knowledge/ — AGENTS.md carries the working rules.\n");
6914
7081
  }
6915
7082
  function init(args, cwd, io, env) {
6916
7083
  const { version, templatesDir } = env;
7084
+ const manager = detectManager(env.userAgent);
6917
7085
  if (!existsSync(templatesDir)) return fail(io, "broken-install", [`the ksor package is missing its templates: ${templatesDir}`, "reinstall it — `pnpm add -D @panaversity/ksor`, or `npm i -g @panaversity/ksor`."], exitCodes.environment);
6918
7086
  const word = args[0] ?? null;
6919
7087
  if (word === null) return usage$1(io);
@@ -6946,7 +7114,8 @@ function init(args, cwd, io, env) {
6946
7114
  materialize(templatesDir, targetDir, {
6947
7115
  name,
6948
7116
  version
6949
- }, created);
7117
+ }, manager, created);
7118
+ materializeExtras(targetDir, manager, created);
6950
7119
  } catch (error) {
6951
7120
  rollback(created);
6952
7121
  throw error;
@@ -6957,7 +7126,8 @@ function init(args, cwd, io, env) {
6957
7126
  materialize(templatesDir, stage, {
6958
7127
  name,
6959
7128
  version
6960
- });
7129
+ }, manager);
7130
+ materializeExtras(stage, manager);
6961
7131
  } catch (error) {
6962
7132
  rmSync(stage, {
6963
7133
  recursive: true,
@@ -6990,7 +7160,7 @@ function init(args, cwd, io, env) {
6990
7160
  const detail = error instanceof Error ? error.message : String(error);
6991
7161
  io.err(`note: the project was created, but a follow-up step failed: ${detail}\n`);
6992
7162
  }
6993
- handoff(io, name, isDot);
7163
+ handoff(io, name, isDot, manager);
6994
7164
  return 0;
6995
7165
  }
6996
7166
  function runInit(args, cwd, io, env) {
@@ -7080,7 +7250,8 @@ async function main(args) {
7080
7250
  err: (text) => process.stderr.write(text)
7081
7251
  }, {
7082
7252
  version: pkg.version,
7083
- templatesDir: fileURLToPath(new URL("../templates/scaffold", import.meta.url))
7253
+ templatesDir: fileURLToPath(new URL("../templates/scaffold", import.meta.url)),
7254
+ userAgent: process.env.npm_config_user_agent
7084
7255
  });
7085
7256
  }
7086
7257
  if (verb === "serve") {
@@ -32,8 +32,11 @@ Read this before spending an afternoon on a provider's console.
32
32
  **It protects the MCP door, not the website.** Everything on this page is a
33
33
  bearer token on `POST /mcp`. Your static site is a separate surface, served by
34
34
  whatever hosts it, and configuring auth here leaves it exactly as public as it
35
- was. If people must not read the record at all, the site needs its own access
36
- controlor must not be published.
35
+ was. Keeping people out of the SITE is a different mechanism and is not on this
36
+ pagesee "Keeping people out of the site" in
37
+ [deploying.md](./deploying.md), which covers the three shapes: a host-level gate
38
+ in front of everything, per-audience builds for a restricted subset, and why the
39
+ per-request case needs a decision first.
37
40
 
38
41
  **It is one gate, not per-user rules.** The door checks that a token was signed
39
42
  by the issuer you named and audienced at this record. It reads no scopes, no
package/docs/deploying.md CHANGED
@@ -7,6 +7,11 @@ status: draft
7
7
 
8
8
  ## Before you start
9
9
 
10
+ Commands on this page use the pnpm spelling (`pnpm build`, `pnpm serve`).
11
+ Since 0.0.36, `ksor init` emits the scaffold for the manager that ran it —
12
+ npm and bun included — and your scaffold's own README speaks that manager;
13
+ translate accordingly (`npm run build`, `bun run build`).
14
+
10
15
  Four things must exist, and the order matters. Nothing below works without them,
11
16
  and three of the four are outside this page.
12
17
 
@@ -246,6 +251,148 @@ a row) and reaches the site at its next build (it reads a file), so a site built
246
251
  without the DSN would keep publishing what the door already refuses. Set
247
252
  `KSOR_DB_URL` on the site build as well as on the door.
248
253
 
254
+ ## Keeping people out of the site
255
+
256
+ The door has auth ([authorization.md](./authorization.md)). The **site** is
257
+ static files, so it has none — and the way to protect it is not to add code, it
258
+ is to put something in front of it.
259
+
260
+ Three requirements, three different answers. Pick the row you actually have.
261
+
262
+ ### "Everyone must sign in before reading anything"
263
+
264
+ **Put a gate in front of the origin.** Nothing in ksor changes, and it protects
265
+ every byte — HTML, `llms.txt`, images, the search index — because the request
266
+ never reaches the files.
267
+
268
+ | host | what to turn on |
269
+ | ------------- | ---------------------------------------------------------------------------------------------------- |
270
+ | Vercel | Deployment Protection (password, or SSO on paid plans) |
271
+ | Cloudflare | Cloudflare Access in front of the deployment |
272
+ | anything else | an authenticating reverse proxy — nginx with `auth_request`, oauth2-proxy, Caddy with `forward_auth` |
273
+
274
+ This is the strongest gate available to a static site, and the only one that
275
+ holds against `curl`. It is coarse — whole deployment, all or nothing — which is
276
+ exactly right when the answer is "this record is internal".
277
+
278
+ **A sign-in button on the site is not an alternative to this.** The gate has
279
+ already authenticated the reader before a page renders; a second login inside it
280
+ would ask the same person to sign in twice, and on its own would protect
281
+ nothing.
282
+
283
+ ### "Some documents are restricted, most are not"
284
+
285
+ **Build per audience.** `KSOR_AUDIENCE=<tier> pnpm build` stages only what that
286
+ tier may see, so restricted documents are **never written into the artifact** —
287
+ enforcement by absence, which is the only kind a static host can honour. Publish
288
+ the public artifact openly and the wider one behind the gate above.
289
+
290
+ Plain `pnpm build` is always the public tier, so the safe thing is the default.
291
+
292
+ ### "Different readers see different documents, decided per request"
293
+
294
+ Two supported answers, and a third that is yours.
295
+
296
+ **Read through the door instead.** This is the one ksor is built for. The MCP
297
+ surface already applies the audience scope **per request** and writes a
298
+ `retrieval_log` row carrying the actor for every read — per-person governance
299
+ with an audit trail, which a static site cannot have at any price. If the
300
+ requirement is "who read what, and were they allowed to", that is the door, not
301
+ the website.
302
+
303
+ **Or split the record.** Content needing per-person confidentiality inside one
304
+ tier is usually content that belongs in its own record, with its own gate. That
305
+ is what the audience model and the second-record design anticipate.
306
+
307
+ **Or fork the site — you already own it.** `system/site` is yours outright
308
+ (decision 4). Nothing stops you removing `output: "export"` and filtering per
309
+ request in your own repository. ksor's contract is unaffected; this is a
310
+ directory you own, changed the way you want it.
311
+
312
+ What you take on if you do:
313
+
314
+ > ksor's guarantee is **enforcement by absence** — a restricted document is
315
+ > never written into the artifact, and a conformance suite asserts that against
316
+ > a positive control that proves the check is not blind. A request-time filter
317
+ > is a **different** guarantee, and it becomes yours to test, because those
318
+ > suites will no longer be testing it for you. A filter that is bypassed serves
319
+ > the document; an absent file cannot be.
320
+
321
+ That is the whole trade. It is a reasonable thing to do with your eyes open, and
322
+ a bad thing to drift into because a login button suggested it.
323
+
324
+ ### What does NOT work
325
+
326
+ **Hiding rendered content behind a signed-in check in the browser.** If the page
327
+ was built with the content in it, the content is in the response before any
328
+ JavaScript runs — `curl` and every crawler see it. A component that blurs or
329
+ collapses it is presenting, not protecting. If you build one, say so in its own
330
+ comment, or the next reader will take it for a gate.
331
+
332
+ ## Naming the reader — the sign-in control
333
+
334
+ The site ships an optional sign-in control. Read the section above before you
335
+ turn it on, because the one thing it does not do is the thing its name suggests.
336
+
337
+ **What it does:** signs the reader in against your authorization server and puts
338
+ their name in the navbar. That is the whole feature.
339
+
340
+ **What it does not do:** restrict anything. The site is a static export — every
341
+ published document is a file the host hands to whoever asks, and no amount of
342
+ browser JavaScript changes that. If the requirement is "keep people out", the
343
+ answer is the origin gate above, and the sign-in control is not a step toward it.
344
+
345
+ So the honest use is a record already behind a gate, where the reader is
346
+ authenticated but anonymous to the page, and you want the navbar to say who they
347
+ are and offer a way out. That is worth having. It is not access control.
348
+
349
+ ### Turning it on
350
+
351
+ Register a **public** client — PKCE, no secret — at the same authorization
352
+ server the door names in `KSOR_SSO_URL`, then set three variables in the
353
+ repository-root `.env`:
354
+
355
+ ```sh
356
+ NEXT_PUBLIC_KSOR_SSO_URL=https://your-sso.example.com
357
+ NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID=your-client-id
358
+ NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI=https://your-site.example.com/auth/callback
359
+ ```
360
+
361
+ All three or none: with any of them missing the control does not render, which
362
+ is the default and is not an error.
363
+
364
+ They are `NEXT_PUBLIC_`, so they are **inlined at build time**. Set them before
365
+ `pnpm build`; setting them on a running site changes nothing. This also means
366
+ they are public — which is correct, because a public client has nothing secret
367
+ to leak, and it is the reason none of these is a secret.
368
+
369
+ Two things to get exactly right at the provider:
370
+
371
+ - **The redirect URI must match byte for byte**, including the scheme and any
372
+ trailing slash. This is the failure everyone hits first, and providers report
373
+ it as a generic callback mismatch.
374
+ - **Add the site's origin to the allowed web origins** (Auth0 calls it that;
375
+ others call it CORS). The token exchange is a browser `fetch`, so a missing
376
+ origin fails as CORS, not as auth.
377
+
378
+ For local work, both values are `http://localhost:3000` — and the callback is
379
+ `http://localhost:3000/auth/callback`.
380
+
381
+ Endpoints are **discovered**, not configured: the control reads
382
+ `/.well-known/oauth-authorization-server`, then OIDC discovery. Any provider
383
+ publishing either one works, which is why there is no vendor setting here.
384
+ Verified against Auth0 and against a Better Auth deployment.
385
+
386
+ ### What it stores, and for how long
387
+
388
+ The session lives in `sessionStorage` — this tab, until it closes. No refresh
389
+ token is requested and none is stored.
390
+
391
+ That is deliberate, and it is a smaller footprint than the obvious alternative.
392
+ A token that unlocks nothing on this site should not outlive the visit; the
393
+ blast radius should match the benefit. If you need a longer session, you need
394
+ the gate, not a longer-lived token in a browser.
395
+
249
396
  ## Authorization, or the deliberate absence of it
250
397
 
251
398
  `ksor serve` **refuses to boot unauthenticated on a public bind.** There is no
package/docs/index.md CHANGED
@@ -19,7 +19,10 @@ instead of their training memory. The corpus grows with each implemented verb.
19
19
  `.claude/skills/` copies), adopter CI, and a dependency-free format
20
20
  checker (`pnpm check`). `ksor init .` scaffolds into an empty directory
21
21
  whose name passes the project-name grammar. Everything emitted belongs to
22
- the adopter (templates are MIT-0).
22
+ the adopter (templates are MIT-0). The scaffold is emitted for the package
23
+ manager that ran init — `npx …` yields an npm project, `bunx …` a bun one,
24
+ `pnpm dlx …` (or a bare `ksor`) the pnpm shape — so the commands below use
25
+ the pnpm spelling and your own scaffold's README speaks your manager.
23
26
  - Inside a scaffolded project, `pnpm install && pnpm dev` serves the record
24
27
  at `http://localhost:3000`; `pnpm build` writes a fully static export to
25
28
  `system/site/out/`. `KSOR_BASE_PATH=/repo pnpm build` targets sub-path
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
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",
@@ -112,7 +112,8 @@ Stand it up in this order (each step's errors explain how to fix themselves):
112
112
 
113
113
  `provision` is separate on purpose: applying DDL and granting ingest are acts
114
114
  an operator performs, not side effects of starting a server. (It is not
115
- called `setup` because `pnpm setup` is pnpm's own command and would shadow
115
+ called `setup` because package managers claim that word for commands of
116
+ their own, which would shadow
116
117
  it — the step would print "No changes to the environment were made" and do
117
118
  nothing.)
118
119
 
@@ -18,8 +18,11 @@ pnpm install
18
18
  pnpm dev # browse the knowledge at http://localhost:3000
19
19
  ```
20
20
 
21
+ <!-- ksor:pm pnpm -->
21
22
  No pnpm? Run `npm install -g pnpm` — or `corepack enable pnpm` on Node
22
- versions that bundle corepack. The first `pnpm install` also fetches the
23
+ versions that bundle corepack.
24
+ <!-- /ksor:pm -->
25
+ The first `pnpm install` also fetches the
23
26
  `ksor` tool (pinned in `package.json`) and writes it into your lockfile —
24
27
  commit the updated lockfile.
25
28
 
@@ -128,6 +131,7 @@ the record well-formed (`format-checker`, also `pnpm check`).
128
131
 
129
132
  ### A note on the lockfile
130
133
 
134
+ <!-- ksor:pm pnpm -->
131
135
  The committed `pnpm-lock.yaml` covers the site. It cannot cover
132
136
  `@panaversity/ksor` itself, because the version pinned in `package.json` is
133
137
  stamped by the CLI that scaffolded this project and could not be resolved before
@@ -138,6 +142,34 @@ The deploy config already accounts for this (`vercel.json` installs with
138
142
  `--no-frozen-lockfile`), and the shipped `validate.yml` runs no install. If you
139
143
  add CI of your own, note that pnpm turns on `--frozen-lockfile` automatically
140
144
  whenever `CI` is set.
145
+ <!-- /ksor:pm -->
146
+ <!-- ksor:pm npm -->
147
+ No lockfile ships with this scaffold: npm keeps ONE lock for the whole
148
+ workspace, and the `@panaversity/ksor` version pinned in `package.json` was
149
+ stamped by the CLI that scaffolded this project — it could not be resolved
150
+ into a lock before it existed. Your FIRST `npm install` writes
151
+ `package-lock.json`; run it before you push, and COMMIT the result — that
152
+ lock is why two machines build the same site.
153
+
154
+ One honest difference from the pnpm scaffold: pnpm quarantines newly
155
+ published dependency versions for 48 hours (`minimumReleaseAge`), so a
156
+ routine install never picks up a day-zero compromised release. npm has no
157
+ equivalent — `.npmrc` here carries the install-script denial half of that
158
+ posture, and this sentence is the disclosure of the half it cannot.
159
+ <!-- /ksor:pm -->
160
+ <!-- ksor:pm bun -->
161
+ No lockfile ships with this scaffold: the `@panaversity/ksor` version pinned
162
+ in `package.json` was stamped by the CLI that scaffolded this project — it
163
+ could not be resolved into a lock before it existed. Your FIRST
164
+ `bun install` writes `bun.lock`; run it before you push, and COMMIT the
165
+ result — that lock is why two machines build the same site.
166
+
167
+ One honest difference from the pnpm scaffold: pnpm quarantines newly
168
+ published dependency versions for 48 hours (`minimumReleaseAge`), so a
169
+ routine install never picks up a day-zero compromised release. bun has no
170
+ equivalent (its default refusal of dependency install scripts covers the
171
+ OTHER half of that posture), and this sentence is the disclosure.
172
+ <!-- /ksor:pm -->
141
173
 
142
174
  ## The files, explained
143
175
 
@@ -158,9 +190,18 @@ different coding agent's way of finding the same working contract.
158
190
  | `.gitattributes` | markdown is checked out byte-stable on every platform, so the same commit hashes the same everywhere. |
159
191
  | `.env.example` | the variables the served rung needs; copy to `.env` (gitignored) and fill in. |
160
192
  | `.gitignore` | keeps build output, `node_modules/`, and `.env` out of the record's history. |
161
- | `package.json` | the surface commands — `pnpm dev` (the site) and `pnpm provision` / `pnpm refresh` / `pnpm serve` (the agent surface: set up once, publish, then serve) — plus `pnpm build` / `pnpm check`, the pinned `@panaversity/ksor` tool, and the pnpm version this project pins. |
193
+ | `package.json` | the surface commands — `pnpm dev` (the site) and `pnpm provision` / `pnpm refresh` / `pnpm serve` (the agent surface: set up once, publish, then serve) — plus `pnpm build` / `pnpm check`, the pinned `@panaversity/ksor` tool and the workspace layout the manifest declares. |
194
+ <!-- ksor:pm pnpm -->
162
195
  | `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. |
163
196
  | `pnpm-lock.yaml` | the exact dependency versions — the reason two machines build the same site. |
197
+ <!-- /ksor:pm -->
198
+ <!-- ksor:pm npm -->
199
+ | `.npmrc` | dependency install scripts are denied; the comment inside discloses the one protection this scaffold lacks (a 48-hour quarantine on new releases). |
200
+ | `package-lock.json` | the exact dependency versions — written by your FIRST install; commit it, it is the reason two machines build the same site. |
201
+ <!-- /ksor:pm -->
202
+ <!-- ksor:pm bun -->
203
+ | `bun.lock` | the exact dependency versions — written by your FIRST install; commit it, it is the reason two machines build the same site. |
204
+ <!-- /ksor:pm -->
164
205
 
165
206
  `format-checker` deliberately contains a program, `check.mjs`, and not only
166
207
  prose: rules that are only written down cannot refuse anything. `pnpm check`
@@ -181,9 +222,11 @@ and anything that can serve files can serve it.
181
222
  (never pin `system/site` as the root directory — the record lives
182
223
  outside it), build with `pnpm build`, serve `system/site/out/`. It also
183
224
  declares the MCP **door** as a second service built from the shipped
184
- `Dockerfile`, so `/mcp` and the site share one domain. If the
185
- build image's pnpm predates the `packageManager` pin, set the
225
+ `Dockerfile`, so `/mcp` and the site share one domain.
226
+ <!-- ksor:pm pnpm -->
227
+ If the build image's pnpm predates the `packageManager` pin, set the
186
228
  `ENABLE_EXPERIMENTAL_COREPACK=1` build environment variable.
229
+ <!-- /ksor:pm -->
187
230
  **Once `instance.md` declares a `database:`, the BUILD needs the DSN too.**
188
231
  `pnpm build` first runs `pnpm export-denylist`, which asks the record's
189
232
  database what has been withdrawn (`ksor takedown --export`) and writes
@@ -233,6 +276,13 @@ put it behind access control you already trust (VPN, SSO proxy,
233
276
  authenticated host). The tiers govern what a build contains; where each
234
277
  build may be served is yours to enforce.
235
278
 
279
+ The site can also show a **sign-in control** that names the reader in the
280
+ navbar. It is off until you set three variables (see `.env.example`), and it
281
+ names people rather than keeping them out — a static export cannot gate itself,
282
+ so it is worth having on a record already behind one of the answers above, and
283
+ is not a substitute for them. Setup and the honest limits:
284
+ `node_modules/@panaversity/ksor/docs/deploying.md`.
285
+
236
286
  ## Ownership
237
287
 
238
288
  Everything here is yours. The scaffold was generated by
@@ -41,6 +41,23 @@ GEMINI_API_KEY=
41
41
  # KSOR_JWKS_URL=https://your-sso.example.com/.well-known/jwks.json
42
42
  KSOR_AUTH=disabled-local
43
43
 
44
+ # ── Sign-in on the WEBSITE (optional) ───────────────────────────────────────
45
+ # Names the reader in the navbar. It does NOT restrict reading: the site is a
46
+ # static export, so every published document is a file the host serves to
47
+ # whoever asks. To actually keep people out, see docs/deploying.md →
48
+ # "Keeping people out of the site".
49
+ #
50
+ # All three are required together, and are inlined at BUILD time — set them
51
+ # before `pnpm build`, not on the running site. Leave them unset and the
52
+ # sign-in control does not render at all.
53
+ #
54
+ # The issuer is the same authorization server the door names in KSOR_SSO_URL.
55
+ # Register a PUBLIC client (PKCE, no secret) whose redirect list contains the
56
+ # callback URL below, written out literally.
57
+ # NEXT_PUBLIC_KSOR_SSO_URL=https://your-sso.example.com
58
+ # NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID=
59
+ # NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI=https://your-site.example.com/auth/callback
60
+
44
61
  # ── Production knobs ────────────────────────────────────────────────────────
45
62
  # Unset is fine for a local run; each one matters once this serves for real.
46
63
 
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Where the issuer sends the reader back.
5
+ *
6
+ * A PAGE, not a route handler. Under `output: "export"` a route handler cannot
7
+ * share a segment with a page, and more to the point there is no server to run
8
+ * one — the whole exchange happens in the browser, which is what lets a static
9
+ * site be an OAuth client at all.
10
+ */
11
+
12
+ import { useEffect, useState } from "react";
13
+
14
+ import { completeSignIn } from "@/lib/auth/flow";
15
+
16
+ export default function CallbackPage(): React.ReactElement {
17
+ const [error, setError] = useState<string | null>(null);
18
+
19
+ useEffect(() => {
20
+ let cancelled = false;
21
+ void (async () => {
22
+ const result = await completeSignIn(new URLSearchParams(window.location.search));
23
+ if (cancelled) return;
24
+ if (result.ok) {
25
+ // replace(), not assign(): the callback URL carries a spent
26
+ // authorization code, and leaving it in history means Back re-runs a
27
+ // flow that can only fail the second time.
28
+ window.location.replace(result.returnTo || "/");
29
+ return;
30
+ }
31
+ setError(result.reason);
32
+ })();
33
+ return () => {
34
+ cancelled = true;
35
+ };
36
+ }, []);
37
+
38
+ return (
39
+ <main className="mx-auto flex min-h-[60vh] max-w-md flex-col justify-center gap-3 px-6 text-center">
40
+ {error === null ? (
41
+ <p className="text-muted-foreground text-sm">Signing you in…</p>
42
+ ) : (
43
+ <>
44
+ <h1 className="text-lg font-semibold">Sign-in did not complete</h1>
45
+ <p className="text-muted-foreground text-sm">{error}</p>
46
+ <a className="text-sm underline underline-offset-4" href="/">
47
+ Back to the record
48
+ </a>
49
+ </>
50
+ )}
51
+ </main>
52
+ );
53
+ }
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The navbar's sign-in control.
5
+ *
6
+ * Renders NOTHING when this record does not configure an issuer, so a scaffold
7
+ * that never sets one looks exactly as it does today — no placeholder, no
8
+ * disabled button, no hint that a feature is missing.
9
+ *
10
+ * What it does NOT do, said here because a sign-in control implies it: gate
11
+ * anything. The record's pages are static files the host serves to whoever
12
+ * asks. This names a reader; it does not decide what they may read.
13
+ */
14
+
15
+ import { LogIn, LogOut, User } from "lucide-react";
16
+ import { useEffect, useState } from "react";
17
+
18
+ import { Button } from "@/components/ui/button";
19
+ import {
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuLabel,
24
+ DropdownMenuSeparator,
25
+ DropdownMenuTrigger,
26
+ } from "@/components/ui/dropdown-menu";
27
+ import { authConfig } from "@/lib/auth/config";
28
+ import { beginSignIn } from "@/lib/auth/flow";
29
+ import { clearSession, readSession, type Session } from "@/lib/auth/session";
30
+
31
+ export function SignIn(): React.ReactElement | null {
32
+ // Read AFTER mount, never during render: the session lives in sessionStorage,
33
+ // which the prerender has no access to. Reading it during render would make
34
+ // the server-rendered HTML and the first client render disagree, and React
35
+ // would blow the tree away with a hydration error.
36
+ const [session, setSession] = useState<Session | null>(null);
37
+ const [ready, setReady] = useState(false);
38
+
39
+ useEffect(() => {
40
+ setSession(readSession());
41
+ setReady(true);
42
+ }, []);
43
+
44
+ if (authConfig === null) return null;
45
+
46
+ // Hold the space until mounted, so the navbar does not jump when the session
47
+ // resolves one frame later.
48
+ if (!ready) return <div className="h-8 w-20" aria-hidden />;
49
+
50
+ if (session === null) {
51
+ return (
52
+ <Button
53
+ variant="outline"
54
+ size="sm"
55
+ onClick={() => {
56
+ void beginSignIn(window.location.pathname + window.location.search);
57
+ }}
58
+ >
59
+ <LogIn className="size-4" aria-hidden />
60
+ Sign in
61
+ </Button>
62
+ );
63
+ }
64
+
65
+ const label = session.name ?? session.email ?? "Signed in";
66
+ return (
67
+ <DropdownMenu>
68
+ <DropdownMenuTrigger asChild>
69
+ <Button variant="ghost" size="sm" aria-label={`Signed in as ${label}`}>
70
+ <User className="size-4" aria-hidden />
71
+ <span className="max-w-32 truncate">{label}</span>
72
+ </Button>
73
+ </DropdownMenuTrigger>
74
+ <DropdownMenuContent align="end" className="w-56">
75
+ <DropdownMenuLabel className="font-normal">
76
+ <span className="block truncate text-sm font-medium">{label}</span>
77
+ {session.email !== null && session.email !== label ? (
78
+ <span className="text-muted-foreground block truncate text-xs">{session.email}</span>
79
+ ) : null}
80
+ </DropdownMenuLabel>
81
+ <DropdownMenuSeparator />
82
+ <DropdownMenuItem
83
+ onClick={() => {
84
+ clearSession();
85
+ setSession(null);
86
+ }}
87
+ >
88
+ <LogOut className="size-4" aria-hidden />
89
+ Sign out
90
+ </DropdownMenuItem>
91
+ </DropdownMenuContent>
92
+ </DropdownMenu>
93
+ );
94
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Whether this record's site offers sign-in, and against whom.
3
+ *
4
+ * Sign-in is ABSENT unless an issuer is configured. There is no half-state and
5
+ * no localhost fallback: a build with no issuer renders no control, and every
6
+ * function here returns null rather than guessing. That is the same posture the
7
+ * door takes (`ksor serve` refuses to boot rather than serve an undeclared
8
+ * auth state) expressed in the only way a static build can — by not existing.
9
+ *
10
+ * What this buys, stated once so nobody has to infer it: a VERIFIED reader
11
+ * identity, rather than a guessed one. It protects nothing the site publishes,
12
+ * and nothing in the browser calls the door yet — the flow requests `openid
13
+ * profile email` and no audience, so the access token it receives is the
14
+ * issuer's own and is not something the door would accept. Making the browser
15
+ * a door client means requesting this record's resource (RFC 8707) and is a
16
+ * new read surface, not a config change. Under `output: "export"` every document is a file the host serves
17
+ * to whoever asks; see `docs/deploying.md` → "Keeping people out of the site"
18
+ * for the three mechanisms that actually restrict reading.
19
+ */
20
+
21
+ /** Values are inlined at BUILD time, so they must be NEXT_PUBLIC_ to exist. */
22
+ export interface AuthConfig {
23
+ /** The authorization server's base URL — the same issuer the door names. */
24
+ readonly issuer: string;
25
+ /** This site's public OAuth client. No secret: PKCE proves the caller. */
26
+ readonly clientId: string;
27
+ /**
28
+ * The callback URL, written out LITERALLY.
29
+ *
30
+ * Never derived from `window.location`. A derivation has to guess whether the
31
+ * first path segment is a base path, and guesses wrong under `KSOR_BASE_PATH`
32
+ * — producing a `redirect_uri` the issuer rejects, with an error that names
33
+ * the URI and not the guess that built it.
34
+ */
35
+ readonly redirectUri: string;
36
+ }
37
+
38
+ const issuer = process.env["NEXT_PUBLIC_KSOR_SSO_URL"]?.trim() ?? "";
39
+ const clientId = process.env["NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID"]?.trim() ?? "";
40
+ const redirectUri = process.env["NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI"]?.trim() ?? "";
41
+
42
+ /**
43
+ * The configuration, or null when this record does not offer sign-in.
44
+ *
45
+ * All three are required together. Two out of three is a misconfiguration that
46
+ * would otherwise surface as a failed redirect on the reader's screen, so it
47
+ * resolves to "no sign-in" and the control never renders.
48
+ */
49
+ export const authConfig: AuthConfig | null =
50
+ issuer !== "" && clientId !== "" && redirectUri !== "" ? { issuer, clientId, redirectUri } : null;
51
+
52
+ /** Where the record's own scopes end: identity only, and no refresh token. */
53
+ export const OAUTH_SCOPE = "openid profile email";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Where the issuer's endpoints actually are — asked, never assumed.
3
+ *
4
+ * Hardcoding `/authorize` and `/oauth/token` would work against one vendor and
5
+ * fail against the next, which is the opposite of what this project claims.
6
+ * Every standards-compliant authorization server publishes a metadata document
7
+ * naming its own endpoints, and the door already reads exactly these two
8
+ * (`gateway-kit/src/jwks-discovery.ts`): RFC 8414 first, then OpenID Discovery.
9
+ *
10
+ * So the site and the door discover the same issuer the same way, and pointing
11
+ * both at a different provider stays an environment change.
12
+ */
13
+
14
+ export interface Endpoints {
15
+ readonly authorization_endpoint: string;
16
+ readonly token_endpoint: string;
17
+ }
18
+
19
+ const PATHS = ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"];
20
+
21
+ function isEndpoints(value: unknown): value is Endpoints {
22
+ if (typeof value !== "object" || value === null) return false;
23
+ const v = value as Record<string, unknown>;
24
+ return typeof v["authorization_endpoint"] === "string" && typeof v["token_endpoint"] === "string";
25
+ }
26
+
27
+ let cached: Endpoints | null = null;
28
+
29
+ /**
30
+ * The issuer's endpoints, or null if it publishes no usable metadata.
31
+ *
32
+ * Memoized for the page's lifetime: the flow reads it twice (once to leave,
33
+ * once to come back) and those are separate page loads, so this saves a request
34
+ * only within one of them. Correctness does not depend on it.
35
+ */
36
+ export async function discover(issuer: string): Promise<Endpoints | null> {
37
+ if (cached !== null) return cached;
38
+ const base = issuer.replace(/\/+$/, "");
39
+ for (const path of PATHS) {
40
+ try {
41
+ const response = await fetch(`${base}${path}`);
42
+ if (!response.ok) continue;
43
+ const document: unknown = await response.json();
44
+ if (isEndpoints(document)) {
45
+ cached = document;
46
+ return document;
47
+ }
48
+ } catch {
49
+ /* try the next document; a provider publishing neither is the null case */
50
+ }
51
+ }
52
+ return null;
53
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The two halves of an authorization-code flow, as plain functions.
3
+ *
4
+ * Everything is `fetch` and `window.location`. Nothing here needs a server on
5
+ * this origin, which is why a static export can do it at all.
6
+ */
7
+
8
+ import { authConfig, OAUTH_SCOPE, type AuthConfig } from "./config";
9
+ import { discover } from "./discovery";
10
+ import { createPkcePair, createState } from "./pkce";
11
+ import { writeSession, type Session } from "./session";
12
+
13
+ /**
14
+ * The verifier and state, held across the redirect.
15
+ *
16
+ * `sessionStorage`, not a variable: the browser leaves this origin entirely and
17
+ * comes back to a fresh page load, so anything in memory is gone. It is also
18
+ * not `localStorage` — this is single-flow, single-tab state that should not
19
+ * outlive the tab that started it.
20
+ */
21
+ const PENDING = "ksor.oauth.pending";
22
+
23
+ interface Pending {
24
+ readonly verifier: string;
25
+ readonly state: string;
26
+ /** Where the reader was, so sign-in returns them there. */
27
+ readonly returnTo: string;
28
+ }
29
+
30
+ /** Send the reader to the issuer. Returns only if it could not start. */
31
+ export async function beginSignIn(returnTo: string): Promise<void> {
32
+ const config: AuthConfig | null = authConfig;
33
+ if (config === null) return;
34
+
35
+ const { verifier, challenge } = await createPkcePair();
36
+ const state = createState();
37
+ const pending: Pending = { verifier, state, returnTo };
38
+ sessionStorage.setItem(PENDING, JSON.stringify(pending));
39
+
40
+ const endpoints = await discover(config.issuer);
41
+ if (endpoints === null) return;
42
+
43
+ const url = new URL(endpoints.authorization_endpoint);
44
+ url.searchParams.set("response_type", "code");
45
+ url.searchParams.set("client_id", config.clientId);
46
+ url.searchParams.set("redirect_uri", config.redirectUri);
47
+ url.searchParams.set("scope", OAUTH_SCOPE);
48
+ url.searchParams.set("state", state);
49
+ url.searchParams.set("code_challenge", challenge);
50
+ url.searchParams.set("code_challenge_method", "S256");
51
+ window.location.assign(url.toString());
52
+ }
53
+
54
+ export type CallbackResult =
55
+ | { readonly ok: true; readonly returnTo: string }
56
+ | { readonly ok: false; readonly reason: string };
57
+
58
+ interface TokenResponse {
59
+ readonly access_token?: unknown;
60
+ readonly id_token?: unknown;
61
+ readonly expires_in?: unknown;
62
+ }
63
+
64
+ /** Claims this site reads. Everything else in the token is ignored. */
65
+ interface IdClaims {
66
+ readonly sub?: unknown;
67
+ readonly name?: unknown;
68
+ readonly email?: unknown;
69
+ }
70
+
71
+ /** Decode a JWT payload WITHOUT verifying it. Read the caveat before using. */
72
+ function decodeClaims(idToken: string): IdClaims | null {
73
+ try {
74
+ const payload = idToken.split(".")[1];
75
+ if (payload === undefined) return null;
76
+ const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
77
+ const parsed: unknown = JSON.parse(json);
78
+ return typeof parsed === "object" && parsed !== null ? (parsed as IdClaims) : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ const str = (value: unknown): string | null => (typeof value === "string" ? value : null);
85
+
86
+ /**
87
+ * Redeem the code the issuer sent back, and store the session.
88
+ *
89
+ * The id_token's claims are decoded, NOT verified, and that is deliberate: they
90
+ * are used only to render a name in this browser's own navbar. A forged token
91
+ * would let a reader lie to themselves about their own display name and nothing
92
+ * else — no content is gated on it, and the ACCESS token is verified by the
93
+ * server that actually accepts it (the door checks signature, issuer and
94
+ * audience per request). Verifying here would mean shipping a JWKS client to
95
+ * defend against the reader's own devtools.
96
+ */
97
+ export async function completeSignIn(params: URLSearchParams): Promise<CallbackResult> {
98
+ const config: AuthConfig | null = authConfig;
99
+ if (config === null) return { ok: false, reason: "This record does not offer sign-in." };
100
+
101
+ const issuerError = params.get("error");
102
+ if (issuerError !== null) {
103
+ return { ok: false, reason: params.get("error_description") ?? issuerError };
104
+ }
105
+
106
+ const raw = sessionStorage.getItem(PENDING);
107
+ sessionStorage.removeItem(PENDING);
108
+ if (raw === null) {
109
+ return { ok: false, reason: "This sign-in did not start here. Try again from the site." };
110
+ }
111
+ const pending = JSON.parse(raw) as Pending;
112
+
113
+ // CSRF: the state must be the one this browser generated. Checked before the
114
+ // code is sent anywhere, so a planted code is never redeemed.
115
+ if (params.get("state") !== pending.state) {
116
+ return { ok: false, reason: "Sign-in could not be verified. Try again." };
117
+ }
118
+ const code = params.get("code");
119
+ if (code === null) return { ok: false, reason: "The issuer returned no authorization code." };
120
+
121
+ // No `credentials: "include"`: a public PKCE client sends no cookies, and
122
+ // sending them is refused outright by browsers against a wildcard CORS origin.
123
+ const endpoints = await discover(config.issuer);
124
+ if (endpoints === null) {
125
+ return { ok: false, reason: "The authorization server published no metadata document." };
126
+ }
127
+
128
+ const response = await fetch(endpoints.token_endpoint, {
129
+ method: "POST",
130
+ headers: { "content-type": "application/x-www-form-urlencoded" },
131
+ body: new URLSearchParams({
132
+ grant_type: "authorization_code",
133
+ code,
134
+ redirect_uri: config.redirectUri,
135
+ client_id: config.clientId,
136
+ code_verifier: pending.verifier,
137
+ }),
138
+ });
139
+ if (!response.ok) {
140
+ return { ok: false, reason: `The issuer refused the exchange (${response.status}).` };
141
+ }
142
+
143
+ const token = (await response.json()) as TokenResponse;
144
+ const accessToken = str(token.access_token);
145
+ if (accessToken === null) return { ok: false, reason: "The issuer returned no access token." };
146
+
147
+ const claims = typeof token.id_token === "string" ? decodeClaims(token.id_token) : null;
148
+ const lifetime = typeof token.expires_in === "number" ? token.expires_in : 3600;
149
+ const session: Session = {
150
+ subject: str(claims?.sub) ?? "unknown",
151
+ name: str(claims?.name),
152
+ email: str(claims?.email),
153
+ accessToken,
154
+ expiresAt: Date.now() + lifetime * 1000,
155
+ };
156
+ writeSession(session);
157
+ return { ok: true, returnTo: pending.returnTo };
158
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * RFC 7636 (PKCE, S256) using the Web Crypto API — no dependency.
3
+ *
4
+ * PKCE is what lets a browser be an OAuth client with NO SECRET. The client
5
+ * generates a random verifier, sends only its SHA-256 hash to start the flow,
6
+ * and presents the verifier when redeeming the code. An attacker who steals the
7
+ * authorization code cannot redeem it without the verifier, which never left
8
+ * the browser that started the flow.
9
+ *
10
+ * That is the whole reason this site needs no server: there is nothing to keep
11
+ * secret, so there is nothing that must live somewhere the reader cannot see.
12
+ */
13
+
14
+ /** Base64url without padding — RFC 7636 §4.2 requires exactly this alphabet. */
15
+ function base64url(bytes: Uint8Array): string {
16
+ let binary = "";
17
+ for (const byte of bytes) binary += String.fromCharCode(byte);
18
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
+ }
20
+
21
+ function randomBase64url(byteLength: number): string {
22
+ const bytes = new Uint8Array(byteLength);
23
+ crypto.getRandomValues(bytes);
24
+ return base64url(bytes);
25
+ }
26
+
27
+ export interface PkcePair {
28
+ readonly verifier: string;
29
+ readonly challenge: string;
30
+ }
31
+
32
+ /** A verifier and its S256 challenge. 32 bytes → 43 chars, the RFC's minimum. */
33
+ export async function createPkcePair(): Promise<PkcePair> {
34
+ const verifier = randomBase64url(32);
35
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
36
+ return { verifier, challenge: base64url(new Uint8Array(digest)) };
37
+ }
38
+
39
+ /** CSRF state, bound to this flow and checked when the issuer redirects back. */
40
+ export function createState(): string {
41
+ return randomBase64url(16);
42
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The reader's identity, for as long as the tab is open.
3
+ *
4
+ * Storage is `sessionStorage`, deliberately, and it is the one decision here
5
+ * worth arguing about. The alternative — `localStorage` with long-lived tokens
6
+ * — is what the predecessor did and is what NOT to copy: a token readable by
7
+ * any script on the origin, surviving for days, for a feature that grants no
8
+ * access to anything on this site. The blast radius should match the benefit.
9
+ *
10
+ * So: per-tab, gone when the tab closes, and no refresh token is requested at
11
+ * all. Signing in again is one redirect through an issuer that already knows
12
+ * the reader; it is not worth holding a long-lived credential to avoid.
13
+ */
14
+
15
+ const KEY = "ksor.session";
16
+
17
+ export interface Session {
18
+ /** The `sub` claim — the issuer's stable identifier for this reader. */
19
+ readonly subject: string;
20
+ readonly name: string | null;
21
+ readonly email: string | null;
22
+ /**
23
+ * The issuer's access token. Held because a session without one is not a
24
+ * session — NOT because anything here calls an API with it yet. No audience
25
+ * is requested, so it is the issuer's own token and the door would refuse it.
26
+ */
27
+ readonly accessToken: string;
28
+ /** Epoch milliseconds. A session past this is treated as absent. */
29
+ readonly expiresAt: number;
30
+ }
31
+
32
+ function isSession(value: unknown): value is Session {
33
+ if (typeof value !== "object" || value === null) return false;
34
+ const v = value as Record<string, unknown>;
35
+ return (
36
+ typeof v["subject"] === "string" &&
37
+ typeof v["accessToken"] === "string" &&
38
+ typeof v["expiresAt"] === "number" &&
39
+ (v["name"] === null || typeof v["name"] === "string") &&
40
+ (v["email"] === null || typeof v["email"] === "string")
41
+ );
42
+ }
43
+
44
+ /**
45
+ * The stored session, or null.
46
+ *
47
+ * Every failure returns null rather than throwing: storage can be unavailable
48
+ * (private windows, blocked site data), the value can be another version's
49
+ * shape, and none of those are worth breaking a page render over.
50
+ */
51
+ export function readSession(): Session | null {
52
+ try {
53
+ const raw = sessionStorage.getItem(KEY);
54
+ if (raw === null) return null;
55
+ const parsed: unknown = JSON.parse(raw);
56
+ if (!isSession(parsed)) return null;
57
+ if (Date.now() >= parsed.expiresAt) {
58
+ sessionStorage.removeItem(KEY);
59
+ return null;
60
+ }
61
+ return parsed;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function writeSession(session: Session): void {
68
+ try {
69
+ sessionStorage.setItem(KEY, JSON.stringify(session));
70
+ } catch {
71
+ /* storage unavailable: the reader stays signed out, which is honest */
72
+ }
73
+ }
74
+
75
+ export function clearSession(): void {
76
+ try {
77
+ sessionStorage.removeItem(KEY);
78
+ } catch {
79
+ /* nothing to do — a session that cannot be read cannot be used */
80
+ }
81
+ }
@@ -1,4 +1,6 @@
1
1
  import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
2
+
3
+ import { SignIn } from "@/components/sign-in";
2
4
  import { appTitle } from "./shared";
3
5
 
4
6
  export function baseOptions(): BaseLayoutProps {
@@ -13,5 +15,9 @@ export function baseOptions(): BaseLayoutProps {
13
15
  </span>
14
16
  ),
15
17
  },
18
+ // `secondary` puts it at the navbar's trailing edge, beside the theme
19
+ // toggle. SignIn renders null when no issuer is configured, so a record
20
+ // that does not offer sign-in shows nothing rather than an empty slot.
21
+ links: [{ type: "custom", secondary: true, children: <SignIn /> }],
16
22
  };
17
23
  }
@@ -1,4 +1,5 @@
1
1
  import { createMDX } from "fumadocs-mdx/next";
2
+ import { readFileSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
 
@@ -10,6 +11,28 @@ const withMDX = createMDX();
10
11
  // resolved from this file's own location so it holds wherever the repo lands.
11
12
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
12
13
 
14
+ // The record keeps ONE .env, at the repo root, because that is where `ksor`
15
+ // reads it. This build runs in system/site, so Next would never see it — and an
16
+ // adopter following the scaffold's own instructions would set NEXT_PUBLIC_*
17
+ // variables that silently never reach the bundle (found live). Read the root
18
+ // file here, and let a real environment variable win, which is the same
19
+ // precedence the CLI states.
20
+ function loadRootEnv() {
21
+ let contents;
22
+ try {
23
+ contents = readFileSync(path.join(repoRoot, ".env"), "utf8");
24
+ } catch {
25
+ return; // no .env is the normal case — only .env.example ships
26
+ }
27
+ for (const line of contents.split("\n")) {
28
+ const match = /^\s*(NEXT_PUBLIC_[A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
29
+ if (match?.[1] !== undefined && process.env[match[1]] === undefined) {
30
+ process.env[match[1]] = (match[2] ?? "").trim().replace(/^["']|["']$/g, "");
31
+ }
32
+ }
33
+ }
34
+ loadRootEnv();
35
+
13
36
  /** @type {import('next').NextConfig} */
14
37
  const config = {
15
38
  reactStrictMode: true,