Skip to content

Commit 92156b7

Browse files
committed
v1.9.4: per-user author attribution, team from users config
When auth is enabled, mutations record the authenticated user as author instead of the static config author. Team endpoint returns users from config instead of .team/ files. All mirror events now include `by` field.
1 parent e1fbb85 commit 92156b7

51 files changed

Lines changed: 796 additions & 222 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@graphmemory/server",
3-
"version": "1.9.3",
3+
"version": "1.9.4",
44
"description": "MCP server for semantic graph memory from markdown files",
55
"main": "dist/cli/index.js",
66
"bin": {

site/src/pages/changelog.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ description: Graph Memory release history and version changes.
55

66
# Changelog
77

8+
## v1.9.4
9+
10+
**March 2026**
11+
12+
### New
13+
14+
- **Per-user author attribution** — when authentication is configured, all mutations (create, update, delete, link, attachment) record the authenticated user as author (`createdBy`/`updatedBy`) instead of the static config `author`. Falls back to config author when auth is disabled
15+
- **Team from users config**`GET /api/projects/:id/team` returns users from config when auth is enabled, instead of reading `.team/` directory files. `.team/` files still used when auth is disabled
16+
- **Author in all mirror events** — relation and attachment events in `events.jsonl` now include `by` field for audit trail
17+
18+
### Tests
19+
20+
- **29 new tests** — author flow: `resolveRequestAuthor`, mirror `by` field, manager author override for Task/Knowledge/Skill managers
21+
- **Total: 1947 tests across 55 suites**
22+
23+
---
24+
825
## v1.9.3
926

1027
**March 2026**

src/api/index.ts

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { createRestApp } from '@/api/rest/index';
1111
import { attachWebSocket } from '@/api/rest/websocket';
1212
import { resolveUserFromBearer, resolveAccess, canWrite, canRead } from '@/lib/access';
1313
import { MAX_BODY_SIZE, SESSION_SWEEP_INTERVAL_MS } from '@/lib/defaults';
14-
import { GRAPH_NAMES, type GraphName, type AccessLevel } from '@/lib/multi-config';
14+
import { GRAPH_NAMES, type GraphName, type AccessLevel, resolveRequestAuthor, type UserConfig } from '@/lib/multi-config';
1515
import type { DocGraph } from '@/graphs/docs';
1616
import { DocGraphManager } from '@/graphs/docs';
1717
import type { CodeGraph } from '@/graphs/code-types';
@@ -190,6 +190,7 @@ export function createMcpServer(
190190
readonlyGraphs?: Set<string>,
191191
userAccess?: Map<string, AccessLevel>,
192192
getSessionId?: () => string | undefined,
193+
users?: Record<string, UserConfig>,
193194
): McpServer {
194195
// Backward-compat: single EmbedFn → use for both document and query
195196
const defaultPair: EmbedFns = { document: (q) => embed(q, ''), query: (q) => embed(q, '') };
@@ -221,6 +222,7 @@ export function createMcpServer(
221222
);
222223
// Mutation tools are registered through mutServer to serialize concurrent writes
223224
const mutServer = mutationQueue ? createMutationServer(server, mutationQueue, getSessionId) : server;
225+
const resolveAuthor = () => resolveRequestAuthor(sessionContext?.userId, users);
224226

225227
// Check if mutation tools should be registered for a graph:
226228
// - graph must not be readonly (global setting — tools hidden for all)
@@ -298,13 +300,13 @@ export function createMcpServer(
298300
listRelations.register(server, knowledgeMgr);
299301
findLinkedNotes.register(server, knowledgeMgr);
300302
if (canMutate('knowledge')) {
301-
createNote.register(mutServer, knowledgeMgr);
302-
updateNote.register(mutServer, knowledgeMgr);
303-
deleteNote.register(mutServer, knowledgeMgr);
304-
createRelation.register(mutServer, knowledgeMgr);
305-
deleteRelation.register(mutServer, knowledgeMgr);
306-
addNoteAttachment.register(mutServer, knowledgeMgr);
307-
removeNoteAttachment.register(mutServer, knowledgeMgr);
303+
createNote.register(mutServer, knowledgeMgr, resolveAuthor);
304+
updateNote.register(mutServer, knowledgeMgr, resolveAuthor);
305+
deleteNote.register(mutServer, knowledgeMgr, resolveAuthor);
306+
createRelation.register(mutServer, knowledgeMgr, resolveAuthor);
307+
deleteRelation.register(mutServer, knowledgeMgr, resolveAuthor);
308+
addNoteAttachment.register(mutServer, knowledgeMgr, resolveAuthor);
309+
removeNoteAttachment.register(mutServer, knowledgeMgr, resolveAuthor);
308310
}
309311
}
310312

@@ -319,31 +321,31 @@ export function createMcpServer(
319321
searchTasksTool.register(server, taskMgr);
320322
findLinkedTasks.register(server, taskMgr);
321323
if (canMutate('tasks')) {
322-
createTask.register(mutServer, taskMgr);
323-
updateTask.register(mutServer, taskMgr);
324-
deleteTask.register(mutServer, taskMgr);
325-
moveTask.register(mutServer, taskMgr);
326-
reorderTaskTool.register(mutServer, taskMgr);
327-
linkTask.register(mutServer, taskMgr);
328-
createTaskLink.register(mutServer, taskMgr);
329-
deleteTaskLink.register(mutServer, taskMgr);
330-
bulkMove.register(mutServer, taskMgr);
331-
bulkPriority.register(mutServer, taskMgr);
332-
bulkDelete.register(mutServer, taskMgr);
333-
addTaskAttachment.register(mutServer, taskMgr);
334-
removeTaskAttachment.register(mutServer, taskMgr);
324+
createTask.register(mutServer, taskMgr, resolveAuthor);
325+
updateTask.register(mutServer, taskMgr, resolveAuthor);
326+
deleteTask.register(mutServer, taskMgr, resolveAuthor);
327+
moveTask.register(mutServer, taskMgr, resolveAuthor);
328+
reorderTaskTool.register(mutServer, taskMgr, resolveAuthor);
329+
linkTask.register(mutServer, taskMgr, resolveAuthor);
330+
createTaskLink.register(mutServer, taskMgr, resolveAuthor);
331+
deleteTaskLink.register(mutServer, taskMgr, resolveAuthor);
332+
bulkMove.register(mutServer, taskMgr, resolveAuthor);
333+
bulkPriority.register(mutServer, taskMgr, resolveAuthor);
334+
bulkDelete.register(mutServer, taskMgr, resolveAuthor);
335+
addTaskAttachment.register(mutServer, taskMgr, resolveAuthor);
336+
removeTaskAttachment.register(mutServer, taskMgr, resolveAuthor);
335337
}
336338

337339
// Epic tools (same graph, same access)
338340
getEpicTool.register(server, taskMgr);
339341
listEpicsTool.register(server, taskMgr);
340342
searchEpicsTool.register(server, taskMgr);
341343
if (canMutate('tasks')) {
342-
createEpicTool.register(mutServer, taskMgr);
343-
updateEpicTool.register(mutServer, taskMgr);
344-
deleteEpicTool.register(mutServer, taskMgr);
345-
linkEpicTaskTool.register(mutServer, taskMgr);
346-
unlinkEpicTaskTool.register(mutServer, taskMgr);
344+
createEpicTool.register(mutServer, taskMgr, resolveAuthor);
345+
updateEpicTool.register(mutServer, taskMgr, resolveAuthor);
346+
deleteEpicTool.register(mutServer, taskMgr, resolveAuthor);
347+
linkEpicTaskTool.register(mutServer, taskMgr, resolveAuthor);
348+
unlinkEpicTaskTool.register(mutServer, taskMgr, resolveAuthor);
347349
}
348350
}
349351

@@ -359,15 +361,15 @@ export function createMcpServer(
359361
findLinkedSkills.register(server, skillMgr);
360362
recallSkills.register(server, skillMgr);
361363
if (canMutate('skills')) {
362-
createSkillTool.register(mutServer, skillMgr);
363-
updateSkillTool.register(mutServer, skillMgr);
364-
deleteSkillTool.register(mutServer, skillMgr);
365-
linkSkill.register(mutServer, skillMgr);
366-
createSkillLink.register(mutServer, skillMgr);
367-
deleteSkillLink.register(mutServer, skillMgr);
368-
addSkillAttachment.register(mutServer, skillMgr);
369-
removeSkillAttachment.register(mutServer, skillMgr);
370-
bumpSkillUsage.register(mutServer, skillMgr);
364+
createSkillTool.register(mutServer, skillMgr, resolveAuthor);
365+
updateSkillTool.register(mutServer, skillMgr, resolveAuthor);
366+
deleteSkillTool.register(mutServer, skillMgr, resolveAuthor);
367+
linkSkill.register(mutServer, skillMgr, resolveAuthor);
368+
createSkillLink.register(mutServer, skillMgr, resolveAuthor);
369+
deleteSkillLink.register(mutServer, skillMgr, resolveAuthor);
370+
addSkillAttachment.register(mutServer, skillMgr, resolveAuthor);
371+
removeSkillAttachment.register(mutServer, skillMgr, resolveAuthor);
372+
bumpSkillUsage.register(mutServer, skillMgr, resolveAuthor);
371373
}
372374
}
373375

@@ -684,6 +686,7 @@ export async function startMultiProjectHttpServer(
684686
mcpReadonlyGraphs,
685687
mcpUserAccess,
686688
() => transport.sessionId,
689+
users,
687690
);
688691
await mcpServer.connect(transport);
689692
await transport.handleRequest(req, res, body);

src/api/rest/epics.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { validateBody, validateQuery, createEpicSchema, updateEpicSchema, epicSe
44
import { requireWriteAccess } from '@/api/rest/index';
55
import { VersionConflictError } from '@/graphs/manager-types';
66
import type { EpicStatus } from '@/graphs/task-types';
7+
import { resolveRequestAuthor, type UserConfig } from '@/lib/multi-config';
78

8-
export function createEpicsRouter(): Router {
9+
export function createEpicsRouter(users?: Record<string, UserConfig>): Router {
910
const router = Router({ mergeParams: true });
1011

1112
function getProject(req: any) {
@@ -59,9 +60,10 @@ export function createEpicsRouter(): Router {
5960
router.post('/', requireWriteAccess, validateBody(createEpicSchema), async (req, res, next) => {
6061
try {
6162
const p = getProject(req);
63+
const author = resolveRequestAuthor(req.userId, users);
6264
const { title, description, status, priority, tags } = req.body;
6365
const created = await p.mutationQueue.enqueue(async () => {
64-
const epicId = await p.taskManager.createEpic(title, description, status, priority, tags);
66+
const epicId = await p.taskManager.createEpic(title, description, status, priority, tags, author);
6567
return p.taskManager.getEpic(epicId);
6668
});
6769
res.status(201).json(created);
@@ -72,10 +74,11 @@ export function createEpicsRouter(): Router {
7274
router.put('/:epicId', requireWriteAccess, validateBody(updateEpicSchema), async (req, res, next) => {
7375
try {
7476
const p = getProject(req);
77+
const author = resolveRequestAuthor(req.userId, users);
7578
const epicId = req.params.epicId as string;
7679
const { version, status, ...patch } = req.body;
7780
const result = await p.mutationQueue.enqueue(async () => {
78-
const ok = await p.taskManager.updateEpic(epicId, patch, status as EpicStatus | undefined, version);
81+
const ok = await p.taskManager.updateEpic(epicId, patch, status as EpicStatus | undefined, version, author);
7982
if (!ok) return null;
8083
return p.taskManager.getEpic(epicId);
8184
});
@@ -93,9 +96,10 @@ export function createEpicsRouter(): Router {
9396
router.delete('/:epicId', requireWriteAccess, async (req, res, next) => {
9497
try {
9598
const p = getProject(req);
99+
const author = resolveRequestAuthor(req.userId, users);
96100
const epicId = req.params.epicId as string;
97101
const ok = await p.mutationQueue.enqueue(async () => {
98-
return p.taskManager.deleteEpic(epicId);
102+
return p.taskManager.deleteEpic(epicId, author);
99103
});
100104
if (!ok) return res.status(404).json({ error: 'Epic not found' });
101105
res.status(204).end();
@@ -106,10 +110,11 @@ export function createEpicsRouter(): Router {
106110
router.post('/:epicId/link', requireWriteAccess, validateBody(epicLinkSchema), async (req, res, next) => {
107111
try {
108112
const p = getProject(req);
113+
const author = resolveRequestAuthor(req.userId, users);
109114
const epicId = req.params.epicId as string;
110115
const { taskId } = req.body;
111116
const ok = await p.mutationQueue.enqueue(async () => {
112-
return p.taskManager.linkTaskToEpic(taskId, epicId);
117+
return p.taskManager.linkTaskToEpic(taskId, epicId, author);
113118
});
114119
if (!ok) return res.status(400).json({ error: 'Failed to link task to epic' });
115120
res.status(201).json({ taskId, epicId, linked: true });
@@ -120,10 +125,11 @@ export function createEpicsRouter(): Router {
120125
router.delete('/:epicId/link', requireWriteAccess, validateBody(epicLinkSchema), async (req, res, next) => {
121126
try {
122127
const p = getProject(req);
128+
const author = resolveRequestAuthor(req.userId, users);
123129
const epicId = req.params.epicId as string;
124130
const { taskId } = req.body;
125131
const ok = await p.mutationQueue.enqueue(async () => {
126-
return p.taskManager.unlinkTaskFromEpic(taskId, epicId);
132+
return p.taskManager.unlinkTaskFromEpic(taskId, epicId, author);
127133
});
128134
if (!ok) return res.status(404).json({ error: 'Link not found' });
129135
res.status(204).end();

src/api/rest/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,12 @@ export function createRestApp(projectManager: ProjectManager, options?: RestAppO
404404
);
405405
if (!hasAnyAccess) return res.status(403).json({ error: 'Access denied' });
406406
}
407+
// When auth is configured, team = users from config (no .team/ files needed)
408+
if (hasUsers) {
409+
const members = Object.entries(users).map(([id, u]) => ({ id, name: u.name, email: u.email }));
410+
return res.json({ results: members });
411+
}
412+
// No auth — read from .team/ directory
407413
const p = req.project!;
408414
const ws = p.workspaceId ? projectManager.getWorkspace(p.workspaceId) : undefined;
409415
const baseDir = ws ? ws.config.mirrorDir : p.config.projectDir;
@@ -460,10 +466,10 @@ export function createRestApp(projectManager: ProjectManager, options?: RestAppO
460466

461467
// Mount domain routers (gated by manager existence + read access)
462468
// Mutation endpoints (POST/PUT/DELETE) inside routers check req.accessLevel for write access
463-
app.use('/api/projects/:projectId/knowledge', ...graphMiddleware('knowledgeManager', 'knowledge'), createKnowledgeRouter());
464-
app.use('/api/projects/:projectId/tasks', ...graphMiddleware('taskManager', 'tasks'), createTasksRouter());
465-
app.use('/api/projects/:projectId/epics', ...graphMiddleware('taskManager', 'tasks'), createEpicsRouter());
466-
app.use('/api/projects/:projectId/skills', ...graphMiddleware('skillManager', 'skills'), createSkillsRouter());
469+
app.use('/api/projects/:projectId/knowledge', ...graphMiddleware('knowledgeManager', 'knowledge'), createKnowledgeRouter(users));
470+
app.use('/api/projects/:projectId/tasks', ...graphMiddleware('taskManager', 'tasks'), createTasksRouter(users));
471+
app.use('/api/projects/:projectId/epics', ...graphMiddleware('taskManager', 'tasks'), createEpicsRouter(users));
472+
app.use('/api/projects/:projectId/skills', ...graphMiddleware('skillManager', 'skills'), createSkillsRouter(users));
467473
app.use('/api/projects/:projectId/docs', ...graphMiddleware('docManager', 'docs'), createDocsRouter());
468474
app.use('/api/projects/:projectId/code', ...graphMiddleware('codeManager', 'code'), createCodeRouter());
469475
app.use('/api/projects/:projectId/files', ...graphMiddleware('fileIndexManager', 'files'), createFilesRouter());

src/api/rest/knowledge.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import { validateBody, validateQuery, createNoteSchema, updateNoteSchema, create
77
import { requireWriteAccess } from '@/api/rest/index';
88
import { VersionConflictError } from '@/graphs/manager-types';
99
import { MAX_UPLOAD_SIZE } from '@/lib/defaults';
10+
import { resolveRequestAuthor, type UserConfig } from '@/lib/multi-config';
1011

1112
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_UPLOAD_SIZE } });
1213

13-
export function createKnowledgeRouter(): Router {
14+
export function createKnowledgeRouter(users?: Record<string, UserConfig>): Router {
1415
const router = Router({ mergeParams: true });
1516

1617
function getProject(req: any) {
@@ -60,8 +61,9 @@ export function createKnowledgeRouter(): Router {
6061
try {
6162
const p = getProject(req);
6263
const { title, content, tags } = req.body;
64+
const author = resolveRequestAuthor(req.userId, users);
6365
const created = await p.mutationQueue.enqueue(async () => {
64-
const noteId = await p.knowledgeManager.createNote(title, content, tags);
66+
const noteId = await p.knowledgeManager.createNote(title, content, tags, author);
6567
return p.knowledgeManager.getNote(noteId);
6668
});
6769
res.status(201).json(created);
@@ -74,8 +76,9 @@ export function createKnowledgeRouter(): Router {
7476
const p = getProject(req);
7577
const noteId = req.params.noteId as string;
7678
const { version, ...patch } = req.body;
79+
const author = resolveRequestAuthor(req.userId, users);
7780
const result = await p.mutationQueue.enqueue(async () => {
78-
const ok = await p.knowledgeManager.updateNote(noteId, patch, version);
81+
const ok = await p.knowledgeManager.updateNote(noteId, patch, version, author);
7982
if (!ok) return null;
8083
return p.knowledgeManager.getNote(noteId);
8184
});
@@ -94,8 +97,9 @@ export function createKnowledgeRouter(): Router {
9497
try {
9598
const p = getProject(req);
9699
const noteId = req.params.noteId as string;
100+
const author = resolveRequestAuthor(req.userId, users);
97101
const ok = await p.mutationQueue.enqueue(async () => {
98-
return p.knowledgeManager.deleteNote(noteId);
102+
return p.knowledgeManager.deleteNote(noteId, author);
99103
});
100104
if (!ok) return res.status(404).json({ error: 'Note not found' });
101105
res.status(204).end();
@@ -107,8 +111,9 @@ export function createKnowledgeRouter(): Router {
107111
try {
108112
const p = getProject(req);
109113
const { fromId, toId, kind, targetGraph, projectId } = req.body;
114+
const author = resolveRequestAuthor(req.userId, users);
110115
const ok = await p.mutationQueue.enqueue(async () => {
111-
return p.knowledgeManager.createRelation(fromId, toId, kind, targetGraph, projectId);
116+
return p.knowledgeManager.createRelation(fromId, toId, kind, targetGraph, projectId, author);
112117
});
113118
if (!ok) return res.status(400).json({ error: 'Failed to create relation' });
114119
res.status(201).json({ fromId, toId, kind, targetGraph: targetGraph || undefined });
@@ -120,8 +125,9 @@ export function createKnowledgeRouter(): Router {
120125
try {
121126
const p = getProject(req);
122127
const { fromId, toId, targetGraph, projectId } = req.body;
128+
const author = resolveRequestAuthor(req.userId, users);
123129
const ok = await p.mutationQueue.enqueue(async () => {
124-
return p.knowledgeManager.deleteRelation(fromId, toId, targetGraph, projectId);
130+
return p.knowledgeManager.deleteRelation(fromId, toId, targetGraph, projectId, author);
125131
});
126132
if (!ok) return res.status(404).json({ error: 'Relation not found' });
127133
res.status(204).end();
@@ -157,9 +163,10 @@ export function createKnowledgeRouter(): Router {
157163
const file = req.file;
158164
if (!file) return res.status(400).json({ error: 'No file uploaded' });
159165
const filename = attachmentFilenameSchema.parse(file.originalname);
166+
const author = resolveRequestAuthor(req.userId, users);
160167

161168
const meta = await p.mutationQueue.enqueue(async () => {
162-
return p.knowledgeManager.addAttachment(noteId, filename, file.buffer);
169+
return p.knowledgeManager.addAttachment(noteId, filename, file.buffer, author);
163170
});
164171
if (!meta) return res.status(404).json({ error: 'Note not found' });
165172
res.status(201).json(meta);
@@ -198,8 +205,9 @@ export function createKnowledgeRouter(): Router {
198205
const p = getProject(req);
199206
const noteId = req.params.noteId as string;
200207
const filename = attachmentFilenameSchema.parse(req.params.filename);
208+
const author = resolveRequestAuthor(req.userId, users);
201209
const ok = await p.mutationQueue.enqueue(async () => {
202-
return p.knowledgeManager.removeAttachment(noteId, filename);
210+
return p.knowledgeManager.removeAttachment(noteId, filename, author);
203211
});
204212
if (!ok) return res.status(404).json({ error: 'Attachment not found' });
205213
res.status(204).end();

0 commit comments

Comments
 (0)