forlogic-core 2.3.5 → 2.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: Lib hardening roadmap
3
+ description: Roadmap de hardening do forlogic-core dividido em blocos por impacto (zero-impact → opt-in → breaking)
4
+ type: feature
5
+ ---
6
+
7
+ Plano completo em `.lovable/plan.md`. Status:
8
+
9
+ **Bloco 1 (patch, zero impact — IMPLEMENTADO)**
10
+ - Headers de dev reforçados: COOP, CORP, bloqueio de TRACE/CONNECT, Vary sempre.
11
+ - SupabaseSingleton bloqueia `apikey` em query string.
12
+ - `errorService` usa `sanitizeErrorMessage` + `generateId('short')`.
13
+ - `lib/utils/generateId.ts`, `lib/utils/sanitizeError.ts`.
14
+ - `lib/validation/schemas.ts` reexportado pelo barrel.
15
+ - `scripts/check-rls.ts` + `npm run check:rls`.
16
+
17
+ **Bloco 2 (minor, opt-in — IMPLEMENTADO)**
18
+ - Subpath `forlogic-core/edge`: requireUserJWT, requireOwnership, safeJsonParse, jsonError, rateLimit, withCors, corsPreflight, stripTechHeaders, HttpError. Standalone (sem deps de React/Supabase SDK).
19
+ - Subpath `forlogic-core/storage`: detectMimeFromMagicBytes, validateMagicBytes, sanitizeAndRandomizeFilename, getSignedUrl, RECOMMENDED_PRIVATE_BUCKETS, SAFE_IMAGE_MIME, SAFE_DOCUMENT_MIME.
20
+ - Subpath `forlogic-core/validation`: schemas zod (também no barrel).
21
+ - `lib/templates/migrations/`: 01-user-owned-table.sql, 02-user-roles.sql, README.
22
+ - `CoreProviders` ganha prop opcional `securityMode?: 'legacy' | 'strict'` (default `legacy`) + helper `getSecurityMode()`.
23
+ - `package.json#exports` e `rollup.config.js` configurados para os 3 novos subpaths (standaloneBuilds).
24
+
25
+ **Bloco 3 (major v2.0.0 — breaking, próxima fase)**
26
+ - JWT fora de localStorage.
27
+ - `apikey` fora da URL no Realtime.
28
+ - CORS default restritivo (sem `*.lovable.app`).
29
+ - SameSite=Strict em cookies.
30
+ - Lint rule banindo `Date.now()` como ID.
31
+ - `securityMode='strict'` vira default; flag removida em v3.
32
+
33
+ ## Uso pelos consumidores
34
+
35
+ ```ts
36
+ // Edge Function (Deno)
37
+ import { requireUserJWT, jsonError, rateLimit } from 'npm:forlogic-core/edge';
38
+
39
+ // Browser
40
+ import { validateMagicBytes, SAFE_IMAGE_MIME, sanitizeAndRandomizeFilename } from 'forlogic-core/storage';
41
+ import { emailSchema, userIdSchema } from 'forlogic-core/validation';
42
+
43
+ // App root
44
+ <CoreProviders securityMode="strict">...</CoreProviders>
45
+ ```
@@ -17,5 +17,7 @@ export interface ModulesContentProps {
17
17
  alias?: string;
18
18
  /** Identificador do módulo de origem (de onde o menu foi aberto). Usado em analytics. */
19
19
  sourceModule?: string;
20
+ /** Exibe o grupo "Admin" no menu. Restrito a usuários SysAdmin. */
21
+ showAdminGroup?: boolean;
20
22
  }
21
- export declare function ModulesContent({ onModuleClick, contractedModules, onModuleInterest, nonContractedUrl, educaUrl, saberGestaoUrl, wikiUrl, alias, sourceModule, }: ModulesContentProps): import("react/jsx-runtime").JSX.Element;
23
+ export declare function ModulesContent({ onModuleClick, contractedModules, onModuleInterest, nonContractedUrl, educaUrl, saberGestaoUrl, wikiUrl, alias, sourceModule, showAdminGroup, }: ModulesContentProps): import("react/jsx-runtime").JSX.Element;
@@ -1,3 +1,3 @@
1
1
  import { ModulesDialogProps } from "./types";
