Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,14 @@
API_HOST=127.0.0.1
API_PORT=3000
DATABASE_URL="postgresql://taskdesk:taskdesk_local@localhost:5432/taskdesk?schema=public"

# Explicit local-only bypass; rejected by production and Vercel runtimes.
NODE_ENV=development
AUTH_MODE=local
VITE_AUTH_MODE=local
# Hosted auth: set AUTH_MODE=supabase and omit VITE_AUTH_MODE.
# Publishable keys are public. Never put secret/service_role keys in VITE_*.
SUPABASE_URL=
SUPABASE_PUBLISHABLE_KEY=
VITE_SUPABASE_URL=
VITE_SUPABASE_PUBLISHABLE_KEY=
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- run: npm ci --no-audit --no-fund
- name: Audit high and critical runtime advisories
run: npm run audit:prod
- name: Generate Prisma Client
run: npm run db:generate
- name: Apply database migrations
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

Plataforma pessoal de organização de tarefas a partir de mensagens acionáveis, sem transformar todo e-mail em chamado.

**Status atual:** API CRUD e dashboard Vue conectados. O painel lista, cria e atualiza tarefas pela API. Gmail e Microsoft 365 ainda não estão conectados.
**Status atual:** CRUD, histórico e isolamento por usuário implementados, com login por Supabase Auth. A ativação hospedada depende de um projeto Supabase dedicado e das variáveis de ambiente. Gmail e Microsoft 365 ainda não estão conectados.

## Stack
- Web: Vue 3, Vite, TypeScript e CSS puro.
- API: Node.js, Express, TypeScript e Zod.
- Banco: PostgreSQL 16 e Prisma 6.
- Banco: PostgreSQL 16 e Prisma 7.
- Testes: Vitest e Supertest.

## Requisitos
Node.js 20.15+, npm 10+ e Docker Engine com Docker Compose para PostgreSQL local.
Node.js 22.12+, npm 10+ e Docker Engine com Docker Compose para PostgreSQL local.

