Skip to content

security: phase-1 auth + crud context hardening#101

Merged
remcostoeten merged 4 commits intodaddyfrom
feature/avatars
Feb 14, 2026
Merged

security: phase-1 auth + crud context hardening#101
remcostoeten merged 4 commits intodaddyfrom
feature/avatars

Conversation

@remcostoeten
Copy link
Owner

@remcostoeten remcostoeten commented Feb 14, 2026

Summary\n- normalize AI auth routes to shared requireAuth pattern\n- harden CRUD context with server runtime guard\n- add CRUD context guard tests and API auth route tests\n- normalize guest-user constants usage and update audit dashboard progress\n\n## Dashboard\n- completes CRITICAL-003 guard/test path and AUTH-001 verification\n- updates docs/audit-dashboard.html statuses accordingly\n

Summary by Sourcery

Harden API authentication and CRUD context usage, normalize guest user handling, and update editor, tasks, and AI flows along with adding an audit dashboard and tests.

New Features:

  • Add an interactive audit dashboard HTML page to track implementation phases and tasks.
  • Introduce shared platform detection helpers and Platform constants for web, Tauri, Expo, and server runtimes.

Bug Fixes:

  • Secure the import and tasks API routes with auth guards and user scoping, including payload validation and size limits.
  • Normalize AI-related API routes to use a shared auth helper and correctly scope database queries by user.
  • Prevent server-side usage of the @skriuw/crud user context by enforcing a client-only runtime guard.
  • Ensure task sync and item routes enforce ownership via userId and maintain stable IDs and parent relationships.
  • Fix task description editor state updates and visually indicate completed task content.

Enhancements:

  • Improve note template insertion by detecting existing content, confirming destructive replacement, and replacing page content more robustly.
  • Normalize guest user handling via shared GUEST_USER_ID and isGuestUserId utilities across notes, tasks, tags, shortcuts, settings, and client API adapters.
  • Refine task detail panel behavior and task panel navigation stack interactions in the editor UI.

Documentation:

  • Update AGENTS.md with stricter agent workflow requirements and document the platform and storage strategy.
  • Add an audit-dashboard.html document that encodes the active implementation plan and task tracking.

Tests:

  • Add tests covering AI auth route behavior, user scoping, and rate limiting, plus CRUD context runtime guards and concurrent usage.
  • Add placeholders for import route tests to validate auth, payload handling, and limits.

Summary by CodeRabbit

  • New Features

    • Added confirmation dialog for template insertion when replacing existing content in notes
  • Bug Fixes

    • Fixed task description synchronization to properly reset when description is cleared
  • Improvements

    • Enhanced task visibility with strikethrough styling for completed tasks
    • Improved task navigation with context-aware stack-based opening behavior
    • Refined task title handling in description editor

@vercel
Copy link
Contributor

vercel bot commented Feb 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
skriuw Ready Ready Preview, Comment Feb 14, 2026 4:41pm

@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 14, 2026

Reviewer's Guide

Implements phase-1 security and platform hardening by normalizing AI API auth to shared helpers, enforcing client-only CRUD context with runtime guards and tests, securing and batching the import and task sync APIs with user scoping and validation, standardizing guest user handling via shared constants, adding platform detection helpers, and enhancing editor/tasks UX and the audit dashboard to track this work.

Sequence diagram for secured import API flow

