-
-
Notifications
You must be signed in to change notification settings - Fork 3
feat: Web POC with ZenStack v3, Turso, and MCP Auto-Sync #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
515eecc
4785116
66e475d
1e9bfd4
703108e
0448b22
af0abd7
cff2b57
5648039
83ab55b
192a36a
7fd92d9
bf57b25
4f50375
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| # Dependencies | ||
| node_modules/ | ||
|
|
||
| # Next.js | ||
| .next/ | ||
| out/ | ||
|
|
||
| # Environment | ||
| .env*.local | ||
|
|
||
| # Database | ||
| *.db | ||
| *.db-* | ||
|
|
||
| # Generated | ||
| lib/generated/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| "use server"; | ||
|
|
||
| import { getClient, syncWithTurso } from "@/lib/db"; | ||
|
|
||
| export interface Task { | ||
| id: string; | ||
| title: string; | ||
| description: string | null; | ||
| column_id: string; | ||
| position: number; | ||
| created_by: string; | ||
| assigned_to: string | null; | ||
| created_at: number; | ||
| updated_at: number; | ||
| archived: number; | ||
| } | ||
|
|
||
| export interface ColumnData { | ||
| id: string; | ||
| name: string; | ||
| position: number; | ||
| wip_limit: number | null; | ||
| tasks: Task[]; | ||
| } | ||
|
|
||
| export async function getBoardData(boardId: string): Promise<{ | ||
| board: { id: string; name: string } | null; | ||
| columns: ColumnData[]; | ||
| tasks: Task[]; | ||
| }> { | ||
| await syncWithTurso(); | ||
| const client = getClient(); | ||
|
|
||
| try { | ||
| const boardResult = await client.execute({ | ||
| sql: "SELECT id, name FROM boards WHERE id = ?", | ||
| args: [boardId], | ||
| }); | ||
|
|
||
| if (boardResult.rows.length === 0) { | ||
| return { board: null, columns: [], tasks: [] }; | ||
| } | ||
|
|
||
| const board = { | ||
| id: String(boardResult.rows[0].id), | ||
| name: String(boardResult.rows[0].name), | ||
| }; | ||
|
|
||
| const columnsResult = await client.execute({ | ||
| sql: "SELECT id, name, position, wip_limit FROM columns WHERE board_id = ? ORDER BY position ASC", | ||
| args: [boardId], | ||
| }); | ||
|
|
||
| const columns: ColumnData[] = columnsResult.rows.map((row) => ({ | ||
| id: String(row.id), | ||
| name: String(row.name), | ||
| position: Number(row.position), | ||
| wip_limit: row.wip_limit ? Number(row.wip_limit) : null, | ||
| tasks: [], | ||
| })); | ||
|
|
||
| const columnIds = columns.map((c) => c.id); | ||
|
|
||
| let tasks: Task[] = []; | ||
| if (columnIds.length > 0) { | ||
| const placeholders = columnIds.map(() => "?").join(","); | ||
| const tasksResult = await client.execute({ | ||
| sql: `SELECT id, title, description, column_id, position, created_by, assigned_to, created_at, updated_at, archived | ||
| FROM tasks | ||
| WHERE column_id IN (${placeholders}) AND archived = 0 | ||
| ORDER BY position ASC`, | ||
| args: columnIds, | ||
| }); | ||
|
|
||
| tasks = tasksResult.rows.map((row) => ({ | ||
| id: String(row.id), | ||
| title: String(row.title), | ||
| description: row.description ? String(row.description) : null, | ||
| column_id: String(row.column_id), | ||
| position: Number(row.position), | ||
| created_by: String(row.created_by), | ||
| assigned_to: row.assigned_to ? String(row.assigned_to) : null, | ||
| created_at: Number(row.created_at), | ||
| updated_at: Number(row.updated_at), | ||
| archived: Number(row.archived), | ||
| })); | ||
| } | ||
|
|
||
| const columnsWithTasks = columns.map((col) => ({ | ||
| ...col, | ||
| tasks: tasks.filter((t) => t.column_id === col.id), | ||
| })); | ||
|
|
||
| return { | ||
| board, | ||
| columns: columnsWithTasks, | ||
| tasks, | ||
| }; | ||
| } catch (error) { | ||
| console.error("Error fetching board:", error); | ||
| return { board: null, columns: [], tasks: [] }; | ||
| } | ||
| } | ||
|
|
||
| export async function moveTask( | ||
| taskId: string, | ||
| newColumnId: string, | ||
| newPosition: number | ||
| ): Promise<Task | null> { | ||
| const client = getClient(); | ||
| const now = Math.floor(Date.now() / 1000); | ||
|
|
||
| try { | ||
| await client.execute({ | ||
| sql: "UPDATE tasks SET column_id = ?, position = ?, updated_at = ? WHERE id = ?", | ||
| args: [newColumnId, newPosition, now, taskId], | ||
| }); | ||
|
|
||
| await syncWithTurso(); | ||
|
|
||
| const result = await client.execute({ | ||
| sql: "SELECT id, title, description, column_id, position, created_by, assigned_to, created_at, updated_at, archived FROM tasks WHERE id = ?", | ||
| args: [taskId], | ||
| }); | ||
|
|
||
| if (result.rows.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| const row = result.rows[0]; | ||
| return { | ||
| id: String(row.id), | ||
| title: String(row.title), | ||
| description: row.description ? String(row.description) : null, | ||
| column_id: String(row.column_id), | ||
| position: Number(row.position), | ||
| created_by: String(row.created_by), | ||
| assigned_to: row.assigned_to ? String(row.assigned_to) : null, | ||
| created_at: Number(row.created_at), | ||
| updated_at: Number(row.updated_at), | ||
| archived: Number(row.archived), | ||
| }; | ||
| } catch (error) { | ||
| console.error("Failed to move task:", error); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function addTask(data: { | ||
| title: string; | ||
| description?: string; | ||
| column_id: string; | ||
| created_by: string; | ||
| }): Promise<Task | null> { | ||
| const client = getClient(); | ||
| const now = Math.floor(Date.now() / 1000); | ||
| const id = crypto.randomUUID(); | ||
|
||
|
|
||
| try { | ||
| await client.execute({ | ||
| sql: `INSERT INTO tasks (id, title, description, column_id, position, created_by, assigned_to, created_at, updated_at, archived, version, depends_on, files, labels) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | ||
| args: [ | ||
| id, | ||
| data.title, | ||
| data.description || null, | ||
| data.column_id, | ||
| 0, | ||
| data.created_by, | ||
| null, | ||
| now, | ||
| now, | ||
| 0, | ||
| 1, | ||
| "[]", | ||
| "[]", | ||
| "[]", | ||
| ], | ||
| }); | ||
|
|
||
| await syncWithTurso(); | ||
|
|
||
| return { | ||
| id, | ||
| title: data.title, | ||
| description: data.description || null, | ||
| column_id: data.column_id, | ||
| position: 0, | ||
| created_by: data.created_by, | ||
| assigned_to: null, | ||
| created_at: now, | ||
| updated_at: now, | ||
| archived: 0, | ||
| }; | ||
| } catch (error) { | ||
| console.error("Failed to add task:", error); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function archiveTask(taskId: string): Promise<boolean> { | ||
| const client = getClient(); | ||
| const now = Math.floor(Date.now() / 1000); | ||
|
|
||
| try { | ||
| await client.execute({ | ||
| sql: "UPDATE tasks SET archived = 1, archived_at = ?, updated_at = ? WHERE id = ?", | ||
| args: [now, now, taskId], | ||
| }); | ||
|
|
||
| await syncWithTurso(); | ||
|
|
||
| return true; | ||
| } catch (error) { | ||
| console.error("Failed to archive task:", error); | ||
| return false; | ||
| } | ||
| } | ||
|
Comment on lines
+26
to
+218
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,63 @@ | ||||||||||||||||||||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||||||||||||||||||||
| import { createClient } from '@libsql/client'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export async function GET( | ||||||||||||||||||||||
| request: NextRequest, | ||||||||||||||||||||||
| { params }: { params: Promise<{ id: string }> } | ||||||||||||||||||||||
| ) { | ||||||||||||||||||||||
| const { id: boardId } = await params; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| // Connect to local Turso embedded replica | ||||||||||||||||||||||
| const client = createClient({ | ||||||||||||||||||||||
| url: process.env.TURSO_DATABASE_URL || 'file:./local.db', | ||||||||||||||||||||||
| syncUrl: process.env.TURSO_SYNC_URL, | ||||||||||||||||||||||
| authToken: process.env.TURSO_AUTH_TOKEN, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
Comment on lines
+12
to
+16
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Sync with remote before reading | ||||||||||||||||||||||
| await client.sync(); | ||||||||||||||||||||||
|
Comment on lines
+18
to
+19
|
||||||||||||||||||||||
| // Sync with remote before reading | |
| await client.sync(); | |
| // Sync with remote before reading, if supported/configured | |
| if (process.env.TURSO_SYNC_URL && typeof (client as any).sync === 'function') { | |
| try { | |
| await client.sync(); | |
| } catch (syncError) { | |
| console.warn('Turso sync failed, continuing with local replica:', syncError); | |
| } | |
| } |
Copilot
AI
Jan 31, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The boardId parameter from the URL is not used in the SQL query (line 8, 32-34). The query fetches ALL non-archived tasks from the database regardless of which board they belong to. This is a data leak - users can access tasks from any board by changing the boardId in the URL. Add a WHERE clause to filter by board_id or join with the columns table to ensure only tasks belonging to the requested board are returned.
| ORDER BY position ASC, created_at DESC | |
| `, | |
| args: [], | |
| AND board_id = ? | |
| ORDER BY position ASC, created_at DESC | |
| `, | |
| args: [boardId], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SQL injection vulnerability: The placeholders variable is concatenated directly into the SQL string without proper parameterization. While columnIds comes from the database, constructing SQL with string interpolation for IN clauses is dangerous. Use proper parameterized queries. libsql/Turso should support binding arrays for IN clauses, or construct the query differently to avoid this pattern.