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.
- package/.next/standalone/README.md +365 -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 +431 -33
- 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 +365 -99
- package/bin/wiki-viewer.js +431 -33
- package/package.json +19 -2
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory store for pending agent registration requests.
|
|
3
|
+
*
|
|
4
|
+
* Cleared on server restart. The registrationId itself acts as the
|
|
5
|
+
* agent's secret for polling (no separate PIN needed).
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import type { AgentScope } from "./registry";
|
|
9
|
+
|
|
10
|
+
export type RegistrationStatus =
|
|
11
|
+
| "pending"
|
|
12
|
+
| "approved" // token has been minted but not yet picked up
|
|
13
|
+
| "consumed" // token was picked up (one-shot)
|
|
14
|
+
| "denied";
|
|
15
|
+
|
|
16
|
+
export interface PendingRegistration {
|
|
17
|
+
registrationId: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
displayName: string;
|
|
20
|
+
requestedScope: AgentScope;
|
|
21
|
+
requestedAt: string; // ISO-8601
|
|
22
|
+
status: RegistrationStatus;
|
|
23
|
+
/** Plaintext token, present only after approval and before pickup. */
|
|
24
|
+
tokenPlaintext?: string;
|
|
25
|
+
/** ISO-8601 timestamp of approval/denial */
|
|
26
|
+
resolvedAt?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Module-level singleton (survives Next.js HMR via globalThis)
|
|
30
|
+
const g = globalThis as typeof globalThis & {
|
|
31
|
+
__wvPendingRegs?: Map<string, PendingRegistration>;
|
|
32
|
+
};
|
|
33
|
+
if (!g.__wvPendingRegs) {
|
|
34
|
+
g.__wvPendingRegs = new Map();
|
|
35
|
+
}
|
|
36
|
+
const store: Map<string, PendingRegistration> = g.__wvPendingRegs;
|
|
37
|
+
|
|
38
|
+
export function createRegistration(opts: {
|
|
39
|
+
agentId: string;
|
|
40
|
+
displayName: string;
|
|
41
|
+
requestedScope?: AgentScope;
|
|
42
|
+
}): PendingRegistration {
|
|
43
|
+
const registrationId = `reg_${randomBytes(16).toString("hex")}`;
|
|
44
|
+
const reg: PendingRegistration = {
|
|
45
|
+
registrationId,
|
|
46
|
+
agentId: opts.agentId,
|
|
47
|
+
displayName: opts.displayName,
|
|
48
|
+
requestedScope: opts.requestedScope ?? {
|
|
49
|
+
paths: ["**/*"],
|
|
50
|
+
ops: ["read", "mutate"],
|
|
51
|
+
},
|
|
52
|
+
requestedAt: new Date().toISOString(),
|
|
53
|
+
status: "pending",
|
|
54
|
+
};
|
|
55
|
+
store.set(registrationId, reg);
|
|
56
|
+
return reg;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getRegistration(id: string): PendingRegistration | undefined {
|
|
60
|
+
return store.get(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listPendingRegistrations(): PendingRegistration[] {
|
|
64
|
+
return [...store.values()].filter((r) => r.status === "pending");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listAllRegistrations(): PendingRegistration[] {
|
|
68
|
+
return [...store.values()];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Mark as approved and stash one-shot token. */
|
|
72
|
+
export function approveRegistration(id: string, tokenPlaintext: string): boolean {
|
|
73
|
+
const reg = store.get(id);
|
|
74
|
+
if (!reg || reg.status !== "pending") return false;
|
|
75
|
+
reg.status = "approved";
|
|
76
|
+
reg.tokenPlaintext = tokenPlaintext;
|
|
77
|
+
reg.resolvedAt = new Date().toISOString();
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Consume (pick up) the one-shot token. Returns plaintext then clears it. */
|
|
82
|
+
export function consumeRegistration(id: string): string | null {
|
|
83
|
+
const reg = store.get(id);
|
|
84
|
+
if (!reg || reg.status !== "approved" || !reg.tokenPlaintext) return null;
|
|
85
|
+
const token = reg.tokenPlaintext;
|
|
86
|
+
reg.status = "consumed";
|
|
87
|
+
reg.tokenPlaintext = undefined;
|
|
88
|
+
return token;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function denyRegistration(id: string): boolean {
|
|
92
|
+
const reg = store.get(id);
|
|
93
|
+
if (!reg || reg.status !== "pending") return false;
|
|
94
|
+
reg.status = "denied";
|
|
95
|
+
reg.resolvedAt = new Date().toISOString();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { SpanAttrs } from "./types";
|
|
2
|
+
|
|
3
|
+
export type { SpanAttrs };
|
|
4
|
+
|
|
5
|
+
function escapeAttr(value: string): string {
|
|
6
|
+
return value.replace(/&/g, "&").replace(/"/g, """);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildAttrs(attrs: SpanAttrs): string {
|
|
10
|
+
const parts: string[] = [
|
|
11
|
+
`id="${escapeAttr(attrs.spanId)}"`,
|
|
12
|
+
`origin="${attrs.origin}"`,
|
|
13
|
+
];
|
|
14
|
+
if (attrs.basis) parts.push(`basis="${escapeAttr(attrs.basis)}"`);
|
|
15
|
+
if (attrs.basisDetail) parts.push(`basis-detail="${escapeAttr(attrs.basisDetail)}"`);
|
|
16
|
+
parts.push(`by="${escapeAttr(attrs.by)}"`);
|
|
17
|
+
parts.push(`at="${escapeAttr(attrs.at)}"`);
|
|
18
|
+
if (attrs.inResponseTo) parts.push(`in-response-to="${escapeAttr(attrs.inResponseTo)}"`);
|
|
19
|
+
return parts.join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wrap the text content of a markdown block in a <proof-span>.
|
|
24
|
+
*
|
|
25
|
+
* Wrap rules:
|
|
26
|
+
* paragraph -> wrap full markdown text
|
|
27
|
+
* heading -> wrap text after the leading "#"s
|
|
28
|
+
* bulletList / orderedList / taskList -> wrap each list item's text line
|
|
29
|
+
* blockquote -> wrap text content (after "> " prefix)
|
|
30
|
+
* codeBlock / table / hr / html -> return null (caller records in sidecar.blockProvenance)
|
|
31
|
+
*/
|
|
32
|
+
export function wrapAsProofSpan(
|
|
33
|
+
markdown: string,
|
|
34
|
+
attrs: SpanAttrs,
|
|
35
|
+
): string | null {
|
|
36
|
+
const a = buildAttrs(attrs);
|
|
37
|
+
|
|
38
|
+
// Detect block type from markdown shape
|
|
39
|
+
const trimmed = markdown.trim();
|
|
40
|
+
|
|
41
|
+
// code block
|
|
42
|
+
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) return null;
|
|
43
|
+
|
|
44
|
+
// html block
|
|
45
|
+
if (trimmed.startsWith("<") && !trimmed.startsWith("<proof-span")) return null;
|
|
46
|
+
|
|
47
|
+
// hr
|
|
48
|
+
if (/^[-*_]{3,}\s*$/.test(trimmed)) return null;
|
|
49
|
+
|
|
50
|
+
// table (contains | pipes in first line)
|
|
51
|
+
const firstLine = trimmed.split("\n")[0];
|
|
52
|
+
if (firstLine.includes("|") && /^\|.*\|/.test(firstLine)) return null;
|
|
53
|
+
|
|
54
|
+
// heading: "# text" -> "# <proof-span ...>text</proof-span>"
|
|
55
|
+
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
|
56
|
+
if (headingMatch) {
|
|
57
|
+
const hashes = headingMatch[1];
|
|
58
|
+
const text = headingMatch[2].trimEnd();
|
|
59
|
+
return `${hashes} <proof-span ${a}>${text}</proof-span>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// blockquote: each line starts with "> "
|
|
63
|
+
if (trimmed.startsWith(">")) {
|
|
64
|
+
return trimmed
|
|
65
|
+
.split("\n")
|
|
66
|
+
.map((line) => {
|
|
67
|
+
const bqMatch = line.match(/^(>\s?)(.*)/);
|
|
68
|
+
if (!bqMatch) return line;
|
|
69
|
+
const prefix = bqMatch[1];
|
|
70
|
+
const content = bqMatch[2];
|
|
71
|
+
if (!content.trim()) return line;
|
|
72
|
+
return `${prefix}<proof-span ${a}>${content}</proof-span>`;
|
|
73
|
+
})
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// list items (bullet/ordered/task)
|
|
78
|
+
// Each item starts with "- ", "* ", "+ ", or "N. "
|
|
79
|
+
// Task items: "- [ ] " or "- [x] "
|
|
80
|
+
const listItemRe = /^(\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)/;
|
|
81
|
+
if (listItemRe.test(trimmed.split("\n")[0])) {
|
|
82
|
+
return trimmed
|
|
83
|
+
.split("\n")
|
|
84
|
+
.map((line) => {
|
|
85
|
+
const m = line.match(/^(\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?)(.*)/);
|
|
86
|
+
if (!m) return line;
|
|
87
|
+
const prefix = m[1];
|
|
88
|
+
const content = m[2];
|
|
89
|
+
if (!content.trim()) return line;
|
|
90
|
+
return `${prefix}<proof-span ${a}>${content}</proof-span>`;
|
|
91
|
+
})
|
|
92
|
+
.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// paragraph: wrap the whole thing
|
|
96
|
+
return `<proof-span ${a}>${trimmed}</proof-span>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove all <proof-span ...>...</proof-span> wrappers, keeping inner content.
|
|
101
|
+
* Used for "Accept" (keep text, drop attribution).
|
|
102
|
+
*/
|
|
103
|
+
export function unwrapProofSpans(markdown: string): string {
|
|
104
|
+
return markdown.replace(/<proof-span\b[^>]*>([\s\S]*?)<\/proof-span>/g, "$1");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove a specific <proof-span id="spanId">...</proof-span>, discarding content.
|
|
109
|
+
* Used for "Revert" (delete AI-authored text).
|
|
110
|
+
*/
|
|
111
|
+
export function revertProofSpan(markdown: string, spanId: string): string {
|
|
112
|
+
const escapedId = spanId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
113
|
+
const re = new RegExp(
|
|
114
|
+
`<proof-span\\b[^>]*\\bid="${escapedId}"[^>]*>[\\s\\S]*?<\\/proof-span>`,
|
|
115
|
+
"g",
|
|
116
|
+
);
|
|
117
|
+
return markdown.replace(re, "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract all span IDs from a markdown string.
|
|
122
|
+
*/
|
|
123
|
+
export function extractSpanIds(markdown: string): string[] {
|
|
124
|
+
const ids: string[] = [];
|
|
125
|
+
const re = /<proof-span\b[^>]*\bid="([^"]+)"/g;
|
|
126
|
+
let m: RegExpExecArray | null;
|
|
127
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
128
|
+
ids.push(m[1]);
|
|
129
|
+
}
|
|
130
|
+
return ids;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Generate a unique span ID "p" + 4-hex.
|
|
135
|
+
*/
|
|
136
|
+
export function newSpanId(): string {
|
|
137
|
+
const hex = Math.floor(Math.random() * 0xffff)
|
|
138
|
+
.toString(16)
|
|
139
|
+
.padStart(4, "0");
|
|
140
|
+
return `p${hex}`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { OPS_PER_MINUTE } from "../proof-config";
|
|
2
|
+
|
|
3
|
+
interface Bucket {
|
|
4
|
+
tokens: number;
|
|
5
|
+
lastRefill: number; // ms epoch
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const buckets = new Map<string, Bucket>();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Token-bucket rate limiter per `by` identity.
|
|
12
|
+
* Bucket size = OPS_PER_MINUTE (default 60).
|
|
13
|
+
* Refill rate: 1 token/sec (continuous approximation).
|
|
14
|
+
*
|
|
15
|
+
* Returns { ok: true } when tokens consumed, or
|
|
16
|
+
* { ok: false, retryAfterMs } when exhausted.
|
|
17
|
+
*/
|
|
18
|
+
export function checkAndConsume(
|
|
19
|
+
by: string,
|
|
20
|
+
n: number = 1,
|
|
21
|
+
): { ok: true } | { ok: false; retryAfterMs: number } {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const bucketSize = OPS_PER_MINUTE;
|
|
24
|
+
|
|
25
|
+
let bucket = buckets.get(by);
|
|
26
|
+
if (!bucket) {
|
|
27
|
+
bucket = { tokens: bucketSize, lastRefill: now };
|
|
28
|
+
buckets.set(by, bucket);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Refill: 1 token per 1000 ms elapsed
|
|
32
|
+
const elapsed = now - bucket.lastRefill;
|
|
33
|
+
const refill = Math.floor(elapsed / 1000);
|
|
34
|
+
if (refill > 0) {
|
|
35
|
+
bucket.tokens = Math.min(bucketSize, bucket.tokens + refill);
|
|
36
|
+
bucket.lastRefill = bucket.lastRefill + refill * 1000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (bucket.tokens < n) {
|
|
40
|
+
// How long until we have n tokens?
|
|
41
|
+
const needed = n - bucket.tokens;
|
|
42
|
+
const retryAfterMs = needed * 1000 - (now - bucket.lastRefill);
|
|
43
|
+
return { ok: false, retryAfterMs: Math.max(1, retryAfterMs) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
bucket.tokens -= n;
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Exposed for tests only — reset all buckets. */
|
|
51
|
+
export function _resetBuckets(): void {
|
|
52
|
+
buckets.clear();
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory token bucket rate limiter for registration attempts.
|
|
3
|
+
*
|
|
4
|
+
* Bucket size: 10 requests
|
|
5
|
+
* Refill rate: 1 token per 6 seconds (= 10/min)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface Bucket {
|
|
9
|
+
tokens: number;
|
|
10
|
+
lastRefillAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BUCKET_CAPACITY = 10;
|
|
14
|
+
const REFILL_INTERVAL_MS = 6_000; // 1 token per 6s = 10/min
|
|
15
|
+
|
|
16
|
+
// Module-level singleton keyed by IP (or "__global__" if no IP available)
|
|
17
|
+
const g = globalThis as typeof globalThis & {
|
|
18
|
+
__wvRegBuckets?: Map<string, Bucket>;
|
|
19
|
+
};
|
|
20
|
+
if (!g.__wvRegBuckets) {
|
|
21
|
+
g.__wvRegBuckets = new Map();
|
|
22
|
+
}
|
|
23
|
+
const buckets: Map<string, Bucket> = g.__wvRegBuckets;
|
|
24
|
+
|
|
25
|
+
function refill(bucket: Bucket): void {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const elapsed = now - bucket.lastRefillAt;
|
|
28
|
+
const tokensToAdd = Math.floor(elapsed / REFILL_INTERVAL_MS);
|
|
29
|
+
if (tokensToAdd > 0) {
|
|
30
|
+
bucket.tokens = Math.min(BUCKET_CAPACITY, bucket.tokens + tokensToAdd);
|
|
31
|
+
bucket.lastRefillAt = now;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Returns true if request is allowed; false if rate limited. */
|
|
36
|
+
export function checkRegisterRateLimit(key: string): boolean {
|
|
37
|
+
let bucket = buckets.get(key);
|
|
38
|
+
if (!bucket) {
|
|
39
|
+
bucket = { tokens: BUCKET_CAPACITY, lastRefillAt: Date.now() };
|
|
40
|
+
buckets.set(key, bucket);
|
|
41
|
+
}
|
|
42
|
+
refill(bucket);
|
|
43
|
+
if (bucket.tokens > 0) {
|
|
44
|
+
bucket.tokens--;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reset all buckets — for tests only. */
|
|
51
|
+
export function _resetRegisterBuckets(): void {
|
|
52
|
+
buckets.clear();
|
|
53
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent registry.
|
|
3
|
+
*
|
|
4
|
+
* Persists to ~/.wiki-viewer/agents.json (agent records).
|
|
5
|
+
* Human authentication is handled by Better Auth (see src/lib/auth/server.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { withFileMutex } from "./mutex";
|
|
12
|
+
|
|
13
|
+
// Sentinel key for all registry write operations
|
|
14
|
+
const REGISTRY_MUTEX_KEY = "__registry__";
|
|
15
|
+
|
|
16
|
+
// Throttle updateLastSeen: skip if updated < 30s ago
|
|
17
|
+
const lastSeenWriteAt: Map<string, number> = new Map();
|
|
18
|
+
const LAST_SEEN_THROTTLE_MS = 30_000;
|
|
19
|
+
|
|
20
|
+
export interface AgentScope {
|
|
21
|
+
paths: string[]; // glob patterns
|
|
22
|
+
ops: Array<"read" | "mutate">;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Agent {
|
|
26
|
+
id: string;
|
|
27
|
+
displayName: string;
|
|
28
|
+
tokenHash: string; // sha256 hex of raw token
|
|
29
|
+
scope: AgentScope;
|
|
30
|
+
createdAt: string; // ISO-8601
|
|
31
|
+
lastSeen: string; // ISO-8601
|
|
32
|
+
/** Better Auth user.id of the owner who approved this agent. Undefined for legacy entries. */
|
|
33
|
+
ownerUserId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Registry {
|
|
37
|
+
version: 1;
|
|
38
|
+
agents: Agent[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function wikiViewerDir(): string {
|
|
44
|
+
// Prefer HOME env var so tests can override by setting process.env.HOME.
|
|
45
|
+
const home = process.env.HOME ?? os.homedir();
|
|
46
|
+
return path.join(home, ".wiki-viewer");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function agentsJsonPath(): string {
|
|
50
|
+
return path.join(wikiViewerDir(), "agents.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
// ── Hashing ───────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export function hashToken(token: string): string {
|
|
57
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function timingSafeEqualHex(a: string, b: string): boolean {
|
|
61
|
+
// Pad to equal length before comparing to avoid length-based timing leak
|
|
62
|
+
const bufA = Buffer.from(a.padEnd(64, "0"), "utf8");
|
|
63
|
+
const bufB = Buffer.from(b.padEnd(64, "0"), "utf8");
|
|
64
|
+
if (bufA.length !== bufB.length) return false;
|
|
65
|
+
return timingSafeEqual(bufA, bufB) && a.length === b.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Read / Write ──────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export async function readRegistry(): Promise<Registry | null> {
|
|
71
|
+
try {
|
|
72
|
+
const raw = await readFile(agentsJsonPath(), "utf8");
|
|
73
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
74
|
+
// Migrate: silently drop legacy `owner` field if present
|
|
75
|
+
return {
|
|
76
|
+
version: 1,
|
|
77
|
+
agents: Array.isArray(parsed.agents) ? (parsed.agents as Agent[]) : [],
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Inner write — caller must already hold REGISTRY_MUTEX_KEY or be in single-writer context. */
|
|
85
|
+
async function _writeRegistryUnsafe(r: Registry): Promise<void> {
|
|
86
|
+
await mkdir(wikiViewerDir(), { recursive: true });
|
|
87
|
+
const tmp = agentsJsonPath() + ".tmp";
|
|
88
|
+
await writeFile(tmp, JSON.stringify(r, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
89
|
+
try {
|
|
90
|
+
await chmod(tmp, 0o600);
|
|
91
|
+
} catch {
|
|
92
|
+
// Non-fatal on Windows/some environments
|
|
93
|
+
}
|
|
94
|
+
await rename(tmp, agentsJsonPath());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function writeRegistry(r: Registry): Promise<void> {
|
|
98
|
+
await withFileMutex(REGISTRY_MUTEX_KEY, () => _writeRegistryUnsafe(r));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Ensure registry (creates if missing) ─────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Public: creates registry if missing. Acquires the mutex itself.
|
|
105
|
+
* Do NOT call from inside a mutex-held context.
|
|
106
|
+
*/
|
|
107
|
+
export async function ensureRegistry(): Promise<Registry> {
|
|
108
|
+
const existing = await readRegistry();
|
|
109
|
+
if (existing) return existing;
|
|
110
|
+
|
|
111
|
+
const r: Registry = { version: 1, agents: [] };
|
|
112
|
+
await writeRegistry(r); // acquires lock
|
|
113
|
+
return r;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Inner ensure — for use within a locked context.
|
|
118
|
+
* Creates registry if missing using unlocked write.
|
|
119
|
+
*/
|
|
120
|
+
async function _ensureRegistryUnsafe(): Promise<Registry> {
|
|
121
|
+
const existing = await readRegistry();
|
|
122
|
+
if (existing) return existing;
|
|
123
|
+
|
|
124
|
+
const r: Registry = { version: 1, agents: [] };
|
|
125
|
+
await _writeRegistryUnsafe(r);
|
|
126
|
+
return r;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Agent lookups ─────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export async function lookupAgentByToken(token: string): Promise<Agent | null> {
|
|
132
|
+
const r = await readRegistry();
|
|
133
|
+
if (!r) return null;
|
|
134
|
+
const candidate = hashToken(token);
|
|
135
|
+
for (const agent of r.agents) {
|
|
136
|
+
if (timingSafeEqualHex(agent.tokenHash, candidate)) {
|
|
137
|
+
return agent;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function lookupAgentById(id: string): Promise<Agent | null> {
|
|
144
|
+
const r = await readRegistry();
|
|
145
|
+
if (!r) return null;
|
|
146
|
+
return r.agents.find((a) => a.id === id) ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function addAgent(agent: Agent): Promise<void> {
|
|
150
|
+
await withFileMutex(REGISTRY_MUTEX_KEY, async () => {
|
|
151
|
+
const r = await _ensureRegistryUnsafe(); // no re-entrant lock
|
|
152
|
+
// Replace if id already present
|
|
153
|
+
const idx = r.agents.findIndex((a) => a.id === agent.id);
|
|
154
|
+
if (idx >= 0) {
|
|
155
|
+
r.agents[idx] = agent;
|
|
156
|
+
} else {
|
|
157
|
+
r.agents.push(agent);
|
|
158
|
+
}
|
|
159
|
+
await _writeRegistryUnsafe(r);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function removeAgent(id: string): Promise<boolean> {
|
|
164
|
+
return withFileMutex(REGISTRY_MUTEX_KEY, async () => {
|
|
165
|
+
const r = await readRegistry();
|
|
166
|
+
if (!r) return false;
|
|
167
|
+
const before = r.agents.length;
|
|
168
|
+
r.agents = r.agents.filter((a) => a.id !== id);
|
|
169
|
+
if (r.agents.length === before) return false;
|
|
170
|
+
await _writeRegistryUnsafe(r);
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function updateLastSeen(id: string): Promise<void> {
|
|
176
|
+
// Throttle: skip if updated recently
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const last = lastSeenWriteAt.get(id) ?? 0;
|
|
179
|
+
if (now - last < LAST_SEEN_THROTTLE_MS) return;
|
|
180
|
+
|
|
181
|
+
await withFileMutex(REGISTRY_MUTEX_KEY, async () => {
|
|
182
|
+
const r = await readRegistry();
|
|
183
|
+
if (!r) return;
|
|
184
|
+
const agent = r.agents.find((a) => a.id === id);
|
|
185
|
+
if (!agent) return;
|
|
186
|
+
agent.lastSeen = new Date().toISOString();
|
|
187
|
+
lastSeenWriteAt.set(id, Date.now());
|
|
188
|
+
await _writeRegistryUnsafe(r);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { Sidecar } from "./types";
|
|
4
|
+
|
|
5
|
+
export function sidecarPath(rootDir: string, mdPath: string): string {
|
|
6
|
+
return path.join(rootDir, ".proof", mdPath + ".json");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function readSidecar(
|
|
10
|
+
rootDir: string,
|
|
11
|
+
mdPath: string,
|
|
12
|
+
): Promise<Sidecar | null> {
|
|
13
|
+
const filePath = sidecarPath(rootDir, mdPath);
|
|
14
|
+
try {
|
|
15
|
+
const raw = await readFile(filePath, "utf-8");
|
|
16
|
+
const parsed = JSON.parse(raw) as Sidecar;
|
|
17
|
+
if (parsed.schemaVersion !== 1) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Sidecar schema version mismatch: expected 1, got ${parsed.schemaVersion}`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return parsed;
|
|
23
|
+
} catch (err: unknown) {
|
|
24
|
+
if (
|
|
25
|
+
err instanceof Error &&
|
|
26
|
+
"code" in err &&
|
|
27
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
28
|
+
) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function writeSidecar(
|
|
36
|
+
rootDir: string,
|
|
37
|
+
mdPath: string,
|
|
38
|
+
sc: Sidecar,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const dest = sidecarPath(rootDir, mdPath);
|
|
41
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
42
|
+
const tmp = dest + ".tmp";
|
|
43
|
+
await writeFile(tmp, JSON.stringify(sc, null, 2), "utf-8");
|
|
44
|
+
await rename(tmp, dest);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function emptySidecar(mdPath: string): Sidecar {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
return {
|
|
50
|
+
schemaVersion: 1,
|
|
51
|
+
path: mdPath,
|
|
52
|
+
revision: 0,
|
|
53
|
+
createdAt: now,
|
|
54
|
+
updatedAt: now,
|
|
55
|
+
refMap: {},
|
|
56
|
+
refAliases: {},
|
|
57
|
+
comments: [],
|
|
58
|
+
suggestions: [],
|
|
59
|
+
archivedSuggestions: [],
|
|
60
|
+
events: [],
|
|
61
|
+
nextEventId: 1,
|
|
62
|
+
lastAck: {},
|
|
63
|
+
fingerprint: "",
|
|
64
|
+
blockProvenance: {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { authHeaders } from "./client-auth";
|
|
2
|
+
import type { SuggestionKind } from "./types";
|
|
3
|
+
|
|
4
|
+
interface PostResult {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
stale: boolean;
|
|
7
|
+
newRevision?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function postSuggestionOp(
|
|
11
|
+
path: string,
|
|
12
|
+
baseRevision: number,
|
|
13
|
+
op: Record<string, unknown>,
|
|
14
|
+
): Promise<PostResult> {
|
|
15
|
+
const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
|
|
16
|
+
const res = await fetch(`/api/agent/files/${encoded}`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"Idempotency-Key": crypto.randomUUID(),
|
|
21
|
+
...authHeaders(),
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify({ baseRevision, by: "human", ops: [op] }),
|
|
24
|
+
});
|
|
25
|
+
if (res.status === 409) {
|
|
26
|
+
const data = (await res.json()) as {
|
|
27
|
+
code?: string;
|
|
28
|
+
snapshot?: { revision?: number };
|
|
29
|
+
};
|
|
30
|
+
if (data.code === "STALE_REVISION" && data.snapshot?.revision !== undefined) {
|
|
31
|
+
return { ok: false, stale: true, newRevision: data.snapshot.revision };
|
|
32
|
+
}
|
|
33
|
+
return { ok: false, stale: false };
|
|
34
|
+
}
|
|
35
|
+
return { ok: res.ok, stale: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit a human suggestion for a block, retrying once on a stale-revision 409.
|
|
40
|
+
*
|
|
41
|
+
* `getRevision` returns the freshest known snapshot revision, `refresh`
|
|
42
|
+
* reloads the snapshot+sidecar so a retry can use the latest revision.
|
|
43
|
+
*/
|
|
44
|
+
export async function captureSuggestion(args: {
|
|
45
|
+
path: string;
|
|
46
|
+
ref: string;
|
|
47
|
+
kind: SuggestionKind;
|
|
48
|
+
markdown?: string;
|
|
49
|
+
basisDetail?: string;
|
|
50
|
+
getRevision: () => number;
|
|
51
|
+
refresh: () => Promise<void>;
|
|
52
|
+
}): Promise<boolean> {
|
|
53
|
+
const { path, ref, kind, markdown, basisDetail, getRevision, refresh } = args;
|
|
54
|
+
const op: Record<string, unknown> = {
|
|
55
|
+
type: "suggestion.add",
|
|
56
|
+
ref,
|
|
57
|
+
kind,
|
|
58
|
+
basis: "suggested",
|
|
59
|
+
};
|
|
60
|
+
if (kind !== "delete") op.markdown = markdown ?? "";
|
|
61
|
+
if (basisDetail) op.basisDetail = basisDetail;
|
|
62
|
+
|
|
63
|
+
let result = await postSuggestionOp(path, getRevision(), op);
|
|
64
|
+
if (!result.ok && result.stale && result.newRevision !== undefined) {
|
|
65
|
+
await refresh();
|
|
66
|
+
result = await postSuggestionOp(path, result.newRevision, op);
|
|
67
|
+
}
|
|
68
|
+
return result.ok;
|
|
69
|
+
}
|