first commit

This commit is contained in:
Tomas Dvorak
2026-04-10 12:04:09 +02:00
commit 3cb40adb23
203 changed files with 40226 additions and 0 deletions
+16
View File
@@ -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"]
+16
View File
@@ -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"
}
}
+311
View File
@@ -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());
}
+152
View File
@@ -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;
}