mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-04 12:33:01 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-fund --no-audit
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV AUTH_PORT=3001
|
||||
|
||||
EXPOSE 3001
|
||||
USER node
|
||||
CMD ["node", "src/server.mjs"]
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@productier/auth-service",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.mjs",
|
||||
"start": "node src/server.mjs",
|
||||
"check": "node --check src/server.mjs && node --check src/auth.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-auth": "^1.5.6",
|
||||
"kysely": "^0.28.14",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { magicLink } from "better-auth/plugins";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import nodemailer from "nodemailer";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { recordDevMail } from "./dev-mailbox.mjs";
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const authUrl = process.env.AUTH_URL || "http://localhost:3001";
|
||||
const databaseUrl = process.env.DATABASE_URL || "postgres://productier:productier@localhost:5432/productier?sslmode=disable";
|
||||
const authSecret = process.env.BETTER_AUTH_SECRET || "replace-me-with-a-long-random-secret";
|
||||
const appMode = (process.env.APP_ENV || process.env.NODE_ENV || "development").trim().toLowerCase();
|
||||
const productionLikeMode = appMode === "staging" || appMode === "production";
|
||||
const magicLinkProvider = resolveMagicLinkProvider(process.env.AUTH_MAGIC_LINK_PROVIDER, productionLikeMode);
|
||||
export const devMailboxEnabled = resolveDevMailboxEnabled(process.env.AUTH_DEV_MAILBOX_ENABLED, appMode);
|
||||
const smtpConfig = magicLinkProvider === "smtp" ? loadSMTPConfig() : null;
|
||||
|
||||
validateRuntimeConfig();
|
||||
const magicLinkTransport = createMagicLinkTransport();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl
|
||||
});
|
||||
|
||||
const db = new Kysely({
|
||||
dialect: new PostgresDialect({
|
||||
pool
|
||||
})
|
||||
});
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Productier",
|
||||
baseURL: authUrl,
|
||||
secret: authSecret,
|
||||
trustedOrigins: [frontendUrl, authUrl],
|
||||
database: {
|
||||
db,
|
||||
type: "postgres"
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: true
|
||||
},
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await magicLinkTransport.send({
|
||||
email,
|
||||
url
|
||||
});
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
let authReadyPromise = null;
|
||||
|
||||
export async function ensureAuthReady() {
|
||||
if (!authReadyPromise) {
|
||||
authReadyPromise = auth.$context
|
||||
.then(async context => {
|
||||
await context.runMigrations();
|
||||
await magicLinkTransport.verify();
|
||||
})
|
||||
.catch(error => {
|
||||
authReadyPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return authReadyPromise;
|
||||
}
|
||||
|
||||
export async function closeAuth() {
|
||||
await magicLinkTransport.close();
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
function validateRuntimeConfig() {
|
||||
assertHTTPURL(frontendUrl, "FRONTEND_URL");
|
||||
assertHTTPURL(authUrl, "AUTH_URL");
|
||||
assertAbsoluteURL(databaseUrl, "DATABASE_URL");
|
||||
|
||||
if (!productionLikeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be explicitly set in staging/production");
|
||||
}
|
||||
if (isWeakSecret(authSecret)) {
|
||||
throw new Error("BETTER_AUTH_SECRET must be set to a strong non-placeholder value in staging/production");
|
||||
}
|
||||
assertSecurePublicURL(frontendUrl, "FRONTEND_URL");
|
||||
assertSecurePublicURL(authUrl, "AUTH_URL");
|
||||
if (magicLinkProvider !== "smtp") {
|
||||
throw new Error("AUTH_MAGIC_LINK_PROVIDER must be set to smtp in staging/production");
|
||||
}
|
||||
if (devMailboxEnabled) {
|
||||
throw new Error("AUTH_DEV_MAILBOX_ENABLED must be false in staging/production");
|
||||
}
|
||||
}
|
||||
|
||||
function assertHTTPURL(value, envName) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch (error) {
|
||||
throw new Error(`${envName} must be a valid absolute URL`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`${envName} must use http or https`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAbsoluteURL(value, envName) {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(value);
|
||||
} catch (error) {
|
||||
throw new Error(`${envName} must be a valid absolute URL`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSecurePublicURL(value, envName) {
|
||||
const parsed = new URL(value);
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
|
||||
return;
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error(`${envName} must use https in staging/production`);
|
||||
}
|
||||
}
|
||||
|
||||
function isWeakSecret(secret) {
|
||||
const normalized = secret.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "" ||
|
||||
normalized === "replace-me-with-a-long-random-secret" ||
|
||||
normalized === "changeme" ||
|
||||
normalized === "change-me" ||
|
||||
normalized === "replace-me"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return secret.trim().length < 16;
|
||||
}
|
||||
|
||||
function resolveMagicLinkProvider(rawValue, isProductionLike) {
|
||||
const normalized = String(rawValue || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized) {
|
||||
return isProductionLike ? "smtp" : "dev-mailbox";
|
||||
}
|
||||
if (normalized !== "dev-mailbox" && normalized !== "smtp") {
|
||||
throw new Error("AUTH_MAGIC_LINK_PROVIDER must be one of: dev-mailbox, smtp");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveDevMailboxEnabled(rawValue, mode) {
|
||||
const defaultEnabled = mode === "development" || mode === "test";
|
||||
if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
|
||||
return defaultEnabled;
|
||||
}
|
||||
return parseBoolean(rawValue, "AUTH_DEV_MAILBOX_ENABLED");
|
||||
}
|
||||
|
||||
function parseBoolean(rawValue, envName) {
|
||||
const normalized = String(rawValue).trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "1":
|
||||
case "true":
|
||||
case "yes":
|
||||
case "on":
|
||||
return true;
|
||||
case "0":
|
||||
case "false":
|
||||
case "no":
|
||||
case "off":
|
||||
return false;
|
||||
default:
|
||||
throw new Error(`${envName} must be a boolean value`);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSMTPConfig() {
|
||||
const host = String(process.env.AUTH_SMTP_HOST || "").trim();
|
||||
const username = String(process.env.AUTH_SMTP_USER || "").trim();
|
||||
const password = String(process.env.AUTH_SMTP_PASSWORD || "");
|
||||
const from = String(process.env.AUTH_MAIL_FROM || "").trim();
|
||||
|
||||
if (!host) {
|
||||
throw new Error("AUTH_SMTP_HOST is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
|
||||
}
|
||||
if (!username) {
|
||||
throw new Error("AUTH_SMTP_USER is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error("AUTH_SMTP_PASSWORD is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
|
||||
}
|
||||
if (!from) {
|
||||
throw new Error("AUTH_MAIL_FROM is required when AUTH_MAGIC_LINK_PROVIDER=smtp");
|
||||
}
|
||||
|
||||
const portRaw = String(process.env.AUTH_SMTP_PORT || "587").trim();
|
||||
const port = Number.parseInt(portRaw, 10);
|
||||
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||
throw new Error("AUTH_SMTP_PORT must be a valid TCP port");
|
||||
}
|
||||
|
||||
const secure = process.env.AUTH_SMTP_SECURE
|
||||
? parseBoolean(process.env.AUTH_SMTP_SECURE, "AUTH_SMTP_SECURE")
|
||||
: port === 465;
|
||||
const skipVerify = process.env.AUTH_SMTP_SKIP_VERIFY
|
||||
? parseBoolean(process.env.AUTH_SMTP_SKIP_VERIFY, "AUTH_SMTP_SKIP_VERIFY")
|
||||
: false;
|
||||
const tlsRejectUnauthorized = process.env.AUTH_SMTP_TLS_REJECT_UNAUTHORIZED
|
||||
? parseBoolean(process.env.AUTH_SMTP_TLS_REJECT_UNAUTHORIZED, "AUTH_SMTP_TLS_REJECT_UNAUTHORIZED")
|
||||
: true;
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
username,
|
||||
password,
|
||||
from,
|
||||
skipVerify,
|
||||
tlsRejectUnauthorized
|
||||
};
|
||||
}
|
||||
|
||||
function createMagicLinkTransport() {
|
||||
if (magicLinkProvider === "dev-mailbox") {
|
||||
return {
|
||||
async send({ email, url }) {
|
||||
await recordDevMail({
|
||||
kind: "magic-link",
|
||||
email,
|
||||
subject: "Your Productier magic link",
|
||||
url
|
||||
});
|
||||
console.log(`[Productier auth] magic link for ${email}: ${url}`);
|
||||
},
|
||||
async verify() {
|
||||
return;
|
||||
},
|
||||
async close() {
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpConfig.host,
|
||||
port: smtpConfig.port,
|
||||
secure: smtpConfig.secure,
|
||||
auth: {
|
||||
user: smtpConfig.username,
|
||||
pass: smtpConfig.password
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: smtpConfig.tlsRejectUnauthorized
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
async send({ email, url }) {
|
||||
const subject = "Your Productier magic link";
|
||||
const text = [
|
||||
"Sign in to Productier using this secure magic link:",
|
||||
url,
|
||||
"",
|
||||
"If you did not request this link, you can ignore this email."
|
||||
].join("\n");
|
||||
const html = [
|
||||
"<p>Sign in to Productier using this secure magic link:</p>",
|
||||
`<p><a href="${url}">${url}</a></p>`,
|
||||
"<p>If you did not request this link, you can ignore this email.</p>"
|
||||
].join("");
|
||||
|
||||
await transporter.sendMail({
|
||||
from: smtpConfig.from,
|
||||
to: email,
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
});
|
||||
},
|
||||
async verify() {
|
||||
if (smtpConfig.skipVerify) {
|
||||
return;
|
||||
}
|
||||
await transporter.verify();
|
||||
},
|
||||
async close() {
|
||||
if (typeof transporter.close === "function") {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const MAILBOX_PATH = path.join(process.cwd(), ".productier-dev-mailbox.json");
|
||||
|
||||
async function readMailbox() {
|
||||
try {
|
||||
const raw = await fs.readFile(MAILBOX_PATH, "utf8");
|
||||
const entries = JSON.parse(raw);
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordDevMail(entry) {
|
||||
const mailbox = await readMailbox();
|
||||
mailbox.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
...entry
|
||||
});
|
||||
await fs.writeFile(MAILBOX_PATH, JSON.stringify(mailbox.slice(0, 25), null, 2));
|
||||
}
|
||||
|
||||
export async function listDevMail(email) {
|
||||
const mailbox = await readMailbox();
|
||||
if (!email) {
|
||||
return mailbox;
|
||||
}
|
||||
return mailbox.filter(entry => entry.email.toLowerCase() === email.toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import http from "node:http";
|
||||
import { URL } from "node:url";
|
||||
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
|
||||
import { auth, closeAuth, devMailboxEnabled, ensureAuthReady } from "./auth.mjs";
|
||||
import { listDevMail } from "./dev-mailbox.mjs";
|
||||
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const authPort = Number(process.env.AUTH_PORT || "3001");
|
||||
const appMode = (process.env.APP_ENV || process.env.NODE_ENV || "development").trim().toLowerCase();
|
||||
const enforceExplicitCORS = appMode === "staging" || appMode === "production";
|
||||
const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOW_ORIGINS, frontendUrl, enforceExplicitCORS);
|
||||
const authHandler = toNodeHandler(auth);
|
||||
let isShuttingDown = false;
|
||||
let shutdownTimer = null;
|
||||
|
||||
function setCorsHeaders(request, response) {
|
||||
const requestOrigin = request.headers.origin;
|
||||
if (requestOrigin && allowedOrigins.has(requestOrigin)) {
|
||||
response.setHeader("Access-Control-Allow-Origin", requestOrigin);
|
||||
} else if (!requestOrigin) {
|
||||
response.setHeader("Access-Control-Allow-Origin", frontendUrl);
|
||||
}
|
||||
response.setHeader("Vary", "Origin");
|
||||
response.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie");
|
||||
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
|
||||
}
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
setCorsHeaders(request, response);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url || "/", `http://${request.headers.host || `localhost:${authPort}`}`);
|
||||
|
||||
try {
|
||||
if (url.pathname === "/api/dev-mailbox" && request.method === "GET" && devMailboxEnabled) {
|
||||
const email = url.searchParams.get("email") || undefined;
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ data: await listDevMail(email) }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/auth/")) {
|
||||
authHandler(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/health" && request.method === "GET") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true, service: "auth" }));
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Not found" }));
|
||||
} catch (error) {
|
||||
console.error("[Productier auth] request failed", error);
|
||||
response.writeHead(500, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Internal server error" }));
|
||||
}
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
await ensureAuthReady();
|
||||
} catch (error) {
|
||||
console.error("[Productier auth] startup failed during auth initialization", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
server.listen(authPort, () => {
|
||||
console.log(`[Productier auth] listening on http://localhost:${authPort}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function shutdown(signal) {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
isShuttingDown = true;
|
||||
console.log(`[Productier auth] received ${signal}, shutting down`);
|
||||
|
||||
shutdownTimer = setTimeout(() => {
|
||||
console.error("[Productier auth] forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
shutdownTimer.unref();
|
||||
|
||||
server.close(async closeError => {
|
||||
if (closeError) {
|
||||
console.error("[Productier auth] server close failed", closeError);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
try {
|
||||
await closeAuth();
|
||||
} catch (error) {
|
||||
console.error("[Productier auth] auth shutdown failed", error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (shutdownTimer) {
|
||||
clearTimeout(shutdownTimer);
|
||||
}
|
||||
process.exit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
|
||||
void bootstrap();
|
||||
|
||||
function parseAllowedOrigins(raw, fallbackOrigin, strictMode) {
|
||||
const origins = new Set();
|
||||
if (strictMode && (!raw || raw.trim().length === 0)) {
|
||||
throw new Error("CORS_ALLOW_ORIGINS must be set in staging/production");
|
||||
}
|
||||
const source = raw && raw.trim().length > 0 ? raw : fallbackOrigin;
|
||||
source
|
||||
.split(",")
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
.forEach(origin => {
|
||||
try {
|
||||
origins.add(new URL(origin).origin);
|
||||
} catch (error) {
|
||||
if (strictMode) {
|
||||
throw new Error(`invalid origin in CORS_ALLOW_ORIGINS: ${origin}`);
|
||||
}
|
||||
console.warn(`[Productier auth] ignoring invalid CORS origin: ${origin}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (origins.size === 0) {
|
||||
if (strictMode) {
|
||||
throw new Error("CORS_ALLOW_ORIGINS must include at least one valid origin");
|
||||
}
|
||||
origins.add(fallbackOrigin);
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
Reference in New Issue
Block a user