7 Commits

Author SHA1 Message Date
Felipe Coutinho
9a7ae0fa3d fix(docker): adicionar NODE_PATH no entrypoint para resolução do drizzle-orm
Corrige erro "Cannot find module 'drizzle-orm'" ao rodar migrations no
container — o drizzle-kit em /app/migrate/ não encontrava o módulo sem
NODE_PATH apontando para o node_modules isolado.

Closes #34

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 19:35:52 +00:00
Felipe Coutinho
98fe6a0f4f Update version badge from 2.3.4 to 2.3.5 2026-04-07 10:53:10 -03:00
Felipe Coutinho
d10eae13e5 Revise versioning and commit message guidelines
Updated versioning instructions to include README.md updates and clarified commit message guidelines.
2026-04-07 10:52:39 -03:00
Felipe Coutinho
43697b4fd2 fix(csp): mover CSP para proxy.ts para leitura em runtime
Content-Security-Policy estava em next.config.ts (build time),
então S3_ENDPOINT nunca era incluído no connect-src ao buildar
via Docker no CI. Movido para proxy.ts que avalia em runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:49:23 +00:00
Felipe Coutinho
27e3ba5f0d Update version badge from 2.1.2 to 2.3.4 2026-04-05 20:33:55 -03:00
Felipe Coutinho
31485eec8f fix(csp): permitir upload de anexos para o storage externo
connect-src bloqueava fetch para o Supabase Storage desde o commit
de segurança (10afef9). Adiciona a origin do S3_ENDPOINT na política.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:47:23 +00:00
Felipe Coutinho
3be64aa8d0 fix(auth): corrigir verify e unificar tokens com prefixo opm_
- Corrige /api/auth/device/verify que rejeitava tokens criados via
  Settings (revertido de JWT para hash lookup)
- Renomeia prefixo de tokens de os_ para opm_ (OpenMonetis)
- Remove rotas JWT não utilizadas (token, refresh)
- Simplifica api-token.ts mantendo apenas hashToken e extractBearerToken

BREAKING CHANGE: tokens existentes com prefixo os_ param de funcionar.
Revogar e recriar tokens após o deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 00:05:03 +00:00
14 changed files with 84 additions and 406 deletions

View File

@@ -7,6 +7,36 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.3.6] - 2026-04-09
### Corrigido
- Docker: adicionado `NODE_PATH=/app/migrate/node_modules` no entrypoint para que o `drizzle-kit` consiga resolver `drizzle-orm` ao executar as migrations no container
## [2.3.5] - 2026-04-07
### Corrigido
- CSP: movido `Content-Security-Policy` do `next.config.ts` (build time) para `proxy.ts` (runtime), corrigindo bloqueio de upload de anexos quando `S3_ENDPOINT` não estava disponível durante o build do Docker
## [2.3.4] - 2026-04-05
### Corrigido
- Anexos: corrigido upload que falhava com `NetworkError` — CSP `connect-src` bloqueava fetch para o Storage
## [2.3.3] - 2026-04-05
### Corrigido
- Tokens: corrigido `/api/auth/device/verify` que rejeitava tokens criados via Settings (revertido de JWT para hash lookup)
### Alterado
- Tokens: prefixo renomeado de `os_` para `opm_` (OpenMonetis); tokens existentes precisam ser recriados
- Tokens: removidas rotas JWT não utilizadas (`/api/auth/device/token` e `/api/auth/device/refresh`)
- Tokens: `api-token.ts` simplificado para conter apenas `hashToken` e `extractBearerToken`
## [2.3.2] - 2026-04-04 ## [2.3.2] - 2026-04-04
### Segurança ### Segurança

View File

