wiki-viewer 1.0.0 → 1.0.1
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/.next/standalone/README.md +328 -99
- package/.next/standalone/agents/bootstrap-prompt.md +1 -0
- package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
- package/.next/standalone/bin/wiki-viewer.js +57 -4
- package/.next/standalone/docs/agent-collab-plan.md +1615 -0
- package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
- package/.next/standalone/package.json +19 -2
- package/.next/standalone/pnpm-lock.yaml +1368 -325
- package/.next/standalone/pnpm-workspace.yaml +7 -1
- package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
- package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
- package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
- package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
- package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
- package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
- package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
- package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
- package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
- package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
- package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
- package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
- package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
- package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
- package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
- package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
- package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
- package/.next/standalone/src/app/api/system/config/route.ts +5 -1
- package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
- package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
- package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/route.ts +10 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
- package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
- package/.next/standalone/src/app/globals.css +70 -0
- package/.next/standalone/src/app/page.tsx +461 -217
- package/.next/standalone/src/app/signin/page.tsx +217 -0
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
- package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
- package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
- package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
- package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
- package/.next/standalone/src/components/editor/editor.tsx +513 -10
- package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
- package/.next/standalone/src/components/editor/extensions.ts +2 -0
- package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
- package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
- package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
- package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
- package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
- package/.next/standalone/src/lib/auth/client.ts +4 -0
- package/.next/standalone/src/lib/auth/csrf.ts +68 -0
- package/.next/standalone/src/lib/auth/server.ts +164 -0
- package/.next/standalone/src/lib/config.ts +4 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
- package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
- package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
- package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
- package/.next/standalone/src/lib/proof/activity.ts +74 -0
- package/.next/standalone/src/lib/proof/auth.ts +132 -0
- package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
- package/.next/standalone/src/lib/proof/blocks.ts +73 -0
- package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
- package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
- package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
- package/.next/standalone/src/lib/proof/glob.ts +51 -0
- package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
- package/.next/standalone/src/lib/proof/mutex.ts +32 -0
- package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
- package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
- package/.next/standalone/src/lib/proof/pending.ts +97 -0
- package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
- package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/registry.ts +190 -0
- package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
- package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
- package/.next/standalone/src/lib/proof/types.ts +170 -0
- package/.next/standalone/src/lib/proof-config.ts +14 -0
- package/.next/standalone/src/middleware.ts +38 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
- package/.next/standalone/src/stores/editor-store.ts +115 -9
- package/.next/standalone/src/stores/proof-store.ts +123 -0
- package/.next/standalone/src/stores/view-width-store.ts +62 -0
- package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
- package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
- package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
- package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
- package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
- package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
- package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
- package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
- package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
- package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
- package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
- package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
- package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
- package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
- package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
- package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
- package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
- package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
- package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
- package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
- package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
- package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
- package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
- package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
- package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
- package/README.md +328 -99
- package/bin/wiki-viewer.js +57 -4
- package/package.json +19 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ProofEvent } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface ActivityEvent extends ProofEvent {
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const ACTIVITY_DEFAULT_LIMIT = 50;
|
|
8
|
+
export const ACTIVITY_MAX_LIMIT = 200;
|
|
9
|
+
|
|
10
|
+
/** Derive active connections from recent events (last 5 minutes). */
|
|
11
|
+
export function deriveConnections(
|
|
12
|
+
events: ActivityEvent[],
|
|
13
|
+
): { by: string; opCount: number; lastSeen: string }[] {
|
|
14
|
+
const cutoff = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
15
|
+
const map = new Map<string, { opCount: number; lastSeen: string }>();
|
|
16
|
+
for (const ev of events) {
|
|
17
|
+
if (ev.at < cutoff) continue;
|
|
18
|
+
const existing = map.get(ev.by);
|
|
19
|
+
if (!existing) {
|
|
20
|
+
map.set(ev.by, { opCount: 1, lastSeen: ev.at });
|
|
21
|
+
} else {
|
|
22
|
+
map.set(ev.by, {
|
|
23
|
+
opCount: existing.opCount + 1,
|
|
24
|
+
lastSeen: ev.at > existing.lastSeen ? ev.at : existing.lastSeen,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return Array.from(map.entries())
|
|
29
|
+
.map(([by, v]) => ({ by, ...v }))
|
|
30
|
+
.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
|
|
31
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { Sidecar } from "./types";
|
|
4
|
+
import type { ActivityEvent } from "./activity-shared";
|
|
5
|
+
import {
|
|
6
|
+
ACTIVITY_DEFAULT_LIMIT,
|
|
7
|
+
ACTIVITY_MAX_LIMIT,
|
|
8
|
+
deriveConnections,
|
|
9
|
+
} from "./activity-shared";
|
|
10
|
+
|
|
11
|
+
export type { ActivityEvent } from "./activity-shared";
|
|
12
|
+
export {
|
|
13
|
+
ACTIVITY_DEFAULT_LIMIT,
|
|
14
|
+
ACTIVITY_MAX_LIMIT,
|
|
15
|
+
deriveConnections,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Walk rootDir/.proof/, merge events from all sidecars, sort newest first. */
|
|
19
|
+
export async function aggregateActivity(
|
|
20
|
+
rootDir: string,
|
|
21
|
+
options?: { limit?: number; file?: string },
|
|
22
|
+
): Promise<ActivityEvent[]> {
|
|
23
|
+
const limit = Math.min(
|
|
24
|
+
options?.limit ?? ACTIVITY_DEFAULT_LIMIT,
|
|
25
|
+
ACTIVITY_MAX_LIMIT,
|
|
26
|
+
);
|
|
27
|
+
const fileFilter = options?.file ?? null;
|
|
28
|
+
|
|
29
|
+
const proofDir = path.join(rootDir, ".proof");
|
|
30
|
+
|
|
31
|
+
let allEvents: ActivityEvent[] = [];
|
|
32
|
+
|
|
33
|
+
async function walk(dir: string): Promise<void> {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const fullPath = path.join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
await walk(fullPath);
|
|
44
|
+
} else if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
45
|
+
let sc: Sidecar;
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readFile(fullPath, "utf-8");
|
|
48
|
+
sc = JSON.parse(raw) as Sidecar;
|
|
49
|
+
if (sc.schemaVersion !== 1) continue;
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (fileFilter && sc.path !== fileFilter) continue;
|
|
54
|
+
const events: ActivityEvent[] = (sc.events ?? []).map((ev) => ({
|
|
55
|
+
...ev,
|
|
56
|
+
path: sc.path,
|
|
57
|
+
}));
|
|
58
|
+
allEvents = allEvents.concat(events);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await walk(proofDir);
|
|
64
|
+
|
|
65
|
+
allEvents.sort((a, b) => {
|
|
66
|
+
if (a.at < b.at) return 1;
|
|
67
|
+
if (a.at > b.at) return -1;
|
|
68
|
+
return 0;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return allEvents.slice(0, limit);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent authentication.
|
|
3
|
+
*
|
|
4
|
+
* Accepts one of:
|
|
5
|
+
* 1. Better Auth session cookie (browser UI) — treated as a synthetic user agent
|
|
6
|
+
* 2. Bearer token + X-Agent-Id header (registered external agents)
|
|
7
|
+
*
|
|
8
|
+
* AGENT_BEARER_TOKEN env var is not used.
|
|
9
|
+
*/
|
|
10
|
+
import type { Agent } from "./registry";
|
|
11
|
+
import { lookupAgentByToken, updateLastSeen } from "./registry";
|
|
12
|
+
import { matchGlob } from "./glob";
|
|
13
|
+
import { getSessionFromRequest } from "@/lib/auth/server";
|
|
14
|
+
|
|
15
|
+
// One-time warning if legacy env var is set
|
|
16
|
+
if (process.env.AGENT_BEARER_TOKEN) {
|
|
17
|
+
console.warn(
|
|
18
|
+
"[wiki-viewer] AGENT_BEARER_TOKEN is no longer used; manage agents via the AI Panel.",
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CheckAuthResult =
|
|
23
|
+
| { ok: true; agent: Agent }
|
|
24
|
+
| { ok: false; code: string; message?: string };
|
|
25
|
+
|
|
26
|
+
export async function checkAuth(req: Request): Promise<CheckAuthResult> {
|
|
27
|
+
// 1. Try Better Auth session (browser same-origin requests)
|
|
28
|
+
const session = await getSessionFromRequest(req);
|
|
29
|
+
if (session?.user) {
|
|
30
|
+
const u = session.user;
|
|
31
|
+
return {
|
|
32
|
+
ok: true,
|
|
33
|
+
agent: {
|
|
34
|
+
id: `user:${u.id}`,
|
|
35
|
+
displayName: u.name,
|
|
36
|
+
tokenHash: "",
|
|
37
|
+
scope: { paths: ["**/*"], ops: ["read", "mutate"] },
|
|
38
|
+
createdAt: "",
|
|
39
|
+
lastSeen: "",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Try Bearer token (external agents)
|
|
45
|
+
const authHeader = req.headers.get("authorization") ?? "";
|
|
46
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
|
|
47
|
+
if (!token) {
|
|
48
|
+
return { ok: false, code: "UNAUTHORIZED", message: "No credentials" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const agent = await lookupAgentByToken(token);
|
|
52
|
+
if (!agent) {
|
|
53
|
+
return { ok: false, code: "UNAUTHORIZED", message: "Unknown or invalid token" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// X-Agent-Id MUST be present and MUST equal the agent id bound to this token.
|
|
57
|
+
const presentedId = req.headers.get("x-agent-id") ?? "";
|
|
58
|
+
if (!presentedId) {
|
|
59
|
+
return { ok: false, code: "UNAUTHORIZED", message: "X-Agent-Id header required" };
|
|
60
|
+
}
|
|
61
|
+
if (presentedId !== agent.id) {
|
|
62
|
+
return { ok: false, code: "UNAUTHORIZED", message: "X-Agent-Id does not match token owner" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
void updateLastSeen(agent.id);
|
|
66
|
+
|
|
67
|
+
return { ok: true, agent };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Scope enforcement ─────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export interface ScopeParams {
|
|
73
|
+
filePath?: string;
|
|
74
|
+
op: "read" | "mutate";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function enforceScope(
|
|
78
|
+
agent: Agent,
|
|
79
|
+
params: ScopeParams,
|
|
80
|
+
): { ok: true } | { ok: false; code: "FORBIDDEN"; message: string } {
|
|
81
|
+
const { scope } = agent;
|
|
82
|
+
|
|
83
|
+
if (!scope.ops.includes(params.op)) {
|
|
84
|
+
return { ok: false, code: "FORBIDDEN", message: `Agent scope does not allow "${params.op}"` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (params.filePath) {
|
|
88
|
+
const matched = scope.paths.some((pattern) => matchGlob(pattern, params.filePath!));
|
|
89
|
+
if (!matched) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: "FORBIDDEN",
|
|
93
|
+
message: `Agent scope does not cover path "${params.filePath}"`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { ok: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Verify the `by` field on a mutation matches the authenticated agent id. */
|
|
102
|
+
export function verifyBy(
|
|
103
|
+
agent: Agent,
|
|
104
|
+
by: string | undefined | null,
|
|
105
|
+
): { ok: true } | { ok: false; code: "FORBIDDEN"; message: string } {
|
|
106
|
+
// Browser/session users: accept user:*, "human", "owner" (back-compat)
|
|
107
|
+
if (agent.id.startsWith("user:")) {
|
|
108
|
+
if (!by || by === "owner" || by === "human" || by.startsWith("human:") || by === agent.id) {
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
code: "FORBIDDEN",
|
|
114
|
+
message: `User agent must use "human", "owner", or own id in "by" field`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// AI agents must match their registered id exactly
|
|
119
|
+
if (by !== agent.id) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
code: "FORBIDDEN",
|
|
123
|
+
message: `"by" field must equal agent id "${agent.id}"`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { ok: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @deprecated No longer relevant — always returns false */
|
|
130
|
+
export function hasTokenConfigured(): boolean {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { RootContent } from "mdast";
|
|
3
|
+
import type { Block, Sidecar } from "./types";
|
|
4
|
+
import { blockToMarkdown, blockType } from "./blocks";
|
|
5
|
+
|
|
6
|
+
function sha256hex(input: string): string {
|
|
7
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function textHash(markdown: string): string {
|
|
11
|
+
return sha256hex(markdown).slice(0, 12);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mintRef(markdown: string, usedRefs: Set<string>, position: number): string {
|
|
15
|
+
const base = "b" + sha256hex(markdown).slice(0, 6);
|
|
16
|
+
if (!usedRefs.has(base)) return base;
|
|
17
|
+
// Collision: try position suffix
|
|
18
|
+
const withPos = `${base}_${position}`;
|
|
19
|
+
if (!usedRefs.has(withPos)) return withPos;
|
|
20
|
+
// Fallback: increment counter
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (usedRefs.has(`${base}_${i}`)) i++;
|
|
23
|
+
return `${base}_${i}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Assign Block refs to a list of parsed mdast nodes.
|
|
28
|
+
* If a sidecar is provided, reuse existing refs for matching content hashes.
|
|
29
|
+
* Returns Block[] with stable refs and a new refMap.
|
|
30
|
+
*/
|
|
31
|
+
export function assignRefs(
|
|
32
|
+
nodes: RootContent[],
|
|
33
|
+
sidecar: Sidecar | null,
|
|
34
|
+
): { blocks: Block[]; newRefMap: Record<string, { textHash: string; lastSeenAt: string }> } {
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
const usedRefs = new Set<string>();
|
|
37
|
+
const newRefMap: Record<string, { textHash: string; lastSeenAt: string }> = {};
|
|
38
|
+
|
|
39
|
+
// Build a reverse map: textHash -> ref from existing sidecar
|
|
40
|
+
const hashToRef = new Map<string, string>();
|
|
41
|
+
if (sidecar) {
|
|
42
|
+
for (const [ref, entry] of Object.entries(sidecar.refMap)) {
|
|
43
|
+
// Only map each hash once (first wins, since refs may have been aliased)
|
|
44
|
+
if (!hashToRef.has(entry.textHash)) {
|
|
45
|
+
hashToRef.set(entry.textHash, ref);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const blocks: Block[] = nodes.map((node, i) => {
|
|
51
|
+
const md = blockToMarkdown(node);
|
|
52
|
+
const hash = textHash(md);
|
|
53
|
+
const { type, level, lang } = blockType(node);
|
|
54
|
+
|
|
55
|
+
let ref: string;
|
|
56
|
+
const existingRef = hashToRef.get(hash);
|
|
57
|
+
if (existingRef && !usedRefs.has(existingRef)) {
|
|
58
|
+
ref = existingRef;
|
|
59
|
+
} else {
|
|
60
|
+
ref = mintRef(md, usedRefs, i);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
usedRefs.add(ref);
|
|
64
|
+
newRefMap[ref] = { textHash: hash, lastSeenAt: now };
|
|
65
|
+
|
|
66
|
+
const block: Block = { ref, type, markdown: md };
|
|
67
|
+
if (level !== undefined) block.level = level;
|
|
68
|
+
if (lang !== undefined) block.lang = lang;
|
|
69
|
+
return block;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { blocks, newRefMap };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a ref against current block refs, falling back to aliases.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveRef(
|
|
79
|
+
sidecar: Sidecar,
|
|
80
|
+
ref: string,
|
|
81
|
+
currentRefs: Set<string>,
|
|
82
|
+
): string | null {
|
|
83
|
+
if (currentRefs.has(ref)) return ref;
|
|
84
|
+
const aliased = sidecar.refAliases[ref];
|
|
85
|
+
if (aliased && currentRefs.has(aliased)) return aliased;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* After applying ops: compute new refMap and collect aliases for changed blocks.
|
|
91
|
+
* oldRefMap: refMap before ops. newBlocks: blocks after ops.
|
|
92
|
+
* Returns { newRefMap, refAliases } — aliases map old ref -> new ref for any block
|
|
93
|
+
* that changed identity this mutation. Aliases are ONE-generation only.
|
|
94
|
+
*/
|
|
95
|
+
export function computeRefDelta(
|
|
96
|
+
oldRefMap: Record<string, { textHash: string; lastSeenAt: string }>,
|
|
97
|
+
oldHashToRef: Map<string, string>,
|
|
98
|
+
newBlocks: Block[],
|
|
99
|
+
): {
|
|
100
|
+
newRefMap: Record<string, { textHash: string; lastSeenAt: string }>;
|
|
101
|
+
refAliases: Record<string, string>;
|
|
102
|
+
} {
|
|
103
|
+
const now = new Date().toISOString();
|
|
104
|
+
const newRefMap: Record<string, { textHash: string; lastSeenAt: string }> = {};
|
|
105
|
+
const refAliases: Record<string, string> = {};
|
|
106
|
+
|
|
107
|
+
for (const block of newBlocks) {
|
|
108
|
+
const hash = textHash(block.markdown);
|
|
109
|
+
newRefMap[block.ref] = { textHash: hash, lastSeenAt: now };
|
|
110
|
+
|
|
111
|
+
// If this block's hash previously mapped to a different ref, record alias
|
|
112
|
+
const oldRef = oldHashToRef.get(hash);
|
|
113
|
+
if (oldRef && oldRef !== block.ref) {
|
|
114
|
+
refAliases[oldRef] = block.ref;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// For any old ref that's no longer present (was replaced/deleted), try to alias
|
|
119
|
+
// it if we can identify which new block replaced it by position or hash.
|
|
120
|
+
for (const [oldRef, entry] of Object.entries(oldRefMap)) {
|
|
121
|
+
if (!newRefMap[oldRef] && !refAliases[oldRef]) {
|
|
122
|
+
// Old ref gone, no content match found. Check if new block with same hash exists
|
|
123
|
+
const match = newBlocks.find((b) => b.ref !== oldRef && textHash(b.markdown) === entry.textHash);
|
|
124
|
+
if (match) {
|
|
125
|
+
refAliases[oldRef] = match.ref;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { newRefMap, refAliases };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { textHash };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { unified } from "unified";
|
|
2
|
+
import remarkParse from "remark-parse";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkStringify from "remark-stringify";
|
|
5
|
+
import type { Root, RootContent } from "mdast";
|
|
6
|
+
import type { BlockType } from "./types";
|
|
7
|
+
|
|
8
|
+
const PARSER = unified().use(remarkParse).use(remarkGfm);
|
|
9
|
+
const STRINGIFY = unified()
|
|
10
|
+
.use(remarkStringify, {
|
|
11
|
+
bullet: "-",
|
|
12
|
+
fence: "`",
|
|
13
|
+
fences: true,
|
|
14
|
+
listItemIndent: "one",
|
|
15
|
+
rule: "-",
|
|
16
|
+
emphasis: "*",
|
|
17
|
+
strong: "*",
|
|
18
|
+
})
|
|
19
|
+
.use(remarkGfm);
|
|
20
|
+
|
|
21
|
+
export function parseBlocks(markdown: string): RootContent[] {
|
|
22
|
+
const tree = PARSER.parse(markdown) as Root;
|
|
23
|
+
return tree.children;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function blockToMarkdown(node: RootContent): string {
|
|
27
|
+
const tree: Root = { type: "root", children: [node] };
|
|
28
|
+
return (STRINGIFY.stringify(tree) as string).replace(/\n+$/, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function blocksToMarkdown(nodes: RootContent[]): string {
|
|
32
|
+
if (nodes.length === 0) return "";
|
|
33
|
+
const tree: Root = { type: "root", children: nodes };
|
|
34
|
+
return (STRINGIFY.stringify(tree) as string).replace(/\n+$/, "") + "\n";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function blockType(node: RootContent): {
|
|
38
|
+
type: BlockType;
|
|
39
|
+
level?: number;
|
|
40
|
+
lang?: string;
|
|
41
|
+
} {
|
|
42
|
+
switch (node.type) {
|
|
43
|
+
case "heading":
|
|
44
|
+
return { type: "heading", level: (node as unknown as { depth: number }).depth };
|
|
45
|
+
case "paragraph":
|
|
46
|
+
return { type: "paragraph" };
|
|
47
|
+
case "list": {
|
|
48
|
+
const n = node as unknown as { ordered: boolean; children: Array<{ checked: boolean | null | undefined }> };
|
|
49
|
+
if (
|
|
50
|
+
n.children?.some(
|
|
51
|
+
(li) => li.checked !== null && li.checked !== undefined,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return { type: "taskList" };
|
|
55
|
+
return { type: n.ordered ? "orderedList" : "bulletList" };
|
|
56
|
+
}
|
|
57
|
+
case "blockquote":
|
|
58
|
+
return { type: "blockquote" };
|
|
59
|
+
case "code":
|
|
60
|
+
return {
|
|
61
|
+
type: "codeBlock",
|
|
62
|
+
lang: (node as unknown as { lang: string | null }).lang ?? undefined,
|
|
63
|
+
};
|
|
64
|
+
case "table":
|
|
65
|
+
return { type: "table" };
|
|
66
|
+
case "thematicBreak":
|
|
67
|
+
return { type: "hr" };
|
|
68
|
+
case "html":
|
|
69
|
+
return { type: "html" };
|
|
70
|
+
default:
|
|
71
|
+
return { type: "paragraph" }; // fallback
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side auth for same-origin UI requests.
|
|
3
|
+
*
|
|
4
|
+
* Authentication is now handled by the owner cookie (wv_owner) set via
|
|
5
|
+
* /api/owner/init. Same-origin fetch automatically attaches cookies, so no
|
|
6
|
+
* explicit Authorization header is needed from the browser UI.
|
|
7
|
+
*
|
|
8
|
+
* All fetch calls from the browser must use credentials: "same-origin" (the
|
|
9
|
+
* default) or credentials: "include".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @deprecated No longer returns a token. Owner cookie is used instead. */
|
|
13
|
+
export function getAgentToken(): string | null {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns empty object — authentication is via owner cookie automatically.
|
|
19
|
+
* Kept for backwards-compat with call sites.
|
|
20
|
+
*/
|
|
21
|
+
export function authHeaders(): HeadersInit {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ProofEvent, Sidecar } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Append one or more events to a working sidecar (in-memory mutation).
|
|
5
|
+
* Assigns monotonic IDs from sidecar.nextEventId.
|
|
6
|
+
*/
|
|
7
|
+
export function emitEvents(
|
|
8
|
+
sidecar: Sidecar,
|
|
9
|
+
partials: Array<Omit<ProofEvent, "id">>,
|
|
10
|
+
): ProofEvent[] {
|
|
11
|
+
const emitted: ProofEvent[] = [];
|
|
12
|
+
for (const partial of partials) {
|
|
13
|
+
const ev = { ...partial, id: sidecar.nextEventId++ } as ProofEvent;
|
|
14
|
+
sidecar.events.push(ev);
|
|
15
|
+
emitted.push(ev);
|
|
16
|
+
}
|
|
17
|
+
return emitted;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Poll events after a given ID, up to limit.
|
|
22
|
+
*/
|
|
23
|
+
export function pollEvents(
|
|
24
|
+
sidecar: Sidecar,
|
|
25
|
+
afterId: number,
|
|
26
|
+
limit: number,
|
|
27
|
+
): ProofEvent[] {
|
|
28
|
+
return sidecar.events.filter((e) => e.id > afterId).slice(0, limit);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Trim sidecar events to stay under TRIM_SIZE, preserving events newer than
|
|
33
|
+
* the oldest lastAck cursor to avoid stranding agents.
|
|
34
|
+
*/
|
|
35
|
+
export function trimEvents(sidecar: Sidecar, trimSize: number): void {
|
|
36
|
+
if (sidecar.events.length <= trimSize) return;
|
|
37
|
+
|
|
38
|
+
const ackValues = Object.values(sidecar.lastAck);
|
|
39
|
+
const oldestAck = ackValues.length > 0 ? Math.min(...ackValues) : Infinity;
|
|
40
|
+
|
|
41
|
+
// Events to keep: either within trimSize from the end, OR above the oldest ack
|
|
42
|
+
const keepFrom = sidecar.events.length - trimSize;
|
|
43
|
+
const trimmedEvents = sidecar.events.filter(
|
|
44
|
+
(e, i) => i >= keepFrom || e.id > oldestAck,
|
|
45
|
+
);
|
|
46
|
+
sidecar.events = trimmedEvents;
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process file locking via proper-lockfile.
|
|
3
|
+
*
|
|
4
|
+
* Uses a sentinel file in ~/.wiki-viewer/.locks/ keyed by a hash of the lock
|
|
5
|
+
* key so the actual data file need not exist before locking.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import lockfile from "proper-lockfile";
|
|
12
|
+
|
|
13
|
+
function locksDir(): string {
|
|
14
|
+
const home = process.env.HOME ?? os.homedir();
|
|
15
|
+
return path.join(home, ".wiki-viewer", ".locks");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sentinelPath(lockKey: string): string {
|
|
19
|
+
const hash = createHash("sha256").update(lockKey, "utf8").digest("hex").slice(0, 32);
|
|
20
|
+
return path.join(locksDir(), hash);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function withFileLock<T>(lockKey: string, fn: () => Promise<T>): Promise<T> {
|
|
24
|
+
const dir = locksDir();
|
|
25
|
+
await mkdir(dir, { recursive: true });
|
|
26
|
+
const sentinel = sentinelPath(lockKey);
|
|
27
|
+
// Create sentinel if absent (flag "a" = append/create, never truncate)
|
|
28
|
+
await writeFile(sentinel, "", { flag: "a" });
|
|
29
|
+
|
|
30
|
+
const release = await lockfile.lock(sentinel, {
|
|
31
|
+
retries: { retries: 10, factor: 1.5, minTimeout: 50, maxTimeout: 2_000 },
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
} finally {
|
|
36
|
+
await release();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal glob matcher.
|
|
3
|
+
*
|
|
4
|
+
* Supported patterns:
|
|
5
|
+
* ** — any sequence of characters including path separators
|
|
6
|
+
* * — any sequence of characters except path separators (/)
|
|
7
|
+
* ? — single character that is not /
|
|
8
|
+
* Literal characters — matched verbatim (case-sensitive)
|
|
9
|
+
*
|
|
10
|
+
* Patterns are anchored to the full string (implicit ^ and $).
|
|
11
|
+
*/
|
|
12
|
+
export function matchGlob(pattern: string, filePath: string): boolean {
|
|
13
|
+
const regex = globToRegex(pattern);
|
|
14
|
+
return regex.test(filePath);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function globToRegex(pattern: string): RegExp {
|
|
18
|
+
let regStr = "^";
|
|
19
|
+
let i = 0;
|
|
20
|
+
while (i < pattern.length) {
|
|
21
|
+
if (pattern[i] === "*" && pattern[i + 1] === "*") {
|
|
22
|
+
// ** — match anything including slashes
|
|
23
|
+
regStr += ".*";
|
|
24
|
+
i += 2;
|
|
25
|
+
// Skip a trailing slash after ** to avoid double-matching: **/foo matches foo too
|
|
26
|
+
if (pattern[i] === "/") {
|
|
27
|
+
regStr += "(?:.+/)?";
|
|
28
|
+
i++;
|
|
29
|
+
// roll back the .* so we match just /**/ properly
|
|
30
|
+
// Actually: keep simple — .* already handles it
|
|
31
|
+
}
|
|
32
|
+
} else if (pattern[i] === "*") {
|
|
33
|
+
// * — match non-slash characters
|
|
34
|
+
regStr += "[^/]*";
|
|
35
|
+
i++;
|
|
36
|
+
} else if (pattern[i] === "?") {
|
|
37
|
+
regStr += "[^/]";
|
|
38
|
+
i++;
|
|
39
|
+
} else {
|
|
40
|
+
// Escape regex special chars
|
|
41
|
+
regStr += escapeRegex(pattern[i]!);
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
regStr += "$";
|
|
46
|
+
return new RegExp(regStr);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function escapeRegex(c: string): string {
|
|
50
|
+
return c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
51
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Tiny in-memory LRU. Lives for process lifetime. Acceptable: idempotency
|
|
2
|
+
// guards retries within seconds, not days.
|
|
3
|
+
const MAX = 1000;
|
|
4
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
interface Entry {
|
|
7
|
+
payloadHash: string;
|
|
8
|
+
status: number;
|
|
9
|
+
body: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const store = new Map<string, Entry>();
|
|
14
|
+
|
|
15
|
+
export const idempotency = {
|
|
16
|
+
get(key: string): Entry | null {
|
|
17
|
+
const e = store.get(key);
|
|
18
|
+
if (!e) return null;
|
|
19
|
+
if (e.expiresAt < Date.now()) {
|
|
20
|
+
store.delete(key);
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return e;
|
|
24
|
+
},
|
|
25
|
+
set(key: string, value: Omit<Entry, "expiresAt">): void {
|
|
26
|
+
if (store.size >= MAX) {
|
|
27
|
+
const first = store.keys().next().value;
|
|
28
|
+
if (first !== undefined) store.delete(first);
|
|
29
|
+
}
|
|
30
|
+
store.set(key, { ...value, expiresAt: Date.now() + TTL_MS });
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { withFileLock } from "./file-lock";
|
|
2
|
+
|
|
3
|
+
const locks = new Map<string, Promise<void>>();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Acquire an in-process mutex (for same-process serialisation) and then a
|
|
7
|
+
* cross-process file lock (for multi-replica safety).
|
|
8
|
+
*
|
|
9
|
+
* The in-process mutex is held for the outer scope so that same-process
|
|
10
|
+
* callers never race for the file lock. The file lock is acquired inside so
|
|
11
|
+
* that different processes can safely share the same data files.
|
|
12
|
+
*/
|
|
13
|
+
export async function withFileMutex<T>(
|
|
14
|
+
filePath: string,
|
|
15
|
+
fn: () => Promise<T>,
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
while (locks.has(filePath)) {
|
|
18
|
+
await locks.get(filePath);
|
|
19
|
+
}
|
|
20
|
+
let release!: () => void;
|
|
21
|
+
const p = new Promise<void>((r) => {
|
|
22
|
+
release = r;
|
|
23
|
+
});
|
|
24
|
+
locks.set(filePath, p);
|
|
25
|
+
try {
|
|
26
|
+
// Cross-process file lock wraps the inner fn.
|
|
27
|
+
return await withFileLock(filePath, fn);
|
|
28
|
+
} finally {
|
|
29
|
+
locks.delete(filePath);
|
|
30
|
+
release();
|
|
31
|
+
}
|
|
32
|
+
}
|