Compare commits

...

5 Commits

Author SHA1 Message Date
Paperclip a7e98c0582 feat(GRO-608): Add Stripe webhook handler for payment events
- Add POST /api/webhooks/stripe endpoint for Stripe event processing
- Handle payment_intent.succeeded: mark invoice as paid, set paymentMethod=card
- Handle payment_intent.payment_failed: record failure reason on invoice
- Handle charge.refunded: mark invoice as void
- Handle charge.dispute.created: log dispute
- Idempotency: skip if stripePaymentIntentId already recorded
- Add stripe_payment_intent_id, stripe_refund_id, payment_failure_reason columns
- Requires STRIPE_WEBHOOK_SECRET env var for signature verification

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 00:09:46 +00:00
groombook-qa[bot] 4f6a1e8149 fix(GRO-574): switch rate limit to memory storage to unblock UAT
* feat(GRO-566): add SKIP_OOBE env var to bypass setup wizard

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* Add rate_limit table migration for Better Auth (GRO-574)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-574): switch rate limit to memory storage to unblock UAT

Better Auth rate_limit table migration exists on branch but hasn't
been deployed to UAT. Switching to memory storage bypasses the
missing table entirely, restoring auth functionality immediately.

Memory storage is per-instance (not shared) — rate limiting still
functions but won't be distributed across pods. This is acceptable
for UAT while the migration is being promoted through the pipeline.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: groombook-qa[bot] <269744346+groombook-qa[bot]@users.noreply.github.com>
2026-04-12 12:20:00 +00:00
groombook-cto[bot] be3cfa9a54 Merge pull request #268 from groombook/feature/gro-565-better-auth-phase3
feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification
2026-04-12 11:29:06 +00:00
groombook-cto[bot] 06e7ddaa61 Merge branch 'main' into feature/gro-565-better-auth-phase3 2026-04-12 11:25:35 +00:00
Paperclip bc1f11a901 feat(GRO-565): Better Auth Phase 3 - password change, OIDC discovery, session cleanup, email verification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 02:47:17 +00:00
10 changed files with 244 additions and 19 deletions
+1
View File
@@ -22,6 +22,7 @@
"hono": "^4.6.17", "hono": "^4.6.17",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"stripe": "^22.0.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
+4
View File
@@ -28,6 +28,7 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
import { devRouter } from "./routes/dev.js"; import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js"; import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js"; import { startReminderScheduler } from "./services/reminders.js";
import { webhooksRouter } from "./routes/stripe-webhooks.js";
const app = new Hono(); const app = new Hono();
@@ -50,6 +51,9 @@ app.route("/api/book", bookRouter);
// Public portal routes — client-facing, authenticated via impersonation session header // Public portal routes — client-facing, authenticated via impersonation session header
app.route("/api/portal", portalRouter); app.route("/api/portal", portalRouter);
// Public Stripe webhook endpoint — signature-verified, no auth required
app.route("/api/webhooks/stripe", webhooksRouter);
// Dev/demo routes — config is always public, users endpoint is guarded internally // Dev/demo routes — config is always public, users endpoint is guarded internally
app.route("/api/dev", devRouter); app.route("/api/dev", devRouter);
+64 -11
View File
@@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins"; import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db"; import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db"; import { decryptSecret } from "@groombook/db";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET; const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
@@ -94,7 +95,7 @@ export async function initAuth(): Promise<void> {
enabled: true, enabled: true,
max: 10, max: 10,
window: 60, window: 60,
storage: "database", storage: "memory",
}, },
plugins: [ plugins: [
genericOAuth({ genericOAuth({
@@ -176,6 +177,52 @@ export async function initAuth(): Promise<void> {
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
// Fetch OIDC discovery document to derive canonical provider URLs.
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
// while keeping authorizationUrl public for browser redirects.
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr);
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
};
const replaceHost = (url: string, newHost: string) => {
try {
const parsed = new URL(url);
const newParsed = new URL(newHost);
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
};
const authzUrl = discovery.authorization_endpoint;
const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) {
oidcConfig = {
authorizationUrl: authzUrl,
tokenUrl: providerConfig.internalBaseUrl
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
: tokenUrl,
userInfoUrl: providerConfig.internalBaseUrl
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
: userInfoUrl,
};
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
} else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
}
} else {
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
}
} catch (err) {
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
}
// Build Better-Auth instance using resolved config // Build Better-Auth instance using resolved config
authInstance = betterAuth({ authInstance = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -187,11 +234,24 @@ export async function initAuth(): Promise<void> {
enabled: true, enabled: true,
max: 10, max: 10,
window: 60, window: 60,
storage: "database", storage: "memory",
}, },
account: { account: {
storeStateStrategy: "cookie" as const, storeStateStrategy: "cookie" as const,
}, },
emailAndPassword: {
enabled: true,
emailVerification: {
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
await sendEmail({
to: user.email,
subject: "Verify your GroomBook email",
text: `Click the link to verify your email: ${url}`,
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
});
},
},
},
plugins: [ plugins: [
genericOAuth({ genericOAuth({
config: [ config: [
@@ -199,15 +259,8 @@ export async function initAuth(): Promise<void> {
providerId: providerConfig.providerId, providerId: providerConfig.providerId,
clientId: providerConfig.clientId, clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret, clientSecret: providerConfig.clientSecret,
...(providerConfig.internalBaseUrl discoveryUrl: discoveryUrlStr,
? { ...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
authorizationUrl: `${new URL(providerConfig.issuerUrl).origin}/application/o/authorize/`,
tokenUrl: `${providerConfig.internalBaseUrl}/application/o/token/`,
userInfoUrl: `${providerConfig.internalBaseUrl}/application/o/userinfo/`,
}
: {
discoveryUrl: `${providerConfig.issuerUrl}/.well-known/openid-configuration`,
}),
scopes: providerConfig.scopes.split(" ").filter(Boolean), scopes: providerConfig.scopes.split(" ").filter(Boolean),
}, },
], ],
+108
View File
@@ -0,0 +1,108 @@
import { Hono } from "hono";
import Stripe from "stripe";
import { eq, getDb, invoices } from "@groombook/db";
export const webhooksRouter = new Hono();
webhooksRouter.post("/stripe", async (c) => {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
return c.json({ error: "Webhook secret not configured" }, 503);
}
const signature = c.req.header("stripe-signature");
if (!signature) {
return c.json({ error: "Missing signature" }, 401);
}
let rawBody: string;
try {
rawBody = await c.req.text();
} catch {
return c.json({ error: "Could not read body" }, 400);
}
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return c.json({ error: message }, 401);
}
const db = getDb();
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
const [inv] = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.limit(1);
if (!inv) continue;
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
await db
.update(invoices)
.set({
status: "paid",
paymentMethod: "card",
paidAt: new Date(),
stripePaymentIntentId: pi.id,
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
}
}
} else if (event.type === "payment_intent.payment_failed") {
const pi = event.data.object as Stripe.PaymentIntent;
if (pi.metadata?.groombook_invoice_ids) {
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
for (const invoiceId of invoiceIds) {
if (!invoiceId) continue;
await db
.update(invoices)
.set({
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
updatedAt: new Date(),
})
.where(eq(invoices.id, invoiceId));
}
}
} else if (event.type === "charge.refunded") {
const charge = event.data.object as Stripe.Charge;
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
const [inv] = await db
.select({ id: invoices.id })
.from(invoices)
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
.limit(1);
if (inv) {
const refundId =
typeof charge.refunded === "boolean" && charge.refunded
? `ch_${charge.id}_refund`
: null;
await db
.update(invoices)
.set({
status: "void",
stripeRefundId: refundId,
updatedAt: new Date(),
})
.where(eq(invoices.id, inv.id));
}
}
} else if (event.type === "charge.dispute.created") {
const dispute = event.data.object as Stripe.Dispute;
console.error(
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
);
}
return c.json({ received: true });
});
+14
View File
@@ -12,6 +12,7 @@ import {
services, services,
staff, staff,
reminderLogs, reminderLogs,
session,
} from "@groombook/db"; } from "@groombook/db";
import { import {
buildReminderEmail, buildReminderEmail,
@@ -155,6 +156,19 @@ export function startReminderScheduler(): void {
runReminderCheck().catch((err) => { runReminderCheck().catch((err) => {
console.error("[reminders] Error during reminder check:", err); console.error("[reminders] Error during reminder check:", err);
}); });
runSessionCleanup().catch((err) => {
console.error("[reminders] Error during session cleanup:", err);
});
}); });
console.log("[reminders] Reminder scheduler started"); console.log("[reminders] Reminder scheduler started");
} }
// Deletes expired sessions from the database.
// Runs every minute alongside reminder checks.
export async function runSessionCleanup(): Promise<void> {
const db = getDb();
const now = new Date();
await db
.delete(session)
.where(lt(session.expiresAt, now));
}
+1 -1
View File
@@ -4,4 +4,4 @@ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "", baseURL: import.meta.env.VITE_API_URL ?? "",
}); });
export const { signIn, signOut, useSession } = authClient; export const { signIn, signOut, useSession, changePassword } = authClient;
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react"; import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { PetForm } from "./PetForm.js"; import { PetForm } from "./PetForm.js";
import { authClient } from "../../lib/auth-client.js";
interface Props { interface Props {
sessionId: string | null; sessionId: string | null;
@@ -148,9 +149,11 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const passwordsMatch = newPassword === confirmPassword; const passwordsMatch = newPassword === confirmPassword;
const canSubmit = currentPassword.length > 0 && newPassword.length > 0 && passwordsMatch; const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
if (readOnly) { if (readOnly) {
return ( return (
@@ -160,17 +163,34 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
); );
} }
function handleSubmit() { async function handleSubmit() {
if (!canSubmit) return; if (!canSubmit) return;
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError("Passwords do not match."); setError("Passwords do not match.");
return; return;
} }
// TODO: Wire up to actual password-change API endpoint once backend support exists
setError(null); setError(null);
setCurrentPassword(""); setLoading(true);
setNewPassword(""); try {
setConfirmPassword(""); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (authClient as any).changePassword({
currentPassword,
newPassword,
});
if (result.error) {
setError(result.error.message ?? "Failed to change password.");
} else {
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setTimeout(() => setSuccess(false), 4000);
}
} catch {
setError("An unexpected error occurred.");
} finally {
setLoading(false);
}
} }
return ( return (
@@ -205,12 +225,13 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
> >
Update Password {loading ? "Updating..." : "Update Password"}
</button> </button>
</div> </div>
</div> </div>
@@ -0,0 +1,4 @@
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
+4
View File
@@ -251,6 +251,9 @@ export const invoices = pgTable(
status: invoiceStatusEnum("status").notNull().default("draft"), status: invoiceStatusEnum("status").notNull().default("draft"),
paymentMethod: paymentMethodEnum("payment_method"), paymentMethod: paymentMethodEnum("payment_method"),
paidAt: timestamp("paid_at"), paidAt: timestamp("paid_at"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
stripeRefundId: text("stripe_refund_id"),
paymentFailureReason: text("payment_failure_reason"),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
@@ -259,6 +262,7 @@ export const invoices = pgTable(
index("idx_invoices_client_id").on(t.clientId), index("idx_invoices_client_id").on(t.clientId),
index("idx_invoices_status").on(t.status), index("idx_invoices_status").on(t.status),
index("idx_invoices_created_at").on(t.createdAt), index("idx_invoices_created_at").on(t.createdAt),
unique("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
] ]
); );
+16
View File
@@ -40,6 +40,9 @@ importers:
nodemailer: nodemailer:
specifier: ^6.9.16 specifier: ^6.9.16
version: 6.10.1 version: 6.10.1
stripe:
specifier: ^22.0.0
version: 22.0.1(@types/node@22.19.15)
zod: zod:
specifier: ^4.3.6 specifier: ^4.3.6
version: 4.3.6 version: 4.3.6
@@ -4124,6 +4127,15 @@ packages:
strip-literal@3.1.0: strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stripe@22.0.1:
resolution: {integrity: sha512-Yw764pZ6s8Xu4CtUZdD5uWOkw6gc9xzO9OKylCuj1gMhMDLbyGbDtaPNNSFE4mB6njYSHESYIVbF1iIzUfAl2g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
strnum@2.2.1: strnum@2.2.1:
resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==} resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==}
@@ -8774,6 +8786,10 @@ snapshots:
dependencies: dependencies:
js-tokens: 9.0.1 js-tokens: 9.0.1
stripe@22.0.1(@types/node@22.19.15):
optionalDependencies:
'@types/node': 22.19.15
strnum@2.2.1: {} strnum@2.2.1: {}
supports-color@7.2.0: supports-color@7.2.0: