Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Farhood 821ea0046d Auto-create staff records for OAuth users with no existing staff record
Fixes GRO-1118 - uat-tester receives HTTP 403 post-login

When a user authenticates via OAuth but has no corresponding staff record,
the RBAC middleware now auto-creates a staff record with a default
"receptionist" role instead of returning 403. This allows new OAuth
users to access the app immediately.

The middleware now checks for staff records in this order:
1. By userId (Better-Auth user ID)
2. By oidcSub (legacy OIDC subject)
3. By email (auto-link existing staff)
4. Create new staff record if authenticated user has email and name

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-12 19:19:02 +00:00
42 changed files with 108 additions and 87 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ let dbSelectResult: unknown[] = [];
const mockEq = vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val }));
const mockDecryptSecret = vi.fn((s: string) => `decrypted:${s}`);
vi.mock("../db", () => {
vi.mock("./db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
+1 -1
View File
@@ -38,7 +38,7 @@ const mockGroomer: MockStaff = { id: "staff-3", role: "groomer", isSuperUser: fa
// ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("../db", () => {
vi.mock("./db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
+1 -1
View File
@@ -40,7 +40,7 @@ function resetMock() {
deletedId = null;
}
vi.mock("../db", () => {
vi.mock("./db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
+1 -1
View File
@@ -39,7 +39,7 @@ function resetMock() {
lastUpdate = {};
}
vi.mock("../db", () => {
vi.mock("./db", () => {
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
+1 -1
View File
@@ -76,7 +76,7 @@ function makeChainableResult(data: unknown[]): unknown {
});
}
vi.mock("../db", () => {
vi.mock("./db", () => {
function makeTable(name: string) {
return new Proxy(
{ _name: name },
+1 -1
View File
@@ -40,7 +40,7 @@ function resetDb() {
// ─── Module mocks ─────────────────────────────────────────────────────────────
vi.mock("../db", () => {
vi.mock("./db", () => {
const pets = new Proxy(
{ _name: "pets" },
{ get(t, p) { return p === "_name" ? "pets" : {}; } }
+1 -1
View File
@@ -47,7 +47,7 @@ function resetMock() {
updatedValues = [];
}
vi.mock("../db", () => {
vi.mock("./db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
+1 -1
View File
@@ -46,7 +46,7 @@ const GROOMER: StaffRow = {
let staffLookupResult: StaffRow | null = null;
let managerFallbackResult: StaffRow | null = MANAGER;
vi.mock("../db", () => {
vi.mock("./db", () => {
const staff = new Proxy(
{ _name: "staff" },
{
+1 -1
View File
@@ -23,7 +23,7 @@ const PET_ROW = {
let clientResults: typeof ACTIVE_CLIENT[] = [];
let petResults: typeof PET_ROW[] = [];
vi.mock("../db", () => {
vi.mock("./db", () => {
// Proxy objects for table/column references — values don't matter for tests
const tableProxy = (name: string) =>
new Proxy(
+1 -1
View File
@@ -39,7 +39,7 @@ function clearAuthEnv() {
// ─── Mock db module ───────────────────────────────────────────────────────────
vi.mock("../db", () => {
vi.mock("./db", () => {
const authProviderConfig = new Proxy(
{ _name: "auth_provider_config" },
{
+1 -1
View File
@@ -49,7 +49,7 @@ function resetMock() {
updatedValues = [];
}
vi.mock("../db", () => {
vi.mock("./db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
+6 -27
View File
@@ -94,6 +94,11 @@ function pick<T>(arr: T[]): T {
return arr[Math.floor(rand() * arr.length)]!;
}
/** Return n distinct random elements from an array. */
function pickN<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => rand() - 0.5);
return shuffled.slice(0, n);
}
function randInt(min: number, max: number): number {
return Math.floor(rand() * (max - min + 1)) + min;
@@ -454,32 +459,6 @@ async function seedKnownUsers() {
}
}
// ── Staff: UAT Tester (oidcSub from SEED_UAT_TESTER_OIDC_SUB env var) ──
const uatTesterOidcSub = process.env.SEED_UAT_TESTER_OIDC_SUB;
if (uatTesterOidcSub) {
const UAT_TESTER_STAFF_ID = "00000000-0000-0000-0000-000000000007";
const [existingUatTester] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "uat-tester@groombook.dev"))
.limit(1);
if (existingUatTester) {
console.log(`✓ Staff 'UAT Tester' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: UAT_TESTER_STAFF_ID,
name: "UAT Tester",
email: "uat-tester@groombook.dev",
oidcSub: uatTesterOidcSub,
role: "groomer",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff 'UAT Tester' (oidcSub: ${uatTesterOidcSub})`);
}
}
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
@@ -1100,7 +1079,7 @@ async function seed() {
const groomer = pick(groomers);
const bather = bathers.length > 0 && rand() < 0.6 ? pick(bathers) : null;
const startTime = randDate(appointmentsBackDate, now);
let startTime = randDate(appointmentsBackDate, now);
startTime.setHours(randInt(8, 16), pick([0, 15, 30, 45]), 0, 0);
const endTime = new Date(startTime.getTime() + svc.dur * 60 * 1000);
const effectivePrice = svc.price;
+1 -1
View File
@@ -22,7 +22,7 @@ import { searchRouter } from "./routes/search.js";
import { getObject } from "./lib/s3.js";
import { calendarRouter } from "./routes/calendar.js";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "./db/index.js";
import { getDb, businessSettings, eq, staff } from "./db";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js";
+2 -2
View File
@@ -1,8 +1,8 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "../db/index.js";
import { decryptSecret } from "../db/index.js";
import { getDb, authProviderConfig, eq } from "./db";
import { decryptSecret } from "./db";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { getDb, impersonationAuditLogs } from "../db/index.js";
import { getDb, impersonationAuditLogs } from "../db";
import type { PortalEnv } from "./portalSession.js";
/**
+1 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, impersonationSessions } from "../db/index.js";
import { and, eq, getDb, impersonationSessions } from "../db";
export interface PortalEnv {
Variables: {
+23 -2
View File
@@ -1,7 +1,7 @@
import type { MiddlewareHandler } from "hono";
import { and, eq, getDb, sql, staff } from "../db/index.js";
import { and, eq, getDb, sql, staff, staffRoleEnum } from "../db";
export type StaffRole = "groomer" | "receptionist" | "manager";
type StaffRole = typeof staffRoleEnum.enumValues[number];
export type StaffRow = typeof staff.$inferSelect;
export interface AppEnv {
@@ -110,6 +110,27 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
return;
}
}
// Auto-create staff record for authenticated OAuth users with no existing staff record
// This allows new OAuth users to access the app (defaults to receptionist role)
if (jwt.email && jwt.name) {
const [newStaff] = await db
.insert(staff)
.values({
email: jwt.email,
name: jwt.name,
userId: jwt.sub,
role: "receptionist",
active: true,
})
.returning();
if (newStaff) {
c.set("staff", newStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import { Hono } from "hono";
import { eq, getDb, staff, clients, pets, services } from "../../db/index.js";
import { eq, getDb, staff, clients, pets, services } from "./db";
export const adminSeedRouter = new Hono();
+1 -1
View File
@@ -15,7 +15,7 @@ import {
pets,
services,
staff,
} from "../db/index.js";
} from "../db";
import type { AppEnv } from "../middleware/rbac.js";
export const appointmentGroupsRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -18,7 +18,7 @@ import {
reminderLogs,
services,
staff,
} from "../db/index.js";
} from "../db";
import { buildConfirmationEmail, sendEmail } from "../services/email.js";
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
import type { AppEnv } from "../middleware/rbac.js";
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, authProviderConfig, encryptSecret } from "../db/index.js";
import { eq, getDb, authProviderConfig, encryptSecret } from "../db";
import { requireSuperUser } from "../middleware/rbac.js";
import { reinitAuth } from "../lib/auth.js";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
appointments,
clients,
pets,
} from "../db/index.js";
} from "../db";
import {
generateAvailableSlots,
BUSINESS_START_HOUR,
+1 -1
View File
@@ -10,7 +10,7 @@ import {
pets,
services,
staff,
} from "../db/index.js";
} from "../db";
export const calendarRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, clients, appointments } from "../db/index.js";
import { and, eq, exists, getDb, or, clients, appointments } from "../db";
import type { AppEnv } from "../middleware/rbac.js";
export const clientsRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { getDb, staff, clients, eq, sql } from "../db/index.js";
import { getDb, staff, clients, eq, sql } from "../db";
const devRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "../db/index.js";
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "../db";
import type { AppEnv } from "../middleware/rbac.js";
export const groomingLogsRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -9,7 +9,7 @@ import {
impersonationAuditLogs,
clients,
desc,
} from "../db/index.js";
} from "../db";
import type { AppEnv } from "../middleware/rbac.js";
export const impersonationRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -13,7 +13,7 @@ import {
services,
clients,
sql,
} from "../db/index.js";
} from "../db";
import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, exists, getDb, or, pets, appointments } from "../db/index.js";
import { and, eq, exists, getDb, or, pets, appointments } from "../db";
import type { AppEnv } from "../middleware/rbac.js";
import {
getPresignedUploadUrl,
+2 -2
View File
@@ -1,8 +1,8 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, inArray } from "../db/index.js";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db/index.js";
import { eq, inArray } from "../db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "../db";
import { validatePortalSession } from "../middleware/portalSession.js";
import { portalAudit } from "../middleware/portalAudit.js";
import type { PortalEnv } from "../middleware/portalSession.js";
+1 -1
View File
@@ -12,7 +12,7 @@ import {
invoiceTipSplits,
services,
staff,
} from "../db/index.js";
} from "../db";
export const reportsRouter = new Hono();
+1 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { and, eq, getDb, clients, ilike, or, pets } from "../db/index.js";
import { and, eq, getDb, clients, ilike, or, pets } from "../db";
export const searchRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, services } from "../db/index.js";
import { eq, getDb, services } from "../db";
export const servicesRouter = new Hono();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, businessSettings } from "../db/index.js";
import { eq, getDb, businessSettings } from "../db";
import { getPresignedUploadUrl, deleteObject, putObject, getObject } from "../lib/s3.js";
import { requireSuperUser } from "../middleware/rbac.js";
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "../db/index.js";
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "../db";
import type { AppEnv } from "../middleware/rbac.js";
const RATE_LIMIT_WINDOW_MS = 60_000;
+1 -1
View File
@@ -2,7 +2,7 @@ import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { randomBytes } from "node:crypto";
import { and, eq, getDb, ne, staff, appointments } from "../db/index.js";
import { and, eq, getDb, ne, staff, appointments } from "../db";
import type { AppEnv } from "../middleware/rbac.js";
export const staffRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import Stripe from "stripe";
import { z } from "zod/v3";
import { eq, getDb, invoices } from "../db/index.js";
import { eq, getDb, invoices } from "../db";
import { getStripeClient } from "../services/payment.js";
export const webhooksRouter = new Hono();
+1 -1
View File
@@ -8,7 +8,7 @@ import {
clients,
pets,
services,
} from "../db/index.js";
} from "../db";
import type { AppEnv } from "../middleware/rbac.js";
export const waitlistRouter = new Hono<AppEnv>();
+1 -1
View File
@@ -1,5 +1,5 @@
import Stripe from "stripe";
import { getDb, clients, eq, inArray, invoices } from "../db/index.js";
import { getDb, clients, eq, inArray, invoices } from "../db";
let _stripe: Stripe | null | undefined;
+1 -1
View File
@@ -14,7 +14,7 @@ import {
staff,
reminderLogs,
session,
} from "../db/index.js";
} from "../db";
import {
buildReminderEmail,
sendEmail,
+1 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "../db/index.js";
import { and, eq, getDb, waitlistEntries, clients, pets, services } from "../db";
import { buildWaitlistNotificationEmail, sendEmail } from "./email.js";
export async function notifyWaitlistForAppointment(
+38 -17
View File
@@ -16,6 +16,12 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.800.0
version: 3.1041.0
'@groombook/db':
specifier: workspace:*
version: link:../../packages/db
'@groombook/types':
specifier: workspace:*
version: link:../../packages/types
'@hono/node-server':
specifier: ^1.13.7
version: 1.19.14(hono@4.12.16)
@@ -24,10 +30,7 @@ importers:
version: 0.7.6(hono@4.12.16)(zod@4.4.2)
better-auth:
specifier: ^1.5.6
version: 1.6.9(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0))
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
version: 1.6.9(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0))
hono:
specifier: ^4.6.17
version: 4.12.16
@@ -37,9 +40,6 @@ importers:
nodemailer:
specifier: ^6.9.16
version: 6.10.1
postgres:
specifier: ^3.4.5
version: 3.4.9
stripe:
specifier: ^22.0.0
version: 22.1.0(@types/node@22.19.17)
@@ -62,9 +62,6 @@ importers:
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0))
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
eslint:
specifier: ^9.18.0
version: 9.39.4
@@ -81,6 +78,34 @@ importers:
specifier: ^3.2.4
version: 3.2.4(@types/node@22.19.17)(tsx@4.21.0)
packages/db:
dependencies:
drizzle-orm:
specifier: ^0.38.4
version: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
postgres:
specifier: ^3.4.5
version: 3.4.9
devDependencies:
'@types/node':
specifier: ^22.10.7
version: 22.19.17
drizzle-kit:
specifier: ^0.30.4
version: 0.30.6
tsx:
specifier: ^4.19.0
version: 4.21.0
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/types:
devDependencies:
typescript:
specifier: ^5.7.3
version: 5.9.3
packages:
'@ampproject/remapping@2.3.0':
@@ -2907,12 +2932,10 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.2
'@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))':
'@better-auth/drizzle-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)':
dependencies:
'@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
'@better-auth/utils': 0.4.0
optionalDependencies:
drizzle-orm: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
'@better-auth/kysely-adapter@1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)':
dependencies:
@@ -3902,10 +3925,10 @@ snapshots:
balanced-match@4.0.4: {}
better-auth@1.6.9(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0)):
better-auth@1.6.9(vitest@3.2.4(@types/node@22.19.17)(tsx@4.21.0)):
dependencies:
'@better-auth/core': 1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(kysely@0.28.16)(postgres@3.4.9))
'@better-auth/drizzle-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
'@better-auth/kysely-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.16)
'@better-auth/memory-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
'@better-auth/mongo-adapter': 1.6.9(@better-auth/core@1.6.9(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.2))(jose@6.2.3)(kysely@0.28.16)(nanostores@1.3.0))(@better-auth/utils@0.4.0)
@@ -3922,8 +3945,6 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.2
optionalDependencies:
drizzle-kit: 0.30.6
drizzle-orm: 0.38.4(kysely@0.28.16)(postgres@3.4.9)
vitest: 3.2.4(@types/node@22.19.17)(tsx@4.21.0)
transitivePeerDependencies:
- '@cloudflare/workers-types'