## Executar localmente
```bash
Expand Down Expand Up @@ -46,7 +46,7 @@ Status: `NEW`, `IN_PROGRESS`, `WAITING`, `COMPLETED`.
Prioridades: `LOW`, `NORMAL`, `HIGH`, `URGENT`.

## Segurança e escopo
A autenticação e autorização por usuário ainda não foram implementadas. Não exponha a API publicamente nem utilize dados pessoais reais até que esses controles estejam prontos. Integrações de e-mail permanecem desativadas; nenhuma mensagem é enviada ou excluída automaticamente.
O modo local usa uma identidade fixa e só funciona com `NODE_ENV=development`, `AUTH_MODE=local` e API em loopback. Nunca exponha esse modo na rede. Em produção, a API valida tokens com Supabase Auth e restringe tarefas e histórico ao usuário autenticado. A configuração de deploy está em [deployment](docs/DEPLOYMENT.md). Integrações de e-mail permanecem desativadas.

Consulte [AGENTS.md](AGENTS.md), [arquitetura](docs/ARCHITECTURE.md), [segurança](docs/SECURITY.md) e [roadmap](docs/ROADMAP.md).

Expand Down
2 changes: 1 addition & 1 deletion apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A migração inicial cria `Task`, `TaskActivity`, enums, índices e a chave estr
- `PATCH /api/v1/tasks/:id`
- `DELETE /api/v1/tasks/:id`

Autenticação e autorização por usuário ainda não estão implementadas. Não exponha a API com dados pessoais.
A API exige um Bearer token validado pelo Supabase Auth e restringe todos os acessos ao dono da tarefa. O modo local exige configuração explícita de desenvolvimento. Consulte [security](../../docs/SECURITY.md) e [deployment](../../docs/DEPLOYMENT.md).

## Testes de persistência

Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"name":"@taskdesk/api","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"tsx watch src/server.ts","build":"prisma generate && tsc -p tsconfig.build.json","start":"node dist/server.js","typecheck":"prisma generate && tsc --noEmit -p tsconfig.json","test":"vitest run","db:generate":"prisma generate","db:migrate":"prisma migrate dev","db:deploy":"prisma migrate deploy","db:studio":"prisma studio"},"dependencies":{"@prisma/client":"^6.19.1","dotenv":"^16.4.7","express":"^5.1.0","zod":"^3.25.76"},"devDependencies":{"@types/express":"^5.0.1","@types/node":"^22.13.0","@types/supertest":"^6.0.2","prisma":"^6.19.1","supertest":"^7.0.0","tsx":"^4.19.2","vitest":"^2.1.9"}}
{"name":"@taskdesk/api","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"tsx watch src/server.ts","build":"prisma generate && tsc -p tsconfig.build.json","start":"node dist/server.js","typecheck":"prisma generate && tsc --noEmit -p tsconfig.json","test":"vitest run","db:generate":"prisma generate","db:migrate":"prisma migrate dev","db:deploy":"prisma migrate deploy","db:studio":"prisma studio"},"dependencies":{"@prisma/adapter-pg":"7.10.1","@prisma/client":"7.10.1","@supabase/supabase-js":"2.119.0","dotenv":"^16.4.7","express":"^5.1.0","zod":"4.6.7"},"devDependencies":{"@types/express":"^5.0.1","@types/node":"^22.13.0","@types/supertest":"^6.0.2","prisma":"7.10.1","supertest":"^7.0.0","tsx":"^4.19.2","vitest":"5.1.0"}}
11 changes: 11 additions & 0 deletions apps/api/prisma.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { config } from "dotenv";
import { defineConfig } from "prisma/config";

config({ path: new URL("../../.env", import.meta.url) });

export default defineConfig({
schema: "prisma/schema.prisma",
migrations: { path: "prisma/migrations" },
// Client generation works without runtime secrets; migration commands reject an empty URL.
datasource: { url: process.env.DATABASE_URL ?? "" },
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- DropIndex
DROP INDEX "Task_dueAt_idx";

-- DropIndex
DROP INDEX "Task_status_priority_idx";

-- AlterTable
ALTER TABLE "Task" ADD COLUMN "ownerId" UUID;

-- CreateIndex
CREATE INDEX "Task_ownerId_status_priority_idx" ON "Task"("ownerId", "status", "priority");

-- CreateIndex
CREATE INDEX "Task_ownerId_dueAt_idx" ON "Task"("ownerId", "dueAt");

-- Prisma is the only data access boundary. Deny direct Data API access by default.
-- The trusted server database role needs BYPASSRLS; do not give that role to clients.
ALTER TABLE "Task" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TaskActivity" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "_prisma_migrations" ENABLE ROW LEVEL SECURITY;
2 changes: 2 additions & 0 deletions apps/api/prisma/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
6 changes: 3 additions & 3 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ generator client {

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

enum TaskStatus {
Expand All @@ -22,6 +21,7 @@ enum TaskPriority {
}

model Task {
ownerId String? @db.Uuid
id String @id @default(cuid())
title String @db.VarChar(160)
description String? @db.Text
Expand All @@ -34,8 +34,8 @@ model Task {
updatedAt DateTime @updatedAt
activities TaskActivity[]

@@index([status, priority])
@@index([dueAt])
@@index([ownerId, status, priority])
@@index([ownerId, dueAt])
}

model TaskActivity {
Expand Down
8 changes: 4 additions & 4 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("createApp",()=>{
afterEach(()=>vi.restoreAllMocks());

it("returns security headers and does not cache API responses",async()=>{
const response=await request(createApp({taskRepository:createRepository()})).get("/health");
const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()})).get("/health");
expect(response.status).toBe(200);
expect(response.headers["x-content-type-options"]).toBe("nosniff");
expect(response.headers["cache-control"]).toBe("no-store");
Expand All @@ -28,7 +28,7 @@ describe("createApp",()=>{

it("returns a generic error without logging the underlying error message",async()=>{
const errorSpy=vi.spyOn(console,"error").mockImplementation(()=>undefined);
const response=await request(createApp({taskRepository:createRepository()}))
const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()}))
.post("/api/v1/tasks")
.send({title:"Tarefa válida"});

Expand All @@ -40,15 +40,15 @@ describe("createApp",()=>{

it("rejects oversized JSON as a client error without logging task data",async()=>{
const errorSpy=vi.spyOn(console,"error").mockImplementation(()=>undefined);
const response=await request(createApp({taskRepository:createRepository()}))
const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()}))
.post("/api/v1/tasks").send({title:"x".repeat(110000)});
expect(response.status).toBe(413);
expect(response.body).toEqual({error:"O corpo da requisição excede o limite permitido."});
expect(errorSpy).not.toHaveBeenCalled();
});

it("rejects malformed JSON with a safe client error",async()=>{
const response=await request(createApp({taskRepository:createRepository()}))
const response=await request(createApp({authenticate:async()=>({id:"test-user"}),taskRepository:createRepository()}))
.post("/api/v1/tasks")
.set("Content-Type","application/json")
.send('{"title":');
Expand Down
80 changes: 57 additions & 23 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,61 @@
import express,{type ErrorRequestHandler,type Request,type Response} from "express";
import { PrismaClient } from "@prisma/client";
import express, { type ErrorRequestHandler } from "express";
import { createPrismaClient } from "./database/prisma.js";
import { createAuthenticator, AuthenticationUnavailable, type Authenticate } from "./auth/authentication.js";
import { PrismaTaskRepository } from "./tasks/prisma-task.repository.js";
import { createTasksRouter } from "./tasks/tasks.routes.js";
import type { TaskRepository } from "./tasks/task.types.js";
export interface AppOptions { taskRepository?:TaskRepository; }
export function createApp(options:AppOptions={}) {
const app=express();
const prisma=options.taskRepository?null:new PrismaClient();
const repository=options.taskRepository??new PrismaTaskRepository(prisma!);
app.disable("x-powered-by");
app.use((request:Request,response:Response,next)=>{response.setHeader("X-Content-Type-Options","nosniff");response.setHeader("Cache-Control","no-store");next();});
app.use(express.json({limit:"100kb"}));
app.get("/health",(_request:Request,response:Response)=>response.status(200).json({status:"ok",service:"taskdesk-api",timestamp:new Date().toISOString()}));
app.get("/api/v1",(_request:Request,response:Response)=>response.status(200).json({name:"taskDesk API",version:"v1",status:"available"}));
app.use("/api/v1/tasks",createTasksRouter(repository));
const errorHandler:ErrorRequestHandler=(error:unknown,_request,response,_next)=>{
if(typeof error === "object" && error !== null && "type" in error && error.type === "entity.too.large"){
response.status(413).json({error:"O corpo da requisição excede o limite permitido."});return;
}
if(error instanceof SyntaxError && "body" in error){response.status(400).json({error:"O corpo da requisição contém JSON inválido."});return;}
console.error("Unhandled API error");
response.status(500).json({error:"Não foi possível concluir a operação."});
};
app.use(errorHandler);
return app;

export interface AppOptions {
taskRepository?: TaskRepository;
repositoryForUser?: (userId: string) => TaskRepository;
authenticate?: Authenticate;
}

export function createApp(options: AppOptions = {}) {
const app = express();
let prisma: ReturnType<typeof createPrismaClient> | undefined;
const authenticate = options.authenticate ?? createAuthenticator();
const repositoryForUser = options.repositoryForUser ?? ((userId: string) =>
options.taskRepository ?? new PrismaTaskRepository(prisma ??= createPrismaClient(), userId));

app.disable("x-powered-by");
app.use((_request, response, next) => {
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Cache-Control", "no-store");
next();
});
app.use(express.json({ limit: "100kb" }));
app.get("/health", (_request, response) => response.status(200).json({
status: "ok", service: "taskdesk-api", timestamp: new Date().toISOString(),
}));
app.get("/api/v1", (_request, response) => response.status(200).json({
name: "taskDesk API", version: "v1", status: "available",
}));
app.use("/api/v1/tasks", async (request, response, next) => {
const identity = await authenticate(request);
if (!identity) {
response.status(401).json({ error: "Entre na sua conta para continuar." });
return;
}
return createTasksRouter(repositoryForUser(identity.id))(request, response, next);
});

const errorHandler: ErrorRequestHandler = (error: unknown, _request, response, _next) => {
if (error instanceof AuthenticationUnavailable) {
response.status(503).json({ error: "O acesso está temporariamente indisponível." });
return;
}
if (typeof error === "object" && error !== null && "type" in error && error.type === "entity.too.large") {
response.status(413).json({ error: "O corpo da requisição excede o limite permitido." });
return;
}
if (error instanceof SyntaxError && "body" in error) {
response.status(400).json({ error: "O corpo da requisição contém JSON inválido." });
return;
}
console.error("Unhandled API error");
response.status(500).json({ error: "Não foi possível concluir a operação." });
};
app.use(errorHandler);
return app;
}
52 changes: 52 additions & 0 deletions apps/api/src/auth/authentication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import request from "supertest";
import { createApp } from "../app.js";
import { createAuthenticator } from "./authentication.js";
import type { TaskRepository } from "../tasks/task.types.js";

const owner = "11111111-1111-4111-8111-111111111111";
const repository = {
list: vi.fn(async () => []), findById: async () => null, listActivities: async () => [],
create: async () => { throw new Error(); }, update: async () => null, delete: async () => false,
} satisfies TaskRepository;
const env = {SUPABASE_URL:"https://auth.example.test", SUPABASE_PUBLISHABLE_KEY:"sb_publishable_test"};

describe("authentication boundary", () => {
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); });
it("denies anonymous, malformed and invalid bearer tokens before reading tasks", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({msg:"Invalid JWT",code:"bad_jwt"}), {status:401}));
vi.stubGlobal("fetch", fetchMock);
const app = createApp({taskRepository:repository, authenticate:createAuthenticator(env)});
expect((await request(app).get("/api/v1/tasks")).status).toBe(401);
expect((await request(app).get("/api/v1/tasks").set("Authorization","Basic abc")).status).toBe(401);
expect((await request(app).get("/api/v1/tasks").set("Authorization","Bearer invalid")).status).toBe(401);
expect(repository.list).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("uses the verified user identity, never supplied ownership metadata", async () => {
vi.stubGlobal("fetch",vi.fn().mockResolvedValue(new Response(JSON.stringify({id:owner,is_anonymous:false,user_metadata:{ownerId:"attacker"}}),{status:200})));
const factory=vi.fn(()=>repository);
const app=createApp({repositoryForUser:factory,authenticate:createAuthenticator(env)});
expect((await request(app).get("/api/v1/tasks").set("Authorization","Bearer verified-token")).status).toBe(200);
expect(factory).toHaveBeenCalledWith(owner);
});
it("fails closed when auth is missing or unavailable and keeps health available",async()=>{
const missing=createApp({taskRepository:repository,authenticate:createAuthenticator({})});
expect((await request(missing).get("/api/v1/tasks")).status).toBe(503);
expect((await request(missing).get("/health")).status).toBe(200);
vi.stubGlobal("fetch",vi.fn().mockResolvedValue(new Response('{}',{status:503})));
const unavailable=createApp({taskRepository:repository,authenticate:createAuthenticator(env)});
expect((await request(unavailable).get("/api/v1/tasks").set("Authorization","Bearer token")).status).toBe(503);
expect(repository.list).not.toHaveBeenCalled();
});
it("rejects Supabase anonymous accounts",async()=>{
vi.stubGlobal("fetch",vi.fn().mockResolvedValue(new Response(JSON.stringify({id:owner,is_anonymous:true}),{status:200})));
const app=createApp({taskRepository:repository,authenticate:createAuthenticator(env)});
expect((await request(app).get("/api/v1/tasks").set("Authorization","Bearer token")).status).toBe(401);
});
it("forbids development bypass in production or Vercel",()=>{
expect(()=>createAuthenticator({AUTH_MODE:"local",NODE_ENV:"production"})).toThrow();
expect(()=>createAuthenticator({AUTH_MODE:"local",NODE_ENV:"development",VERCEL:"1"})).toThrow();
expect(()=>createAuthenticator({AUTH_MODE:"local",NODE_ENV:"development"})).not.toThrow();
});
});
38 changes: 38 additions & 0 deletions apps/api/src/auth/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createClient } from "@supabase/supabase-js";
import type { Request } from "express";

export interface Identity { id: string; }
export type Authenticate = (request: Request) => Promise<Identity | null>;
export const LOCAL_USER_ID = "00000000-0000-4000-8000-000000000001";

export class AuthenticationUnavailable extends Error {}

export function createAuthenticator(env: NodeJS.ProcessEnv = process.env): Authenticate {
if (env.AUTH_MODE === "local") {
if (env.NODE_ENV !== "development" || env.VERCEL) {
throw new Error("Local authentication is allowed only in local development.");
}
return async () => ({ id: LOCAL_USER_ID });
}
if (env.AUTH_MODE && env.AUTH_MODE !== "supabase") throw new Error("Invalid AUTH_MODE.");
const url = env.SUPABASE_URL;
const key = env.SUPABASE_PUBLISHABLE_KEY;
if (!url || !key) return async () => { throw new AuthenticationUnavailable(); };
if (!key.startsWith("sb_publishable_")) throw new Error("Use a Supabase publishable key.");
const client = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
global: { fetch: (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(8000) }) },
});
return async request => {
const match = /^Bearer ([^\s]+)$/i.exec(request.get("Authorization") ?? "");
if (!match) return null;
const { data, error } = await client.auth.getUser(match[1]);
if (error) {
if (error.status === undefined || error.status >= 500) throw new AuthenticationUnavailable();
return null;
}
// Never derive ownership from user-editable metadata or decode an unverified JWT.
if (!data.user || data.user.is_anonymous) return null;
return { id: data.user.id };
};
}
13 changes: 13 additions & 0 deletions apps/api/src/database/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

export function createPrismaClient(connectionString = process.env.DATABASE_URL): PrismaClient {
if (!connectionString) throw new Error("DATABASE_URL is required.");
const adapter = new PrismaPg({
connectionString,
max: 5,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
});
return new PrismaClient({ adapter });
}
7 changes: 6 additions & 1 deletion apps/api/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "dotenv/config";
import { config } from "dotenv";
config({path:new URL("../../../.env",import.meta.url)});
import { createApp } from "./app.js";

const port = Number(process.env.API_PORT ?? 3000);
Expand All @@ -8,6 +9,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("API_PORT must be an integer between 1 and 65535.");
}

if (process.env.AUTH_MODE === "local" && !["127.0.0.1", "::1", "localhost"].includes(host)) {
throw new Error("Local authentication requires a loopback API_HOST.");
}

const app = createApp();

app.listen(port, host, () => {
Expand Down
Loading
Loading