-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvite-plugin-api-dev.ts
More file actions
247 lines (216 loc) · 8.65 KB
/
Copy pathvite-plugin-api-dev.ts
File metadata and controls
247 lines (216 loc) · 8.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import { readFileSync } from "node:fs";
import type { Connect, Plugin } from "vite";
/**
* Serve `/api/*` durante `npm run dev`.
*
* Em produção estas rotas são funções serverless (`api/*.ts`). O servidor do
* Vite não serve `api/`, então sem isto `npm run dev` daria uma tela de login
* que não funciona — e, desde que o banco vive atrás de `/api/rpc`, um app que
* não carrega nada.
*
* A regra NÃO é duplicada aqui: o plugin importa os mesmos módulos que as
* funções serverless usam. O que ele faz é o transporte HTTP e a leitura do
* `.env.local`.
*/
export function apiDev(): Plugin {
return {
name: "macronaut:api-dev",
apply: "serve",
configureServer(server) {
server.middlewares.use("/api/login", rota(login));
server.middlewares.use("/api/rpc", rota(rpc));
server.middlewares.use("/api/codigo", rota(codigo));
server.middlewares.use("/api/parear", rota(parear));
server.middlewares.use("/api/ingest", rota(ingest));
},
};
}
type Responder = (status: number, json: unknown) => void;
type Rota = (corpo: unknown, req: Connect.IncomingMessage, responder: Responder) => Promise<void>;
/** POST + corpo JSON + tratamento de erro, que as duas rotas compartilham. */
function rota(fn: Rota): Connect.NextHandleFunction {
return (req, res, next) => {
if (req.method !== "POST") return next();
let cru = "";
req.on("data", (c) => (cru += c));
req.on("end", () => {
const responder: Responder = (status, json) => {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(json));
};
void (async () => {
try {
await fn(JSON.parse(cru || "{}"), req, responder);
} catch (e) {
// Em dev a causa vai inteira para a tela: é a máquina do próprio
// desenvolvedor, e adivinhar por que falhou é pior.
responder(500, { error: e instanceof Error ? e.message : String(e) });
}
})();
});
};
}
/**
* O segredo de assinatura em desenvolvimento.
*
* Fixo e avisado quando `AUTH_SECRET` não existe: exigir a variável faria
* `npm run dev` parar de funcionar em toda máquina que ainda não a tem, e o
* bilhete assinado aqui só vale contra o banco local. Na Vercel não há
* fallback — `lerConfig` recusa subir sem o segredo de verdade.
*/
const SEGREDO_DEV = "macronaut-dev-inseguro";
let avisou = false;
function config() {
const env = lerEnvLocal();
const dbUrl = process.env.DB_URL ?? env.DB_URL;
const dbToken = process.env.DB_TOKEN ?? env.DB_TOKEN;
const segredo = process.env.AUTH_SECRET ?? env.AUTH_SECRET ?? SEGREDO_DEV;
if (segredo === SEGREDO_DEV && !avisou) {
avisou = true;
console.warn(
"[macronaut] AUTH_SECRET não definida: usando um segredo de desenvolvimento. " +
"Defina AUTH_SECRET no .env.local e na Vercel antes de publicar.",
);
}
return { dbUrl, dbToken, segredo };
}
async function cliente(dbUrl: string, dbToken: string | undefined) {
const { createClient } = await import("@libsql/client");
return createClient({ url: dbUrl, authToken: dbToken });
}
const login: Rota = async (corpo, _req, responder) => {
const { email, senha } = (corpo ?? {}) as { email?: unknown; senha?: unknown };
if (typeof email !== "string" || typeof senha !== "string" || !email || !senha) {
return responder(400, { error: "e-mail e senha são obrigatórios" });
}
const { dbUrl, dbToken, segredo } = config();
if (!dbUrl) {
return responder(500, {
error:
"DB_URL não configurada. Copie .env.example para .env.local e preencha antes de entrar.",
});
}
const [{ authenticate }, { findUserByEmail }, auth, tokens] = await Promise.all([
import("./api/_lib/login-core.js"),
import("./src/repositories/users.js"),
import("./src/domain/auth.js"),
import("./api/_lib/tokens.js"),
]);
const db = await cliente(dbUrl, dbToken);
const r = await authenticate(
{
findUser: (e) => findUserByEmail(db, e),
verify: auth.verifyPassword,
dummyHash: auth.DUMMY_HASH,
emitirToken: (u) =>
tokens.emitir(
{ u, exp: Math.floor(Date.now() / 1000) + tokens.DURACAO_SESSAO_S, esc: "sessao" },
segredo,
),
},
{ email, senha },
);
if (!r.ok) return responder(401, { error: "e-mail ou senha inválidos" });
return responder(200, { user: r.user, token: r.token });
};
const rpc: Rota = async (corpo, req, responder) => {
const { dbUrl, dbToken, segredo } = config();
if (!dbUrl) return responder(500, { error: "DB_URL não configurada" });
const [servidor, rpcCore, registro] = await Promise.all([
import("./api/_lib/servidor.js"),
import("./api/_lib/rpc-core.js"),
import("./api/_lib/registro.js"),
]);
const sessao = servidor.autorizar(req.headers.authorization, segredo, "sessao");
if (!sessao) return responder(401, { error: "401 sessão inválida ou expirada" });
let chamadas;
try {
chamadas = rpcCore.lerChamadas(corpo);
} catch (e) {
return responder(400, { error: e instanceof Error ? e.message : String(e) });
}
const db = await cliente(dbUrl, dbToken);
return responder(200, await rpcCore.executarLote(registro.REGISTRO, db, sessao.u, chamadas));
};
const codigo: Rota = async (_corpo, req, responder) => {
const { dbUrl, dbToken, segredo } = config();
if (!dbUrl) return responder(500, { error: "DB_URL não configurada" });
const [servidor, tokens, disp] = await Promise.all([
import("./api/_lib/servidor.js"),
import("./api/_lib/tokens.js"),
import("./src/repositories/dispositivos.js"),
]);
const sessao = servidor.autorizar(req.headers.authorization, segredo, "sessao");
if (!sessao) return responder(401, { error: "401 sessão inválida ou expirada" });
const db = await cliente(dbUrl, dbToken);
const cru = tokens.gerarCodigo();
const { expira_em } = await disp.criarCodigo(db, sessao.u, tokens.hashDeCodigo(cru, segredo));
return responder(200, { codigo: cru, expira_em });
};
const parear: Rota = async (corpo, _req, responder) => {
const { dbUrl, dbToken, segredo } = config();
if (!dbUrl) return responder(500, { error: "DB_URL não configurada" });
const { codigo: cru, nome, plataforma } = (corpo ?? {}) as Record<string, unknown>;
const [tokens, disp] = await Promise.all([
import("./api/_lib/tokens.js"),
import("./src/repositories/dispositivos.js"),
]);
if (typeof cru !== "string" || tokens.normalizarCodigo(cru).length !== 8) {
return responder(400, { error: "código inválido" });
}
const aparelho = {
nome: typeof nome === "string" && nome.trim() ? nome.trim().slice(0, 120) : "Aparelho",
plataforma: plataforma === "android" ? "android" : "desconhecida",
};
const db = await cliente(dbUrl, dbToken);
const r = await disp.resgatarCodigo(db, tokens.hashDeCodigo(cru, segredo), aparelho);
if (!r) return responder(401, { error: "código inválido ou expirado" });
return responder(200, {
token: tokens.emitir(
{ u: r.userId, exp: tokens.SEM_EXPIRACAO, esc: "dispositivo", d: r.deviceId },
segredo,
),
dispositivo: { id: r.deviceId, nome: aparelho.nome },
});
};
const ingest: Rota = async (corpo, req, responder) => {
const { dbUrl, dbToken, segredo } = config();
if (!dbUrl) return responder(500, { error: "DB_URL não configurada" });
const [servidor, ingestCore, disp] = await Promise.all([
import("./api/_lib/servidor.js"),
import("./api/_lib/ingest-core.js"),
import("./src/repositories/dispositivos.js"),
]);
const dispositivo = servidor.autorizar(req.headers.authorization, segredo, "dispositivo");
if (!dispositivo || typeof dispositivo.d !== "number") {
return responder(401, { error: "401 token de dispositivo inválido" });
}
let lote;
try {
lote = ingestCore.lerLote(corpo);
} catch (e) {
return responder(400, { error: e instanceof Error ? e.message : String(e) });
}
const db = await cliente(dbUrl, dbToken);
if (!(await disp.dispositivoAtivo(db, dispositivo.u, dispositivo.d))) {
return responder(401, { error: "401 dispositivo revogado" });
}
const r = await ingestCore.gravarLote(db, dispositivo.u, lote);
await disp.marcarVisto(db, dispositivo.d);
return responder(200, r);
};
/** Lê `.env.local` sem depender de o processo ter sido iniciado com --env-file. */
function lerEnvLocal(): Record<string, string> {
try {
const texto = readFileSync(".env.local", "utf-8");
const out: Record<string, string> = {};
for (const linha of texto.split("\n")) {
const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/.exec(linha);
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
return out;
} catch {
return {};
}
}