Skip to content

feat(core): phase 2 shared schemas and API payload validation#102

Merged
remcostoeten merged 1 commit intodaddyfrom
feature/p2-core-orchestration
Feb 14, 2026
Merged

feat(core): phase 2 shared schemas and API payload validation#102
remcostoeten merged 1 commit intodaddyfrom
feature/p2-core-orchestration

Conversation

@remcostoeten
Copy link
Owner

@remcostoeten remcostoeten commented Feb 14, 2026

Summary\n- scaffold @skriuw/core package (schemas/types/rules + exports)\n- add zod validation to notes/settings/tasks mutation endpoints, ai config, and import\n- add schema unit tests and API invalid-payload integration tests\n- update dashboard progress for Phase 2 tasks\n\n## Key validation\n- packages/core schema tests passing\n- apps/web API validation payload tests passing\n- apps/web AI auth-route tests passing\n

Summary by Sourcery

Introduce a shared @skriuw/core package with Zod schemas, types, and business rules, and adopt these schemas for API payload validation across key web app endpoints.

New Features:

  • Add @skriuw/core package exporting shared schemas, types, and validation rules for notes, tasks, shortcuts, settings, AI, and import flows.

Enhancements:

  • Apply shared Zod schemas to validate payloads for notes create/update, settings upsert, task sync and item update, AI config, and import API routes, returning structured 400 responses on invalid input.
  • Expose a shared guest user identifier in the web auth helper for reuse across the app.
  • Update the audit dashboard documentation to mark Phase 2 core package and validation tasks as completed.

Build:

  • Configure the @skriuw/core package build with tsup, TypeScript project settings, and workspace wiring into the web app.

Tests:

  • Add schema unit tests under @skriuw/core and integration tests in the web app to ensure invalid API payloads are rejected with detailed Zod error responses.

Summary by CodeRabbit

  • New Features

    • Centralized runtime validation and shared schemas/types now available across the app and a new core package.
    • New configurable AI and import schemas with input constraints and sensible defaults.
  • Bug Fixes

    • Consistent 400 responses for invalid payloads and stricter field-level validation across endpoints.
    • Enforced limits for notes, tasks, shortcuts, and imports.
  • Tests

    • Expanded validation test suite covering endpoints and schema edge cases.
  • Documentation

    • Audit dashboard updated with task completion status.

@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 Error Error Feb 14, 2026 5:44pm

@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

Introduces a new @skriuw/core package that centralizes Zod schemas, shared types, and business rules, then wires those schemas into web app API routes for notes, settings, tasks, AI config, and import, adding consistent 400 responses on invalid payloads plus tests and dashboard updates for Phase 2 validation work.

Sequence diagram for API request validation with @skriuw/core schemas

sequenceDiagram
  actor Client
  participant Route as NotesRoute_POST
  participant Core as Core_CreateNoteSchema
  participant DB as Database

  Client->>Route: HTTP POST /api/notes with JSON body
  Route->>Route: requireMutation
  Route->>Route: request.json()
  Route->>Core: CreateNoteSchema.safeParse(body)
  alt payload invalid
    Core-->>Route: success false, error
    Route-->>Client: 400 Invalid payload with error.flatten
  else payload valid
    Core-->>Route: success true, data
    Route->>Route: normalize type, timestamps
    Route->>DB: insert into folders or notes
    DB-->>Route: inserted record
    Route-->>Client: 200 JSON note or folder response
  end
Loading

Class diagram for @skriuw/core schemas, types, and rules

classDiagram
  class Rules {
    <<module>>
    MAX_NOTE_NAME_LENGTH
    MAX_NOTE_CONTENT_BYTES
    MAX_TASK_CONTENT_LENGTH
    MAX_TASK_DESCRIPTION_LENGTH
    MAX_SHORTCUT_KEYS_PER_COMBO
    MAX_SHORTCUT_COMBOS
    AI_DEFAULT_TEMPERATURE
    AI_MIN_TEMPERATURE
    AI_MAX_TEMPERATURE
    AI_DAILY_PROMPT_LIMIT
    IMPORT_MAX_PAYLOAD_BYTES
    IMPORT_MAX_ITEMS
    RATE_LIMITS
  }

  class CreateNoteSchema {
    +string name
    +string type
    +unknown content
    +string parentFolderId
    +string icon
    +string coverImage
    +string[] tags
    +boolean pinned
    +boolean favorite
    +boolean isPublic
  }

  class UpdateNoteSchema {
    +string id
    +string name
    +unknown content
    +string parentFolderId
    +string icon
    +string coverImage
    +string[] tags
    +boolean pinned
    +number pinnedAt
    +boolean favorite
    +boolean isPublic
    +string publicId
    +number publicViews
    +number updatedAt
  }

  class NoteResponseSchema {
    +string id
    +string type
    +string name
    +unknown content
    +string parentFolderId
    +string icon
    +string coverImage
    +string[] tags
    +boolean pinned
    +number pinnedAt
    +boolean favorite
    +boolean isPublic
    +string publicId
    +number publicViews
    +string userId
    +number createdAt
    +number updatedAt
  }

  class FolderResponseSchema {
    +string id
    +string type
    +string name
    +string parentFolderId
    +boolean pinned
    +number pinnedAt
    +string userId
    +number createdAt
    +number updatedAt
    +unknown[] children
  }

  class TaskSchema {
    +string id
    +string noteId
    +string noteName
    +string blockId
    +string content
    +string description
    +number checked
    +number dueDate
    +string parentTaskId
    +number position
    +number createdAt
    +number updatedAt
  }

  class TaskCreateSchema {
    +string noteId
    +string blockId
    +string content
    +string description
    +boolean checked
    +number dueDate
    +string parentTaskId
    +number position
  }

  class TaskUpdateSchema {
    +string content
    +string description
    +boolean checked
    +number dueDate
    +number updatedAt
  }

  class TaskSyncItemSchema {
    +string blockId
    +string content
    +boolean checked
    +string parentTaskId
    +number position
  }

  class TaskSyncPayloadSchema {
    +string noteId
    +TaskSyncItemSchema[] tasks
  }

  class AIConfigCreateSchema {
    +string provider
    +string model
    +string basePrompt
    +number temperature
  }

  class AIConfigPatchSchema {
    +string provider
    +string model
    +string basePrompt
    +number temperature
  }

  class AIConfigResponseSchema {
    +string id
    +string provider
    +string model
    +string basePrompt
    +number temperature
    +boolean isActive
  }

  class AIPromptRequestSchema {
    +string prompt
    +boolean isTest
    +string configId
  }

  class AIPromptResponseSchema {
    +string content
    +number tokensUsed
    +string provider
    +string model
  }

  class AIUsageResponseSchema {
    +number promptsUsedToday
    +number promptsRemaining
    +number resetAt
  }

  class SettingsUpsertSchema {
    +record settings
  }

  class SettingsRecordSchema {
    +string id
    +string key
    +record value
    +string userId
    +number createdAt
    +number updatedAt
  }

  class ImportItemSchema {
    +string id
    +string name
    +string type
    +boolean pinned
    +number pinnedAt
    +number createdAt
    +number updatedAt
    +string parentFolderId
    +unknown content
    +unknown favorite
    +ImportItemSchema[] children
  }

  class ImportPayloadSchema {
    +ImportItemSchema[] items
  }

  class ShortcutUpsertSchema {
    +string id
    +string[][] keys
    +number customizedAt
  }

  class ShortcutResponseSchema {
    +string id
    +string[][] keys
    +string userId
    +number customizedAt
    +number createdAt
    +number updatedAt
  }

  class CoreTypes {
    <<module>>
    +type Id
    +type Timestamp
    +type NoteType
    +type CreateNoteInput
    +type UpdateNoteInput
    +type NoteResponse
    +type FolderResponse
    +type NoteOrFolderResponse
    +type SettingsUpsertInput
    +type SettingsRecord
    +type Task
    +type TaskCreateInput
    +type TaskUpdateInput
    +type TaskSyncItem
    +type TaskSyncPayload
    +type KeyCombo
    +type ShortcutUpsertInput
    +type ShortcutResponse
    +type AIConfigCreateInput
    +type AIConfigPatchInput
    +type AIConfigResponse
    +type AIPromptRequest
    +type AIPromptResponse
    +type AIUsageResponse
    +type ImportItem
    +type ImportPayload
  }

  Rules <.. CreateNoteSchema
  Rules <.. UpdateNoteSchema
  Rules <.. TaskSchema
  Rules <.. TaskCreateSchema
  Rules <.. TaskUpdateSchema
  Rules <.. TaskSyncItemSchema
  Rules <.. AIConfigCreateSchema
  Rules <.. AIConfigPatchSchema
  Rules <.. ImportPayloadSchema
  Rules <.. ShortcutUpsertSchema

  CreateNoteSchema <|-- CreateNoteInput
  UpdateNoteSchema <|-- UpdateNoteInput
  NoteResponseSchema <|-- NoteResponse
  FolderResponseSchema <|-- FolderResponse
  TaskSchema <|-- Task
  TaskCreateSchema <|-- TaskCreateInput
  TaskUpdateSchema <|-- TaskUpdateInput
  TaskSyncItemSchema <|-- TaskSyncItem
  TaskSyncPayloadSchema <|-- TaskSyncPayload
  AIConfigCreateSchema <|-- AIConfigCreateInput
  AIConfigPatchSchema <|-- AIConfigPatchInput
  AIConfigResponseSchema <|-- AIConfigResponse
  AIPromptRequestSchema <|-- AIPromptRequest
  AIPromptResponseSchema <|-- AIPromptResponse
  AIUsageResponseSchema <|-- AIUsageResponse
  SettingsUpsertSchema <|-- SettingsUpsertInput
  SettingsRecordSchema <|-- SettingsRecord
  ImportItemSchema <|-- ImportItem
  ImportPayloadSchema <|-- ImportPayload
  ShortcutUpsertSchema <|-- ShortcutUpsertInput
  ShortcutResponseSchema <|-- ShortcutResponse

  TaskSyncPayloadSchema *-- TaskSyncItemSchema
  ImportPayloadSchema *-- ImportItemSchema
  ImportItemSchema *-- ImportItemSchema
  ShortcutResponseSchema o-- ShortcutUpsertSchema

  CoreTypes ..> CreateNoteSchema
  CoreTypes ..> UpdateNoteSchema
  CoreTypes ..> NoteResponseSchema
  CoreTypes ..> FolderResponseSchema
  CoreTypes ..> TaskSchema
  CoreTypes ..> TaskCreateSchema
  CoreTypes ..> TaskUpdateSchema
  CoreTypes ..> TaskSyncItemSchema
  CoreTypes ..> TaskSyncPayloadSchema
  CoreTypes ..> AIConfigCreateSchema
  CoreTypes ..> AIConfigPatchSchema
  CoreTypes ..> AIConfigResponseSchema
  CoreTypes ..> AIPromptRequestSchema
  CoreTypes ..> AIPromptResponseSchema
  CoreTypes ..> AIUsageResponseSchema
  CoreTypes ..> SettingsUpsertSchema
  CoreTypes ..> SettingsRecordSchema
  CoreTypes ..> ImportItemSchema
  CoreTypes ..> ImportPayloadSchema
  CoreTypes ..> ShortcutUpsertSchema
  CoreTypes ..> ShortcutResponseSchema
