ui fixes, input fixes, better dev workflow
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"rollover": "tsx src/scripts/run-rollover.ts",
|
||||
"plan:manage": "tsx src/scripts/manage-plan.ts"
|
||||
"plan:manage": "tsx src/scripts/manage-plan.ts",
|
||||
"verify:break-glass": "tsx src/scripts/verify-break-glass.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts"
|
||||
|
||||
@@ -49,6 +49,9 @@ const Env = z.object({
|
||||
JWT_AUDIENCE: z.string().min(1).default("skymoney-web"),
|
||||
COOKIE_SECRET: z.string().min(32),
|
||||
COOKIE_DOMAIN: z.string().optional(),
|
||||
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
|
||||
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
|
||||
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
|
||||
AUTH_DISABLED: BoolFromEnv.optional().default(false),
|
||||
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
|
||||
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
||||
@@ -90,6 +93,9 @@ const rawEnv = {
|
||||
JWT_AUDIENCE: process.env.JWT_AUDIENCE,
|
||||
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
|
||||
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
|
||||
EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE,
|
||||
BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED,
|
||||
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
|
||||
AUTH_DISABLED: process.env.AUTH_DISABLED,
|
||||
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
|
||||
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
|
||||
@@ -157,4 +163,10 @@ if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.BREAK_GLASS_VERIFY_ENABLED && !parsed.BREAK_GLASS_VERIFY_CODE) {
|
||||
throw new Error(
|
||||
"BREAK_GLASS_VERIFY_ENABLED=true requires BREAK_GLASS_VERIFY_CODE (32+ chars)."
|
||||
);
|
||||
}
|
||||
|
||||
export const env = parsed;
|
||||
|
||||
94
api/src/scripts/verify-break-glass.ts
Normal file
94
api/src/scripts/verify-break-glass.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const parsed: Record<string, string> = {};
|
||||
for (const arg of args) {
|
||||
if (!arg.startsWith("--")) continue;
|
||||
const [key, ...rest] = arg.slice(2).split("=");
|
||||
parsed[key] = rest.join("=");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBool(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a, "utf8");
|
||||
const right = Buffer.from(b, "utf8");
|
||||
if (left.length !== right.length) return false;
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const email = (args.email || "").trim().toLowerCase();
|
||||
const providedCode = args.code || process.env.BREAK_GLASS_VERIFY_CODE_INPUT || "";
|
||||
const expectedCode = process.env.BREAK_GLASS_VERIFY_CODE || "";
|
||||
const enabled = parseBool(process.env.BREAK_GLASS_VERIFY_ENABLED);
|
||||
|
||||
if (!enabled) {
|
||||
throw new Error("BREAK_GLASS_VERIFY_ENABLED must be true to use this command.");
|
||||
}
|
||||
if (expectedCode.length < 32) {
|
||||
throw new Error("BREAK_GLASS_VERIFY_CODE must be set and at least 32 characters.");
|
||||
}
|
||||
if (!email || !email.includes("@")) {
|
||||
throw new Error("Usage: npm run verify:break-glass -- --email=user@example.com --code=<long-secret>");
|
||||
}
|
||||
if (!providedCode) {
|
||||
throw new Error("Missing --code (or BREAK_GLASS_VERIFY_CODE_INPUT).");
|
||||
}
|
||||
if (!safeEqual(providedCode, expectedCode)) {
|
||||
throw new Error("Invalid break-glass code.");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, email: true, emailVerified: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error(`No user found for email: ${email}`);
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { emailVerified: true },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.emailToken.deleteMany({
|
||||
where: { userId: user.id, type: "signup" },
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
email: user.email,
|
||||
wasAlreadyVerified: user.emailVerified,
|
||||
action: "email_marked_verified_break_glass",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
@@ -128,6 +128,7 @@ declare module "fastify" {
|
||||
export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<FastifyInstance> {
|
||||
const config = { ...env, ...overrides } as AppConfig;
|
||||
const isProd = config.NODE_ENV === "production";
|
||||
const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE;
|
||||
const cookieDomain = config.COOKIE_DOMAIN || undefined;
|
||||
|
||||
const app = Fastify({
|
||||
@@ -933,7 +934,11 @@ app.post(
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true, needsVerification: true };
|
||||
return {
|
||||
ok: true,
|
||||
needsVerification: true,
|
||||
...(exposeDevVerificationCode ? { verificationCode: code } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
app.post(
|
||||
@@ -1155,7 +1160,7 @@ app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true };
|
||||
return { ok: true, ...(exposeDevVerificationCode ? { verificationCode: code } : {}) };
|
||||
});
|
||||
|
||||
app.post(
|
||||
|
||||
@@ -16,20 +16,24 @@ function readEnvValue(filePath: string, key: string): string | undefined {
|
||||
}
|
||||
|
||||
function resolveDatabaseUrl(): string {
|
||||
if (process.env.TEST_DATABASE_URL?.trim()) return process.env.TEST_DATABASE_URL.trim();
|
||||
if (process.env.BACKUP_DATABASE_URL?.trim()) return process.env.BACKUP_DATABASE_URL.trim();
|
||||
if (process.env.DATABASE_URL?.trim()) return process.env.DATABASE_URL.trim();
|
||||
const normalizeHost = (url: string) => url.replace("@postgres:", "@127.0.0.1:");
|
||||
|
||||
if (process.env.TEST_DATABASE_URL?.trim()) return normalizeHost(process.env.TEST_DATABASE_URL.trim());
|
||||
if (process.env.BACKUP_DATABASE_URL?.trim())
|
||||
return normalizeHost(process.env.BACKUP_DATABASE_URL.trim());
|
||||
|
||||
const envPaths = [resolve(process.cwd(), ".env"), resolve(process.cwd(), "..", ".env")];
|
||||
for (const envPath of envPaths) {
|
||||
const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL");
|
||||
if (testUrl) return testUrl;
|
||||
if (testUrl) return normalizeHost(testUrl);
|
||||
const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL");
|
||||
if (backupUrl) return backupUrl;
|
||||
if (backupUrl) return normalizeHost(backupUrl);
|
||||
const dbUrl = readEnvValue(envPath, "DATABASE_URL");
|
||||
if (dbUrl) return dbUrl.replace("@postgres:", "@127.0.0.1:");
|
||||
if (dbUrl) return normalizeHost(dbUrl);
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL?.trim()) return normalizeHost(process.env.DATABASE_URL.trim());
|
||||
|
||||
return "postgres://app:app@127.0.0.1:5432/skymoney_test";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user