Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ SUPABASE_URL=
# Your Public Anon Key (found in Supabase Settings -> API)
SUPABASE_ANON_KEY=
CORS_ALLOWED_ORIGINS=http://localhost:5173
# Frontend Hono API base URL.
# - Local dev example: http://localhost:10000
# - Render example: https://taskgraph-100program9-server.onrender.com
VITE_HONO_BASE_URL=
153 changes: 69 additions & 84 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,20 @@ type AppEnv = { Variables: { auth: AuthContext } };
const app = new Hono<AppEnv>();

const corsAllowAll = process.env.CORS_ALLOW_ALL === 'true';
const corsAllowedOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? '')
const defaultLocalOrigins = [
'http://localhost:5173',
'http://127.0.0.1:5173',
'http://localhost:3000',
'http://127.0.0.1:3000',
];

const configuredCorsOrigins = (process.env.CORS_ALLOWED_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);

const corsAllowedOrigins = Array.from(new Set([...defaultLocalOrigins, ...configuredCorsOrigins]));

function isOriginAllowed(origin: string | undefined): boolean {
if (!origin) return true;
if (corsAllowAll) return true;
Expand Down Expand Up @@ -126,6 +135,10 @@ function parseStatus(raw: unknown): TaskStatus | null {
return null;
}

function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

async function parseJson<T>(c: Context, fallback: T): Promise<T> {
try {
const parsed = await c.req.json<T>();
Expand Down Expand Up @@ -285,9 +298,12 @@ async function handleCreateItem(c: Context<AppEnv>): Promise<Response> {
}

async function handleUpdateItemStatus(c: Context<AppEnv>): Promise<Response> {
const idFromPath = c.req.param('id');
const body = await parseJson(c, { id: '', status: '' });

if (!body.id || !body.status) {
const id = typeof idFromPath === 'string' && idFromPath.trim().length > 0 ? idFromPath : body.id;

if (!id || !body.status) {
return c.json({ error: 'id and status are required' }, 400);
}
Comment on lines +306 to 308
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing UUID format validation.

Other handlers (handleUpdateItem, handleArchiveItem, handleSoftDeleteItem) validate the ID with isUuid(id), but this handler does not. This creates an inconsistent validation approach across endpoints.

🔧 Proposed fix to add UUID validation
   const id = typeof idFromPath === 'string' && idFromPath.trim().length > 0 ? idFromPath : body.id;
 
-  if (!id || !body.status) {
-    return c.json({ error: 'id and status are required' }, 400);
+  if (!id || !isUuid(id)) {
+    return c.json({ error: 'valid item id is required' }, 400);
+  }
+  if (!body.status) {
+    return c.json({ error: 'status is required' }, 400);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/index.ts` around lines 291 - 293, Add the same UUID
validation used elsewhere: call isUuid(id) before accepting the request (like in
handleUpdateItem/handleArchiveItem/handleSoftDeleteItem) and return a 400 JSON
error if it fails; specifically, in the handler that currently checks if (!id ||
!body.status) add a check if (!isUuid(id)) return c.json({ error: 'invalid id
format' }, 400) so ID format is consistently validated across endpoints.


Expand All @@ -305,92 +321,106 @@ async function handleUpdateItemStatus(c: Context<AppEnv>): Promise<Response> {
status: validatedStatus,
updated_at: new Date().toISOString()
})
.eq('id', body.id);
.eq('id', id)
.is('deleted_at', null);

if (error) return c.json({ error: error.message }, 400);
return c.body(null, 204);
}

async function handleUpdateItem(c: Context<AppEnv>): Promise<Response> {
const { token } = c.get('auth');
const supabase = createSupabaseWithToken(token);
const id = c.req.param('id');
const idFromPath = c.req.param('id');
const body = await parseJson(c, {
id: '',
title: '',
description: null as any,
description: null as string | null,
motivation: null as number | null,
due: '',
durationMinutes: null as number | null,
duration_minutes: null as number | null,
motivation: null as number | null,
});

const id = typeof idFromPath === 'string' && idFromPath.trim().length > 0 ? idFromPath : body.id;
if (!id || !isUuid(id)) {
return c.json({ error: 'valid item id is required' }, 400);
}

const title = typeof body.title === 'string' ? body.title.trim() : '';
const due = typeof body.due === 'string' ? body.due.trim() : '';

const validatedDescription =
typeof body.description === 'string' && body.description.trim().length > 0
? body.description.trim()
: null;

if (!id || !title || !due) {
return c.json({ error: 'id, title and due are required' }, 400);
if (!title || !due || Number.isNaN(Date.parse(due))) {
return c.json({ error: 'valid title and due are required' }, 400);
}

const rawDuration = body.duration_minutes ?? body.durationMinutes;
const durationMinutes =
typeof rawDuration === 'number' && Number.isFinite(rawDuration)
? rawDuration
const description =
body.description === null || typeof body.description === 'string'
? body.description
: null;
const motivation =
typeof body.motivation === 'number' && Number.isFinite(body.motivation)
? body.motivation
: null;
const rawDuration = body.duration_minutes ?? body.durationMinutes;
const durationMinutes =
typeof rawDuration === 'number' && Number.isFinite(rawDuration)
? rawDuration
: null;

const { token } = c.get('auth');
const supabase = createSupabaseWithToken(token);

const { error } = await supabase
.from('items')
.update({
title,
description: validatedDescription,
due,
duration_minutes: durationMinutes,
description,
motivation,
due: new Date(due).toISOString(),
duration_minutes: durationMinutes,
sync_status: 'modified',
updated_at: new Date().toISOString(),
sync_status: 'synced',
})
.eq('id', id);
.eq('id', id)
.is('deleted_at', null);

if (error) return c.json({ error: error.message }, 400);
return c.body(null, 204);
}

async function handleArchiveItem(c: Context<AppEnv>): Promise<Response> {
const idFromPath = c.req.param('id');
const body = await parseJson(c, { id: '' });
if (!body.id) return c.json({ error: 'id is required' }, 400);

const id = typeof idFromPath === 'string' && idFromPath.trim().length > 0 ? idFromPath : body.id;
if (!id || !isUuid(id)) return c.json({ error: 'valid id is required' }, 400);

const { token } = c.get('auth');
const supabase = createSupabaseWithToken(token);

const { error } = await supabase
.from('items')
.update({ is_archived: true })
.eq('id', body.id)
.update({ is_archived: true, updated_at: new Date().toISOString() })
.eq('id', id)
.is('deleted_at', null);

if (error) return c.json({ error: error.message }, 400);
return c.body(null, 204);
}

async function handleSoftDeleteItem(c: Context<AppEnv>): Promise<Response> {
const idFromPath = c.req.param('id');
const body = await parseJson(c, { id: '' });
if (!body.id) return c.json({ error: 'id is required' }, 400);

const id = typeof idFromPath === 'string' && idFromPath.trim().length > 0 ? idFromPath : body.id;
if (!id || !isUuid(id)) return c.json({ error: 'valid id is required' }, 400);

const { token } = c.get('auth');
const supabase = createSupabaseWithToken(token);

const { error } = await supabase
.from('items')
.update({ deleted_at: new Date().toISOString() })
.eq('id', body.id);
.update({ deleted_at: new Date().toISOString(), updated_at: new Date().toISOString() })
.eq('id', id)
.is('deleted_at', null);

if (error) return c.json({ error: error.message }, 400);
return c.body(null, 204);
Expand Down Expand Up @@ -473,67 +503,19 @@ app.post('/api/items', async (c) => handleCreateItem(c));

app.post('/api/items/create', async (c) => handleCreateItem(c));

app.patch('/api/items/:id/status', async (c) => {
const { token } = c.get('auth');
const supabase = createSupabaseWithToken(token);
const id = c.req.param('id');
const body = await parseJson(c, { status: '' });

if (!body.status) {
return c.json({ error: 'status is required' }, 400);
}

const validatedStatus = parseStatus(body.status);
if (!validatedStatus) {
return c.json({ error: 'invalid status' }, 400);
}

const { error } = await supabase
.from('items')
.update({
status: validatedStatus,
updated_at: new Date().toISOString()
})
.eq('id', id);

if (error) return c.json({ error: error.message }, 400);
return c.body(null, 204);
});

app.patch('/api/items/:id', async (c) => handleUpdateItem(c));

app.post('/api/items/update-status', async (c) => handleUpdateItemStatus(c));
app.post('/api/items/update', async (c) => handleUpdateItem(c));

app.post('/api/items/:id/archive', async (c) => {
const { token } = c.get('auth');
const supabase = createSupabaseWithToken(token);
const id = c.req.param('id');
app.patch('/api/items/:id/status', async (c) => handleUpdateItemStatus(c));

const { error } = await supabase
.from('items')
.update({ is_archived: true })
.eq('id', id)
.is('deleted_at', null);
app.post('/api/items/update-status', async (c) => handleUpdateItemStatus(c));

if (error) return c.json({ error: error.message }, 400);
return c.body(null, 204);
});
app.post('/api/items/:id/archive', async (c) => handleArchiveItem(c));

app.post('/api/items/archive', async (c) => handleArchiveItem(c));

app.delete('/api/items/:id', async (c) => {
const { token } = c.get('auth');
const supabase = createSupabaseWithToken(token);
const id = c.req.param('id');

const { error } = await supabase
.from('items')
.update({ deleted_at: new Date().toISOString() })
.eq('id', id);

if (error) return c.json({ error: error.message }, 400);
return c.body(null, 204);
});
app.delete('/api/items/:id', async (c) => handleSoftDeleteItem(c));

app.post('/api/items/soft-delete', async (c) => handleSoftDeleteItem(c));

Expand All @@ -549,8 +531,11 @@ app.get('/api/commands/get_deleted_items', async (c) => handleGetDeletedItems(c)
app.post('/api/commands/create_item', async (c) => handleCreateItem(c));

app.post('/api/commands/update_item_status', async (c) => handleUpdateItemStatus(c));

app.patch('/api/commands/update_item/:id', async (c) => handleUpdateItem(c));

app.post('/api/commands/update_item_details', async (c) => handleUpdateItem(c));

app.post('/api/commands/archive_item', async (c) => handleArchiveItem(c));

app.post('/api/commands/soft_delete_item', async (c) => handleSoftDeleteItem(c));
Expand Down
Loading
Loading