2
- export declare function ModulesDialog({ open, onOpenChange, onModuleClick, contractedModules, onModuleInterest, educaUrl, saberGestaoUrl, wikiUrl, sourceModule, }: ModulesDialogProps): import("react/jsx-runtime").JSX.Element;
2
+ export declare function ModulesDialog({ open, onOpenChange, onModuleClick, contractedModules, onModuleInterest, educaUrl, saberGestaoUrl, wikiUrl, sourceModule, showAdminGroup, }: ModulesDialogProps): import("react/jsx-runtime").JSX.Element;
3
3
  export default ModulesDialog;
@@ -12,6 +12,8 @@ export interface Module {
12
12
  softwareAlias?: string;
13
13
  /** Steps do onboarding para módulos não contratados */
14
14
  onboardingSteps?: OnboardingStep[];
15
+ /** Quando true, o módulo é sempre tratado como contratado (não passa pelo filtro de contractedModules). */
16
+ alwaysContracted?: boolean;
15
17
  }
16
18
  export interface ModuleGroup {
17
19
  id: string;
@@ -38,4 +40,6 @@ export interface ModulesDialogProps {
38
40
  allModulesUrl?: string;
39
41
  /** Identificador do módulo de origem (de onde o menu foi aberto). Usado em analytics. */
40
42
  sourceModule?: string;
43
+ /** Quando true, inclui o card "Admin" ao final da aba Qualiex. Restrito a SysAdmin. Quando omitido, o ModulesDialog deriva de `user.isSysAdmin`. */
44
+ showAdminGroup?: boolean;
41
45
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * forlogic-core/edge — helpers para Edge Functions (Deno) que consomem a stack.
3
+ *
4
+ * Uso típico em uma Edge Function:
5
+ * ```ts
6
+ * import { requireUserJWT, safeJsonParse, jsonError, rateLimit } from 'npm:forlogic-core/edge';
7
+ * import { z } from 'npm:zod';
8
+ *
9
+ * const bodySchema = z.object({ message: z.string().max(500) });
10
+ *
11
+ * Deno.serve(async (req) => {
12
+ * try {
13
+ * const user = await requireUserJWT(req);
14
+ * const limited = await rateLimit(`msg:${user.sub}`, { max: 10, windowSec: 60 });
15
+ * if (limited) return jsonError('Too many requests', 429);
16
+ * const body = await safeJsonParse(req, bodySchema);
17
+ * // ... lógica
18
+ * return Response.json({ ok: true });
19
+ * } catch (err) {
20
+ * return jsonError(err);
21
+ * }
22
+ * });
23
+ * ```
24
+ *
25
+ * Todos os helpers são tree-shakable e não dependem de Supabase SDK,
26
+ * mantendo o cold-start mínimo nas Edge Functions.
27
+ */
28
+ export interface JwtPayload {
29
+ sub: string;
30
+ exp?: number;
31
+ iat?: number;
32
+ email?: string;
33
+ role?: string;
34
+ [key: string]: unknown;
35
+ }
36
+ export interface RateLimitOptions {
37
+ /** Máximo de requests permitidos dentro da janela. */
38
+ max: number;
39
+ /** Janela em segundos. */
40
+ windowSec: number;
41
+ }
42
+ export declare class HttpError extends Error {
43
+ status: number;
44
+ constructor(status: number, message: string);
45
+ }
46
+ /**
47
+ * Resposta JSON de erro segura: nunca vaza stack/detalhes técnicos para o caller.
48
+ * Aceita `Error`, `HttpError` ou (status, message).
49
+ */
50
+ export declare function jsonError(err: unknown, status?: number): Response;
51
+ /**
52
+ * Extrai e valida o JWT do header `Authorization: Bearer ...`.
53
+ * Apenas decodifica o payload e checa `exp` — assume que o gateway (Supabase)
54
+ * já validou a assinatura. Para validação criptográfica adicional, combine com `verifyJwt`.
55
+ */
56
+ export declare function requireUserJWT(req: Request): Promise<JwtPayload>;
57
+ /**
58
+ * Garante que o usuário autenticado é dono do recurso.
59
+ * Use depois de `requireUserJWT`.
60
+ */
61
+ export declare function requireOwnership(userId: string, resourceOwnerId: string | null | undefined): void;
62
+ interface ZodLike<T> {
63
+ parse(input: unknown): T;
64
+ }
65
+ /**
66
+ * Faz parse do body como JSON e valida com um schema zod (opcional).
67
+ * Limita o tamanho do payload para evitar DoS.
68
+ */
69
+ export declare function safeJsonParse<T = unknown>(req: Request, schema?: ZodLike<T>, opts?: {
70
+ maxBytes?: number;
71
+ }): Promise<T>;
72
+ /**
73
+ * Remove headers técnicos antes de retornar a resposta ao caller.
74
+ * Use sempre que repassar uma `Response` vinda de Supabase REST/RPC.
75
+ */
76
+ export declare function stripTechHeaders(res: Response): Response;
77
+ /**
78
+ * Rate limiter in-memory (por instância Deno). Para limites cross-instance,
79
+ * persista em Supabase ou Upstash; este helper cobre 90% dos casos.
80
+ *
81
+ * Retorna `true` se a request DEVE ser bloqueada.
82
+ */
83
+ export declare function rateLimit(key: string, opts: RateLimitOptions): boolean;
84
+ export interface CorsOptions {
85
+ /** Lista explícita de origens permitidas (sem wildcard com credentials). */
86
+ allowedOrigins: string[];
87
+ allowedMethods?: string[];
88
+ allowedHeaders?: string[];
89
+ credentials?: boolean;
90
+ }
91
+ /**
92
+ * Aplica CORS seguro a uma resposta. Sempre define `Vary: Origin`.
93
+ * Nunca usa `*` quando `credentials=true`.
94
+ */
95
+ export declare function withCors(req: Request, res: Response, opts: CorsOptions): Response;
96
+ /** Resposta padrão para preflight OPTIONS. */
97
+ export declare function corsPreflight(req: Request, opts: CorsOptions): Response | null;
98
+ export {};
@@ -0,0 +1,124 @@
1
+ class HttpError extends Error {
2
+ constructor(status, message) {
3
+ super(message);
4
+ this.status = status;
5
+ this.name = "HttpError";
6
+ }
7
+ }
8
+ function jsonError(err, status = 500) {
9
+ if (err instanceof HttpError) {
10
+ return Response.json({ error: err.message }, { status: err.status });
11
+ }
12
+ if (typeof err === "string") {
13
+ return Response.json({ error: err }, { status });
14
+ }
15
+ const message = err instanceof Error ? err.message : "Internal error";
16
+ const isProd = globalThis.Deno?.env?.get?.("DENO_DEPLOYMENT_ID");
17
+ return Response.json(
18
+ { error: isProd ? "Internal error" : message },
19
+ { status }
20
+ );
21
+ }
22
+ function decodeJwt(token) {
23
+ const parts = token.split(".");
24
+ if (parts.length !== 3) throw new HttpError(401, "Malformed token");
25
+ try {
26
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
27
+ const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
28
+ return JSON.parse(atob(padded));
29
+ } catch {
30
+ throw new HttpError(401, "Invalid token");
31
+ }
32
+ }
33
+ async function requireUserJWT(req) {
34
+ const auth = req.headers.get("authorization") ?? req.headers.get("Authorization");
35
+ if (!auth?.startsWith("Bearer ")) throw new HttpError(401, "Missing bearer token");
36
+ const token = auth.slice(7).trim();
37
+ if (!token) throw new HttpError(401, "Empty token");
38
+ const payload = decodeJwt(token);
39
+ if (!payload.sub) throw new HttpError(401, "Token missing sub");
40
+ if (payload.exp && payload.exp * 1e3 < Date.now()) {
41
+ throw new HttpError(401, "Token expired");
42
+ }
43
+ return payload;
44
+ }
45
+ function requireOwnership(userId, resourceOwnerId) {
46
+ if (!resourceOwnerId || userId !== resourceOwnerId) {
47
+ throw new HttpError(403, "Forbidden");
48
+ }
49
+ }
50
+ async function safeJsonParse(req, schema, opts = {}) {
51
+ const maxBytes = opts.maxBytes ?? 1e6;
52
+ const contentLength = Number(req.headers.get("content-length") ?? 0);
53
+ if (contentLength > maxBytes) throw new HttpError(413, "Payload too large");
54
+ let raw;
55
+ try {
56
+ raw = await req.text();
57
+ } catch {
58
+ throw new HttpError(400, "Invalid body");
59
+ }
60
+ if (raw.length > maxBytes) throw new HttpError(413, "Payload too large");
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(raw);
64
+ } catch {
65
+ throw new HttpError(400, "Invalid JSON");
66
+ }
67
+ if (schema) {
68
+ try {
69
+ return schema.parse(parsed);
70
+ } catch (err) {
71
+ throw new HttpError(400, err instanceof Error ? err.message : "Validation failed");
72
+ }
73
+ }
74
+ return parsed;
75
+ }
76
+ const TECH_HEADERS = [
77
+ "x-postgres-error",
78
+ "x-postgres-hint",
79
+ "x-pg-error",
80
+ "x-supabase-internal",
81
+ "x-debug",
82
+ "server",
83
+ "x-powered-by"
84
+ ];
85
+ function stripTechHeaders(res) {
86
+ const headers = new Headers(res.headers);
87
+ for (const h of TECH_HEADERS) headers.delete(h);
88
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
89
+ }
90
+ const buckets = /* @__PURE__ */ new Map();
91
+ function rateLimit(key, opts) {
92
+ const now = Date.now();
93
+ const bucket = buckets.get(key);
94
+ if (!bucket || bucket.resetAt < now) {
95
+ buckets.set(key, { count: 1, resetAt: now + opts.windowSec * 1e3 });
96
+ return false;
97
+ }
98
+ bucket.count++;
99
+ return bucket.count > opts.max;
100
+ }
101
+ function withCors(req, res, opts) {
102
+ const origin = req.headers.get("origin");
103
+ const headers = new Headers(res.headers);
104
+ headers.set("Vary", "Origin");
105
+ if (origin && opts.allowedOrigins.includes(origin)) {
106
+ headers.set("Access-Control-Allow-Origin", origin);
107
+ if (opts.credentials) headers.set("Access-Control-Allow-Credentials", "true");
108
+ headers.set(
109
+ "Access-Control-Allow-Methods",
110
+ (opts.allowedMethods ?? ["GET", "POST", "PUT", "DELETE", "OPTIONS"]).join(", ")
111
+ );
112
+ headers.set(
113
+ "Access-Control-Allow-Headers",
114
+ (opts.allowedHeaders ?? ["authorization", "content-type", "apikey"]).join(", ")
115
+ );
116
+ }
117
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
118
+ }
119
+ function corsPreflight(req, opts) {
120
+ if (req.method !== "OPTIONS") return null;
121
+ return withCors(req, new Response(null, { status: 204 }), opts);
122
+ }
123
+
124
+ export { HttpError, corsPreflight, jsonError, rateLimit, requireOwnership, requireUserJWT, safeJsonParse, stripTechHeaders, withCors };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ class HttpError extends Error {
4
+ constructor(status, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = "HttpError";
8
+ }
9
+ }
10
+ function jsonError(err, status = 500) {
11
+ if (err instanceof HttpError) {
12
+ return Response.json({ error: err.message }, { status: err.status });
13
+ }
14
+ if (typeof err === "string") {
15
+ return Response.json({ error: err }, { status });
16
+ }
17
+ const message = err instanceof Error ? err.message : "Internal error";
18
+ const isProd = globalThis.Deno?.env?.get?.("DENO_DEPLOYMENT_ID");
19
+ return Response.json(
20
+ { error: isProd ? "Internal error" : message },
21
+ { status }
22
+ );
23
+ }
24
+ function decodeJwt(token) {
25
+ const parts = token.split(".");
26
+ if (parts.length !== 3) throw new HttpError(401, "Malformed token");
27
+ try {
28
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
29
+ const padded = payload + "=".repeat((4 - payload.length % 4) % 4);
30
+ return JSON.parse(atob(padded));
31
+ } catch {
32
+ throw new HttpError(401, "Invalid token");
33
+ }
34
+ }
35
+ async function requireUserJWT(req) {
36
+ const auth = req.headers.get("authorization") ?? req.headers.get("Authorization");
37
+ if (!auth?.startsWith("Bearer ")) throw new HttpError(401, "Missing bearer token");
38
+ const token = auth.slice(7).trim();
39
+ if (!token) throw new HttpError(401, "Empty token");
40
+ const payload = decodeJwt(token);
41
+ if (!payload.sub) throw new HttpError(401, "Token missing sub");
42
+ if (payload.exp && payload.exp * 1e3 < Date.now()) {
43
+ throw new HttpError(401, "Token expired");
44
+ }
45
+ return payload;
46
+ }
47
+ function requireOwnership(userId, resourceOwnerId) {
48
+ if (!resourceOwnerId || userId !== resourceOwnerId) {
49
+ throw new HttpError(403, "Forbidden");
50
+ }
51
+ }
52
+ async function safeJsonParse(req, schema, opts = {}) {
53
+ const maxBytes = opts.maxBytes ?? 1e6;
54
+ const contentLength = Number(req.headers.get("content-length") ?? 0);
55
+ if (contentLength > maxBytes) throw new HttpError(413, "Payload too large");
56
+ let raw;
57
+ try {
58
+ raw = await req.text();
59
+ } catch {
60
+ throw new HttpError(400, "Invalid body");
61
+ }
62
+ if (raw.length > maxBytes) throw new HttpError(413, "Payload too large");
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(raw);
66
+ } catch {
67
+ throw new HttpError(400, "Invalid JSON");
68
+ }
69
+ if (schema) {
70
+ try {
71
+ return schema.parse(parsed);
72
+ } catch (err) {
73
+ throw new HttpError(400, err instanceof Error ? err.message : "Validation failed");
74
+ }
75
+ }
76
+ return parsed;
77
+ }
78
+ const TECH_HEADERS = [
79
+ "x-postgres-error",
80
+ "x-postgres-hint",
81
+ "x-pg-error",
82
+ "x-supabase-internal",
83
+ "x-debug",
84
+ "server",
85
+ "x-powered-by"
86
+ ];
87
+ function stripTechHeaders(res) {
88
+ const headers = new Headers(res.headers);
89
+ for (const h of TECH_HEADERS) headers.delete(h);
90
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
91
+ }
92
+ const buckets = /* @__PURE__ */ new Map();
93
+ function rateLimit(key, opts) {
94
+ const now = Date.now();
95
+ const bucket = buckets.get(key);
96
+ if (!bucket || bucket.resetAt < now) {
97
+ buckets.set(key, { count: 1, resetAt: now + opts.windowSec * 1e3 });
98
+ return false;
99
+ }
100
+ bucket.count++;
101
+ return bucket.count > opts.max;
102
+ }
103
+ function withCors(req, res, opts) {
104
+ const origin = req.headers.get("origin");
105
+ const headers = new Headers(res.headers);
106
+ headers.set("Vary", "Origin");
107
+ if (origin && opts.allowedOrigins.includes(origin)) {
108
+ headers.set("Access-Control-Allow-Origin", origin);
109
+ if (opts.credentials) headers.set("Access-Control-Allow-Credentials", "true");
110
+ headers.set(
111
+ "Access-Control-Allow-Methods",
112
+ (opts.allowedMethods ?? ["GET", "POST", "PUT", "DELETE", "OPTIONS"]).join(", ")
113
+ );
114
+ headers.set(
115
+ "Access-Control-Allow-Headers",
116
+ (opts.allowedHeaders ?? ["authorization", "content-type", "apikey"]).join(", ")
117
+ );
118
+ }
119
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
120
+ }
121
+ function corsPreflight(req, opts) {
122
+ if (req.method !== "OPTIONS") return null;
123
+ return withCors(req, new Response(null, { status: 204 }), opts);
124
+ }
125
+
126
+ exports.HttpError = HttpError;
127
+ exports.corsPreflight = corsPreflight;
128
+ exports.jsonError = jsonError;
129
+ exports.rateLimit = rateLimit;
130
+ exports.requireOwnership = requireOwnership;
131
+ exports.requireUserJWT = requireUserJWT;
132
+ exports.safeJsonParse = safeJsonParse;
133
+ exports.stripTechHeaders = stripTechHeaders;
134
+ exports.withCors = withCors;