@panaversity/ksor 0.0.34 → 0.0.35

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,74 @@
1
1
  # @panaversity/ksor
2
2
 
3
+ ## 0.0.35
4
+
5
+ ### Patch Changes
6
+
7
+ - 0fc6bce: Name the reader on the website
8
+
9
+ The scaffolded site can now sign a reader in and show who they are in the
10
+ navbar. It is off until three variables are set — the control does not render at
11
+ all without them, which stays the default.
12
+
13
+ The flow is OAuth 2.0 Authorization Code with PKCE against a public client, with
14
+ no secret anywhere in a build that ships to browsers. Endpoints are discovered
15
+ (RFC 8414, then OIDC), so no vendor is named in the code or in configuration;
16
+ verified end to end against Auth0 and against a Better Auth deployment. The
17
+ session lives in `sessionStorage` for the tab, and no refresh token is requested
18
+ or stored — a token that unlocks nothing on this site should not outlive the
19
+ visit.
20
+
21
+ What it does NOT do is restrict reading, and the documentation leads with that.
22
+ The site is a static export: every published document is a file the host serves
23
+ to whoever asks, so keeping people out is still the origin gate or a per-audience
24
+ build, both unchanged. This names an already-authenticated reader; it is not a
25
+ step toward access control, and treating it as one would be the mistake the
26
+ "Keeping people out of the site" section exists to prevent.
27
+
28
+ Also fixes a real gap it exposed: the site build never read the repository-root
29
+ `.env`, so following the scaffold's own instructions would have set variables
30
+ that silently never reached the bundle.
31
+
32
+ - 4b431b2: Trim the shell-retirement revision in AGENTS.md from 222 words to 129, keeping
33
+ what an agent must act on and moving the reasoning to the commit that carried
34
+ it.
35
+
36
+ Working rule 6 requires a reversed decision to keep its entry and gain a
37
+ revision note, so removing it is not available — and it is not irrelevant
38
+ either: without it an agent looks for a deleted directory with no explanation,
39
+ or restores two-shell assertions thinking they were lost by accident, or reads
40
+ decision 9, sees no obstacle, and treats dropping `output: "export"` as
41
+ unblocked. That last one is the reason it stays.
42
+
43
+ But coding principle 1 applies to this file too — context is liability, and
44
+ AGENTS.md loads every session. The narrative half was 90 words explaining why
45
+ the proof had been valuable, which the commit already records.
46
+
47
+ Also documents the thing that was missing entirely: **how to keep people out of
48
+ the site.** The door's auth had four recipes; the site had nothing, and the
49
+ most common requirement — "everyone signs in before reading anything" — is also
50
+ the easiest, needs no ksor change, and was written down nowhere.
51
+
52
+ Three shapes, separated because they had been muddled: a host-level gate in
53
+ front of the origin (protects every byte, holds against `curl`, and makes a
54
+ site sign-in button redundant rather than complementary); per-audience builds
55
+ for a restricted subset (enforcement by absence, already built); and the
56
+ per-request case, which a static export cannot express and which issue #130
57
+ records rather than implements. Plus what does not work — hiding rendered
58
+ content behind a browser check, which presents rather than protects.
59
+
60
+ The per-request case gets three answers rather than a deferral: **read through
61
+ the door** (already applies audience scope per request and logs an actor per
62
+ read — per-person governance with an audit trail a static site cannot have),
63
+ **split the record** (content needing per-person confidentiality inside one tier
64
+ usually belongs in its own record), or **fork the site**, which an adopter owns
65
+ outright under decision 4.
66
+
67
+ The fork is offered with what it costs stated: ksor's guarantee is enforcement
68
+ by ABSENCE, asserted against a positive control; a request-time filter is a
69
+ different guarantee and becomes the adopter's to test. A filter that is bypassed
70
+ serves the document; an absent file cannot be.
71
+
3
72
  ## 0.0.34
4
73
 
5
74
  ### Patch Changes
@@ -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
@@ -246,6 +246,148 @@ a row) and reaches the site at its next build (it reads a file), so a site built
246
246
  without the DSN would keep publishing what the door already refuses. Set