@@ -16,9 +16,10 @@
3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`. 3. **Periods** usam formato `YYYY-MM` (ex: `"2025-11"`). Utils em `src/shared/utils/period/`.
4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`. 4. **Moeda**: R$ com 2 decimais. DB: `numeric(12, 2)`. Utils em `src/shared/utils/currency.ts`.
5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations. 5. **Revalidation**: usar `revalidateForEntity("entity")` de `src/shared/lib/actions/helpers.ts` apos mutations.
6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json`. 6. **Versionamento**: registrar mudancas no `CHANGELOG.md` seguindo Keep a Changelog, também altere o `package.json` e `readme.md`.
7. **Comunicacao**: responder em portugues clara e direta com o time. 7. **Comunicacao**: responder em portugues clara e direta com o time.
8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema. 8. **Commit messages**: agrupar por natureza. em pt-br. seguindo o padrao do sistema.
9. **README.md**: sempre que fizer alteracoes significativas, atualize o README.md.
--- ---

View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.1.2-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.3.6-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)

View File

@@ -2,7 +2,7 @@
echo "Rodando migrations..." echo "Rodando migrations..."
RETRIES=5 RETRIES=5
until /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do until NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do
RETRIES=$((RETRIES - 1)) RETRIES=$((RETRIES - 1))
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)" echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)"
sleep 5 sleep 5

View File

@@ -4,8 +4,6 @@ import type { NextConfig } from "next";
// Carregar variáveis de ambiente explicitamente // Carregar variáveis de ambiente explicitamente
dotenv.config(); dotenv.config();
const isDev = process.env.NODE_ENV === "development";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
cacheComponents: true, cacheComponents: true,
@@ -44,18 +42,6 @@ const nextConfig: NextConfig = {
key: "X-Frame-Options", key: "X-Frame-Options",
value: "DENY", value: "DENY",
}, },
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
"font-src 'self'",
"connect-src 'self' https://umami.felipecoutinho.com",
"frame-ancestors 'none'",
].join("; "),
},
{ {
key: "Referrer-Policy", key: "Referrer-Policy",
value: "strict-origin-when-cross-origin", value: "strict-origin-when-cross-origin",

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.3.2", "version": "2.3.6",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {

View File

@@ -1,86 +0,0 @@
import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema";
import {
extractBearerToken,
refreshAccessToken,
verifyJwt,
} from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db";
export async function POST(request: Request) {
try {
// Extrair refresh token do header
const authHeader = request.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return NextResponse.json(
{ error: "Refresh token não fornecido" },
{ status: 401 },
);
}
// Validar refresh token
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_refresh") {
return NextResponse.json(
{ error: "Refresh token inválido ou expirado" },
{ status: 401 },
);
}
// Verificar se token não foi revogado
const tokenRecord = await db.query.apiTokens.findFirst({
where: and(
eq(apiTokens.id, payload.tokenId),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()),
),
});
if (!tokenRecord) {
return NextResponse.json(
{ error: "Token revogado ou não encontrado" },
{ status: 401 },
);
}
// Gerar novo access token
const result = refreshAccessToken(token);
if (!result) {
return NextResponse.json(
{ error: "Não foi possível renovar o token" },
{ status: 401 },
);
}
// Atualizar último uso e expiração (sem sobrescrever tokenHash,
// pois o JWT é auto-verificável por assinatura)
await db
.update(apiTokens)
.set({
lastUsedAt: new Date(),
lastUsedIp:
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null,
expiresAt: result.expiresAt,
})
.where(eq(apiTokens.id, payload.tokenId));
return NextResponse.json({
accessToken: result.accessToken,
expiresAt: result.expiresAt.toISOString(),
});
} catch (error) {
console.error("[API] Error refreshing device token:", error);
return NextResponse.json(
{ error: "Erro ao renovar token" },
{ status: 500 },
);
}
}

View File

@@ -1,74 +0,0 @@
import { headers } from "next/headers";
import { connection, NextResponse } from "next/server";
import { z } from "zod";
import { apiTokens } from "@/db/schema";
import {
generateTokenPair,
getTokenPrefix,
hashToken,
} from "@/shared/lib/auth/api-token";
import { auth } from "@/shared/lib/auth/config";
import { db } from "@/shared/lib/db";
const createTokenSchema = z.object({
name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"),
deviceId: z.string().optional(),
});
export async function POST(request: Request) {
await connection();
// Verificar autenticação via sessão web
const requestHeaders = new Headers(await headers());
const session = await auth.api.getSession({ headers: requestHeaders });
if (!session?.user) {
return NextResponse.json({ error: "Não autenticado" }, { status: 401 });
}
try {
// Validar body
const body = await request.json();
const { name, deviceId } = createTokenSchema.parse(body);
// Gerar par de tokens
const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair(
session.user.id,
deviceId,
);
// Salvar hash do token no banco
await db.insert(apiTokens).values({
id: tokenId,
userId: session.user.id,
name,
tokenHash: hashToken(accessToken),
tokenPrefix: getTokenPrefix(accessToken),
expiresAt,
});
// Retornar tokens (mostrados apenas uma vez)
return NextResponse.json(
{
accessToken,
refreshToken,
tokenId,
name,
expiresAt: expiresAt.toISOString(),
message:
"Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.",
},
{ status: 201 },
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.issues[0]?.message ?? "Dados inválidos" },
{ status: 400 },
);
}
console.error("[API] Error creating device token:", error);
return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
import { and, eq, gt, isNull } from "drizzle-orm"; import { and, eq, gt, isNull } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiTokens } from "@/db/schema"; import { apiTokens } from "@/db/schema";
import { extractBearerToken, verifyJwt } from "@/shared/lib/auth/api-token"; import { extractBearerToken, hashToken } from "@/shared/lib/auth/api-token";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -17,21 +17,20 @@ export async function POST(request: Request) {
); );
} }
// Verificar JWT (assinatura + expiração) // Validar token opm_xxx via hash
const payload = verifyJwt(token); if (!token.startsWith("opm_")) {
if (!payload || payload.type !== "api_access") {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Token inválido ou expirado" }, { valid: false, error: "Formato de token inválido" },
{ status: 401 }, { status: 401 },
); );
} }
// Buscar token no banco por tokenId para checar revogação const tokenHash = hashToken(token);
// Buscar token no banco
const tokenRecord = await db.query.apiTokens.findFirst({ const tokenRecord = await db.query.apiTokens.findFirst({
where: and( where: and(
eq(apiTokens.id, payload.tokenId), eq(apiTokens.tokenHash, tokenHash),
eq(apiTokens.userId, payload.sub),
isNull(apiTokens.revokedAt), isNull(apiTokens.revokedAt),
gt(apiTokens.expiresAt, new Date()), gt(apiTokens.expiresAt, new Date()),
), ),
@@ -39,7 +38,7 @@ export async function POST(request: Request) {
if (!tokenRecord) { if (!tokenRecord) {
return NextResponse.json( return NextResponse.json(
{ valid: false, error: "Token revogado ou não encontrado" }, { valid: false, error: "Token inválido ou revogado" },
{ status: 401 }, { status: 401 },
); );
} }

View File

@@ -48,8 +48,8 @@ export async function POST(request: Request) {
); );
} }
// Validar token os_xxx via hash // Validar token opm_xxx via hash
if (!token.startsWith("os_")) { if (!token.startsWith("opm_")) {
return NextResponse.json( return NextResponse.json(
{ error: "Formato de token inválido" }, { error: "Formato de token inválido" },
{ status: 401 }, { status: 401 },

View File

@@ -41,8 +41,8 @@ export async function POST(request: Request) {
); );
} }
// Validar token os_xxx via hash // Validar token opm_xxx via hash
if (!token.startsWith("os_")) { if (!token.startsWith("opm_")) {
return NextResponse.json( return NextResponse.json(
{ error: "Formato de token inválido" }, { error: "Formato de token inválido" },
{ status: 401 }, { status: 401 },

View File

@@ -610,7 +610,7 @@ const revokeApiTokenSchema = z.object({
}); });
function generateSecureToken(): string { function generateSecureToken(): string {
const prefix = "os"; const prefix = "opm";
const randomPart = randomBytes(32).toString("base64url"); const randomPart = randomBytes(32).toString("base64url");
return `${prefix}_${randomPart}`; return `${prefix}_${randomPart}`;
} }

View File

@@ -21,6 +21,38 @@ const PROTECTED_ROUTES = [
// Rotas públicas (não requerem autenticação) // Rotas públicas (não requerem autenticação)
const PUBLIC_AUTH_ROUTES = ["/login", "/signup"]; const PUBLIC_AUTH_ROUTES = ["/login", "/signup"];
function buildCsp(): string {
const isDev = process.env.NODE_ENV === "development";
const s3Origin = (() => {
try {
return process.env.S3_ENDPOINT
? new URL(process.env.S3_ENDPOINT).origin
: "";
} catch {
return "";
}
})();
const connectExtras = ["https://umami.felipecoutinho.com", s3Origin]
.filter(Boolean)
.join(" ");
const imgExtras = ["https://lh3.googleusercontent.com", s3Origin]
.filter(Boolean)
.join(" ");
return [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
"style-src 'self' 'unsafe-inline'",
`img-src 'self' ${imgExtras} data: blob:`,
"font-src 'self'",
`connect-src 'self' ${connectExtras}`,
"frame-ancestors 'none'",
].join("; ");
}
export default async function proxy(request: NextRequest) { export default async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
@@ -63,7 +95,9 @@ export default async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL("/login", request.url)); return NextResponse.redirect(new URL("/login", request.url));
} }
return NextResponse.next(); const response = NextResponse.next();
response.headers.set("Content-Security-Policy", buildCsp());
return response;
} }
export const config = { export const config = {

View File

@@ -1,149 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
function getJwtSecret(): string {
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error(
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
);
}
return secret;
}
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
// ============================================================================
// TYPES
// ============================================================================
export interface JwtPayload {
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
}
// ============================================================================
// JWT UTILITIES
// ============================================================================
/**
* Base64URL encode a string
*/
function base64UrlEncode(str: string): string {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Base64URL decode a string
*/
function base64UrlDecode(str: string): string {
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
}
/**
* Create HMAC-SHA256 signature
*/
function createSignature(data: string): string {
return crypto
.createHmac("sha256", getJwtSecret())
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Create a JWT token
*/
export function createJwt(
payload: Omit<JwtPayload, "iat" | "exp">,
expiresIn: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
return `${headerEncoded}.${payloadEncoded}.${signature}`;
}
/**
* Verify and decode a JWT token
* @returns The decoded payload or null if invalid
*/
export function verifyJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(
`${headerEncoded}.${payloadEncoded}`,
);
// Constant-time comparison to prevent timing attacks
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
return payload;
} catch {
return null;
}
}
// ============================================================================
// TOKEN GENERATION
// ============================================================================
/**
* Generate a random token ID
*/
export function generateTokenId(): string {
return crypto.randomUUID();
}
/** /**
* Hash a token using SHA-256 * Hash a token using SHA-256
*/ */
@@ -151,74 +7,6 @@ export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex"); return crypto.createHash("sha256").update(token).digest("hex");
} }
/**
* Get the display prefix of a token (first 8 chars after prefix)
*/
export function getTokenPrefix(token: string): string {
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
}
/**
* Generate a complete token pair (access + refresh)
*/
export function generateTokenPair(
userId: string,
deviceId?: string,
): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY,
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY,
);
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
}
/**
* Refresh an access token using a refresh token
*/
export function refreshAccessToken(
refreshToken: string,
): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
if (!payload || payload.type !== "api_refresh") {
return null;
}
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{
sub: payload.sub,
type: "api_access",
tokenId: payload.tokenId,
deviceId: payload.deviceId,
},
ACCESS_TOKEN_EXPIRY,
);
return { accessToken, expiresAt };
}
// ============================================================================
// VALIDATION HELPERS
// ============================================================================
/** /**
* Extract bearer token from Authorization header * Extract bearer token from Authorization header
*/ */