Loading

File-Level Changes

Change Details Files
Add @skriuw/core package with shared Zod schemas, types, and business rules.
  • Create package metadata, build, and TypeScript config for the core package.
  • Define reusable validation rules/constants for notes, tasks, shortcuts, AI, and import limits.
  • Implement Zod schemas for notes, settings, tasks (CRUD + sync), shortcuts, AI config/usage, and import payloads, plus exported TypeScript types aggregating these schemas.
  • Expose schemas, types, and rules via a central index for consumption by other packages.
packages/core/package.json
packages/core/tsconfig.json
packages/core/tsup.config.ts
packages/core/src/index.ts
packages/core/src/rules/index.ts
packages/core/src/schemas/index.ts
packages/core/src/schemas/notes.ts
packages/core/src/schemas/settings.ts
packages/core/src/schemas/tasks.ts
packages/core/src/schemas/shortcuts.ts
packages/core/src/schemas/ai.ts
packages/core/src/schemas/import.ts
packages/core/src/types/index.ts
packages/core/src/schemas/__tests__/schemas.test.ts
Wire core schemas into web API routes to validate request payloads and standardize error responses.
  • Add @skriuw/core as a dependency of the web app.
  • Replace ad-hoc or inline Zod validation with imports from @skriuw/core in notes, settings, tasks, AI config, and import routes.
  • Introduce small invalidPayload helpers in several routes to return 400 with a common { error: 'Invalid payload', details: error.flatten() } shape.
  • Update API handlers to operate on parsed payloads instead of raw request bodies and remove now-redundant manual checks.
apps/web/package.json
apps/web/app/api/notes/route.ts
apps/web/app/api/settings/route.ts
apps/web/app/api/tasks/item/[taskId]/route.ts
apps/web/app/api/tasks/sync/route.ts
apps/web/app/api/ai/config/route.ts
apps/web/app/api/import/route.ts
Add tests to enforce schema correctness and API behavior on invalid payloads.
  • Introduce an integration test suite that mocks auth and persistence layers and asserts that key API endpoints return 400 with Zod error details for malformed payloads.
  • Ensure DB-layer mocks short-circuit when validation fails, so no writes occur on invalid input.
  • Prepare placeholder or real schema unit tests in the core package (file scaffold present).
apps/web/__tests__/api/validation-payloads.test.ts
packages/core/src/schemas/__tests__/schemas.test.ts
Update documentation and shared auth constants to reflect Phase 2 completion.
  • Mark Phase 2 audit dashboard tasks as done with completion notes and clarify task descriptions.
  • Export GUEST_USER_ID from api-auth to mirror the shared guest ID constant used elsewhere.
docs/audit-dashboard.html
apps/web/lib/api-auth.ts
bun.lock

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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 14, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds a new @skriuw/core package (schemas, types, rules) and updates multiple web API routes to use those Zod schemas for request validation; includes tests for validation payloads and small exports/packaging changes across the repo.

Changes

Cohort / File(s) Summary
Core Package config & entry
packages/core/package.json, packages/core/tsconfig.json, packages/core/tsup.config.ts, packages/core/src/index.ts
New workspace package setup, build/type configs, and barrel entry point for @skriuw/core.
Core rules & constants
packages/core/src/rules/index.ts
New exported limits and rate constants (note/task lengths, AI temp bounds, import limits, shortcut limits) used across schemas and validations.
Core schemas (data models)
packages/core/src/schemas/...
packages/core/src/schemas/notes.ts, .../tasks.ts, .../settings.ts, .../ai.ts, .../import.ts, .../shortcuts.ts, packages/core/src/schemas/index.ts
Zod schemas and inferred types for notes, tasks, settings, AI config, import payloads, and shortcuts; re-exported from schemas index.
Core types
packages/core/src/types/index.ts
Type-only barrel exposing primitive aliases and re-exporting schema-derived types for consumers.
Core tests
packages/core/src/schemas/__tests__/schemas.test.ts
Comprehensive unit tests exercising positive/negative cases and rule-enforced limits for the new schemas.
Web app: route validation integration
apps/web/app/api/notes/route.ts, apps/web/app/api/settings/route.ts, apps/web/app/api/ai/config/route.ts, apps/web/app/api/tasks/sync/route.ts, apps/web/app/api/tasks/item/[taskId]/route.ts, apps/web/app/api/import/route.ts
Routes replaced ad-hoc payload checks with @skriuw/core Zod schemas (safeParse), added a shared invalidPayload 400 response helper, and switched route logic to use parsed payload fields.
Web app: tests & mocks
apps/web/__tests__/api/validation-payloads.test.ts
New tests that mock auth, DB adapters, encryption, route handlers, and assert 400 responses + that DB is not called on invalid payloads.
Web app: dependency & small export change
apps/web/package.json, apps/web/lib/api-auth.ts
Added @skriuw/core as a workspace dependency; re-exported GUEST_USER_ID constant (aliasing shared import).
Docs
docs/audit-dashboard.html
Updated phase task entries to include done and note fields (task completion metadata).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