sequenceDiagram
    actor User
    participant Browser
    participant ImportRoute as API_Import_POST
    participant Auth as requireMutation
    participant DB as Database

    User->>Browser: Trigger import (upload file)
    Browser->>ImportRoute: POST /api/import { items }

    ImportRoute->>Auth: requireMutation()
    Auth-->>ImportRoute: { authenticated, userId } or { response }
    alt Not authenticated
        ImportRoute-->>Browser: auth.response (401/403)
    else Authenticated
        ImportRoute->>ImportRoute: Check Content-Length <= 5MB
        alt Payload too large
            ImportRoute-->>Browser: 413 Payload too large
        else Size ok
            ImportRoute->>ImportRoute: Parse JSON body
            ImportRoute->>ImportRoute: ImportPayloadSchema.safeParse(body)
            alt Invalid payload
                ImportRoute-->>Browser: 400 Invalid payload (Zod details)
            else Valid payload
                ImportRoute->>ImportRoute: processItem() for each root item
                ImportRoute->>ImportRoute: Build foldersToInsert, notesToInsert (inject userId, deletedAt null)
                ImportRoute->>DB: transaction {
                    loop Folders chunks
                        DB-->>DB: insert folders
                        DB-->>DB: onConflictDoUpdate(id)
                    end
                    loop Notes chunks
                        DB-->>DB: insert notes
                        DB-->>DB: onConflictDoUpdate(id)
                    end
                DB-->>ImportRoute: Transaction committed
                ImportRoute-->>Browser: 200 { success, stats }
            end
        end
    end
Loading

Sequence diagram for secured task sync API flow

sequenceDiagram
    actor User
    participant Browser
    participant SyncRoute as API_Tasks_Sync_POST
    participant Auth as requireMutation
    participant DB as Database

    User->>Browser: Edit note tasks
    Browser->>SyncRoute: POST /api/tasks/sync { noteId, tasks }

    SyncRoute->>Auth: requireMutation()
    Auth-->>SyncRoute: { authenticated, userId } or { response }
    alt Not authenticated
        SyncRoute-->>Browser: auth.response (401/403)
    else Authenticated
        alt Missing noteId
            SyncRoute-->>Browser: 400 { error: noteId is required }
        else noteId present
            SyncRoute->>DB: select notes.id where id = noteId and userId = userId
            DB-->>SyncRoute: note row or empty
            alt Note not found or not owned by user
                SyncRoute-->>Browser: 404 { error: Note not found }
            else Note owned by user
                SyncRoute->>DB: select * from tasks where noteId = noteId and userId = userId
                DB-->>SyncRoute: existingTasks
                SyncRoute->>SyncRoute: Build taskIdByBlockId map
                loop For each incoming task
                    SyncRoute->>SyncRoute: Ensure stable id for blockId
                    SyncRoute->>SyncRoute: Map parentTaskId via taskIdByBlockId
                    SyncRoute->>SyncRoute: Build row with userId, timestamps
                end
                SyncRoute->>DB: delete from tasks where noteId = noteId and userId = userId
                alt rows.length > 0
                    SyncRoute->>DB: insert into tasks values(rows)
                end
                DB-->>SyncRoute: OK
                SyncRoute-->>Browser: 200 { success: true }
            end
        end
    end
Loading

Entity relationship diagram for secured notes, folders, and tasks

erDiagram
    users {
      string id PK
    }

    folders {
      string id PK
      string userId FK
      string name
      string parentFolderId
      boolean pinned
      bigint pinnedAt
      bigint createdAt
      bigint updatedAt
      bigint deletedAt
      string type
    }

    notes {
      string id PK
      string userId FK
      string name
      text content
      string parentFolderId
      boolean pinned
      bigint pinnedAt
      boolean favorite
      bigint createdAt
      bigint updatedAt
      bigint deletedAt
      string type
    }

    tasks {
      string id PK
      string userId FK
      string noteId FK
      string blockId
      text content
      text description
      boolean checked
      bigint dueDate
      string parentTaskId FK
      int position
      bigint createdAt
      bigint updatedAt
    }

    users ||--o{ folders : owns
    users ||--o{ notes : owns
    users ||--o{ tasks : owns

    folders ||--o{ folders : parent_of
    folders ||--o{ notes : contains

    notes ||--o{ tasks : has
    tasks ||--o{ tasks : parent_of
Loading

Class diagram for auth constants, platform helpers, and CRUD context guard

classDiagram
    class AuthConstants {
      <<module>>
      +const GUEST_USER_ID : string
      +const LEGACY_GUEST_USER_ID : string
      +const GUEST_USER_IDS : readonly string[]
      +isGuestUserId(userId string) bool
    }

    class PlatformHelpers {
      <<module>>
      +isTauri() bool
      +isExpo() bool
      +isWeb() bool
      +isServer() bool
    }

    class Platform {
      <<object>>
      +isWeb() bool
      +isTauri() bool
      +isExpo() bool
      +isServer() bool
      +web : bool
      +tauri : bool
      +expo : bool
      +server : bool
    }

    class CrudContext {
      <<module>>
      -currentContext : UserContext
      +setUserContext(ctx UserContext) void
      +getUserContext() UserContext
      +clearUserContext() void
      +getCrudUserId() string
      +withUser(userId string, fn PromiseFunction~T~) Promise~T~
      +withUserSync(userId string, fn Function~T~) T
      +createScopedContext(ctx UserContext) ScopedContext
      -isServerRuntime() bool
      -assertClientRuntime(api string) void
    }

    class UserContext {
      <<type>>
      +userId : string
      +other metadata
    }

    class ScopedContext {
      <<type>>
      +getUserId() string
      +getContext() UserContext
    }

    AuthConstants <.. PlatformHelpers : shared utilities
    PlatformHelpers <.. Platform : uses
    UserContext <.. CrudContext : manages
    ScopedContext <.. CrudContext : creates

    note for CrudContext "assertClientRuntime throws if window is undefined, preventing use of CRUD context on the server runtime"
Loading

File-Level Changes

Change Details Files
Add confirmation and safer document replacement when inserting templates in the editor footer bar.
  • Introduce helper functions to detect effectively empty documents/blocks before template insertion.
  • Prompt the user with a confirm dialog before overwriting non-empty content.
  • Switch from appending template blocks at cursor to replacing the entire document when content exists, and ensure cursor is moved to the first block.
  • Wrap footer content with the ConfirmDialog component.
apps/web/features/editor/components/note-footer-bar.tsx
Secure and optimize the import API route with auth, validation, size limits, user scoping, and batched DB writes.
  • Require authenticated mutation via requireMutation and scope imported records to the current userId.
  • Enforce a 5MB payload size limit and return informative 413 responses for oversized imports.
  • Validate nested import payloads using recursive Zod schemas before processing.
  • Flatten folder/note trees into batched insert arrays, inject userId/deletedAt flags, and perform chunked transactional inserts with onConflictDoUpdate, returning simple stats.
apps/web/app/api/import/route.ts
Reimplement task sync API on top of the shared DB with strict user scoping and stable task IDs per block.
  • Guard the sync endpoint with requireMutation and ensure the note belongs to the current user before syncing tasks.
  • Replace storage adapter usage with direct Drizzle queries against tasks, filtered by noteId and userId.
  • Generate stable task IDs by mapping blockId to IDs before resolving parentTaskId links, preserving metadata from existing rows.
  • Implement sync as delete-then-bulk-insert scoped to noteId+userId for predictable note-local task replacement.
apps/web/app/api/tasks/sync/route.ts
Normalize AI API routes to use shared auth helper and ensure user-scoped queries with tests.
  • Replace direct auth.api.getSession calls in AI config, prompt, and usage routes with requireAuth.
  • Update all DB queries in these routes to filter on the authenticated userId.
  • Adjust AI config POST/PATCH handlers to use userId from auth helper for lookups and inserts.
  • Add bun-based tests that mock requireAuth and DB calls to verify auth gating and user scoping of AI routes.
apps/web/app/api/ai/config/route.ts
apps/web/app/api/ai/prompt/route.ts
apps/web/app/api/ai/usage/route.ts
apps/web/__tests__/api/ai-auth-routes.test.ts
Harden task item API routes to be user-scoped and mutation-protected.
  • Introduce requireAuth for GET and requireMutation for PATCH/DELETE in task item and note task collection routes.
  • Ensure all selects, updates, and deletes on tasks include a userId equality check in addition to id/blockId/noteId filters.
  • Propagate userId into ancestor-chain resolution so breadcrumbs are user-scoped.
  • Keep serialization shape but return 404 when scoped queries yield no rows.
apps/web/app/api/tasks/item/[taskId]/route.ts
apps/web/app/api/tasks/[noteId]/route.ts
Enforce client-only usage of the CRUD user context API with runtime guards and tests.
  • Add a runtime detector and assertClientRuntime helper that throws when context APIs are invoked on the server (no window).
  • Guard setUserContext, getUserContext, clearUserContext, getCrudUserId, withUser, withUserSync, and createScopedContext with assertClientRuntime.
  • Add Vitest tests that simulate client vs server runtimes and assert that server calls throw and concurrent withUser calls are rejected.
packages/crud/src/context.ts
packages/crud/src/context.test.ts
Standardize guest user handling across web features via shared constants and helpers.
  • Introduce shared GUEST_USER_ID, LEGACY_GUEST_USER_ID, and isGuestUserId helper in @skriuw/shared constants and export them from the shared index.
  • Replace ad-hoc 'guest'/'guest-user' literals and local guest checks in tasks, notes, tags, shortcuts, settings hooks, and client-api adapter with the shared constants and helper.
  • Update api-auth to rely on the shared guest constant instead of defining its own.
  • Align guest-local sync logic (e.g., task sync) with stable IDs and parent mappings while keeping guest semantics.
packages/shared/src/constants/auth.ts
packages/shared/src/constants/index.ts
packages/shared/src/index.ts
apps/web/features/tasks/hooks/use-tasks-query.ts
apps/web/features/notes/hooks/use-notes-query.ts
apps/web/features/tags/hooks/use-tags-query.ts
apps/web/features/shortcuts/hooks/use-shortcuts-query.ts
apps/web/features/settings/hooks/use-settings-query.ts
apps/web/lib/storage/adapters/client-api.ts
apps/web/lib/api-auth.ts
Add core platform detection helpers and Platform facade for multi-runtime support.
  • Create helpers to detect Tauri, Expo, web, and server runtimes via global/window feature checks.
  • Expose a Platform object with boolean methods and computed getters (web, tauri, expo, server) delegating to these helpers for centralized platform checks.
  • Place these utilities in the new core shared area to be reused by adapters and platform-specific logic later.
packages/core/shared/helpers/determine-platform.ts
packages/core/shared/platform.ts
Enhance editor tasks UX and task detail behavior.
  • In the task block React spec, apply line-through and muted styling to content when a task is checked.
  • Change task detail button behavior to push onto an existing task stack if one is open, otherwise open the task panel, improving nested task navigation.
  • Initialize the single task panel description state to an empty string when no description is present and ensure the editor memo depends on the title to keep initial content in sync.
apps/web/features/editor/slash-menu/task-block.tsx
apps/web/features/tasks/components/task-detail-panel.tsx
apps/web/features/tasks/components/task-description-editor.tsx
Introduce and wire up an interactive audit dashboard documenting phases, tasks, and statuses for agents.
  • Add AGENTS.md instructions pointing agents to docs/audit-dashboard.html as the source of truth and workflow requirements.
  • Create a rich HTML dashboard that lists phases, task groups, and tasks with tags, search, filters, and progress stats, persisting completion state in IndexedDB.
  • Mark this PR’s auth and CRUD-context items (CRITICAL-003, AUTH-001, guest normalization, import hardening) as completed in the dashboard metadata.
AGENTS.md
docs/audit-dashboard.html

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@remcostoeten remcostoeten merged commit f94c1bc into daddy Feb 14, 2026
4 of 6 checks passed
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 14, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

This PR centralizes authentication patterns across multiple API routes using requireAuth and requireMutation helpers, standardizes guest user identification via GUEST_USER_ID constants across hooks and utilities, introduces platform detection helpers (Web, Tauri, Expo, Server), adds comprehensive test coverage for AI auth and import routes, enhances task/import APIs with user scoping and validation, and introduces a new audit dashboard for project governance tracking.

Changes

Cohort / File(s) Summary
Authentication Refactoring
apps/web/app/api/ai/config/route.ts, apps/web/app/api/ai/prompt/route.ts, apps/web/app/api/ai/usage/route.ts, apps/web/app/api/tasks/[noteId]/route.ts, apps/web/app/api/tasks/item/[taskId]/route.ts, apps/web/app/api/tasks/sync/route.ts
Migrated from session-based to centralized requireAuth/requireMutation helpers; replaced session.user.id with userId from auth result; added early-return auth checks and user-scoped database queries.
Import Route Enhancement
apps/web/app/api/import/route.ts
Added authentication gate, 5MB payload size validation, Zod validation schema, batched tree-flattening insertion with transactional bulk operations, and structured error responses.
Guest User Standardization
apps/web/features/notes/hooks/use-notes-query.ts, apps/web/features/tasks/hooks/use-tasks-query.ts, apps/web/features/settings/hooks/use-settings-query.ts, apps/web/features/shortcuts/hooks/use-shortcuts-query.ts, apps/web/features/tags/hooks/use-tags-query.ts, apps/web/lib/api-auth.ts, apps/web/lib/storage/adapters/client-api.ts
Replaced hard-coded guest identifiers with GUEST_USER_ID and isGuestUserId helpers from shared constants; updated all guest-detection logic and default userId fallbacks.
Shared Constants & Utilities
packages/shared/src/constants/auth.ts, packages/shared/src/constants/index.ts, packages/shared/src/index.ts
Introduced GUEST_USER_ID, LEGACY_GUEST_USER_ID, GUEST_USER_IDS, and isGuestUserId() function; added re-export barrels for public API exposure.
Platform Detection
packages/core/shared/helpers/determine-platform.ts, packages/core/shared/platform.ts
Created isTauri(), isExpo(), isWeb(), isServer() predicate functions and centralized Platform object for runtime environment detection.
Context Runtime Guards
packages/crud/src/context.ts, packages/crud/src/context.test.ts
Added client-only runtime guards via assertClientRuntime() to context API functions; new test suite validates server-side rejection and client-side behavior.
UI Component Updates
apps/web/features/editor/components/note-footer-bar.tsx, apps/web/features/editor/slash-menu/task-block.tsx, apps/web/features/tasks/components/task-description-editor.tsx, apps/web/features/tasks/components/task-detail-panel.tsx
Introduced confirm-dialog flow for template insertion, stack-based task navigation, updated editor initialization dependencies, and improved task description synchronization.
Test Coverage
apps/web/__tests__/api/ai-auth-routes.test.ts, apps/web/__tests__/api/import-route.test.ts
Added comprehensive test suites for AI authentication endpoints and import route handler with mocked dependencies, auth scenarios, payload validation, and database interaction assertions.
Documentation & Dashboard
AGENTS.md, docs/audit-dashboard.html
Updated agent governance guidance with platform/storage strategy; introduced interactive in-browser audit dashboard with task tracking, progress metrics, filtering, and IndexedDB persistence.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #67: Overlapping guest-mode storage and CRUD plumbing changes — introduces/uses GUEST_USER_ID and propagates userId through CRUD/storage adapters.
  • PR #46: Overlapping authentication and environment layers — modifies auth client, helpers, env usage, and related guards and tests.
  • PR #33: Overlapping task feature modifications — changes task API routes, task editor components, and task panels/sidebar structures.

Suggested labels

codex

Poem

🐰 A rabbit hops through auth's new hall,
Guest identities standardized for all,
Platforms detected with functions clean,
Tasks now scoped in ways unseen,
With dashboards gleaming, governance shines—
The burrow's refactored, perfectly aligned! 🌟

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/avatars

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In apps/web/app/api/import/route.ts, the onConflictDoUpdate handlers for folders and notes only target id and will happily update rows regardless of userId; in a multi-tenant DB this could let imported data overwrite other users’ records on ID collision, so consider scoping conflicts (and/or update conditions) by both id and userId.
  • In apps/web/app/api/tasks/sync/route.ts, the delete-then-insert replacement of tasks for a note is not wrapped in a transaction, which can leave a note with no tasks or a partially written set if the insert fails; consider using a transaction (or UPSERT pattern) around the delete/insert block for better atomicity.
  • The updated insertTemplate implementation in NoteFooterBar uses editor.insertBlocks(blocks as any, blocks[0] as any, 'after') when the document is empty, which passes a block from the new content as the anchor instead of an existing document block; this is likely incompatible with the editor API and could no-op or throw, so it would be safer to use the current first document block (or a dedicated insertion API) as the anchor.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `apps/web/app/api/import/route.ts`, the `onConflictDoUpdate` handlers for folders and notes only target `id` and will happily update rows regardless of `userId`; in a multi-tenant DB this could let imported data overwrite other users’ records on ID collision, so consider scoping conflicts (and/or update conditions) by both `id` and `userId`.
- In `apps/web/app/api/tasks/sync/route.ts`, the delete-then-insert replacement of tasks for a note is not wrapped in a transaction, which can leave a note with no tasks or a partially written set if the insert fails; consider using a transaction (or UPSERT pattern) around the delete/insert block for better atomicity.
- The updated `insertTemplate` implementation in `NoteFooterBar` uses `editor.insertBlocks(blocks as any, blocks[0] as any, 'after')` when the document is empty, which passes a block from the new content as the anchor instead of an existing document block; this is likely incompatible with the editor API and could no-op or throw, so it would be safer to use the current first document block (or a dedicated insertion API) as the anchor.

## Individual Comments

### Comment 1
<location> `apps/web/features/editor/components/note-footer-bar.tsx:71-73` </location>
<code_context>
-		if (!anchor) return
-		editor.insertBlocks(blocks as any, anchor, 'after')
-		editor.setTextCursorPosition(anchor, 'end')
+		if (editor.document?.length) {
+			editor.replaceBlocks(editor.document, blocks as any)
+		} else {
+			editor.insertBlocks(blocks as any, blocks[0] as any, 'after')
+		}
</code_context>

<issue_to_address>
**issue (bug_risk):** Anchor passed to insertBlocks for an empty document looks incorrect and may no-op or misplace the template.

In the `else` branch, `insertBlocks(blocks as any, blocks[0] as any, 'after')` uses `blocks[0]` as the anchor even though it isn’t in `editor.document`, so the insertion may no-op or land in an unexpected place. Instead, anchor to an existing root node (e.g. `editor.document?.[0]`) or use an API that inserts at the start of an empty document to preserve the previous behavior.
</issue_to_address>

### Comment 2
<location> `apps/web/app/api/import/route.ts:117-123` </location>
<code_context>

-		return NextResponse.json({ success: true })
+		// Execute Batched Inserts in Transaction
+		await db.transaction(async (tx) => {
+			// 1. Insert Folders
+			if (foldersToInsert.length > 0) {
+				const chunkSize = 1000
+				for (let i = 0; i < foldersToInsert.length; i += chunkSize) {
+					const chunk = foldersToInsert.slice(i, i + chunkSize)
+					await tx.insert(folders)
+						.values(chunk)
+						.onConflictDoUpdate({
</code_context>

<issue_to_address>
**issue (bug_risk):** Import upserts can overwrite other users’ data because conflicts are resolved only on id, not (userId, id).

The upserts for folders and notes use `onConflictDoUpdate({ target: folders.id })` / `target: notes.id` while also setting `userId` in the values. If two users import records with the same `id`, the later import will overwrite the other user’s row instead of creating a separate record.

To avoid cross-user data clobbering, either:
- Use a composite unique key `(userId, id)` as the conflict target, or
- When a conflict is detected with a different `userId`, skip the upsert or assign a new ID for the importing user.

As written, a malicious or buggy client can overwrite another user’s data by choosing colliding IDs.
</issue_to_address>

### Comment 3
<location> `apps/web/app/api/tasks/sync/route.ts:74-78` </location>
<code_context>
+			}
+		})
+
+		// Replace note-scoped task rows atomically enough for current usage.
+		await db.delete(tasks).where(and(eq(tasks.noteId, body.noteId), eq(tasks.userId, userId)))
+		if (rows.length > 0) {
+			await db.insert(tasks).values(rows)
 		}
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Deleting and reinserting tasks without an explicit transaction can cause inconsistent state under concurrent calls.

The current sequence:
1) delete all tasks for the note/user
2) insert the rebuilt rows
can race under concurrent syncs: a later delete can remove rows inserted by an earlier sync, and any error after the delete leaves the note with no tasks. Please wrap the delete + insert in a single transaction (if `getDatabase` supports it) so each sync is all-or-nothing and not affected by concurrent calls.

```suggestion
		// Replace note-scoped task rows atomically within a transaction to avoid
		// inconsistencies under concurrent syncs.
		await db.transaction(async (tx) => {
			await tx
				.delete(tasks)
				.where(and(eq(tasks.noteId, body.noteId), eq(tasks.userId, userId)))

			if (rows.length > 0) {
				await tx.insert(tasks).values(rows)
			}
		})
```
</issue_to_address>

### Comment 4
<location> `apps/web/__tests__/api/ai-auth-routes.test.ts:126-110` </location>
<code_context>
+describe('AI auth route normalization', () => {
</code_context>

<issue_to_address>
**suggestion (testing):** Broaden AI auth route tests to cover successful flows and rate-limit errors for stronger regression protection

The existing tests cover unauthenticated delegation to `requireAuth` and `userId` scoping, but they miss the new success and error paths on these sensitive routes. To align with the "AUTH-001 verification" goal, consider adding:

- `POST /api/ai/prompt` success: mock `requireAuth` as authenticated, `checkRateLimit` as allowed, and `sendPrompt` to return a response; assert the response shape (e.g. `text`, `tokensUsed`) and that `aiPromptLog.insert` receives the correct `userId` and provider/model.
- `POST /api/ai/prompt` rate-limited: mock `checkRateLimit` to return `allowed: false` and assert a 429 with the expected message.
- `GET /api/ai/config` with existing config: set `_setResults` to a single row and assert the decrypted response matches the DB data for the authenticated `userId`.
- `GET /api/ai/usage` unauthenticated: mirror your prompt test to ensure the unauthenticated `requireAuth` result is returned directly and the DB is not queried.

Suggested implementation:

```typescript
	mockDb.set.mockClear()
})

describe('AI auth route normalization', () => {
	it('POST /api/ai/prompt returns prompt response and logs usage when authenticated and not rate-limited', async () => {
		const userId = 'user_123'
		const provider = 'openai'
		const model = 'gpt-4'
		const prompt = 'Hello, AI'

		// authenticated user
		mockRequireAuth.mockResolvedValue({
			authenticated: true,
			userId
		})

		// rate limit allows the request
		mockCheckRateLimit.mockResolvedValue({
			allowed: true,
			limit: 1000,
			remaining: 999,
			reset: new Date().toISOString()
		})

		// AI provider returns a response
		const promptResult = {
			text: 'Hi there!',
			tokensUsed: 42,
			provider,
			model
		}
		mockSendPrompt.mockResolvedValue(promptResult)

		const request = new Request('http://localhost/api/ai/prompt', {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ prompt })
		})

		const response = await promptPost(request)
		expect(response.status).toBe(200)

		const body = await response.json()
		expect(body).toEqual(
			expect.objectContaining({
				text: promptResult.text,
				tokensUsed: promptResult.tokensUsed,
				provider,
				model
			})
		)

		// ensure we log usage with the correct user and provider/model
		expect(mockDb.insert).toHaveBeenCalled()
		expect(mockDb.values).toHaveBeenCalledWith(
			expect.objectContaining({
				userId,
				provider,
				model
			})
		)
	})

	it('POST /api/ai/prompt returns 429 when rate-limited', async () => {
		const userId = 'user_456'

		// authenticated user
		mockRequireAuth.mockResolvedValue({
			authenticated: true,
			userId
		})

		// rate limit denies the request
		mockCheckRateLimit.mockResolvedValue({
			allowed: false,
			limit: 1000,
			remaining: 0,
			reset: new Date().toISOString()
		})

		const request = new Request('http://localhost/api/ai/prompt', {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ prompt: 'This should be rate limited' })
		})

		const response = await promptPost(request)
		expect(response.status).toBe(429)

		const body = await response.json()
		expect(body).toEqual(
			expect.objectContaining({
				error: expect.any(String)
			})
		)
	})

	it('GET /api/ai/config returns decrypted config for authenticated user', async () => {
		const userId = 'user_789'
		const provider = 'openai'
		const model = 'gpt-4o'
		const baseUrl = 'https://api.openai.com/v1'
		const encryptedApiKey = 'encrypted-key'
		const decryptedApiKey = 'sk-test-decrypted'

		mockRequireAuth.mockResolvedValue({
			authenticated: true,
			userId
		})

		// DB returns a single config row for this user
		mockDb._setResults([
			{
				userId,
				provider,
				model,
				baseUrl,
				apiKeyEncrypted: encryptedApiKey
			}
		])

		// decrypt returns a usable API key
		if (typeof mockDecrypt === 'function') {
			mockDecrypt.mockResolvedValue(decryptedApiKey)
		}

		const request = new Request('http://localhost/api/ai/config', {
			method: 'GET'
		})

		const response = await configGet(request)
		expect(response.status).toBe(200)

		const body = await response.json()
		expect(body).toEqual(
			expect.objectContaining({
				provider,
				model,
				baseUrl
			})
		)

		// if we have a decrypt mock, ensure the decrypted key is surfaced
		if (typeof mockDecrypt === 'function') {
			expect(body).toEqual(
				expect.objectContaining({
					apiKey: decryptedApiKey
				})
			)
		}
	})

	it('GET /api/ai/usage returns auth helper response when unauthenticated and does not hit the DB', async () => {
		const unauthResponse = new Response(JSON.stringify({ error: 'Unauthorized' }), {
			status: 401,
			headers: { 'content-type': 'application/json' }
		})

		mockRequireAuth.mockResolvedValue({
			authenticated: false,
			response: unauthResponse
		})

		const request = new Request('http://localhost/api/ai/usage', {
			method: 'GET'
		})

		const response = await usageGet(request)

		// returns the exact response from the auth helper
		expect(response).toBe(unauthResponse)

		// ensure we did not query usage stats while unauthenticated
		expect(mockDb.select).not.toHaveBeenCalled()
		expect(mockDb.from).not.toHaveBeenCalled()
	})
})

describe('AI auth route normalization', () => {

```

These edits assume the following already exist in this test file (they likely do, based on the existing tests and mocks):
1. Handlers:
   - `promptPost` for `POST /api/ai/prompt`
   - `configGet` for `GET /api/ai/config`
   - `usageGet` for `GET /api/ai/usage`
2. Mocks:
   - `mockRequireAuth` for the authentication helper
   - `mockCheckRateLimit` for the rate-limit helper used by the prompt route
   - `mockSendPrompt` (or similarly named) for the AI provider call used in the prompt route
   - `mockDb` with `_setResults`, `select`, `from`, `insert`, and `values` mocks
   - An optional `mockDecrypt` (or similar) for decrypting the stored API key

If the actual names differ (e.g. `promptPOST` instead of `promptPost`, `getConfig` instead of `configGet`, `decrypt` instead of `mockDecrypt`), update the new tests to match the real imports/mocks. Also, if the config handler returns a slightly different shape (e.g. nested under `config`), adjust the `expect(body).toEqual(expect.objectContaining(...))` assertions accordingly. The new describe block uses the same "AI auth route normalization" label; if you prefer a unique name, you can rename it to something like `"AI auth route normalization (success and error flows)"` without changing the test logic.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +71 to +73
if (editor.document?.length) {
editor.replaceBlocks(editor.document, blocks as any)
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Anchor passed to insertBlocks for an empty document looks incorrect and may no-op or misplace the template.

In the else branch, insertBlocks(blocks as any, blocks[0] as any, 'after') uses blocks[0] as the anchor even though it isn’t in editor.document, so the insertion may no-op or land in an unexpected place. Instead, anchor to an existing root node (e.g. editor.document?.[0]) or use an API that inserts at the start of an empty document to preserve the previous behavior.

Comment on lines +117 to +123
await db.transaction(async (tx) => {
// 1. Insert Folders
if (foldersToInsert.length > 0) {
const chunkSize = 1000
for (let i = 0; i < foldersToInsert.length; i += chunkSize) {
const chunk = foldersToInsert.slice(i, i + chunkSize)
await tx.insert(folders)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Import upserts can overwrite other users’ data because conflicts are resolved only on id, not (userId, id).

The upserts for folders and notes use onConflictDoUpdate({ target: folders.id }) / target: notes.id while also setting userId in the values. If two users import records with the same id, the later import will overwrite the other user’s row instead of creating a separate record.

To avoid cross-user data clobbering, either:

  • Use a composite unique key (userId, id) as the conflict target, or
  • When a conflict is detected with a different userId, skip the upsert or assign a new ID for the importing user.

As written, a malicious or buggy client can overwrite another user’s data by choosing colliding IDs.

Comment on lines +74 to 78
// Replace note-scoped task rows atomically enough for current usage.
await db.delete(tasks).where(and(eq(tasks.noteId, body.noteId), eq(tasks.userId, userId)))
if (rows.length > 0) {
await db.insert(tasks).values(rows)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Deleting and reinserting tasks without an explicit transaction can cause inconsistent state under concurrent calls.

The current sequence:

  1. delete all tasks for the note/user
  2. insert the rebuilt rows
    can race under concurrent syncs: a later delete can remove rows inserted by an earlier sync, and any error after the delete leaves the note with no tasks. Please wrap the delete + insert in a single transaction (if getDatabase supports it) so each sync is all-or-nothing and not affected by concurrent calls.
Suggested change
// Replace note-scoped task rows atomically enough for current usage.
await db.delete(tasks).where(and(eq(tasks.noteId, body.noteId), eq(tasks.userId, userId)))
if (rows.length > 0) {
await db.insert(tasks).values(rows)
}
// Replace note-scoped task rows atomically within a transaction to avoid
// inconsistencies under concurrent syncs.
await db.transaction(async (tx) => {
await tx
.delete(tasks)
.where(and(eq(tasks.noteId, body.noteId), eq(tasks.userId, userId)))
if (rows.length > 0) {
await tx.insert(tasks).values(rows)
}
})

;({ POST: promptPost } = await import('@/app/api/ai/prompt/route'))
;({ GET: configGet } = await import('@/app/api/ai/config/route'))
;({ GET: usageGet } = await import('@/app/api/ai/usage/route'))
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Broaden AI auth route tests to cover successful flows and rate-limit errors for stronger regression protection

The existing tests cover unauthenticated delegation to requireAuth and userId scoping, but they miss the new success and error paths on these sensitive routes. To align with the "AUTH-001 verification" goal, consider adding:

  • POST /api/ai/prompt success: mock requireAuth as authenticated, checkRateLimit as allowed, and sendPrompt to return a response; assert the response shape (e.g. text, tokensUsed) and that aiPromptLog.insert receives the correct userId and provider/model.
  • POST /api/ai/prompt rate-limited: mock checkRateLimit to return allowed: false and assert a 429 with the expected message.
  • GET /api/ai/config with existing config: set _setResults to a single row and assert the decrypted response matches the DB data for the authenticated userId.
  • GET /api/ai/usage unauthenticated: mirror your prompt test to ensure the unauthenticated requireAuth result is returned directly and the DB is not queried.

Suggested implementation:

	mockDb.set.mockClear()
})

describe('AI auth route normalization', () => {
	it('POST /api/ai/prompt returns prompt response and logs usage when authenticated and not rate-limited', async () => {
		const userId = 'user_123'
		const provider = 'openai'
		const model = 'gpt-4'
		const prompt = 'Hello, AI'

		// authenticated user
		mockRequireAuth.mockResolvedValue({
			authenticated: true,
			userId
		})

		// rate limit allows the request
		mockCheckRateLimit.mockResolvedValue({
			allowed: true,
			limit: 1000,
			remaining: 999,
			reset: new Date().toISOString()
		})

		// AI provider returns a response
		const promptResult = {
			text: 'Hi there!',
			tokensUsed: 42,
			provider,
			model
		}
		mockSendPrompt.mockResolvedValue(promptResult)

		const request = new Request('http://localhost/api/ai/prompt', {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ prompt })
		})

		const response = await promptPost(request)
		expect(response.status).toBe(200)

		const body = await response.json()
		expect(body).toEqual(
			expect.objectContaining({
				text: promptResult.text,
				tokensUsed: promptResult.tokensUsed,
				provider,
				model
			})
		)

		// ensure we log usage with the correct user and provider/model
		expect(mockDb.insert).toHaveBeenCalled()
		expect(mockDb.values).toHaveBeenCalledWith(
			expect.objectContaining({
				userId,
				provider,
				model
			})
		)
	})

	it('POST /api/ai/prompt returns 429 when rate-limited', async () => {
		const userId = 'user_456'

		// authenticated user
		mockRequireAuth.mockResolvedValue({
			authenticated: true,
			userId
		})

		// rate limit denies the request
		mockCheckRateLimit.mockResolvedValue({
			allowed: false,
			limit: 1000,
			remaining: 0,
			reset: new Date().toISOString()
		})

		const request = new Request('http://localhost/api/ai/prompt', {
			method: 'POST',
			headers: { 'content-type': 'application/json' },
			body: JSON.stringify({ prompt: 'This should be rate limited' })
		})

		const response = await promptPost(request)
		expect(response.status).toBe(429)

		const body = await response.json()
		expect(body).toEqual(
			expect.objectContaining({
				error: expect.any(String)
			})
		)
	})

	it('GET /api/ai/config returns decrypted config for authenticated user', async () => {
		const userId = 'user_789'
		const provider = 'openai'
		const model = 'gpt-4o'
		const baseUrl = 'https://api.openai.com/v1'
		const encryptedApiKey = 'encrypted-key'
		const decryptedApiKey = 'sk-test-decrypted'

		mockRequireAuth.mockResolvedValue({
			authenticated: true,
			userId
		})

		// DB returns a single config row for this user
		mockDb._setResults([
			{
				userId,
				provider,
				model,
				baseUrl,
				apiKeyEncrypted: encryptedApiKey
			}
		])

		// decrypt returns a usable API key
		if (typeof mockDecrypt === 'function') {
			mockDecrypt.mockResolvedValue(decryptedApiKey)
		}

		const request = new Request('http://localhost/api/ai/config', {
			method: 'GET'
		})

		const response = await configGet(request)
		expect(response.status).toBe(200)

		const body = await response.json()
		expect(body).toEqual(
			expect.objectContaining({
				provider,
				model,
				baseUrl
			})
		)

		// if we have a decrypt mock, ensure the decrypted key is surfaced
		if (typeof mockDecrypt === 'function') {
			expect(body).toEqual(
				expect.objectContaining({
					apiKey: decryptedApiKey
				})
			)
		}
	})

	it('GET /api/ai/usage returns auth helper response when unauthenticated and does not hit the DB', async () => {
		const unauthResponse = new Response(JSON.stringify({ error: 'Unauthorized' }), {
			status: 401,
			headers: { 'content-type': 'application/json' }
		})

		mockRequireAuth.mockResolvedValue({
			authenticated: false,
			response: unauthResponse
		})

		const request = new Request('http://localhost/api/ai/usage', {
			method: 'GET'
		})

		const response = await usageGet(request)

		// returns the exact response from the auth helper
		expect(response).toBe(unauthResponse)

		// ensure we did not query usage stats while unauthenticated
		expect(mockDb.select).not.toHaveBeenCalled()
		expect(mockDb.from).not.toHaveBeenCalled()
	})
})

describe('AI auth route normalization', () => {

These edits assume the following already exist in this test file (they likely do, based on the existing tests and mocks):

  1. Handlers:
    • promptPost for POST /api/ai/prompt
    • configGet for GET /api/ai/config
    • usageGet for GET /api/ai/usage
  2. Mocks:
    • mockRequireAuth for the authentication helper
    • mockCheckRateLimit for the rate-limit helper used by the prompt route
    • mockSendPrompt (or similarly named) for the AI provider call used in the prompt route
    • mockDb with _setResults, select, from, insert, and values mocks
    • An optional mockDecrypt (or similar) for decrypting the stored API key

If the actual names differ (e.g. promptPOST instead of promptPost, getConfig instead of configGet, decrypt instead of mockDecrypt), update the new tests to match the real imports/mocks. Also, if the config handler returns a slightly different shape (e.g. nested under config), adjust the expect(body).toEqual(expect.objectContaining(...)) assertions accordingly. The new describe block uses the same "AI auth route normalization" label; if you prefer a unique name, you can rename it to something like "AI auth route normalization (success and error flows)" without changing the test logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant