diff --git a/backend/src/modules/repository-scope.test.ts b/backend/src/modules/repository-scope.test.ts new file mode 100644 index 0000000..1223747 --- /dev/null +++ b/backend/src/modules/repository-scope.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { resetMemoryDatastore } from '../test-helpers.js'; +import { createNote, countNotesByWorkspaces, listNotes } from './notes/repository.js'; +import { createNoteAgentAction, listNoteAgentActions, listPendingActions } from './note-agent-actions/repository.js'; +import { createNoteShare, findShareByToken, listSharesForNote } from './note-shares/repository.js'; +import { createCollaborator, listCollaboratorsForNote, listSharedWithMe } from './note-collaborators/repository.js'; +import type { NoteDoc } from './notes/types.js'; +import type { NoteAgentActionDoc } from './note-agent-actions/types.js'; +import type { NoteShareDoc } from './note-shares/types.js'; +import type { NoteCollaboratorDoc } from './note-collaborators/types.js'; + +const now = '2026-05-05T00:00:00.000Z'; + +beforeEach(() => { + resetMemoryDatastore(); +}); + +function note(overrides: Partial): NoteDoc { + return { + id: 'note-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + title: 'Scoped note', + body: 'body', + status: 'active', + tags: [], + links: [], + createdAt: now, + updatedAt: now, + createdBy: 'user-1', + updatedBy: 'user-1', + ...overrides, + }; +} + +function action(overrides: Partial): NoteAgentActionDoc { + return { + id: 'action-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + noteId: 'note-1', + actorId: 'agent-1', + actorType: 'agent', + toolName: 'notes.notes.update', + actionType: 'update', + state: 'draft', + createdAt: now, + updatedAt: now, + createdBy: 'agent-1', + updatedBy: 'agent-1', + ...overrides, + }; +} + +function share(overrides: Partial): NoteShareDoc { + return { + id: 'share-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + noteId: 'note-1', + shareToken: 'token-1', + createdAt: now, + ...overrides, + }; +} + +function collaborator(overrides: Partial): NoteCollaboratorDoc { + return { + id: 'collab-1', + productId: 'notelett', + noteId: 'note-1', + workspaceId: 'ws-1', + sharedByUserId: 'user-1', + sharedWithUserId: 'user-2', + permission: 'view', + createdAt: now, + ...overrides, + }; +} + +describe('repository scope isolation', () => { + it('keeps note list and workspace counts scoped by user, product, and workspace', async () => { + await createNote(note({ id: 'note-visible' })); + await createNote(note({ id: 'note-other-user', userId: 'user-2' })); + await createNote(note({ id: 'note-other-product', productId: 'other-product' })); + await createNote(note({ id: 'note-other-workspace', workspaceId: 'ws-2' })); + + const scoped = await listNotes('user-1', 'notelett', { + workspaceId: 'ws-1', + limit: 50, + offset: 0, + }); + expect(scoped.items.map(item => item.id)).toEqual(['note-visible']); + expect(scoped.total).toBe(1); + + const allUserNotes = await listNotes('user-1', 'notelett', { limit: 50, offset: 0 }); + expect(allUserNotes.items.map(item => item.id).sort()).toEqual(['note-other-workspace', 'note-visible']); + + const counts = await countNotesByWorkspaces('user-1', 'notelett', ['ws-1', 'ws-2']); + expect([...counts.entries()]).toEqual([ + ['ws-1', 1], + ['ws-2', 1], + ]); + }); + + it('keeps agent action lists scoped by user, product, and workspace', async () => { + await createNoteAgentAction(action({ id: 'action-visible' })); + await createNoteAgentAction(action({ id: 'action-other-user', userId: 'user-2' })); + await createNoteAgentAction(action({ id: 'action-other-product', productId: 'other-product' })); + await createNoteAgentAction(action({ id: 'action-other-workspace', workspaceId: 'ws-2' })); + + const workspaceActions = await listNoteAgentActions('user-1', 'notelett', { + workspaceId: 'ws-1', + limit: 50, + offset: 0, + }); + expect(workspaceActions.items.map(item => item.id)).toEqual(['action-visible']); + expect(workspaceActions.total).toBe(1); + + const pending = await listPendingActions('user-1', 'notelett'); + expect(pending.items.map(item => item.id).sort()).toEqual(['action-other-workspace', 'action-visible']); + expect(pending.total).toBe(2); + }); + + it('keeps share and collaborator lookups scoped by product, user, note, and workspace filters', async () => { + await createNoteShare(share({ id: 'share-other-product', productId: 'other-product', shareToken: 'foreign-token' })); + expect(await findShareByToken('foreign-token', 'notelett')).toBeNull(); + + await createNoteShare(share({ id: 'share-visible', shareToken: 'token-visible' })); + await createNoteShare(share({ id: 'share-other-user', userId: 'user-2' })); + await createNoteShare(share({ id: 'share-other-note', noteId: 'note-2' })); + const shares = await listSharesForNote('user-1', 'notelett', 'ws-1', 'note-1'); + expect(shares.map(item => item.id)).toEqual(['share-visible']); + expect((await findShareByToken('token-visible', 'notelett'))?.id).toBe('share-visible'); + + await createCollaborator(collaborator({ id: 'collab-visible' })); + await createCollaborator(collaborator({ id: 'collab-other-product', productId: 'other-product' })); + await createCollaborator(collaborator({ id: 'collab-other-note', noteId: 'note-2' })); + await createCollaborator(collaborator({ id: 'collab-other-recipient', sharedWithUserId: 'user-3' })); + + const noteCollaborators = await listCollaboratorsForNote('note-1', 'notelett'); + expect(noteCollaborators.map(item => item.id)).toEqual(['collab-visible', 'collab-other-recipient']); + + const sharedWithMe = await listSharedWithMe('user-2', 'notelett'); + expect(sharedWithMe.map(item => item.id)).toEqual(['collab-visible', 'collab-other-note']); + }); +}); diff --git a/docs/COSMOS_QUERY_REVIEW.md b/docs/COSMOS_QUERY_REVIEW.md new file mode 100644 index 0000000..04371b8 --- /dev/null +++ b/docs/COSMOS_QUERY_REVIEW.md @@ -0,0 +1,40 @@ +# Cosmos Query Review + +Date: 2026-05-05 + +This review covers current NoteLett repository access patterns against the Cosmos container registrations in `backend/src/lib/cosmos-init.ts`. All documents retain `productId: "notelett"` and all list routes include product/user/workspace scope filters before returning user data. + +## Container Partitions + +| Container | Partition key | Primary access pattern | +| --- | --- | --- | +| `notes`, `note_relationships`, `note_tasks`, `note_artifacts`, `note_agent_actions`, `note_shares`, `note_versions` | `/workspaceId` | Workspace-scoped note surfaces and agent write audit trail | +| `workspaces`, `saved_views`, `note_prompts`, `note_prompt_schedules`, `note_prompt_webhooks`, `note_intake_rules`, `note_intake_jobs` | `/userId` | User-owned configuration, templates, and intake state | +| `note_collaborators` | `/sharedWithUserId` | Shared-with-me lookup; owner-side collaborator listing is note-scoped | +| `palace_*` | `/userId` | Per-user memory palace data and maintenance operations | + +## Cross-Partition Or Count-Heavy Operations + +| Operation | Why it can cross partitions | Guardrails | +| --- | --- | --- | +| `notes.listNotes()` without `workspaceId` | `notes` is partitioned by `/workspaceId`, but dashboard/search surfaces can list across a user's workspaces. | Always filters by `userId` and `productId`; paginated to caller limit. Workspace-specific calls include `workspaceId`. | +| `note_agent_actions.listPendingActions()` | Pending review queue intentionally spans workspaces in the `/workspaceId` container. | Filters by `userId`, `productId`, and state; performs two count queries plus two bounded reads. | +| `note_shares.findShareByToken()` | Public share-token resolution starts from token/product instead of workspace. | Filters by `shareToken` and `productId`, returns one record, and rejects expired shares before public note lookup revalidates note/user/product scope. | +| `note_collaborators.listCollaboratorsForNote()` | The container is optimized for `/sharedWithUserId`; owner-side note collaborator listing starts from `noteId`. | Filters by `noteId` and `productId`, limits to 100; route verifies the caller can access the note before listing. | +| `palace` search/maintenance scans | Palace data is `/userId` partitioned, but some relevance, duplicate, KG, and maintenance jobs scan hundreds to thousands of user memories/triples. | All scans include `userId` and `productId`; limits are explicit (`500`, `1000`, or `5000`) and remain single-user partition reads. | +| `palace.getPalaceStats()` | Counts six Palace containers for a user. | Counts are all scoped by `userId` and `productId`; suitable for diagnostics/dashboard use. | +| Prompt template builtin merge | User templates and builtins live in separate `/userId` partitions. | User partition and `__builtin__` partition are queried separately, then merged in memory with limit bounds. | + +## Regression Coverage + +`backend/src/modules/repository-scope.test.ts` verifies scope isolation for: + +- Notes list and workspace counts across user, product, and workspace boundaries. +- Agent action workspace lists and cross-workspace pending queues across user and product boundaries. +- Share token, share list, note collaborator, and shared-with-me lookups across product, user, note, and workspace filters. + +## Follow-Up Notes + +- If dashboard global note search becomes high traffic, prefer a user-partitioned materialized search/index container or a dedicated search service over broad `/workspaceId` fan-out. +- If pending review volume grows, consider a user-partitioned projection for `draft`/`proposed` agent actions to avoid two cross-workspace counts per queue load. +- Public share tokens should remain high-entropy and globally unique; token lookup is intentionally cross-partition but bounded to one item.