codex

Poem

🐰 I hopped a path of schemas bright and neat,
Central rules and Zod made every payload meet,
Tests mocked the burrow, routes tidied with care,
A carrot of types shared everywhere,
Hooray — validation carrots for all to eat!

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (16 files):

⚔️ AGENTS.md (content)
⚔️ apps/web/__tests__/api/import-route.test.ts (content)
⚔️ apps/web/app/api/ai/config/route.ts (content)
⚔️ apps/web/app/api/import/route.ts (content)
⚔️ apps/web/app/api/notes/route.ts (content)
⚔️ apps/web/app/api/settings/route.ts (content)
⚔️ apps/web/app/api/tasks/item/[taskId]/route.ts (content)
⚔️ apps/web/app/api/tasks/sync/route.ts (content)
⚔️ apps/web/features/editor/components/note-footer-bar.tsx (content)
⚔️ apps/web/features/tasks/hooks/use-tasks-query.ts (content)
⚔️ apps/web/lib/api-auth.ts (content)
⚔️ apps/web/package.json (content)
⚔️ bun.lock (content)
⚔️ docs/audit-dashboard.html (content)
⚔️ packages/core/shared/helpers/determine-platform.ts (content)
⚔️ packages/core/shared/platform.ts (content)

These conflicts must be resolved before merging into daddy.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main objective of the changeset: introducing a shared core package with schemas and adding validation to API endpoints.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/p2-core-orchestration

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 5 issues, and left some high level feedback:

  • The invalidPayload helper is duplicated across several API routes; consider extracting a shared utility (e.g., in apps/web/lib/api-validation.ts) to centralize error formatting and status handling.
  • Each schema file redefines TimestampSchema as z.number().int().nonnegative(); you could export a shared primitive (e.g., from a schemas/primitives.ts) to avoid duplication and keep time-related validation consistent.
  • In ImportItemSchema you type it as z.ZodType<any>, which loses static type safety; consider defining the union shape explicitly with proper generics so the inferred ImportItem type is more precise and less any-driven.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `invalidPayload` helper is duplicated across several API routes; consider extracting a shared utility (e.g., in `apps/web/lib/api-validation.ts`) to centralize error formatting and status handling.
- Each schema file redefines `TimestampSchema` as `z.number().int().nonnegative()`; you could export a shared primitive (e.g., from a `schemas/primitives.ts`) to avoid duplication and keep time-related validation consistent.
- In `ImportItemSchema` you type it as `z.ZodType<any>`, which loses static type safety; consider defining the union shape explicitly with proper generics so the inferred `ImportItem` type is more precise and less `any`-driven.

## Individual Comments

### Comment 1
<location> `apps/web/app/api/ai/config/route.ts:8-14` </location>
<code_context>
 import { encryptPrompt, decryptPrompt } from '@/features/ai/utilities'
+import { z } from 'zod'
+
+function invalidPayload(error: z.ZodError) {
+	return NextResponse.json(
+		{
+			error: 'Invalid payload',
+			details: error.flatten()
+		},
+		{ status: 400 }
+	)
+}
</code_context>

<issue_to_address>
**suggestion:** Consider centralizing the `invalidPayload` helper instead of duplicating it across multiple route files.

The same helper is defined in multiple route modules. Extracting it to a shared utility (for example under `lib/http` or `lib/validation`) would reduce duplication, keep the error response consistent, and make future changes easier to apply.

Suggested implementation:

```typescript
import { requireAuth } from '@/lib/api-auth'
import { AIConfigCreateSchema, AIConfigPatchSchema } from '@skriuw/core'
import { getDatabase, aiProviderConfig, eq, and } from '@skriuw/db'
import { NextResponse } from 'next/server'
import { encryptPrompt, decryptPrompt } from '@/features/ai/utilities'
import { invalidPayload } from '@/lib/http/invalid-payload'

```

```typescript
export async function GET() {

```

1. Create a shared helper at `apps/web/lib/http/invalid-payload.ts` (or equivalent alias path for `@/lib/http/invalid-payload`) with something like:
   ```ts
   import { NextResponse } from 'next/server'
   import { ZodError } from 'zod'

   export function invalidPayload(error: ZodError) {
     return NextResponse.json(
       {
         error: 'Invalid payload',
         details: error.flatten(),
       },
       { status: 400 },
     )
   }
   ```
2. Update all other route files that currently define their own `invalidPayload` helper to remove the local function and `z` import, and instead import `invalidPayload` from `@/lib/http/invalid-payload`.
3. If `NextResponse` is not used elsewhere in this route file beyond the `invalidPayload` helper, you can safely remove the `NextResponse` import from this file as well.
</issue_to_address>

### Comment 2
<location> `apps/web/app/api/tasks/item/[taskId]/route.ts:146-157` </location>
<code_context>
 		}
-		if (typeof body.checked === 'number' || typeof body.checked === 'boolean') {
-			updateData.checked = body.checked ? 1 : 0
+		if (typeof payload.checked === 'number' || typeof payload.checked === 'boolean') {
+			updateData.checked = payload.checked ? 1 : 0
 		}
-		if (body.dueDate !== undefined) {
</code_context>

<issue_to_address>
**suggestion:** Align the runtime type checks with the `TaskUpdateSchema` to avoid redundant or slightly divergent validation logic.

Because `TaskUpdateSchema.checked` already restricts the value to `boolean | 0 | 1`, this `typeof` check duplicates that validation and may drift if the schema changes. Instead, rely on the schema by checking for key presence (e.g. `'checked' in payload`) and then normalizing the value, keeping all validation rules in one place.

```suggestion
		if (typeof payload.content === 'string') {
			updateData.content = payload.content
		}
		if (payload.description !== undefined) {
			updateData.description = payload.description
		}
		if ('checked' in payload) {
			updateData.checked = payload.checked ? 1 : 0
		}
		if (payload.dueDate !== undefined) {
			updateData.dueDate = payload.dueDate
		}
```
</issue_to_address>

### Comment 3
<location> `packages/core/src/schemas/import.ts:16` </location>
<code_context>
+  parentFolderId: z.string().nullable().optional()
+})
+
+export const ImportItemSchema: z.ZodType<any> = z.lazy(() =>
+  z.union([
+    ImportItemBaseSchema.extend({
+      type: z.literal('note'),
+      content: z.unknown().optional(),
+      favorite: z.union([z.boolean(), z.number(), z.string()]).optional()
+    }),
+    ImportItemBaseSchema.extend({
+      type: z.literal('folder'),
+      children: z.array(ImportItemSchema).optional()
+    })
+  ])
+)
</code_context>

<issue_to_address>
**suggestion:** Using `ZodType<any>` for `ImportItemSchema` weakens type safety; you can tighten this by inferring the type or using `unknown`.

Using `any` here disables static checking for `ImportItemSchema`. Instead, consider defining the recursive schema with `z.lazy`, then inferring the type via `z.infer<typeof ImportItemSchema>`, or annotating it as `ZodType<ImportItem>` once `ImportItem` exists. If that’s not feasible yet, `ZodType<unknown>` is still safer than `any` and keeps the core package more robustly typed.

```suggestion
export const ImportItemSchema = z.lazy(() =>
```
</issue_to_address>