247
247
  `KSOR_DB_URL` on the site build as well as on the door.
248
248
 
249
+ ## Keeping people out of the site
250
+
251
+ The door has auth ([authorization.md](./authorization.md)). The **site** is
252
+ static files, so it has none — and the way to protect it is not to add code, it
253
+ is to put something in front of it.
254
+
255
+ Three requirements, three different answers. Pick the row you actually have.
256
+
257
+ ### "Everyone must sign in before reading anything"
258
+
259
+ **Put a gate in front of the origin.** Nothing in ksor changes, and it protects
260
+ every byte — HTML, `llms.txt`, images, the search index — because the request
261
+ never reaches the files.
262
+
263
+ | host | what to turn on |
264
+ | ------------- | ---------------------------------------------------------------------------------------------------- |
265
+ | Vercel | Deployment Protection (password, or SSO on paid plans) |
266
+ | Cloudflare | Cloudflare Access in front of the deployment |
267
+ | anything else | an authenticating reverse proxy — nginx with `auth_request`, oauth2-proxy, Caddy with `forward_auth` |
268
+
269
+ This is the strongest gate available to a static site, and the only one that
270
+ holds against `curl`. It is coarse — whole deployment, all or nothing — which is
271
+ exactly right when the answer is "this record is internal".
272
+
273
+ **A sign-in button on the site is not an alternative to this.** The gate has
274
+ already authenticated the reader before a page renders; a second login inside it
275
+ would ask the same person to sign in twice, and on its own would protect
276
+ nothing.
277
+
278
+ ### "Some documents are restricted, most are not"
279
+
280
+ **Build per audience.** `KSOR_AUDIENCE=<tier> pnpm build` stages only what that
281
+ tier may see, so restricted documents are **never written into the artifact** —
282
+ enforcement by absence, which is the only kind a static host can honour. Publish
283
+ the public artifact openly and the wider one behind the gate above.
284
+
285
+ Plain `pnpm build` is always the public tier, so the safe thing is the default.
286
+
287
+ ### "Different readers see different documents, decided per request"
288
+
289
+ Two supported answers, and a third that is yours.
290
+
291
+ **Read through the door instead.** This is the one ksor is built for. The MCP
292
+ surface already applies the audience scope **per request** and writes a
293
+ `retrieval_log` row carrying the actor for every read — per-person governance
294
+ with an audit trail, which a static site cannot have at any price. If the
295
+ requirement is "who read what, and were they allowed to", that is the door, not
296
+ the website.
297
+
298
+ **Or split the record.** Content needing per-person confidentiality inside one
299
+ tier is usually content that belongs in its own record, with its own gate. That
300
+ is what the audience model and the second-record design anticipate.
301
+
302
+ **Or fork the site — you already own it.** `system/site` is yours outright
303
+ (decision 4). Nothing stops you removing `output: "export"` and filtering per
304
+ request in your own repository. ksor's contract is unaffected; this is a
305
+ directory you own, changed the way you want it.
306
+
307
+ What you take on if you do:
308
+
309
+ > ksor's guarantee is **enforcement by absence** — a restricted document is
310
+ > never written into the artifact, and a conformance suite asserts that against
311
+ > a positive control that proves the check is not blind. A request-time filter
312
+ > is a **different** guarantee, and it becomes yours to test, because those
313
+ > suites will no longer be testing it for you. A filter that is bypassed serves
314
+ > the document; an absent file cannot be.
315
+
316
+ That is the whole trade. It is a reasonable thing to do with your eyes open, and
317
+ a bad thing to drift into because a login button suggested it.
318
+
319
+ ### What does NOT work
320
+
321
+ **Hiding rendered content behind a signed-in check in the browser.** If the page
322
+ was built with the content in it, the content is in the response before any
323
+ JavaScript runs — `curl` and every crawler see it. A component that blurs or
324
+ collapses it is presenting, not protecting. If you build one, say so in its own
325
+ comment, or the next reader will take it for a gate.
326
+
327
+ ## Naming the reader — the sign-in control
328
+
329
+ The site ships an optional sign-in control. Read the section above before you
330
+ turn it on, because the one thing it does not do is the thing its name suggests.
331
+
332
+ **What it does:** signs the reader in against your authorization server and puts
333
+ their name in the navbar. That is the whole feature.
334
+
335
+ **What it does not do:** restrict anything. The site is a static export — every
336
+ published document is a file the host hands to whoever asks, and no amount of
337
+ browser JavaScript changes that. If the requirement is "keep people out", the
338
+ answer is the origin gate above, and the sign-in control is not a step toward it.
339
+
340
+ So the honest use is a record already behind a gate, where the reader is
341
+ authenticated but anonymous to the page, and you want the navbar to say who they
342
+ are and offer a way out. That is worth having. It is not access control.
343
+
344
+ ### Turning it on
345
+
346
+ Register a **public** client — PKCE, no secret — at the same authorization
347
+ server the door names in `KSOR_SSO_URL`, then set three variables in the
348
+ repository-root `.env`:
349
+
350
+ ```sh
351
+ NEXT_PUBLIC_KSOR_SSO_URL=https://your-sso.example.com
352
+ NEXT_PUBLIC_KSOR_OAUTH_CLIENT_ID=your-client-id
353
+ NEXT_PUBLIC_KSOR_OAUTH_REDIRECT_URI=https://your-site.example.com/auth/callback
354
+ ```
355
+
356
+ All three or none: with any of them missing the control does not render, which
357
+ is the default and is not an error.
358
+
359
+ They are `NEXT_PUBLIC_`, so they are **inlined at build time**. Set them before
360
+ `pnpm build`; setting them on a running site changes nothing. This also means
361
+ they are public — which is correct, because a public client has nothing secret
362
+ to leak, and it is the reason none of these is a secret.
363
+
364
+ Two things to get exactly right at the provider:
365
+
366
+ - **The redirect URI must match byte for byte**, including the scheme and any
367
+ trailing slash. This is the failure everyone hits first, and providers report
368
+ it as a generic callback mismatch.
369
+ - **Add the site's origin to the allowed web origins** (Auth0 calls it that;
370
+ others call it CORS). The token exchange is a browser `fetch`, so a missing
371
+ origin fails as CORS, not as auth.
372
+
373
+ For local work, both values are `http://localhost:3000` — and the callback is
374
+ `http://localhost:3000/auth/callback`.
375
+
376
+ Endpoints are **discovered**, not configured: the control reads
377
+ `/.well-known/oauth-authorization-server`, then OIDC discovery. Any provider
378
+ publishing either one works, which is why there is no vendor setting here.
379
+ Verified against Auth0 and against a Better Auth deployment.
380
+
381
+ ### What it stores, and for how long
382
+
383
+ The session lives in `sessionStorage` — this tab, until it closes. No refresh
384
+ token is requested and none is stored.
385
+
386
+ That is deliberate, and it is a smaller footprint than the obvious alternative.
387
+ A token that unlocks nothing on this site should not outlive the visit; the
388
+ blast radius should match the benefit. If you need a longer session, you need
389
+ the gate, not a longer-lived token in a browser.
390
+
249
391
  ## Authorization, or the deliberate absence of it
250
392
 
251
393
  `ksor serve` **refuses to boot unauthenticated on a public bind.** There is no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panaversity/ksor",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
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",
@@ -233,6 +233,13 @@ put it behind access control you already trust (VPN, SSO proxy,
233
233
  authenticated host). The tiers govern what a build contains; where each
234
234
  build may be served is yours to enforce.
235
235
 
236
+ The site can also show a **sign-in control** that names the reader in the
237
+ navbar. It is off until you set three variables (see `.env.example`), and it
238
+ names people rather than keeping them out — a static export cannot gate itself,
239
+ so it is worth having on a record already behind one of the answers above, and
240
+ is not a substitute for them. Setup and the honest limits:
241
+ `node_modules/@panaversity/ksor/docs/deploying.md`.
242
+
236
243
  ## Ownership
237
244
 
238
245
  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,