wiki-viewer 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.next/standalone/README.md +365 -99
  2. package/.next/standalone/agents/bootstrap-prompt.md +1 -0
  3. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
  4. package/.next/standalone/bin/wiki-viewer.js +431 -33
  5. package/.next/standalone/docs/agent-collab-plan.md +1615 -0
  6. package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
  7. package/.next/standalone/package.json +19 -2
  8. package/.next/standalone/pnpm-lock.yaml +1368 -325
  9. package/.next/standalone/pnpm-workspace.yaml +7 -1
  10. package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
  11. package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
  12. package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
  13. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
  14. package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
  15. package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
  16. package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
  17. package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
  18. package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
  19. package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
  20. package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
  21. package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
  22. package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
  23. package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
  24. package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
  25. package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
  26. package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
  27. package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
  28. package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
  29. package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
  30. package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
  31. package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
  32. package/.next/standalone/src/app/api/system/config/route.ts +5 -1
  33. package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
  34. package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
  35. package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
  36. package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
  37. package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
  38. package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
  39. package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
  40. package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
  41. package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
  42. package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
  43. package/.next/standalone/src/app/api/wiki/route.ts +10 -0
  44. package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
  45. package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
  46. package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
  47. package/.next/standalone/src/app/globals.css +70 -0
  48. package/.next/standalone/src/app/page.tsx +461 -217
  49. package/.next/standalone/src/app/signin/page.tsx +217 -0
  50. package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
  51. package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
  52. package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
  53. package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
  54. package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
  55. package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
  56. package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
  57. package/.next/standalone/src/components/editor/editor.tsx +513 -10
  58. package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
  59. package/.next/standalone/src/components/editor/extensions.ts +2 -0
  60. package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
  61. package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
  62. package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
  63. package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
  64. package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
  65. package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
  66. package/.next/standalone/src/lib/auth/client.ts +4 -0
  67. package/.next/standalone/src/lib/auth/csrf.ts +68 -0
  68. package/.next/standalone/src/lib/auth/server.ts +164 -0
  69. package/.next/standalone/src/lib/config.ts +4 -0
  70. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
  71. package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
  72. package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
  73. package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
  74. package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
  75. package/.next/standalone/src/lib/proof/activity.ts +74 -0
  76. package/.next/standalone/src/lib/proof/auth.ts +132 -0
  77. package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
  78. package/.next/standalone/src/lib/proof/blocks.ts +73 -0
  79. package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
  80. package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
  81. package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
  82. package/.next/standalone/src/lib/proof/glob.ts +51 -0
  83. package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
  84. package/.next/standalone/src/lib/proof/mutex.ts +32 -0
  85. package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
  86. package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
  87. package/.next/standalone/src/lib/proof/pending.ts +97 -0
  88. package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
  89. package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
  90. package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
  91. package/.next/standalone/src/lib/proof/registry.ts +190 -0
  92. package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
  93. package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
  94. package/.next/standalone/src/lib/proof/types.ts +170 -0
  95. package/.next/standalone/src/lib/proof-config.ts +14 -0
  96. package/.next/standalone/src/middleware.ts +38 -0
  97. package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
  98. package/.next/standalone/src/stores/editor-store.ts +115 -9
  99. package/.next/standalone/src/stores/proof-store.ts +123 -0
  100. package/.next/standalone/src/stores/view-width-store.ts +62 -0
  101. package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
  102. package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
  103. package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
  104. package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
  105. package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
  106. package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
  107. package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
  108. package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
  109. package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
  110. package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
  111. package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
  112. package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
  113. package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
  114. package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
  115. package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
  116. package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
  117. package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
  118. package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
  119. package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
  120. package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
  121. package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
  122. package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
  123. package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
  124. package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
  125. package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
  126. package/README.md +365 -99
  127. package/bin/wiki-viewer.js +431 -33
  128. package/package.json +19 -2
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { markdownToHtml } from "@/lib/markdown/to-html";
5
+ import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
6
+
7
+ interface MarkdownPreviewProps {
8
+ markdown: string;
9
+ pagePath: string;
10
+ onNavigate: (targetPath: string, anchor: string | null) => void;
11
+ }
12
+
13
+ export function MarkdownPreview({
14
+ markdown,
15
+ pagePath,
16
+ onNavigate,
17
+ }: MarkdownPreviewProps) {
18
+ const [html, setHtml] = useState<string>("");
19
+ const containerRef = useRef<HTMLDivElement>(null);
20
+
21
+ // Recompute HTML whenever markdown or pagePath changes.
22
+ useEffect(() => {
23
+ let cancelled = false;
24
+ markdownToHtml(markdown, { pagePath, sanitize: true }).then((result) => {
25
+ if (!cancelled) setHtml(result);
26
+ });
27
+ return () => {
28
+ cancelled = true;
29
+ };
30
+ }, [markdown, pagePath]);
31
+
32
+ // Delegated click handler: wiki-links navigate; external links get _blank.
33
+ const handleClick = useCallback(
34
+ (e: MouseEvent) => {
35
+ const link = (e.target as HTMLElement).closest(
36
+ "a",
37
+ ) as HTMLAnchorElement | null;
38
+ if (!link) return;
39
+
40
+ if (link.dataset.wikiLink === "true") {
41
+ e.preventDefault();
42
+ const slug = link.dataset.slug ?? "";
43
+ if (!slug) return;
44
+ const dir = useWikiSlugsStore.getState().getDir(slug);
45
+ if (!dir) return; // broken link - no nav
46
+ const targetPath =
47
+ dir === "root" ? `${slug}.md` : `${dir}/${slug}.md`;
48
+ const anchor = link.dataset.anchor ?? null;
49
+ onNavigate(targetPath, anchor);
50
+ return;
51
+ }
52
+
53
+ // External links: ensure _blank + noreferrer.
54
+ const href = link.getAttribute("href") ?? "";
55
+ if (/^https?:\/\//.test(href)) {
56
+ if (!link.target) {
57
+ link.setAttribute("target", "_blank");
58
+ link.setAttribute("rel", "noreferrer");
59
+ }
60
+ return;
61
+ }
62
+
63
+ // Anchor-only hrefs: let browser scroll natively.
64
+ },
65
+ [onNavigate],
66
+ );
67
+
68
+ // Attach and detach click handler whenever html or handler changes.
69
+ useEffect(() => {
70
+ const el = containerRef.current;
71
+ if (!el) return;
72
+ // Set _blank on external links at bind time so they work without JS.
73
+ el.querySelectorAll<HTMLAnchorElement>("a[href^='http']").forEach((a) => {
74
+ if (!a.target) {
75
+ a.setAttribute("target", "_blank");
76
+ a.setAttribute("rel", "noreferrer");
77
+ }
78
+ });
79
+ el.addEventListener("click", handleClick);
80
+ return () => el.removeEventListener("click", handleClick);
81
+ }, [html, handleClick]);
82
+
83
+ // Apply broken-link styling after render and whenever slug store updates.
84
+ const slugsLoadedAt = useWikiSlugsStore((s) => s.loadedAt);
85
+ useEffect(() => {
86
+ const el = containerRef.current;
87
+ if (!el) return;
88
+ const store = useWikiSlugsStore.getState();
89
+ el.querySelectorAll<HTMLAnchorElement>(
90
+ 'a[data-wiki-link="true"]',
91
+ ).forEach((link) => {
92
+ const slug = link.dataset.slug ?? "";
93
+ if (slug && !store.has(slug)) {
94
+ link.dataset.broken = "true";
95
+ } else {
96
+ delete link.dataset.broken;
97
+ }
98
+ });
99
+ }, [html, slugsLoadedAt]);
100
+
101
+ // Disable task-list checkboxes in read-only viewer.
102
+ useEffect(() => {
103
+ const el = containerRef.current;
104
+ if (!el) return;
105
+ el.querySelectorAll<HTMLInputElement>(
106
+ 'input[type="checkbox"]',
107
+ ).forEach((input) => {
108
+ input.disabled = true;
109
+ });
110
+ }, [html]);
111
+
112
+ return (
113
+ <div
114
+ ref={containerRef}
115
+ className="tiptap md-preview"
116
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is sanitized by rehype-sanitize
117
+ dangerouslySetInnerHTML={{ __html: html }}
118
+ />
119
+ );
120
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Email allowlist check.
3
+ *
4
+ * Source of truth is ~/.wiki-viewer/config.json (allowedEmails / allowedDomains),
5
+ * editable at runtime via the UI. Env vars AUTH_ALLOWED_EMAILS and
6
+ * AUTH_ALLOWED_DOMAIN act as a fallback seed when config is empty, so existing
7
+ * deployments keep working without a config file.
8
+ *
9
+ * If neither config nor env sets any restriction, all emails are allowed.
10
+ */
11
+ import { readConfig } from "@/lib/config";
12
+
13
+ function splitList(raw: string | undefined): string[] {
14
+ return (raw ?? "")
15
+ .split(",")
16
+ .map((s) => s.trim().toLowerCase())
17
+ .filter(Boolean);
18
+ }
19
+
20
+ /** Resolve the effective allowlist: config wins, env is the fallback. */
21
+ export async function getAllowlist(): Promise<{
22
+ emails: string[];
23
+ domains: string[];
24
+ }> {
25
+ const config = await readConfig();
26
+ const emails =
27
+ config.allowedEmails && config.allowedEmails.length > 0
28
+ ? config.allowedEmails.map((s) => s.trim().toLowerCase()).filter(Boolean)
29
+ : splitList(process.env.AUTH_ALLOWED_EMAILS);
30
+ const domains =
31
+ config.allowedDomains && config.allowedDomains.length > 0
32
+ ? config.allowedDomains.map((s) => s.trim().toLowerCase()).filter(Boolean)
33
+ : splitList(process.env.AUTH_ALLOWED_DOMAIN);
34
+ return { emails, domains };
35
+ }
36
+
37
+ export async function isEmailAllowed(email: string): Promise<boolean> {
38
+ const { emails, domains } = await getAllowlist();
39
+
40
+ // No restrictions set — allow all
41
+ if (emails.length === 0 && domains.length === 0) return true;
42
+
43
+ const e = email.toLowerCase();
44
+ if (emails.includes(e)) return true;
45
+
46
+ const at = e.lastIndexOf("@");
47
+ if (at >= 0 && domains.includes(e.slice(at + 1))) return true;
48
+
49
+ return false;
50
+ }
@@ -0,0 +1,4 @@
1
+ "use client";
2
+ import { createAuthClient } from "better-auth/react";
3
+
4
+ export const authClient = createAuthClient();
@@ -0,0 +1,68 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ /**
4
+ * Reject cross-origin state-changing requests that arrive with a session cookie.
5
+ * Bearer-only requests (no Origin/Referer) pass through — they are authenticated
6
+ * separately and are not vulnerable to CSRF.
7
+ *
8
+ * Returns null if the request may proceed, or a NextResponse(403) to return.
9
+ *
10
+ * Call at the top of every POST/PUT/DELETE/PATCH handler in /api/wiki/* and
11
+ * /api/system/* before requireUser.
12
+ */
13
+ export function checkOrigin(req: Request): NextResponse | null {
14
+ const method = req.method.toUpperCase();
15
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") return null;
16
+
17
+ // Bearer-auth agents typically omit Origin/Referer — let checkAuth handle them.
18
+ const origin = req.headers.get("origin");
19
+ const referer = req.headers.get("referer");
20
+ if (!origin && !referer) return null;
21
+
22
+ const allowed = buildAllowedOrigins(req.headers.get("host") ?? "");
23
+
24
+ if (origin) {
25
+ if (allowed.has(origin)) return null;
26
+ return NextResponse.json(
27
+ { error: "FORBIDDEN", message: "Bad origin" },
28
+ { status: 403 },
29
+ );
30
+ }
31
+
32
+ if (referer) {
33
+ try {
34
+ const { protocol, host } = new URL(referer);
35
+ if (allowed.has(`${protocol}//${host}`)) return null;
36
+ } catch {
37
+ // malformed Referer — reject
38
+ }
39
+ return NextResponse.json(
40
+ { error: "FORBIDDEN", message: "Bad referer" },
41
+ { status: 403 },
42
+ );
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ function buildAllowedOrigins(hostHeader: string): Set<string> {
49
+ const hostname = hostHeader.split(":")[0];
50
+ const extra = (process.env.WIKI_OWNER_HOSTS ?? "")
51
+ .split(",")
52
+ .map((h) => h.trim())
53
+ .filter(Boolean);
54
+
55
+ const hosts = Array.from(new Set(["localhost", "127.0.0.1", hostname, ...extra]));
56
+ const ports = ["", ":3000", ":3003"];
57
+ if (hostHeader.includes(":")) ports.push(":" + hostHeader.split(":")[1]);
58
+
59
+ const allowed = new Set<string>();
60
+ for (const h of hosts) {
61
+ for (const proto of ["http", "https"]) {
62
+ for (const port of ports) {
63
+ allowed.add(`${proto}://${h}${port}`);
64
+ }
65
+ }
66
+ }
67
+ return allowed;
68
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Better Auth server instance.
3
+ * Supports: email+password, Google OAuth (optional).
4
+ * DB: SQLite at ~/.wiki-viewer/auth.db (WAL mode).
5
+ */
6
+ import { betterAuth } from "better-auth";
7
+ import { nextCookies } from "better-auth/next-js";
8
+ import Database from "better-sqlite3";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
12
+ import { randomBytes } from "node:crypto";
13
+ import { isEmailAllowed } from "./allowlist";
14
+
15
+ // Guard runs at server startup, not during next build (NEXT_PHASE=phase-production-build).
16
+ // Set WIKI_ALLOW_INSECURE=1 to bypass the https requirement (development / CI / smoke tests).
17
+ if (
18
+ process.env.NODE_ENV === "production" &&
19
+ process.env.NEXT_PHASE !== "phase-production-build" &&
20
+ process.env.WIKI_ALLOW_INSECURE !== "1"
21
+ ) {
22
+ const url = process.env.BETTER_AUTH_URL ?? "";
23
+ if (!url) {
24
+ throw new Error(
25
+ "BETTER_AUTH_URL is required in production. Set it to https://your-domain so cookies and OAuth callbacks resolve correctly. Set WIKI_ALLOW_INSECURE=1 to bypass (development only).",
26
+ );
27
+ }
28
+ if (!url.startsWith("https://")) {
29
+ throw new Error(
30
+ `BETTER_AUTH_URL must be https:// in production (got: ${url}). Set WIKI_ALLOW_INSECURE=1 to bypass (development only).`,
31
+ );
32
+ }
33
+ }
34
+
35
+ const DATA_DIR = path.join(process.env.HOME ?? os.homedir(), ".wiki-viewer");
36
+ mkdirSync(DATA_DIR, { recursive: true });
37
+ const DB_PATH = path.join(DATA_DIR, "auth.db");
38
+ const SECRET_PATH = path.join(DATA_DIR, "auth.secret");
39
+
40
+ function resolveSecret(): string {
41
+ if (process.env.BETTER_AUTH_SECRET) return process.env.BETTER_AUTH_SECRET;
42
+ if (existsSync(SECRET_PATH)) {
43
+ return readFileSync(SECRET_PATH, "utf-8").trim();
44
+ }
45
+ const fresh = randomBytes(32).toString("base64");
46
+ writeFileSync(SECRET_PATH, fresh, { mode: 0o600 });
47
+ try {
48
+ chmodSync(SECRET_PATH, 0o600);
49
+ } catch {
50
+ // chmod best-effort on platforms that support it
51
+ }
52
+ return fresh;
53
+ }
54
+
55
+ const db = new Database(DB_PATH);
56
+ db.pragma("journal_mode = WAL");
57
+
58
+ function getTrustedOrigins(): string[] {
59
+ const extra = (process.env.WIKI_OWNER_HOSTS ?? "")
60
+ .split(",")
61
+ .map((h) => h.trim())
62
+ .filter(Boolean);
63
+ const ports = ["", ":3000", ":3003"];
64
+ const base = ["http://localhost:3000", "http://localhost:3003"];
65
+ const extras = extra.flatMap((h) =>
66
+ ports.flatMap((p) => [`http://${h}${p}`, `https://${h}${p}`]),
67
+ );
68
+ return Array.from(new Set([...base, ...extras]));
69
+ }
70
+
71
+ const socialProviders: Record<string, unknown> = {};
72
+ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
73
+ socialProviders.google = {
74
+ clientId: process.env.GOOGLE_CLIENT_ID,
75
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
76
+ };
77
+ }
78
+
79
+ export const auth = betterAuth({
80
+ database: db,
81
+ secret: resolveSecret(),
82
+ baseURL: process.env.BETTER_AUTH_URL,
83
+ emailAndPassword: {
84
+ enabled: true,
85
+ requireEmailVerification: false,
86
+ autoSignIn: true,
87
+ },
88
+ // Loosen Better Auth's default 3-per-10-seconds limit on /sign-in/email
89
+ // so a single bad password doesn't lock the user out for the window.
90
+ // Per-user lockout is not implemented (Better Auth rate-limits by IP).
91
+ rateLimit: {
92
+ enabled: process.env.NODE_ENV === "production",
93
+ window: 60,
94
+ max: 100,
95
+ customRules: {
96
+ "/sign-in/email": { window: 60, max: 20 },
97
+ "/sign-up/email": { window: 60, max: 10 },
98
+ },
99
+ },
100
+ socialProviders,
101
+ databaseHooks: {
102
+ user: {
103
+ create: {
104
+ before: async (user: { email: string }) => {
105
+ if (!(await isEmailAllowed(user.email))) {
106
+ throw new Error("SIGNUP_NOT_ALLOWED");
107
+ }
108
+ return { data: user };
109
+ },
110
+ },
111
+ },
112
+ },
113
+ plugins: [nextCookies()],
114
+ trustedOrigins: getTrustedOrigins(),
115
+ });
116
+
117
+ // Auto-migrate tables on first import. Idempotent: re-runs are no-ops once schema matches.
118
+ // Exposed as a Promise so consumers can await readiness before issuing auth calls.
119
+ const MIGRATED_PROMISE_KEY = Symbol.for("wiki-viewer.better-auth.migration-promise");
120
+ interface GlobalWithPromise {
121
+ [k: symbol]: Promise<void> | undefined;
122
+ }
123
+ const g = globalThis as unknown as GlobalWithPromise;
124
+ if (!g[MIGRATED_PROMISE_KEY]) {
125
+ g[MIGRATED_PROMISE_KEY] = (async () => {
126
+ const mod = (await import("better-auth/db/migration")) as {
127
+ getMigrations: (opts: unknown) => Promise<{ runMigrations: () => Promise<void> }>;
128
+ };
129
+ const { runMigrations } = await mod.getMigrations(
130
+ (auth as unknown as { options: unknown }).options,
131
+ );
132
+ await runMigrations();
133
+ })();
134
+ }
135
+
136
+ /** Await this in tests / one-shot scripts before calling auth.api.*. */
137
+ export function authReady(): Promise<void> {
138
+ return g[MIGRATED_PROMISE_KEY] ?? Promise.resolve();
139
+ }
140
+
141
+ export interface SessionUser {
142
+ id: string;
143
+ email: string;
144
+ name: string;
145
+ }
146
+
147
+ export async function getSessionFromRequest(req: Request) {
148
+ return auth.api.getSession({ headers: req.headers });
149
+ }
150
+
151
+ export async function requireUser(
152
+ req: Request,
153
+ ): Promise<{ ok: true; user: SessionUser } | { ok: false }> {
154
+ const session = await getSessionFromRequest(req);
155
+ if (!session?.user) return { ok: false };
156
+ return {
157
+ ok: true,
158
+ user: {
159
+ id: session.user.id,
160
+ email: session.user.email,
161
+ name: session.user.name,
162
+ },
163
+ };
164
+ }
@@ -5,6 +5,10 @@ import path from "node:path";
5
5
  interface WikiViewerConfig {
6
6
  pinnedPaths?: string[];
7
7
  lastOpenedPath?: string;
8
+ /** Email allowlist for signup. Empty/undefined = no email restriction. */
9
+ allowedEmails?: string[];
10
+ /** Domain allowlist for signup. Empty/undefined = no domain restriction. */
11
+ allowedDomains?: string[];
8
12
  }
9
13
 
10
14
  function configPath() {
@@ -41,6 +41,19 @@ export function parseFrontmatter(text: string): ParsedFrontmatter {
41
41
 
42
42
  const block = match[1];
43
43
  const body = text.slice(match[0].length);
44
+
45
+ // Guard against runaway capture. BLOCK_RE matches from the opening `---`
46
+ // to the FIRST `\n---\n`, which may be a markdown horizontal rule far down
47
+ // the document rather than a real closing fence. If the block does not look
48
+ // like YAML (a markdown heading or HTML on the first non-empty line, or a
49
+ // blank line gap typical of body content), bail out and treat the whole
50
+ // text as body so the document still renders.
51
+ const blockLines = block.split(/\r?\n/);
52
+ const firstContent = blockLines.find((l) => l.trim() !== "") ?? "";
53
+ if (/^\s*(#{1,6}\s|<|>|-\s|\*\s|\d+\.\s)/.test(firstContent)) {
54
+ return { data: {}, body: text };
55
+ }
56
+
44
57
  const data: Record<string, unknown> = {};
45
58
 
46
59
  const lines = block.split(/\r?\n/);
@@ -80,5 +93,12 @@ export function parseFrontmatter(text: string): ParsedFrontmatter {
80
93
  i += 1;
81
94
  }
82
95
 
96
+ // A valid frontmatter block always yields at least one key. Zero keys means
97
+ // BLOCK_RE consumed body content up to a stray `---` (e.g. a horizontal
98
+ // rule). Reject so the body is not swallowed.
99
+ if (Object.keys(data).length === 0) {
100
+ return { data: {}, body: text };
101
+ }
102
+
83
103
  return { data, body };
84
104
  }
@@ -0,0 +1,106 @@
1
+ import { defaultSchema } from "rehype-sanitize";
2
+ import type { Options as SanitizeOptions } from "rehype-sanitize";
3
+
4
+ /**
5
+ * Sanitize schema for the read-only markdown preview.
6
+ *
7
+ * The base `defaultSchema` from rehype-sanitize strips most attributes and
8
+ * many elements. Wiki content legitimately embeds raw HTML: tables, task-list
9
+ * checkboxes, and video/embed iframes produced by the shared pipeline.
10
+ * Extend the schema so that markup renders instead of being escaped to literal
11
+ * text, while still removing scripts, event handlers, and other dangerous
12
+ * vectors that defaultSchema blocks.
13
+ */
14
+ const tableTags = ["table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup", "col"];
15
+
16
+ export const previewSanitizeSchema: SanitizeOptions = {
17
+ ...defaultSchema,
18
+ tagNames: Array.from(
19
+ new Set([
20
+ ...(defaultSchema.tagNames ?? []),
21
+ ...tableTags,
22
+ // Task-list structure emitted by fixTaskListHtml.
23
+ "label",
24
+ // Embed wrapper divs and iframes from upgradeProviderVideos.
25
+ "iframe",
26
+ "video",
27
+ ]),
28
+ ),
29
+ attributes: {
30
+ ...defaultSchema.attributes,
31
+ // Allow layout attributes on table elements.
32
+ table: [...(defaultSchema.attributes?.table ?? []), "className", "style"],
33
+ th: [
34
+ ...(defaultSchema.attributes?.th ?? []),
35
+ "colSpan",
36
+ "rowSpan",
37
+ "style",
38
+ "className",
39
+ ],
40
+ td: [
41
+ ...(defaultSchema.attributes?.td ?? []),
42
+ "colSpan",
43
+ "rowSpan",
44
+ "style",
45
+ "className",
46
+ ],
47
+ col: ["span", "style", "className"],
48
+ colgroup: ["span", "style", "className"],
49
+ tr: ["style", "className"],
50
+ // Preserve wiki-link data attributes used by the delegated click handler.
51
+ // rehype-sanitize uses hast property names (camelCase) in the attributes map.
52
+ // Plain string = allow any value; tuple [name, val1, val2] = allow only those values.
53
+ a: [
54
+ ...(defaultSchema.attributes?.a ?? []),
55
+ "className",
56
+ ["dataWikiLink", "true"],
57
+ "dataSlug",
58
+ "dataAlias",
59
+ "dataAnchor",
60
+ ["dataBroken", "true"],
61
+ ["dataPdfLink", "true"],
62
+ ],
63
+ // Task-list input/label.
64
+ input: ["type", "checked", "disabled"],
65
+ label: ["className"],
66
+ // Embed iframes - allow all attrs used by upgradeProviderVideos.
67
+ iframe: [
68
+ "src",
69
+ "allow",
70
+ "allowFullScreen",
71
+ "frameBorder",
72
+ "loading",
73
+ "referrerPolicy",
74
+ "dataEmbedProvider",
75
+ ],
76
+ // Embed wrapper divs.
77
+ div: [
78
+ ...(defaultSchema.attributes?.div ?? []),
79
+ "className",
80
+ ["dataEmbed", "true"],
81
+ "dataProvider",
82
+ "dataSrc",
83
+ "dataOriginalUrl",
84
+ "dataAspectRatio",
85
+ ],
86
+ // Task-list list items.
87
+ li: [
88
+ ...(defaultSchema.attributes?.li ?? []),
89
+ "className",
90
+ "dataType",
91
+ "dataChecked",
92
+ ],
93
+ ul: [
94
+ ...(defaultSchema.attributes?.ul ?? []),
95
+ "className",
96
+ "dataType",
97
+ ],
98
+ // Allow class names + id on all elements for styling.
99
+ "*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "id"],
100
+ },
101
+ // Allow https iframe src (for embed providers). http excluded intentionally.
102
+ protocols: {
103
+ ...defaultSchema.protocols,
104
+ src: [...(defaultSchema.protocols?.src ?? []), "https"],
105
+ },
106
+ };
@@ -1,9 +1,13 @@
1
+ import rehypeParse from "rehype-parse";
2
+ import rehypeRaw from "rehype-raw";
3
+ import rehypeSanitize from "rehype-sanitize";
1
4
  import rehypeStringify from "rehype-stringify";
2
5
  import remarkGfm from "remark-gfm";
3
6
  import remarkParse from "remark-parse";
4
7
  import remarkRehype from "remark-rehype";
5
8
  import { unified } from "unified";
6
9
  import { detectEmbed } from "@/lib/embeds/detect";
10
+ import { previewSanitizeSchema } from "@/lib/markdown/sanitize-schema";
7
11
 
8
12
  // Canonical wiki-link regex (llm-wiki-pm ground truth).
9
13
  // Groups: 1=slug 2=alias 3=anchor
@@ -133,8 +137,11 @@ function resolveRelativeUrls(html: string, pagePath: string): string {
133
137
  }
134
138
 
135
139
  // Unified's plugin resolution + processor freeze runs on every `unified()`
136
- // call. Reuse a single frozen pipeline across every page render so
140
+ // call. Reuse single frozen pipelines across every page render so
137
141
  // navigation doesn't pay that cost on the hot path.
142
+
143
+ // Base pipeline: produces HTML with raw nodes passed through as-is.
144
+ // Used for both editor and (pre-sanitize) viewer passes.
138
145
  const processor = unified()
139
146
  .use(remarkParse)
140
147
  .use(remarkGfm)
@@ -142,26 +149,57 @@ const processor = unified()
142
149
  .use(rehypeStringify, { allowDangerousHtml: true })
143
150
  .freeze();
144
151
 
152
+ // Sanitize-only pipeline: takes a fully assembled HTML string, parses it back
153
+ // into hast (rehype-parse as a fragment), expands any raw nodes (rehype-raw),
154
+ // then strips unsafe nodes (rehype-sanitize). Runs LAST so all string
155
+ // post-processing is covered by sanitize.
156
+ const sanitizerOnly = unified()
157
+ .use(rehypeParse, { fragment: true })
158
+ .use(rehypeRaw)
159
+ .use(rehypeSanitize, previewSanitizeSchema)
160
+ .use(rehypeStringify)
161
+ .freeze();
162
+
163
+ export interface MarkdownToHtmlOptions {
164
+ /** File path used to resolve relative URLs (./image.png etc.). */
165
+ pagePath?: string;
166
+ /** Run rehype-sanitize on output. Use true for read-only viewer. Default false. */
167
+ sanitize?: boolean;
168
+ }
169
+
145
170
  export async function markdownToHtml(
146
171
  markdown: string,
147
- pagePath?: string,
172
+ optsOrPagePath?: string | MarkdownToHtmlOptions,
148
173
  ): Promise<string> {
174
+ const opts: MarkdownToHtmlOptions =
175
+ typeof optsOrPagePath === "string"
176
+ ? { pagePath: optsOrPagePath }
177
+ : (optsOrPagePath ?? {});
178
+
149
179
  // Pre-process wiki-links before remark (which would treat [[ as text)
150
180
  const preprocessed = convertWikiLinks(markdown);
151
181
 
182
+ // Always use the base pipeline first.
152
183
  const result = await processor.process(preprocessed);
153
-
154
184
  let html = String(result);
155
185
 
156
- // Post-process task lists for Tiptap compatibility
186
+ // Post-process task lists for Tiptap compatibility.
157
187
  html = fixTaskListHtml(html);
158
188
 
159
- // Heal <video src="youtube-url"> into real iframe embeds
189
+ // Heal <video src="youtube-url"> into real iframe embeds.
190
+ // Must run before sanitize so <video src> attr is still present.
160
191
  html = upgradeProviderVideos(html);
161
192
 
162
- // Resolve relative URLs if page path is provided
163
- if (pagePath) {
164
- html = resolveRelativeUrls(html, pagePath);
193
+ // Resolve relative URLs if page path is provided.
194
+ // Must run before sanitize so interpolated paths are covered.
195
+ if (opts.pagePath) {
196
+ html = resolveRelativeUrls(html, opts.pagePath);
197
+ }
198
+
199
+ // Sanitize last, after all string post-processing, so no injected
200
+ // content escapes the sanitizer.
201
+ if (opts.sanitize) {
202
+ html = String(await sanitizerOnly.process(html));
165
203
  }
166
204
 
167
205
  return html;
@@ -49,6 +49,20 @@ turndown.addRule("wikiLink", {
49
49
  },
50
50
  });
51
51
 
52
+ // Preserve <proof-span> provenance marks — must run before styledSpan so the
53
+ // custom tag is matched first. DOM uppercases custom tag names, so check PROOF-SPAN.
54
+ turndown.addRule("proofSpan", {
55
+ filter: (node) => node.nodeName === "PROOF-SPAN",
56
+ replacement: (content, node) => {
57
+ const el = node as HTMLElement;
58
+ const attrs: string[] = [];
59
+ for (const a of Array.from(el.attributes)) {
60
+ attrs.push(`${a.name}="${a.value.replace(/"/g, "&quot;")}"`);
61
+ }
62
+ return `<proof-span${attrs.length ? " " + attrs.join(" ") : ""}>${content}</proof-span>`;
63
+ },
64
+ });
65
+
52
66
  // Preserve inline styled spans (text color, background color, font weight, etc.)
53
67
  // so colors and highlights survive markdown roundtrip.
54
68
  turndown.addRule("styledSpan", {