### Comment 4
<location> `apps/web/__tests__/api/validation-payloads.test.ts:116` </location>
<code_context>
+	mockSqlDb.transaction.mockClear()
+})
+
+describe('API payload validation', () => {
+	it('POST /api/notes returns 400 with validation details', async () => {
+		const response = await notesPost(
</code_context>

<issue_to_address>
**suggestion (testing):** Add happy-path tests to complement the invalid-payload cases for each endpoint

The current tests verify that invalid payloads are rejected before DB access, but they don’t confirm that valid payloads pass through correctly. Please add at least one valid-payload test per endpoint (notes POST/PUT, settings POST, tasks sync POST, import POST, ai/config POST) that asserts: (1) a 2xx response, (2) no validation error structure in the response body, and (3) the mocked DB method is called with the expected arguments (e.g., settings upsert receives an object, tasks sync uses the validated `noteId` and `tasks`). This will confirm that schema-compliant requests reach the handlers as intended.

Suggested implementation:

```typescript
	mockServerDb.create.mockClear()
	mockServerDb.update.mockClear()
	mockServerDb.upsert.mockClear()

	mockSqlDb._setResults([])
	mockSqlDb.select.mockClear()
	mockSqlDb.transaction.mockClear()
})

describe('API payload validation', () => {
	it('POST /api/notes returns 400 with validation details', async () => {
		const response = await notesPost(
			new NextRequest('http://localhost/api/notes', {
				method: 'POST',
				body: JSON.stringify({ type: 'note' })
			})
		)

		expect(response.status).toBe(400)
		const json = await response.json()
		expect(json.error).toBe('Invalid payload')
		expect(json.details.fieldErrors.name).toBeDefined()
	})

	it('POST /api/notes accepts a valid payload and calls create', async () => {
		const validPayload = {
			type: 'note',
			name: 'My note',
			content: 'Some content'
		}

		const response = await notesPost(
			new NextRequest('http://localhost/api/notes', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.create).toHaveBeenCalledTimes(1)
		expect(mockServerDb.create).toHaveBeenCalledWith(
			expect.objectContaining({
				type: 'note',
				name: validPayload.name
			})
		)
	})

	it('PUT /api/notes/[id] accepts a valid payload and calls update', async () => {
		const noteId = 'note-123'
		const validPayload = {
			type: 'note',
			name: 'Updated note',
			content: 'Updated content'
		}

		const response = await notesPut(
			new NextRequest(`http://localhost/api/notes/${noteId}`, {
				method: 'PUT',
				body: JSON.stringify(validPayload)
			}),
			{ params: { id: noteId } } as any
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.update).toHaveBeenCalledTimes(1)
		expect(mockServerDb.update).toHaveBeenCalledWith(
			expect.objectContaining({
				id: noteId,
				type: 'note',
				name: validPayload.name
			})
		)
	})

	it('POST /api/settings accepts a valid payload and calls upsert', async () => {
		const validPayload = {
			theme: 'dark',
			editor: { fontSize: 14 }
		}

		const response = await settingsPost(
			new NextRequest('http://localhost/api/settings', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.upsert).toHaveBeenCalledTimes(1)
		expect(mockServerDb.upsert).toHaveBeenCalledWith(
			expect.objectContaining(validPayload)
		)
	})

	it('POST /api/tasks/sync accepts a valid payload and uses validated noteId and tasks', async () => {
		const validPayload = {
			noteId: 'note-123',
			tasks: [
				{
					id: 'task-1',
					title: 'First task',
					done: false
				}
			]
		}

		const response = await tasksSyncPost(
			new NextRequest('http://localhost/api/tasks/sync', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockSqlDb.transaction).toHaveBeenCalledTimes(1)
		// Ensure the transaction callback receives the validated payload
		const [transactionCallback] = mockSqlDb.transaction.mock.calls[0]
		const fakeDb = {
			insert: jest.fn()
		}
		await transactionCallback(fakeDb)

		expect(fakeDb.insert).toHaveBeenCalledWith(
			expect.objectContaining({
				noteId: validPayload.noteId,
				tasks: validPayload.tasks
			})
		)
	})

	it('POST /api/import accepts a valid payload and calls the appropriate DB logic', async () => {
		const validPayload = {
			source: 'markdown',
			data: '# My imported note'
		}

		const response = await importPost(
			new NextRequest('http://localhost/api/import', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		// We only assert that some DB write was triggered; the exact method may vary.
		expect(
			mockServerDb.create.mock.calls.length +
				mockServerDb.update.mock.calls.length +
				mockServerDb.upsert.mock.calls.length
		).toBeGreaterThan(0)
	})

	it('POST /api/ai/config accepts a valid payload and persists configuration', async () => {
		const validPayload = {
			model: 'gpt-4.1-mini',
			maxTokens: 256,
			systemPrompt: 'You are a helpful assistant.'
		}

		const response = await aiConfigPost(
			new NextRequest('http://localhost/api/ai/config', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.upsert).toHaveBeenCalled()
		expect(mockServerDb.upsert).toHaveBeenCalledWith(
			expect.objectContaining(validPayload)
		)

```

1. Ensure the helper functions used in these tests are correctly imported or available in the test scope:
   - `notesPut`
   - `settingsPost`
   - `tasksSyncPost`
   - `importPost`
   - `aiConfigPost`
2. Adjust the expected status codes if your endpoints return specific values (for example, `201` for create, `204` for updates without bodies). If an endpoint returns `204`, replace the `response.json()` calls with assertions on `response.status` only.
3. Align `validPayload` shapes with your existing Zod (or other) schemas for each endpoint. The properties I used (`type`, `name`, `content`, `theme`, `editor`, `noteId`, `tasks`, etc.) may need to be updated to match the actual validation rules.
4. If `mockSqlDb.transaction` does not accept a `(db) => {}` callback with an `insert` method, adjust the tasks sync test to assert against whatever mocked repository/DB function is actually called inside the transaction, and remove the `fakeDb`/`insert` logic accordingly.
5. If your DB abstraction for imports or AI config uses a specific method (e.g., `mockServerDb.import` or a dedicated repository mock), replace the generic DB assertions with calls to the appropriate mock and assert the payload/arguments as needed.
</issue_to_address>

### Comment 5
<location> `apps/web/__tests__/api/validation-payloads.test.ts:180-189` </location>
<code_context>
+		expect(mockSqlDb.select).not.toHaveBeenCalled()
+	})
+
+	it('POST /api/import returns 400 with validation details', async () => {
+		const response = await importPost(
+			new NextRequest('http://localhost/api/import', {
+				method: 'POST',
+				body: JSON.stringify({ wrong: 'schema' })
+			})
+		)
+
+		expect(response.status).toBe(400)
+		const json = await response.json()
+		expect(json.error).toBe('Invalid payload')
+		expect(json.details).toBeDefined()
+		expect(mockSqlDb.transaction).not.toHaveBeenCalled()
+	})
+
</code_context>

<issue_to_address>
**suggestion (testing):** Tighten import payload assertions by checking field-level validation errors and edge cases

Here we only check that `json.details` exists. Since `ImportPayloadSchema` enforces a structured shape (required `items`, note/folder unions, `IMPORT_MAX_ITEMS`, etc.), it’d be more valuable to assert specific paths like `details.fieldErrors.items` (or the equivalent) for invalid payloads, and to add at least one schema‑driven edge case (e.g., > `IMPORT_MAX_ITEMS` items or an item missing `type`) to confirm those constraints show up as structured `fieldErrors`.

Suggested implementation:

```typescript
	it('POST /api/import returns 400 with validation details', async () => {
		const response = await importPost(
			new NextRequest('http://localhost/api/import', {
				method: 'POST',
				body: JSON.stringify({ wrong: 'schema' })
			})
		)

		expect(response.status).toBe(400)
		const json = await response.json()

		expect(json.error).toBe('Invalid payload')

		// Tightened assertions: ensure field-level validation information is present
		expect(json.details).toBeDefined()
		expect(json.details.fieldErrors).toBeDefined()
		expect(json.details.fieldErrors.items).toBeDefined()
		expect(Array.isArray(json.details.fieldErrors.items)).toBe(true)
		expect(json.details.fieldErrors.items.length).toBeGreaterThan(0)

		expect(mockSqlDb.transaction).not.toHaveBeenCalled()
	})

	it('POST /api/import returns 400 with fieldErrors for invalid items', async () => {
		// Edge case: items array present but contains an invalid entry (e.g. missing "type")
		const response = await importPost(
			new NextRequest('http://localhost/api/import', {
				method: 'POST',
				body: JSON.stringify({
					items: [
						{
							// deliberately missing required fields like "type" to trigger item-level validation
						}
					]
				})
			})
		)

		expect(response.status).toBe(400)
		const json = await response.json()

		expect(json.error).toBe('Invalid payload')
		expect(json.details).toBeDefined()
		expect(json.details.fieldErrors).toBeDefined()

		// Be tolerant to how array field paths are encoded in fieldErrors:
		// either as "items.0.type" or nested inside items[0].type
		const itemTypeError =
			json.details.fieldErrors['items.0.type'] ??
			json.details.fieldErrors.items?.[0]?.type

		expect(itemTypeError).toBeDefined()
		expect(mockSqlDb.transaction).not.toHaveBeenCalled()
	})

```

If the `fieldErrors` shape differs (for example, if arrays are keyed as `items._errors` or with a different pathing convention), adjust the assertions in both tests to match your actual `ImportPayloadSchema` error format. If `IMPORT_MAX_ITEMS` is exported from your import module and you want to assert that limit explicitly, you can add another test similar to the second one, building an `items` array of length `IMPORT_MAX_ITEMS + 1` and asserting that `fieldErrors.items` (or the appropriate path) contains a message indicating the max-item constraint.
</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 +8 to +14
function invalidPayload(error: z.ZodError) {
return NextResponse.json(
{
error: 'Invalid payload',
details: error.flatten()
},
{ status: 400 }
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Consider centralizing the invalidPayload helper instead of duplicating it across multiple route files.

The same helper is defined in multiple route modules. Extracting it to a shared utility (for example under lib/http or lib/validation) would reduce duplication, keep the error response consistent, and make future changes easier to apply.

Suggested implementation:

import { requireAuth } from '@/lib/api-auth'
import { AIConfigCreateSchema, AIConfigPatchSchema } from '@skriuw/core'
import { getDatabase, aiProviderConfig, eq, and } from '@skriuw/db'
import { NextResponse } from 'next/server'
import { encryptPrompt, decryptPrompt } from '@/features/ai/utilities'
import { invalidPayload } from '@/lib/http/invalid-payload'
export async function GET() {
  1. Create a shared helper at apps/web/lib/http/invalid-payload.ts (or equivalent alias path for @/lib/http/invalid-payload) with something like:
    import { NextResponse } from 'next/server'
    import { ZodError } from 'zod'
    
    export function invalidPayload(error: ZodError) {
      return NextResponse.json(
        {
          error: 'Invalid payload',
          details: error.flatten(),
        },
        { status: 400 },
      )
    }
  2. Update all other route files that currently define their own invalidPayload helper to remove the local function and z import, and instead import invalidPayload from @/lib/http/invalid-payload.
  3. If NextResponse is not used elsewhere in this route file beyond the invalidPayload helper, you can safely remove the NextResponse import from this file as well.

Comment on lines +146 to 157
if (typeof payload.content === 'string') {
updateData.content = payload.content
}
if (body.description !== undefined) {
updateData.description = body.description
if (payload.description !== undefined) {
updateData.description = payload.description
}
if (typeof body.checked === 'number' || typeof body.checked === 'boolean') {
updateData.checked = body.checked ? 1 : 0
if (typeof payload.checked === 'number' || typeof payload.checked === 'boolean') {
updateData.checked = payload.checked ? 1 : 0
}
if (body.dueDate !== undefined) {
updateData.dueDate = body.dueDate
if (payload.dueDate !== undefined) {
updateData.dueDate = payload.dueDate
}
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Align the runtime type checks with the TaskUpdateSchema to avoid redundant or slightly divergent validation logic.

Because TaskUpdateSchema.checked already restricts the value to boolean | 0 | 1, this typeof check duplicates that validation and may drift if the schema changes. Instead, rely on the schema by checking for key presence (e.g. 'checked' in payload) and then normalizing the value, keeping all validation rules in one place.

Suggested change
if (typeof payload.content === 'string') {
updateData.content = payload.content
}
if (body.description !== undefined) {
updateData.description = body.description
if (payload.description !== undefined) {
updateData.description = payload.description
}
if (typeof body.checked === 'number' || typeof body.checked === 'boolean') {
updateData.checked = body.checked ? 1 : 0
if (typeof payload.checked === 'number' || typeof payload.checked === 'boolean') {
updateData.checked = payload.checked ? 1 : 0
}
if (body.dueDate !== undefined) {
updateData.dueDate = body.dueDate
if (payload.dueDate !== undefined) {
updateData.dueDate = payload.dueDate
}
if (typeof payload.content === 'string') {
updateData.content = payload.content
}
if (payload.description !== undefined) {
updateData.description = payload.description
}
if ('checked' in payload) {
updateData.checked = payload.checked ? 1 : 0
}
if (payload.dueDate !== undefined) {
updateData.dueDate = payload.dueDate
}

parentFolderId: z.string().nullable().optional()
})

export const ImportItemSchema: z.ZodType<any> = z.lazy(() =>
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion: Using ZodType<any> for ImportItemSchema weakens type safety; you can tighten this by inferring the type or using unknown.

Using any here disables static checking for ImportItemSchema. Instead, consider defining the recursive schema with z.lazy, then inferring the type via z.infer<typeof ImportItemSchema>, or annotating it as ZodType<ImportItem> once ImportItem exists. If that’s not feasible yet, ZodType<unknown> is still safer than any and keeps the core package more robustly typed.

Suggested change
export const ImportItemSchema: z.ZodType<any> = z.lazy(() =>
export const ImportItemSchema = z.lazy(() =>

mockSqlDb.transaction.mockClear()
})

describe('API payload validation', () => {
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): Add happy-path tests to complement the invalid-payload cases for each endpoint

The current tests verify that invalid payloads are rejected before DB access, but they don’t confirm that valid payloads pass through correctly. Please add at least one valid-payload test per endpoint (notes POST/PUT, settings POST, tasks sync POST, import POST, ai/config POST) that asserts: (1) a 2xx response, (2) no validation error structure in the response body, and (3) the mocked DB method is called with the expected arguments (e.g., settings upsert receives an object, tasks sync uses the validated noteId and tasks). This will confirm that schema-compliant requests reach the handlers as intended.

Suggested implementation:

	mockServerDb.create.mockClear()
	mockServerDb.update.mockClear()
	mockServerDb.upsert.mockClear()

	mockSqlDb._setResults([])
	mockSqlDb.select.mockClear()
	mockSqlDb.transaction.mockClear()
})

describe('API payload validation', () => {
	it('POST /api/notes returns 400 with validation details', async () => {
		const response = await notesPost(
			new NextRequest('http://localhost/api/notes', {
				method: 'POST',
				body: JSON.stringify({ type: 'note' })
			})
		)

		expect(response.status).toBe(400)
		const json = await response.json()
		expect(json.error).toBe('Invalid payload')
		expect(json.details.fieldErrors.name).toBeDefined()
	})

	it('POST /api/notes accepts a valid payload and calls create', async () => {
		const validPayload = {
			type: 'note',
			name: 'My note',
			content: 'Some content'
		}

		const response = await notesPost(
			new NextRequest('http://localhost/api/notes', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.create).toHaveBeenCalledTimes(1)
		expect(mockServerDb.create).toHaveBeenCalledWith(
			expect.objectContaining({
				type: 'note',
				name: validPayload.name
			})
		)
	})

	it('PUT /api/notes/[id] accepts a valid payload and calls update', async () => {
		const noteId = 'note-123'
		const validPayload = {
			type: 'note',
			name: 'Updated note',
			content: 'Updated content'
		}

		const response = await notesPut(
			new NextRequest(`http://localhost/api/notes/${noteId}`, {
				method: 'PUT',
				body: JSON.stringify(validPayload)
			}),
			{ params: { id: noteId } } as any
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.update).toHaveBeenCalledTimes(1)
		expect(mockServerDb.update).toHaveBeenCalledWith(
			expect.objectContaining({
				id: noteId,
				type: 'note',
				name: validPayload.name
			})
		)
	})

	it('POST /api/settings accepts a valid payload and calls upsert', async () => {
		const validPayload = {
			theme: 'dark',
			editor: { fontSize: 14 }
		}

		const response = await settingsPost(
			new NextRequest('http://localhost/api/settings', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.upsert).toHaveBeenCalledTimes(1)
		expect(mockServerDb.upsert).toHaveBeenCalledWith(
			expect.objectContaining(validPayload)
		)
	})

	it('POST /api/tasks/sync accepts a valid payload and uses validated noteId and tasks', async () => {
		const validPayload = {
			noteId: 'note-123',
			tasks: [
				{
					id: 'task-1',
					title: 'First task',
					done: false
				}
			]
		}

		const response = await tasksSyncPost(
			new NextRequest('http://localhost/api/tasks/sync', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockSqlDb.transaction).toHaveBeenCalledTimes(1)
		// Ensure the transaction callback receives the validated payload
		const [transactionCallback] = mockSqlDb.transaction.mock.calls[0]
		const fakeDb = {
			insert: jest.fn()
		}
		await transactionCallback(fakeDb)

		expect(fakeDb.insert).toHaveBeenCalledWith(
			expect.objectContaining({
				noteId: validPayload.noteId,
				tasks: validPayload.tasks
			})
		)
	})

	it('POST /api/import accepts a valid payload and calls the appropriate DB logic', async () => {
		const validPayload = {
			source: 'markdown',
			data: '# My imported note'
		}

		const response = await importPost(
			new NextRequest('http://localhost/api/import', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		// We only assert that some DB write was triggered; the exact method may vary.
		expect(
			mockServerDb.create.mock.calls.length +
				mockServerDb.update.mock.calls.length +
				mockServerDb.upsert.mock.calls.length
		).toBeGreaterThan(0)
	})

	it('POST /api/ai/config accepts a valid payload and persists configuration', async () => {
		const validPayload = {
			model: 'gpt-4.1-mini',
			maxTokens: 256,
			systemPrompt: 'You are a helpful assistant.'
		}

		const response = await aiConfigPost(
			new NextRequest('http://localhost/api/ai/config', {
				method: 'POST',
				body: JSON.stringify(validPayload)
			})
		)

		expect(response.status).toBeGreaterThanOrEqual(200)
		expect(response.status).toBeLessThan(300)

		const json = await response.json()
		expect(json.error).toBeUndefined()
		expect(json.details).toBeUndefined()

		expect(mockServerDb.upsert).toHaveBeenCalled()
		expect(mockServerDb.upsert).toHaveBeenCalledWith(
			expect.objectContaining(validPayload)
		)
  1. Ensure the helper functions used in these tests are correctly imported or available in the test scope:
    • notesPut
    • settingsPost
    • tasksSyncPost
    • importPost
    • aiConfigPost
  2. Adjust the expected status codes if your endpoints return specific values (for example, 201 for create, 204 for updates without bodies). If an endpoint returns 204, replace the response.json() calls with assertions on response.status only.
  3. Align validPayload shapes with your existing Zod (or other) schemas for each endpoint. The properties I used (type, name, content, theme, editor, noteId, tasks, etc.) may need to be updated to match the actual validation rules.
  4. If mockSqlDb.transaction does not accept a (db) => {} callback with an insert method, adjust the tasks sync test to assert against whatever mocked repository/DB function is actually called inside the transaction, and remove the fakeDb/insert logic accordingly.
  5. If your DB abstraction for imports or AI config uses a specific method (e.g., mockServerDb.import or a dedicated repository mock), replace the generic DB assertions with calls to the appropriate mock and assert the payload/arguments as needed.

Comment on lines +180 to +189
it('POST /api/import returns 400 with validation details', async () => {
const response = await importPost(
new NextRequest('http://localhost/api/import', {
method: 'POST',
body: JSON.stringify({ wrong: 'schema' })
})
)

expect(response.status).toBe(400)
const json = await response.json()
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): Tighten import payload assertions by checking field-level validation errors and edge cases

Here we only check that json.details exists. Since ImportPayloadSchema enforces a structured shape (required items, note/folder unions, IMPORT_MAX_ITEMS, etc.), it’d be more valuable to assert specific paths like details.fieldErrors.items (or the equivalent) for invalid payloads, and to add at least one schema‑driven edge case (e.g., > IMPORT_MAX_ITEMS items or an item missing type) to confirm those constraints show up as structured fieldErrors.

Suggested implementation:

	it('POST /api/import returns 400 with validation details', async () => {
		const response = await importPost(
			new NextRequest('http://localhost/api/import', {
				method: 'POST',
				body: JSON.stringify({ wrong: 'schema' })
			})
		)

		expect(response.status).toBe(400)
		const json = await response.json()

		expect(json.error).toBe('Invalid payload')

		// Tightened assertions: ensure field-level validation information is present
		expect(json.details).toBeDefined()
		expect(json.details.fieldErrors).toBeDefined()
		expect(json.details.fieldErrors.items).toBeDefined()
		expect(Array.isArray(json.details.fieldErrors.items)).toBe(true)
		expect(json.details.fieldErrors.items.length).toBeGreaterThan(0)

		expect(mockSqlDb.transaction).not.toHaveBeenCalled()
	})

	it('POST /api/import returns 400 with fieldErrors for invalid items', async () => {
		// Edge case: items array present but contains an invalid entry (e.g. missing "type")
		const response = await importPost(
			new NextRequest('http://localhost/api/import', {
				method: 'POST',
				body: JSON.stringify({
					items: [
						{
							// deliberately missing required fields like "type" to trigger item-level validation
						}
					]
				})
			})
		)

		expect(response.status).toBe(400)
		const json = await response.json()

		expect(json.error).toBe('Invalid payload')
		expect(json.details).toBeDefined()
		expect(json.details.fieldErrors).toBeDefined()

		// Be tolerant to how array field paths are encoded in fieldErrors:
		// either as "items.0.type" or nested inside items[0].type
		const itemTypeError =
			json.details.fieldErrors['items.0.type'] ??
			json.details.fieldErrors.items?.[0]?.type

		expect(itemTypeError).toBeDefined()
		expect(mockSqlDb.transaction).not.toHaveBeenCalled()
	})

If the fieldErrors shape differs (for example, if arrays are keyed as items._errors or with a different pathing convention), adjust the assertions in both tests to match your actual ImportPayloadSchema error format. If IMPORT_MAX_ITEMS is exported from your import module and you want to assert that limit explicitly, you can add another test similar to the second one, building an items array of length IMPORT_MAX_ITEMS + 1 and asserting that fieldErrors.items (or the appropriate path) contains a message indicating the max-item constraint.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/web/app/api/import/route.ts (1)

92-130: ⚠️ Potential issue | 🔴 Critical

Upsert on conflict can overwrite another user's data (IDOR).

The onConflictDoUpdate targets folders.id / notes.id without scoping the update to the current userId. If an attacker crafts an import payload with an existing ID belonging to a different user, the conflict will fire and silently overwrite that user's folder name, parent, or note content.

Add a where clause scoping the update to the current user's records:

🔒 Example fix for folders (apply similarly to notes)
 await tx.insert(folders)
   .values(chunk)
   .onConflictDoUpdate({
     target: folders.id,
     set: {
       name: sql`excluded.name`,
       parentFolderId: sql`excluded.parent_folder_id`,
       updatedAt: sql`excluded.updated_at`
-    }
+    },
+    where: eq(folders.userId, userId)
   })
apps/web/app/api/settings/route.ts (2)

110-127: ⚠️ Potential issue | 🟠 Major

PUT handler lacks payload validation.

POST validates with SettingsUpsertSchema.safeParse(body) (line 81-82), but PUT on line 117-121 still uses raw body?.settings ?? {} without validation. This inconsistency means PUT accepts arbitrary input that bypasses the schema.

🐛 Proposed fix — apply the same validation as POST
 		const body = await request.json()
+		const parsed = SettingsUpsertSchema.safeParse(body)
+		if (!parsed.success) return invalidPayload(parsed.error)
 		const now = getSafeTimestamp()
 
 		// Encrypt storage connectors before persistence
-		const rawSettings = body?.settings ?? {}
+		const rawSettings = parsed.data.settings

73-76: ⚠️ Potential issue | 🟠 Major

Mutation handlers use requireAuth() instead of requireMutation().

POST, PUT, and DELETE are mutation endpoints but use requireAuth(). Based on learnings, mutations should use requireMutation() unless the endpoint is intentionally public. The tasks/sync and notes routes already follow this pattern correctly.

Also applies to: 110-113, 145-148

apps/web/app/api/ai/config/route.ts (1)

48-51: ⚠️ Potential issue | 🟠 Major

POST and PATCH use requireAuth() instead of requireMutation().

Same issue as the settings route — these are mutation endpoints and should use requireMutation() per the project's auth convention. Based on learnings: "mutations require requireMutation() unless endpoint is intentionally public."

Also applies to: 107-110

🤖 Fix all issues with AI agents
In `@apps/web/app/api/tasks/sync/route.ts`:
- Around line 88-94: The delete-then-insert sequence using
db.delete(...).where(and(eq(tasks.noteId, payload.noteId), eq(tasks.userId,
userId))) followed by a conditional db.insert(tasks).values(rows) is not atomic
and can cause data loss if the insert fails; wrap both operations inside a
single db.transaction(...) so the delete and insert execute as one transaction
(perform the delete and, if rows.length > 0, perform the insert inside the same
transaction callback) to ensure rollback on failure.

In `@packages/core/src/schemas/index.ts`:
- Around line 1-6: The file begins with a UTF-8 BOM character before the first
export which can break tooling; remove the leading BOM so the file starts
directly with "export" (affecting the module that re-exports symbols from
'./notes', './settings', './tasks', './shortcuts', './ai', and
'./import')—ensure no hidden characters remain at the top of
packages/core/src/schemas/index.ts by saving the file without BOM (e.g., use
UTF-8 without BOM) so the first character is the letter 'e' of "export".

In `@packages/core/src/schemas/settings.ts`:
- Line 1: The file begins with a UTF-8 BOM causing a hidden character before the
first token (see the leading "import { z } from 'zod'" statement); remove the
BOM so the file starts exactly with the import statement (no invisible
characters) and save the file as UTF-8 without BOM; repeat the same removal for
any sibling file that showed the same issue (e.g., the other schemas file that
also begins with an import).

In `@packages/core/src/schemas/tasks.ts`:
- Around line 42-48: TaskSyncItemSchema's checked field accepts only boolean
while TaskCreateSchema allows 0 | 1; update TaskSyncItemSchema (the checked
property in the TaskSyncItemSchema object) to accept the same types as
TaskCreateSchema (either allow numeric 0/1 as well or preprocess 0/1 into
boolean) so sync payloads with checked: 0 or checked: 1 validate the same as
create payloads; modify the zod schema for TaskSyncItemSchema.checked to mirror
the TaskCreateSchema approach (either use a union including z.literal(0) and
z.literal(1) or a z.preprocess that converts numeric 0/1 to booleans).
🧹 Nitpick comments (13)
docs/audit-dashboard.html (1)

871-895: Optional: Sanitize task.note and task.t before inserting via innerHTML.

All task data is currently hardcoded, so this isn't exploitable today. However, the render function injects task.note (line 891) and task.t (line 878) directly into innerHTML without escaping. If this dashboard ever consumes external data (e.g., from an API or URL params), this becomes an XSS vector. A lightweight escapeHtml() helper would future-proof it.

apps/web/lib/api-auth.ts (1)

4-4: Consider a direct re-export to avoid the intermediate alias.

The alias SHARED_GUEST_USER_ID only exists to be re-assigned on Line 191. A single re-export is cleaner:

♻️ Suggested simplification

At Line 4, replace:

-import { GUEST_USER_ID as SHARED_GUEST_USER_ID } from '@skriuw/shared'

At Lines 191-192, replace:

-export const GUEST_USER_ID = SHARED_GUEST_USER_ID
+export { GUEST_USER_ID } from '@skriuw/shared'

Also applies to: 191-192

apps/web/app/api/tasks/item/[taskId]/route.ts (1)

18-26: Consider extracting invalidPayload into a shared API utility.

This helper duplicates the same pattern used inline in apps/web/app/api/import/route.ts (and likely other routes). A single shared helper (e.g., in @/lib/api-auth or a new @/lib/api-validation module) would reduce duplication and guarantee a consistent 400 response shape across all routes.

packages/core/package.json (1)

26-30: Pin tsup and typescript versions instead of using "latest".

"latest" is resolved at install time and varies across environments, which can cause non-reproducible builds or surprise breakages when a new major version ships. Pin to specific semver ranges matching the versions used elsewhere in the monorepo.

♻️ Suggested change
   "devDependencies": {
     "@skriuw/config": "workspace:*",
-    "tsup": "latest",
-    "typescript": "latest"
+    "tsup": "^8.0.0",
+    "typescript": "^5.9.3"
   }
apps/web/app/api/tasks/sync/route.ts (2)

10-13: SyncPayload type is now redundant.

The local SyncPayload type (lines 10-13) duplicates what TaskSyncPayloadSchema already describes, and the cast on line 31 (body: SyncPayload) is misleading since the actual shape is validated by the schema on line 32. Consider removing the type and using unknown or just await request.json() without the cast.

♻️ Proposed cleanup
-type SyncPayload = {
-	noteId: string
-	tasks: ExtractedTask[]
-}
-
 ...
 
-		const body: SyncPayload = await request.json()
+		const body = await request.json()
 		const parsed = TaskSyncPayloadSchema.safeParse(body)

Also applies to: 31-31


15-23: invalidPayload helper is duplicated across 4 route files.

This exact function appears in tasks/sync/route.ts, settings/route.ts, notes/route.ts, and ai/config/route.ts. Extract it into a shared utility (e.g., @/lib/api-validation or similar) to keep things DRY.

apps/web/__tests__/api/validation-payloads.test.ts (2)

38-63: The then property on the chainable mock makes it a thenable, which is fragile.

The Biome lint warning is valid here. Because chain has a then method, any await chain.select().from(...) resolves immediately via the custom then rather than being a real promise. This works for the current tests but is brittle — any intermediate await in production code on a partial chain will silently resolve with unexpected data. Consider using mockResolvedValue on terminal methods instead, or wrapping the resolution in an explicit async pattern.

That said, for a test mock this is a known pattern. Flagging for awareness rather than as a blocker.


195-207: Inconsistent: uses new Request instead of new NextRequest.

All other tests use new NextRequest(...), but the AI config test uses new Request(...). While this may work since NextRequest extends Request, it's inconsistent and the AI config route's POST handler accepts Request (not NextRequest). Consider aligning for consistency.

packages/core/src/schemas/settings.ts (1)

3-3: Extract TimestampSchema to a shared primitives file.

TimestampSchema is defined identically across 5 schema files (settings.ts, notes.ts, shortcuts.ts, tasks.ts, import.ts). Create a primitives.ts or common.ts in packages/core/src/schemas/ and import it everywhere to eliminate duplication and improve maintainability.

packages/core/src/schemas/import.ts (2)

4-4: TimestampSchema is duplicated in four schema files — extract to a shared internal module.

The identical z.number().int().nonnegative() definition appears in import.ts, shortcuts.ts, tasks.ts, and notes.ts. Consider extracting it to a shared file (e.g., packages/core/src/schemas/_shared.ts) and importing it.


16-28: z.ZodType<any> annotation erases the inferred type — acceptable trade-off for recursive schemas, but worth documenting.

This is a known Zod limitation with z.lazy() recursive types. The exported ImportItem type on Line 34 will resolve to any. If downstream consumers rely on type-checking import items, they won't get compile-time safety.

Consider adding a brief comment explaining why the any annotation is needed, so future maintainers don't try to "fix" it.

packages/core/src/types/index.ts (1)

30-30: NoteType duplicates the enum values already defined in NoteTypeSchema.

NoteTypeSchema in notes.ts is z.enum(['note', 'folder']). Deriving the type from the schema keeps a single source of truth:

Suggested change
-export type NoteType = 'note' | 'folder'
+export type NoteType = z.infer<typeof NoteTypeSchema>

(with the corresponding import of NoteTypeSchema from ../schemas)

packages/core/src/schemas/notes.ts (1)

8-19: content has no size validation despite MAX_NOTE_CONTENT_BYTES being defined in rules.

z.unknown() can't enforce byte-size limits directly, but consider adding a comment or a note that content size must be enforced at the route/middleware level using MAX_NOTE_CONTENT_BYTES. This avoids future confusion about why the constant exists but isn't referenced in the note schemas.

Comment on lines 88 to 94
// 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)))
await db
.delete(tasks)
.where(and(eq(tasks.noteId, payload.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.

⚠️ Potential issue | 🟠 Major

Delete-then-insert is not wrapped in a transaction.

The delete on lines 89-91 and the conditional insert on lines 92-94 are separate operations. If the insert fails, the existing tasks are already deleted, resulting in data loss. Consider wrapping both in a db.transaction(...) block.

🐛 Proposed fix
-		await db
-			.delete(tasks)
-			.where(and(eq(tasks.noteId, payload.noteId), eq(tasks.userId, userId)))
-		if (rows.length > 0) {
-			await db.insert(tasks).values(rows)
-		}
+		await db.transaction(async (tx) => {
+			await tx
+				.delete(tasks)
+				.where(and(eq(tasks.noteId, payload.noteId), eq(tasks.userId, userId)))
+			if (rows.length > 0) {
+				await tx.insert(tasks).values(rows)
+			}
+		})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

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)))
await db
.delete(tasks)
.where(and(eq(tasks.noteId, payload.noteId), eq(tasks.userId, userId)))
if (rows.length > 0) {
await db.insert(tasks).values(rows)
}
// Replace note-scoped task rows atomically enough for current usage.
await db.transaction(async (tx) => {
await tx
.delete(tasks)
.where(and(eq(tasks.noteId, payload.noteId), eq(tasks.userId, userId)))
if (rows.length > 0) {
await tx.insert(tasks).values(rows)
}
})
🤖 Prompt for AI Agents
In `@apps/web/app/api/tasks/sync/route.ts` around lines 88 - 94, The
delete-then-insert sequence using db.delete(...).where(and(eq(tasks.noteId,
payload.noteId), eq(tasks.userId, userId))) followed by a conditional
db.insert(tasks).values(rows) is not atomic and can cause data loss if the
insert fails; wrap both operations inside a single db.transaction(...) so the
delete and insert execute as one transaction (perform the delete and, if
rows.length > 0, perform the insert inside the same transaction callback) to
ensure rollback on failure.

Comment on lines +1 to +6
export * from './notes'
export * from './settings'
export * from './tasks'
export * from './shortcuts'
export * from './ai'
export * from './import'
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BOM character at the start of the file.

Line 1 starts with a UTF-8 BOM (). This can cause subtle issues with tooling and bundlers. Remove it so the file starts cleanly with export.

🤖 Prompt for AI Agents
In `@packages/core/src/schemas/index.ts` around lines 1 - 6, The file begins with
a UTF-8 BOM character before the first export which can break tooling; remove
the leading BOM so the file starts directly with "export" (affecting the module
that re-exports symbols from './notes', './settings', './tasks', './shortcuts',
'./ai', and './import')—ensure no hidden characters remain at the top of
packages/core/src/schemas/index.ts by saving the file without BOM (e.g., use
UTF-8 without BOM) so the first character is the letter 'e' of "export".

@@ -0,0 +1,22 @@
import { z } from 'zod'
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BOM character at the start of the file.

Same as schemas/index.ts — remove the UTF-8 BOM.

🤖 Prompt for AI Agents
In `@packages/core/src/schemas/settings.ts` at line 1, The file begins with a
UTF-8 BOM causing a hidden character before the first token (see the leading
"import { z } from 'zod'" statement); remove the BOM so the file starts exactly
with the import statement (no invisible characters) and save the file as UTF-8
without BOM; repeat the same removal for any sibling file that showed the same
issue (e.g., the other schemas file that also begins with an import).

Comment on lines +42 to +48
export const TaskSyncItemSchema = z.object({
blockId: z.string().min(1),
content: z.string().max(MAX_TASK_CONTENT_LENGTH),
checked: z.boolean(),
parentTaskId: z.string().nullable(),
position: z.number().int().optional()
})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

TaskSyncItemSchema.checked only accepts boolean, while TaskCreateSchema.checked also accepts 0 | 1.

If sync payloads originate from the same client/editor that sends task-create payloads, a checked: 1 value would pass TaskCreateSchema but fail TaskSyncItemSchema. Consider aligning the checked type:

Suggested fix
 export const TaskSyncItemSchema = z.object({
   blockId: z.string().min(1),
   content: z.string().max(MAX_TASK_CONTENT_LENGTH),
-  checked: z.boolean(),
+  checked: z.union([z.boolean(), TaskCheckedSchema]),
   parentTaskId: z.string().nullable(),
   position: z.number().int().optional()
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const TaskSyncItemSchema = z.object({
blockId: z.string().min(1),
content: z.string().max(MAX_TASK_CONTENT_LENGTH),
checked: z.boolean(),
parentTaskId: z.string().nullable(),
position: z.number().int().optional()
})
export const TaskSyncItemSchema = z.object({
blockId: z.string().min(1),
content: z.string().max(MAX_TASK_CONTENT_LENGTH),
checked: z.union([z.boolean(), TaskCheckedSchema]),
parentTaskId: z.string().nullable(),
position: z.number().int().optional()
})
🤖 Prompt for AI Agents
In `@packages/core/src/schemas/tasks.ts` around lines 42 - 48,
TaskSyncItemSchema's checked field accepts only boolean while TaskCreateSchema
allows 0 | 1; update TaskSyncItemSchema (the checked property in the
TaskSyncItemSchema object) to accept the same types as TaskCreateSchema (either
allow numeric 0/1 as well or preprocess 0/1 into boolean) so sync payloads with
checked: 0 or checked: 1 validate the same as create payloads; modify the zod
schema for TaskSyncItemSchema.checked to mirror the TaskCreateSchema approach
(either use a union including z.literal(0) and z.literal(1) or a z.preprocess
that converts numeric 0/1 to booleans).

@remcostoeten remcostoeten force-pushed the feature/p2-core-orchestration branch from dc6a49e to bcc4809 Compare February 14, 2026 17:39
@remcostoeten remcostoeten merged commit 459fc9d into daddy Feb 14, 2026
3 of 6 checks passed
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